将单一全局 isLoading 布尔值重构为按 topic_id 跟踪的 processingTopicIds 集合,isLoading 派生自当前选中话题是否在集合中。导航响应不再清空处理 状态,切换话题后切回原话题仍能正确禁用输入。重连时通过新增的 /api/executions 端点对账后端权威执行状态,修正断连期间丢失的 execution_completed 信号导致的状态漂移。
1205 lines
36 KiB
Rust
1205 lines
36 KiB
Rust
use axum::http::StatusCode;
|
||
use axum::{
|
||
Json,
|
||
extract::{Query, State},
|
||
};
|
||
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);
|
||
}
|
||
}
|
||
}
|
||
// 掩码网关认证 token(避免通过 /api/config 泄露)
|
||
if let Some(ref token) = masked.gateway.auth_token {
|
||
if !token.is_empty() {
|
||
let visible: String = token.chars().take(4).collect();
|
||
masked.gateway.auth_token = Some(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();
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
// 保留原始 auth_token(若提交的是掩码值)
|
||
if let Some(ref submitted) = new_config.gateway.auth_token {
|
||
if is_masked_key(submitted) {
|
||
new_config.gateway.auth_token = cfg.gateway.auth_token.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(),
|
||
}))
|
||
}
|
||
|
||
#[derive(Serialize)]
|
||
pub struct ExecutionsResponse {
|
||
/// 当前正在执行的 Agent 的 topic_id 列表
|
||
pub topic_ids: Vec<String>,
|
||
}
|
||
|
||
/// GET /api/executions — 返回当前正在执行的 Agent 的 topic_id 列表
|
||
///
|
||
/// 供前端重连时对账执行状态:前端据此判断断连期间哪些话题的
|
||
/// 智能体仍在运行(需保持禁用)、哪些已完成(应解锁)。
|
||
pub async fn list_executions(State(state): State<Arc<GatewayState>>) -> Json<ExecutionsResponse> {
|
||
let topic_ids = state.cancel_manager.list_active_topic_ids().await;
|
||
Json(ExecutionsResponse { topic_ids })
|
||
}
|
||
|
||
/// 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/model-options 返回可用的 provider/model 名列表(供专家/子代理编辑下拉框)。
|
||
#[derive(Serialize)]
|
||
pub struct ModelOptionsResponse {
|
||
pub providers: Vec<String>,
|
||
pub models: Vec<String>,
|
||
/// 当前默认 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<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 })
|
||
}
|
||
|
||
/// GET /api/model-options — 返回 config.json 中配置的 provider/model 名列表。
|
||
pub async fn model_options(State(state): State<Arc<GatewayState>>) -> Json<ModelOptionsResponse> {
|
||
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<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>,
|
||
#[serde(default)]
|
||
pub provider: Option<String>,
|
||
#[serde(default)]
|
||
pub model: Option<String>,
|
||
}
|
||
|
||
#[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(),
|
||
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(),
|
||
body: updated.body.clone(),
|
||
});
|
||
|
||
Ok(Json(SubagentUpdateResponse {
|
||
success: true,
|
||
subagent: Some(status),
|
||
error: None,
|
||
}))
|
||
}
|
||
|
||
#[derive(Deserialize)]
|
||
pub struct SubagentCreateRequest {
|
||
pub name: String,
|
||
pub description: String,
|
||
#[serde(default)]
|
||
pub body: String,
|
||
pub scope: String,
|
||
#[serde(default)]
|
||
pub capability: CapabilityPolicy,
|
||
#[serde(default)]
|
||
pub provider: Option<String>,
|
||
#[serde(default)]
|
||
pub model: Option<String>,
|
||
}
|
||
|
||
#[derive(Deserialize)]
|
||
pub struct SubagentDeleteRequest {
|
||
pub name: String,
|
||
}
|
||
|
||
#[derive(Serialize)]
|
||
pub struct SubagentDeleteResponse {
|
||
pub success: bool,
|
||
pub path: String,
|
||
#[serde(skip_serializing_if = "Option::is_none")]
|
||
pub error: Option<String>,
|
||
}
|
||
|
||
/// POST /api/subagents/create — Create a new subagent (writes SUBAGENT.md)
|
||
pub async fn subagents_create(
|
||
State(state): State<Arc<GatewayState>>,
|
||
Json(req): Json<SubagentCreateRequest>,
|
||
) -> Result<Json<SubagentUpdateResponse>, (StatusCode, String)> {
|
||
let scope = SubagentScope::parse(&req.scope).ok_or_else(|| {
|
||
(
|
||
StatusCode::BAD_REQUEST,
|
||
format!("invalid scope: {}", req.scope),
|
||
)
|
||
})?;
|
||
|
||
let created = state
|
||
.subagent_runtime
|
||
.create_subagent(
|
||
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)
|
||
})?;
|
||
|
||
// 返回创建后的状态(含 disabled_in_scopes)
|
||
let status = state
|
||
.subagent_runtime
|
||
.list_with_status()
|
||
.into_iter()
|
||
.find(|s| s.name == created.name)
|
||
.unwrap_or_else(|| SubagentWithStatus {
|
||
name: created.name.clone(),
|
||
description: created.description.clone(),
|
||
source: created.source.as_str().to_string(),
|
||
disabled_in_scopes: vec![],
|
||
capability: created.capability.clone(),
|
||
provider: created.provider.clone(),
|
||
model: created.model.clone(),
|
||
body: created.body.clone(),
|
||
});
|
||
|
||
Ok(Json(SubagentUpdateResponse {
|
||
success: true,
|
||
subagent: Some(status),
|
||
error: None,
|
||
}))
|
||
}
|
||
|
||
/// DELETE /api/subagents/delete?name= — Delete a subagent (removes SUBAGENT.md)
|
||
pub async fn subagents_delete(
|
||
State(state): State<Arc<GatewayState>>,
|
||
Query(req): Query<SubagentDeleteRequest>,
|
||
) -> Result<Json<SubagentDeleteResponse>, (StatusCode, Json<SubagentDeleteResponse>)> {
|
||
let path = state
|
||
.subagent_runtime
|
||
.delete_subagent(&req.name, 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,
|
||
Json(SubagentDeleteResponse {
|
||
success: false,
|
||
path: String::new(),
|
||
error: Some(err),
|
||
}),
|
||
)
|
||
})?;
|
||
|
||
Ok(Json(SubagentDeleteResponse {
|
||
success: true,
|
||
path: path.display().to_string(),
|
||
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,
|
||
#[serde(default)]
|
||
pub provider: Option<String>,
|
||
#[serde(default)]
|
||
pub model: Option<String>,
|
||
}
|
||
|
||
#[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>,
|
||
#[serde(default)]
|
||
pub provider: Option<String>,
|
||
#[serde(default)]
|
||
pub model: Option<String>,
|
||
}
|
||
|
||
#[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>,
|
||
}
|
||
|
||
/// POST /api/session/select-model 请求体
|
||
#[derive(Deserialize)]
|
||
pub struct SelectModelRequest {
|
||
pub session_id: String,
|
||
/// None 或空字符串表示清除覆盖(继承默认)
|
||
pub provider: Option<String>,
|
||
/// None 或空字符串表示清除覆盖(继承默认)
|
||
pub model: Option<String>,
|
||
}
|
||
|
||
#[derive(Serialize)]
|
||
pub struct SelectModelResponse {
|
||
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,
|
||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||
pub provider: Option<String>,
|
||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||
pub model: Option<String>,
|
||
}
|
||
|
||
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,
|
||
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<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,
|
||
&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<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(),
|
||
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<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),
|
||
}),
|
||
),
|
||
}
|
||
}
|
||
|
||
/// POST /api/session/select-model — 设置(或清除)session 的用户模型覆盖
|
||
pub async fn session_select_model(
|
||
State(state): State<Arc<GatewayState>>,
|
||
Json(req): Json<SelectModelRequest>,
|
||
) -> (StatusCode, Json<SelectModelResponse>) {
|
||
// 规范化: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<String>,
|
||
pub model: Option<String>,
|
||
}
|
||
|
||
pub async fn session_selected_model(
|
||
State(state): State<Arc<GatewayState>>,
|
||
Query(q): Query<SessionSelectedModelQuery>,
|
||
) -> Json<SessionSelectedModelResponse> {
|
||
let (provider, model) = state
|
||
.model_selections
|
||
.get(&q.session_id)
|
||
.unwrap_or((None, None));
|
||
Json(SessionSelectedModelResponse { provider, model })
|
||
}
|