use axum::{Json, extract::{Query, State}}; use axum::http::StatusCode; use serde::{Deserialize, Serialize}; use std::sync::Arc; use super::GatewayState; use crate::config::{Config, get_default_config_path}; use crate::domain::CapabilityPolicy; use crate::experts::{Expert, ExpertScope, ExpertWithStatus}; use crate::skills::SkillWithStatus; use crate::tools::task::runtime::{SubagentScope, SubagentWithStatus}; #[derive(Deserialize)] pub struct SkillToggleRequest { pub name: String, pub scope: String, pub enabled: bool, } #[derive(Serialize)] pub struct SkillToggleResponse { success: bool, #[serde(skip_serializing_if = "Option::is_none")] changed: Option, #[serde(skip_serializing_if = "Option::is_none")] available: Option, #[serde(skip_serializing_if = "Option::is_none")] disabled_in_scopes: Option>, #[serde(skip_serializing_if = "Option::is_none")] error: Option, } #[derive(Serialize)] pub struct SkillListResponse { skills_system_enabled: bool, total: usize, skills: Vec, } #[derive(Serialize)] pub struct HealthResponse { status: String, version: String, } pub async fn health() -> Json { Json(HealthResponse { status: "ok".to_string(), version: env!("CARGO_PKG_VERSION").to_string(), }) } const API_KEY_MASK: &str = "••••••••"; /// Mask sensitive fields in config for safe display fn mask_config(config: &Config) -> Config { let mut masked = config.clone(); for provider in masked.providers.values_mut() { if !provider.api_key.is_empty() { let visible: String = provider.api_key.chars().take(4).collect(); provider.api_key = format!("{}{}", visible, API_KEY_MASK); } } for channel in masked.channels.values_mut() { if let Some(feishu) = channel.as_feishu_mut() { if !feishu.app_secret.is_empty() { let visible: String = feishu.app_secret.chars().take(4).collect(); feishu.app_secret = format!("{}{}", visible, API_KEY_MASK); } } } masked } /// Check if an api_key value is masked (contains the mask suffix) fn is_masked_key(value: &str) -> bool { value.ends_with(API_KEY_MASK) } #[derive(Deserialize)] pub struct SaveConfigRequest { pub config: Config, } #[derive(Serialize)] pub struct SaveConfigResponse { pub success: bool, pub message: String, pub config_path: String, } /// GET /api/config — Return current config with masked sensitive fields pub async fn get_config( State(state): State>, ) -> Json { Json(mask_config(&*state.config.read().await)) } /// PUT /api/config — Save config to file, preserving original api_keys if masked pub async fn save_config( State(state): State>, Json(req): Json, ) -> Result, (StatusCode, String)> { // Merge: preserve original api_keys if the submitted ones are masked let mut new_config = req.config; // Read old values under read lock (not held across disk I/O) { let cfg = state.config.read().await; for (name, provider) in new_config.providers.iter_mut() { if is_masked_key(&provider.api_key) { if let Some(original) = cfg.providers.get(name) { provider.api_key = original.api_key.clone(); } } } for (name, channel) in new_config.channels.iter_mut() { if let Some(feishu) = channel.as_feishu_mut() { if is_masked_key(&feishu.app_secret) { if let Some(original_channel) = cfg.channels.get(name) { if let Some(original_feishu) = original_channel.as_feishu() { feishu.app_secret = original_feishu.app_secret.clone(); } } } } } } // read lock released here // Validate timezone if let Err(e) = new_config.time.parse_timezone() { return Err((StatusCode::BAD_REQUEST, format!("Invalid timezone: {}", e))); } // Determine config file path let config_path = std::env::var("CONFIG_PATH") .map(std::path::PathBuf::from) .unwrap_or_else(|_| get_default_config_path()); // Serialize and write to disk (no lock held) let json = serde_json::to_string_pretty(&new_config) .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("Serialize error: {}", e)))?; std::fs::write(&config_path, &json) .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("Write error: {}", e)))?; // Update in-memory config (write lock, held only for assignment) { let mut cfg = state.config.write().await; *cfg = new_config.clone(); } // 同步更新 ExpertRuntime 的 config,让 sources 等变更即时生效(无需重启) if let Err(e) = state.experts.update_config(new_config.experts.clone()) { tracing::warn!(error = %e, "Failed to sync experts config after save_config"); } tracing::info!(path = %config_path.display(), "Config saved via API"); Ok(Json(SaveConfigResponse { success: true, message: "配置已保存".to_string(), config_path: config_path.to_string_lossy().to_string(), })) } #[derive(Serialize)] pub struct RestartResponse { pub success: bool, pub message: String, } /// POST /api/restart — Restart the gateway to apply config changes pub async fn restart( State(state): State>, ) -> Result, (StatusCode, Json)> { let active = state.cancel_manager.active_count().await; if active > 0 { return Err(( StatusCode::CONFLICT, Json(RestartResponse { success: false, message: format!("当前有 {} 个任务正在运行,请等待完成后再重启", active), }), )); } let restart_tx = state.restart_tx.clone(); tokio::spawn(async move { tokio::time::sleep(std::time::Duration::from_millis(200)).await; if let Err(e) = restart_tx.send(true) { tracing::warn!( error = %e, "Failed to send restart signal; receiver may have already exited. \ HTTP response already returned success, but restart may not occur." ); } }); Ok(Json(RestartResponse { success: true, message: "服务正在重启...".to_string(), })) } /// GET /api/mcp/status — Return MCP server connection status pub async fn mcp_status( State(state): State>, ) -> Json { let status = match &state.mcp_manager { Some(manager) => { // Clone mcp_servers before await to avoid holding read lock across it let mcp_servers = state.config.read().await.mcp_servers.clone(); manager.get_status(&mcp_servers).await } None => crate::mcp::client::McpStatusResponse { enabled: false, total_servers: 0, connected_servers: 0, failed_servers: 0, total_tools: 0, servers: vec![], }, }; Json(status) } /// GET /api/skills — Return all discovered skills with their disabled status pub async fn skills_list( State(state): State>, ) -> Json { let skills_enabled = state.config.read().await.skills.enabled; if !skills_enabled { return Json(SkillListResponse { skills_system_enabled: false, total: 0, skills: vec![], }); } let skills = state.skills.list_skills_with_status(); let total = skills.len(); Json(SkillListResponse { skills_system_enabled: true, total, skills, }) } #[derive(Serialize)] pub struct ToolInfo { pub name: String, pub description: String, /// "builtin" 或 "mcp:{server_key}" pub source: String, } #[derive(Serialize)] pub struct ToolsListResponse { pub total: usize, pub tools: Vec, } /// GET /api/model-options 返回可用的 provider/model 名列表(供专家/子代理编辑下拉框)。 #[derive(Serialize)] pub struct ModelOptionsResponse { pub providers: Vec, pub models: Vec, /// 当前默认 agent 的 provider/model 名(来自 config.json agents.default)。 /// 前端用于在"继承默认"选项旁标注当前生效的模型。 pub current: CurrentModel, } #[derive(Serialize)] pub struct CurrentModel { pub provider: String, pub model: String, } /// GET /api/tools — Return all registered tools (builtin + MCP) with name/description/source. /// 通过 SessionManager::tools() 只读访问 ToolRegistry,不修改状态。 pub async fn tools_list( State(state): State>, ) -> Json { let registry = state.session_manager.tools(); let tools: Vec = registry .get_definitions() .into_iter() .map(|t| { let source = if t.function.name.starts_with("mcp_") { // mcp_{server}_{tool} → mcp:{server} let parts: Vec<&str> = t.function.name.splitn(3, '_').collect(); if parts.len() == 3 { format!("mcp:{}", parts[1]) } else { "mcp".to_string() } } else { "builtin".to_string() }; ToolInfo { name: t.function.name, description: t.function.description, source, } }) .collect(); let total = tools.len(); Json(ToolsListResponse { total, tools }) } /// GET /api/model-options — 返回 config.json 中配置的 provider/model 名列表。 pub async fn model_options( State(state): State>, ) -> Json { let config = state.config.read().await; let resolver = crate::config::ModelResolver::from_config(&config); // 当前默认 agent 的 provider/model 名(直接引用 providers/models 表的 key) let current = if let Some(agent) = config.agents.get("default") { CurrentModel { provider: agent.provider.clone(), model: agent.model.clone(), } } else { CurrentModel { provider: String::new(), model: String::new(), } }; Json(ModelOptionsResponse { providers: resolver.provider_names(), models: resolver.model_names(), current, }) } /// POST /api/skills/toggle — Enable or disable a specific skill pub async fn skills_toggle( State(state): State>, Json(req): Json, ) -> (StatusCode, Json) { let scope = match crate::skills::SkillScope::parse(&req.scope) { Some(s) => s, None => { return ( StatusCode::BAD_REQUEST, Json(SkillToggleResponse { success: false, changed: None, available: None, disabled_in_scopes: None, error: Some(format!("invalid scope: {}", req.scope)), }), ); } }; let result = if req.enabled { state.skills.enable_skill(scope, &req.name, true) } else { state.skills.disable_skill(scope, &req.name, true) }; match result { Ok(change) => ( StatusCode::OK, Json(SkillToggleResponse { success: true, changed: Some(change.changed), available: Some(change.available), disabled_in_scopes: Some( change.disabled_in_scopes.iter().map(|s| s.as_str().to_string()).collect(), ), error: None, }), ), Err(msg) => { let status = if msg.contains("not found") { StatusCode::NOT_FOUND } else { StatusCode::INTERNAL_SERVER_ERROR }; ( status, Json(SkillToggleResponse { success: false, changed: None, available: None, disabled_in_scopes: None, error: Some(msg), }), ) } } } #[derive(Deserialize)] pub struct SubagentToggleRequest { pub name: String, pub scope: String, pub enabled: bool, } #[derive(Serialize)] pub struct SubagentToggleResponse { success: bool, #[serde(skip_serializing_if = "Option::is_none")] changed: Option, #[serde(skip_serializing_if = "Option::is_none")] available: Option, #[serde(skip_serializing_if = "Option::is_none")] disabled_in_scopes: Option>, #[serde(skip_serializing_if = "Option::is_none")] error: Option, } #[derive(Serialize)] pub struct SubagentListResponse { subagents_system_enabled: bool, total: usize, subagents: Vec, } /// GET /api/subagents — Return all discovered subagents with their disabled status pub async fn subagents_list( State(state): State>, ) -> Json { let subagents_enabled = state.config.read().await.subagents.enabled; if !subagents_enabled { return Json(SubagentListResponse { subagents_system_enabled: false, total: 0, subagents: vec![], }); } let subagents = state.subagent_runtime.list_with_status(); let total = subagents.len(); Json(SubagentListResponse { subagents_system_enabled: true, total, subagents, }) } /// POST /api/subagents/toggle — Enable or disable a specific subagent pub async fn subagents_toggle( State(state): State>, Json(req): Json, ) -> (StatusCode, Json) { let scope = match SubagentScope::parse(&req.scope) { Some(s) => s, None => { return ( StatusCode::BAD_REQUEST, Json(SubagentToggleResponse { success: false, changed: None, available: None, disabled_in_scopes: None, error: Some(format!("invalid scope: {}", req.scope)), }), ); } }; let result = if req.enabled { state.subagent_runtime.enable_subagent(scope, &req.name) } else { state.subagent_runtime.disable_subagent(scope, &req.name) }; match result { Ok(change) => ( StatusCode::OK, Json(SubagentToggleResponse { success: true, changed: Some(change.changed), available: Some(change.available), disabled_in_scopes: Some( change .disabled_in_scopes .iter() .map(|s| s.as_str().to_string()) .collect(), ), error: None, }), ), Err(msg) => { let status = if msg.contains("not found") { StatusCode::NOT_FOUND } else { StatusCode::INTERNAL_SERVER_ERROR }; ( status, Json(SubagentToggleResponse { success: false, changed: None, available: None, disabled_in_scopes: None, error: Some(msg), }), ) } } } #[derive(Deserialize)] pub struct SubagentUpdateRequest { pub name: String, pub description: Option, pub body: Option, #[serde(default)] pub capability: Option, #[serde(default)] pub provider: Option, #[serde(default)] pub model: Option, } #[derive(Serialize)] pub struct SubagentUpdateResponse { pub success: bool, pub subagent: Option, #[serde(skip_serializing_if = "Option::is_none")] pub error: Option, } /// PUT /api/subagents/update — Update subagent capability/description/body (writes back SUBAGENT.md) pub async fn subagents_update( State(state): State>, Json(req): Json, ) -> Result, (StatusCode, String)> { let updated = state .subagent_runtime .update_subagent( &req.name, req.description.as_deref(), req.body.as_deref(), req.capability.as_ref(), Some(&req.provider), Some(&req.model), true, ) .map_err(|err| { let status = if err.contains("not found") { StatusCode::NOT_FOUND } else if err.contains("builtin") { StatusCode::BAD_REQUEST } else { StatusCode::INTERNAL_SERVER_ERROR }; (status, err) })?; // 返回更新后的状态(含 disabled_in_scopes) let status = state .subagent_runtime .list_with_status() .into_iter() .find(|s| s.name == updated.name) .unwrap_or_else(|| SubagentWithStatus { name: updated.name.clone(), description: updated.description.clone(), source: updated.source.as_str().to_string(), disabled_in_scopes: vec![], capability: updated.capability.clone(), provider: updated.provider.clone(), model: updated.model.clone(), }); Ok(Json(SubagentUpdateResponse { success: true, subagent: Some(status), error: None, })) } // ===================== Experts ===================== #[derive(Deserialize)] pub struct ExpertToggleRequest { pub name: String, pub scope: String, pub enabled: bool, } #[derive(Serialize)] pub struct ExpertToggleResponse { success: bool, #[serde(skip_serializing_if = "Option::is_none")] changed: Option, #[serde(skip_serializing_if = "Option::is_none")] available: Option, #[serde(skip_serializing_if = "Option::is_none")] disabled_in_scopes: Option>, #[serde(skip_serializing_if = "Option::is_none")] error: Option, } #[derive(Serialize)] pub struct ExpertListResponse { experts_system_enabled: bool, total: usize, experts: Vec, } #[derive(Deserialize)] pub struct ExpertCreateRequest { pub name: String, pub description: String, pub body: String, pub scope: String, #[serde(default)] pub capability: CapabilityPolicy, #[serde(default)] pub provider: Option, #[serde(default)] pub model: Option, } #[derive(Deserialize)] pub struct ExpertUpdateRequest { pub name: String, pub scope: String, pub description: Option, pub body: Option, #[serde(default)] pub capability: Option, #[serde(default)] pub provider: Option, #[serde(default)] pub model: Option, } #[derive(Deserialize)] pub struct ExpertDeleteRequest { pub name: String, pub scope: String, } #[derive(Deserialize)] pub struct ExpertSelectedQuery { pub session_id: String, } #[derive(Serialize)] pub struct ExpertSelectedResponse { pub expert_name: Option, pub expert: Option, } #[derive(Deserialize)] pub struct ExpertSelectRequest { pub session_id: String, pub expert_name: Option, } #[derive(Serialize)] pub struct ExpertSelectResponse { pub success: bool, #[serde(skip_serializing_if = "Option::is_none")] pub error: Option, } /// POST /api/session/select-model 请求体 #[derive(Deserialize)] pub struct SelectModelRequest { pub session_id: String, /// None 或空字符串表示清除覆盖(继承默认) pub provider: Option, /// None 或空字符串表示清除覆盖(继承默认) pub model: Option, } #[derive(Serialize)] pub struct SelectModelResponse { pub success: bool, #[serde(skip_serializing_if = "Option::is_none")] pub error: Option, } #[derive(Serialize)] pub struct ExpertResponse { pub name: String, pub description: String, pub body: String, pub source: String, pub path: String, #[serde(default)] pub capability: CapabilityPolicy, #[serde(default, skip_serializing_if = "Option::is_none")] pub provider: Option, #[serde(default, skip_serializing_if = "Option::is_none")] pub model: Option, } impl From for ExpertResponse { fn from(expert: Expert) -> Self { Self { name: expert.name, description: expert.description, body: expert.body, source: expert.source.as_str().to_string(), path: expert.path.display().to_string(), capability: expert.capability, provider: expert.provider, model: expert.model, } } } #[derive(Serialize)] pub struct ExpertDeleteResponse { pub success: bool, pub path: String, #[serde(skip_serializing_if = "Option::is_none")] pub error: Option, } /// GET /api/experts — Return all discovered experts with their disabled status pub async fn experts_list( State(state): State>, ) -> Json { let experts_enabled = state.config.read().await.experts.enabled; if !experts_enabled { return Json(ExpertListResponse { experts_system_enabled: false, total: 0, experts: vec![], }); } let experts = state.experts.list_experts_with_status(); let total = experts.len(); Json(ExpertListResponse { experts_system_enabled: true, total, experts, }) } /// POST /api/experts/toggle — Enable or disable a specific expert pub async fn experts_toggle( State(state): State>, Json(req): Json, ) -> (StatusCode, Json) { let scope = match ExpertScope::parse(&req.scope) { Some(s) => s, None => { return ( StatusCode::BAD_REQUEST, Json(ExpertToggleResponse { success: false, changed: None, available: None, disabled_in_scopes: None, error: Some(format!("invalid scope: {}", req.scope)), }), ); } }; let result = if req.enabled { state.experts.enable_expert(scope, &req.name) } else { state.experts.disable_expert(scope, &req.name) }; match result { Ok(change) => ( StatusCode::OK, Json(ExpertToggleResponse { success: true, changed: Some(change.changed), available: Some(change.available), disabled_in_scopes: Some( change .disabled_in_scopes .iter() .map(|s| s.as_str().to_string()) .collect(), ), error: None, }), ), Err(msg) => { let status = if msg.contains("not found") { StatusCode::NOT_FOUND } else { StatusCode::INTERNAL_SERVER_ERROR }; ( status, Json(ExpertToggleResponse { success: false, changed: None, available: None, disabled_in_scopes: None, error: Some(msg), }), ) } } } /// POST /api/experts/create — Create a new expert pub async fn experts_create( State(state): State>, Json(req): Json, ) -> Result, (StatusCode, String)> { let scope = ExpertScope::parse(&req.scope) .ok_or_else(|| (StatusCode::BAD_REQUEST, format!("invalid scope: {}", req.scope)))?; let expert = state .experts .create_expert( scope, &req.name, &req.description, &req.body, &req.capability, &req.provider, &req.model, true, ) .map_err(|err| { let status = if err.contains("already exists") { StatusCode::CONFLICT } else { StatusCode::BAD_REQUEST }; (status, err) })?; Ok(Json(ExpertResponse::from(expert))) } /// PUT /api/experts/update — Update an existing expert pub async fn experts_update( State(state): State>, Json(req): Json, ) -> Result, (StatusCode, String)> { let scope = ExpertScope::parse(&req.scope) .ok_or_else(|| (StatusCode::BAD_REQUEST, format!("invalid scope: {}", req.scope)))?; let expert = state .experts .update_expert( scope, &req.name, req.description.as_deref(), req.body.as_deref(), req.capability.as_ref(), Some(&req.provider), Some(&req.model), true, ) .map_err(|err| { let status = if err.contains("not found") { StatusCode::NOT_FOUND } else { StatusCode::BAD_REQUEST }; (status, err) })?; Ok(Json(ExpertResponse::from(expert))) } /// DELETE /api/experts/delete?name=&scope= — Delete an expert pub async fn experts_delete( State(state): State>, Query(req): Query, ) -> Result, (StatusCode, Json)> { let scope = match ExpertScope::parse(&req.scope) { Some(s) => s, None => { return Err(( StatusCode::BAD_REQUEST, Json(ExpertDeleteResponse { success: false, path: String::new(), error: Some(format!("invalid scope: {}", req.scope)), }), )); } }; match state.experts.delete_expert(scope, &req.name, true) { Ok(path) => Ok(Json(ExpertDeleteResponse { success: true, path: path.display().to_string(), error: None, })), Err(msg) => { let status = if msg.contains("not found") { StatusCode::NOT_FOUND } else { StatusCode::INTERNAL_SERVER_ERROR }; Err(( status, Json(ExpertDeleteResponse { success: false, path: String::new(), error: Some(msg), }), )) } } } /// GET /api/experts/selected?session_id=... — Return the currently selected expert for a session pub async fn experts_selected( State(state): State>, Query(q): Query, ) -> Json { let expert_name = state.experts.selected_expert_name_for(&q.session_id); let expert = expert_name.as_ref().and_then(|name| { // Build an ExpertWithStatus from the discovered catalog. state .experts .list_experts_with_status() .into_iter() .find(|e| &e.name == name) }); Json(ExpertSelectedResponse { expert_name, expert, }) } /// POST /api/experts/select — Select (or clear) the expert for a session pub async fn experts_select( State(state): State>, Json(req): Json, ) -> (StatusCode, Json) { let result = match req.expert_name { Some(name) => state.experts.select_expert(&req.session_id, &name), None => state.experts.clear_expert(&req.session_id), }; match result { Ok(()) => ( StatusCode::OK, Json(ExpertSelectResponse { success: true, error: None, }), ), Err(msg) => ( StatusCode::BAD_REQUEST, Json(ExpertSelectResponse { success: false, error: Some(msg), }), ), } } /// POST /api/session/select-model — 设置(或清除)session 的用户模型覆盖 pub async fn session_select_model( State(state): State>, Json(req): Json, ) -> (StatusCode, Json) { // 规范化:trim 后空字符串视为 None(与 frontmatter 解析逻辑一致) let provider = req .provider .map(|s| s.trim().to_string()) .filter(|s| !s.is_empty()); let model = req .model .map(|s| s.trim().to_string()) .filter(|s| !s.is_empty()); // 校验:provider/model 名必须在 config 的 providers/models 表中存在 // (与 AgentFactory::create 中的解析失败行为对齐,提前反馈错误) let config = state.config.read().await; if let Some(name) = provider.as_ref() { if !config.providers.contains_key(name) { return ( StatusCode::BAD_REQUEST, Json(SelectModelResponse { success: false, error: Some(format!("provider '{}' not found in config", name)), }), ); } } if let Some(name) = model.as_ref() { if !config.models.contains_key(name) { return ( StatusCode::BAD_REQUEST, Json(SelectModelResponse { success: false, error: Some(format!("model '{}' not found in config", name)), }), ); } } drop(config); state .model_selections .set(&req.session_id, provider, model); ( StatusCode::OK, Json(SelectModelResponse { success: true, error: None, }), ) } /// GET /api/session/selected-model?session_id=... — 返回该 session 当前的用户模型覆盖 #[derive(Deserialize)] pub struct SessionSelectedModelQuery { pub session_id: String, } #[derive(Serialize)] pub struct SessionSelectedModelResponse { /// None 表示未设置用户覆盖(继承默认 / 专家配置) pub provider: Option, pub model: Option, } pub async fn session_selected_model( State(state): State>, Query(q): Query, ) -> Json { let (provider, model) = state .model_selections .get(&q.session_id) .unwrap_or((None, None)); Json(SessionSelectedModelResponse { provider, model }) }