diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 2f92afb4c..b2d5f4821 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -13,6 +13,8 @@ on: permissions: contents: write + packages: write + actions: read env: CARGO_INCREMENTAL: 0 diff --git a/src-tauri/src/app/runner.rs b/src-tauri/src/app/runner.rs index 513797757..4879a68c2 100644 --- a/src-tauri/src/app/runner.rs +++ b/src-tauri/src/app/runner.rs @@ -814,6 +814,16 @@ pub fn run() { commands::native_agent_cmd::native_agent_get_session, commands::native_agent_cmd::native_agent_delete_session, commands::native_agent_cmd::native_agent_list_sessions, + // Models config commands + commands::models_cmd::get_models_config, + commands::models_cmd::save_models_config, + commands::models_cmd::get_provider_models, + commands::models_cmd::get_all_provider_models, + commands::models_cmd::add_model_to_provider, + commands::models_cmd::remove_model_from_provider, + commands::models_cmd::toggle_model_enabled, + commands::models_cmd::add_provider, + commands::models_cmd::remove_provider, // Network commands commands::network_cmd::get_network_info, // OAuth Plugin commands diff --git a/src-tauri/src/commands/mod.rs b/src-tauri/src/commands/mod.rs index bddda51be..6529bde21 100644 --- a/src-tauri/src/commands/mod.rs +++ b/src-tauri/src/commands/mod.rs @@ -8,6 +8,7 @@ pub mod injection_cmd; pub mod kiro_local; pub mod machine_id_cmd; pub mod mcp_cmd; +pub mod models_cmd; pub mod native_agent_cmd; pub mod network_cmd; pub mod oauth_cmd; diff --git a/src-tauri/src/commands/models_cmd.rs b/src-tauri/src/commands/models_cmd.rs new file mode 100644 index 000000000..1447654e8 --- /dev/null +++ b/src-tauri/src/commands/models_cmd.rs @@ -0,0 +1,200 @@ +//! 模型配置命令模块 +//! +//! 提供动态模型配置的 Tauri 命令 + +use crate::config::{save_config, ModelInfo, ModelsConfig, ProviderModelsConfig}; +use crate::AppState; +use serde::{Deserialize, Serialize}; +use std::collections::HashMap; +use tauri::State; + +/// 获取模型配置 +#[tauri::command] +pub async fn get_models_config(app_state: State<'_, AppState>) -> Result { + let state = app_state.read().await; + Ok(state.config.models.clone()) +} + +/// 保存模型配置 +#[tauri::command] +pub async fn save_models_config( + app_state: State<'_, AppState>, + config: ModelsConfig, +) -> Result<(), String> { + let mut state = app_state.write().await; + state.config.models = config; + // 保存配置到文件 + save_config(&state.config).map_err(|e| e.to_string())?; + Ok(()) +} + +/// 获取指定 Provider 的模型列表 +#[tauri::command] +pub async fn get_provider_models( + app_state: State<'_, AppState>, + provider: String, +) -> Result, String> { + let state = app_state.read().await; + let models = state + .config + .models + .providers + .get(&provider) + .map(|p| { + p.models + .iter() + .filter(|m| m.enabled) + .map(|m| m.id.clone()) + .collect() + }) + .unwrap_or_default(); + Ok(models) +} + +/// 简化的 Provider 配置(用于前端) +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct SimpleProviderConfig { + pub label: String, + pub models: Vec, +} + +/// 获取所有 Provider 的简化配置(用于前端下拉框) +#[tauri::command] +pub async fn get_all_provider_models( + app_state: State<'_, AppState>, +) -> Result, String> { + let state = app_state.read().await; + let result: HashMap = state + .config + .models + .providers + .iter() + .map(|(key, value)| { + ( + key.clone(), + SimpleProviderConfig { + label: value.label.clone(), + models: value + .models + .iter() + .filter(|m| m.enabled) + .map(|m| m.id.clone()) + .collect(), + }, + ) + }) + .collect(); + Ok(result) +} + +/// 添加模型到指定 Provider +#[tauri::command] +pub async fn add_model_to_provider( + app_state: State<'_, AppState>, + provider: String, + model_id: String, + model_name: Option, +) -> Result<(), String> { + let mut state = app_state.write().await; + + if let Some(provider_config) = state.config.models.providers.get_mut(&provider) { + // 检查是否已存在 + if provider_config.models.iter().any(|m| m.id == model_id) { + return Err(format!("模型 {} 已存在于 {} 中", model_id, provider)); + } + provider_config.models.push(ModelInfo { + id: model_id, + name: model_name, + enabled: true, + }); + } else { + return Err(format!("Provider {} 不存在", provider)); + } + + save_config(&state.config).map_err(|e| e.to_string())?; + Ok(()) +} + +/// 从指定 Provider 移除模型 +#[tauri::command] +pub async fn remove_model_from_provider( + app_state: State<'_, AppState>, + provider: String, + model_id: String, +) -> Result<(), String> { + let mut state = app_state.write().await; + + if let Some(provider_config) = state.config.models.providers.get_mut(&provider) { + provider_config.models.retain(|m| m.id != model_id); + } else { + return Err(format!("Provider {} 不存在", provider)); + } + + save_config(&state.config).map_err(|e| e.to_string())?; + Ok(()) +} + +/// 切换模型启用状态 +#[tauri::command] +pub async fn toggle_model_enabled( + app_state: State<'_, AppState>, + provider: String, + model_id: String, + enabled: bool, +) -> Result<(), String> { + let mut state = app_state.write().await; + + if let Some(provider_config) = state.config.models.providers.get_mut(&provider) { + if let Some(model) = provider_config.models.iter_mut().find(|m| m.id == model_id) { + model.enabled = enabled; + } else { + return Err(format!("模型 {} 不存在于 {} 中", model_id, provider)); + } + } else { + return Err(format!("Provider {} 不存在", provider)); + } + + save_config(&state.config).map_err(|e| e.to_string())?; + Ok(()) +} + +/// 添加新的 Provider +#[tauri::command] +pub async fn add_provider( + app_state: State<'_, AppState>, + provider_id: String, + label: String, +) -> Result<(), String> { + let mut state = app_state.write().await; + + if state.config.models.providers.contains_key(&provider_id) { + return Err(format!("Provider {} 已存在", provider_id)); + } + + state.config.models.providers.insert( + provider_id, + ProviderModelsConfig { + label, + models: vec![], + }, + ); + + save_config(&state.config).map_err(|e| e.to_string())?; + Ok(()) +} + +/// 移除 Provider +#[tauri::command] +pub async fn remove_provider( + app_state: State<'_, AppState>, + provider_id: String, +) -> Result<(), String> { + let mut state = app_state.write().await; + + if state.config.models.providers.remove(&provider_id).is_none() { + return Err(format!("Provider {} 不存在", provider_id)); + } + + save_config(&state.config).map_err(|e| e.to_string())?; + Ok(()) +} diff --git a/src-tauri/src/config/mod.rs b/src-tauri/src/config/mod.rs index 718506c45..78138d951 100644 --- a/src-tauri/src/config/mod.rs +++ b/src-tauri/src/config/mod.rs @@ -21,9 +21,10 @@ pub use path_utils::{collapse_tilde, contains_tilde, expand_tilde}; pub use types::{ generate_secure_api_key, AmpConfig, AmpModelMapping, ApiKeyEntry, Config, CredentialEntry, CredentialPoolConfig, CustomProviderConfig, EndpointProvidersConfig, GeminiApiKeyEntry, - IFlowCredentialEntry, InjectionRuleConfig, InjectionSettings, LoggingConfig, ProviderConfig, - ProvidersConfig, QuotaExceededConfig, RemoteManagementConfig, RetrySettings, RoutingConfig, - ServerConfig, TlsConfig, VertexApiKeyEntry, VertexModelAlias, DEFAULT_API_KEY, + IFlowCredentialEntry, InjectionRuleConfig, InjectionSettings, LoggingConfig, ModelInfo, + ModelsConfig, ProviderConfig, ProviderModelsConfig, ProvidersConfig, QuotaExceededConfig, + RemoteManagementConfig, RetrySettings, RoutingConfig, RoutingRuleConfig, ServerConfig, + TlsConfig, VertexApiKeyEntry, VertexModelAlias, DEFAULT_API_KEY, }; pub use yaml::{load_config, save_config, ConfigError, ConfigManager, YamlService}; diff --git a/src-tauri/src/config/tests.rs b/src-tauri/src/config/tests.rs index b5c2a14c9..22e93d842 100644 --- a/src-tauri/src/config/tests.rs +++ b/src-tauri/src/config/tests.rs @@ -219,6 +219,7 @@ fn arb_config() -> impl Strategy { ampcode: crate::config::AmpConfig::default(), endpoint_providers: crate::config::EndpointProvidersConfig::default(), minimize_to_tray: true, + models: crate::config::ModelsConfig::default(), }) } @@ -492,6 +493,7 @@ fn arb_valid_config() -> impl Strategy { ampcode: crate::config::AmpConfig::default(), endpoint_providers: crate::config::EndpointProvidersConfig::default(), minimize_to_tray: true, + models: crate::config::ModelsConfig::default(), }) } @@ -537,6 +539,7 @@ fn arb_invalid_config() -> impl Strategy { ampcode: crate::config::AmpConfig::default(), endpoint_providers: crate::config::EndpointProvidersConfig::default(), minimize_to_tray: true, + models: crate::config::ModelsConfig::default(), }; // 根据类型使配置无效 match invalid_type { diff --git a/src-tauri/src/config/types.rs b/src-tauri/src/config/types.rs index 04fad8c35..105751366 100644 --- a/src-tauri/src/config/types.rs +++ b/src-tauri/src/config/types.rs @@ -311,6 +311,9 @@ pub struct Config { /// 关闭时最小化到托盘(而不是退出应用) #[serde(default = "default_minimize_to_tray")] pub minimize_to_tray: bool, + /// 模型配置(动态加载 Provider 和模型列表) + #[serde(default)] + pub models: ModelsConfig, } fn default_minimize_to_tray() -> bool { @@ -712,6 +715,317 @@ impl Default for LoggingConfig { } } +// ============ 模型配置类型 ============ + +/// 模型信息 +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +pub struct ModelInfo { + /// 模型 ID + pub id: String, + /// 模型显示名称(可选) + #[serde(default, skip_serializing_if = "Option::is_none")] + pub name: Option, + /// 是否启用 + #[serde(default = "default_model_enabled")] + pub enabled: bool, +} + +fn default_model_enabled() -> bool { + true +} + +/// Provider 模型配置 +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +pub struct ProviderModelsConfig { + /// Provider 显示标签 + pub label: String, + /// 模型列表 + #[serde(default)] + pub models: Vec, +} + +/// 模型配置(顶层) +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +pub struct ModelsConfig { + /// 是否从 models.dev 获取模型列表(预留功能) + #[serde(default)] + pub fetch_from_models_dev: bool, + /// models.dev 缓存 TTL(秒) + #[serde(default = "default_cache_ttl_secs")] + pub cache_ttl_secs: u64, + /// Provider 模型配置 + #[serde(default)] + pub providers: HashMap, +} + +fn default_cache_ttl_secs() -> u64 { + 3600 +} + +impl Default for ModelsConfig { + fn default() -> Self { + let mut providers = HashMap::new(); + + // Claude (直连 Anthropic API) + providers.insert( + "claude".to_string(), + ProviderModelsConfig { + label: "Claude".to_string(), + models: vec![ + ModelInfo { + id: "claude-opus-4-5-20251101".to_string(), + name: None, + enabled: true, + }, + ModelInfo { + id: "claude-sonnet-4-5-20250929".to_string(), + name: None, + enabled: true, + }, + ModelInfo { + id: "claude-sonnet-4-20250514".to_string(), + name: None, + enabled: true, + }, + ], + }, + ); + + // Anthropic (API Key Provider) + providers.insert( + "anthropic".to_string(), + ProviderModelsConfig { + label: "Anthropic".to_string(), + models: vec![ + ModelInfo { + id: "claude-opus-4-5-20251101".to_string(), + name: None, + enabled: true, + }, + ModelInfo { + id: "claude-sonnet-4-5-20250929".to_string(), + name: None, + enabled: true, + }, + ModelInfo { + id: "claude-sonnet-4-20250514".to_string(), + name: None, + enabled: true, + }, + ], + }, + ); + + // Kiro + providers.insert( + "kiro".to_string(), + ProviderModelsConfig { + label: "Kiro".to_string(), + models: vec![ + ModelInfo { + id: "claude-sonnet-4-5-20250929".to_string(), + name: None, + enabled: true, + }, + ModelInfo { + id: "claude-sonnet-4-20250514".to_string(), + name: None, + enabled: true, + }, + ], + }, + ); + + // OpenAI + providers.insert( + "openai".to_string(), + ProviderModelsConfig { + label: "OpenAI".to_string(), + models: vec![ + ModelInfo { + id: "gpt-4o".to_string(), + name: None, + enabled: true, + }, + ModelInfo { + id: "gpt-4o-mini".to_string(), + name: None, + enabled: true, + }, + ModelInfo { + id: "gpt-4-turbo".to_string(), + name: None, + enabled: true, + }, + ModelInfo { + id: "o1".to_string(), + name: None, + enabled: true, + }, + ModelInfo { + id: "o1-mini".to_string(), + name: None, + enabled: true, + }, + ModelInfo { + id: "o3".to_string(), + name: None, + enabled: true, + }, + ModelInfo { + id: "o3-mini".to_string(), + name: None, + enabled: true, + }, + ], + }, + ); + + // Gemini + providers.insert( + "gemini".to_string(), + ProviderModelsConfig { + label: "Gemini".to_string(), + models: vec![ + ModelInfo { + id: "gemini-2.0-flash-exp".to_string(), + name: None, + enabled: true, + }, + ModelInfo { + id: "gemini-1.5-pro".to_string(), + name: None, + enabled: true, + }, + ModelInfo { + id: "gemini-1.5-flash".to_string(), + name: None, + enabled: true, + }, + ], + }, + ); + + // Qwen + providers.insert( + "qwen".to_string(), + ProviderModelsConfig { + label: "通义千问".to_string(), + models: vec![ + ModelInfo { + id: "qwen-max".to_string(), + name: None, + enabled: true, + }, + ModelInfo { + id: "qwen-plus".to_string(), + name: None, + enabled: true, + }, + ModelInfo { + id: "qwen-turbo".to_string(), + name: None, + enabled: true, + }, + ], + }, + ); + + // Codex + providers.insert( + "codex".to_string(), + ProviderModelsConfig { + label: "Codex".to_string(), + models: vec![ModelInfo { + id: "codex-mini-latest".to_string(), + name: None, + enabled: true, + }], + }, + ); + + // Claude OAuth + providers.insert( + "claude_oauth".to_string(), + ProviderModelsConfig { + label: "Claude OAuth".to_string(), + models: vec![ + ModelInfo { + id: "claude-sonnet-4-5-20250929".to_string(), + name: None, + enabled: true, + }, + ModelInfo { + id: "claude-3-5-sonnet-20241022".to_string(), + name: None, + enabled: true, + }, + ], + }, + ); + + // iFlow + providers.insert( + "iflow".to_string(), + ProviderModelsConfig { + label: "iFlow".to_string(), + models: vec![], + }, + ); + + // Antigravity + providers.insert( + "antigravity".to_string(), + ProviderModelsConfig { + label: "Antigravity".to_string(), + models: vec![ + ModelInfo { + id: "gemini-3-pro-preview".to_string(), + name: None, + enabled: true, + }, + ModelInfo { + id: "gemini-3-pro-image-preview".to_string(), + name: None, + enabled: true, + }, + ModelInfo { + id: "gemini-3-flash-preview".to_string(), + name: None, + enabled: true, + }, + ModelInfo { + id: "gemini-2.5-computer-use-preview-10-2025".to_string(), + name: None, + enabled: true, + }, + ModelInfo { + id: "gemini-claude-sonnet-4-5".to_string(), + name: None, + enabled: true, + }, + ModelInfo { + id: "gemini-claude-sonnet-4-5-thinking".to_string(), + name: None, + enabled: true, + }, + ModelInfo { + id: "gemini-claude-opus-4-5-thinking".to_string(), + name: None, + enabled: true, + }, + ], + }, + ); + + Self { + fetch_from_models_dev: false, + cache_ttl_secs: default_cache_ttl_secs(), + providers, + } + } +} + /// 参数注入配置 #[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] pub struct InjectionSettings { @@ -801,6 +1115,7 @@ impl Default for Config { ampcode: AmpConfig::default(), endpoint_providers: EndpointProvidersConfig::default(), minimize_to_tray: default_minimize_to_tray(), + models: ModelsConfig::default(), } } } diff --git a/src-tauri/src/server/handlers/provider_calls.rs b/src-tauri/src/server/handlers/provider_calls.rs index faf0c8864..ee83e4478 100644 --- a/src-tauri/src/server/handlers/provider_calls.rs +++ b/src-tauri/src/server/handlers/provider_calls.rs @@ -780,297 +780,129 @@ pub async fn call_provider_anthropic( } // Anthropic API Key - 根据 base_url 决定调用方式 CredentialData::AnthropicKey { api_key, base_url } => { - // 如果有自定义 base_url,假设是 OpenAI 兼容的代理服务器 - // 需要将 Anthropic 请求转换为 OpenAI 请求,然后将响应转换回来 - if let Some(custom_url) = base_url { - state.logs.write().await.add( - "info", - &format!( - "[ANTHROPIC_COMPAT] 使用 OpenAI 兼容 API: base_url={} credential_uuid={} stream={}", - custom_url, - &credential.uuid[..8], - request.stream - ), - ); + // 使用 Anthropic 原生格式调用(无论是否有自定义 base_url) + let claude = ClaudeCustomProvider::with_config(api_key.clone(), base_url.clone()); + let request_url = claude.get_base_url(); + state.logs.write().await.add( + "info", + &format!( + "[ANTHROPIC] 使用 Anthropic API: base_url={} credential_uuid={} stream={}", + request_url, + &credential.uuid[..8], + request.stream + ), + ); + match claude.call_api(request).await { + Ok(resp) => { + let status = resp.status(); + state.logs.write().await.add( + "info", + &format!( + "[ANTHROPIC] 响应状态: status={} model={} stream={}", + status, + request.model, + request.stream + ), + ); - // 将 Anthropic 请求转换为 OpenAI 请求 - let openai_request = crate::converter::anthropic_to_openai::convert_anthropic_to_openai(request); - - // 使用 OpenAI 兼容 API 调用 - let openai = OpenAICustomProvider::with_config(api_key.clone(), Some(custom_url.clone())); - match openai.call_api(&openai_request).await { - Ok(resp) => { - let status = resp.status(); + // 如果是流式请求,直接透传流式响应 + if request.stream && status.is_success() { state.logs.write().await.add( "info", - &format!( - "[ANTHROPIC_COMPAT] 响应状态: status={} model={} stream={}", - status, - request.model, - request.stream - ), + "[ANTHROPIC] 流式请求,透传 SSE 响应", ); - - // 流式请求暂不支持格式转换,直接透传 OpenAI SSE 格式 - // TODO: 实现 OpenAI SSE -> Anthropic SSE 的流式转换 - if request.stream && status.is_success() { - state.logs.write().await.add( - "info", - "[ANTHROPIC_COMPAT] 流式请求,透传 OpenAI SSE 响应(暂不支持格式转换)", - ); - if let Some(db) = &state.db { - let _ = state.pool_service.mark_healthy( - db, - &credential.uuid, - Some(&request.model), - ); - let _ = state.pool_service.record_usage(db, &credential.uuid); - } - - // 直接透传 OpenAI SSE 流 - let stream = resp.bytes_stream(); - return Response::builder() - .status(StatusCode::OK) - .header(header::CONTENT_TYPE, "text/event-stream") - .header(header::CACHE_CONTROL, "no-cache") - .header("Connection", "keep-alive") - .body(Body::from_stream(stream)) - .unwrap_or_else(|_| { - ( - StatusCode::INTERNAL_SERVER_ERROR, - Json(serde_json::json!({"error": {"message": "Failed to build stream response"}})), - ) - .into_response() - }); - } - - // 非流式响应需要转换格式 - if status.is_success() { - if let Some(db) = &state.db { - let _ = state.pool_service.mark_healthy( - db, - &credential.uuid, - Some(&request.model), - ); - let _ = state.pool_service.record_usage(db, &credential.uuid); - } - } else { - if let Some(db) = &state.db { - let _ = state.pool_service.mark_unhealthy( - db, - &credential.uuid, - Some(&format!("API error: {}", status)), - ); - } - } - - match resp.text().await { - Ok(body) => { - if status.is_success() { - // 将 OpenAI 响应转换为 Anthropic 响应 - match serde_json::from_str::(&body) { - Ok(openai_resp) => { - let anthropic_resp = convert_openai_response_to_anthropic(&openai_resp, &request.model); - Response::builder() - .status(StatusCode::OK) - .header(header::CONTENT_TYPE, "application/json") - .body(Body::from(serde_json::to_string(&anthropic_resp).unwrap_or_default())) - .unwrap_or_else(|_| { - ( - StatusCode::INTERNAL_SERVER_ERROR, - Json(serde_json::json!({"error": {"message": "Failed to build response"}})), - ) - .into_response() - }) - } - Err(e) => { - state.logs.write().await.add( - "error", - &format!("[ANTHROPIC_COMPAT] 解析 OpenAI 响应失败: {}", e), - ); - // 返回原始响应 - Response::builder() - .status(StatusCode::OK) - .header(header::CONTENT_TYPE, "application/json") - .body(Body::from(body)) - .unwrap_or_else(|_| { - ( - StatusCode::INTERNAL_SERVER_ERROR, - Json(serde_json::json!({"error": {"message": "Failed to build response"}})), - ) - .into_response() - }) - } - } - } else { - ( - StatusCode::from_u16(status.as_u16()) - .unwrap_or(StatusCode::INTERNAL_SERVER_ERROR), - Json(serde_json::json!({"error": {"message": body}})), - ) - .into_response() - } - } - Err(e) => ( - StatusCode::INTERNAL_SERVER_ERROR, - Json(serde_json::json!({"error": {"message": format!("Failed to read response: {}", e)}})), - ) - .into_response(), - } - } - Err(e) => { if let Some(db) = &state.db { - let _ = state.pool_service.mark_unhealthy( + let _ = state.pool_service.mark_healthy( db, &credential.uuid, - Some(&format!("API call failed: {}", e)), + Some(&request.model), ); + let _ = state.pool_service.record_usage(db, &credential.uuid); } - ( - StatusCode::INTERNAL_SERVER_ERROR, - Json(serde_json::json!({"error": {"message": format!("OpenAI compatible API call failed: {}", e)}})), - ) - .into_response() + let stream = resp.bytes_stream(); + return Response::builder() + .status(StatusCode::OK) + .header(header::CONTENT_TYPE, "text/event-stream") + .header(header::CACHE_CONTROL, "no-cache") + .header("Connection", "keep-alive") + .body(Body::from_stream(stream)) + .unwrap_or_else(|_| { + ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(serde_json::json!({"error": {"message": "Failed to build stream response"}})), + ) + .into_response() + }); } - } - } else { - // 没有自定义 base_url,使用原生 Anthropic API - let claude = ClaudeCustomProvider::with_config(api_key.clone(), None); - let request_url = claude.get_base_url(); - state.logs.write().await.add( - "info", - &format!( - "[ANTHROPIC] 使用 Anthropic API: base_url=https://api.anthropic.com -> {}/v1/messages credential_uuid={} stream={}", - request_url, - &credential.uuid[..8], - request.stream - ), - ); - match claude.call_api(request).await { - Ok(resp) => { - let status = resp.status(); - state.logs.write().await.add( - "info", - &format!( - "[ANTHROPIC] 响应状态: status={} model={} stream={}", - status, - request.model, - request.stream - ), - ); - // 如果是流式请求,直接透传流式响应 - if request.stream && status.is_success() { - state.logs.write().await.add( - "info", - "[ANTHROPIC] 流式请求,透传 SSE 响应", - ); - if let Some(db) = &state.db { - let _ = state.pool_service.mark_healthy( - db, - &credential.uuid, - Some(&request.model), - ); - let _ = state.pool_service.record_usage(db, &credential.uuid); - } - let stream = resp.bytes_stream(); - return Response::builder() - .status(StatusCode::OK) - .header(header::CONTENT_TYPE, "text/event-stream") - .header(header::CACHE_CONTROL, "no-cache") - .header("Connection", "keep-alive") - .body(Body::from_stream(stream)) - .unwrap_or_else(|_| { - ( - StatusCode::INTERNAL_SERVER_ERROR, - Json(serde_json::json!({"error": {"message": "Failed to build stream response"}})), - ) - .into_response() - }); - } - - // 非流式请求,读取完整响应 - match resp.text().await { - Ok(body) => { - if status.is_success() { - if let Some(db) = &state.db { - let _ = state.pool_service.mark_healthy( - db, - &credential.uuid, - Some(&request.model), - ); - let _ = state.pool_service.record_usage(db, &credential.uuid); - } - Response::builder() - .status(StatusCode::OK) - .header(header::CONTENT_TYPE, "application/json") - .body(Body::from(body)) - .unwrap_or_else(|_| { - ( - StatusCode::INTERNAL_SERVER_ERROR, - Json(serde_json::json!({"error": {"message": "Failed to build response"}})), - ) - .into_response() - }) - } else { - state.logs.write().await.add( - "error", - &format!( - "[ANTHROPIC] 请求失败: status={} body={}", - status, - &body.chars().take(200).collect::() - ), + // 非流式请求,读取完整响应 + match resp.text().await { + Ok(body) => { + if status.is_success() { + if let Some(db) = &state.db { + let _ = state.pool_service.mark_healthy( + db, + &credential.uuid, + Some(&request.model), ); - if let Some(db) = &state.db { - let _ = state.pool_service.mark_unhealthy( - db, - &credential.uuid, - Some(&body), - ); - } - ( - StatusCode::from_u16(status.as_u16()) - .unwrap_or(StatusCode::INTERNAL_SERVER_ERROR), - Json(serde_json::json!({"error": {"message": body}})), - ) - .into_response() + let _ = state.pool_service.record_usage(db, &credential.uuid); } - } - Err(e) => { + Response::builder() + .status(StatusCode::OK) + .header(header::CONTENT_TYPE, "application/json") + .body(Body::from(body)) + .unwrap_or_else(|_| { + ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(serde_json::json!({"error": {"message": "Failed to build response"}})), + ) + .into_response() + }) + } else { state.logs.write().await.add( "error", - &format!("[ANTHROPIC] 读取响应失败: {}", e), + &format!( + "[ANTHROPIC] 请求失败: status={} body={}", + status, + &body[..body.len().min(500)] + ), ); if let Some(db) = &state.db { let _ = state.pool_service.mark_unhealthy( db, &credential.uuid, - Some(&e.to_string()), + Some(&format!("API error: {}", status)), ); } ( - StatusCode::INTERNAL_SERVER_ERROR, - Json(serde_json::json!({"error": {"message": e.to_string()}})), + StatusCode::from_u16(status.as_u16()) + .unwrap_or(StatusCode::INTERNAL_SERVER_ERROR), + Json(serde_json::json!({"error": {"message": body}})), ) .into_response() } } - } - Err(e) => { - if let Some(db) = &state.db { - let _ = state.pool_service.mark_unhealthy( - db, - &credential.uuid, - Some(&e.to_string()), - ); - } - ( + Err(e) => ( StatusCode::INTERNAL_SERVER_ERROR, - Json(serde_json::json!({"error": {"message": e.to_string()}})), + Json(serde_json::json!({"error": {"message": format!("Failed to read response: {}", e)}})), ) - .into_response() + .into_response(), } } + Err(e) => { + if let Some(db) = &state.db { + let _ = state.pool_service.mark_unhealthy( + db, + &credential.uuid, + Some(&format!("API call failed: {}", e)), + ); + } + ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(serde_json::json!({"error": {"message": format!("Anthropic API call failed: {}", e)}})), + ) + .into_response() + } } } } diff --git a/src/components/agent/chat/components/StreamingRenderer.tsx b/src/components/agent/chat/components/StreamingRenderer.tsx index 77c69bd90..8449a4aa5 100644 --- a/src/components/agent/chat/components/StreamingRenderer.tsx +++ b/src/components/agent/chat/components/StreamingRenderer.tsx @@ -5,7 +5,7 @@ * Requirements: 9.3, 9.4 */ -import React, { memo, useMemo } from "react"; +import React, { memo, useMemo, useState, useEffect, useRef } from "react"; import { cn } from "@/lib/utils"; import { ChevronDown, Lightbulb } from "lucide-react"; import { MarkdownRenderer } from "./MarkdownRenderer"; @@ -60,6 +60,121 @@ const StreamingCursor: React.FC = () => ( /> ); +// ============ 流式文本组件(逐字符动画) ============ + +interface StreamingTextProps { + /** 目标文本(完整内容) */ + text: string; + /** 是否正在流式输出 */ + isStreaming: boolean; + /** 是否显示光标 */ + showCursor?: boolean; + /** 每个字符的渲染间隔(毫秒),默认 12ms */ + charInterval?: number; +} + +/** + * 流式文本组件 + * + * 实现逐字符平滑显示效果,类似 ChatGPT/Claude 的打字机效果。 + * 当流式结束时,立即显示完整文本。 + */ +const StreamingText: React.FC = memo( + ({ text, isStreaming, showCursor = true, charInterval = 12 }) => { + const [displayText, setDisplayText] = useState(""); + const displayIndexRef = useRef(0); + const animationRef = useRef(null); + const prevTextRef = useRef(""); + + useEffect(() => { + // 如果不是流式输出,直接显示完整文本 + if (!isStreaming) { + setDisplayText(text); + displayIndexRef.current = text.length; + prevTextRef.current = text; + if (animationRef.current) { + cancelAnimationFrame(animationRef.current); + animationRef.current = null; + } + return; + } + + // 检测文本是否有新增 + if (text.length <= prevTextRef.current.length) { + prevTextRef.current = text; + return; + } + + prevTextRef.current = text; + + // 如果已经有动画在运行,让它继续 + if (animationRef.current !== null) { + return; + } + + let lastTime = 0; + + const animate = (currentTime: number) => { + if (!lastTime) lastTime = currentTime; + const elapsed = currentTime - lastTime; + + if (elapsed >= charInterval) { + // 计算这一帧应该显示多少个字符 + const charsToAdd = Math.max(1, Math.floor(elapsed / charInterval)); + const newIndex = Math.min( + displayIndexRef.current + charsToAdd, + text.length, + ); + + if (newIndex > displayIndexRef.current) { + displayIndexRef.current = newIndex; + setDisplayText(text.slice(0, newIndex)); + } + + lastTime = currentTime; + } + + // 继续动画直到追上目标 + if (displayIndexRef.current < text.length) { + animationRef.current = requestAnimationFrame(animate); + } else { + animationRef.current = null; + } + }; + + animationRef.current = requestAnimationFrame(animate); + + return () => { + if (animationRef.current) { + cancelAnimationFrame(animationRef.current); + animationRef.current = null; + } + }; + }, [text, isStreaming, charInterval]); + + // 组件卸载时清理 + useEffect(() => { + return () => { + if (animationRef.current) { + cancelAnimationFrame(animationRef.current); + } + }; + }, []); + + const shouldShowCursor = + isStreaming && showCursor && displayIndexRef.current < text.length; + + return ( +
+ + {shouldShowCursor && } +
+ ); + }, +); + +StreamingText.displayName = "StreamingText"; + // ============ 思考内容解析 ============ interface ParsedContent { @@ -186,11 +301,14 @@ export const StreamingRenderer: React.FC = memo( if (!partVisible) return null; const isLastPart = index === contentParts.length - 1; + // 使用 StreamingText 组件实现逐字符动画 return ( -
- - {isLastPart && shouldShowCursor && } -
+ ); } else if (part.type === "tool_use") { // 渲染单个工具调用 @@ -230,12 +348,13 @@ export const StreamingRenderer: React.FC = memo( {/* 工具调用区域 */} {hasToolCalls && } - {/* 文本内容区域 */} + {/* 文本内容区域 - 使用 StreamingText 组件实现逐字符动画 */} {visibleText.length > 0 && ( -
- - {shouldShowCursor && } -
+ )} {/* 如果没有内容但正在流式输出,显示光标 */} diff --git a/src/components/agent/chat/hooks/useAgentChat.ts b/src/components/agent/chat/hooks/useAgentChat.ts index 4dad1862b..4446b391f 100644 --- a/src/components/agent/chat/hooks/useAgentChat.ts +++ b/src/components/agent/chat/hooks/useAgentChat.ts @@ -14,7 +14,14 @@ import { type SessionInfo, type StreamEvent, } from "@/lib/api/agent"; -import { Message, MessageImage, ContentPart, PROVIDER_CONFIG } from "../types"; +import { + Message, + MessageImage, + ContentPart, + PROVIDER_CONFIG, + getProviderConfig, + type ProviderConfigMap, +} from "../types"; /** 话题(会话)信息 */ export interface Topic { @@ -78,6 +85,11 @@ export function useAgentChat() { running: false, }); + // 动态模型配置(从后端加载) + const [providerConfig, setProviderConfig] = + useState(PROVIDER_CONFIG); + const [isConfigLoading, setIsConfigLoading] = useState(true); + // Configuration State (Persistent) const defaultProvider = "claude"; const defaultModel = PROVIDER_CONFIG["claude"]?.models[0] || ""; @@ -102,6 +114,21 @@ export function useAgentChat() { const [isSending, setIsSending] = useState(false); + // 加载动态模型配置 + useEffect(() => { + const loadConfig = async () => { + try { + const config = await getProviderConfig(); + setProviderConfig(config); + } catch (error) { + console.warn("加载模型配置失败,使用默认配置:", error); + } finally { + setIsConfigLoading(false); + } + }; + loadConfig(); + }, []); + // Persistence Effects useEffect(() => { savePersisted("agent_pref_provider", providerType); @@ -564,6 +591,8 @@ export function useAgentChat() { setProviderType, model, setModel, + providerConfig, // 动态模型配置 + isConfigLoading, // 配置加载状态 // Chat messages, diff --git a/src/components/agent/chat/hooks/useStreamingText.ts b/src/components/agent/chat/hooks/useStreamingText.ts new file mode 100644 index 000000000..3fc32ecfb --- /dev/null +++ b/src/components/agent/chat/hooks/useStreamingText.ts @@ -0,0 +1,156 @@ +import { useState, useEffect, useRef, useCallback } from "react"; + +interface UseStreamingTextOptions { + /** 每个字符的渲染间隔(毫秒),默认 15ms */ + charInterval?: number; + /** 是否启用动画,默认 true */ + animated?: boolean; + /** 当文本追赶上目标时的回调 */ + onCatchUp?: () => void; +} + +interface UseStreamingTextReturn { + /** 当前显示的文本 */ + displayText: string; + /** 目标文本(完整内容) */ + targetText: string; + /** 是否正在动画中 */ + isAnimating: boolean; + /** 设置目标文本 */ + setTargetText: (text: string) => void; + /** 追加文本到目标 */ + appendText: (text: string) => void; + /** 重置状态 */ + reset: () => void; + /** 立即显示完整文本(跳过动画) */ + skipToEnd: () => void; +} + +/** + * 流式文本渲染 Hook + * + * 实现逐字符平滑显示效果,类似 ChatGPT/Claude 的打字机效果。 + * + * @example + * ```tsx + * const { displayText, appendText, reset } = useStreamingText(); + * + * // 当收到流式数据时 + * appendText(newChunk); + * + * // 渲染 + *
{displayText}
+ * ``` + */ +export function useStreamingText( + options: UseStreamingTextOptions = {}, +): UseStreamingTextReturn { + const { charInterval = 15, animated = true, onCatchUp } = options; + + const [displayText, setDisplayText] = useState(""); + const [targetText, setTargetText] = useState(""); + const [isAnimating, setIsAnimating] = useState(false); + + const animationRef = useRef(null); + const displayIndexRef = useRef(0); + + // 清理动画 + const clearAnimation = useCallback(() => { + if (animationRef.current !== null) { + cancelAnimationFrame(animationRef.current); + animationRef.current = null; + } + }, []); + + // 动画循环 + useEffect(() => { + if (!animated) { + // 禁用动画时直接显示完整文本 + setDisplayText(targetText); + displayIndexRef.current = targetText.length; + setIsAnimating(false); + return; + } + + // 如果显示文本已经追上目标文本,停止动画 + if (displayIndexRef.current >= targetText.length) { + setIsAnimating(false); + onCatchUp?.(); + return; + } + + setIsAnimating(true); + + let lastTime = 0; + + const animate = (currentTime: number) => { + if (!lastTime) lastTime = currentTime; + const elapsed = currentTime - lastTime; + + if (elapsed >= charInterval) { + // 计算这一帧应该显示多少个字符 + const charsToAdd = Math.max(1, Math.floor(elapsed / charInterval)); + const newIndex = Math.min( + displayIndexRef.current + charsToAdd, + targetText.length, + ); + + if (newIndex > displayIndexRef.current) { + displayIndexRef.current = newIndex; + setDisplayText(targetText.slice(0, newIndex)); + } + + lastTime = currentTime; + } + + // 继续动画直到追上目标 + if (displayIndexRef.current < targetText.length) { + animationRef.current = requestAnimationFrame(animate); + } else { + setIsAnimating(false); + onCatchUp?.(); + } + }; + + animationRef.current = requestAnimationFrame(animate); + + return clearAnimation; + }, [targetText, animated, charInterval, clearAnimation, onCatchUp]); + + // 追加文本 + const appendText = useCallback((text: string) => { + setTargetText((prev) => prev + text); + }, []); + + // 重置 + const reset = useCallback(() => { + clearAnimation(); + setDisplayText(""); + setTargetText(""); + displayIndexRef.current = 0; + setIsAnimating(false); + }, [clearAnimation]); + + // 跳过动画,立即显示完整文本 + const skipToEnd = useCallback(() => { + clearAnimation(); + setDisplayText(targetText); + displayIndexRef.current = targetText.length; + setIsAnimating(false); + }, [clearAnimation, targetText]); + + // 组件卸载时清理 + useEffect(() => { + return clearAnimation; + }, [clearAnimation]); + + return { + displayText, + targetText, + isAnimating, + setTargetText, + appendText, + reset, + skipToEnd, + }; +} diff --git a/src/components/agent/chat/types.ts b/src/components/agent/chat/types.ts index 774f8f391..d5bcce48a 100644 --- a/src/components/agent/chat/types.ts +++ b/src/components/agent/chat/types.ts @@ -1,4 +1,5 @@ import type { ToolCallState, TokenUsage } from "@/lib/api/agent"; +import { invoke } from "@tauri-apps/api/core"; export interface MessageImage { data: string; @@ -60,6 +61,14 @@ export const PROVIDER_CONFIG: Record< "claude-sonnet-4-20250514", ], }, + anthropic: { + label: "Anthropic", + models: [ + "claude-opus-4-5-20251101", + "claude-sonnet-4-5-20250929", + "claude-sonnet-4-20250514", + ], + }, kiro: { label: "Kiro", models: ["claude-sonnet-4-5-20250929", "claude-sonnet-4-20250514"], @@ -109,3 +118,41 @@ export const PROVIDER_CONFIG: Record< ], }, }; + +// ============ 动态模型配置 API ============ + +/** 简化的 Provider 配置(从后端返回) */ +export interface SimpleProviderConfig { + label: string; + models: string[]; +} + +/** Provider 配置映射类型 */ +export type ProviderConfigMap = Record; + +/** + * 从后端获取所有 Provider 的模型配置 + * 如果获取失败,返回默认的 PROVIDER_CONFIG + */ +export async function getProviderConfig(): Promise { + try { + const config = await invoke("get_all_provider_models"); + return config; + } catch (error) { + console.warn("获取模型配置失败,使用默认配置:", error); + return PROVIDER_CONFIG; + } +} + +/** + * 获取指定 Provider 的模型列表 + */ +export async function getProviderModels(provider: string): Promise { + try { + const models = await invoke("get_provider_models", { provider }); + return models; + } catch (error) { + console.warn(`获取 ${provider} 模型列表失败:`, error); + return PROVIDER_CONFIG[provider]?.models ?? []; + } +} diff --git a/src/components/config/AuthDirSettings.tsx b/src/components/config/AuthDirSettings.tsx deleted file mode 100644 index e482068dc..000000000 --- a/src/components/config/AuthDirSettings.tsx +++ /dev/null @@ -1,209 +0,0 @@ -import { useState, useEffect } from "react"; -import { - Folder, - RotateCcw, - Check, - AlertCircle, - FolderOpen, -} from "lucide-react"; -import { Config } from "@/lib/api/config"; -import { invoke } from "@tauri-apps/api/core"; - -interface AuthDirSettingsProps { - config: Config | null; - onConfigChange: (config: Config) => void; -} - -const DEFAULT_AUTH_DIR = "~/.proxycast/auth"; - -export function AuthDirSettings({ - config, - onConfigChange, -}: AuthDirSettingsProps) { - const [authDir, setAuthDir] = useState(DEFAULT_AUTH_DIR); - const [isSaving, setIsSaving] = useState(false); - const [saveSuccess, setSaveSuccess] = useState(false); - const [error, setError] = useState(null); - const [expandedPath, setExpandedPath] = useState(null); - - // Load auth_dir from config - useEffect(() => { - if (config?.auth_dir) { - setAuthDir(config.auth_dir); - } - }, [config]); - - // Validate and expand path - const validatePath = async (path: string) => { - try { - const expanded = await invoke("expand_path", { path }); - setExpandedPath(expanded); - setError(null); - return true; - } catch (err) { - setError(`路径无效: ${err}`); - setExpandedPath(null); - return false; - } - }; - - // Handle path change - const handlePathChange = (newPath: string) => { - setAuthDir(newPath); - setSaveSuccess(false); - // Debounce validation - const timer = setTimeout(() => { - validatePath(newPath); - }, 300); - return () => clearTimeout(timer); - }; - - // Reset to default - const handleReset = () => { - setAuthDir(DEFAULT_AUTH_DIR); - setSaveSuccess(false); - validatePath(DEFAULT_AUTH_DIR); - }; - - // Save changes - const handleSave = async () => { - if (!config) return; - - setIsSaving(true); - setError(null); - setSaveSuccess(false); - - try { - // Validate path first - const isValid = await validatePath(authDir); - if (!isValid) { - setIsSaving(false); - return; - } - - // Update config - const newConfig = { - ...config, - auth_dir: authDir, - }; - onConfigChange(newConfig); - setSaveSuccess(true); - - // Clear success message after 3 seconds - setTimeout(() => setSaveSuccess(false), 3000); - } catch (err) { - setError(`保存失败: ${err}`); - } finally { - setIsSaving(false); - } - }; - - // Open folder in file manager - const handleOpenFolder = async () => { - try { - await invoke("open_auth_dir", { path: authDir }); - } catch (err) { - setError(`打开文件夹失败: ${err}`); - } - }; - - const hasChanges = config?.auth_dir !== authDir; - - return ( -
-
-

- - 认证目录设置 -

-

- 配置 OAuth Token 文件的存储目录。支持使用 ~ 表示用户主目录。 -

-
- -
-
- -
-
- - handlePathChange(e.target.value)} - className="w-full pl-9 pr-3 py-2 rounded-lg border bg-background text-sm font-mono focus:ring-2 focus:ring-primary/20 focus:border-primary outline-none" - placeholder={DEFAULT_AUTH_DIR} - /> -
- - -
-
- - {/* Expanded path preview */} - {expandedPath && ( -
- 展开后路径: - - {expandedPath} - -
- )} - - {/* Error display */} - {error && ( -
- - {error} -
- )} - - {/* Success message */} - {saveSuccess && ( -
- - 设置已保存 -
- )} - - {/* Save button */} -
- -
-
- - {/* Help text */} -
-

说明

-
    -
  • 认证目录用于存储 OAuth Token 文件(Kiro、Gemini、Qwen 等)
  • -
  • - 使用 ~{" "} - 表示用户主目录,例如{" "} - ~/.proxycast/auth -
  • -
  • 修改此设置后,现有的 Token 文件不会自动迁移,需要手动移动
  • -
  • 导出配置时,Token 文件会从此目录读取并包含在导出包中
  • -
-
-
- ); -} diff --git a/src/components/config/ConfigEditor.tsx b/src/components/config/ConfigEditor.tsx deleted file mode 100644 index 2ed72a5d5..000000000 --- a/src/components/config/ConfigEditor.tsx +++ /dev/null @@ -1,249 +0,0 @@ -import React, { useState, useEffect, useCallback, useRef } from "react"; -import { AlertCircle, Check, Copy, FileCode } from "lucide-react"; -import { Config, configApi } from "@/lib/api/config"; - -interface ConfigEditorProps { - config: Config | null; - onConfigChange: (config: Config) => void; -} - -// Simple YAML syntax highlighter -function highlightYaml(yaml: string): string { - return ( - yaml - // Comments - .replace(/(#.*$)/gm, '$1') - // Keys (before colon) - .replace( - /^(\s*)([a-zA-Z_][a-zA-Z0-9_]*):/gm, - '$1$2:', - ) - // Strings in quotes - .replace( - /"([^"\\]*(\\.[^"\\]*)*)"/g, - '"$1"', - ) - .replace( - /'([^'\\]*(\\.[^'\\]*)*)'/g, - "'$1'", - ) - // Booleans - .replace( - /:\s*(true|false)(\s|$)/gi, - ': $1$2', - ) - // Numbers - .replace( - /:\s*(\d+\.?\d*)(\s|$)/g, - ': $1$2', - ) - // Null - .replace(/:\s*(null|~)(\s|$)/gi, ': $1$2') - ); -} - -export function ConfigEditor({ config, onConfigChange }: ConfigEditorProps) { - const [yamlContent, setYamlContent] = useState(""); - const [highlightedContent, setHighlightedContent] = useState(""); - const [error, setError] = useState(null); - const [isValid, setIsValid] = useState(true); - const [copied, setCopied] = useState(false); - const [isValidating, setIsValidating] = useState(false); - const textareaRef = useRef(null); - const highlightRef = useRef(null); - const validateTimeoutRef = useRef | null>(null); - - // Load YAML from config - const loadYamlFromConfig = useCallback(async () => { - if (!config) return; - try { - const result = await configApi.exportConfig(config, false); - setYamlContent(result.content); - setHighlightedContent(highlightYaml(result.content)); - setError(null); - setIsValid(true); - } catch (err) { - setError(`加载配置失败: ${err}`); - } - }, [config]); - - // Load initial YAML from config - useEffect(() => { - if (config) { - loadYamlFromConfig(); - } - }, [config, loadYamlFromConfig]); - - // Validate YAML with debounce - const validateYaml = useCallback( - async (content: string) => { - if (!content.trim()) { - setError(null); - setIsValid(false); - return; - } - - setIsValidating(true); - try { - const validatedConfig = await configApi.validateConfigYaml(content); - setError(null); - setIsValid(true); - onConfigChange(validatedConfig); - } catch (err) { - setError(`${err}`); - setIsValid(false); - } finally { - setIsValidating(false); - } - }, - [onConfigChange], - ); - - // Handle content change with debounced validation - const handleContentChange = (newContent: string) => { - setYamlContent(newContent); - setHighlightedContent(highlightYaml(newContent)); - - // Clear previous timeout - if (validateTimeoutRef.current) { - clearTimeout(validateTimeoutRef.current); - } - - // Debounce validation - validateTimeoutRef.current = setTimeout(() => { - validateYaml(newContent); - }, 500); - }; - - // Sync scroll between textarea and highlight - const handleScroll = () => { - if (textareaRef.current && highlightRef.current) { - highlightRef.current.scrollTop = textareaRef.current.scrollTop; - highlightRef.current.scrollLeft = textareaRef.current.scrollLeft; - } - }; - - // Copy to clipboard - const handleCopy = async () => { - try { - await navigator.clipboard.writeText(yamlContent); - setCopied(true); - setTimeout(() => setCopied(false), 2000); - } catch { - setError("复制失败"); - } - }; - - // Handle tab key for indentation - const handleKeyDown = (e: React.KeyboardEvent) => { - if (e.key === "Tab") { - e.preventDefault(); - const textarea = e.currentTarget; - const start = textarea.selectionStart; - const end = textarea.selectionEnd; - const newContent = - yamlContent.substring(0, start) + " " + yamlContent.substring(end); - handleContentChange(newContent); - // Restore cursor position - setTimeout(() => { - textarea.selectionStart = textarea.selectionEnd = start + 2; - }, 0); - } - }; - - return ( -
-
-
- -

YAML 配置编辑器

-
-
- {isValidating && ( - 验证中... - )} - {!isValidating && isValid && yamlContent && ( - - - 有效 - - )} - -
-
- - {/* Editor container */} -
- {/* Syntax highlighted layer */} -