PicoBot/src/gateway/http.rs
oudecheng a9429a5657 fix(web): 输入框/发送按钮状态按话题隔离,避免处理中仍可发送
将单一全局 isLoading 布尔值重构为按 topic_id 跟踪的 processingTopicIds
集合,isLoading 派生自当前选中话题是否在集合中。导航响应不再清空处理
状态,切换话题后切回原话题仍能正确禁用输入。重连时通过新增的
/api/executions 端点对账后端权威执行状态,修正断连期间丢失的
execution_completed 信号导致的状态漂移。
2026-08-07 11:45:47 +08:00

1205 lines
36 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::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 })
}