feat: 新增子代理管理API,支持获取子代理列表和切换子代理状态
This commit is contained in:
parent
38b9f661ee
commit
8d30ccd020
@ -7,12 +7,14 @@ use crate::gateway::todo_prompt_provider::TodoPromptProvider;
|
||||
use crate::skills::{SkillPromptProvider, SkillRuntime};
|
||||
use crate::storage::persistent_session_id;
|
||||
use crate::storage::PromptInjectionRepository;
|
||||
use crate::tools::task::runtime::{SubagentPromptProvider, SubagentRuntime};
|
||||
use crate::tools::{ToolContext, ToolRegistry};
|
||||
|
||||
#[derive(Clone)]
|
||||
pub(crate) struct AgentFactory {
|
||||
tools: Arc<ToolRegistry>,
|
||||
skills: Arc<SkillRuntime>,
|
||||
subagent_runtime: Arc<SubagentRuntime>,
|
||||
reinject_every: usize,
|
||||
prompt_repository: Arc<dyn PromptInjectionRepository>,
|
||||
/// 实例创建时间戳(用于区分新旧 AgentFactory 实例)
|
||||
@ -36,6 +38,7 @@ impl AgentFactory {
|
||||
pub(crate) fn new(
|
||||
tools: Arc<ToolRegistry>,
|
||||
skills: Arc<SkillRuntime>,
|
||||
subagent_runtime: Arc<SubagentRuntime>,
|
||||
reinject_every: usize,
|
||||
prompt_repository: Arc<dyn PromptInjectionRepository>,
|
||||
) -> Self {
|
||||
@ -49,6 +52,7 @@ impl AgentFactory {
|
||||
Self {
|
||||
tools,
|
||||
skills,
|
||||
subagent_runtime,
|
||||
reinject_every,
|
||||
prompt_repository,
|
||||
instance_id,
|
||||
@ -77,6 +81,7 @@ impl AgentFactory {
|
||||
self.prompt_repository.clone(),
|
||||
)),
|
||||
Box::new(SkillPromptProvider::new(self.skills.clone())),
|
||||
Box::new(SubagentPromptProvider::new(self.subagent_runtime.clone())),
|
||||
Box::new(TodoPromptProvider::new()),
|
||||
]));
|
||||
|
||||
|
||||
@ -6,6 +6,7 @@ use std::sync::Arc;
|
||||
use super::GatewayState;
|
||||
use crate::config::{Config, get_default_config_path};
|
||||
use crate::skills::SkillWithStatus;
|
||||
use crate::tools::task::runtime::{SubagentScope, SubagentWithStatus};
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub struct SkillToggleRequest {
|
||||
@ -294,3 +295,118 @@ pub async fn skills_toggle(
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[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),
|
||||
}),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -43,6 +43,7 @@ use crate::logging;
|
||||
use crate::scheduler::Scheduler;
|
||||
use crate::skills::SkillRuntime;
|
||||
use crate::tools::task::repository::TaskRepository;
|
||||
use crate::tools::task::runtime::SubagentRuntime;
|
||||
use agent_task_executor::{AgentTaskExecutor, SchedulerMaintenanceService};
|
||||
use cancel_manager::CancelManager;
|
||||
use outbound_dispatcher::OutboundDispatcher;
|
||||
@ -64,6 +65,7 @@ pub struct GatewayState {
|
||||
pub restart_tx: watch::Sender<bool>,
|
||||
pub mcp_manager: Option<Arc<crate::mcp::client::McpClientManager>>,
|
||||
pub skills: Arc<SkillRuntime>,
|
||||
pub subagent_runtime: Arc<SubagentRuntime>,
|
||||
}
|
||||
|
||||
impl GatewayState {
|
||||
@ -88,7 +90,7 @@ impl GatewayState {
|
||||
mcp_servers: config.mcp_servers.clone(),
|
||||
};
|
||||
|
||||
let (session_manager, task_repository, mcp_manager) = build_session_manager_with_sender(
|
||||
let (session_manager, task_repository, mcp_manager, subagent_runtime) = build_session_manager_with_sender(
|
||||
agent_prompt_reinject_every,
|
||||
show_tool_results,
|
||||
config.time.timezone.clone(),
|
||||
@ -123,6 +125,7 @@ impl GatewayState {
|
||||
restart_tx,
|
||||
mcp_manager,
|
||||
skills,
|
||||
subagent_runtime,
|
||||
})
|
||||
}
|
||||
|
||||
@ -234,6 +237,8 @@ pub async fn run(
|
||||
.route("/api/mcp/status", routing::get(http::mcp_status))
|
||||
.route("/api/skills", routing::get(http::skills_list))
|
||||
.route("/api/skills/toggle", routing::post(http::skills_toggle))
|
||||
.route("/api/subagents", routing::get(http::subagents_list))
|
||||
.route("/api/subagents/toggle", routing::post(http::subagents_toggle))
|
||||
.route("/ws", routing::get(ws::ws_handler))
|
||||
.fallback(static_handler)
|
||||
.with_state(state.clone())
|
||||
@ -246,6 +251,8 @@ pub async fn run(
|
||||
.route("/api/mcp/status", routing::get(http::mcp_status))
|
||||
.route("/api/skills", routing::get(http::skills_list))
|
||||
.route("/api/skills/toggle", routing::post(http::skills_toggle))
|
||||
.route("/api/subagents", routing::get(http::subagents_list))
|
||||
.route("/api/subagents/toggle", routing::post(http::subagents_toggle))
|
||||
.route("/ws", routing::get(ws::ws_handler))
|
||||
.fallback_service(ServeDir::new(&static_dir))
|
||||
.with_state(state.clone())
|
||||
|
||||
@ -1,6 +1,7 @@
|
||||
//! Gateway Runtime - builds SessionManager with decoupled MCP integration
|
||||
|
||||
use std::collections::{HashMap, HashSet};
|
||||
use std::path::PathBuf;
|
||||
use std::sync::Arc;
|
||||
|
||||
use tokio::sync::RwLock;
|
||||
@ -16,6 +17,7 @@ use crate::storage::{
|
||||
ConversationRepository, MemoryRepository, PromptInjectionRepository, SchedulerJobRepository,
|
||||
SessionStore, SkillEventRepository, TodoRepository,
|
||||
};
|
||||
use crate::tools::task::runtime::SubagentRuntime;
|
||||
use crate::tools::{
|
||||
DefaultSubAgentRuntime, InMemoryTaskRepository, NoopSessionMessageSender,
|
||||
SessionMessageSender, SubAgentRuntimeConfig, SubagentCatalog, TaskTool, ToolRegistry,
|
||||
@ -50,7 +52,7 @@ pub(crate) fn build_session_manager(
|
||||
session_ttl_hours: Option<u64>,
|
||||
mcp_config: crate::mcp::McpConfig,
|
||||
bus: Option<Arc<MessageBus>>,
|
||||
) -> Result<(SessionManager, Arc<dyn TaskRepository>, Option<Arc<McpClientManager>>), AgentError> {
|
||||
) -> Result<(SessionManager, Arc<dyn TaskRepository>, Option<Arc<McpClientManager>>, Arc<SubagentRuntime>), AgentError> {
|
||||
build_session_manager_with_sender(
|
||||
agent_prompt_reinject_every,
|
||||
show_tool_results,
|
||||
@ -85,7 +87,7 @@ pub(crate) fn build_session_manager_with_sender(
|
||||
session_ttl_hours: Option<u64>,
|
||||
mcp_config: crate::mcp::McpConfig,
|
||||
bus: Option<Arc<MessageBus>>,
|
||||
) -> Result<(SessionManager, Arc<dyn TaskRepository>, Option<Arc<McpClientManager>>), AgentError> {
|
||||
) -> Result<(SessionManager, Arc<dyn TaskRepository>, Option<Arc<McpClientManager>>, Arc<SubagentRuntime>), AgentError> {
|
||||
let store = Arc::new(
|
||||
SessionStore::new()
|
||||
.map_err(|err| AgentError::Other(format!("session store init error: {}", err)))?,
|
||||
@ -172,7 +174,7 @@ pub(crate) fn build_session_manager_with_sender(
|
||||
}
|
||||
|
||||
// Create SubAgentRuntime (if task tool is enabled)
|
||||
let (factory, task_repository): (_, Arc<dyn TaskRepository>) = if task_config.enabled {
|
||||
let (factory, task_repository, subagent_runtime): (_, Arc<dyn TaskRepository>, Arc<SubagentRuntime>) = if task_config.enabled {
|
||||
let task_repository = Arc::new(InMemoryTaskRepository::new());
|
||||
// Build subagent tools with MCP tools (task tool registered separately below)
|
||||
let subagent_tools = Arc::new(
|
||||
@ -185,8 +187,13 @@ pub(crate) fn build_session_manager_with_sender(
|
||||
)
|
||||
);
|
||||
|
||||
// Create subagent catalog with discovery
|
||||
// Create subagent catalog with discovery, wrap in SubagentRuntime
|
||||
let catalog = Arc::new(SubagentCatalog::discover(&subagents_config));
|
||||
let subagent_runtime = Arc::new(SubagentRuntime::new(
|
||||
subagents_config.clone(),
|
||||
catalog,
|
||||
std::env::current_dir().unwrap_or_else(|_| PathBuf::from(".")),
|
||||
));
|
||||
|
||||
let runtime_config = SubAgentRuntimeConfig {
|
||||
default_allowed_tools: task_config.allowed_tools.iter().cloned().collect(),
|
||||
@ -197,13 +204,13 @@ pub(crate) fn build_session_manager_with_sender(
|
||||
max_nesting_depth: task_config.max_nesting_depth,
|
||||
};
|
||||
|
||||
let subagent_runtime = Arc::new(DefaultSubAgentRuntime::new(
|
||||
let default_subagent_runtime = Arc::new(DefaultSubAgentRuntime::new(
|
||||
runtime_config,
|
||||
task_repository.clone(),
|
||||
conversations.clone(),
|
||||
subagent_tools.clone(),
|
||||
provider_config.clone(),
|
||||
catalog,
|
||||
subagent_runtime.clone(),
|
||||
bus.clone(),
|
||||
store.clone(),
|
||||
));
|
||||
@ -211,14 +218,16 @@ pub(crate) fn build_session_manager_with_sender(
|
||||
// 注册 task 工具到子代理工具集(需在 runtime 创建之后,打破循环依赖)
|
||||
if factory.is_enabled("task") {
|
||||
subagent_tools.register(TaskTool::new(
|
||||
subagent_runtime.clone(),
|
||||
default_subagent_runtime.clone(),
|
||||
Some(task_config.max_nesting_depth),
|
||||
));
|
||||
}
|
||||
|
||||
(factory.with_subagent_runtime(subagent_runtime), task_repository)
|
||||
(factory.with_subagent_runtime(default_subagent_runtime), task_repository, subagent_runtime)
|
||||
} else {
|
||||
(factory, Arc::new(InMemoryTaskRepository::new()))
|
||||
// task_config 未启用时仍创建 subagent_runtime(供 API 使用)
|
||||
let subagent_runtime = Arc::new(SubagentRuntime::from_config(subagents_config.clone()));
|
||||
(factory, Arc::new(InMemoryTaskRepository::new()), subagent_runtime)
|
||||
};
|
||||
|
||||
// Build base tools
|
||||
@ -260,6 +269,7 @@ pub(crate) fn build_session_manager_with_sender(
|
||||
let agent_factory = AgentFactory::new(
|
||||
tools.clone(),
|
||||
skills.clone(),
|
||||
subagent_runtime.clone(),
|
||||
agent_prompt_reinject_every as usize,
|
||||
prompt_repository.clone(),
|
||||
);
|
||||
@ -296,5 +306,5 @@ pub(crate) fn build_session_manager_with_sender(
|
||||
scheduled_tasks,
|
||||
memory_maintenance,
|
||||
task_repository: task_repository.clone(),
|
||||
}), task_repository, mcp_manager))
|
||||
}), task_repository, mcp_manager, subagent_runtime))
|
||||
}
|
||||
|
||||
@ -10,6 +10,7 @@ use crate::skills::SkillRuntime;
|
||||
use crate::storage::{ConversationRepository, PromptInjectionRepository, SessionRecord, SessionStore, SkillEventRepository};
|
||||
use crate::tools::ToolRegistry;
|
||||
use crate::tools::task::repository::TaskRepository;
|
||||
use crate::tools::task::runtime::SubagentRuntime;
|
||||
use async_trait::async_trait;
|
||||
use std::collections::HashMap;
|
||||
use std::sync::Arc;
|
||||
@ -249,6 +250,7 @@ impl Session {
|
||||
skills: Arc<SkillRuntime>,
|
||||
store: Arc<SessionStore>,
|
||||
agent_prompt_reinject_every: u64,
|
||||
subagent_runtime: Arc<SubagentRuntime>,
|
||||
) -> Result<Self, AgentError> {
|
||||
let conversations: Arc<dyn ConversationRepository> = store.clone();
|
||||
let skill_events: Arc<dyn SkillEventRepository> = store.clone();
|
||||
@ -256,6 +258,7 @@ impl Session {
|
||||
let agent_factory = AgentFactory::new(
|
||||
tools,
|
||||
skills.clone(),
|
||||
subagent_runtime,
|
||||
agent_prompt_reinject_every as usize,
|
||||
prompt_repository.clone(),
|
||||
);
|
||||
@ -682,7 +685,7 @@ impl SessionManager {
|
||||
mcp_config,
|
||||
None,
|
||||
)
|
||||
.map(|(session_manager, _, _)| session_manager)
|
||||
.map(|(session_manager, _, _, _)| session_manager)
|
||||
}
|
||||
|
||||
pub fn tools(&self) -> Arc<ToolRegistry> {
|
||||
@ -938,6 +941,7 @@ mod tests {
|
||||
skills,
|
||||
store,
|
||||
100,
|
||||
Arc::new(SubagentRuntime::from_config(Default::default())),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
@ -986,6 +990,7 @@ mod tests {
|
||||
skills,
|
||||
store.clone(),
|
||||
100,
|
||||
Arc::new(SubagentRuntime::from_config(Default::default())),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
@ -2012,6 +2017,7 @@ mod tests {
|
||||
skills,
|
||||
store.clone(),
|
||||
100,
|
||||
Arc::new(SubagentRuntime::from_config(Default::default())),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
@ -2052,6 +2058,7 @@ mod tests {
|
||||
skills,
|
||||
store.clone(),
|
||||
100,
|
||||
Arc::new(SubagentRuntime::from_config(Default::default())),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
@ -2125,6 +2132,7 @@ mod tests {
|
||||
skills,
|
||||
store.clone(),
|
||||
0,
|
||||
Arc::new(SubagentRuntime::from_config(Default::default())),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
@ -1,7 +1,7 @@
|
||||
use std::collections::{HashMap, HashSet};
|
||||
use std::fs;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::sync::Arc;
|
||||
use std::sync::{Arc, RwLock};
|
||||
use std::time::Duration;
|
||||
|
||||
use async_trait::async_trait;
|
||||
@ -295,8 +295,8 @@ pub struct DefaultSubAgentRuntime {
|
||||
conversation_repository: Arc<dyn ConversationRepository>,
|
||||
subagent_tools: Arc<ToolRegistry>,
|
||||
provider_config: LLMProviderConfig,
|
||||
/// 子代理定义目录(内置 + 自定义)
|
||||
catalog: Arc<SubagentCatalog>,
|
||||
/// 子代理运行时协调层(管理禁用状态)
|
||||
subagent_runtime: Arc<SubagentRuntime>,
|
||||
bus: Option<Arc<MessageBus>>,
|
||||
store: Arc<SessionStore>,
|
||||
}
|
||||
@ -308,7 +308,7 @@ impl DefaultSubAgentRuntime {
|
||||
conversation_repository: Arc<dyn ConversationRepository>,
|
||||
subagent_tools: Arc<ToolRegistry>,
|
||||
provider_config: LLMProviderConfig,
|
||||
catalog: Arc<SubagentCatalog>,
|
||||
subagent_runtime: Arc<SubagentRuntime>,
|
||||
bus: Option<Arc<MessageBus>>,
|
||||
store: Arc<SessionStore>,
|
||||
) -> Self {
|
||||
@ -318,18 +318,17 @@ impl DefaultSubAgentRuntime {
|
||||
conversation_repository,
|
||||
subagent_tools,
|
||||
provider_config,
|
||||
catalog,
|
||||
subagent_runtime,
|
||||
bus,
|
||||
store,
|
||||
}
|
||||
}
|
||||
|
||||
/// 查找子代理定义,找不到时 fallback 到 general
|
||||
fn find_subagent_def(&self, type_name: &str) -> SubagentDef {
|
||||
self.catalog
|
||||
.find(type_name)
|
||||
.cloned()
|
||||
.unwrap_or_else(|| self.catalog.find("general").expect("general subagent must exist").clone())
|
||||
/// 查找子代理定义(过滤禁用项),找不到或被禁用时返回 Err
|
||||
fn find_subagent_def(&self, type_name: &str) -> Result<SubagentDef, String> {
|
||||
self.subagent_runtime
|
||||
.find_available(type_name)
|
||||
.ok_or_else(|| format!("subagent type '{}' is disabled or not found", type_name))
|
||||
}
|
||||
|
||||
/// 获取实际使用的工具白名单(预留,未来可用于动态工具过滤)
|
||||
@ -530,7 +529,9 @@ impl SubAgentRuntime for DefaultSubAgentRuntime {
|
||||
.ok_or_else(|| TaskError::MissingContext("channel_name".to_string()))?;
|
||||
|
||||
// 2. 查找子代理定义
|
||||
let def = self.find_subagent_def(task.subagent_type.as_str());
|
||||
let def = self
|
||||
.find_subagent_def(task.subagent_type.as_str())
|
||||
.map_err(TaskError::InvalidArguments)?;
|
||||
|
||||
// 3. 创建任务会话
|
||||
let topic_id = parent_context.topic_id.clone();
|
||||
@ -733,7 +734,7 @@ impl SubAgentRuntime for DefaultSubAgentRuntime {
|
||||
}
|
||||
|
||||
fn available_subagent_names(&self) -> Vec<String> {
|
||||
self.catalog.names()
|
||||
self.subagent_runtime.available_names()
|
||||
}
|
||||
}
|
||||
|
||||
@ -870,6 +871,357 @@ fn xml_escape(s: &str) -> String {
|
||||
.replace('\'', "'")
|
||||
}
|
||||
|
||||
// ========== 子代理运行时协调层(管理禁用状态) ==========
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
|
||||
pub enum SubagentScope {
|
||||
User,
|
||||
Project,
|
||||
}
|
||||
|
||||
impl SubagentScope {
|
||||
pub fn parse(value: &str) -> Option<Self> {
|
||||
match value {
|
||||
"user" => Some(Self::User),
|
||||
"project" => Some(Self::Project),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn as_str(&self) -> &'static str {
|
||||
match self {
|
||||
Self::User => "user",
|
||||
Self::Project => "project",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A subagent entry with its disabled status across scopes.
|
||||
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
|
||||
pub struct SubagentWithStatus {
|
||||
pub name: String,
|
||||
pub description: String,
|
||||
pub source: String,
|
||||
/// Which scopes have this subagent disabled. Empty means enabled.
|
||||
pub disabled_in_scopes: Vec<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct SubagentAvailabilityChange {
|
||||
pub name: String,
|
||||
pub scope: SubagentScope,
|
||||
pub changed: bool,
|
||||
pub disabled_in_scopes: Vec<SubagentScope>,
|
||||
pub available: bool,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Default, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
|
||||
struct SubagentStateFile {
|
||||
#[serde(default)]
|
||||
disabled_subagents: Vec<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Default)]
|
||||
struct SubagentDisableState {
|
||||
user_disabled: HashSet<String>,
|
||||
project_disabled: HashSet<String>,
|
||||
}
|
||||
|
||||
impl SubagentDisableState {
|
||||
fn is_disabled(&self, name: &str) -> bool {
|
||||
self.user_disabled.contains(name) || self.project_disabled.contains(name)
|
||||
}
|
||||
|
||||
fn disabled_scopes_for(&self, name: &str) -> Vec<SubagentScope> {
|
||||
let mut scopes = Vec::new();
|
||||
if self.user_disabled.contains(name) {
|
||||
scopes.push(SubagentScope::User);
|
||||
}
|
||||
if self.project_disabled.contains(name) {
|
||||
scopes.push(SubagentScope::Project);
|
||||
}
|
||||
scopes
|
||||
}
|
||||
}
|
||||
|
||||
fn user_subagent_state_path() -> Option<PathBuf> {
|
||||
crate::platform::home_dir().map(|p| p.join(".picobot").join("subagent-state.json"))
|
||||
}
|
||||
|
||||
fn project_subagent_state_path(cwd: &Path) -> PathBuf {
|
||||
cwd.join(".picobot").join("subagent-state.json")
|
||||
}
|
||||
|
||||
fn subagent_state_path(scope: SubagentScope, cwd: &Path) -> PathBuf {
|
||||
match scope {
|
||||
SubagentScope::User => user_subagent_state_path()
|
||||
.unwrap_or_else(|| cwd.join(".picobot").join("subagent-state.json")),
|
||||
SubagentScope::Project => project_subagent_state_path(cwd),
|
||||
}
|
||||
}
|
||||
|
||||
fn load_subagent_disable_state(cwd: &Path) -> SubagentDisableState {
|
||||
SubagentDisableState {
|
||||
user_disabled: user_subagent_state_path()
|
||||
.map(|path| load_disabled_subagent_names(&path))
|
||||
.unwrap_or_default(),
|
||||
project_disabled: load_disabled_subagent_names(&project_subagent_state_path(cwd)),
|
||||
}
|
||||
}
|
||||
|
||||
fn load_disabled_subagent_names(path: &Path) -> HashSet<String> {
|
||||
match load_subagent_state_file(path) {
|
||||
Ok(state) => state.disabled_subagents.into_iter().collect(),
|
||||
Err(err) => {
|
||||
tracing::warn!(path = %path.display(), error = %err, "Failed to load subagent state file");
|
||||
HashSet::new()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn load_subagent_state_file(path: &Path) -> Result<SubagentStateFile, String> {
|
||||
if !path.exists() {
|
||||
return Ok(SubagentStateFile::default());
|
||||
}
|
||||
let content = fs::read_to_string(path)
|
||||
.map_err(|err| format!("failed to read subagent state file: {}", err))?;
|
||||
serde_json::from_str(&content)
|
||||
.map_err(|err| format!("failed to parse subagent state file: {}", err))
|
||||
}
|
||||
|
||||
fn save_subagent_state_file(path: &Path, state: &SubagentStateFile) -> Result<(), String> {
|
||||
if let Some(parent) = path.parent() {
|
||||
fs::create_dir_all(parent)
|
||||
.map_err(|err| format!("failed to create subagent state directory: {}", err))?;
|
||||
}
|
||||
let content = serde_json::to_string_pretty(state)
|
||||
.map_err(|err| format!("failed to render subagent state file: {}", err))?;
|
||||
let tmp_path = path.with_extension("json.tmp");
|
||||
fs::write(&tmp_path, format!("{}\n", content))
|
||||
.map_err(|err| format!("failed to write temporary subagent state file: {}", err))?;
|
||||
crate::platform::atomic_rename(&tmp_path, path)
|
||||
.map_err(|err| format!("failed to persist subagent state file: {}", err))?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// 子代理运行时协调层
|
||||
///
|
||||
/// 在 `SubagentCatalog`(纯数据容器)之上管理禁用状态,所有过滤逻辑在此层。
|
||||
/// 对齐 `SkillRuntime` 模式。
|
||||
#[derive(Debug)]
|
||||
pub struct SubagentRuntime {
|
||||
catalog: Arc<SubagentCatalog>,
|
||||
disable_state: RwLock<SubagentDisableState>,
|
||||
#[allow(dead_code)]
|
||||
config: SubagentsConfig,
|
||||
cwd: PathBuf,
|
||||
}
|
||||
|
||||
impl SubagentRuntime {
|
||||
pub fn new(config: SubagentsConfig, catalog: Arc<SubagentCatalog>, cwd: PathBuf) -> Self {
|
||||
let disable_state = load_subagent_disable_state(&cwd);
|
||||
Self {
|
||||
catalog,
|
||||
disable_state: RwLock::new(disable_state),
|
||||
config,
|
||||
cwd,
|
||||
}
|
||||
}
|
||||
|
||||
/// 从配置构造(discover + wrap)
|
||||
pub fn from_config(config: SubagentsConfig) -> Self {
|
||||
let cwd = std::env::current_dir().unwrap_or_else(|_| PathBuf::from("."));
|
||||
let catalog = Arc::new(SubagentCatalog::discover(&config));
|
||||
Self::new(config, catalog, cwd)
|
||||
}
|
||||
|
||||
/// 列出所有子代理(含禁用项),带 disabled_in_scopes
|
||||
pub fn list_with_status(&self) -> Vec<SubagentWithStatus> {
|
||||
let state = self.disable_state.read().expect("subagent state rwlock poisoned");
|
||||
let mut items: Vec<SubagentWithStatus> = self
|
||||
.catalog
|
||||
.all()
|
||||
.iter()
|
||||
.map(|def| {
|
||||
let scopes = state.disabled_scopes_for(&def.name);
|
||||
SubagentWithStatus {
|
||||
name: def.name.clone(),
|
||||
description: def.description.clone(),
|
||||
source: def.source.as_str().to_string(),
|
||||
disabled_in_scopes: scopes.iter().map(|s| s.as_str().to_string()).collect(),
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
items.sort_by(|a, b| a.name.cmp(&b.name));
|
||||
items
|
||||
}
|
||||
|
||||
/// 可用子代理名称(过滤禁用项)
|
||||
pub fn available_names(&self) -> Vec<String> {
|
||||
let state = self.disable_state.read().expect("subagent state rwlock poisoned");
|
||||
self.catalog
|
||||
.names()
|
||||
.into_iter()
|
||||
.filter(|name| !state.is_disabled(name))
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// 查找可用子代理(过滤禁用项)
|
||||
pub fn find_available(&self, name: &str) -> Option<SubagentDef> {
|
||||
let state = self.disable_state.read().expect("subagent state rwlock poisoned");
|
||||
if state.is_disabled(name) {
|
||||
return None;
|
||||
}
|
||||
self.catalog.find(name).cloned()
|
||||
}
|
||||
|
||||
/// 生成过滤后的系统索引提示词
|
||||
pub fn system_index_prompt_filtered(&self) -> Option<String> {
|
||||
let state = self.disable_state.read().expect("subagent state rwlock poisoned");
|
||||
let available_defs: Vec<&SubagentDef> = self
|
||||
.catalog
|
||||
.all()
|
||||
.into_iter()
|
||||
.filter(|def| !state.is_disabled(&def.name))
|
||||
.collect();
|
||||
|
||||
if available_defs.is_empty() {
|
||||
return None;
|
||||
}
|
||||
|
||||
let mut prompt = String::from(
|
||||
"# 子代理系统\n\n\
|
||||
子代理是专用的执行单元,用于处理特定类型的任务。\n\
|
||||
创建子代理任务时,可以选择以下类型之一:\n\n\
|
||||
<available_subagents>\n",
|
||||
);
|
||||
|
||||
for def in available_defs {
|
||||
prompt.push_str(&format!(
|
||||
" <subagent>\n <name>{}</name>\n <description>{}</description>\n </subagent>\n",
|
||||
xml_escape(&def.name),
|
||||
xml_escape(&def.description),
|
||||
));
|
||||
}
|
||||
|
||||
prompt.push_str("</available_subagents>");
|
||||
Some(prompt)
|
||||
}
|
||||
|
||||
/// 禁用子代理
|
||||
pub fn disable_subagent(
|
||||
&self,
|
||||
scope: SubagentScope,
|
||||
name: &str,
|
||||
) -> Result<SubagentAvailabilityChange, String> {
|
||||
self.set_subagent_enabled(scope, name, false)
|
||||
}
|
||||
|
||||
/// 启用子代理
|
||||
pub fn enable_subagent(
|
||||
&self,
|
||||
scope: SubagentScope,
|
||||
name: &str,
|
||||
) -> Result<SubagentAvailabilityChange, String> {
|
||||
self.set_subagent_enabled(scope, name, true)
|
||||
}
|
||||
|
||||
fn set_subagent_enabled(
|
||||
&self,
|
||||
scope: SubagentScope,
|
||||
name: &str,
|
||||
enabled: bool,
|
||||
) -> Result<SubagentAvailabilityChange, String> {
|
||||
// 校验子代理存在
|
||||
if self.catalog.find(name).is_none() {
|
||||
return Err(format!("subagent '{}' not found", name));
|
||||
}
|
||||
|
||||
// 更新对应 scope 的 state 文件
|
||||
let state_path = subagent_state_path(scope, &self.cwd);
|
||||
let mut state_file = load_subagent_state_file(&state_path)?;
|
||||
let mut disabled: HashSet<String> = state_file.disabled_subagents.into_iter().collect();
|
||||
let changed = if enabled {
|
||||
disabled.remove(name)
|
||||
} else {
|
||||
disabled.insert(name.to_string())
|
||||
};
|
||||
|
||||
let mut disabled_list: Vec<String> = disabled.into_iter().collect();
|
||||
disabled_list.sort();
|
||||
state_file.disabled_subagents = disabled_list;
|
||||
save_subagent_state_file(&state_path, &state_file)?;
|
||||
|
||||
// 更新内存中的 disable_state
|
||||
{
|
||||
let mut state = self
|
||||
.disable_state
|
||||
.write()
|
||||
.expect("subagent state rwlock poisoned");
|
||||
match scope {
|
||||
SubagentScope::User => {
|
||||
if enabled {
|
||||
state.user_disabled.remove(name);
|
||||
} else {
|
||||
state.user_disabled.insert(name.to_string());
|
||||
}
|
||||
}
|
||||
SubagentScope::Project => {
|
||||
if enabled {
|
||||
state.project_disabled.remove(name);
|
||||
} else {
|
||||
state.project_disabled.insert(name.to_string());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 计算新的 disabled_in_scopes
|
||||
let state = self
|
||||
.disable_state
|
||||
.read()
|
||||
.expect("subagent state rwlock poisoned");
|
||||
let disabled_in_scopes = state.disabled_scopes_for(name);
|
||||
|
||||
Ok(SubagentAvailabilityChange {
|
||||
name: name.to_string(),
|
||||
scope,
|
||||
changed,
|
||||
available: disabled_in_scopes.is_empty(),
|
||||
disabled_in_scopes,
|
||||
})
|
||||
}
|
||||
|
||||
/// 获取 catalog 引用(用于 DefaultSubAgentRuntime 等需要直接访问的场景)
|
||||
pub fn catalog(&self) -> &Arc<SubagentCatalog> {
|
||||
&self.catalog
|
||||
}
|
||||
}
|
||||
|
||||
/// 为子代理系统提供索引提示词
|
||||
///
|
||||
/// 负责提供过滤禁用项后的子代理系统索引提示词,注入主 agent。
|
||||
pub struct SubagentPromptProvider {
|
||||
runtime: Arc<SubagentRuntime>,
|
||||
}
|
||||
|
||||
impl SubagentPromptProvider {
|
||||
pub fn new(runtime: Arc<SubagentRuntime>) -> Self {
|
||||
Self { runtime }
|
||||
}
|
||||
}
|
||||
|
||||
impl SystemPromptProvider for SubagentPromptProvider {
|
||||
fn build(&self, _context: &SystemPromptContext) -> Option<SystemPrompt> {
|
||||
self.runtime
|
||||
.system_index_prompt_filtered()
|
||||
.map(|content| SystemPrompt {
|
||||
content,
|
||||
context: Some("subagents".to_string()),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// ========== 自定义子代理发现 ==========
|
||||
|
||||
/// 源顺序解析
|
||||
@ -1056,3 +1408,166 @@ fn split_frontmatter(content: &str) -> Option<(&str, &str)> {
|
||||
|
||||
Some((frontmatter, body))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::config::SubagentsConfig;
|
||||
|
||||
static SUBAGENT_TEST_ENV_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(());
|
||||
|
||||
fn acquire_test_lock() -> std::sync::MutexGuard<'static, ()> {
|
||||
SUBAGENT_TEST_ENV_LOCK
|
||||
.lock()
|
||||
.unwrap_or_else(|err| err.into_inner())
|
||||
}
|
||||
|
||||
struct HomeDirGuard {
|
||||
previous: Option<std::ffi::OsString>,
|
||||
previous_userprofile: Option<std::ffi::OsString>,
|
||||
}
|
||||
|
||||
impl HomeDirGuard {
|
||||
fn enter(path: &Path) -> Self {
|
||||
let home_backup = std::env::var_os("HOME");
|
||||
let userprofile_backup = std::env::var_os("USERPROFILE");
|
||||
unsafe {
|
||||
std::env::set_var("HOME", path);
|
||||
std::env::set_var("USERPROFILE", path);
|
||||
}
|
||||
Self {
|
||||
previous: home_backup,
|
||||
previous_userprofile: userprofile_backup,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for HomeDirGuard {
|
||||
fn drop(&mut self) {
|
||||
unsafe {
|
||||
match &self.previous {
|
||||
Some(value) => std::env::set_var("HOME", value),
|
||||
None => std::env::remove_var("HOME"),
|
||||
}
|
||||
match &self.previous_userprofile {
|
||||
Some(value) => std::env::set_var("USERPROFILE", value),
|
||||
None => std::env::remove_var("USERPROFILE"),
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn make_runtime(cwd: &Path) -> SubagentRuntime {
|
||||
let catalog = Arc::new(SubagentCatalog::new());
|
||||
SubagentRuntime::new(SubagentsConfig::default(), catalog, cwd.to_path_buf())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_disable_subagent_filters_from_prompt() {
|
||||
let _lock = acquire_test_lock();
|
||||
let temp = tempfile::tempdir().unwrap();
|
||||
let home = tempfile::tempdir().unwrap();
|
||||
let _home_guard = HomeDirGuard::enter(home.path());
|
||||
|
||||
let runtime = make_runtime(temp.path());
|
||||
|
||||
// general 在初始 prompt 中
|
||||
let prompt = runtime.system_index_prompt_filtered().unwrap();
|
||||
assert!(prompt.contains("<name>general</name>"));
|
||||
|
||||
// 在 project scope 禁用 general
|
||||
let change = runtime
|
||||
.disable_subagent(SubagentScope::Project, "general")
|
||||
.unwrap();
|
||||
assert!(change.changed);
|
||||
assert!(!change.available);
|
||||
|
||||
// 禁用后 prompt 不应包含 general(explore 仍可用,所以 prompt 仍为 Some)
|
||||
let prompt = runtime.system_index_prompt_filtered().unwrap();
|
||||
assert!(!prompt.contains("<name>general</name>"));
|
||||
assert!(prompt.contains("<name>explore</name>"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_enable_subagent_restores() {
|
||||
let _lock = acquire_test_lock();
|
||||
let temp = tempfile::tempdir().unwrap();
|
||||
let home = tempfile::tempdir().unwrap();
|
||||
let _home_guard = HomeDirGuard::enter(home.path());
|
||||
|
||||
let runtime = make_runtime(temp.path());
|
||||
|
||||
runtime
|
||||
.disable_subagent(SubagentScope::Project, "general")
|
||||
.unwrap();
|
||||
let prompt = runtime.system_index_prompt_filtered().unwrap();
|
||||
assert!(!prompt.contains("<name>general</name>"));
|
||||
|
||||
let change = runtime
|
||||
.enable_subagent(SubagentScope::Project, "general")
|
||||
.unwrap();
|
||||
assert!(change.changed);
|
||||
assert!(change.available);
|
||||
|
||||
let prompt = runtime.system_index_prompt_filtered().unwrap();
|
||||
assert!(prompt.contains("<name>general</name>"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_list_with_status_includes_disabled() {
|
||||
let _lock = acquire_test_lock();
|
||||
let temp = tempfile::tempdir().unwrap();
|
||||
let home = tempfile::tempdir().unwrap();
|
||||
let _home_guard = HomeDirGuard::enter(home.path());
|
||||
|
||||
let runtime = make_runtime(temp.path());
|
||||
runtime
|
||||
.disable_subagent(SubagentScope::Project, "general")
|
||||
.unwrap();
|
||||
|
||||
let items = runtime.list_with_status();
|
||||
let general = items.iter().find(|i| i.name == "general").unwrap();
|
||||
assert!(general
|
||||
.disabled_in_scopes
|
||||
.contains(&"project".to_string()));
|
||||
|
||||
// explore 应仍启用
|
||||
let explore = items.iter().find(|i| i.name == "explore").unwrap();
|
||||
assert!(explore.disabled_in_scopes.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_find_available_filters_disabled() {
|
||||
let _lock = acquire_test_lock();
|
||||
let temp = tempfile::tempdir().unwrap();
|
||||
let home = tempfile::tempdir().unwrap();
|
||||
let _home_guard = HomeDirGuard::enter(home.path());
|
||||
|
||||
let runtime = make_runtime(temp.path());
|
||||
runtime
|
||||
.disable_subagent(SubagentScope::Project, "general")
|
||||
.unwrap();
|
||||
|
||||
assert!(runtime.find_available("general").is_none());
|
||||
assert!(runtime.find_available("explore").is_some());
|
||||
|
||||
// available_names 不应包含 general
|
||||
let names = runtime.available_names();
|
||||
assert!(!names.contains(&"general".to_string()));
|
||||
assert!(names.contains(&"explore".to_string()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_disable_unknown_subagent_errors() {
|
||||
let _lock = acquire_test_lock();
|
||||
let temp = tempfile::tempdir().unwrap();
|
||||
let home = tempfile::tempdir().unwrap();
|
||||
let _home_guard = HomeDirGuard::enter(home.path());
|
||||
|
||||
let runtime = make_runtime(temp.path());
|
||||
let err = runtime
|
||||
.disable_subagent(SubagentScope::Project, "nonexistent")
|
||||
.unwrap_err();
|
||||
assert!(err.contains("not found"));
|
||||
}
|
||||
}
|
||||
|
||||
@ -36,6 +36,17 @@ pub enum SubagentSource {
|
||||
Custom(String),
|
||||
}
|
||||
|
||||
impl SubagentSource {
|
||||
pub fn as_str(&self) -> &str {
|
||||
match self {
|
||||
Self::Builtin => "builtin",
|
||||
Self::User => "user",
|
||||
Self::Project => "project",
|
||||
Self::Custom(_) => "custom",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 子代理完整定义
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct SubagentDef {
|
||||
|
||||
@ -46,6 +46,19 @@ interface SkillListResponse {
|
||||
skills: SkillItem[]
|
||||
}
|
||||
|
||||
interface SubagentItem {
|
||||
name: string
|
||||
description: string
|
||||
source: string
|
||||
disabled_in_scopes: string[]
|
||||
}
|
||||
|
||||
interface SubagentListResponse {
|
||||
subagents_system_enabled: boolean
|
||||
total: number
|
||||
subagents: SubagentItem[]
|
||||
}
|
||||
|
||||
interface McpServerStatus {
|
||||
key: string
|
||||
name: string
|
||||
@ -89,20 +102,20 @@ interface ConfigPageProps {
|
||||
type TabId = 'connection' | 'gateway' | 'providers' | 'models' | 'agents' | 'time' | 'scheduler' | 'skills' | 'tools' | 'memory' | 'image' | 'subagents' | 'mcp' | 'channels'
|
||||
|
||||
const TABS: { id: TabId; label: string; icon: typeof Settings }[] = [
|
||||
{ id: 'connection', label: '连接', icon: Wifi },
|
||||
{ id: 'gateway', label: '网关', icon: Server },
|
||||
{ id: 'providers', label: '服务商', icon: Cpu },
|
||||
{ id: 'models', label: '模型', icon: Brain },
|
||||
{ id: 'agents', label: '代理', icon: Bot },
|
||||
{ id: 'time', label: '时间', icon: Clock },
|
||||
{ id: 'scheduler', label: '调度器', icon: Calendar },
|
||||
{ id: 'mcp', label: 'MCP 服务器', icon: Plug },
|
||||
{ id: 'skills', label: '技能', icon: Wrench },
|
||||
{ id: 'subagents', label: '子代理', icon: Bot },
|
||||
{ id: 'channels', label: '渠道', icon: Radio },
|
||||
{ id: 'tools', label: '工具', icon: Settings },
|
||||
{ id: 'memory', label: '记忆维护', icon: Users },
|
||||
{ id: 'scheduler', label: '调度器', icon: Calendar },
|
||||
{ id: 'image', label: '图片上下文', icon: Image },
|
||||
{ id: 'subagents', label: '子代理', icon: Bot },
|
||||
{ id: 'mcp', label: 'MCP 服务器', icon: Plug },
|
||||
{ id: 'channels', label: '渠道', icon: Radio },
|
||||
{ id: 'time', label: '时间', icon: Clock },
|
||||
{ id: 'connection', label: '连接', icon: Wifi },
|
||||
{ id: 'gateway', label: '网关', icon: Server },
|
||||
]
|
||||
|
||||
// ── Shared UI primitives ───────────────────────────────
|
||||
@ -177,11 +190,14 @@ function TagEditor({ tags, onChange }: { tags: string[]; onChange: (t: string[])
|
||||
)
|
||||
}
|
||||
|
||||
function SectionCard({ title, children }: { title: string; children: ReactNode }) {
|
||||
function SectionCard({ title, subtitle, children }: { title: string; subtitle?: string; children: ReactNode }) {
|
||||
return (
|
||||
<div className="rounded-xl border border-[var(--border-color)] bg-[var(--bg-secondary)]/60 overflow-hidden">
|
||||
<div className="px-4 py-2.5 border-b border-[var(--border-color)] bg-[var(--bg-tertiary)]/30">
|
||||
<h3 className="text-sm font-medium text-[var(--text-secondary)]">{title}</h3>
|
||||
<div className="flex items-center gap-2">
|
||||
<h3 className="text-sm font-medium text-[var(--text-secondary)]">{title}</h3>
|
||||
{subtitle && <span className="text-[10px] text-[var(--text-muted)] bg-[var(--bg-tertiary)] px-1.5 py-0.5 rounded">{subtitle}</span>}
|
||||
</div>
|
||||
</div>
|
||||
<div className="p-4 space-y-4">{children}</div>
|
||||
</div>
|
||||
@ -302,7 +318,7 @@ function MapEntryHeader({ name, onDelete, onRename }: { name: string; onDelete:
|
||||
// ── Main Component ─────────────────────────────────────
|
||||
export function ConfigPage({ onClose, onSaveConnection }: ConfigPageProps) {
|
||||
const [config, setConfig] = useState<AppConfig | null>(null)
|
||||
const [activeTab, setActiveTab] = useState<TabId>('gateway')
|
||||
const [activeTab, setActiveTab] = useState<TabId>('providers')
|
||||
const [loading, setLoading] = useState(true)
|
||||
// Connection settings (localStorage-based)
|
||||
const [connHost, setConnHost] = useState(() => {
|
||||
@ -322,6 +338,8 @@ export function ConfigPage({ onClose, onSaveConnection }: ConfigPageProps) {
|
||||
const [mcpStatus, setMcpStatus] = useState<McpStatusResponse | null>(null)
|
||||
const [skillList, setSkillList] = useState<SkillListResponse | null>(null)
|
||||
const [skillListLoading, setSkillListLoading] = useState(false)
|
||||
const [subagentList, setSubagentList] = useState<SubagentListResponse | null>(null)
|
||||
const [subagentListLoading, setSubagentListLoading] = useState(false)
|
||||
|
||||
const fetchMcpStatus = useCallback(async () => {
|
||||
try {
|
||||
@ -348,6 +366,24 @@ export function ConfigPage({ onClose, onSaveConnection }: ConfigPageProps) {
|
||||
return resp
|
||||
}, [])
|
||||
|
||||
const fetchSubagentList = useCallback(async () => {
|
||||
setSubagentListLoading(true)
|
||||
try {
|
||||
const resp = await fetch('/api/subagents')
|
||||
if (resp.ok) setSubagentList(await resp.json())
|
||||
} catch { /* ignore fetch errors */ }
|
||||
finally { setSubagentListLoading(false) }
|
||||
}, [])
|
||||
|
||||
const toggleSubagent = useCallback(async (name: string, scope: string, enabled: boolean) => {
|
||||
const resp = await fetch('/api/subagents/toggle', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ name, scope, enabled }),
|
||||
})
|
||||
return resp
|
||||
}, [])
|
||||
|
||||
const handleClose = useCallback(() => {
|
||||
if (dirty && !confirm('有未保存的更改,确定要关闭吗?')) return
|
||||
onClose()
|
||||
@ -371,6 +407,11 @@ export function ConfigPage({ onClose, onSaveConnection }: ConfigPageProps) {
|
||||
if (activeTab === 'skills') fetchSkillList()
|
||||
}, [activeTab, fetchSkillList])
|
||||
|
||||
// Fetch subagent list when subagents tab is selected
|
||||
useEffect(() => {
|
||||
if (activeTab === 'subagents') fetchSubagentList()
|
||||
}, [activeTab, fetchSubagentList])
|
||||
|
||||
// ESC to close
|
||||
useEffect(() => {
|
||||
const h = (e: KeyboardEvent) => { if (e.key === 'Escape') handleClose() }
|
||||
@ -709,7 +750,7 @@ export function ConfigPage({ onClose, onSaveConnection }: ConfigPageProps) {
|
||||
}
|
||||
|
||||
return (
|
||||
<SectionCard title="已发现技能">
|
||||
<SectionCard title="已发现技能" subtitle="即时生效">
|
||||
{skillListLoading && skills.length === 0 ? (
|
||||
<div className="flex items-center gap-2 text-sm text-[var(--text-muted)]">
|
||||
<Loader2 className="h-4 w-4 animate-spin" /> 加载中...
|
||||
@ -804,9 +845,81 @@ export function ConfigPage({ onClose, onSaveConnection }: ConfigPageProps) {
|
||||
examplePaths={['D:\\my-subagents', '/home/user/shared-agents']}
|
||||
/>
|
||||
</SectionCard>
|
||||
{renderDiscoveredSubagents()}
|
||||
</div>
|
||||
)
|
||||
|
||||
const renderDiscoveredSubagents = () => {
|
||||
if (!subagentList || !subagentList.subagents_system_enabled) return null
|
||||
|
||||
const subagents = subagentList.subagents
|
||||
|
||||
const handleToggle = async (name: string, currentlyEnabled: boolean) => {
|
||||
const prevList = subagentList
|
||||
setSubagentList({
|
||||
...subagentList,
|
||||
subagents: subagents.map(s =>
|
||||
s.name === name
|
||||
? { ...s, disabled_in_scopes: currentlyEnabled ? ['project'] : [] }
|
||||
: s
|
||||
),
|
||||
})
|
||||
|
||||
try {
|
||||
const resp = await toggleSubagent(name, 'project', !currentlyEnabled)
|
||||
const data = await resp.json()
|
||||
if (!resp.ok || !data.success) {
|
||||
setSubagentList(prevList)
|
||||
setToast(data.error || '切换子代理状态失败')
|
||||
setTimeout(() => setToast(''), 3000)
|
||||
return
|
||||
}
|
||||
setSubagentList({
|
||||
...prevList,
|
||||
subagents: prevList.subagents.map(s =>
|
||||
s.name === name
|
||||
? { ...s, disabled_in_scopes: data.disabled_in_scopes || [] }
|
||||
: s
|
||||
),
|
||||
})
|
||||
} catch {
|
||||
setSubagentList(prevList)
|
||||
setToast('网络错误,切换子代理状态失败')
|
||||
setTimeout(() => setToast(''), 3000)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<SectionCard title="已发现子代理" subtitle="即时生效">
|
||||
{subagentListLoading && subagents.length === 0 ? (
|
||||
<div className="flex items-center gap-2 text-sm text-[var(--text-muted)]">
|
||||
<Loader2 className="h-4 w-4 animate-spin" /> 加载中...
|
||||
</div>
|
||||
) : subagents.length === 0 ? (
|
||||
<p className="text-sm text-[var(--text-muted)]">未发现任何子代理</p>
|
||||
) : (
|
||||
<div className="space-y-1">
|
||||
{subagents.map(subagent => {
|
||||
const isEnabled = subagent.disabled_in_scopes.length === 0
|
||||
return (
|
||||
<div key={subagent.name} className="flex items-center gap-3 py-2 px-2 rounded-lg hover:bg-[var(--bg-hover)] transition-colors">
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-sm font-mono text-[var(--text-primary)]">{subagent.name}</span>
|
||||
<span className="text-[10px] px-1.5 py-0.5 rounded bg-[var(--bg-tertiary)] text-[var(--text-muted)] uppercase tracking-wider">{subagent.source}</span>
|
||||
</div>
|
||||
<p className="text-xs text-[var(--text-muted)] truncate mt-0.5">{subagent.description}</p>
|
||||
</div>
|
||||
<Toggle checked={isEnabled} onChange={() => handleToggle(subagent.name, isEnabled)} />
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</SectionCard>
|
||||
)
|
||||
}
|
||||
|
||||
const renderMcp = () => {
|
||||
const entries = Object.entries(config.mcpServers)
|
||||
const statusFor = (key: string) => mcpStatus?.servers?.find(s => s.key === key)
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user