mirror of
https://github.com/aiclientproxy/proxycast.git
synced 2026-09-24 23:10:56 +08:00
chore: 更新 antigravity-provider 版本到 v0.4.0
This commit is contained in:
Generated
+1
-1
@@ -3674,7 +3674,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "proxycast"
|
||||
version = "0.29.0"
|
||||
version = "0.30.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"arboard",
|
||||
|
||||
@@ -14,6 +14,7 @@ use crate::commands::flow_monitor_cmd::{
|
||||
SessionManagerState,
|
||||
};
|
||||
use crate::commands::machine_id_cmd::MachineIdState;
|
||||
use crate::commands::model_registry_cmd::ModelRegistryState;
|
||||
use crate::commands::orchestrator_cmd::OrchestratorState;
|
||||
use crate::commands::plugin_cmd::PluginManagerState;
|
||||
use crate::commands::plugin_install_cmd::PluginInstallerState;
|
||||
@@ -132,6 +133,7 @@ pub struct AppStates {
|
||||
pub oauth_plugin_manager: crate::commands::oauth_plugin_cmd::OAuthPluginManagerState,
|
||||
pub orchestrator: OrchestratorState,
|
||||
pub connect_state: ConnectStateWrapper,
|
||||
pub model_registry: ModelRegistryState,
|
||||
// 用于 setup hook 的共享实例
|
||||
pub shared_stats: Arc<parking_lot::RwLock<telemetry::StatsAggregator>>,
|
||||
pub shared_tokens: Arc<parking_lot::RwLock<telemetry::TokenTracker>>,
|
||||
@@ -207,6 +209,9 @@ pub fn init_states(config: &Config) -> Result<AppStates, String> {
|
||||
// 初始化 Connect 状态(延迟初始化,在 setup hook 中完成)
|
||||
let connect_state = ConnectStateWrapper(Arc::new(RwLock::new(None)));
|
||||
|
||||
// 初始化 Model Registry 状态(延迟初始化,在 setup hook 中完成)
|
||||
let model_registry_state: ModelRegistryState = Arc::new(RwLock::new(None));
|
||||
|
||||
// 初始化默认技能仓库
|
||||
{
|
||||
let conn = db.lock().expect("Failed to lock database");
|
||||
@@ -242,6 +247,7 @@ pub fn init_states(config: &Config) -> Result<AppStates, String> {
|
||||
oauth_plugin_manager: oauth_plugin_manager_state,
|
||||
orchestrator: orchestrator_state,
|
||||
connect_state,
|
||||
model_registry: model_registry_state,
|
||||
shared_stats,
|
||||
shared_tokens,
|
||||
shared_logger,
|
||||
|
||||
@@ -71,6 +71,7 @@ pub fn run() {
|
||||
oauth_plugin_manager: oauth_plugin_manager_state,
|
||||
orchestrator: orchestrator_state,
|
||||
connect_state: connect_state,
|
||||
model_registry: model_registry_state,
|
||||
shared_stats,
|
||||
shared_tokens,
|
||||
shared_logger,
|
||||
@@ -145,6 +146,7 @@ pub fn run() {
|
||||
.manage(oauth_plugin_manager_state)
|
||||
.manage(orchestrator_state)
|
||||
.manage(connect_state)
|
||||
.manage(model_registry_state)
|
||||
.on_window_event(move |window, event| {
|
||||
// 处理窗口关闭事件
|
||||
if let tauri::WindowEvent::CloseRequested { api, .. } = event {
|
||||
@@ -219,6 +221,33 @@ pub fn run() {
|
||||
});
|
||||
}
|
||||
|
||||
// 初始化 Model Registry 服务
|
||||
{
|
||||
let app_handle = app.handle().clone();
|
||||
let db_clone = db_clone.clone();
|
||||
tauri::async_runtime::spawn(async move {
|
||||
// 创建 ModelRegistryService
|
||||
let service = crate::services::model_registry_service::ModelRegistryService::new(db_clone);
|
||||
|
||||
// 初始化服务
|
||||
match service.initialize().await {
|
||||
Ok(()) => {
|
||||
tracing::info!("[启动] Model Registry 服务初始化成功");
|
||||
// 更新状态
|
||||
if let Some(state) = app_handle
|
||||
.try_state::<crate::commands::model_registry_cmd::ModelRegistryState>()
|
||||
{
|
||||
let mut guard = state.write().await;
|
||||
*guard = Some(service);
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
tracing::error!("[启动] Model Registry 服务初始化失败: {}", e);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// 注册 Deep Link 事件处理器(仅 macOS)
|
||||
// _Requirements: 1.4_
|
||||
#[cfg(target_os = "macos")]
|
||||
@@ -984,6 +1013,17 @@ pub fn run() {
|
||||
commands::connect_cmd::refresh_relay_registry,
|
||||
commands::connect_cmd::list_relay_providers,
|
||||
commands::connect_cmd::send_connect_callback,
|
||||
// Model Registry commands
|
||||
commands::model_registry_cmd::get_model_registry,
|
||||
commands::model_registry_cmd::refresh_model_registry,
|
||||
commands::model_registry_cmd::search_models,
|
||||
commands::model_registry_cmd::get_model_preferences,
|
||||
commands::model_registry_cmd::toggle_model_favorite,
|
||||
commands::model_registry_cmd::hide_model,
|
||||
commands::model_registry_cmd::record_model_usage,
|
||||
commands::model_registry_cmd::get_model_sync_state,
|
||||
commands::model_registry_cmd::get_models_for_provider,
|
||||
commands::model_registry_cmd::get_models_by_tier,
|
||||
])
|
||||
.run(tauri::generate_context!())
|
||||
.expect("error while running tauri application");
|
||||
|
||||
@@ -9,6 +9,7 @@ pub mod injection_cmd;
|
||||
pub mod kiro_local;
|
||||
pub mod machine_id_cmd;
|
||||
pub mod mcp_cmd;
|
||||
pub mod model_registry_cmd;
|
||||
pub mod models_cmd;
|
||||
pub mod native_agent_cmd;
|
||||
pub mod network_cmd;
|
||||
|
||||
@@ -0,0 +1,155 @@
|
||||
//! 模型注册表 Tauri 命令
|
||||
//!
|
||||
//! 提供模型注册表相关的前端 API
|
||||
|
||||
use crate::models::model_registry::{
|
||||
EnhancedModelMetadata, ModelSyncState, ModelTier, UserModelPreference,
|
||||
};
|
||||
use crate::services::model_registry_service::ModelRegistryService;
|
||||
use std::sync::Arc;
|
||||
use tauri::State;
|
||||
use tokio::sync::RwLock;
|
||||
|
||||
/// 模型注册服务状态
|
||||
pub type ModelRegistryState = Arc<RwLock<Option<ModelRegistryService>>>;
|
||||
|
||||
/// 获取所有模型
|
||||
#[tauri::command]
|
||||
pub async fn get_model_registry(
|
||||
state: State<'_, ModelRegistryState>,
|
||||
) -> Result<Vec<EnhancedModelMetadata>, String> {
|
||||
let guard = state.read().await;
|
||||
let service = guard
|
||||
.as_ref()
|
||||
.ok_or_else(|| "模型注册服务未初始化".to_string())?;
|
||||
|
||||
Ok(service.get_all_models().await)
|
||||
}
|
||||
|
||||
/// 刷新模型注册表(从 models.dev 获取最新数据)
|
||||
#[tauri::command]
|
||||
pub async fn refresh_model_registry(
|
||||
state: State<'_, ModelRegistryState>,
|
||||
) -> Result<(), String> {
|
||||
let guard = state.read().await;
|
||||
let service = guard
|
||||
.as_ref()
|
||||
.ok_or_else(|| "模型注册服务未初始化".to_string())?;
|
||||
|
||||
service.refresh_from_models_dev().await
|
||||
}
|
||||
|
||||
/// 搜索模型
|
||||
#[tauri::command]
|
||||
pub async fn search_models(
|
||||
state: State<'_, ModelRegistryState>,
|
||||
query: String,
|
||||
limit: Option<usize>,
|
||||
) -> Result<Vec<EnhancedModelMetadata>, String> {
|
||||
let guard = state.read().await;
|
||||
let service = guard
|
||||
.as_ref()
|
||||
.ok_or_else(|| "模型注册服务未初始化".to_string())?;
|
||||
|
||||
Ok(service.search_models(&query, limit.unwrap_or(50)).await)
|
||||
}
|
||||
|
||||
/// 获取用户模型偏好
|
||||
#[tauri::command]
|
||||
pub async fn get_model_preferences(
|
||||
state: State<'_, ModelRegistryState>,
|
||||
) -> Result<Vec<UserModelPreference>, String> {
|
||||
let guard = state.read().await;
|
||||
let service = guard
|
||||
.as_ref()
|
||||
.ok_or_else(|| "模型注册服务未初始化".to_string())?;
|
||||
|
||||
service.get_all_preferences().await
|
||||
}
|
||||
|
||||
/// 切换模型收藏状态
|
||||
#[tauri::command]
|
||||
pub async fn toggle_model_favorite(
|
||||
state: State<'_, ModelRegistryState>,
|
||||
model_id: String,
|
||||
) -> Result<bool, String> {
|
||||
let guard = state.read().await;
|
||||
let service = guard
|
||||
.as_ref()
|
||||
.ok_or_else(|| "模型注册服务未初始化".to_string())?;
|
||||
|
||||
service.toggle_favorite(&model_id).await
|
||||
}
|
||||
|
||||
/// 隐藏模型
|
||||
#[tauri::command]
|
||||
pub async fn hide_model(
|
||||
state: State<'_, ModelRegistryState>,
|
||||
model_id: String,
|
||||
) -> Result<(), String> {
|
||||
let guard = state.read().await;
|
||||
let service = guard
|
||||
.as_ref()
|
||||
.ok_or_else(|| "模型注册服务未初始化".to_string())?;
|
||||
|
||||
service.hide_model(&model_id).await
|
||||
}
|
||||
|
||||
/// 记录模型使用
|
||||
#[tauri::command]
|
||||
pub async fn record_model_usage(
|
||||
state: State<'_, ModelRegistryState>,
|
||||
model_id: String,
|
||||
) -> Result<(), String> {
|
||||
let guard = state.read().await;
|
||||
let service = guard
|
||||
.as_ref()
|
||||
.ok_or_else(|| "模型注册服务未初始化".to_string())?;
|
||||
|
||||
service.record_usage(&model_id).await
|
||||
}
|
||||
|
||||
/// 获取模型同步状态
|
||||
#[tauri::command]
|
||||
pub async fn get_model_sync_state(
|
||||
state: State<'_, ModelRegistryState>,
|
||||
) -> Result<ModelSyncState, String> {
|
||||
let guard = state.read().await;
|
||||
let service = guard
|
||||
.as_ref()
|
||||
.ok_or_else(|| "模型注册服务未初始化".to_string())?;
|
||||
|
||||
Ok(service.get_sync_state().await)
|
||||
}
|
||||
|
||||
/// 按 Provider 获取模型
|
||||
#[tauri::command]
|
||||
pub async fn get_models_for_provider(
|
||||
state: State<'_, ModelRegistryState>,
|
||||
provider_id: String,
|
||||
) -> Result<Vec<EnhancedModelMetadata>, String> {
|
||||
let guard = state.read().await;
|
||||
let service = guard
|
||||
.as_ref()
|
||||
.ok_or_else(|| "模型注册服务未初始化".to_string())?;
|
||||
|
||||
Ok(service.get_models_by_provider(&provider_id).await)
|
||||
}
|
||||
|
||||
/// 按服务等级获取模型
|
||||
#[tauri::command]
|
||||
pub async fn get_models_by_tier(
|
||||
state: State<'_, ModelRegistryState>,
|
||||
tier: String,
|
||||
) -> Result<Vec<EnhancedModelMetadata>, String> {
|
||||
let guard = state.read().await;
|
||||
let service = guard
|
||||
.as_ref()
|
||||
.ok_or_else(|| "模型注册服务未初始化".to_string())?;
|
||||
|
||||
let tier: ModelTier = tier
|
||||
.parse()
|
||||
.map_err(|_| format!("无效的服务等级: {}", tier))?;
|
||||
|
||||
Ok(service.get_models_by_tier(tier).await)
|
||||
}
|
||||
@@ -65,10 +65,13 @@ pub async fn init_orchestrator(
|
||||
.map(|c| {
|
||||
// 从 credential 中提取支持的模型列表
|
||||
let supported_models = extract_supported_models(&c.credential);
|
||||
// 保存原始的 provider_type 字符串(如 "antigravity"、"kiro" 等)
|
||||
let original_provider_type = c.provider_type.to_string();
|
||||
|
||||
CredentialInfo {
|
||||
id: c.uuid.clone(),
|
||||
provider_type: map_pool_provider_type(&c.provider_type.to_string()),
|
||||
provider_type: map_pool_provider_type(&original_provider_type),
|
||||
original_provider_type: Some(original_provider_type),
|
||||
supported_models,
|
||||
is_healthy: c.is_healthy,
|
||||
current_load: None,
|
||||
@@ -95,34 +98,47 @@ fn extract_supported_models(
|
||||
CredentialData::ClaudeKey { .. } | CredentialData::ClaudeOAuth { .. } => {
|
||||
vec![
|
||||
"claude-opus-4-5-20251101".to_string(),
|
||||
"claude-opus-4-20250514".to_string(),
|
||||
"claude-sonnet-4-5-20250929".to_string(),
|
||||
"claude-sonnet-4-20250514".to_string(),
|
||||
"claude-haiku-4-5-20251001".to_string(),
|
||||
"claude-3-7-sonnet-20250219".to_string(),
|
||||
"claude-3-5-haiku-20241022".to_string(),
|
||||
]
|
||||
}
|
||||
CredentialData::OpenAIKey { .. } => {
|
||||
vec![
|
||||
"gpt-5.2-codex".to_string(),
|
||||
"gpt-5.2".to_string(),
|
||||
"gpt-5.1-codex-max".to_string(),
|
||||
"gpt-5.1-codex".to_string(),
|
||||
"gpt-5.1-codex-mini".to_string(),
|
||||
"gpt-5.1".to_string(),
|
||||
"gpt-5-codex".to_string(),
|
||||
"gpt-5-codex-mini".to_string(),
|
||||
"gpt-5".to_string(),
|
||||
"gpt-4o".to_string(),
|
||||
"gpt-4o-mini".to_string(),
|
||||
"gpt-4-turbo".to_string(),
|
||||
"o1".to_string(),
|
||||
"o1-mini".to_string(),
|
||||
]
|
||||
}
|
||||
CredentialData::GeminiOAuth { .. } => {
|
||||
vec![
|
||||
"gemini-2.0-flash-exp".to_string(),
|
||||
"gemini-1.5-pro".to_string(),
|
||||
"gemini-1.5-flash".to_string(),
|
||||
"gemini-3-pro-preview".to_string(),
|
||||
"gemini-3-flash-preview".to_string(),
|
||||
"gemini-2.5-pro".to_string(),
|
||||
"gemini-2.5-flash".to_string(),
|
||||
"gemini-2.5-flash-lite".to_string(),
|
||||
]
|
||||
}
|
||||
CredentialData::GeminiApiKey {
|
||||
excluded_models, ..
|
||||
} => {
|
||||
let all_models = vec![
|
||||
"gemini-2.0-flash-exp".to_string(),
|
||||
"gemini-1.5-pro".to_string(),
|
||||
"gemini-1.5-flash".to_string(),
|
||||
"gemini-3-pro-preview".to_string(),
|
||||
"gemini-3-flash-preview".to_string(),
|
||||
"gemini-2.5-pro".to_string(),
|
||||
"gemini-2.5-flash".to_string(),
|
||||
"gemini-2.5-flash-lite".to_string(),
|
||||
];
|
||||
all_models
|
||||
.into_iter()
|
||||
@@ -131,8 +147,13 @@ fn extract_supported_models(
|
||||
}
|
||||
CredentialData::KiroOAuth { .. } => {
|
||||
vec![
|
||||
"claude-opus-4-5".to_string(),
|
||||
"claude-opus-4-5-20251101".to_string(),
|
||||
"claude-haiku-4-5".to_string(),
|
||||
"claude-sonnet-4-5".to_string(),
|
||||
"claude-sonnet-4-5-20250929".to_string(),
|
||||
"claude-sonnet-4-20250514".to_string(),
|
||||
"claude-3-7-sonnet-20250219".to_string(),
|
||||
]
|
||||
}
|
||||
CredentialData::CodexOAuth { .. } => {
|
||||
@@ -140,16 +161,23 @@ fn extract_supported_models(
|
||||
}
|
||||
CredentialData::QwenOAuth { .. } => {
|
||||
vec![
|
||||
"qwen-max".to_string(),
|
||||
"qwen-plus".to_string(),
|
||||
"qwen-turbo".to_string(),
|
||||
"qwen3-coder-plus".to_string(),
|
||||
"qwen3-coder-flash".to_string(),
|
||||
]
|
||||
}
|
||||
CredentialData::AntigravityOAuth { .. } => {
|
||||
vec![
|
||||
// Max 等级
|
||||
"gemini-3-pro-preview".to_string(),
|
||||
"gemini-3-pro-image-preview".to_string(),
|
||||
"gemini-claude-opus-4-5-thinking".to_string(),
|
||||
// Pro 等级
|
||||
"gemini-2.5-flash".to_string(),
|
||||
"gemini-2.5-computer-use-preview-10-2025".to_string(),
|
||||
"gemini-claude-sonnet-4-5".to_string(),
|
||||
"gemini-claude-sonnet-4-5-thinking".to_string(),
|
||||
"gemini-claude-opus-4-5-thinking".to_string(),
|
||||
// Mini 等级
|
||||
"gemini-3-flash-preview".to_string(),
|
||||
]
|
||||
}
|
||||
_ => vec![],
|
||||
@@ -165,6 +193,7 @@ fn map_pool_provider_type(pool_type: &str) -> ProviderType {
|
||||
"kiro" => ProviderType::Kiro,
|
||||
"codex" => ProviderType::OpenAI,
|
||||
"qwen" => ProviderType::Custom,
|
||||
"antigravity" => ProviderType::Antigravity,
|
||||
_ => ProviderType::Custom,
|
||||
}
|
||||
}
|
||||
@@ -233,10 +262,13 @@ pub struct CredentialInfoRequest {
|
||||
|
||||
impl From<CredentialInfoRequest> for CredentialInfo {
|
||||
fn from(req: CredentialInfoRequest) -> Self {
|
||||
// 保存原始的 provider_type 字符串
|
||||
let original_provider_type = req.provider_type.clone();
|
||||
CredentialInfo {
|
||||
id: req.id,
|
||||
provider_type: ProviderType::from_str(&req.provider_type)
|
||||
.unwrap_or(ProviderType::Custom),
|
||||
original_provider_type: Some(original_provider_type),
|
||||
supported_models: req.supported_models,
|
||||
is_healthy: req.is_healthy,
|
||||
current_load: req.current_load,
|
||||
|
||||
@@ -589,13 +589,10 @@ pub fn convert_openai_to_antigravity_with_context(
|
||||
}
|
||||
|
||||
// 转换工具定义
|
||||
// 注意:Antigravity API 统一使用 Gemini 格式(function_declarations)
|
||||
// Claude 模型在 Antigravity 内部会自动转换
|
||||
// 注意:Antigravity API 统一使用 functionDeclarations 格式
|
||||
// Claude 和 Gemini 模型都使用相同的结构,但字段名可能不同
|
||||
let tools: Option<serde_json::Value> = request.tools.as_ref().and_then(|tools| {
|
||||
let is_claude = is_claude_model(actual_model);
|
||||
|
||||
// Gemini 模型使用 function_declarations + parametersJsonSchema
|
||||
// Claude 模型使用 function_declarations + inputSchema(注意字段名不同)
|
||||
let mut function_declarations: Vec<serde_json::Value> = Vec::new();
|
||||
|
||||
for t in tools {
|
||||
@@ -605,7 +602,8 @@ 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");
|
||||
@@ -615,20 +613,22 @@ 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 模型使用 inputSchema 字段名
|
||||
// Claude 模型使用 parameters 字段(标准 Gemini 格式)
|
||||
function_declarations.push(serde_json::json!({
|
||||
"name": function.name,
|
||||
"description": function.description.clone().unwrap_or_default(),
|
||||
"inputSchema": params_schema
|
||||
"parameters": params_schema
|
||||
}));
|
||||
} else {
|
||||
// Gemini 模型使用 parametersJsonSchema 字段名
|
||||
// Gemini 模型使用 parametersJsonSchema 字段
|
||||
function_declarations.push(serde_json::json!({
|
||||
"name": function.name,
|
||||
"description": function.description.clone(),
|
||||
"description": function.description.clone().unwrap_or_default(),
|
||||
"parametersJsonSchema": params_schema
|
||||
}));
|
||||
}
|
||||
|
||||
@@ -539,6 +539,54 @@ impl CredentialProviderRegistry {
|
||||
|
||||
info!("Plugin installed to: {:?}", target_dir);
|
||||
|
||||
// 尝试下载 UI 资源包(如果存在)
|
||||
let ui_url = if version_tag == "latest" {
|
||||
format!(
|
||||
"https://github.com/{}/{}/releases/latest/download/{}-ui.zip",
|
||||
owner, repo, repo
|
||||
)
|
||||
} else {
|
||||
format!(
|
||||
"https://github.com/{}/{}/releases/download/{}/{}-ui.zip",
|
||||
owner, repo, version_tag, repo
|
||||
)
|
||||
};
|
||||
|
||||
info!("Trying to download UI assets from: {}", ui_url);
|
||||
|
||||
if let Ok(ui_response) = client.get(&ui_url).send().await {
|
||||
if ui_response.status().is_success() {
|
||||
if let Ok(ui_bytes) = ui_response.bytes().await {
|
||||
let ui_cursor = std::io::Cursor::new(ui_bytes);
|
||||
if let Ok(mut ui_archive) = zip::ZipArchive::new(ui_cursor) {
|
||||
for i in 0..ui_archive.len() {
|
||||
if let Ok(mut file) = ui_archive.by_index(i) {
|
||||
let outpath = target_dir.join(file.name());
|
||||
|
||||
if file.name().ends_with('/') {
|
||||
let _ = std::fs::create_dir_all(&outpath);
|
||||
} else {
|
||||
if let Some(p) = outpath.parent() {
|
||||
if !p.exists() {
|
||||
let _ = std::fs::create_dir_all(p);
|
||||
}
|
||||
}
|
||||
if let Ok(mut outfile) = std::fs::File::create(&outpath) {
|
||||
let _ = std::io::copy(&mut file, &mut outfile);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
info!("UI assets installed for plugin: {}", plugin_id);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
info!("No UI assets available for plugin: {} (HTTP {})", plugin_id, ui_response.status());
|
||||
}
|
||||
} else {
|
||||
info!("No UI assets available for plugin: {}", plugin_id);
|
||||
}
|
||||
|
||||
// 注册插件(创建 PluginInstance)
|
||||
self.register_from_dir(&target_dir, &plugin_id).await?;
|
||||
|
||||
|
||||
@@ -0,0 +1,761 @@
|
||||
//! 本地硬编码的国内模型数据
|
||||
//!
|
||||
//! 这些模型数据用于补充 models.dev API 未覆盖的国内模型
|
||||
|
||||
use crate::models::model_registry::{
|
||||
EnhancedModelMetadata, ModelCapabilities, ModelLimits, ModelPricing, ModelSource, ModelStatus,
|
||||
ModelTier,
|
||||
};
|
||||
|
||||
/// 获取所有本地硬编码的国内模型
|
||||
pub fn get_local_models() -> Vec<EnhancedModelMetadata> {
|
||||
let mut models = Vec::new();
|
||||
models.extend(get_dashscope_models());
|
||||
models.extend(get_zhipu_models());
|
||||
models.extend(get_baichuan_models());
|
||||
models.extend(get_moonshot_models());
|
||||
models.extend(get_deepseek_models());
|
||||
models.extend(get_doubao_models());
|
||||
models.extend(get_minimax_models());
|
||||
models.extend(get_yi_models());
|
||||
models.extend(get_stepfun_models());
|
||||
models
|
||||
}
|
||||
|
||||
/// 通义千问系列模型 (阿里云百炼)
|
||||
fn get_dashscope_models() -> Vec<EnhancedModelMetadata> {
|
||||
let now = chrono::Utc::now().timestamp();
|
||||
vec![
|
||||
EnhancedModelMetadata {
|
||||
id: "qwen3-coder-plus".to_string(),
|
||||
display_name: "通义千问 Coder Plus".to_string(),
|
||||
provider_id: "dashscope".to_string(),
|
||||
provider_name: "阿里云百炼".to_string(),
|
||||
family: Some("qwen-coder".to_string()),
|
||||
tier: ModelTier::Pro,
|
||||
capabilities: ModelCapabilities {
|
||||
vision: false, tools: true, streaming: true,
|
||||
json_mode: true, function_calling: true, reasoning: false,
|
||||
},
|
||||
pricing: Some(ModelPricing {
|
||||
input_per_million: Some(4.0), output_per_million: Some(16.0),
|
||||
cache_read_per_million: None, cache_write_per_million: None,
|
||||
currency: "CNY".to_string(),
|
||||
}),
|
||||
limits: ModelLimits {
|
||||
context_length: Some(131072), max_output_tokens: Some(8192),
|
||||
requests_per_minute: None, tokens_per_minute: None,
|
||||
},
|
||||
status: ModelStatus::Active,
|
||||
release_date: Some("2025-01-01".to_string()),
|
||||
is_latest: true,
|
||||
description: Some("阿里云通义千问代码模型".to_string()),
|
||||
source: ModelSource::Local,
|
||||
created_at: now, updated_at: now,
|
||||
},
|
||||
EnhancedModelMetadata {
|
||||
id: "qwen-max".to_string(),
|
||||
display_name: "通义千问 Max".to_string(),
|
||||
provider_id: "dashscope".to_string(),
|
||||
provider_name: "阿里云百炼".to_string(),
|
||||
family: Some("qwen".to_string()),
|
||||
tier: ModelTier::Max,
|
||||
capabilities: ModelCapabilities {
|
||||
vision: true, tools: true, streaming: true,
|
||||
json_mode: true, function_calling: true, reasoning: true,
|
||||
},
|
||||
pricing: Some(ModelPricing {
|
||||
input_per_million: Some(20.0), output_per_million: Some(60.0),
|
||||
cache_read_per_million: None, cache_write_per_million: None,
|
||||
currency: "CNY".to_string(),
|
||||
}),
|
||||
limits: ModelLimits {
|
||||
context_length: Some(32768), max_output_tokens: Some(8192),
|
||||
requests_per_minute: None, tokens_per_minute: None,
|
||||
},
|
||||
status: ModelStatus::Active,
|
||||
release_date: Some("2024-12-01".to_string()),
|
||||
is_latest: true,
|
||||
description: Some("通义千问旗舰模型".to_string()),
|
||||
source: ModelSource::Local,
|
||||
created_at: now, updated_at: now,
|
||||
},
|
||||
EnhancedModelMetadata {
|
||||
id: "qwen-plus".to_string(),
|
||||
display_name: "通义千问 Plus".to_string(),
|
||||
provider_id: "dashscope".to_string(),
|
||||
provider_name: "阿里云百炼".to_string(),
|
||||
family: Some("qwen".to_string()),
|
||||
tier: ModelTier::Pro,
|
||||
capabilities: ModelCapabilities {
|
||||
vision: true, tools: true, streaming: true,
|
||||
json_mode: true, function_calling: true, reasoning: false,
|
||||
},
|
||||
pricing: Some(ModelPricing {
|
||||
input_per_million: Some(4.0), output_per_million: Some(12.0),
|
||||
cache_read_per_million: None, cache_write_per_million: None,
|
||||
currency: "CNY".to_string(),
|
||||
}),
|
||||
limits: ModelLimits {
|
||||
context_length: Some(131072), max_output_tokens: Some(8192),
|
||||
requests_per_minute: None, tokens_per_minute: None,
|
||||
},
|
||||
status: ModelStatus::Active,
|
||||
release_date: Some("2024-12-01".to_string()),
|
||||
is_latest: true,
|
||||
description: Some("通义千问增强版".to_string()),
|
||||
source: ModelSource::Local,
|
||||
created_at: now, updated_at: now,
|
||||
},
|
||||
EnhancedModelMetadata {
|
||||
id: "qwen-turbo".to_string(),
|
||||
display_name: "通义千问 Turbo".to_string(),
|
||||
provider_id: "dashscope".to_string(),
|
||||
provider_name: "阿里云百炼".to_string(),
|
||||
family: Some("qwen".to_string()),
|
||||
tier: ModelTier::Mini,
|
||||
capabilities: ModelCapabilities {
|
||||
vision: false, tools: true, streaming: true,
|
||||
json_mode: true, function_calling: true, reasoning: false,
|
||||
},
|
||||
pricing: Some(ModelPricing {
|
||||
input_per_million: Some(0.3), output_per_million: Some(0.6),
|
||||
cache_read_per_million: None, cache_write_per_million: None,
|
||||
currency: "CNY".to_string(),
|
||||
}),
|
||||
limits: ModelLimits {
|
||||
context_length: Some(131072), max_output_tokens: Some(8192),
|
||||
requests_per_minute: None, tokens_per_minute: None,
|
||||
},
|
||||
status: ModelStatus::Active,
|
||||
release_date: Some("2024-12-01".to_string()),
|
||||
is_latest: true,
|
||||
description: Some("通义千问快速版".to_string()),
|
||||
source: ModelSource::Local,
|
||||
created_at: now, updated_at: now,
|
||||
},
|
||||
]
|
||||
}
|
||||
|
||||
/// 智谱 GLM 系列模型
|
||||
fn get_zhipu_models() -> Vec<EnhancedModelMetadata> {
|
||||
let now = chrono::Utc::now().timestamp();
|
||||
vec![
|
||||
EnhancedModelMetadata {
|
||||
id: "glm-4-plus".to_string(),
|
||||
display_name: "GLM-4 Plus".to_string(),
|
||||
provider_id: "zhipu".to_string(),
|
||||
provider_name: "智谱 AI".to_string(),
|
||||
family: Some("glm-4".to_string()),
|
||||
tier: ModelTier::Max,
|
||||
capabilities: ModelCapabilities {
|
||||
vision: true, tools: true, streaming: true,
|
||||
json_mode: true, function_calling: true, reasoning: true,
|
||||
},
|
||||
pricing: Some(ModelPricing {
|
||||
input_per_million: Some(50.0), output_per_million: Some(50.0),
|
||||
cache_read_per_million: None, cache_write_per_million: None,
|
||||
currency: "CNY".to_string(),
|
||||
}),
|
||||
limits: ModelLimits {
|
||||
context_length: Some(128000), max_output_tokens: Some(4096),
|
||||
requests_per_minute: None, tokens_per_minute: None,
|
||||
},
|
||||
status: ModelStatus::Active,
|
||||
release_date: Some("2024-08-01".to_string()),
|
||||
is_latest: true,
|
||||
description: Some("智谱旗舰模型".to_string()),
|
||||
source: ModelSource::Local,
|
||||
created_at: now, updated_at: now,
|
||||
},
|
||||
EnhancedModelMetadata {
|
||||
id: "glm-4-air".to_string(),
|
||||
display_name: "GLM-4 Air".to_string(),
|
||||
provider_id: "zhipu".to_string(),
|
||||
provider_name: "智谱 AI".to_string(),
|
||||
family: Some("glm-4".to_string()),
|
||||
tier: ModelTier::Pro,
|
||||
capabilities: ModelCapabilities {
|
||||
vision: false, tools: true, streaming: true,
|
||||
json_mode: true, function_calling: true, reasoning: false,
|
||||
},
|
||||
pricing: Some(ModelPricing {
|
||||
input_per_million: Some(1.0), output_per_million: Some(1.0),
|
||||
cache_read_per_million: None, cache_write_per_million: None,
|
||||
currency: "CNY".to_string(),
|
||||
}),
|
||||
limits: ModelLimits {
|
||||
context_length: Some(128000), max_output_tokens: Some(4096),
|
||||
requests_per_minute: None, tokens_per_minute: None,
|
||||
},
|
||||
status: ModelStatus::Active,
|
||||
release_date: Some("2024-06-01".to_string()),
|
||||
is_latest: true,
|
||||
description: Some("智谱高性价比模型".to_string()),
|
||||
source: ModelSource::Local,
|
||||
created_at: now, updated_at: now,
|
||||
},
|
||||
EnhancedModelMetadata {
|
||||
id: "glm-4-flash".to_string(),
|
||||
display_name: "GLM-4 Flash".to_string(),
|
||||
provider_id: "zhipu".to_string(),
|
||||
provider_name: "智谱 AI".to_string(),
|
||||
family: Some("glm-4".to_string()),
|
||||
tier: ModelTier::Mini,
|
||||
capabilities: ModelCapabilities {
|
||||
vision: false, tools: true, streaming: true,
|
||||
json_mode: true, function_calling: true, reasoning: false,
|
||||
},
|
||||
pricing: Some(ModelPricing {
|
||||
input_per_million: Some(0.1), output_per_million: Some(0.1),
|
||||
cache_read_per_million: None, cache_write_per_million: None,
|
||||
currency: "CNY".to_string(),
|
||||
}),
|
||||
limits: ModelLimits {
|
||||
context_length: Some(128000), max_output_tokens: Some(4096),
|
||||
requests_per_minute: None, tokens_per_minute: None,
|
||||
},
|
||||
status: ModelStatus::Active,
|
||||
release_date: Some("2024-06-01".to_string()),
|
||||
is_latest: true,
|
||||
description: Some("智谱快速模型".to_string()),
|
||||
source: ModelSource::Local,
|
||||
created_at: now, updated_at: now,
|
||||
},
|
||||
]
|
||||
}
|
||||
|
||||
/// 百川系列模型
|
||||
fn get_baichuan_models() -> Vec<EnhancedModelMetadata> {
|
||||
let now = chrono::Utc::now().timestamp();
|
||||
vec![
|
||||
EnhancedModelMetadata {
|
||||
id: "Baichuan4".to_string(),
|
||||
display_name: "百川 4".to_string(),
|
||||
provider_id: "baichuan".to_string(),
|
||||
provider_name: "百川智能".to_string(),
|
||||
family: Some("baichuan".to_string()),
|
||||
tier: ModelTier::Max,
|
||||
capabilities: ModelCapabilities {
|
||||
vision: false, tools: true, streaming: true,
|
||||
json_mode: true, function_calling: true, reasoning: false,
|
||||
},
|
||||
pricing: Some(ModelPricing {
|
||||
input_per_million: Some(100.0), output_per_million: Some(100.0),
|
||||
cache_read_per_million: None, cache_write_per_million: None,
|
||||
currency: "CNY".to_string(),
|
||||
}),
|
||||
limits: ModelLimits {
|
||||
context_length: Some(32768), max_output_tokens: Some(4096),
|
||||
requests_per_minute: None, tokens_per_minute: None,
|
||||
},
|
||||
status: ModelStatus::Active,
|
||||
release_date: Some("2024-10-01".to_string()),
|
||||
is_latest: true,
|
||||
description: Some("百川旗舰模型".to_string()),
|
||||
source: ModelSource::Local,
|
||||
created_at: now, updated_at: now,
|
||||
},
|
||||
EnhancedModelMetadata {
|
||||
id: "Baichuan3-Turbo".to_string(),
|
||||
display_name: "百川 3 Turbo".to_string(),
|
||||
provider_id: "baichuan".to_string(),
|
||||
provider_name: "百川智能".to_string(),
|
||||
family: Some("baichuan".to_string()),
|
||||
tier: ModelTier::Pro,
|
||||
capabilities: ModelCapabilities {
|
||||
vision: false, tools: true, streaming: true,
|
||||
json_mode: true, function_calling: true, reasoning: false,
|
||||
},
|
||||
pricing: Some(ModelPricing {
|
||||
input_per_million: Some(12.0), output_per_million: Some(12.0),
|
||||
cache_read_per_million: None, cache_write_per_million: None,
|
||||
currency: "CNY".to_string(),
|
||||
}),
|
||||
limits: ModelLimits {
|
||||
context_length: Some(32768), max_output_tokens: Some(4096),
|
||||
requests_per_minute: None, tokens_per_minute: None,
|
||||
},
|
||||
status: ModelStatus::Active,
|
||||
release_date: Some("2024-06-01".to_string()),
|
||||
is_latest: true,
|
||||
description: Some("百川高性价比模型".to_string()),
|
||||
source: ModelSource::Local,
|
||||
created_at: now, updated_at: now,
|
||||
},
|
||||
]
|
||||
}
|
||||
|
||||
/// 月之暗面 Moonshot 系列模型
|
||||
fn get_moonshot_models() -> Vec<EnhancedModelMetadata> {
|
||||
let now = chrono::Utc::now().timestamp();
|
||||
vec![
|
||||
EnhancedModelMetadata {
|
||||
id: "moonshot-v1-128k".to_string(),
|
||||
display_name: "Moonshot V1 128K".to_string(),
|
||||
provider_id: "moonshot".to_string(),
|
||||
provider_name: "月之暗面".to_string(),
|
||||
family: Some("moonshot".to_string()),
|
||||
tier: ModelTier::Pro,
|
||||
capabilities: ModelCapabilities {
|
||||
vision: false, tools: true, streaming: true,
|
||||
json_mode: true, function_calling: true, reasoning: false,
|
||||
},
|
||||
pricing: Some(ModelPricing {
|
||||
input_per_million: Some(60.0), output_per_million: Some(60.0),
|
||||
cache_read_per_million: None, cache_write_per_million: None,
|
||||
currency: "CNY".to_string(),
|
||||
}),
|
||||
limits: ModelLimits {
|
||||
context_length: Some(128000), max_output_tokens: Some(4096),
|
||||
requests_per_minute: None, tokens_per_minute: None,
|
||||
},
|
||||
status: ModelStatus::Active,
|
||||
release_date: Some("2024-03-01".to_string()),
|
||||
is_latest: true,
|
||||
description: Some("月之暗面长上下文模型".to_string()),
|
||||
source: ModelSource::Local,
|
||||
created_at: now, updated_at: now,
|
||||
},
|
||||
EnhancedModelMetadata {
|
||||
id: "moonshot-v1-32k".to_string(),
|
||||
display_name: "Moonshot V1 32K".to_string(),
|
||||
provider_id: "moonshot".to_string(),
|
||||
provider_name: "月之暗面".to_string(),
|
||||
family: Some("moonshot".to_string()),
|
||||
tier: ModelTier::Pro,
|
||||
capabilities: ModelCapabilities {
|
||||
vision: false, tools: true, streaming: true,
|
||||
json_mode: true, function_calling: true, reasoning: false,
|
||||
},
|
||||
pricing: Some(ModelPricing {
|
||||
input_per_million: Some(24.0), output_per_million: Some(24.0),
|
||||
cache_read_per_million: None, cache_write_per_million: None,
|
||||
currency: "CNY".to_string(),
|
||||
}),
|
||||
limits: ModelLimits {
|
||||
context_length: Some(32000), max_output_tokens: Some(4096),
|
||||
requests_per_minute: None, tokens_per_minute: None,
|
||||
},
|
||||
status: ModelStatus::Active,
|
||||
release_date: Some("2024-03-01".to_string()),
|
||||
is_latest: true,
|
||||
description: Some("月之暗面标准模型".to_string()),
|
||||
source: ModelSource::Local,
|
||||
created_at: now, updated_at: now,
|
||||
},
|
||||
EnhancedModelMetadata {
|
||||
id: "moonshot-v1-8k".to_string(),
|
||||
display_name: "Moonshot V1 8K".to_string(),
|
||||
provider_id: "moonshot".to_string(),
|
||||
provider_name: "月之暗面".to_string(),
|
||||
family: Some("moonshot".to_string()),
|
||||
tier: ModelTier::Mini,
|
||||
capabilities: ModelCapabilities {
|
||||
vision: false, tools: true, streaming: true,
|
||||
json_mode: true, function_calling: true, reasoning: false,
|
||||
},
|
||||
pricing: Some(ModelPricing {
|
||||
input_per_million: Some(12.0), output_per_million: Some(12.0),
|
||||
cache_read_per_million: None, cache_write_per_million: None,
|
||||
currency: "CNY".to_string(),
|
||||
}),
|
||||
limits: ModelLimits {
|
||||
context_length: Some(8000), max_output_tokens: Some(4096),
|
||||
requests_per_minute: None, tokens_per_minute: None,
|
||||
},
|
||||
status: ModelStatus::Active,
|
||||
release_date: Some("2024-03-01".to_string()),
|
||||
is_latest: true,
|
||||
description: Some("月之暗面快速模型".to_string()),
|
||||
source: ModelSource::Local,
|
||||
created_at: now, updated_at: now,
|
||||
},
|
||||
]
|
||||
}
|
||||
|
||||
/// DeepSeek 系列模型
|
||||
fn get_deepseek_models() -> Vec<EnhancedModelMetadata> {
|
||||
let now = chrono::Utc::now().timestamp();
|
||||
vec![
|
||||
EnhancedModelMetadata {
|
||||
id: "deepseek-chat".to_string(),
|
||||
display_name: "DeepSeek Chat".to_string(),
|
||||
provider_id: "deepseek".to_string(),
|
||||
provider_name: "DeepSeek".to_string(),
|
||||
family: Some("deepseek".to_string()),
|
||||
tier: ModelTier::Pro,
|
||||
capabilities: ModelCapabilities {
|
||||
vision: false, tools: true, streaming: true,
|
||||
json_mode: true, function_calling: true, reasoning: false,
|
||||
},
|
||||
pricing: Some(ModelPricing {
|
||||
input_per_million: Some(1.0), output_per_million: Some(2.0),
|
||||
cache_read_per_million: Some(0.1), cache_write_per_million: Some(1.0),
|
||||
currency: "CNY".to_string(),
|
||||
}),
|
||||
limits: ModelLimits {
|
||||
context_length: Some(64000), max_output_tokens: Some(8192),
|
||||
requests_per_minute: None, tokens_per_minute: None,
|
||||
},
|
||||
status: ModelStatus::Active,
|
||||
release_date: Some("2024-12-01".to_string()),
|
||||
is_latest: true,
|
||||
description: Some("DeepSeek V3 对话模型".to_string()),
|
||||
source: ModelSource::Local,
|
||||
created_at: now, updated_at: now,
|
||||
},
|
||||
EnhancedModelMetadata {
|
||||
id: "deepseek-reasoner".to_string(),
|
||||
display_name: "DeepSeek Reasoner".to_string(),
|
||||
provider_id: "deepseek".to_string(),
|
||||
provider_name: "DeepSeek".to_string(),
|
||||
family: Some("deepseek".to_string()),
|
||||
tier: ModelTier::Max,
|
||||
capabilities: ModelCapabilities {
|
||||
vision: false, tools: false, streaming: true,
|
||||
json_mode: false, function_calling: false, reasoning: true,
|
||||
},
|
||||
pricing: Some(ModelPricing {
|
||||
input_per_million: Some(4.0), output_per_million: Some(16.0),
|
||||
cache_read_per_million: Some(0.4), cache_write_per_million: Some(4.0),
|
||||
currency: "CNY".to_string(),
|
||||
}),
|
||||
limits: ModelLimits {
|
||||
context_length: Some(64000), max_output_tokens: Some(8192),
|
||||
requests_per_minute: None, tokens_per_minute: None,
|
||||
},
|
||||
status: ModelStatus::Active,
|
||||
release_date: Some("2025-01-01".to_string()),
|
||||
is_latest: true,
|
||||
description: Some("DeepSeek R1 推理模型".to_string()),
|
||||
source: ModelSource::Local,
|
||||
created_at: now, updated_at: now,
|
||||
},
|
||||
EnhancedModelMetadata {
|
||||
id: "deepseek-coder".to_string(),
|
||||
display_name: "DeepSeek Coder".to_string(),
|
||||
provider_id: "deepseek".to_string(),
|
||||
provider_name: "DeepSeek".to_string(),
|
||||
family: Some("deepseek-coder".to_string()),
|
||||
tier: ModelTier::Pro,
|
||||
capabilities: ModelCapabilities {
|
||||
vision: false, tools: true, streaming: true,
|
||||
json_mode: true, function_calling: true, reasoning: false,
|
||||
},
|
||||
pricing: Some(ModelPricing {
|
||||
input_per_million: Some(1.0), output_per_million: Some(2.0),
|
||||
cache_read_per_million: Some(0.1), cache_write_per_million: Some(1.0),
|
||||
currency: "CNY".to_string(),
|
||||
}),
|
||||
limits: ModelLimits {
|
||||
context_length: Some(64000), max_output_tokens: Some(8192),
|
||||
requests_per_minute: None, tokens_per_minute: None,
|
||||
},
|
||||
status: ModelStatus::Active,
|
||||
release_date: Some("2024-06-01".to_string()),
|
||||
is_latest: true,
|
||||
description: Some("DeepSeek 代码模型".to_string()),
|
||||
source: ModelSource::Local,
|
||||
created_at: now, updated_at: now,
|
||||
},
|
||||
]
|
||||
}
|
||||
|
||||
/// 字节豆包系列模型
|
||||
fn get_doubao_models() -> Vec<EnhancedModelMetadata> {
|
||||
let now = chrono::Utc::now().timestamp();
|
||||
vec![
|
||||
EnhancedModelMetadata {
|
||||
id: "doubao-pro-256k".to_string(),
|
||||
display_name: "豆包 Pro 256K".to_string(),
|
||||
provider_id: "doubao".to_string(),
|
||||
provider_name: "字节跳动".to_string(),
|
||||
family: Some("doubao".to_string()),
|
||||
tier: ModelTier::Max,
|
||||
capabilities: ModelCapabilities {
|
||||
vision: true, tools: true, streaming: true,
|
||||
json_mode: true, function_calling: true, reasoning: false,
|
||||
},
|
||||
pricing: Some(ModelPricing {
|
||||
input_per_million: Some(5.0), output_per_million: Some(9.0),
|
||||
cache_read_per_million: None, cache_write_per_million: None,
|
||||
currency: "CNY".to_string(),
|
||||
}),
|
||||
limits: ModelLimits {
|
||||
context_length: Some(256000), max_output_tokens: Some(4096),
|
||||
requests_per_minute: None, tokens_per_minute: None,
|
||||
},
|
||||
status: ModelStatus::Active,
|
||||
release_date: Some("2024-10-01".to_string()),
|
||||
is_latest: true,
|
||||
description: Some("豆包旗舰长上下文模型".to_string()),
|
||||
source: ModelSource::Local,
|
||||
created_at: now, updated_at: now,
|
||||
},
|
||||
EnhancedModelMetadata {
|
||||
id: "doubao-pro-32k".to_string(),
|
||||
display_name: "豆包 Pro 32K".to_string(),
|
||||
provider_id: "doubao".to_string(),
|
||||
provider_name: "字节跳动".to_string(),
|
||||
family: Some("doubao".to_string()),
|
||||
tier: ModelTier::Pro,
|
||||
capabilities: ModelCapabilities {
|
||||
vision: true, tools: true, streaming: true,
|
||||
json_mode: true, function_calling: true, reasoning: false,
|
||||
},
|
||||
pricing: Some(ModelPricing {
|
||||
input_per_million: Some(0.8), output_per_million: Some(2.0),
|
||||
cache_read_per_million: None, cache_write_per_million: None,
|
||||
currency: "CNY".to_string(),
|
||||
}),
|
||||
limits: ModelLimits {
|
||||
context_length: Some(32000), max_output_tokens: Some(4096),
|
||||
requests_per_minute: None, tokens_per_minute: None,
|
||||
},
|
||||
status: ModelStatus::Active,
|
||||
release_date: Some("2024-06-01".to_string()),
|
||||
is_latest: true,
|
||||
description: Some("豆包标准模型".to_string()),
|
||||
source: ModelSource::Local,
|
||||
created_at: now, updated_at: now,
|
||||
},
|
||||
EnhancedModelMetadata {
|
||||
id: "doubao-lite-32k".to_string(),
|
||||
display_name: "豆包 Lite 32K".to_string(),
|
||||
provider_id: "doubao".to_string(),
|
||||
provider_name: "字节跳动".to_string(),
|
||||
family: Some("doubao".to_string()),
|
||||
tier: ModelTier::Mini,
|
||||
capabilities: ModelCapabilities {
|
||||
vision: false, tools: true, streaming: true,
|
||||
json_mode: true, function_calling: true, reasoning: false,
|
||||
},
|
||||
pricing: Some(ModelPricing {
|
||||
input_per_million: Some(0.3), output_per_million: Some(0.6),
|
||||
cache_read_per_million: None, cache_write_per_million: None,
|
||||
currency: "CNY".to_string(),
|
||||
}),
|
||||
limits: ModelLimits {
|
||||
context_length: Some(32000), max_output_tokens: Some(4096),
|
||||
requests_per_minute: None, tokens_per_minute: None,
|
||||
},
|
||||
status: ModelStatus::Active,
|
||||
release_date: Some("2024-06-01".to_string()),
|
||||
is_latest: true,
|
||||
description: Some("豆包轻量模型".to_string()),
|
||||
source: ModelSource::Local,
|
||||
created_at: now, updated_at: now,
|
||||
},
|
||||
]
|
||||
}
|
||||
|
||||
/// MiniMax 系列模型
|
||||
fn get_minimax_models() -> Vec<EnhancedModelMetadata> {
|
||||
let now = chrono::Utc::now().timestamp();
|
||||
vec![
|
||||
EnhancedModelMetadata {
|
||||
id: "abab6.5s-chat".to_string(),
|
||||
display_name: "MiniMax abab6.5s".to_string(),
|
||||
provider_id: "minimax".to_string(),
|
||||
provider_name: "MiniMax".to_string(),
|
||||
family: Some("abab".to_string()),
|
||||
tier: ModelTier::Pro,
|
||||
capabilities: ModelCapabilities {
|
||||
vision: false, tools: true, streaming: true,
|
||||
json_mode: true, function_calling: true, reasoning: false,
|
||||
},
|
||||
pricing: Some(ModelPricing {
|
||||
input_per_million: Some(1.0), output_per_million: Some(1.0),
|
||||
cache_read_per_million: None, cache_write_per_million: None,
|
||||
currency: "CNY".to_string(),
|
||||
}),
|
||||
limits: ModelLimits {
|
||||
context_length: Some(245760), max_output_tokens: Some(8192),
|
||||
requests_per_minute: None, tokens_per_minute: None,
|
||||
},
|
||||
status: ModelStatus::Active,
|
||||
release_date: Some("2024-06-01".to_string()),
|
||||
is_latest: true,
|
||||
description: Some("MiniMax 长上下文模型".to_string()),
|
||||
source: ModelSource::Local,
|
||||
created_at: now, updated_at: now,
|
||||
},
|
||||
]
|
||||
}
|
||||
|
||||
/// 零一万物 Yi 系列模型
|
||||
fn get_yi_models() -> Vec<EnhancedModelMetadata> {
|
||||
let now = chrono::Utc::now().timestamp();
|
||||
vec![
|
||||
EnhancedModelMetadata {
|
||||
id: "yi-large".to_string(),
|
||||
display_name: "Yi Large".to_string(),
|
||||
provider_id: "yi".to_string(),
|
||||
provider_name: "零一万物".to_string(),
|
||||
family: Some("yi".to_string()),
|
||||
tier: ModelTier::Max,
|
||||
capabilities: ModelCapabilities {
|
||||
vision: false, tools: true, streaming: true,
|
||||
json_mode: true, function_calling: true, reasoning: false,
|
||||
},
|
||||
pricing: Some(ModelPricing {
|
||||
input_per_million: Some(20.0), output_per_million: Some(20.0),
|
||||
cache_read_per_million: None, cache_write_per_million: None,
|
||||
currency: "CNY".to_string(),
|
||||
}),
|
||||
limits: ModelLimits {
|
||||
context_length: Some(32768), max_output_tokens: Some(4096),
|
||||
requests_per_minute: None, tokens_per_minute: None,
|
||||
},
|
||||
status: ModelStatus::Active,
|
||||
release_date: Some("2024-05-01".to_string()),
|
||||
is_latest: true,
|
||||
description: Some("零一万物旗舰模型".to_string()),
|
||||
source: ModelSource::Local,
|
||||
created_at: now, updated_at: now,
|
||||
},
|
||||
EnhancedModelMetadata {
|
||||
id: "yi-medium".to_string(),
|
||||
display_name: "Yi Medium".to_string(),
|
||||
provider_id: "yi".to_string(),
|
||||
provider_name: "零一万物".to_string(),
|
||||
family: Some("yi".to_string()),
|
||||
tier: ModelTier::Pro,
|
||||
capabilities: ModelCapabilities {
|
||||
vision: false, tools: true, streaming: true,
|
||||
json_mode: true, function_calling: true, reasoning: false,
|
||||
},
|
||||
pricing: Some(ModelPricing {
|
||||
input_per_million: Some(2.5), output_per_million: Some(2.5),
|
||||
cache_read_per_million: None, cache_write_per_million: None,
|
||||
currency: "CNY".to_string(),
|
||||
}),
|
||||
limits: ModelLimits {
|
||||
context_length: Some(16384), max_output_tokens: Some(4096),
|
||||
requests_per_minute: None, tokens_per_minute: None,
|
||||
},
|
||||
status: ModelStatus::Active,
|
||||
release_date: Some("2024-05-01".to_string()),
|
||||
is_latest: true,
|
||||
description: Some("零一万物标准模型".to_string()),
|
||||
source: ModelSource::Local,
|
||||
created_at: now, updated_at: now,
|
||||
},
|
||||
EnhancedModelMetadata {
|
||||
id: "yi-spark".to_string(),
|
||||
display_name: "Yi Spark".to_string(),
|
||||
provider_id: "yi".to_string(),
|
||||
provider_name: "零一万物".to_string(),
|
||||
family: Some("yi".to_string()),
|
||||
tier: ModelTier::Mini,
|
||||
capabilities: ModelCapabilities {
|
||||
vision: false, tools: true, streaming: true,
|
||||
json_mode: true, function_calling: true, reasoning: false,
|
||||
},
|
||||
pricing: Some(ModelPricing {
|
||||
input_per_million: Some(1.0), output_per_million: Some(1.0),
|
||||
cache_read_per_million: None, cache_write_per_million: None,
|
||||
currency: "CNY".to_string(),
|
||||
}),
|
||||
limits: ModelLimits {
|
||||
context_length: Some(16384), max_output_tokens: Some(4096),
|
||||
requests_per_minute: None, tokens_per_minute: None,
|
||||
},
|
||||
status: ModelStatus::Active,
|
||||
release_date: Some("2024-05-01".to_string()),
|
||||
is_latest: true,
|
||||
description: Some("零一万物快速模型".to_string()),
|
||||
source: ModelSource::Local,
|
||||
created_at: now, updated_at: now,
|
||||
},
|
||||
]
|
||||
}
|
||||
|
||||
/// 阶跃星辰 Step 系列模型
|
||||
fn get_stepfun_models() -> Vec<EnhancedModelMetadata> {
|
||||
let now = chrono::Utc::now().timestamp();
|
||||
vec![
|
||||
EnhancedModelMetadata {
|
||||
id: "step-2-16k".to_string(),
|
||||
display_name: "Step 2 16K".to_string(),
|
||||
provider_id: "stepfun".to_string(),
|
||||
provider_name: "阶跃星辰".to_string(),
|
||||
family: Some("step".to_string()),
|
||||
tier: ModelTier::Max,
|
||||
capabilities: ModelCapabilities {
|
||||
vision: true, tools: true, streaming: true,
|
||||
json_mode: true, function_calling: true, reasoning: false,
|
||||
},
|
||||
pricing: Some(ModelPricing {
|
||||
input_per_million: Some(38.0), output_per_million: Some(120.0),
|
||||
cache_read_per_million: None, cache_write_per_million: None,
|
||||
currency: "CNY".to_string(),
|
||||
}),
|
||||
limits: ModelLimits {
|
||||
context_length: Some(16384), max_output_tokens: Some(4096),
|
||||
requests_per_minute: None, tokens_per_minute: None,
|
||||
},
|
||||
status: ModelStatus::Active,
|
||||
release_date: Some("2024-09-01".to_string()),
|
||||
is_latest: true,
|
||||
description: Some("阶跃星辰旗舰模型".to_string()),
|
||||
source: ModelSource::Local,
|
||||
created_at: now, updated_at: now,
|
||||
},
|
||||
EnhancedModelMetadata {
|
||||
id: "step-1-128k".to_string(),
|
||||
display_name: "Step 1 128K".to_string(),
|
||||
provider_id: "stepfun".to_string(),
|
||||
provider_name: "阶跃星辰".to_string(),
|
||||
family: Some("step".to_string()),
|
||||
tier: ModelTier::Pro,
|
||||
capabilities: ModelCapabilities {
|
||||
vision: false, tools: true, streaming: true,
|
||||
json_mode: true, function_calling: true, reasoning: false,
|
||||
},
|
||||
pricing: Some(ModelPricing {
|
||||
input_per_million: Some(40.0), output_per_million: Some(100.0),
|
||||
cache_read_per_million: None, cache_write_per_million: None,
|
||||
currency: "CNY".to_string(),
|
||||
}),
|
||||
limits: ModelLimits {
|
||||
context_length: Some(128000), max_output_tokens: Some(4096),
|
||||
requests_per_minute: None, tokens_per_minute: None,
|
||||
},
|
||||
status: ModelStatus::Active,
|
||||
release_date: Some("2024-06-01".to_string()),
|
||||
is_latest: true,
|
||||
description: Some("阶跃星辰长上下文模型".to_string()),
|
||||
source: ModelSource::Local,
|
||||
created_at: now, updated_at: now,
|
||||
},
|
||||
EnhancedModelMetadata {
|
||||
id: "step-1-flash".to_string(),
|
||||
display_name: "Step 1 Flash".to_string(),
|
||||
provider_id: "stepfun".to_string(),
|
||||
provider_name: "阶跃星辰".to_string(),
|
||||
family: Some("step".to_string()),
|
||||
tier: ModelTier::Mini,
|
||||
capabilities: ModelCapabilities {
|
||||
vision: false, tools: true, streaming: true,
|
||||
json_mode: true, function_calling: true, reasoning: false,
|
||||
},
|
||||
pricing: Some(ModelPricing {
|
||||
input_per_million: Some(1.0), output_per_million: Some(4.0),
|
||||
cache_read_per_million: None, cache_write_per_million: None,
|
||||
currency: "CNY".to_string(),
|
||||
}),
|
||||
limits: ModelLimits {
|
||||
context_length: Some(8192), max_output_tokens: Some(4096),
|
||||
requests_per_minute: None, tokens_per_minute: None,
|
||||
},
|
||||
status: ModelStatus::Active,
|
||||
release_date: Some("2024-06-01".to_string()),
|
||||
is_latest: true,
|
||||
description: Some("阶跃星辰快速模型".to_string()),
|
||||
source: ModelSource::Local,
|
||||
created_at: now, updated_at: now,
|
||||
},
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
//! 静态数据模块
|
||||
//!
|
||||
//! 包含本地硬编码的模型数据等
|
||||
|
||||
pub mod local_models;
|
||||
|
||||
pub use local_models::get_local_models;
|
||||
@@ -454,6 +454,89 @@ pub fn create_tables(conn: &Connection) -> Result<(), rusqlite::Error> {
|
||||
// ProxyCast Connect 相关表
|
||||
// ============================================================================
|
||||
|
||||
// ============================================================================
|
||||
// Model Registry 相关表 (借鉴 opencode 的模型管理方式)
|
||||
// ============================================================================
|
||||
|
||||
// 增强的模型注册表
|
||||
// 存储从 models.dev API 获取的模型数据 + 本地补充的国内模型数据
|
||||
conn.execute(
|
||||
"CREATE TABLE IF NOT EXISTS model_registry (
|
||||
id TEXT PRIMARY KEY,
|
||||
display_name TEXT NOT NULL,
|
||||
provider_id TEXT NOT NULL,
|
||||
provider_name TEXT NOT NULL,
|
||||
family TEXT,
|
||||
tier TEXT NOT NULL DEFAULT 'pro',
|
||||
capabilities TEXT NOT NULL DEFAULT '{}',
|
||||
pricing TEXT,
|
||||
limits TEXT NOT NULL DEFAULT '{}',
|
||||
status TEXT NOT NULL DEFAULT 'active',
|
||||
release_date TEXT,
|
||||
is_latest INTEGER DEFAULT 0,
|
||||
description TEXT,
|
||||
source TEXT NOT NULL DEFAULT 'local',
|
||||
created_at INTEGER NOT NULL,
|
||||
updated_at INTEGER NOT NULL
|
||||
)",
|
||||
[],
|
||||
)?;
|
||||
|
||||
// 创建 model_registry 索引
|
||||
conn.execute(
|
||||
"CREATE INDEX IF NOT EXISTS idx_model_registry_provider ON model_registry(provider_id)",
|
||||
[],
|
||||
)?;
|
||||
conn.execute(
|
||||
"CREATE INDEX IF NOT EXISTS idx_model_registry_tier ON model_registry(tier)",
|
||||
[],
|
||||
)?;
|
||||
conn.execute(
|
||||
"CREATE INDEX IF NOT EXISTS idx_model_registry_family ON model_registry(family)",
|
||||
[],
|
||||
)?;
|
||||
conn.execute(
|
||||
"CREATE INDEX IF NOT EXISTS idx_model_registry_source ON model_registry(source)",
|
||||
[],
|
||||
)?;
|
||||
conn.execute(
|
||||
"CREATE INDEX IF NOT EXISTS idx_model_registry_status ON model_registry(status)",
|
||||
[],
|
||||
)?;
|
||||
|
||||
// 用户模型偏好表
|
||||
// 存储用户的收藏、隐藏、使用统计等偏好
|
||||
conn.execute(
|
||||
"CREATE TABLE IF NOT EXISTS user_model_preferences (
|
||||
model_id TEXT PRIMARY KEY,
|
||||
is_favorite INTEGER DEFAULT 0,
|
||||
is_hidden INTEGER DEFAULT 0,
|
||||
custom_alias TEXT,
|
||||
usage_count INTEGER DEFAULT 0,
|
||||
last_used_at INTEGER,
|
||||
created_at INTEGER NOT NULL,
|
||||
updated_at INTEGER NOT NULL
|
||||
)",
|
||||
[],
|
||||
)?;
|
||||
|
||||
// 创建 user_model_preferences 索引
|
||||
conn.execute(
|
||||
"CREATE INDEX IF NOT EXISTS idx_user_model_preferences_favorite ON user_model_preferences(is_favorite)",
|
||||
[],
|
||||
)?;
|
||||
|
||||
// 模型同步状态表
|
||||
// 记录 models.dev API 同步状态
|
||||
conn.execute(
|
||||
"CREATE TABLE IF NOT EXISTS model_sync_state (
|
||||
key TEXT PRIMARY KEY,
|
||||
value TEXT NOT NULL,
|
||||
updated_at INTEGER NOT NULL
|
||||
)",
|
||||
[],
|
||||
)?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
|
||||
@@ -31,6 +31,7 @@ pub mod websocket;
|
||||
mod commands;
|
||||
mod config;
|
||||
mod converter;
|
||||
mod data;
|
||||
mod logger;
|
||||
mod models;
|
||||
mod providers;
|
||||
|
||||
@@ -4,6 +4,7 @@ pub mod codewhisperer;
|
||||
pub mod kiro_fingerprint;
|
||||
pub mod machine_id;
|
||||
pub mod mcp_model;
|
||||
pub mod model_registry;
|
||||
pub mod openai;
|
||||
pub mod prompt_model;
|
||||
pub mod provider_model;
|
||||
|
||||
@@ -0,0 +1,608 @@
|
||||
//! 模型注册表数据结构
|
||||
//!
|
||||
//! 借鉴 opencode 的模型管理方式,定义增强的模型元数据结构
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
/// 模型能力
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
|
||||
pub struct ModelCapabilities {
|
||||
/// 是否支持视觉输入
|
||||
pub vision: bool,
|
||||
/// 是否支持工具调用
|
||||
pub tools: bool,
|
||||
/// 是否支持流式输出
|
||||
pub streaming: bool,
|
||||
/// 是否支持 JSON 模式
|
||||
pub json_mode: bool,
|
||||
/// 是否支持函数调用
|
||||
pub function_calling: bool,
|
||||
/// 是否支持推理/思考
|
||||
pub reasoning: bool,
|
||||
}
|
||||
|
||||
/// 模型定价
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct ModelPricing {
|
||||
/// 输入价格(每百万 token)
|
||||
pub input_per_million: Option<f64>,
|
||||
/// 输出价格(每百万 token)
|
||||
pub output_per_million: Option<f64>,
|
||||
/// 缓存读取价格(每百万 token)
|
||||
pub cache_read_per_million: Option<f64>,
|
||||
/// 缓存写入价格(每百万 token)
|
||||
pub cache_write_per_million: Option<f64>,
|
||||
/// 货币单位 ("USD" | "CNY")
|
||||
pub currency: String,
|
||||
}
|
||||
|
||||
impl Default for ModelPricing {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
input_per_million: None,
|
||||
output_per_million: None,
|
||||
cache_read_per_million: None,
|
||||
cache_write_per_million: None,
|
||||
currency: "USD".to_string(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 模型限制
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
|
||||
pub struct ModelLimits {
|
||||
/// 上下文长度
|
||||
pub context_length: Option<u32>,
|
||||
/// 最大输出 token 数
|
||||
pub max_output_tokens: Option<u32>,
|
||||
/// 每分钟请求数限制
|
||||
pub requests_per_minute: Option<u32>,
|
||||
/// 每分钟 token 数限制
|
||||
pub tokens_per_minute: Option<u32>,
|
||||
}
|
||||
|
||||
/// 模型状态
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||
#[serde(rename_all = "lowercase")]
|
||||
pub enum ModelStatus {
|
||||
/// 活跃可用
|
||||
Active,
|
||||
/// 预览版
|
||||
Preview,
|
||||
/// Alpha 测试
|
||||
Alpha,
|
||||
/// Beta 测试
|
||||
Beta,
|
||||
/// 已弃用
|
||||
Deprecated,
|
||||
/// 旧版本
|
||||
Legacy,
|
||||
}
|
||||
|
||||
impl Default for ModelStatus {
|
||||
fn default() -> Self {
|
||||
Self::Active
|
||||
}
|
||||
}
|
||||
|
||||
impl std::fmt::Display for ModelStatus {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
match self {
|
||||
Self::Active => write!(f, "active"),
|
||||
Self::Preview => write!(f, "preview"),
|
||||
Self::Alpha => write!(f, "alpha"),
|
||||
Self::Beta => write!(f, "beta"),
|
||||
Self::Deprecated => write!(f, "deprecated"),
|
||||
Self::Legacy => write!(f, "legacy"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl std::str::FromStr for ModelStatus {
|
||||
type Err = String;
|
||||
|
||||
fn from_str(s: &str) -> Result<Self, Self::Err> {
|
||||
match s.to_lowercase().as_str() {
|
||||
"active" => Ok(Self::Active),
|
||||
"preview" => Ok(Self::Preview),
|
||||
"alpha" => Ok(Self::Alpha),
|
||||
"beta" => Ok(Self::Beta),
|
||||
"deprecated" => Ok(Self::Deprecated),
|
||||
"legacy" => Ok(Self::Legacy),
|
||||
_ => Err(format!("Unknown model status: {}", s)),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 模型服务等级
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||
#[serde(rename_all = "lowercase")]
|
||||
pub enum ModelTier {
|
||||
/// 快速响应,适合简单任务
|
||||
Mini,
|
||||
/// 均衡性能,适合大多数任务
|
||||
Pro,
|
||||
/// 最强能力,适合复杂任务
|
||||
Max,
|
||||
}
|
||||
|
||||
impl Default for ModelTier {
|
||||
fn default() -> Self {
|
||||
Self::Pro
|
||||
}
|
||||
}
|
||||
|
||||
impl std::fmt::Display for ModelTier {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
match self {
|
||||
Self::Mini => write!(f, "mini"),
|
||||
Self::Pro => write!(f, "pro"),
|
||||
Self::Max => write!(f, "max"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl std::str::FromStr for ModelTier {
|
||||
type Err = String;
|
||||
|
||||
fn from_str(s: &str) -> Result<Self, Self::Err> {
|
||||
match s.to_lowercase().as_str() {
|
||||
"mini" => Ok(Self::Mini),
|
||||
"pro" => Ok(Self::Pro),
|
||||
"max" => Ok(Self::Max),
|
||||
_ => Err(format!("Unknown model tier: {}", s)),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 模型数据来源
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||
#[serde(rename_all = "lowercase")]
|
||||
pub enum ModelSource {
|
||||
/// 从 models.dev API 获取
|
||||
ModelsDev,
|
||||
/// 本地硬编码(国内模型等)
|
||||
Local,
|
||||
/// 用户自定义
|
||||
Custom,
|
||||
}
|
||||
|
||||
impl Default for ModelSource {
|
||||
fn default() -> Self {
|
||||
Self::Local
|
||||
}
|
||||
}
|
||||
|
||||
impl std::fmt::Display for ModelSource {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
match self {
|
||||
Self::ModelsDev => write!(f, "models.dev"),
|
||||
Self::Local => write!(f, "local"),
|
||||
Self::Custom => write!(f, "custom"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl std::str::FromStr for ModelSource {
|
||||
type Err = String;
|
||||
|
||||
fn from_str(s: &str) -> Result<Self, Self::Err> {
|
||||
match s.to_lowercase().as_str() {
|
||||
"models.dev" | "modelsdev" => Ok(Self::ModelsDev),
|
||||
"local" => Ok(Self::Local),
|
||||
"custom" => Ok(Self::Custom),
|
||||
_ => Err(format!("Unknown model source: {}", s)),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 增强的模型元数据
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct EnhancedModelMetadata {
|
||||
/// 模型 ID (如 "claude-sonnet-4-5-20250514")
|
||||
pub id: String,
|
||||
/// 显示名称 (如 "Claude Sonnet 4.5")
|
||||
pub display_name: String,
|
||||
/// Provider ID (如 "anthropic", "openai", "dashscope")
|
||||
pub provider_id: String,
|
||||
/// Provider 显示名称
|
||||
pub provider_name: String,
|
||||
/// 模型家族 (如 "sonnet", "gpt-4", "qwen")
|
||||
pub family: Option<String>,
|
||||
/// 服务等级
|
||||
pub tier: ModelTier,
|
||||
/// 模型能力
|
||||
pub capabilities: ModelCapabilities,
|
||||
/// 定价信息
|
||||
pub pricing: Option<ModelPricing>,
|
||||
/// 限制信息
|
||||
pub limits: ModelLimits,
|
||||
/// 模型状态
|
||||
pub status: ModelStatus,
|
||||
/// 发布日期
|
||||
pub release_date: Option<String>,
|
||||
/// 是否为最新版本
|
||||
pub is_latest: bool,
|
||||
/// 描述
|
||||
pub description: Option<String>,
|
||||
/// 数据来源
|
||||
pub source: ModelSource,
|
||||
/// 创建时间 (Unix 时间戳)
|
||||
pub created_at: i64,
|
||||
/// 最后更新时间 (Unix 时间戳)
|
||||
pub updated_at: i64,
|
||||
}
|
||||
|
||||
impl EnhancedModelMetadata {
|
||||
/// 创建新的模型元数据
|
||||
pub fn new(
|
||||
id: String,
|
||||
display_name: String,
|
||||
provider_id: String,
|
||||
provider_name: String,
|
||||
) -> Self {
|
||||
let now = chrono::Utc::now().timestamp();
|
||||
Self {
|
||||
id,
|
||||
display_name,
|
||||
provider_id,
|
||||
provider_name,
|
||||
family: None,
|
||||
tier: ModelTier::Pro,
|
||||
capabilities: ModelCapabilities::default(),
|
||||
pricing: None,
|
||||
limits: ModelLimits::default(),
|
||||
status: ModelStatus::Active,
|
||||
release_date: None,
|
||||
is_latest: false,
|
||||
description: None,
|
||||
source: ModelSource::Local,
|
||||
created_at: now,
|
||||
updated_at: now,
|
||||
}
|
||||
}
|
||||
|
||||
/// 设置模型家族
|
||||
pub fn with_family(mut self, family: impl Into<String>) -> Self {
|
||||
self.family = Some(family.into());
|
||||
self
|
||||
}
|
||||
|
||||
/// 设置服务等级
|
||||
pub fn with_tier(mut self, tier: ModelTier) -> Self {
|
||||
self.tier = tier;
|
||||
self
|
||||
}
|
||||
|
||||
/// 设置模型能力
|
||||
pub fn with_capabilities(mut self, capabilities: ModelCapabilities) -> Self {
|
||||
self.capabilities = capabilities;
|
||||
self
|
||||
}
|
||||
|
||||
/// 设置定价信息
|
||||
pub fn with_pricing(mut self, pricing: ModelPricing) -> Self {
|
||||
self.pricing = Some(pricing);
|
||||
self
|
||||
}
|
||||
|
||||
/// 设置限制信息
|
||||
pub fn with_limits(mut self, limits: ModelLimits) -> Self {
|
||||
self.limits = limits;
|
||||
self
|
||||
}
|
||||
|
||||
/// 设置模型状态
|
||||
pub fn with_status(mut self, status: ModelStatus) -> Self {
|
||||
self.status = status;
|
||||
self
|
||||
}
|
||||
|
||||
/// 设置发布日期
|
||||
pub fn with_release_date(mut self, date: impl Into<String>) -> Self {
|
||||
self.release_date = Some(date.into());
|
||||
self
|
||||
}
|
||||
|
||||
/// 设置是否为最新版本
|
||||
pub fn with_is_latest(mut self, is_latest: bool) -> Self {
|
||||
self.is_latest = is_latest;
|
||||
self
|
||||
}
|
||||
|
||||
/// 设置描述
|
||||
pub fn with_description(mut self, description: impl Into<String>) -> Self {
|
||||
self.description = Some(description.into());
|
||||
self
|
||||
}
|
||||
|
||||
/// 设置数据来源
|
||||
pub fn with_source(mut self, source: ModelSource) -> Self {
|
||||
self.source = source;
|
||||
self
|
||||
}
|
||||
}
|
||||
|
||||
/// 用户模型偏好
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct UserModelPreference {
|
||||
/// 模型 ID
|
||||
pub model_id: String,
|
||||
/// 是否收藏
|
||||
pub is_favorite: bool,
|
||||
/// 是否隐藏
|
||||
pub is_hidden: bool,
|
||||
/// 自定义别名
|
||||
pub custom_alias: Option<String>,
|
||||
/// 使用次数
|
||||
pub usage_count: u32,
|
||||
/// 最后使用时间 (Unix 时间戳)
|
||||
pub last_used_at: Option<i64>,
|
||||
/// 创建时间 (Unix 时间戳)
|
||||
pub created_at: i64,
|
||||
/// 更新时间 (Unix 时间戳)
|
||||
pub updated_at: i64,
|
||||
}
|
||||
|
||||
impl UserModelPreference {
|
||||
/// 创建新的用户偏好
|
||||
pub fn new(model_id: String) -> Self {
|
||||
let now = chrono::Utc::now().timestamp();
|
||||
Self {
|
||||
model_id,
|
||||
is_favorite: false,
|
||||
is_hidden: false,
|
||||
custom_alias: None,
|
||||
usage_count: 0,
|
||||
last_used_at: None,
|
||||
created_at: now,
|
||||
updated_at: now,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 模型同步状态
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct ModelSyncState {
|
||||
/// 最后同步时间 (Unix 时间戳)
|
||||
pub last_sync_at: Option<i64>,
|
||||
/// 同步的模型数量
|
||||
pub model_count: u32,
|
||||
/// 是否正在同步
|
||||
pub is_syncing: bool,
|
||||
/// 最后同步错误
|
||||
pub last_error: Option<String>,
|
||||
}
|
||||
|
||||
impl Default for ModelSyncState {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
last_sync_at: None,
|
||||
model_count: 0,
|
||||
is_syncing: false,
|
||||
last_error: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// models.dev API 响应中的 Provider 结构
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct ModelsDevProvider {
|
||||
pub id: String,
|
||||
pub name: String,
|
||||
#[serde(default)]
|
||||
pub api: Option<String>,
|
||||
#[serde(default)]
|
||||
pub npm: Option<String>,
|
||||
#[serde(default)]
|
||||
pub models: std::collections::HashMap<String, ModelsDevModel>,
|
||||
}
|
||||
|
||||
/// models.dev API 响应中的 Model 结构
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct ModelsDevModel {
|
||||
pub id: String,
|
||||
pub name: String,
|
||||
#[serde(default)]
|
||||
pub family: Option<String>,
|
||||
#[serde(default)]
|
||||
pub release_date: Option<String>,
|
||||
#[serde(default)]
|
||||
pub attachment: bool,
|
||||
#[serde(default)]
|
||||
pub reasoning: bool,
|
||||
#[serde(default)]
|
||||
pub temperature: bool,
|
||||
#[serde(default)]
|
||||
pub tool_call: bool,
|
||||
#[serde(default)]
|
||||
pub cost: Option<ModelsDevCost>,
|
||||
#[serde(default)]
|
||||
pub limit: Option<ModelsDevLimit>,
|
||||
#[serde(default)]
|
||||
pub modalities: Option<ModelsDevModalities>,
|
||||
#[serde(default)]
|
||||
pub experimental: Option<bool>,
|
||||
#[serde(default)]
|
||||
pub status: Option<String>,
|
||||
}
|
||||
|
||||
/// models.dev API 响应中的 Cost 结构
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct ModelsDevCost {
|
||||
#[serde(default)]
|
||||
pub input: Option<f64>,
|
||||
#[serde(default)]
|
||||
pub output: Option<f64>,
|
||||
#[serde(default)]
|
||||
pub cache_read: Option<f64>,
|
||||
#[serde(default)]
|
||||
pub cache_write: Option<f64>,
|
||||
}
|
||||
|
||||
/// models.dev API 响应中的 Limit 结构
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct ModelsDevLimit {
|
||||
#[serde(default)]
|
||||
pub context: Option<u32>,
|
||||
#[serde(default)]
|
||||
pub output: Option<u32>,
|
||||
}
|
||||
|
||||
/// models.dev API 响应中的 Modalities 结构
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct ModelsDevModalities {
|
||||
#[serde(default)]
|
||||
pub input: Vec<String>,
|
||||
#[serde(default)]
|
||||
pub output: Vec<String>,
|
||||
}
|
||||
|
||||
impl ModelsDevModel {
|
||||
/// 转换为 EnhancedModelMetadata
|
||||
pub fn to_enhanced_metadata(
|
||||
&self,
|
||||
provider_id: &str,
|
||||
provider_name: &str,
|
||||
) -> EnhancedModelMetadata {
|
||||
let now = chrono::Utc::now().timestamp();
|
||||
|
||||
// 判断是否支持视觉
|
||||
let supports_vision = self
|
||||
.modalities
|
||||
.as_ref()
|
||||
.map(|m| m.input.iter().any(|i| i == "image" || i == "video"))
|
||||
.unwrap_or(false)
|
||||
|| self.attachment;
|
||||
|
||||
// 根据模型名称推断服务等级
|
||||
let tier = infer_model_tier(&self.id, &self.name);
|
||||
|
||||
// 解析状态
|
||||
let status = self
|
||||
.status
|
||||
.as_ref()
|
||||
.and_then(|s| s.parse().ok())
|
||||
.unwrap_or(ModelStatus::Active);
|
||||
|
||||
// 判断是否为最新版本
|
||||
let is_latest = self.id.contains("latest");
|
||||
|
||||
EnhancedModelMetadata {
|
||||
id: self.id.clone(),
|
||||
display_name: self.name.clone(),
|
||||
provider_id: provider_id.to_string(),
|
||||
provider_name: provider_name.to_string(),
|
||||
family: self.family.clone(),
|
||||
tier,
|
||||
capabilities: ModelCapabilities {
|
||||
vision: supports_vision,
|
||||
tools: self.tool_call,
|
||||
streaming: true, // 大多数模型都支持流式
|
||||
json_mode: true, // 大多数模型都支持 JSON 模式
|
||||
function_calling: self.tool_call,
|
||||
reasoning: self.reasoning,
|
||||
},
|
||||
pricing: self.cost.as_ref().map(|c| ModelPricing {
|
||||
input_per_million: c.input,
|
||||
output_per_million: c.output,
|
||||
cache_read_per_million: c.cache_read,
|
||||
cache_write_per_million: c.cache_write,
|
||||
currency: "USD".to_string(),
|
||||
}),
|
||||
limits: ModelLimits {
|
||||
context_length: self.limit.as_ref().and_then(|l| l.context),
|
||||
max_output_tokens: self.limit.as_ref().and_then(|l| l.output),
|
||||
requests_per_minute: None,
|
||||
tokens_per_minute: None,
|
||||
},
|
||||
status,
|
||||
release_date: self.release_date.clone(),
|
||||
is_latest,
|
||||
description: None,
|
||||
source: ModelSource::ModelsDev,
|
||||
created_at: now,
|
||||
updated_at: now,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 根据模型 ID 和名称推断服务等级
|
||||
fn infer_model_tier(model_id: &str, model_name: &str) -> ModelTier {
|
||||
let id_lower = model_id.to_lowercase();
|
||||
let name_lower = model_name.to_lowercase();
|
||||
|
||||
// Max 等级模型
|
||||
let max_patterns = [
|
||||
"opus",
|
||||
"gpt-4o",
|
||||
"gpt-4-turbo",
|
||||
"gemini-2.5-pro",
|
||||
"gemini-ultra",
|
||||
"claude-3-opus",
|
||||
"qwen-max",
|
||||
"glm-4-plus",
|
||||
"deepseek-v3",
|
||||
];
|
||||
for pattern in max_patterns {
|
||||
if id_lower.contains(pattern) || name_lower.contains(pattern) {
|
||||
return ModelTier::Max;
|
||||
}
|
||||
}
|
||||
|
||||
// Mini 等级模型
|
||||
let mini_patterns = [
|
||||
"mini",
|
||||
"nano",
|
||||
"lite",
|
||||
"flash",
|
||||
"haiku",
|
||||
"gpt-4o-mini",
|
||||
"gemini-flash",
|
||||
"qwen-turbo",
|
||||
"glm-4-flash",
|
||||
];
|
||||
for pattern in mini_patterns {
|
||||
if id_lower.contains(pattern) || name_lower.contains(pattern) {
|
||||
return ModelTier::Mini;
|
||||
}
|
||||
}
|
||||
|
||||
// 默认为 Pro 等级
|
||||
ModelTier::Pro
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_model_tier_inference() {
|
||||
assert_eq!(
|
||||
infer_model_tier("claude-opus-4-5-20250514", "Claude Opus 4.5"),
|
||||
ModelTier::Max
|
||||
);
|
||||
assert_eq!(
|
||||
infer_model_tier("gpt-4o-mini", "GPT-4o Mini"),
|
||||
ModelTier::Mini
|
||||
);
|
||||
assert_eq!(
|
||||
infer_model_tier("claude-sonnet-4-5", "Claude Sonnet 4.5"),
|
||||
ModelTier::Pro
|
||||
);
|
||||
assert_eq!(
|
||||
infer_model_tier("gemini-2.5-flash", "Gemini 2.5 Flash"),
|
||||
ModelTier::Mini
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_model_status_parsing() {
|
||||
assert_eq!("active".parse::<ModelStatus>().unwrap(), ModelStatus::Active);
|
||||
assert_eq!(
|
||||
"deprecated".parse::<ModelStatus>().unwrap(),
|
||||
ModelStatus::Deprecated
|
||||
);
|
||||
assert_eq!("beta".parse::<ModelStatus>().unwrap(), ModelStatus::Beta);
|
||||
}
|
||||
}
|
||||
@@ -324,6 +324,7 @@ mod tests {
|
||||
.update_credentials(vec![CredentialInfo {
|
||||
id: "cred-1".to_string(),
|
||||
provider_type: ProviderType::Anthropic,
|
||||
original_provider_type: None,
|
||||
supported_models: vec![
|
||||
"claude-sonnet-4-5-20250514".to_string(),
|
||||
"claude-3-5-haiku-20241022".to_string(),
|
||||
@@ -351,6 +352,7 @@ mod tests {
|
||||
.update_credentials(vec![CredentialInfo {
|
||||
id: "cred-1".to_string(),
|
||||
provider_type: ProviderType::Anthropic,
|
||||
original_provider_type: None,
|
||||
supported_models: vec![
|
||||
"claude-sonnet-4-5-20250514".to_string(),
|
||||
"claude-3-5-haiku-20241022".to_string(),
|
||||
|
||||
@@ -16,6 +16,7 @@ pub enum ProviderType {
|
||||
Kiro,
|
||||
Azure,
|
||||
Bedrock,
|
||||
Antigravity,
|
||||
Custom,
|
||||
}
|
||||
|
||||
@@ -29,6 +30,7 @@ impl ProviderType {
|
||||
"kiro" | "codewhisperer" => Some(ProviderType::Kiro),
|
||||
"azure" => Some(ProviderType::Azure),
|
||||
"bedrock" => Some(ProviderType::Bedrock),
|
||||
"antigravity" => Some(ProviderType::Antigravity),
|
||||
_ => Some(ProviderType::Custom),
|
||||
}
|
||||
}
|
||||
@@ -42,6 +44,7 @@ impl ProviderType {
|
||||
ProviderType::Kiro => "Kiro",
|
||||
ProviderType::Azure => "Azure",
|
||||
ProviderType::Bedrock => "Bedrock",
|
||||
ProviderType::Antigravity => "Antigravity",
|
||||
ProviderType::Custom => "Custom",
|
||||
}
|
||||
}
|
||||
@@ -83,13 +86,30 @@ impl ProviderDefinition {
|
||||
// 简单的 glob 匹配
|
||||
let parts: Vec<&str> = pattern_lower.split('*').collect();
|
||||
if parts.len() == 2 {
|
||||
// 模式如 "claude-*" 或 "*sonnet"
|
||||
let prefix = parts[0];
|
||||
let suffix = parts[1];
|
||||
model_lower.starts_with(prefix) && model_lower.ends_with(suffix)
|
||||
if prefix.is_empty() {
|
||||
model_lower.ends_with(suffix)
|
||||
} else if suffix.is_empty() {
|
||||
model_lower.starts_with(prefix)
|
||||
} else {
|
||||
model_lower.starts_with(prefix) && model_lower.ends_with(suffix)
|
||||
}
|
||||
} else if parts.len() == 3 {
|
||||
// 模式如 "claude-*opus*" (prefix*middle*suffix)
|
||||
let prefix = parts[0];
|
||||
let middle = parts[1];
|
||||
let suffix = parts[2];
|
||||
let starts_ok = prefix.is_empty() || model_lower.starts_with(prefix);
|
||||
let ends_ok = suffix.is_empty() || model_lower.ends_with(suffix);
|
||||
let contains_middle = middle.is_empty() || model_lower.contains(middle);
|
||||
starts_ok && ends_ok && contains_middle
|
||||
} else if parts.len() == 1 {
|
||||
model_lower.starts_with(parts[0])
|
||||
} else {
|
||||
false
|
||||
// 复杂模式,回退到简单包含检查
|
||||
parts.iter().all(|p| p.is_empty() || model_lower.contains(p))
|
||||
}
|
||||
} else {
|
||||
model_lower.contains(&pattern_lower)
|
||||
@@ -221,6 +241,47 @@ pub fn builtin_provider_definitions() -> Vec<ProviderDefinition> {
|
||||
],
|
||||
default_base_url: None,
|
||||
},
|
||||
// Antigravity (Google Cloud Code Assist)
|
||||
ProviderDefinition {
|
||||
provider_type: ProviderType::Antigravity,
|
||||
display_name: "Antigravity".to_string(),
|
||||
families: vec![
|
||||
// Max 等级:Gemini 3 Pro 和 Claude Opus
|
||||
ModelFamily {
|
||||
name: "gemini-3-pro".to_string(),
|
||||
pattern: "gemini-3-pro*".to_string(),
|
||||
tier: 3,
|
||||
description: Some("Gemini 3 Pro via Antigravity".to_string()),
|
||||
},
|
||||
ModelFamily {
|
||||
name: "opus".to_string(),
|
||||
pattern: "*opus*".to_string(),
|
||||
tier: 3,
|
||||
description: Some("Claude Opus via Antigravity".to_string()),
|
||||
},
|
||||
// Pro 等级:Claude Sonnet 和 Gemini 2.5
|
||||
ModelFamily {
|
||||
name: "sonnet".to_string(),
|
||||
pattern: "*sonnet*".to_string(),
|
||||
tier: 2,
|
||||
description: Some("Claude Sonnet via Antigravity".to_string()),
|
||||
},
|
||||
ModelFamily {
|
||||
name: "gemini-2.5".to_string(),
|
||||
pattern: "gemini-2.5*".to_string(),
|
||||
tier: 2,
|
||||
description: Some("Gemini 2.5 via Antigravity".to_string()),
|
||||
},
|
||||
// Mini 等级:Flash 模型
|
||||
ModelFamily {
|
||||
name: "gemini-3-flash".to_string(),
|
||||
pattern: "gemini-3-flash*".to_string(),
|
||||
tier: 1,
|
||||
description: Some("Gemini 3 Flash via Antigravity".to_string()),
|
||||
},
|
||||
],
|
||||
default_base_url: None,
|
||||
},
|
||||
]
|
||||
}
|
||||
|
||||
@@ -395,8 +456,10 @@ pub fn builtin_model_metadata() -> Vec<ModelMetadata> {
|
||||
pub struct CredentialInfo {
|
||||
/// 凭证 ID
|
||||
pub id: String,
|
||||
/// Provider 类型
|
||||
/// Provider 类型(用于模型分类)
|
||||
pub provider_type: ProviderType,
|
||||
/// 原始 Provider 类型字符串(用于前端识别,如 "antigravity"、"kiro" 等)
|
||||
pub original_provider_type: Option<String>,
|
||||
/// 支持的模型列表
|
||||
pub supported_models: Vec<String>,
|
||||
/// 是否健康
|
||||
@@ -476,13 +539,19 @@ impl DynamicPoolBuilder {
|
||||
.or_else(|| metadata.as_ref().and_then(|m| m.family.clone()));
|
||||
|
||||
// 构建 AvailableModel
|
||||
// 优先使用原始 provider 类型(如 "antigravity"),否则使用枚举名称
|
||||
let provider_type_str = credential
|
||||
.original_provider_type
|
||||
.clone()
|
||||
.unwrap_or_else(|| format!("{:?}", credential.provider_type).to_lowercase());
|
||||
|
||||
let available_model = AvailableModel {
|
||||
id: model_id.clone(),
|
||||
display_name: metadata
|
||||
.as_ref()
|
||||
.map(|m| m.display_name.clone())
|
||||
.unwrap_or_else(|| model_id.clone()),
|
||||
provider_type: format!("{:?}", credential.provider_type).to_lowercase(),
|
||||
provider_type: provider_type_str,
|
||||
family,
|
||||
credential_id: credential.id.clone(),
|
||||
context_length: metadata.as_ref().and_then(|m| m.context_length),
|
||||
@@ -590,6 +659,7 @@ mod tests {
|
||||
CredentialInfo {
|
||||
id: "cred-1".to_string(),
|
||||
provider_type: ProviderType::Anthropic,
|
||||
original_provider_type: None,
|
||||
supported_models: vec![
|
||||
"claude-opus-4-5-20251101".to_string(),
|
||||
"claude-sonnet-4-5-20250514".to_string(),
|
||||
@@ -601,6 +671,7 @@ mod tests {
|
||||
CredentialInfo {
|
||||
id: "cred-2".to_string(),
|
||||
provider_type: ProviderType::OpenAI,
|
||||
original_provider_type: None,
|
||||
supported_models: vec!["gpt-4o".to_string(), "gpt-3.5-turbo".to_string()],
|
||||
is_healthy: true,
|
||||
current_load: Some(20),
|
||||
@@ -622,6 +693,7 @@ mod tests {
|
||||
let credentials = vec![CredentialInfo {
|
||||
id: "cred-1".to_string(),
|
||||
provider_type: ProviderType::Anthropic,
|
||||
original_provider_type: None,
|
||||
supported_models: vec![
|
||||
"claude-sonnet-4-5-20250514".to_string(),
|
||||
"claude-3-5-sonnet-20241022".to_string(),
|
||||
|
||||
@@ -2946,6 +2946,7 @@ fn extract_content_from_json(json: &serde_json::Value) -> Option<(String, Vec<(S
|
||||
}
|
||||
|
||||
let mut text = String::new();
|
||||
let mut thinking_text = String::new();
|
||||
let mut images = Vec::new();
|
||||
|
||||
for candidate in candidates {
|
||||
@@ -2955,8 +2956,35 @@ fn extract_content_from_json(json: &serde_json::Value) -> Option<(String, Vec<(S
|
||||
.and_then(|p| p.as_array())
|
||||
{
|
||||
for part in parts {
|
||||
// 检查是否是思维内容
|
||||
let is_thought = part
|
||||
.get("thought")
|
||||
.and_then(|t| t.as_bool())
|
||||
.unwrap_or(false);
|
||||
|
||||
// 跳过纯 thoughtSignature 部分
|
||||
let has_thought_signature = part
|
||||
.get("thoughtSignature")
|
||||
.or_else(|| part.get("thought_signature"))
|
||||
.and_then(|s| s.as_str())
|
||||
.map(|s| !s.is_empty())
|
||||
.unwrap_or(false);
|
||||
|
||||
let has_content = part.get("text").is_some()
|
||||
|| part.get("inlineData").is_some()
|
||||
|| part.get("inline_data").is_some();
|
||||
|
||||
if has_thought_signature && !has_content {
|
||||
continue;
|
||||
}
|
||||
|
||||
if let Some(t) = part.get("text").and_then(|t| t.as_str()) {
|
||||
text.push_str(t);
|
||||
if is_thought {
|
||||
// 思维内容
|
||||
thinking_text.push_str(t);
|
||||
} else {
|
||||
text.push_str(t);
|
||||
}
|
||||
}
|
||||
if let Some(inline_data) =
|
||||
part.get("inlineData").or_else(|| part.get("inline_data"))
|
||||
@@ -2974,10 +3002,19 @@ fn extract_content_from_json(json: &serde_json::Value) -> Option<(String, Vec<(S
|
||||
}
|
||||
}
|
||||
|
||||
if text.is_empty() && images.is_empty() {
|
||||
// 如果有 thinking 内容,用 <thinking> 标签包裹并放在前面
|
||||
let mut final_text = String::new();
|
||||
if !thinking_text.is_empty() {
|
||||
final_text.push_str("<thinking>");
|
||||
final_text.push_str(&thinking_text);
|
||||
final_text.push_str("</thinking>\n\n");
|
||||
}
|
||||
final_text.push_str(&text);
|
||||
|
||||
if final_text.is_empty() && images.is_empty() {
|
||||
None
|
||||
} else {
|
||||
Some((text, images))
|
||||
Some((final_text, images))
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -5,6 +5,7 @@ pub mod live_sync;
|
||||
pub mod machine_id_service;
|
||||
pub mod mcp_service;
|
||||
pub mod mcp_sync;
|
||||
pub mod model_registry_service;
|
||||
pub mod prompt_service;
|
||||
pub mod prompt_sync;
|
||||
pub mod provider_pool_service;
|
||||
|
||||
@@ -0,0 +1,657 @@
|
||||
//! 模型注册服务
|
||||
//!
|
||||
//! 负责从 models.dev API 获取模型数据、管理本地缓存、提供模型搜索等功能
|
||||
|
||||
use crate::data::get_local_models;
|
||||
use crate::database::DbConnection;
|
||||
use crate::models::model_registry::{
|
||||
EnhancedModelMetadata, ModelSource, ModelStatus,
|
||||
ModelSyncState, ModelTier, ModelsDevProvider, UserModelPreference,
|
||||
};
|
||||
use rusqlite::params;
|
||||
use std::collections::HashMap;
|
||||
use std::sync::Arc;
|
||||
use tokio::sync::RwLock;
|
||||
|
||||
const MODELS_DEV_API_URL: &str = "https://models.dev/api.json";
|
||||
const CACHE_DURATION_SECS: i64 = 3600; // 1 小时
|
||||
|
||||
/// 模型注册服务
|
||||
pub struct ModelRegistryService {
|
||||
/// 数据库连接
|
||||
db: DbConnection,
|
||||
/// 内存缓存的模型数据
|
||||
models_cache: Arc<RwLock<Vec<EnhancedModelMetadata>>>,
|
||||
/// 同步状态
|
||||
sync_state: Arc<RwLock<ModelSyncState>>,
|
||||
}
|
||||
|
||||
impl ModelRegistryService {
|
||||
/// 创建新的模型注册服务
|
||||
pub fn new(db: DbConnection) -> Self {
|
||||
Self {
|
||||
db,
|
||||
models_cache: Arc::new(RwLock::new(Vec::new())),
|
||||
sync_state: Arc::new(RwLock::new(ModelSyncState::default())),
|
||||
}
|
||||
}
|
||||
|
||||
/// 初始化服务
|
||||
pub async fn initialize(&self) -> Result<(), String> {
|
||||
tracing::info!("[ModelRegistry] 初始化模型注册服务");
|
||||
|
||||
// 1. 尝试从数据库加载缓存
|
||||
match self.load_from_db().await {
|
||||
Ok(models) if !models.is_empty() => {
|
||||
tracing::info!("[ModelRegistry] 从数据库加载了 {} 个模型", models.len());
|
||||
let mut cache = self.models_cache.write().await;
|
||||
*cache = models;
|
||||
|
||||
// 检查是否需要后台刷新
|
||||
if self.should_refresh().await {
|
||||
tracing::info!("[ModelRegistry] 缓存已过期,启动后台刷新");
|
||||
self.spawn_background_refresh();
|
||||
}
|
||||
return Ok(());
|
||||
}
|
||||
Ok(_) => {
|
||||
tracing::info!("[ModelRegistry] 数据库中没有缓存数据");
|
||||
}
|
||||
Err(e) => {
|
||||
tracing::warn!("[ModelRegistry] 从数据库加载失败: {}", e);
|
||||
}
|
||||
}
|
||||
|
||||
// 2. 使用本地硬编码数据作为初始数据
|
||||
let local_models = get_local_models();
|
||||
tracing::info!(
|
||||
"[ModelRegistry] 使用 {} 个本地硬编码模型作为初始数据",
|
||||
local_models.len()
|
||||
);
|
||||
|
||||
{
|
||||
let mut cache = self.models_cache.write().await;
|
||||
*cache = local_models.clone();
|
||||
}
|
||||
|
||||
// 保存到数据库
|
||||
if let Err(e) = self.save_models_to_db(&local_models).await {
|
||||
tracing::warn!("[ModelRegistry] 保存本地模型到数据库失败: {}", e);
|
||||
}
|
||||
|
||||
// 3. 后台获取 models.dev 数据
|
||||
self.spawn_background_refresh();
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// 检查是否需要刷新
|
||||
async fn should_refresh(&self) -> bool {
|
||||
let state = self.sync_state.read().await;
|
||||
match state.last_sync_at {
|
||||
Some(last_sync) => {
|
||||
let now = chrono::Utc::now().timestamp();
|
||||
now - last_sync > CACHE_DURATION_SECS
|
||||
}
|
||||
None => true,
|
||||
}
|
||||
}
|
||||
|
||||
/// 启动后台刷新任务
|
||||
fn spawn_background_refresh(&self) {
|
||||
let db = self.db.clone();
|
||||
let models_cache = self.models_cache.clone();
|
||||
let sync_state = self.sync_state.clone();
|
||||
|
||||
tokio::spawn(async move {
|
||||
let service = ModelRegistryService {
|
||||
db,
|
||||
models_cache,
|
||||
sync_state,
|
||||
};
|
||||
if let Err(e) = service.refresh_from_models_dev().await {
|
||||
tracing::error!("[ModelRegistry] 后台刷新失败: {}", e);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/// 从 models.dev API 刷新数据
|
||||
pub async fn refresh_from_models_dev(&self) -> Result<(), String> {
|
||||
tracing::info!("[ModelRegistry] 开始从 models.dev 获取数据");
|
||||
|
||||
// 设置同步状态
|
||||
{
|
||||
let mut state = self.sync_state.write().await;
|
||||
state.is_syncing = true;
|
||||
state.last_error = None;
|
||||
}
|
||||
|
||||
// 获取数据
|
||||
let result = self.fetch_models_dev_data().await;
|
||||
|
||||
match result {
|
||||
Ok(models_dev_models) => {
|
||||
// 合并本地模型
|
||||
let local_models = get_local_models();
|
||||
let merged = self.merge_models(models_dev_models, local_models);
|
||||
|
||||
tracing::info!(
|
||||
"[ModelRegistry] 获取并合并了 {} 个模型",
|
||||
merged.len()
|
||||
);
|
||||
|
||||
// 更新缓存
|
||||
{
|
||||
let mut cache = self.models_cache.write().await;
|
||||
*cache = merged.clone();
|
||||
}
|
||||
|
||||
// 保存到数据库
|
||||
self.save_models_to_db(&merged).await?;
|
||||
|
||||
// 更新同步状态
|
||||
{
|
||||
let mut state = self.sync_state.write().await;
|
||||
state.is_syncing = false;
|
||||
state.last_sync_at = Some(chrono::Utc::now().timestamp());
|
||||
state.model_count = merged.len() as u32;
|
||||
state.last_error = None;
|
||||
}
|
||||
|
||||
// 保存同步状态到数据库
|
||||
self.save_sync_state().await?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
Err(e) => {
|
||||
tracing::error!("[ModelRegistry] 从 models.dev 获取数据失败: {}", e);
|
||||
|
||||
// 更新同步状态
|
||||
{
|
||||
let mut state = self.sync_state.write().await;
|
||||
state.is_syncing = false;
|
||||
state.last_error = Some(e.clone());
|
||||
}
|
||||
|
||||
Err(e)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 从 models.dev API 获取数据
|
||||
async fn fetch_models_dev_data(&self) -> Result<Vec<EnhancedModelMetadata>, String> {
|
||||
let client = reqwest::Client::builder()
|
||||
.timeout(std::time::Duration::from_secs(30))
|
||||
.build()
|
||||
.map_err(|e| format!("创建 HTTP 客户端失败: {}", e))?;
|
||||
|
||||
let response = client
|
||||
.get(MODELS_DEV_API_URL)
|
||||
.header("User-Agent", "ProxyCast/1.0")
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| format!("请求 models.dev 失败: {}", e))?;
|
||||
|
||||
if !response.status().is_success() {
|
||||
return Err(format!(
|
||||
"models.dev 返回错误状态码: {}",
|
||||
response.status()
|
||||
));
|
||||
}
|
||||
|
||||
let data: HashMap<String, ModelsDevProvider> = response
|
||||
.json()
|
||||
.await
|
||||
.map_err(|e| format!("解析 models.dev 响应失败: {}", e))?;
|
||||
|
||||
// 转换为内部格式
|
||||
let mut models = Vec::new();
|
||||
for (provider_id, provider) in data {
|
||||
for (_, model) in provider.models {
|
||||
let enhanced = model.to_enhanced_metadata(&provider_id, &provider.name);
|
||||
models.push(enhanced);
|
||||
}
|
||||
}
|
||||
|
||||
tracing::info!(
|
||||
"[ModelRegistry] 从 models.dev 获取了 {} 个模型",
|
||||
models.len()
|
||||
);
|
||||
|
||||
Ok(models)
|
||||
}
|
||||
|
||||
/// 合并 models.dev 数据和本地数据
|
||||
fn merge_models(
|
||||
&self,
|
||||
models_dev: Vec<EnhancedModelMetadata>,
|
||||
local: Vec<EnhancedModelMetadata>,
|
||||
) -> Vec<EnhancedModelMetadata> {
|
||||
let mut merged: HashMap<String, EnhancedModelMetadata> = HashMap::new();
|
||||
|
||||
// 先添加 models.dev 数据
|
||||
for model in models_dev {
|
||||
merged.insert(model.id.clone(), model);
|
||||
}
|
||||
|
||||
// 本地数据覆盖或补充
|
||||
for model in local {
|
||||
// 如果 models.dev 没有这个模型,或者本地数据更新,则使用本地数据
|
||||
if !merged.contains_key(&model.id) {
|
||||
merged.insert(model.id.clone(), model);
|
||||
}
|
||||
}
|
||||
|
||||
let mut result: Vec<_> = merged.into_values().collect();
|
||||
// 按 provider_id 和 display_name 排序
|
||||
result.sort_by(|a, b| {
|
||||
a.provider_id
|
||||
.cmp(&b.provider_id)
|
||||
.then(a.display_name.cmp(&b.display_name))
|
||||
});
|
||||
|
||||
result
|
||||
}
|
||||
|
||||
/// 从数据库加载模型
|
||||
async fn load_from_db(&self) -> Result<Vec<EnhancedModelMetadata>, String> {
|
||||
let (models, sync_rows) = {
|
||||
let conn = self.db.lock().map_err(|e| e.to_string())?;
|
||||
|
||||
let mut stmt = conn
|
||||
.prepare(
|
||||
"SELECT id, display_name, provider_id, provider_name, family, tier,
|
||||
capabilities, pricing, limits, status, release_date, is_latest,
|
||||
description, source, created_at, updated_at
|
||||
FROM model_registry",
|
||||
)
|
||||
.map_err(|e| e.to_string())?;
|
||||
|
||||
let models = stmt
|
||||
.query_map([], |row| {
|
||||
let capabilities_json: String = row.get(6)?;
|
||||
let pricing_json: Option<String> = row.get(7)?;
|
||||
let limits_json: String = row.get(8)?;
|
||||
let status_str: String = row.get(9)?;
|
||||
let tier_str: String = row.get(5)?;
|
||||
let source_str: String = row.get(13)?;
|
||||
|
||||
Ok(EnhancedModelMetadata {
|
||||
id: row.get(0)?,
|
||||
display_name: row.get(1)?,
|
||||
provider_id: row.get(2)?,
|
||||
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()),
|
||||
limits: serde_json::from_str(&limits_json).unwrap_or_default(),
|
||||
status: status_str.parse().unwrap_or(ModelStatus::Active),
|
||||
release_date: row.get(10)?,
|
||||
is_latest: row.get::<_, i32>(11)? != 0,
|
||||
description: row.get(12)?,
|
||||
source: source_str.parse().unwrap_or(ModelSource::Local),
|
||||
created_at: row.get(14)?,
|
||||
updated_at: row.get(15)?,
|
||||
})
|
||||
})
|
||||
.map_err(|e| e.to_string())?
|
||||
.collect::<Result<Vec<_>, _>>()
|
||||
.map_err(|e| e.to_string())?;
|
||||
|
||||
// 加载同步状态数据
|
||||
let mut sync_stmt = conn
|
||||
.prepare("SELECT key, value FROM model_sync_state")
|
||||
.map_err(|e| e.to_string())?;
|
||||
|
||||
let sync_rows: Vec<(String, String)> = sync_stmt
|
||||
.query_map([], |row| Ok((row.get(0)?, row.get(1)?)))
|
||||
.map_err(|e| e.to_string())?
|
||||
.collect::<Result<Vec<_>, _>>()
|
||||
.map_err(|e| e.to_string())?;
|
||||
|
||||
(models, sync_rows)
|
||||
}; // conn 锁在这里释放
|
||||
|
||||
// 更新同步状态(在锁释放后)
|
||||
{
|
||||
let mut state = self.sync_state.write().await;
|
||||
for (key, value) in sync_rows {
|
||||
match key.as_str() {
|
||||
"last_sync_at" => {
|
||||
state.last_sync_at = value.parse().ok();
|
||||
}
|
||||
"model_count" => {
|
||||
state.model_count = value.parse().unwrap_or(0);
|
||||
}
|
||||
"last_error" => {
|
||||
state.last_error = if value.is_empty() { None } else { Some(value) };
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Ok(models)
|
||||
}
|
||||
|
||||
/// 保存模型到数据库
|
||||
async fn save_models_to_db(&self, models: &[EnhancedModelMetadata]) -> Result<(), String> {
|
||||
let conn = self.db.lock().map_err(|e| e.to_string())?;
|
||||
|
||||
// 开始事务
|
||||
conn.execute("BEGIN TRANSACTION", [])
|
||||
.map_err(|e| e.to_string())?;
|
||||
|
||||
// 清空现有数据
|
||||
conn.execute("DELETE FROM model_registry", [])
|
||||
.map_err(|e| e.to_string())?;
|
||||
|
||||
// 插入新数据
|
||||
let mut stmt = conn
|
||||
.prepare(
|
||||
"INSERT INTO model_registry (
|
||||
id, display_name, provider_id, provider_name, family, tier,
|
||||
capabilities, pricing, limits, status, release_date, is_latest,
|
||||
description, source, created_at, updated_at
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
|
||||
)
|
||||
.map_err(|e| e.to_string())?;
|
||||
|
||||
for model in models {
|
||||
let capabilities_json =
|
||||
serde_json::to_string(&model.capabilities).unwrap_or_default();
|
||||
let pricing_json = model
|
||||
.pricing
|
||||
.as_ref()
|
||||
.map(|p| serde_json::to_string(p).unwrap_or_default());
|
||||
let limits_json = serde_json::to_string(&model.limits).unwrap_or_default();
|
||||
|
||||
stmt.execute(params![
|
||||
model.id,
|
||||
model.display_name,
|
||||
model.provider_id,
|
||||
model.provider_name,
|
||||
model.family,
|
||||
model.tier.to_string(),
|
||||
capabilities_json,
|
||||
pricing_json,
|
||||
limits_json,
|
||||
model.status.to_string(),
|
||||
model.release_date,
|
||||
model.is_latest as i32,
|
||||
model.description,
|
||||
model.source.to_string(),
|
||||
model.created_at,
|
||||
model.updated_at,
|
||||
])
|
||||
.map_err(|e| e.to_string())?;
|
||||
}
|
||||
|
||||
// 提交事务
|
||||
conn.execute("COMMIT", []).map_err(|e| e.to_string())?;
|
||||
|
||||
tracing::info!("[ModelRegistry] 保存了 {} 个模型到数据库", models.len());
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// 保存同步状态
|
||||
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())
|
||||
};
|
||||
|
||||
let conn = self.db.lock().map_err(|e| e.to_string())?;
|
||||
let now = chrono::Utc::now().timestamp();
|
||||
|
||||
let mut stmt = conn
|
||||
.prepare(
|
||||
"INSERT OR REPLACE INTO model_sync_state (key, value, updated_at)
|
||||
VALUES (?, ?, ?)",
|
||||
)
|
||||
.map_err(|e| e.to_string())?;
|
||||
|
||||
if let Some(last_sync) = last_sync_at {
|
||||
stmt.execute(params!["last_sync_at", last_sync.to_string(), now])
|
||||
.map_err(|e| e.to_string())?;
|
||||
}
|
||||
|
||||
stmt.execute(params!["model_count", model_count.to_string(), now])
|
||||
.map_err(|e| e.to_string())?;
|
||||
|
||||
if let Some(ref error) = last_error {
|
||||
stmt.execute(params!["last_error", error, now])
|
||||
.map_err(|e| e.to_string())?;
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// 获取所有模型
|
||||
pub async fn get_all_models(&self) -> Vec<EnhancedModelMetadata> {
|
||||
self.models_cache.read().await.clone()
|
||||
}
|
||||
|
||||
/// 获取同步状态
|
||||
pub async fn get_sync_state(&self) -> ModelSyncState {
|
||||
self.sync_state.read().await.clone()
|
||||
}
|
||||
|
||||
/// 按 Provider 获取模型
|
||||
pub async fn get_models_by_provider(&self, provider_id: &str) -> Vec<EnhancedModelMetadata> {
|
||||
self.models_cache
|
||||
.read()
|
||||
.await
|
||||
.iter()
|
||||
.filter(|m| m.provider_id == provider_id)
|
||||
.cloned()
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// 按服务等级获取模型
|
||||
pub async fn get_models_by_tier(&self, tier: ModelTier) -> Vec<EnhancedModelMetadata> {
|
||||
self.models_cache
|
||||
.read()
|
||||
.await
|
||||
.iter()
|
||||
.filter(|m| m.tier == tier)
|
||||
.cloned()
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// 搜索模型(简单的模糊匹配)
|
||||
pub async fn search_models(&self, query: &str, limit: usize) -> Vec<EnhancedModelMetadata> {
|
||||
let models = self.models_cache.read().await;
|
||||
|
||||
if query.is_empty() {
|
||||
return models.iter().take(limit).cloned().collect();
|
||||
}
|
||||
|
||||
let query_lower = query.to_lowercase();
|
||||
let mut scored: Vec<(f64, &EnhancedModelMetadata)> = models
|
||||
.iter()
|
||||
.filter_map(|m| {
|
||||
let score = self.calculate_search_score(m, &query_lower);
|
||||
if score > 0.0 {
|
||||
Some((score, m))
|
||||
} else {
|
||||
None
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
|
||||
// 按分数降序排序
|
||||
scored.sort_by(|a, b| {
|
||||
b.0.partial_cmp(&a.0)
|
||||
.unwrap_or(std::cmp::Ordering::Equal)
|
||||
});
|
||||
|
||||
scored
|
||||
.into_iter()
|
||||
.take(limit)
|
||||
.map(|(_, m)| m.clone())
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// 计算搜索匹配分数
|
||||
fn calculate_search_score(&self, model: &EnhancedModelMetadata, query: &str) -> f64 {
|
||||
let mut score = 0.0;
|
||||
|
||||
// 精确匹配 ID
|
||||
if model.id.to_lowercase() == query {
|
||||
score += 100.0;
|
||||
} else if model.id.to_lowercase().contains(query) {
|
||||
score += 50.0;
|
||||
}
|
||||
|
||||
// 显示名称匹配
|
||||
if model.display_name.to_lowercase().contains(query) {
|
||||
score += 30.0;
|
||||
}
|
||||
|
||||
// Provider 匹配
|
||||
if model.provider_name.to_lowercase().contains(query) {
|
||||
score += 20.0;
|
||||
}
|
||||
|
||||
// 家族匹配
|
||||
if let Some(family) = &model.family {
|
||||
if family.to_lowercase().contains(query) {
|
||||
score += 15.0;
|
||||
}
|
||||
}
|
||||
|
||||
// 最新版本加分
|
||||
if model.is_latest {
|
||||
score += 5.0;
|
||||
}
|
||||
|
||||
// 活跃状态加分
|
||||
if model.status == ModelStatus::Active {
|
||||
score += 3.0;
|
||||
}
|
||||
|
||||
score
|
||||
}
|
||||
|
||||
// ========== 用户偏好相关方法 ==========
|
||||
|
||||
/// 获取所有用户偏好
|
||||
pub async fn get_all_preferences(&self) -> Result<Vec<UserModelPreference>, String> {
|
||||
let conn = self.db.lock().map_err(|e| e.to_string())?;
|
||||
|
||||
let mut stmt = conn
|
||||
.prepare(
|
||||
"SELECT model_id, is_favorite, is_hidden, custom_alias,
|
||||
usage_count, last_used_at, created_at, updated_at
|
||||
FROM user_model_preferences",
|
||||
)
|
||||
.map_err(|e| e.to_string())?;
|
||||
|
||||
let prefs = stmt
|
||||
.query_map([], |row| {
|
||||
Ok(UserModelPreference {
|
||||
model_id: row.get(0)?,
|
||||
is_favorite: row.get::<_, i32>(1)? != 0,
|
||||
is_hidden: row.get::<_, i32>(2)? != 0,
|
||||
custom_alias: row.get(3)?,
|
||||
usage_count: row.get::<_, i32>(4)? as u32,
|
||||
last_used_at: row.get(5)?,
|
||||
created_at: row.get(6)?,
|
||||
updated_at: row.get(7)?,
|
||||
})
|
||||
})
|
||||
.map_err(|e| e.to_string())?
|
||||
.collect::<Result<Vec<_>, _>>()
|
||||
.map_err(|e| e.to_string())?;
|
||||
|
||||
Ok(prefs)
|
||||
}
|
||||
|
||||
/// 切换收藏状态
|
||||
pub async fn toggle_favorite(&self, model_id: &str) -> Result<bool, String> {
|
||||
let conn = self.db.lock().map_err(|e| e.to_string())?;
|
||||
let now = chrono::Utc::now().timestamp();
|
||||
|
||||
// 检查是否存在
|
||||
let exists: bool = conn
|
||||
.query_row(
|
||||
"SELECT 1 FROM user_model_preferences WHERE model_id = ?",
|
||||
params![model_id],
|
||||
|_| Ok(true),
|
||||
)
|
||||
.unwrap_or(false);
|
||||
|
||||
if exists {
|
||||
// 切换状态
|
||||
conn.execute(
|
||||
"UPDATE user_model_preferences
|
||||
SET is_favorite = NOT is_favorite, updated_at = ?
|
||||
WHERE model_id = ?",
|
||||
params![now, model_id],
|
||||
)
|
||||
.map_err(|e| e.to_string())?;
|
||||
} else {
|
||||
// 创建新记录
|
||||
conn.execute(
|
||||
"INSERT INTO user_model_preferences
|
||||
(model_id, is_favorite, is_hidden, usage_count, created_at, updated_at)
|
||||
VALUES (?, 1, 0, 0, ?, ?)",
|
||||
params![model_id, now, now],
|
||||
)
|
||||
.map_err(|e| e.to_string())?;
|
||||
}
|
||||
|
||||
// 返回新状态
|
||||
let new_state: bool = conn
|
||||
.query_row(
|
||||
"SELECT is_favorite FROM user_model_preferences WHERE model_id = ?",
|
||||
params![model_id],
|
||||
|row| Ok(row.get::<_, i32>(0)? != 0),
|
||||
)
|
||||
.unwrap_or(false);
|
||||
|
||||
Ok(new_state)
|
||||
}
|
||||
|
||||
/// 隐藏模型
|
||||
pub async fn hide_model(&self, model_id: &str) -> Result<(), String> {
|
||||
let conn = self.db.lock().map_err(|e| e.to_string())?;
|
||||
let now = chrono::Utc::now().timestamp();
|
||||
|
||||
conn.execute(
|
||||
"INSERT INTO user_model_preferences
|
||||
(model_id, is_favorite, is_hidden, usage_count, created_at, updated_at)
|
||||
VALUES (?, 0, 1, 0, ?, ?)
|
||||
ON CONFLICT(model_id) DO UPDATE SET is_hidden = 1, updated_at = ?",
|
||||
params![model_id, now, now, now],
|
||||
)
|
||||
.map_err(|e| e.to_string())?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// 记录模型使用
|
||||
pub async fn record_usage(&self, model_id: &str) -> Result<(), String> {
|
||||
let conn = self.db.lock().map_err(|e| e.to_string())?;
|
||||
let now = chrono::Utc::now().timestamp();
|
||||
|
||||
conn.execute(
|
||||
"INSERT INTO user_model_preferences
|
||||
(model_id, is_favorite, is_hidden, usage_count, last_used_at, created_at, updated_at)
|
||||
VALUES (?, 0, 0, 1, ?, ?, ?)
|
||||
ON CONFLICT(model_id) DO UPDATE SET
|
||||
usage_count = usage_count + 1,
|
||||
last_used_at = ?,
|
||||
updated_at = ?",
|
||||
params![model_id, now, now, now, now, now],
|
||||
)
|
||||
.map_err(|e| e.to_string())?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
@@ -1,15 +1,5 @@
|
||||
import React, { useState, useEffect } from "react";
|
||||
import {
|
||||
Bot,
|
||||
ChevronDown,
|
||||
Check,
|
||||
Box,
|
||||
Settings2,
|
||||
Zap,
|
||||
Sparkles,
|
||||
Crown,
|
||||
Wand2,
|
||||
} from "lucide-react";
|
||||
import React, { useState } from "react";
|
||||
import { Bot, ChevronDown, Check, Box, Settings2 } from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Popover,
|
||||
@@ -17,55 +7,9 @@ import {
|
||||
PopoverTrigger,
|
||||
} from "@/components/ui/popover";
|
||||
import { ScrollArea } from "@/components/ui/scroll-area";
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipTrigger,
|
||||
} from "@/components/ui/tooltip";
|
||||
import { Navbar } from "../styles";
|
||||
import { PROVIDER_CONFIG } from "../types";
|
||||
import { cn } from "@/lib/utils";
|
||||
import {
|
||||
orchestratorApi,
|
||||
type ServiceTier,
|
||||
type PoolStats,
|
||||
} from "@/lib/api/orchestrator";
|
||||
|
||||
// 服务等级配置
|
||||
const TIER_CONFIG: Record<
|
||||
ServiceTier,
|
||||
{
|
||||
label: string;
|
||||
description: string;
|
||||
icon: React.ReactNode;
|
||||
color: string;
|
||||
bgColor: string;
|
||||
}
|
||||
> = {
|
||||
mini: {
|
||||
label: "Mini",
|
||||
description: "快速响应",
|
||||
icon: <Zap className="w-3.5 h-3.5" />,
|
||||
color: "text-green-600 dark:text-green-400",
|
||||
bgColor: "bg-green-500/10",
|
||||
},
|
||||
pro: {
|
||||
label: "Pro",
|
||||
description: "均衡性能",
|
||||
icon: <Sparkles className="w-3.5 h-3.5" />,
|
||||
color: "text-blue-600 dark:text-blue-400",
|
||||
bgColor: "bg-blue-500/10",
|
||||
},
|
||||
max: {
|
||||
label: "Max",
|
||||
description: "最强能力",
|
||||
icon: <Crown className="w-3.5 h-3.5" />,
|
||||
color: "text-purple-600 dark:text-purple-400",
|
||||
bgColor: "bg-purple-500/10",
|
||||
},
|
||||
};
|
||||
|
||||
type SelectionMode = "simple" | "expert";
|
||||
|
||||
interface ChatNavbarProps {
|
||||
providerType: string;
|
||||
@@ -89,62 +33,10 @@ export const ChatNavbar: React.FC<ChatNavbarProps> = ({
|
||||
onToggleSettings,
|
||||
}) => {
|
||||
const [open, setOpen] = useState(false);
|
||||
const [mode, setMode] = useState<SelectionMode>("simple");
|
||||
const [tier, setTier] = useState<ServiceTier>("pro");
|
||||
const [poolStats, setPoolStats] = useState<PoolStats | null>(null);
|
||||
const [orchestratorReady, setOrchestratorReady] = useState(false);
|
||||
|
||||
// 初始化 orchestrator
|
||||
useEffect(() => {
|
||||
const init = async () => {
|
||||
try {
|
||||
await orchestratorApi.init();
|
||||
setOrchestratorReady(true);
|
||||
const stats = await orchestratorApi.getPoolStats();
|
||||
setPoolStats(stats);
|
||||
} catch (err) {
|
||||
console.warn("Orchestrator 初始化失败,使用专家模式:", err);
|
||||
setMode("expert");
|
||||
}
|
||||
};
|
||||
init();
|
||||
}, []);
|
||||
|
||||
// 简单模式下选择等级时自动选择模型
|
||||
const handleTierSelect = async (selectedTier: ServiceTier) => {
|
||||
setTier(selectedTier);
|
||||
setOpen(false);
|
||||
|
||||
if (!orchestratorReady) return;
|
||||
|
||||
try {
|
||||
const result = await orchestratorApi.selectModel({ tier: selectedTier });
|
||||
// 映射 orchestrator 的 provider_type 到 PROVIDER_CONFIG 的 key
|
||||
const providerKey = mapProviderType(result.provider_type);
|
||||
setProviderType(providerKey);
|
||||
setModel(result.model_id);
|
||||
} catch (err) {
|
||||
console.error("模型选择失败:", err);
|
||||
}
|
||||
};
|
||||
|
||||
// 映射 provider type
|
||||
const mapProviderType = (orchestratorType: string): string => {
|
||||
const mapping: Record<string, string> = {
|
||||
anthropic: "claude",
|
||||
openai: "openai",
|
||||
google: "gemini",
|
||||
gemini: "gemini",
|
||||
kiro: "kiro",
|
||||
codex: "codex",
|
||||
};
|
||||
return mapping[orchestratorType.toLowerCase()] || orchestratorType;
|
||||
};
|
||||
|
||||
const selectedProviderLabel =
|
||||
PROVIDER_CONFIG[providerType]?.label || providerType;
|
||||
const currentModels = PROVIDER_CONFIG[providerType]?.models || [];
|
||||
const tierConfig = TIER_CONFIG[tier];
|
||||
|
||||
return (
|
||||
<Navbar>
|
||||
@@ -170,22 +62,10 @@ export const ChatNavbar: React.FC<ChatNavbarProps> = ({
|
||||
aria-expanded={open}
|
||||
className="h-9 px-3 gap-2 font-normal hover:bg-muted text-foreground"
|
||||
>
|
||||
{mode === "simple" && orchestratorReady ? (
|
||||
<>
|
||||
<span className={tierConfig.color}>{tierConfig.icon}</span>
|
||||
<span className="font-medium">{tierConfig.label}</span>
|
||||
<span className="text-muted-foreground text-xs">
|
||||
({tierConfig.description})
|
||||
</span>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Bot size={16} className="text-primary" />
|
||||
<span className="font-medium">{selectedProviderLabel}</span>
|
||||
<span className="text-muted-foreground">/</span>
|
||||
<span className="text-sm">{model || "Select Model"}</span>
|
||||
</>
|
||||
)}
|
||||
<Bot size={16} className="text-primary" />
|
||||
<span className="font-medium">{selectedProviderLabel}</span>
|
||||
<span className="text-muted-foreground">/</span>
|
||||
<span className="text-sm">{model || "Select Model"}</span>
|
||||
<ChevronDown className="ml-1 h-3 w-3 text-muted-foreground opacity-50" />
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
@@ -193,183 +73,77 @@ export const ChatNavbar: React.FC<ChatNavbarProps> = ({
|
||||
className="w-[420px] p-0 bg-background/95 backdrop-blur-sm border-border shadow-lg"
|
||||
align="center"
|
||||
>
|
||||
{/* Mode Toggle */}
|
||||
<div className="flex items-center justify-between px-3 py-2 border-b bg-muted/30">
|
||||
<span className="text-xs font-medium text-muted-foreground">
|
||||
选择模式
|
||||
</span>
|
||||
<div className="flex gap-1">
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant={mode === "simple" ? "secondary" : "ghost"}
|
||||
size="sm"
|
||||
className="h-7 px-2 text-xs"
|
||||
onClick={() => setMode("simple")}
|
||||
disabled={!orchestratorReady}
|
||||
>
|
||||
<Wand2 className="w-3 h-3 mr-1" />
|
||||
简单
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>Mini/Pro/Max 三档智能选择</TooltipContent>
|
||||
</Tooltip>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant={mode === "expert" ? "secondary" : "ghost"}
|
||||
size="sm"
|
||||
className="h-7 px-2 text-xs"
|
||||
onClick={() => setMode("expert")}
|
||||
>
|
||||
<Settings2 className="w-3 h-3 mr-1" />
|
||||
专家
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>直接选择 Provider 和模型</TooltipContent>
|
||||
</Tooltip>
|
||||
{/* Provider/Model Selection */}
|
||||
<div className="flex h-[300px]">
|
||||
{/* Left Column: Providers */}
|
||||
<div className="w-[140px] border-r bg-muted/30 p-2 flex flex-col gap-1 overflow-y-auto">
|
||||
<div className="text-xs font-semibold text-muted-foreground px-2 py-1.5 mb-1">
|
||||
Providers
|
||||
</div>
|
||||
{Object.entries(PROVIDER_CONFIG).map(([key, config]) => (
|
||||
<button
|
||||
key={key}
|
||||
onClick={() => {
|
||||
setProviderType(key);
|
||||
// Auto-select first model if available
|
||||
if (config.models.length > 0) {
|
||||
setModel(config.models[0]);
|
||||
} else {
|
||||
setModel("");
|
||||
}
|
||||
}}
|
||||
className={cn(
|
||||
"flex items-center justify-between w-full px-2 py-1.5 text-sm rounded-md transition-colors text-left",
|
||||
providerType === key
|
||||
? "bg-primary/10 text-primary font-medium"
|
||||
: "hover:bg-muted text-muted-foreground hover:text-foreground",
|
||||
)}
|
||||
>
|
||||
{config.label}
|
||||
{providerType === key && (
|
||||
<div className="w-1 h-1 rounded-full bg-primary" />
|
||||
)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Right Column: Models */}
|
||||
<div className="flex-1 p-2 flex flex-col overflow-hidden">
|
||||
<div className="text-xs font-semibold text-muted-foreground px-2 py-1.5 mb-1">
|
||||
Models
|
||||
</div>
|
||||
<ScrollArea className="flex-1">
|
||||
<div className="space-y-1 p-1">
|
||||
{currentModels.length === 0 ? (
|
||||
<div className="text-xs text-muted-foreground p-2">
|
||||
No models available
|
||||
</div>
|
||||
) : (
|
||||
currentModels.map((m) => (
|
||||
<button
|
||||
key={m}
|
||||
onClick={() => {
|
||||
setModel(m);
|
||||
setOpen(false);
|
||||
}}
|
||||
className={cn(
|
||||
"flex items-center justify-between w-full px-2 py-1.5 text-sm rounded-md transition-colors text-left group",
|
||||
model === m
|
||||
? "bg-accent text-accent-foreground"
|
||||
: "hover:bg-muted text-muted-foreground hover:text-foreground",
|
||||
)}
|
||||
>
|
||||
{m}
|
||||
{model === m && (
|
||||
<Check size={14} className="text-primary" />
|
||||
)}
|
||||
</button>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</ScrollArea>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{mode === "simple" && orchestratorReady ? (
|
||||
/* Simple Mode: Tier Selection */
|
||||
<div className="p-3">
|
||||
<div className="grid grid-cols-3 gap-2">
|
||||
{(Object.keys(TIER_CONFIG) as ServiceTier[]).map((t) => {
|
||||
const config = TIER_CONFIG[t];
|
||||
const count =
|
||||
poolStats?.[`${t}_count` as keyof PoolStats] ?? 0;
|
||||
const isSelected = tier === t;
|
||||
|
||||
return (
|
||||
<button
|
||||
key={t}
|
||||
onClick={() => handleTierSelect(t)}
|
||||
className={cn(
|
||||
"flex flex-col items-center p-3 rounded-lg border transition-all",
|
||||
isSelected
|
||||
? cn(
|
||||
"border-primary/50",
|
||||
config.bgColor,
|
||||
"ring-2 ring-primary/20",
|
||||
)
|
||||
: "border-border hover:bg-muted/50",
|
||||
count === 0 && "opacity-50 cursor-not-allowed",
|
||||
)}
|
||||
disabled={count === 0}
|
||||
>
|
||||
<span
|
||||
className={cn(
|
||||
"mb-1",
|
||||
isSelected ? config.color : "text-muted-foreground",
|
||||
)}
|
||||
>
|
||||
{config.icon}
|
||||
</span>
|
||||
<span
|
||||
className={cn(
|
||||
"font-medium text-sm",
|
||||
isSelected ? config.color : "text-foreground",
|
||||
)}
|
||||
>
|
||||
{config.label}
|
||||
</span>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{config.description}
|
||||
</span>
|
||||
{poolStats && (
|
||||
<span className="text-xs text-muted-foreground mt-1">
|
||||
{count} 模型
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* Current Selection Info */}
|
||||
{model && (
|
||||
<div className="mt-3 p-2 rounded-md bg-muted/50 text-xs">
|
||||
<span className="text-muted-foreground">当前模型: </span>
|
||||
<span className="font-medium">
|
||||
{selectedProviderLabel} / {model}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
/* Expert Mode: Provider/Model Selection */
|
||||
<div className="flex h-[300px]">
|
||||
{/* Left Column: Providers */}
|
||||
<div className="w-[140px] border-r bg-muted/30 p-2 flex flex-col gap-1 overflow-y-auto">
|
||||
<div className="text-xs font-semibold text-muted-foreground px-2 py-1.5 mb-1">
|
||||
Providers
|
||||
</div>
|
||||
{Object.entries(PROVIDER_CONFIG).map(([key, config]) => (
|
||||
<button
|
||||
key={key}
|
||||
onClick={() => {
|
||||
setProviderType(key);
|
||||
// Auto-select first model if available
|
||||
if (config.models.length > 0) {
|
||||
setModel(config.models[0]);
|
||||
} else {
|
||||
setModel("");
|
||||
}
|
||||
}}
|
||||
className={cn(
|
||||
"flex items-center justify-between w-full px-2 py-1.5 text-sm rounded-md transition-colors text-left",
|
||||
providerType === key
|
||||
? "bg-primary/10 text-primary font-medium"
|
||||
: "hover:bg-muted text-muted-foreground hover:text-foreground",
|
||||
)}
|
||||
>
|
||||
{config.label}
|
||||
{providerType === key && (
|
||||
<div className="w-1 h-1 rounded-full bg-primary" />
|
||||
)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Right Column: Models */}
|
||||
<div className="flex-1 p-2 flex flex-col overflow-hidden">
|
||||
<div className="text-xs font-semibold text-muted-foreground px-2 py-1.5 mb-1">
|
||||
Models
|
||||
</div>
|
||||
<ScrollArea className="flex-1">
|
||||
<div className="space-y-1 p-1">
|
||||
{currentModels.length === 0 ? (
|
||||
<div className="text-xs text-muted-foreground p-2">
|
||||
No models available
|
||||
</div>
|
||||
) : (
|
||||
currentModels.map((m) => (
|
||||
<button
|
||||
key={m}
|
||||
onClick={() => {
|
||||
setModel(m);
|
||||
setOpen(false);
|
||||
}}
|
||||
className={cn(
|
||||
"flex items-center justify-between w-full px-2 py-1.5 text-sm rounded-md transition-colors text-left group",
|
||||
model === m
|
||||
? "bg-accent text-accent-foreground"
|
||||
: "hover:bg-muted text-muted-foreground hover:text-foreground",
|
||||
)}
|
||||
>
|
||||
{m}
|
||||
{model === m && (
|
||||
<Check size={14} className="text-primary" />
|
||||
)}
|
||||
</button>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</ScrollArea>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
</div>
|
||||
|
||||
@@ -137,6 +137,18 @@ export function useAgentChat() {
|
||||
savePersisted("agent_pref_model", model);
|
||||
}, [model]);
|
||||
|
||||
// 当 provider 改变时,检查当前模型是否兼容
|
||||
// 如果不兼容,自动切换到新 provider 的第一个模型
|
||||
useEffect(() => {
|
||||
const currentProviderModels = providerConfig[providerType]?.models || [];
|
||||
if (currentProviderModels.length > 0 && !currentProviderModels.includes(model)) {
|
||||
console.log(
|
||||
`[useAgentChat] 模型 ${model} 不在 ${providerType} 支持列表中,自动切换到 ${currentProviderModels[0]}`
|
||||
);
|
||||
setModel(currentProviderModels[0]);
|
||||
}
|
||||
}, [providerType, providerConfig, model]);
|
||||
|
||||
useEffect(() => {
|
||||
saveTransient("agent_curr_sessionId", sessionId);
|
||||
}, [sessionId]);
|
||||
|
||||
@@ -56,42 +56,91 @@ export const PROVIDER_CONFIG: Record<
|
||||
claude: {
|
||||
label: "Claude",
|
||||
models: [
|
||||
"claude-opus-4-5",
|
||||
"claude-opus-4-5-20251101",
|
||||
"claude-opus-4-1",
|
||||
"claude-opus-4-1-20250805",
|
||||
"claude-opus-4-20250514",
|
||||
"claude-sonnet-4-5",
|
||||
"claude-sonnet-4-5-20250929",
|
||||
"claude-sonnet-4-0",
|
||||
"claude-sonnet-4-20250514",
|
||||
"claude-haiku-4-5",
|
||||
"claude-haiku-4-5-20251001",
|
||||
"claude-3-7-sonnet-20250219",
|
||||
"claude-3-5-haiku-20241022",
|
||||
],
|
||||
},
|
||||
anthropic: {
|
||||
label: "Anthropic",
|
||||
models: [
|
||||
"claude-opus-4-5",
|
||||
"claude-opus-4-5-20251101",
|
||||
"claude-opus-4-1",
|
||||
"claude-opus-4-1-20250805",
|
||||
"claude-opus-4-20250514",
|
||||
"claude-sonnet-4-5",
|
||||
"claude-sonnet-4-5-20250929",
|
||||
"claude-sonnet-4-0",
|
||||
"claude-sonnet-4-20250514",
|
||||
"claude-haiku-4-5",
|
||||
"claude-haiku-4-5-20251001",
|
||||
"claude-3-7-sonnet-20250219",
|
||||
"claude-3-5-haiku-20241022",
|
||||
],
|
||||
},
|
||||
kiro: {
|
||||
label: "Kiro",
|
||||
models: ["claude-sonnet-4-5-20250929", "claude-sonnet-4-20250514"],
|
||||
models: [
|
||||
"claude-opus-4-5",
|
||||
"claude-opus-4-5-20251101",
|
||||
"claude-haiku-4-5",
|
||||
"claude-sonnet-4-5",
|
||||
"claude-sonnet-4-5-20250929",
|
||||
"claude-sonnet-4-20250514",
|
||||
"claude-3-7-sonnet-20250219",
|
||||
],
|
||||
},
|
||||
openai: {
|
||||
label: "OpenAI",
|
||||
models: [
|
||||
"gpt-4o",
|
||||
"gpt-4o-mini",
|
||||
"gpt-4-turbo",
|
||||
"o1",
|
||||
"o1-mini",
|
||||
"gpt-5.2",
|
||||
"gpt-5.2-codex",
|
||||
"gpt-5.1",
|
||||
"gpt-5.1-codex-max",
|
||||
"gpt-5.1-codex",
|
||||
"gpt-5.1-codex-mini",
|
||||
"gpt-5",
|
||||
"gpt-5-pro",
|
||||
"gpt-5-codex",
|
||||
"gpt-5-codex-mini",
|
||||
"gpt-5-mini",
|
||||
"gpt-5-nano",
|
||||
"o4-mini",
|
||||
"o3",
|
||||
"o3-mini",
|
||||
"o1",
|
||||
"o1-mini",
|
||||
"gpt-4.1",
|
||||
"gpt-4.1-mini",
|
||||
"gpt-4.1-nano",
|
||||
"gpt-4o",
|
||||
"gpt-4o-mini",
|
||||
],
|
||||
},
|
||||
gemini: {
|
||||
label: "Gemini",
|
||||
models: ["gemini-2.0-flash-exp", "gemini-1.5-pro", "gemini-1.5-flash"],
|
||||
models: [
|
||||
"gemini-3-pro-preview",
|
||||
"gemini-3-flash-preview",
|
||||
"gemini-2.5-pro",
|
||||
"gemini-2.5-flash",
|
||||
"gemini-2.5-flash-lite",
|
||||
],
|
||||
},
|
||||
qwen: {
|
||||
label: "通义千问",
|
||||
models: ["qwen-max", "qwen-plus", "qwen-turbo"],
|
||||
models: ["qwen3-coder-plus", "qwen3-coder-flash"],
|
||||
},
|
||||
codex: {
|
||||
label: "Codex",
|
||||
@@ -99,11 +148,38 @@ export const PROVIDER_CONFIG: Record<
|
||||
},
|
||||
claude_oauth: {
|
||||
label: "Claude OAuth",
|
||||
models: ["claude-sonnet-4-5-20250929", "claude-3-5-sonnet-20241022"],
|
||||
models: [
|
||||
"claude-opus-4-5",
|
||||
"claude-opus-4-5-20251101",
|
||||
"claude-sonnet-4-5",
|
||||
"claude-sonnet-4-5-20250929",
|
||||
"claude-sonnet-4-20250514",
|
||||
"claude-haiku-4-5",
|
||||
"claude-3-7-sonnet-20250219",
|
||||
],
|
||||
},
|
||||
iflow: {
|
||||
label: "iFlow",
|
||||
models: [],
|
||||
models: [
|
||||
"tstars2.0",
|
||||
"qwen3-coder-plus",
|
||||
"qwen3-max",
|
||||
"qwen3-vl-plus",
|
||||
"qwen3-max-preview",
|
||||
"kimi-k2-0905",
|
||||
"glm-4.7",
|
||||
"glm-4.6",
|
||||
"kimi-k2",
|
||||
"kimi-k2-thinking",
|
||||
"deepseek-v3.2-chat",
|
||||
"deepseek-v3.2-reasoner",
|
||||
"deepseek-v3.2",
|
||||
"deepseek-v3.1",
|
||||
"deepseek-r1",
|
||||
"deepseek-v3",
|
||||
"minimax-m2.1",
|
||||
"minimax-m2",
|
||||
],
|
||||
},
|
||||
antigravity: {
|
||||
label: "Antigravity",
|
||||
@@ -111,6 +187,7 @@ export const PROVIDER_CONFIG: Record<
|
||||
"gemini-3-pro-preview",
|
||||
"gemini-3-pro-image-preview",
|
||||
"gemini-3-flash-preview",
|
||||
"gemini-2.5-flash",
|
||||
"gemini-2.5-computer-use-preview-10-2025",
|
||||
"gemini-claude-sonnet-4-5",
|
||||
"gemini-claude-sonnet-4-5-thinking",
|
||||
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
} from "lucide-react";
|
||||
import { LogsTab } from "./LogsTab";
|
||||
import { RoutesTab } from "./RoutesTab";
|
||||
import { EnhancedModelsTab } from "./EnhancedModelsTab";
|
||||
import { ProviderIcon } from "@/icons/providers";
|
||||
import {
|
||||
startServer,
|
||||
@@ -40,7 +41,7 @@ interface TestState {
|
||||
httpStatus?: number;
|
||||
}
|
||||
|
||||
type TabId = "server" | "routes" | "logs";
|
||||
type TabId = "server" | "routes" | "models" | "logs";
|
||||
|
||||
// 可用的 Provider 信息(合并 OAuth 凭证池和 API Key Provider)
|
||||
interface AvailableProvider {
|
||||
@@ -670,6 +671,7 @@ 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
|
||||
@@ -1017,6 +1019,9 @@ export function ApiServerPage() {
|
||||
{/* Routes Tab */}
|
||||
{activeTab === "routes" && <RoutesTab />}
|
||||
|
||||
{/* Models Tab */}
|
||||
{activeTab === "models" && <EnhancedModelsTab />}
|
||||
|
||||
{/* Logs Tab */}
|
||||
{activeTab === "logs" && <LogsTab />}
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,370 @@
|
||||
/**
|
||||
* 增强版模型列表页面
|
||||
*
|
||||
* 使用 model_registry 数据,支持搜索、收藏、分组等功能
|
||||
*/
|
||||
|
||||
import { useState } from "react";
|
||||
import {
|
||||
Cpu,
|
||||
RefreshCw,
|
||||
Copy,
|
||||
Check,
|
||||
Search,
|
||||
Star,
|
||||
Clock,
|
||||
Filter,
|
||||
Eye,
|
||||
Wrench,
|
||||
Brain,
|
||||
DollarSign,
|
||||
} from "lucide-react";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { useModelRegistry } from "@/hooks/useModelRegistry";
|
||||
import type { EnhancedModelMetadata, ModelTier } from "@/lib/types/modelRegistry";
|
||||
|
||||
export function EnhancedModelsTab() {
|
||||
const {
|
||||
models,
|
||||
preferences,
|
||||
loading,
|
||||
error,
|
||||
lastSyncAt,
|
||||
refresh,
|
||||
search,
|
||||
toggleFavorite,
|
||||
groupedByProvider,
|
||||
} = useModelRegistry();
|
||||
|
||||
const [searchQuery, setSearchQuery] = useState("");
|
||||
const [selectedProvider, setSelectedProvider] = useState<string | null>(null);
|
||||
const [selectedTier, setSelectedTier] = useState<ModelTier | null>(null);
|
||||
const [copied, setCopied] = useState<string | null>(null);
|
||||
const [showFavoritesOnly, setShowFavoritesOnly] = useState(false);
|
||||
|
||||
// 搜索和过滤
|
||||
const filteredModels = (() => {
|
||||
let result = searchQuery ? search(searchQuery) : models;
|
||||
if (selectedProvider) {
|
||||
result = result.filter((m) => m.provider_id === selectedProvider);
|
||||
}
|
||||
if (selectedTier) {
|
||||
result = result.filter((m) => m.tier === selectedTier);
|
||||
}
|
||||
if (showFavoritesOnly) {
|
||||
result = result.filter((m) => preferences.get(m.id)?.is_favorite);
|
||||
}
|
||||
return result;
|
||||
})();
|
||||
|
||||
const providers = Array.from(groupedByProvider.keys());
|
||||
|
||||
const copyModelId = (id: string) => {
|
||||
navigator.clipboard.writeText(id);
|
||||
setCopied(id);
|
||||
setTimeout(() => setCopied(null), 2000);
|
||||
};
|
||||
|
||||
const formatSyncTime = (timestamp: number | null) => {
|
||||
if (!timestamp) return "从未同步";
|
||||
return new Date(timestamp * 1000).toLocaleString("zh-CN");
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{error && (
|
||||
<div className="rounded-lg border border-red-500 bg-red-50 dark:bg-red-950 p-4 text-red-700 dark:text-red-300">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 头部信息 */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-2 text-sm text-muted-foreground">
|
||||
<Clock className="h-4 w-4" />
|
||||
<span>上次同步: {formatSyncTime(lastSyncAt)}</span>
|
||||
</div>
|
||||
<button
|
||||
onClick={refresh}
|
||||
disabled={loading}
|
||||
className="flex items-center gap-2 rounded-lg border px-4 py-2 text-sm font-medium hover:bg-muted disabled:opacity-50"
|
||||
>
|
||||
<RefreshCw className={cn("h-4 w-4", loading && "animate-spin")} />
|
||||
刷新
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* 搜索和过滤 */}
|
||||
<div className="flex flex-col gap-4">
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="relative flex-1">
|
||||
<Search className="absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
|
||||
<input
|
||||
type="text"
|
||||
placeholder="搜索模型名称、ID、Provider..."
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
className="w-full rounded-lg border bg-background pl-10 pr-4 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-primary/20"
|
||||
/>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => setShowFavoritesOnly(!showFavoritesOnly)}
|
||||
className={cn(
|
||||
"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"
|
||||
)}
|
||||
>
|
||||
<Star className={cn("h-4 w-4", showFavoritesOnly && "fill-yellow-500 text-yellow-500")} />
|
||||
收藏
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Provider 过滤 */}
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<button
|
||||
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"
|
||||
)}
|
||||
>
|
||||
全部 ({models.length})
|
||||
</button>
|
||||
{providers.map((providerId) => {
|
||||
const providerModels = groupedByProvider.get(providerId) || [];
|
||||
const providerName = providerModels[0]?.provider_name || providerId;
|
||||
return (
|
||||
<button
|
||||
key={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"
|
||||
)}
|
||||
>
|
||||
{providerName} ({providerModels.length})
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* Tier 过滤 */}
|
||||
<div className="flex items-center gap-2">
|
||||
<Filter className="h-4 w-4 text-muted-foreground" />
|
||||
<span className="text-sm text-muted-foreground">等级:</span>
|
||||
{(["mini", "pro", "max"] as ModelTier[]).map((tier) => (
|
||||
<button
|
||||
key={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"
|
||||
)}
|
||||
>
|
||||
{tier.toUpperCase()}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 模型列表 */}
|
||||
<div className="rounded-lg border bg-card">
|
||||
<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>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{loading ? (
|
||||
<div className="flex items-center justify-center py-12">
|
||||
<RefreshCw className="h-6 w-6 animate-spin text-muted-foreground" />
|
||||
</div>
|
||||
) : filteredModels.length === 0 ? (
|
||||
<div className="flex flex-col items-center justify-center py-12 text-muted-foreground">
|
||||
<Cpu className="h-12 w-12 mb-2 opacity-50" />
|
||||
<p>暂无模型数据</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="divide-y max-h-[600px] overflow-y-auto">
|
||||
{filteredModels.map((model) => (
|
||||
<ModelRow
|
||||
key={model.id}
|
||||
model={model}
|
||||
isFavorite={preferences.get(model.id)?.is_favorite || false}
|
||||
usageCount={preferences.get(model.id)?.usage_count || 0}
|
||||
copied={copied === model.id}
|
||||
onCopy={() => copyModelId(model.id)}
|
||||
onToggleFavorite={() => toggleFavorite(model.id)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 使用说明 */}
|
||||
<div className="rounded-lg border bg-card p-4">
|
||||
<h3 className="mb-2 font-semibold">使用说明</h3>
|
||||
<div className="space-y-2 text-sm text-muted-foreground">
|
||||
<p>• 模型数据来自 models.dev API 和本地配置</p>
|
||||
<p>• 点击星标可收藏常用模型,收藏的模型会优先显示</p>
|
||||
<p>• 支持按 Provider、服务等级筛选模型</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** 单个模型行 */
|
||||
function ModelRow({
|
||||
model,
|
||||
isFavorite,
|
||||
usageCount,
|
||||
copied,
|
||||
onCopy,
|
||||
onToggleFavorite,
|
||||
}: {
|
||||
model: EnhancedModelMetadata;
|
||||
isFavorite: boolean;
|
||||
usageCount: number;
|
||||
copied: boolean;
|
||||
onCopy: () => void;
|
||||
onToggleFavorite: () => void;
|
||||
}) {
|
||||
return (
|
||||
<div className="flex items-center justify-between px-4 py-3 hover:bg-muted/50">
|
||||
<div className="flex items-center gap-3 flex-1 min-w-0">
|
||||
<Cpu className="h-4 w-4 text-muted-foreground flex-shrink-0" />
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
<code className="font-medium truncate">{model.id}</code>
|
||||
<TierBadge tier={model.tier} />
|
||||
{model.is_latest && (
|
||||
<span className="text-xs px-1.5 py-0.5 rounded bg-primary/10 text-primary">
|
||||
最新
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-2 text-xs text-muted-foreground mt-0.5">
|
||||
<span>{model.provider_name}</span>
|
||||
{model.limits.context_length && (
|
||||
<>
|
||||
<span>·</span>
|
||||
<span>{formatContextLength(model.limits.context_length)}</span>
|
||||
</>
|
||||
)}
|
||||
{usageCount > 0 && (
|
||||
<>
|
||||
<span>·</span>
|
||||
<span>使用 {usageCount} 次</span>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 能力图标 */}
|
||||
<div className="flex items-center gap-1 mr-3">
|
||||
{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-green-500" />
|
||||
</span>
|
||||
)}
|
||||
{model.capabilities.reasoning && (
|
||||
<span title="支持推理">
|
||||
<Brain className="h-3.5 w-3.5 text-purple-500" />
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 定价 */}
|
||||
{model.pricing && model.pricing.input_per_million && (
|
||||
<div className="flex items-center gap-1 text-xs text-muted-foreground mr-3">
|
||||
<DollarSign className="h-3 w-3" />
|
||||
<span>{model.pricing.input_per_million.toFixed(2)}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 操作按钮 */}
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
console.log("[EnhancedModelsTab] Toggle favorite:", model.id);
|
||||
onToggleFavorite();
|
||||
}}
|
||||
className="p-2 rounded-lg hover:bg-muted transition-colors cursor-pointer"
|
||||
title={isFavorite ? "取消收藏" : "收藏"}
|
||||
>
|
||||
<Star
|
||||
className={cn(
|
||||
"h-5 w-5",
|
||||
isFavorite ? "text-yellow-500 fill-yellow-500" : "text-muted-foreground hover:text-yellow-400"
|
||||
)}
|
||||
/>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
onCopy();
|
||||
}}
|
||||
className="p-2 rounded-lg hover:bg-muted transition-colors cursor-pointer"
|
||||
title="复制模型 ID"
|
||||
>
|
||||
{copied ? (
|
||||
<Check className="h-5 w-5 text-green-500" />
|
||||
) : (
|
||||
<Copy className="h-5 w-5 text-muted-foreground hover:text-foreground" />
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** 服务等级徽章 */
|
||||
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" };
|
||||
|
||||
return (
|
||||
<span className={cn("text-xs px-1.5 py-0.5 rounded", config.color)}>
|
||||
{config.label}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
/** 获取 Tier 按钮激活状态的样式 */
|
||||
function getTierButtonActiveClass(tier: ModelTier): string {
|
||||
const classes = {
|
||||
mini: "bg-green-100 text-green-700 border-green-300 dark:bg-green-900 dark:text-green-300 dark:border-green-700",
|
||||
pro: "bg-blue-100 text-blue-700 border-blue-300 dark:bg-blue-900 dark:text-blue-300 dark:border-blue-700",
|
||||
max: "bg-purple-100 text-purple-700 border-purple-300 dark:bg-purple-900 dark:text-purple-300 dark:border-purple-700",
|
||||
};
|
||||
return classes[tier] || "bg-primary text-primary-foreground";
|
||||
}
|
||||
|
||||
/** 格式化上下文长度 */
|
||||
function formatContextLength(length: number): string {
|
||||
if (length >= 1000000) {
|
||||
return `${(length / 1000000).toFixed(1)}M`;
|
||||
}
|
||||
if (length >= 1000) {
|
||||
return `${(length / 1000).toFixed(0)}K`;
|
||||
}
|
||||
return String(length);
|
||||
}
|
||||
@@ -0,0 +1,380 @@
|
||||
/**
|
||||
* 增强版模型列表组件
|
||||
*
|
||||
* 使用 model_registry 数据,支持搜索、收藏、分组等功能
|
||||
*/
|
||||
|
||||
import { useState, useMemo } from "react";
|
||||
import {
|
||||
Check,
|
||||
AlertCircle,
|
||||
Loader2,
|
||||
Star,
|
||||
Search,
|
||||
ChevronDown,
|
||||
ChevronRight,
|
||||
Eye,
|
||||
Wrench,
|
||||
Brain,
|
||||
DollarSign,
|
||||
} from "lucide-react";
|
||||
import { cn } from "@/lib/utils";
|
||||
import type { EnhancedModelMetadata } from "@/lib/types/modelRegistry";
|
||||
|
||||
interface EnhancedModelListProps {
|
||||
/** 模型列表 */
|
||||
models: EnhancedModelMetadata[];
|
||||
/** 选中的模型 ID */
|
||||
selectedModelId?: string;
|
||||
/** 选择模型回调 */
|
||||
onSelectModel?: (model: EnhancedModelMetadata) => void;
|
||||
/** 收藏模型回调 */
|
||||
onToggleFavorite?: (modelId: string) => void;
|
||||
/** 收藏的模型 ID 集合 */
|
||||
favorites?: Set<string>;
|
||||
/** 是否加载中 */
|
||||
loading?: boolean;
|
||||
/** 错误信息 */
|
||||
error?: string | null;
|
||||
/** 是否按 Provider 分组 */
|
||||
groupByProvider?: boolean;
|
||||
/** 是否显示搜索框 */
|
||||
showSearch?: boolean;
|
||||
/** 是否显示定价信息 */
|
||||
showPricing?: boolean;
|
||||
/** 自定义类名 */
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function EnhancedModelList({
|
||||
models,
|
||||
selectedModelId,
|
||||
onSelectModel,
|
||||
onToggleFavorite,
|
||||
favorites = new Set(),
|
||||
loading = false,
|
||||
error = null,
|
||||
groupByProvider = true,
|
||||
showSearch = true,
|
||||
showPricing = false,
|
||||
className,
|
||||
}: EnhancedModelListProps) {
|
||||
const [searchQuery, setSearchQuery] = useState("");
|
||||
const [expandedGroups, setExpandedGroups] = useState<Set<string>>(
|
||||
new Set(["favorites"])
|
||||
);
|
||||
|
||||
// 过滤模型
|
||||
const filteredModels = useMemo(() => {
|
||||
if (!searchQuery.trim()) return models;
|
||||
|
||||
const query = searchQuery.toLowerCase();
|
||||
return models.filter(
|
||||
(m) =>
|
||||
m.id.toLowerCase().includes(query) ||
|
||||
m.display_name.toLowerCase().includes(query) ||
|
||||
m.provider_name.toLowerCase().includes(query) ||
|
||||
m.family?.toLowerCase().includes(query)
|
||||
);
|
||||
}, [models, searchQuery]);
|
||||
|
||||
// 按 Provider 分组
|
||||
const groupedModels = useMemo(() => {
|
||||
if (!groupByProvider) {
|
||||
return { all: filteredModels };
|
||||
}
|
||||
|
||||
const groups: Record<string, EnhancedModelMetadata[]> = {};
|
||||
|
||||
// 先添加收藏组
|
||||
const favoriteModels = filteredModels.filter((m) => favorites.has(m.id));
|
||||
if (favoriteModels.length > 0) {
|
||||
groups["favorites"] = favoriteModels;
|
||||
}
|
||||
|
||||
// 按 Provider 分组
|
||||
for (const model of filteredModels) {
|
||||
if (!groups[model.provider_id]) {
|
||||
groups[model.provider_id] = [];
|
||||
}
|
||||
groups[model.provider_id].push(model);
|
||||
}
|
||||
|
||||
return groups;
|
||||
}, [filteredModels, groupByProvider, favorites]);
|
||||
|
||||
// 切换分组展开状态
|
||||
const toggleGroup = (groupId: string) => {
|
||||
setExpandedGroups((prev) => {
|
||||
const next = new Set(prev);
|
||||
if (next.has(groupId)) {
|
||||
next.delete(groupId);
|
||||
} else {
|
||||
next.add(groupId);
|
||||
}
|
||||
return next;
|
||||
});
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className={cn("flex items-center justify-center py-8", className)}>
|
||||
<Loader2 className="h-6 w-6 animate-spin text-muted-foreground" />
|
||||
<span className="ml-2 text-muted-foreground">加载模型列表...</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"flex items-center justify-center py-8 text-destructive",
|
||||
className
|
||||
)}
|
||||
>
|
||||
<AlertCircle className="h-5 w-5 mr-2" />
|
||||
<span>{error}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (models.length === 0) {
|
||||
return (
|
||||
<div className={cn("text-center py-8 text-muted-foreground", className)}>
|
||||
<p>暂无可用模型</p>
|
||||
<p className="text-sm mt-1">请等待模型数据加载</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={cn("space-y-3", className)}>
|
||||
{/* 搜索框 */}
|
||||
{showSearch && (
|
||||
<div className="relative">
|
||||
<Search className="absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
|
||||
<input
|
||||
type="text"
|
||||
placeholder="搜索模型..."
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
className="w-full rounded-lg border bg-background pl-10 pr-4 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-primary/20"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 模型列表 */}
|
||||
<div className="space-y-2">
|
||||
{Object.entries(groupedModels).map(([groupId, groupModels]) => {
|
||||
const isExpanded = expandedGroups.has(groupId);
|
||||
const groupName = getGroupName(groupId, groupModels[0]);
|
||||
|
||||
return (
|
||||
<div key={groupId} className="border rounded-lg overflow-hidden">
|
||||
{/* 分组头部 */}
|
||||
{groupByProvider && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => toggleGroup(groupId)}
|
||||
className="w-full flex items-center justify-between px-3 py-2 bg-muted/50 hover:bg-muted transition-colors"
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
{isExpanded ? (
|
||||
<ChevronDown className="h-4 w-4" />
|
||||
) : (
|
||||
<ChevronRight className="h-4 w-4" />
|
||||
)}
|
||||
{groupId === "favorites" && (
|
||||
<Star className="h-4 w-4 text-yellow-500 fill-yellow-500" />
|
||||
)}
|
||||
<span className="font-medium text-sm">{groupName}</span>
|
||||
</div>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{groupModels.length} 个模型
|
||||
</span>
|
||||
</button>
|
||||
)}
|
||||
|
||||
{/* 模型列表 */}
|
||||
{(!groupByProvider || isExpanded) && (
|
||||
<div className="divide-y">
|
||||
{groupModels.map((model) => (
|
||||
<ModelItem
|
||||
key={model.id}
|
||||
model={model}
|
||||
isSelected={model.id === selectedModelId}
|
||||
isFavorite={favorites.has(model.id)}
|
||||
onSelect={() => onSelectModel?.(model)}
|
||||
onToggleFavorite={() => onToggleFavorite?.(model.id)}
|
||||
showPricing={showPricing}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* 无搜索结果 */}
|
||||
{filteredModels.length === 0 && searchQuery && (
|
||||
<div className="text-center py-8 text-muted-foreground">
|
||||
<p>未找到匹配的模型</p>
|
||||
<p className="text-sm mt-1">尝试其他搜索词</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** 单个模型项 */
|
||||
function ModelItem({
|
||||
model,
|
||||
isSelected,
|
||||
isFavorite,
|
||||
onSelect,
|
||||
onToggleFavorite,
|
||||
showPricing,
|
||||
}: {
|
||||
model: EnhancedModelMetadata;
|
||||
isSelected: boolean;
|
||||
isFavorite: boolean;
|
||||
onSelect: () => void;
|
||||
onToggleFavorite: () => void;
|
||||
showPricing: boolean;
|
||||
}) {
|
||||
return (
|
||||
<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"
|
||||
)}
|
||||
onClick={onSelect}
|
||||
>
|
||||
<div className="flex items-center gap-3 flex-1 min-w-0">
|
||||
{/* 选中指示器 */}
|
||||
<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 && <Check className="h-3 w-3 text-primary-foreground" />}
|
||||
</div>
|
||||
|
||||
{/* 模型信息 */}
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="font-medium text-sm truncate">
|
||||
{model.display_name}
|
||||
</span>
|
||||
{model.is_latest && (
|
||||
<span className="text-xs px-1.5 py-0.5 rounded bg-primary/10 text-primary">
|
||||
最新
|
||||
</span>
|
||||
)}
|
||||
<TierBadge tier={model.tier} />
|
||||
</div>
|
||||
<div className="flex items-center gap-2 text-xs text-muted-foreground">
|
||||
<span>{model.id}</span>
|
||||
{model.limits.context_length && (
|
||||
<>
|
||||
<span>·</span>
|
||||
<span>{formatContextLength(model.limits.context_length)}</span>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 能力标签和操作 */}
|
||||
<div className="flex items-center gap-2 flex-shrink-0">
|
||||
{/* 能力图标 */}
|
||||
<div className="flex items-center gap-1">
|
||||
{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-green-500" />
|
||||
</span>
|
||||
)}
|
||||
{model.capabilities.reasoning && (
|
||||
<span title="支持推理">
|
||||
<Brain className="h-3.5 w-3.5 text-purple-500" />
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 定价 */}
|
||||
{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>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 收藏按钮 */}
|
||||
<button
|
||||
type="button"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onToggleFavorite();
|
||||
}}
|
||||
className="p-1 rounded hover:bg-muted transition-colors"
|
||||
title={isFavorite ? "取消收藏" : "收藏"}
|
||||
>
|
||||
<Star
|
||||
className={cn(
|
||||
"h-4 w-4",
|
||||
isFavorite
|
||||
? "text-yellow-500 fill-yellow-500"
|
||||
: "text-muted-foreground"
|
||||
)}
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** 服务等级徽章 */
|
||||
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" };
|
||||
|
||||
return (
|
||||
<span className={cn("text-xs px-1.5 py-0.5 rounded", config.color)}>
|
||||
{config.label}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
/** 获取分组名称 */
|
||||
function getGroupName(
|
||||
groupId: string,
|
||||
firstModel: EnhancedModelMetadata
|
||||
): string {
|
||||
if (groupId === "favorites") return "收藏";
|
||||
if (groupId === "all") return "全部模型";
|
||||
return firstModel?.provider_name || groupId;
|
||||
}
|
||||
|
||||
/** 格式化上下文长度 */
|
||||
function formatContextLength(length: number): string {
|
||||
if (length >= 1000000) {
|
||||
return `${(length / 1000000).toFixed(1)}M`;
|
||||
}
|
||||
if (length >= 1000) {
|
||||
return `${(length / 1000).toFixed(0)}K`;
|
||||
}
|
||||
return String(length);
|
||||
}
|
||||
@@ -64,11 +64,11 @@ export function ModelList({
|
||||
return (
|
||||
<div className={cn("space-y-2", className)}>
|
||||
{models.map((model) => {
|
||||
const isSelected = model.model_id === selectedModelId;
|
||||
const isSelected = model.id === selectedModelId;
|
||||
|
||||
return (
|
||||
<button
|
||||
key={`${model.model_id}-${model.credential_id}`}
|
||||
key={`${model.id}-${model.credential_id}`}
|
||||
type="button"
|
||||
onClick={() => onSelectModel?.(model)}
|
||||
className={cn(
|
||||
|
||||
@@ -165,7 +165,7 @@ export function ModelSelector({
|
||||
{mode === "expert" && (
|
||||
<ModelList
|
||||
models={models}
|
||||
selectedModelId={selectedModel?.model_id}
|
||||
selectedModelId={selectedModel?.id}
|
||||
onSelectModel={handleModelSelect}
|
||||
loading={modelsLoading}
|
||||
error={modelsError}
|
||||
|
||||
@@ -71,6 +71,10 @@ interface RecommendedOAuthPlugin {
|
||||
downloadUrl: string;
|
||||
/** 标签 */
|
||||
tags?: string[];
|
||||
/** 是否推荐 */
|
||||
recommended?: boolean;
|
||||
/** 是否可安装(false 表示即将推出) */
|
||||
available?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -87,11 +91,13 @@ const recommendedOAuthPlugins: RecommendedOAuthPlugin[] = [
|
||||
type: "git_hub",
|
||||
owner: "aiclientproxy",
|
||||
repo: "kiro-provider",
|
||||
version: "v0.2.0",
|
||||
version: "v0.3.0",
|
||||
},
|
||||
downloadUrl:
|
||||
"https://github.com/aiclientproxy/kiro-provider/releases/download/v0.2.0/kiro-provider-plugin.zip",
|
||||
"https://github.com/aiclientproxy/kiro-provider/releases/download/v0.3.0/kiro-provider-plugin.zip",
|
||||
tags: ["anthropic", "免费"],
|
||||
recommended: true,
|
||||
available: true,
|
||||
},
|
||||
{
|
||||
id: "antigravity-provider",
|
||||
@@ -104,11 +110,13 @@ const recommendedOAuthPlugins: RecommendedOAuthPlugin[] = [
|
||||
type: "git_hub",
|
||||
owner: "aiclientproxy",
|
||||
repo: "antigravity-provider",
|
||||
version: "v0.1.0",
|
||||
version: "v0.4.0",
|
||||
},
|
||||
downloadUrl:
|
||||
"https://github.com/aiclientproxy/antigravity-provider/releases/download/v0.1.0/antigravity-provider-plugin.zip",
|
||||
"https://github.com/aiclientproxy/antigravity-provider/releases/download/v0.4.0/antigravity-provider-plugin.zip",
|
||||
tags: ["gemini", "claude", "免费"],
|
||||
recommended: true,
|
||||
available: true,
|
||||
},
|
||||
{
|
||||
id: "claude-provider",
|
||||
@@ -120,11 +128,13 @@ const recommendedOAuthPlugins: RecommendedOAuthPlugin[] = [
|
||||
type: "git_hub",
|
||||
owner: "aiclientproxy",
|
||||
repo: "claude-provider",
|
||||
version: "v0.1.0",
|
||||
version: "v0.2.0",
|
||||
},
|
||||
downloadUrl:
|
||||
"https://github.com/aiclientproxy/claude-provider/releases/download/v0.1.0/claude-provider-plugin.zip",
|
||||
"https://github.com/aiclientproxy/claude-provider/releases/download/v0.2.0/claude-provider-plugin.zip",
|
||||
tags: ["anthropic", "官方"],
|
||||
recommended: false,
|
||||
available: true,
|
||||
},
|
||||
{
|
||||
id: "droid-provider",
|
||||
@@ -137,11 +147,13 @@ const recommendedOAuthPlugins: RecommendedOAuthPlugin[] = [
|
||||
type: "git_hub",
|
||||
owner: "aiclientproxy",
|
||||
repo: "droid-provider",
|
||||
version: "v0.1.0",
|
||||
version: "v0.2.0",
|
||||
},
|
||||
downloadUrl:
|
||||
"https://github.com/aiclientproxy/droid-provider/releases/download/v0.1.0/droid-provider-plugin.zip",
|
||||
"https://github.com/aiclientproxy/droid-provider/releases/download/v0.2.0/droid-provider-plugin.zip",
|
||||
tags: ["anthropic", "openai"],
|
||||
recommended: false,
|
||||
available: true,
|
||||
},
|
||||
{
|
||||
id: "gemini-provider",
|
||||
@@ -153,11 +165,13 @@ const recommendedOAuthPlugins: RecommendedOAuthPlugin[] = [
|
||||
type: "git_hub",
|
||||
owner: "aiclientproxy",
|
||||
repo: "gemini-provider",
|
||||
version: "v0.1.0",
|
||||
version: "v0.2.0",
|
||||
},
|
||||
downloadUrl:
|
||||
"https://github.com/aiclientproxy/gemini-provider/releases/download/v0.1.0/gemini-provider-plugin.zip",
|
||||
"https://github.com/aiclientproxy/gemini-provider/releases/download/v0.2.0/gemini-provider-plugin.zip",
|
||||
tags: ["gemini", "API Key"],
|
||||
recommended: false,
|
||||
available: true,
|
||||
},
|
||||
];
|
||||
|
||||
@@ -181,13 +195,17 @@ const RecommendedPluginCard: React.FC<{
|
||||
};
|
||||
|
||||
return (
|
||||
<Card className="relative transition-shadow hover:shadow-md">
|
||||
<Badge
|
||||
className="absolute -top-2 -right-2 bg-green-500"
|
||||
variant="default"
|
||||
>
|
||||
推荐
|
||||
</Badge>
|
||||
<Card
|
||||
className={`relative transition-shadow hover:shadow-md ${!plugin.available ? "opacity-70" : ""}`}
|
||||
>
|
||||
{plugin.recommended && (
|
||||
<Badge
|
||||
className="absolute -top-2 -right-2 bg-green-500"
|
||||
variant="default"
|
||||
>
|
||||
推荐
|
||||
</Badge>
|
||||
)}
|
||||
<CardHeader className="pb-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<CardTitle className="text-base flex items-center gap-2">
|
||||
@@ -220,19 +238,25 @@ const RecommendedPluginCard: React.FC<{
|
||||
</div>
|
||||
</CardContent>
|
||||
<CardFooter className="pt-2">
|
||||
<Button className="w-full" onClick={onInstall} disabled={installing}>
|
||||
{installing ? (
|
||||
<>
|
||||
<Loader2 className="h-4 w-4 mr-2 animate-spin" />
|
||||
安装中...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Download className="h-4 w-4 mr-2" />
|
||||
一键安装
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
{plugin.available ? (
|
||||
<Button className="w-full" onClick={onInstall} disabled={installing}>
|
||||
{installing ? (
|
||||
<>
|
||||
<Loader2 className="h-4 w-4 mr-2 animate-spin" />
|
||||
安装中...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Download className="h-4 w-4 mr-2" />
|
||||
一键安装
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
) : (
|
||||
<Button className="w-full" variant="secondary" disabled>
|
||||
即将推出
|
||||
</Button>
|
||||
)}
|
||||
</CardFooter>
|
||||
</Card>
|
||||
);
|
||||
@@ -433,7 +457,10 @@ const PluginDetailView: React.FC<{
|
||||
fallback={
|
||||
<div className="flex flex-col items-center justify-center p-8 text-muted-foreground">
|
||||
<Package className="h-12 w-12 mb-4 opacity-50" />
|
||||
<p>该插件没有提供 UI</p>
|
||||
<p className="text-center">该插件暂无 UI 界面</p>
|
||||
<p className="text-center text-sm mt-2 opacity-70">
|
||||
请通过凭证池页面的「OAuth 凭证」标签管理此插件的凭证
|
||||
</p>
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
@@ -678,16 +705,53 @@ export const OAuthPluginTab: React.FC = () => {
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* 推荐插件 */}
|
||||
{/* 已安装插件 - 放在上面 */}
|
||||
{filteredPlugins.length > 0 && (
|
||||
<div className="rounded-lg border bg-card">
|
||||
<div className="p-4 border-b">
|
||||
<h4 className="font-semibold flex items-center gap-2">
|
||||
<Package className="h-4 w-4" />
|
||||
已安装插件
|
||||
</h4>
|
||||
<p className="text-sm text-muted-foreground mt-1">
|
||||
点击卡片进入插件详情,管理凭证
|
||||
</p>
|
||||
</div>
|
||||
<div className="p-4">
|
||||
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-3">
|
||||
{filteredPlugins.map((plugin) => (
|
||||
<PluginCard
|
||||
key={plugin.id}
|
||||
plugin={plugin}
|
||||
update={updates.find((u) => u.pluginId === plugin.id)}
|
||||
onSelect={() => setSelectedPluginId(plugin.id)}
|
||||
onToggle={() => handleToggle(plugin)}
|
||||
onUninstall={() => {
|
||||
setPluginToUninstall(plugin.id);
|
||||
setUninstallDialogOpen(true);
|
||||
}}
|
||||
onUpdate={
|
||||
updates.find((u) => u.pluginId === plugin.id)
|
||||
? () => update(plugin.id)
|
||||
: undefined
|
||||
}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 推荐插件 - 放在下面 */}
|
||||
{uninstalledRecommendedPlugins.length > 0 && (
|
||||
<div className="rounded-lg border bg-card">
|
||||
<div className="p-4 border-b">
|
||||
<h4 className="font-semibold flex items-center gap-2">
|
||||
<Download className="h-4 w-4" />
|
||||
推荐 OAuth Provider 插件
|
||||
可安装的 OAuth Provider 插件
|
||||
</h4>
|
||||
<p className="text-sm text-muted-foreground mt-1">
|
||||
一键安装推荐的 OAuth Provider 插件,快速扩展支持的 AI 服务
|
||||
一键安装 OAuth Provider 插件,快速扩展支持的 AI 服务
|
||||
</p>
|
||||
</div>
|
||||
<div className="p-4">
|
||||
@@ -705,45 +769,25 @@ export const OAuthPluginTab: React.FC = () => {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 插件列表 */}
|
||||
{filteredPlugins.length === 0 ? (
|
||||
<div className="flex flex-col items-center justify-center p-16 border rounded-lg border-dashed">
|
||||
<Package className="h-12 w-12 text-muted-foreground mb-4" />
|
||||
<p className="text-muted-foreground mb-2">
|
||||
{searchQuery ? "没有找到匹配的插件" : "暂无已安装的插件"}
|
||||
</p>
|
||||
{!searchQuery && (
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => setInstallDialogOpen(true)}
|
||||
>
|
||||
<Plus className="h-4 w-4 mr-1" />
|
||||
安装第一个插件
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-3">
|
||||
{filteredPlugins.map((plugin) => (
|
||||
<PluginCard
|
||||
key={plugin.id}
|
||||
plugin={plugin}
|
||||
update={updates.find((u) => u.pluginId === plugin.id)}
|
||||
onSelect={() => setSelectedPluginId(plugin.id)}
|
||||
onToggle={() => handleToggle(plugin)}
|
||||
onUninstall={() => {
|
||||
setPluginToUninstall(plugin.id);
|
||||
setUninstallDialogOpen(true);
|
||||
}}
|
||||
onUpdate={
|
||||
updates.find((u) => u.pluginId === plugin.id)
|
||||
? () => update(plugin.id)
|
||||
: undefined
|
||||
}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
{/* 空状态 - 没有已安装插件且没有推荐插件 */}
|
||||
{filteredPlugins.length === 0 &&
|
||||
uninstalledRecommendedPlugins.length === 0 && (
|
||||
<div className="flex flex-col items-center justify-center p-16 border rounded-lg border-dashed">
|
||||
<Package className="h-12 w-12 text-muted-foreground mb-4" />
|
||||
<p className="text-muted-foreground mb-2">
|
||||
{searchQuery ? "没有找到匹配的插件" : "暂无可用的插件"}
|
||||
</p>
|
||||
{!searchQuery && (
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => setInstallDialogOpen(true)}
|
||||
>
|
||||
<Plus className="h-4 w-4 mr-1" />
|
||||
手动安装插件
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 安装对话框 */}
|
||||
<InstallPluginDialog
|
||||
|
||||
@@ -31,12 +31,11 @@ import { AddCredentialModal } from "./AddCredentialModal";
|
||||
import { EditCredentialModal } from "./EditCredentialModal";
|
||||
import { ErrorDisplay, useErrorDisplay } from "./ErrorDisplay";
|
||||
import { ConfirmDialog } from "@/components/ConfirmDialog";
|
||||
import { getConfig, saveConfig, Config } from "@/hooks/useTauri";
|
||||
import { VertexAISection } from "./VertexAISection";
|
||||
import { AmpConfigSection } from "./AmpConfigSection";
|
||||
import { getConfig } from "@/hooks/useTauri";
|
||||
import { ProviderIcon } from "@/icons/providers";
|
||||
import { ApiKeyProviderSection, AddCustomProviderModal } from "./api-key";
|
||||
import { OAuthPluginTab } from "./OAuthPluginTab";
|
||||
import { RelayProvidersSection } from "./RelayProvidersSection";
|
||||
import type { AddCustomProviderRequest } from "@/lib/api/apiKeyProvider";
|
||||
import {
|
||||
getLocalKiroCredentialUuid,
|
||||
@@ -60,8 +59,8 @@ const oauthProviderTypes: PoolProviderType[] = [
|
||||
"iflow",
|
||||
];
|
||||
|
||||
// 配置类型 tab(非凭证池,存储在配置文件中)
|
||||
type ConfigTabType = "vertex" | "amp";
|
||||
// 配置类型 tab(非凭证池)
|
||||
type ConfigTabType = "connect";
|
||||
|
||||
// 所有 tab 类型
|
||||
type TabType = PoolProviderType | ConfigTabType;
|
||||
@@ -79,18 +78,13 @@ const providerLabels: Record<PoolProviderType, string> = {
|
||||
gemini_api_key: "Gemini",
|
||||
};
|
||||
|
||||
const configTabLabels: Record<ConfigTabType, string> = {
|
||||
vertex: "Vertex AI",
|
||||
amp: "Amp CLI",
|
||||
};
|
||||
|
||||
// 判断是否为配置类型 tab
|
||||
const isConfigTab = (tab: TabType): tab is ConfigTabType => {
|
||||
return ["vertex", "amp"].includes(tab);
|
||||
return tab === "connect";
|
||||
};
|
||||
|
||||
// 分类类型
|
||||
type CategoryType = "oauth" | "apikey" | "plugins" | "config";
|
||||
type CategoryType = "oauth" | "apikey" | "plugins" | "connect";
|
||||
|
||||
export const ProviderPoolPage = forwardRef<ProviderPoolPageRef>(
|
||||
(_props, ref) => {
|
||||
@@ -137,45 +131,6 @@ export const ProviderPoolPage = forwardRef<ProviderPoolPageRef>(
|
||||
// Kiro 本地活跃凭证 UUID
|
||||
const [localActiveUuid, setLocalActiveUuid] = useState<string | null>(null);
|
||||
|
||||
// 配置 tab 相关状态
|
||||
const [config, setConfig] = useState<Config | null>(null);
|
||||
const [configLoading, setConfigLoading] = useState(false);
|
||||
const [configSaving, setConfigSaving] = useState(false);
|
||||
|
||||
// 加载配置
|
||||
const loadConfig = async () => {
|
||||
setConfigLoading(true);
|
||||
try {
|
||||
const c = await getConfig();
|
||||
setConfig(c);
|
||||
} catch (e) {
|
||||
console.error("Failed to load config:", e);
|
||||
showError("加载配置失败", "config");
|
||||
}
|
||||
setConfigLoading(false);
|
||||
};
|
||||
|
||||
// 保存配置
|
||||
const handleSaveConfig = async () => {
|
||||
if (!config) return;
|
||||
setConfigSaving(true);
|
||||
try {
|
||||
await saveConfig(config);
|
||||
showSuccess("配置已保存");
|
||||
} catch (e) {
|
||||
showError(e instanceof Error ? e.message : String(e), "config");
|
||||
}
|
||||
setConfigSaving(false);
|
||||
};
|
||||
|
||||
// 切换到配置 tab 时加载配置
|
||||
useEffect(() => {
|
||||
if (isConfigTab(activeTab)) {
|
||||
loadConfig();
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [activeTab]);
|
||||
|
||||
// 获取本地活跃的 Kiro 凭证 UUID
|
||||
const fetchLocalActiveUuid = async () => {
|
||||
try {
|
||||
@@ -443,17 +398,17 @@ export const ProviderPoolPage = forwardRef<ProviderPoolPageRef>(
|
||||
</button>
|
||||
<button
|
||||
onClick={() => {
|
||||
setActiveCategory("config");
|
||||
setActiveTab("vertex");
|
||||
setActiveCategory("connect");
|
||||
setActiveTab("connect");
|
||||
}}
|
||||
className={`px-4 py-2 text-sm font-medium rounded-lg border transition-colors ${
|
||||
activeCategory === "config"
|
||||
activeCategory === "connect"
|
||||
? "border-primary bg-primary/10 text-primary"
|
||||
: "border-border bg-card text-muted-foreground hover:text-foreground hover:bg-muted"
|
||||
}`}
|
||||
data-testid="config-category-tab"
|
||||
data-testid="connect-category-tab"
|
||||
>
|
||||
其他配置
|
||||
Connect
|
||||
</button>
|
||||
</div>
|
||||
|
||||
@@ -497,26 +452,10 @@ export const ProviderPoolPage = forwardRef<ProviderPoolPageRef>(
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 其他配置分类 - 配置 Tab 选择 */}
|
||||
{activeCategory === "config" && (
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{(["vertex", "amp"] as const).map((tabId) => {
|
||||
const isActive = activeTab === tabId;
|
||||
return (
|
||||
<button
|
||||
key={tabId}
|
||||
onClick={() => setActiveTab(tabId)}
|
||||
className={`min-w-[120px] px-3 py-2 rounded-lg border text-sm font-medium transition-all ${
|
||||
isActive
|
||||
? "border-primary bg-primary/10 text-primary shadow-sm"
|
||||
: "border-border bg-card hover:border-primary/50 hover:bg-muted text-muted-foreground hover:text-foreground"
|
||||
}`}
|
||||
data-testid={`config-tab-${tabId}`}
|
||||
>
|
||||
{configTabLabels[tabId]}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
{/* Connect 分类 - 中转商列表 */}
|
||||
{activeCategory === "connect" && (
|
||||
<div className="min-h-[400px]" data-testid="connect-section">
|
||||
<RelayProvidersSection />
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -539,67 +478,6 @@ export const ProviderPoolPage = forwardRef<ProviderPoolPageRef>(
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 配置 Tab 内容 */}
|
||||
{activeCategory === "config" &&
|
||||
isConfigTab(activeTab) &&
|
||||
(configLoading ? (
|
||||
<div className="flex items-center justify-center py-12">
|
||||
<RefreshCw className="h-6 w-6 animate-spin text-muted-foreground" />
|
||||
</div>
|
||||
) : config ? (
|
||||
<div className="space-y-4">
|
||||
{activeTab === "vertex" && (
|
||||
<>
|
||||
<VertexAISection
|
||||
entries={config.credential_pool?.vertex_api_keys ?? []}
|
||||
onChange={(entries) =>
|
||||
setConfig({
|
||||
...config,
|
||||
credential_pool: {
|
||||
...config.credential_pool,
|
||||
vertex_api_keys: entries,
|
||||
},
|
||||
})
|
||||
}
|
||||
/>
|
||||
{(config.credential_pool?.vertex_api_keys?.length ?? 0) >
|
||||
0 && (
|
||||
<button
|
||||
onClick={handleSaveConfig}
|
||||
disabled={configSaving}
|
||||
className="w-full px-4 py-2 rounded-lg bg-primary text-primary-foreground text-sm font-medium hover:bg-primary/90 disabled:opacity-50"
|
||||
>
|
||||
{configSaving ? "保存中..." : "保存配置"}
|
||||
</button>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
{activeTab === "amp" && (
|
||||
<AmpConfigSection
|
||||
config={
|
||||
config.ampcode ?? {
|
||||
upstream_url: null,
|
||||
model_mappings: [],
|
||||
restrict_management_to_localhost: false,
|
||||
}
|
||||
}
|
||||
onChange={(ampConfig) =>
|
||||
setConfig({
|
||||
...config,
|
||||
ampcode: ampConfig,
|
||||
})
|
||||
}
|
||||
onSave={handleSaveConfig}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex items-center justify-center py-12 text-muted-foreground">
|
||||
加载配置失败
|
||||
</div>
|
||||
))}
|
||||
|
||||
{/* OAuth 凭证内容 - 卡片布局 */}
|
||||
{activeCategory === "oauth" &&
|
||||
!isConfigTab(activeTab) &&
|
||||
|
||||
@@ -13,11 +13,13 @@
|
||||
| `EditCredentialModal.tsx` | 编辑凭证模态框组件 |
|
||||
| `ErrorDisplay.tsx` | 错误显示组件 |
|
||||
| `UsageDisplay.tsx` | 用量显示组件 |
|
||||
| `RelayProvidersSection.tsx` | Connect 中转商列表组件,展示已验证的中转服务商 |
|
||||
| `VertexAISection.tsx` | Vertex AI 配置区域组件 |
|
||||
| `AmpConfigSection.tsx` | Amp CLI 配置区域组件 |
|
||||
| `GeminiApiKeySection.tsx` | Gemini API Key 配置区域组件 |
|
||||
| `CodexSection.tsx` | Codex 配置区域组件 |
|
||||
| `IFlowSection.tsx` | iFlow 配置区域组件 |
|
||||
| `OAuthPluginTab.tsx` | OAuth 插件标签页组件 |
|
||||
| `index.ts` | 组件导出入口 |
|
||||
|
||||
## 子目录
|
||||
@@ -50,10 +52,12 @@ function App() {
|
||||
- Requirements 1.1: API Key Provider 左右分栏布局
|
||||
- Requirements 2.1, 2.2, 2.3: OAuth 凭证保持卡片布局
|
||||
- Requirements 3.1-3.6: 完整支持 System Provider 类型
|
||||
- Connect: 中转商浏览和一键添加功能
|
||||
|
||||
## 架构说明
|
||||
|
||||
ProviderPoolPage 支持三种分类:
|
||||
ProviderPoolPage 支持四种分类:
|
||||
1. **OAuth 凭证** - 使用卡片式布局显示 OAuth 类型凭证
|
||||
2. **API Key** - 使用左右分栏布局(ApiKeyProviderSection)
|
||||
3. **其他配置** - Vertex AI 和 Amp CLI 配置
|
||||
3. **OAuth 插件** - 第三方 OAuth 插件管理
|
||||
4. **Connect** - 中转商列表,支持浏览和一键获取 API Key
|
||||
|
||||
@@ -0,0 +1,257 @@
|
||||
/**
|
||||
* @file RelayProvidersSection 组件
|
||||
* @description 中转商列表展示组件,支持浏览和一键跳转获取 API Key
|
||||
* @module components/provider-pool/RelayProvidersSection
|
||||
*
|
||||
* _Requirements: Connect 中转商浏览功能_
|
||||
*/
|
||||
|
||||
import { useState } from "react";
|
||||
import {
|
||||
RefreshCw,
|
||||
ExternalLink,
|
||||
Globe,
|
||||
Mail,
|
||||
MessageCircle,
|
||||
Shield,
|
||||
ShieldCheck,
|
||||
Zap,
|
||||
Clock,
|
||||
AlertCircle,
|
||||
} from "lucide-react";
|
||||
import { useRelayRegistry } from "@/hooks/useRelayRegistry";
|
||||
import type { RelayInfo } from "@/hooks/useDeepLink";
|
||||
import { open } from "@tauri-apps/plugin-shell";
|
||||
|
||||
/**
|
||||
* 中转商卡片组件
|
||||
*/
|
||||
function RelayProviderCard({ provider }: { provider: RelayInfo }) {
|
||||
const [imageError, setImageError] = useState(false);
|
||||
|
||||
// 打开外部链接
|
||||
const handleOpenLink = async (url: string) => {
|
||||
try {
|
||||
await open(url);
|
||||
} catch (e) {
|
||||
console.error("打开链接失败:", e);
|
||||
// 回退到 window.open
|
||||
window.open(url, "_blank");
|
||||
}
|
||||
};
|
||||
|
||||
// 获取 API Key 的链接(优先使用 dashboard,其次 website)
|
||||
const getApiKeyLink = () => {
|
||||
return provider.links.dashboard || provider.links.website;
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="rounded-lg border bg-card p-4 hover:border-primary/50 transition-colors">
|
||||
{/* 头部:Logo + 名称 */}
|
||||
<div className="flex items-start gap-3 mb-3">
|
||||
{/* Logo */}
|
||||
<div className="flex-shrink-0 w-12 h-12 rounded-lg bg-muted flex items-center justify-center overflow-hidden">
|
||||
{provider.branding.logo && !imageError ? (
|
||||
<img
|
||||
src={provider.branding.logo}
|
||||
alt={provider.name}
|
||||
className="w-full h-full object-contain"
|
||||
onError={() => setImageError(true)}
|
||||
/>
|
||||
) : (
|
||||
<Globe className="w-6 h-6 text-muted-foreground" />
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 名称和描述 */}
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2">
|
||||
<h3 className="font-semibold text-foreground truncate">
|
||||
{provider.name}
|
||||
</h3>
|
||||
{provider.features.verified && (
|
||||
<ShieldCheck className="w-4 h-4 text-green-500 flex-shrink-0" />
|
||||
)}
|
||||
</div>
|
||||
<p className="text-sm text-muted-foreground line-clamp-2 mt-0.5">
|
||||
{provider.description}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* API 信息 */}
|
||||
<div className="flex flex-wrap gap-2 mb-3">
|
||||
<span className="inline-flex items-center gap-1 px-2 py-0.5 rounded-full bg-primary/10 text-primary text-xs">
|
||||
<Zap className="w-3 h-3" />
|
||||
{provider.api.protocol.toUpperCase()}
|
||||
</span>
|
||||
{provider.features.streaming && (
|
||||
<span className="inline-flex items-center gap-1 px-2 py-0.5 rounded-full bg-blue-500/10 text-blue-500 text-xs">
|
||||
流式响应
|
||||
</span>
|
||||
)}
|
||||
{provider.features.models && provider.features.models.length > 0 && (
|
||||
<span className="inline-flex items-center gap-1 px-2 py-0.5 rounded-full bg-muted text-muted-foreground text-xs">
|
||||
{provider.features.models.length} 个模型
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 功能特性 */}
|
||||
{provider.features.models && provider.features.models.length > 0 && (
|
||||
<div className="mb-3">
|
||||
<p className="text-xs text-muted-foreground mb-1">支持模型:</p>
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{provider.features.models.slice(0, 5).map((model) => (
|
||||
<span
|
||||
key={model}
|
||||
className="px-1.5 py-0.5 rounded bg-muted text-xs text-muted-foreground"
|
||||
>
|
||||
{model}
|
||||
</span>
|
||||
))}
|
||||
{provider.features.models.length > 5 && (
|
||||
<span className="px-1.5 py-0.5 rounded bg-muted text-xs text-muted-foreground">
|
||||
+{provider.features.models.length - 5}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 操作按钮 */}
|
||||
<div className="flex items-center gap-2 pt-3 border-t">
|
||||
{getApiKeyLink() && (
|
||||
<button
|
||||
onClick={() => handleOpenLink(getApiKeyLink()!)}
|
||||
className="flex-1 flex items-center justify-center gap-2 px-3 py-2 rounded-lg bg-primary text-primary-foreground text-sm font-medium hover:bg-primary/90 transition-colors"
|
||||
>
|
||||
<ExternalLink className="w-4 h-4" />
|
||||
获取 API Key
|
||||
</button>
|
||||
)}
|
||||
|
||||
{provider.links.docs && (
|
||||
<button
|
||||
onClick={() => handleOpenLink(provider.links.docs!)}
|
||||
className="flex items-center justify-center gap-1 px-3 py-2 rounded-lg border text-sm hover:bg-muted transition-colors"
|
||||
title="查看文档"
|
||||
>
|
||||
<Globe className="w-4 h-4" />
|
||||
</button>
|
||||
)}
|
||||
|
||||
{provider.contact.email && (
|
||||
<button
|
||||
onClick={() => handleOpenLink(`mailto:${provider.contact.email}`)}
|
||||
className="flex items-center justify-center gap-1 px-3 py-2 rounded-lg border text-sm hover:bg-muted transition-colors"
|
||||
title="联系邮箱"
|
||||
>
|
||||
<Mail className="w-4 h-4" />
|
||||
</button>
|
||||
)}
|
||||
|
||||
{provider.contact.discord && (
|
||||
<button
|
||||
onClick={() => handleOpenLink(provider.contact.discord!)}
|
||||
className="flex items-center justify-center gap-1 px-3 py-2 rounded-lg border text-sm hover:bg-muted transition-colors"
|
||||
title="Discord"
|
||||
>
|
||||
<MessageCircle className="w-4 h-4" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 中转商列表组件
|
||||
*/
|
||||
export function RelayProvidersSection() {
|
||||
const { providers, isLoading, error, refresh } = useRelayRegistry();
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{/* 头部说明 */}
|
||||
<div className="flex items-start justify-between">
|
||||
<div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Shield className="w-5 h-5 text-primary" />
|
||||
<h3 className="font-semibold">ProxyCast Connect</h3>
|
||||
</div>
|
||||
<p className="text-sm text-muted-foreground mt-1">
|
||||
浏览已验证的 AI API 中转服务商,获取 API Key
|
||||
后可通过链接一键添加到凭证池
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={refresh}
|
||||
disabled={isLoading}
|
||||
className="flex items-center gap-2 px-3 py-1.5 rounded-lg border text-sm hover:bg-muted disabled:opacity-50 transition-colors"
|
||||
>
|
||||
<RefreshCw className={`w-4 h-4 ${isLoading ? "animate-spin" : ""}`} />
|
||||
刷新
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* 错误提示 */}
|
||||
{error && (
|
||||
<div className="flex items-center gap-2 p-3 rounded-lg bg-yellow-500/10 border border-yellow-500/20 text-yellow-600 dark:text-yellow-400">
|
||||
<AlertCircle className="w-4 h-4 flex-shrink-0" />
|
||||
<span className="text-sm">{error.message}</span>
|
||||
<button
|
||||
onClick={refresh}
|
||||
className="ml-auto text-sm underline hover:no-underline"
|
||||
>
|
||||
重试
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 加载状态 */}
|
||||
{isLoading && providers.length === 0 && (
|
||||
<div className="flex items-center justify-center py-12">
|
||||
<RefreshCw className="w-6 h-6 animate-spin text-muted-foreground" />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 空状态 */}
|
||||
{!isLoading && providers.length === 0 && !error && (
|
||||
<div className="flex flex-col items-center justify-center py-12 text-muted-foreground">
|
||||
<Clock className="w-12 h-12 mb-4 opacity-50" />
|
||||
<p className="text-lg">暂无中转商</p>
|
||||
<p className="text-sm mt-1">点击刷新按钮加载中转商列表</p>
|
||||
<button
|
||||
onClick={refresh}
|
||||
className="mt-4 flex items-center gap-2 px-4 py-2 rounded-lg bg-primary text-primary-foreground text-sm hover:bg-primary/90"
|
||||
>
|
||||
<RefreshCw className="w-4 h-4" />
|
||||
加载中转商
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 中转商列表 */}
|
||||
{providers.length > 0 && (
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
{providers.map((provider) => (
|
||||
<RelayProviderCard key={provider.id} provider={provider} />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 底部说明 */}
|
||||
{providers.length > 0 && (
|
||||
<div className="text-center text-xs text-muted-foreground pt-4 border-t">
|
||||
<p>
|
||||
获取 API Key 后,中转商会提供一个 <code>proxycast://</code>{" "}
|
||||
链接,点击即可一键添加到凭证池
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default RelayProvidersSection;
|
||||
@@ -1,6 +1,7 @@
|
||||
export { useFlowEvents } from "./useFlowEvents";
|
||||
export { useOAuthPlugins, useSingleOAuthPlugin } from "./useOAuthPlugins";
|
||||
export { useDeepLink } from "./useDeepLink";
|
||||
export { useModelRegistry } from "./useModelRegistry";
|
||||
export type {
|
||||
ConnectPayload,
|
||||
RelayInfo,
|
||||
|
||||
@@ -54,6 +54,10 @@ export interface RelayLinks {
|
||||
docs?: string;
|
||||
/** 状态页链接 */
|
||||
status?: string;
|
||||
/** 控制台/仪表盘链接 */
|
||||
dashboard?: string;
|
||||
/** 网站链接 */
|
||||
website?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -92,6 +96,8 @@ export interface RelayFeatures {
|
||||
function_calling: boolean;
|
||||
/** 是否支持视觉模型 */
|
||||
vision: boolean;
|
||||
/** 是否已验证 */
|
||||
verified?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -0,0 +1,333 @@
|
||||
/**
|
||||
* 模型注册表 Hook
|
||||
*
|
||||
* 提供模型数据管理、搜索、收藏等功能
|
||||
*/
|
||||
|
||||
import { useState, useEffect, useCallback, useMemo } from "react";
|
||||
import { modelRegistryApi } from "@/lib/api/modelRegistry";
|
||||
import type {
|
||||
EnhancedModelMetadata,
|
||||
UserModelPreference,
|
||||
ModelTier,
|
||||
} from "@/lib/types/modelRegistry";
|
||||
|
||||
interface UseModelRegistryOptions {
|
||||
/** 自动加载 */
|
||||
autoLoad?: boolean;
|
||||
/** 过滤的 Provider ID 列表 */
|
||||
providerFilter?: string[];
|
||||
/** 过滤的服务等级 */
|
||||
tierFilter?: ModelTier[];
|
||||
/** 只显示收藏 */
|
||||
favoritesOnly?: boolean;
|
||||
}
|
||||
|
||||
interface UseModelRegistryReturn {
|
||||
/** 模型列表 */
|
||||
models: EnhancedModelMetadata[];
|
||||
/** 用户偏好 */
|
||||
preferences: Map<string, UserModelPreference>;
|
||||
/** 是否加载中 */
|
||||
loading: boolean;
|
||||
/** 错误信息 */
|
||||
error: string | null;
|
||||
/** 最后同步时间 */
|
||||
lastSyncAt: number | null;
|
||||
/** 刷新模型列表 */
|
||||
refresh: () => Promise<void>;
|
||||
/** 搜索模型 */
|
||||
search: (query: string) => EnhancedModelMetadata[];
|
||||
/** 切换收藏 */
|
||||
toggleFavorite: (modelId: string) => Promise<void>;
|
||||
/** 隐藏模型 */
|
||||
hideModel: (modelId: string) => Promise<void>;
|
||||
/** 获取模型详情 */
|
||||
getModel: (modelId: string) => EnhancedModelMetadata | undefined;
|
||||
/** 按 Provider 分组 */
|
||||
groupedByProvider: Map<string, EnhancedModelMetadata[]>;
|
||||
/** 按等级分组 */
|
||||
groupedByTier: Map<ModelTier, EnhancedModelMetadata[]>;
|
||||
}
|
||||
|
||||
/**
|
||||
* 智能排序函数
|
||||
*/
|
||||
function sortModels(
|
||||
models: EnhancedModelMetadata[],
|
||||
preferences: Map<string, UserModelPreference>
|
||||
): EnhancedModelMetadata[] {
|
||||
return [...models].sort((a, b) => {
|
||||
const prefA = preferences.get(a.id);
|
||||
const prefB = preferences.get(b.id);
|
||||
|
||||
// 1. 收藏优先
|
||||
if (prefA?.is_favorite && !prefB?.is_favorite) return -1;
|
||||
if (!prefA?.is_favorite && prefB?.is_favorite) return 1;
|
||||
|
||||
// 2. 最新版本优先
|
||||
if (a.is_latest && !b.is_latest) return -1;
|
||||
if (!a.is_latest && b.is_latest) return 1;
|
||||
|
||||
// 3. 活跃状态优先
|
||||
if (a.status === "active" && b.status !== "active") return -1;
|
||||
if (a.status !== "active" && b.status === "active") return 1;
|
||||
|
||||
// 4. 使用频率
|
||||
const usageA = prefA?.usage_count || 0;
|
||||
const usageB = prefB?.usage_count || 0;
|
||||
if (usageA !== usageB) return usageB - usageA;
|
||||
|
||||
// 5. 按名称字母序
|
||||
return a.display_name.localeCompare(b.display_name);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 简单的模糊搜索
|
||||
*/
|
||||
function fuzzySearch(
|
||||
models: EnhancedModelMetadata[],
|
||||
query: string
|
||||
): EnhancedModelMetadata[] {
|
||||
if (!query.trim()) {
|
||||
return models;
|
||||
}
|
||||
|
||||
const queryLower = query.toLowerCase();
|
||||
|
||||
return models
|
||||
.map((model) => {
|
||||
let score = 0;
|
||||
|
||||
// 精确匹配 ID
|
||||
if (model.id.toLowerCase() === queryLower) {
|
||||
score += 100;
|
||||
} else if (model.id.toLowerCase().includes(queryLower)) {
|
||||
score += 50;
|
||||
}
|
||||
|
||||
// 显示名称匹配
|
||||
if (model.display_name.toLowerCase().includes(queryLower)) {
|
||||
score += 30;
|
||||
}
|
||||
|
||||
// Provider 匹配
|
||||
if (model.provider_name.toLowerCase().includes(queryLower)) {
|
||||
score += 20;
|
||||
}
|
||||
|
||||
// 家族匹配
|
||||
if (model.family?.toLowerCase().includes(queryLower)) {
|
||||
score += 15;
|
||||
}
|
||||
|
||||
// 最新版本加分
|
||||
if (model.is_latest) {
|
||||
score += 5;
|
||||
}
|
||||
|
||||
// 活跃状态加分
|
||||
if (model.status === "active") {
|
||||
score += 3;
|
||||
}
|
||||
|
||||
return { model, score };
|
||||
})
|
||||
.filter(({ score }) => score > 0)
|
||||
.sort((a, b) => b.score - a.score)
|
||||
.map(({ model }) => model);
|
||||
}
|
||||
|
||||
export function useModelRegistry(
|
||||
options: UseModelRegistryOptions = {}
|
||||
): UseModelRegistryReturn {
|
||||
const {
|
||||
autoLoad = true,
|
||||
providerFilter,
|
||||
tierFilter,
|
||||
favoritesOnly = false,
|
||||
} = options;
|
||||
|
||||
const [allModels, setAllModels] = useState<EnhancedModelMetadata[]>([]);
|
||||
const [preferences, setPreferences] = useState<
|
||||
Map<string, UserModelPreference>
|
||||
>(new Map());
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [lastSyncAt, setLastSyncAt] = useState<number | null>(null);
|
||||
|
||||
// 加载模型数据
|
||||
const loadModels = useCallback(async () => {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
const [models, prefs, syncState] = await Promise.all([
|
||||
modelRegistryApi.getModelRegistry(),
|
||||
modelRegistryApi.getModelPreferences(),
|
||||
modelRegistryApi.getModelSyncState(),
|
||||
]);
|
||||
|
||||
setAllModels(models);
|
||||
setPreferences(new Map(prefs.map((p) => [p.model_id, p])));
|
||||
setLastSyncAt(syncState.last_sync_at);
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : String(e));
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
// 刷新(强制从 models.dev 获取)
|
||||
const refresh = useCallback(async () => {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
await modelRegistryApi.refreshModelRegistry();
|
||||
await loadModels();
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : String(e));
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [loadModels]);
|
||||
|
||||
// 过滤后的模型列表
|
||||
const models = useMemo(() => {
|
||||
let filtered = allModels;
|
||||
|
||||
// Provider 过滤
|
||||
if (providerFilter && providerFilter.length > 0) {
|
||||
filtered = filtered.filter((m) => providerFilter.includes(m.provider_id));
|
||||
}
|
||||
|
||||
// 等级过滤
|
||||
if (tierFilter && tierFilter.length > 0) {
|
||||
filtered = filtered.filter((m) =>
|
||||
tierFilter.includes(m.tier as ModelTier)
|
||||
);
|
||||
}
|
||||
|
||||
// 收藏过滤
|
||||
if (favoritesOnly) {
|
||||
filtered = filtered.filter((m) => preferences.get(m.id)?.is_favorite);
|
||||
}
|
||||
|
||||
// 隐藏过滤
|
||||
filtered = filtered.filter((m) => !preferences.get(m.id)?.is_hidden);
|
||||
|
||||
// 智能排序
|
||||
return sortModels(filtered, preferences);
|
||||
}, [allModels, providerFilter, tierFilter, favoritesOnly, preferences]);
|
||||
|
||||
// 模糊搜索
|
||||
const search = useCallback(
|
||||
(query: string): EnhancedModelMetadata[] => {
|
||||
return fuzzySearch(models, query);
|
||||
},
|
||||
[models]
|
||||
);
|
||||
|
||||
// 切换收藏
|
||||
const toggleFavorite = useCallback(async (modelId: string) => {
|
||||
try {
|
||||
const newState = await modelRegistryApi.toggleModelFavorite(modelId);
|
||||
setPreferences((prev) => {
|
||||
const newPrefs = new Map(prev);
|
||||
const current = newPrefs.get(modelId);
|
||||
if (current) {
|
||||
newPrefs.set(modelId, {
|
||||
...current,
|
||||
is_favorite: newState,
|
||||
});
|
||||
} else {
|
||||
newPrefs.set(modelId, {
|
||||
model_id: modelId,
|
||||
is_favorite: newState,
|
||||
is_hidden: false,
|
||||
custom_alias: null,
|
||||
usage_count: 0,
|
||||
last_used_at: null,
|
||||
created_at: Date.now() / 1000,
|
||||
updated_at: Date.now() / 1000,
|
||||
});
|
||||
}
|
||||
return newPrefs;
|
||||
});
|
||||
} catch (e) {
|
||||
console.error("Failed to toggle favorite:", e);
|
||||
}
|
||||
}, []);
|
||||
|
||||
// 隐藏模型
|
||||
const hideModel = useCallback(async (modelId: string) => {
|
||||
try {
|
||||
await modelRegistryApi.hideModel(modelId);
|
||||
setPreferences((prev) => {
|
||||
const newPrefs = new Map(prev);
|
||||
const current = newPrefs.get(modelId);
|
||||
if (current) {
|
||||
newPrefs.set(modelId, { ...current, is_hidden: true });
|
||||
}
|
||||
return newPrefs;
|
||||
});
|
||||
} catch (e) {
|
||||
console.error("Failed to hide model:", e);
|
||||
}
|
||||
}, []);
|
||||
|
||||
// 获取单个模型
|
||||
const getModel = useCallback(
|
||||
(modelId: string) => {
|
||||
return allModels.find((m) => m.id === modelId);
|
||||
},
|
||||
[allModels]
|
||||
);
|
||||
|
||||
// 按 Provider 分组
|
||||
const groupedByProvider = useMemo(() => {
|
||||
const groups = new Map<string, EnhancedModelMetadata[]>();
|
||||
for (const model of models) {
|
||||
const existing = groups.get(model.provider_id) || [];
|
||||
existing.push(model);
|
||||
groups.set(model.provider_id, existing);
|
||||
}
|
||||
return groups;
|
||||
}, [models]);
|
||||
|
||||
// 按等级分组
|
||||
const groupedByTier = useMemo(() => {
|
||||
const groups = new Map<ModelTier, EnhancedModelMetadata[]>();
|
||||
for (const model of models) {
|
||||
const tier = model.tier as ModelTier;
|
||||
const existing = groups.get(tier) || [];
|
||||
existing.push(model);
|
||||
groups.set(tier, existing);
|
||||
}
|
||||
return groups;
|
||||
}, [models]);
|
||||
|
||||
// 自动加载
|
||||
useEffect(() => {
|
||||
if (autoLoad) {
|
||||
loadModels();
|
||||
}
|
||||
}, [autoLoad, loadModels]);
|
||||
|
||||
return {
|
||||
models,
|
||||
preferences,
|
||||
loading,
|
||||
error,
|
||||
lastSyncAt,
|
||||
refresh,
|
||||
search,
|
||||
toggleFavorite,
|
||||
hideModel,
|
||||
getModel,
|
||||
groupedByProvider,
|
||||
groupedByTier,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,114 @@
|
||||
/**
|
||||
* 模型注册表 API
|
||||
*
|
||||
* 提供与后端 ModelRegistryService 交互的 API
|
||||
*/
|
||||
|
||||
import { invoke } from "@tauri-apps/api/core";
|
||||
import type {
|
||||
EnhancedModelMetadata,
|
||||
ModelSyncState,
|
||||
ModelTier,
|
||||
UserModelPreference,
|
||||
} from "@/lib/types/modelRegistry";
|
||||
|
||||
/**
|
||||
* 获取所有模型
|
||||
*/
|
||||
export async function getModelRegistry(): Promise<EnhancedModelMetadata[]> {
|
||||
return invoke("get_model_registry");
|
||||
}
|
||||
|
||||
/**
|
||||
* 刷新模型注册表(从 models.dev 获取最新数据)
|
||||
*/
|
||||
export async function refreshModelRegistry(): Promise<void> {
|
||||
return invoke("refresh_model_registry");
|
||||
}
|
||||
|
||||
/**
|
||||
* 搜索模型
|
||||
* @param query 搜索关键词
|
||||
* @param limit 返回数量限制
|
||||
*/
|
||||
export async function searchModels(
|
||||
query: string,
|
||||
limit?: number
|
||||
): Promise<EnhancedModelMetadata[]> {
|
||||
return invoke("search_models", { query, limit });
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取用户模型偏好
|
||||
*/
|
||||
export async function getModelPreferences(): Promise<UserModelPreference[]> {
|
||||
return invoke("get_model_preferences");
|
||||
}
|
||||
|
||||
/**
|
||||
* 切换模型收藏状态
|
||||
* @param modelId 模型 ID
|
||||
* @returns 新的收藏状态
|
||||
*/
|
||||
export async function toggleModelFavorite(modelId: string): Promise<boolean> {
|
||||
return invoke("toggle_model_favorite", { modelId });
|
||||
}
|
||||
|
||||
/**
|
||||
* 隐藏模型
|
||||
* @param modelId 模型 ID
|
||||
*/
|
||||
export async function hideModel(modelId: string): Promise<void> {
|
||||
return invoke("hide_model", { modelId });
|
||||
}
|
||||
|
||||
/**
|
||||
* 记录模型使用
|
||||
* @param modelId 模型 ID
|
||||
*/
|
||||
export async function recordModelUsage(modelId: string): Promise<void> {
|
||||
return invoke("record_model_usage", { modelId });
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取模型同步状态
|
||||
*/
|
||||
export async function getModelSyncState(): Promise<ModelSyncState> {
|
||||
return invoke("get_model_sync_state");
|
||||
}
|
||||
|
||||
/**
|
||||
* 按 Provider 获取模型
|
||||
* @param providerId Provider ID
|
||||
*/
|
||||
export async function getModelsForProvider(
|
||||
providerId: string
|
||||
): Promise<EnhancedModelMetadata[]> {
|
||||
return invoke("get_models_for_provider", { providerId });
|
||||
}
|
||||
|
||||
/**
|
||||
* 按服务等级获取模型
|
||||
* @param tier 服务等级
|
||||
*/
|
||||
export async function getModelsByTier(
|
||||
tier: ModelTier
|
||||
): Promise<EnhancedModelMetadata[]> {
|
||||
return invoke("get_models_by_tier", { tier });
|
||||
}
|
||||
|
||||
/**
|
||||
* 模型注册表 API 对象
|
||||
*/
|
||||
export const modelRegistryApi = {
|
||||
getModelRegistry,
|
||||
refreshModelRegistry,
|
||||
searchModels,
|
||||
getModelPreferences,
|
||||
toggleModelFavorite,
|
||||
hideModel,
|
||||
recordModelUsage,
|
||||
getModelSyncState,
|
||||
getModelsForProvider,
|
||||
getModelsByTier,
|
||||
};
|
||||
+26
-16
@@ -27,23 +27,29 @@ export type TaskHint =
|
||||
/** 可用模型 */
|
||||
export interface AvailableModel {
|
||||
/** 模型 ID */
|
||||
model_id: string;
|
||||
id: string;
|
||||
/** 显示名称 */
|
||||
display_name: string;
|
||||
/** Provider 类型 */
|
||||
provider_type: string;
|
||||
/** 模型家族 */
|
||||
family?: string;
|
||||
/** 凭证 ID */
|
||||
credential_id: string;
|
||||
/** 上下文长度 */
|
||||
context_length?: number;
|
||||
/** 是否支持视觉 */
|
||||
supports_vision: boolean;
|
||||
/** 是否支持工具 */
|
||||
supports_tools: boolean;
|
||||
/** 输入价格(每 1M tokens) */
|
||||
input_cost_per_million?: number;
|
||||
/** 输出价格(每 1M tokens) */
|
||||
output_cost_per_million?: number;
|
||||
/** 是否健康 */
|
||||
is_healthy: boolean;
|
||||
/** 当前负载 (0-100) */
|
||||
current_load?: number;
|
||||
/** 上下文长度 */
|
||||
context_length?: number;
|
||||
/** 是否支持视觉 */
|
||||
supports_vision?: boolean;
|
||||
/** 是否支持工具 */
|
||||
supports_tools?: boolean;
|
||||
}
|
||||
|
||||
/** 模型池统计 */
|
||||
@@ -62,16 +68,20 @@ export interface PoolStats {
|
||||
|
||||
/** 选择结果 */
|
||||
export interface SelectionResult {
|
||||
/** 选中的模型 ID */
|
||||
model_id: string;
|
||||
/** Provider 类型 */
|
||||
provider_type: string;
|
||||
/** 凭证 ID */
|
||||
credential_id: string;
|
||||
/** 使用的策略 */
|
||||
strategy_used: string;
|
||||
/** 选中的模型 */
|
||||
model: AvailableModel;
|
||||
/** 使用的策略 ID */
|
||||
strategy_id: string;
|
||||
/** 选择原因 */
|
||||
reason?: string;
|
||||
reason: string;
|
||||
/** 置信度 (0-100) */
|
||||
confidence: number;
|
||||
/** 服务等级 */
|
||||
tier: ServiceTier;
|
||||
/** 是否是降级选择 */
|
||||
is_fallback: boolean;
|
||||
/** 降级原因(如果是降级) */
|
||||
fallback_reason?: string;
|
||||
}
|
||||
|
||||
/** 选择请求 */
|
||||
|
||||
@@ -117,6 +117,24 @@ export function PluginUIRenderer({
|
||||
|
||||
// 错误
|
||||
if (error) {
|
||||
// 检查是否是文件不存在的错误
|
||||
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}`}>
|
||||
<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>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`flex flex-col items-center justify-center p-8 ${className}`}
|
||||
|
||||
@@ -31,6 +31,47 @@ export interface PluginModule {
|
||||
*/
|
||||
const loadedPlugins = new Map<string, PluginModule>();
|
||||
|
||||
/**
|
||||
* 插件 ID 到全局变量名的映射
|
||||
* 格式: pluginId -> GlobalVariableName
|
||||
*/
|
||||
const PLUGIN_GLOBAL_NAMES: Record<string, string> = {
|
||||
"kiro-provider": "KiroProviderPlugin",
|
||||
"droid-provider": "DroidProviderPlugin",
|
||||
"claude-provider": "ClaudeProviderPlugin",
|
||||
"gemini-provider": "GeminiProviderPlugin",
|
||||
"antigravity-provider": "AntigravityProviderPlugin",
|
||||
};
|
||||
|
||||
/**
|
||||
* 根据插件 ID 获取全局变量名
|
||||
* 如果没有预定义,则尝试从路径推断
|
||||
*/
|
||||
function getPluginGlobalName(pluginPath: string): string {
|
||||
// 从路径中提取插件 ID
|
||||
const parts = pluginPath.split("/");
|
||||
const pluginIdIndex = parts.findIndex((p) => p.endsWith("-provider"));
|
||||
const pluginId = pluginIdIndex >= 0 ? parts[pluginIdIndex] : null;
|
||||
|
||||
// 查找预定义的全局变量名
|
||||
if (pluginId && PLUGIN_GLOBAL_NAMES[pluginId]) {
|
||||
return PLUGIN_GLOBAL_NAMES[pluginId];
|
||||
}
|
||||
|
||||
// 尝试从插件 ID 推断全局变量名
|
||||
// 例如: my-plugin -> MyPluginPlugin
|
||||
if (pluginId) {
|
||||
const camelCase = pluginId
|
||||
.split("-")
|
||||
.map((part) => part.charAt(0).toUpperCase() + part.slice(1))
|
||||
.join("");
|
||||
return camelCase;
|
||||
}
|
||||
|
||||
// 默认回退
|
||||
return "KiroProviderPlugin";
|
||||
}
|
||||
|
||||
/**
|
||||
* 读取插件文件内容
|
||||
*/
|
||||
@@ -92,22 +133,26 @@ export async function loadPluginUI(
|
||||
// 读取插件文件内容
|
||||
const content = await readPluginFile(pluginPath);
|
||||
|
||||
// 获取插件的全局变量名
|
||||
const globalName = getPluginGlobalName(pluginPath);
|
||||
|
||||
console.log(`[PluginLoader] 加载插件: ${pluginPath}`);
|
||||
console.log(`[PluginLoader] 全局变量名: ${globalName}`);
|
||||
console.log(
|
||||
`[PluginLoader] 全局变量检查: React=${typeof (window as unknown as Record<string, unknown>).React}, ProxyCastPluginComponents=${typeof (window as unknown as Record<string, unknown>).ProxyCastPluginComponents}`,
|
||||
);
|
||||
|
||||
// 执行插件代码
|
||||
// IIFE 格式会自动将结果赋值给 window.KiroProviderPlugin
|
||||
await executeScript(content);
|
||||
|
||||
// 获取插件模块
|
||||
const pluginExports = (window as unknown as Record<string, unknown>)
|
||||
.KiroProviderPlugin as Record<string, unknown> | undefined;
|
||||
const pluginExports = (window as unknown as Record<string, unknown>)[
|
||||
globalName
|
||||
] as Record<string, unknown> | undefined;
|
||||
|
||||
if (!pluginExports) {
|
||||
console.error(
|
||||
`[PluginLoader] 插件 ${pluginPath} 没有导出到 window.KiroProviderPlugin`,
|
||||
`[PluginLoader] 插件 ${pluginPath} 没有导出到 window.${globalName}`,
|
||||
);
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,144 @@
|
||||
/**
|
||||
* 模型注册表类型定义
|
||||
*
|
||||
* 借鉴 opencode 的模型管理方式,定义增强的模型元数据结构
|
||||
*/
|
||||
|
||||
/** 模型能力 */
|
||||
export interface ModelCapabilities {
|
||||
/** 是否支持视觉输入 */
|
||||
vision: boolean;
|
||||
/** 是否支持工具调用 */
|
||||
tools: boolean;
|
||||
/** 是否支持流式输出 */
|
||||
streaming: boolean;
|
||||
/** 是否支持 JSON 模式 */
|
||||
json_mode: boolean;
|
||||
/** 是否支持函数调用 */
|
||||
function_calling: boolean;
|
||||
/** 是否支持推理/思考 */
|
||||
reasoning: boolean;
|
||||
}
|
||||
|
||||
/** 模型定价 */
|
||||
export interface ModelPricing {
|
||||
/** 输入价格(每百万 token) */
|
||||
input_per_million: number | null;
|
||||
/** 输出价格(每百万 token) */
|
||||
output_per_million: number | null;
|
||||
/** 缓存读取价格(每百万 token) */
|
||||
cache_read_per_million: number | null;
|
||||
/** 缓存写入价格(每百万 token) */
|
||||
cache_write_per_million: number | null;
|
||||
/** 货币单位 */
|
||||
currency: string;
|
||||
}
|
||||
|
||||
/** 模型限制 */
|
||||
export interface ModelLimits {
|
||||
/** 上下文长度 */
|
||||
context_length: number | null;
|
||||
/** 最大输出 token 数 */
|
||||
max_output_tokens: number | null;
|
||||
/** 每分钟请求数限制 */
|
||||
requests_per_minute: number | null;
|
||||
/** 每分钟 token 数限制 */
|
||||
tokens_per_minute: number | null;
|
||||
}
|
||||
|
||||
/** 模型状态 */
|
||||
export type ModelStatus =
|
||||
| "active"
|
||||
| "preview"
|
||||
| "alpha"
|
||||
| "beta"
|
||||
| "deprecated"
|
||||
| "legacy";
|
||||
|
||||
/** 模型服务等级 */
|
||||
export type ModelTier = "mini" | "pro" | "max";
|
||||
|
||||
/** 模型数据来源 */
|
||||
export type ModelSource = "models.dev" | "local" | "custom";
|
||||
|
||||
/** 增强的模型元数据 */
|
||||
export interface EnhancedModelMetadata {
|
||||
/** 模型 ID (如 "claude-sonnet-4-5-20250514") */
|
||||
id: string;
|
||||
/** 显示名称 (如 "Claude Sonnet 4.5") */
|
||||
display_name: string;
|
||||
/** Provider ID (如 "anthropic", "openai", "dashscope") */
|
||||
provider_id: string;
|
||||
/** Provider 显示名称 */
|
||||
provider_name: string;
|
||||
/** 模型家族 (如 "sonnet", "gpt-4", "qwen") */
|
||||
family: string | null;
|
||||
/** 服务等级 */
|
||||
tier: ModelTier;
|
||||
/** 模型能力 */
|
||||
capabilities: ModelCapabilities;
|
||||
/** 定价信息 */
|
||||
pricing: ModelPricing | null;
|
||||
/** 限制信息 */
|
||||
limits: ModelLimits;
|
||||
/** 模型状态 */
|
||||
status: ModelStatus;
|
||||
/** 发布日期 */
|
||||
release_date: string | null;
|
||||
/** 是否为最新版本 */
|
||||
is_latest: boolean;
|
||||
/** 描述 */
|
||||
description: string | null;
|
||||
/** 数据来源 */
|
||||
source: ModelSource;
|
||||
/** 创建时间 (Unix 时间戳) */
|
||||
created_at: number;
|
||||
/** 最后更新时间 (Unix 时间戳) */
|
||||
updated_at: number;
|
||||
}
|
||||
|
||||
/** 用户模型偏好 */
|
||||
export interface UserModelPreference {
|
||||
/** 模型 ID */
|
||||
model_id: string;
|
||||
/** 是否收藏 */
|
||||
is_favorite: boolean;
|
||||
/** 是否隐藏 */
|
||||
is_hidden: boolean;
|
||||
/** 自定义别名 */
|
||||
custom_alias: string | null;
|
||||
/** 使用次数 */
|
||||
usage_count: number;
|
||||
/** 最后使用时间 (Unix 时间戳) */
|
||||
last_used_at: number | null;
|
||||
/** 创建时间 (Unix 时间戳) */
|
||||
created_at: number;
|
||||
/** 更新时间 (Unix 时间戳) */
|
||||
updated_at: number;
|
||||
}
|
||||
|
||||
/** 模型同步状态 */
|
||||
export interface ModelSyncState {
|
||||
/** 最后同步时间 (Unix 时间戳) */
|
||||
last_sync_at: number | null;
|
||||
/** 同步的模型数量 */
|
||||
model_count: number;
|
||||
/** 是否正在同步 */
|
||||
is_syncing: boolean;
|
||||
/** 最后同步错误 */
|
||||
last_error: string | null;
|
||||
}
|
||||
|
||||
/** 模型注册表状态 */
|
||||
export interface ModelRegistryState {
|
||||
/** 模型列表 */
|
||||
models: EnhancedModelMetadata[];
|
||||
/** 用户偏好 */
|
||||
preferences: Map<string, UserModelPreference>;
|
||||
/** 最后同步时间 */
|
||||
lastSyncAt: number | null;
|
||||
/** 是否加载中 */
|
||||
isLoading: boolean;
|
||||
/** 错误信息 */
|
||||
error: string | null;
|
||||
}
|
||||
Reference in New Issue
Block a user