PicoBot/src/gateway/http.rs
oudecheng 89c444ad3f feat(gateway): 新增 /api/tools 与 /api/subagents/update 端点
- /api/tools: 只读访问 ToolRegistry,返回 builtin + MCP 工具的 name/description/source

- /api/subagents/update: PUT 接口,调用 SubagentRuntime::update_subagent 写回 SUBAGENT.md frontmatter

- 路由表两处同步注册新端点
2026-07-30 16:54:19 +08:00

880 lines
26 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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<bool>,
#[serde(skip_serializing_if = "Option::is_none")]
available: Option<bool>,
#[serde(skip_serializing_if = "Option::is_none")]
disabled_in_scopes: Option<Vec<String>>,
#[serde(skip_serializing_if = "Option::is_none")]
error: Option<String>,
}
#[derive(Serialize)]
pub struct SkillListResponse {
skills_system_enabled: bool,
total: usize,
skills: Vec<SkillWithStatus>,
}
#[derive(Serialize)]
pub struct HealthResponse {
status: String,
version: String,
}
pub async fn health() -> Json<HealthResponse> {
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<Arc<GatewayState>>,
) -> Json<Config> {
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<Arc<GatewayState>>,
Json(req): Json<SaveConfigRequest>,
) -> Result<Json<SaveConfigResponse>, (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<Arc<GatewayState>>,
) -> Result<Json<RestartResponse>, (StatusCode, Json<RestartResponse>)> {
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<Arc<GatewayState>>,
) -> Json<crate::mcp::client::McpStatusResponse> {
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<Arc<GatewayState>>,
) -> Json<SkillListResponse> {
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<ToolInfo>,
}
/// GET /api/tools — Return all registered tools (builtin + MCP) with name/description/source.
/// 通过 SessionManager::tools() 只读访问 ToolRegistry不修改状态。
pub async fn tools_list(
State(state): State<Arc<GatewayState>>,
) -> Json<ToolsListResponse> {
let registry = state.session_manager.tools();
let tools: Vec<ToolInfo> = 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 })
}
/// POST /api/skills/toggle — Enable or disable a specific skill
pub async fn skills_toggle(
State(state): State<Arc<GatewayState>>,
Json(req): Json<SkillToggleRequest>,
) -> (StatusCode, Json<SkillToggleResponse>) {
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<bool>,
#[serde(skip_serializing_if = "Option::is_none")]
available: Option<bool>,
#[serde(skip_serializing_if = "Option::is_none")]
disabled_in_scopes: Option<Vec<String>>,
#[serde(skip_serializing_if = "Option::is_none")]
error: Option<String>,
}
#[derive(Serialize)]
pub struct SubagentListResponse {
subagents_system_enabled: bool,
total: usize,
subagents: Vec<SubagentWithStatus>,
}
/// GET /api/subagents — Return all discovered subagents with their disabled status
pub async fn subagents_list(
State(state): State<Arc<GatewayState>>,
) -> Json<SubagentListResponse> {
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<Arc<GatewayState>>,
Json(req): Json<SubagentToggleRequest>,
) -> (StatusCode, Json<SubagentToggleResponse>) {
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<String>,
pub body: Option<String>,
#[serde(default)]
pub capability: Option<CapabilityPolicy>,
}
#[derive(Serialize)]
pub struct SubagentUpdateResponse {
pub success: bool,
pub subagent: Option<SubagentWithStatus>,
#[serde(skip_serializing_if = "Option::is_none")]
pub error: Option<String>,
}
/// PUT /api/subagents/update — Update subagent capability/description/body (writes back SUBAGENT.md)
pub async fn subagents_update(
State(state): State<Arc<GatewayState>>,
Json(req): Json<SubagentUpdateRequest>,
) -> Result<Json<SubagentUpdateResponse>, (StatusCode, String)> {
let updated = state
.subagent_runtime
.update_subagent(
&req.name,
req.description.as_deref(),
req.body.as_deref(),
req.capability.as_ref(),
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(),
});
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<bool>,
#[serde(skip_serializing_if = "Option::is_none")]
available: Option<bool>,
#[serde(skip_serializing_if = "Option::is_none")]
disabled_in_scopes: Option<Vec<String>>,
#[serde(skip_serializing_if = "Option::is_none")]
error: Option<String>,
}
#[derive(Serialize)]
pub struct ExpertListResponse {
experts_system_enabled: bool,
total: usize,
experts: Vec<ExpertWithStatus>,
}
#[derive(Deserialize)]
pub struct ExpertCreateRequest {
pub name: String,
pub description: String,
pub body: String,
pub scope: String,
#[serde(default)]
pub capability: CapabilityPolicy,
}
#[derive(Deserialize)]
pub struct ExpertUpdateRequest {
pub name: String,
pub scope: String,
pub description: Option<String>,
pub body: Option<String>,
#[serde(default)]
pub capability: Option<CapabilityPolicy>,
}
#[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<String>,
pub expert: Option<ExpertWithStatus>,
}
#[derive(Deserialize)]
pub struct ExpertSelectRequest {
pub session_id: String,
pub expert_name: Option<String>,
}
#[derive(Serialize)]
pub struct ExpertSelectResponse {
pub success: bool,
#[serde(skip_serializing_if = "Option::is_none")]
pub error: Option<String>,
}
#[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,
}
impl From<Expert> 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,
}
}
}
#[derive(Serialize)]
pub struct ExpertDeleteResponse {
pub success: bool,
pub path: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub error: Option<String>,
}
/// GET /api/experts — Return all discovered experts with their disabled status
pub async fn experts_list(
State(state): State<Arc<GatewayState>>,
) -> Json<ExpertListResponse> {
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<Arc<GatewayState>>,
Json(req): Json<ExpertToggleRequest>,
) -> (StatusCode, Json<ExpertToggleResponse>) {
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<Arc<GatewayState>>,
Json(req): Json<ExpertCreateRequest>,
) -> Result<Json<ExpertResponse>, (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, 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<Arc<GatewayState>>,
Json(req): Json<ExpertUpdateRequest>,
) -> Result<Json<ExpertResponse>, (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(),
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<Arc<GatewayState>>,
Query(req): Query<ExpertDeleteRequest>,
) -> Result<Json<ExpertDeleteResponse>, (StatusCode, Json<ExpertDeleteResponse>)> {
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<Arc<GatewayState>>,
Query(q): Query<ExpertSelectedQuery>,
) -> Json<ExpertSelectedResponse> {
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<Arc<GatewayState>>,
Json(req): Json<ExpertSelectRequest>,
) -> (StatusCode, Json<ExpertSelectResponse>) {
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),
}),
),
}
}