feat(settings): 拆分设置页为懒加载标签页并支持子代理创建/删除
- ConfigPage 拆分为 16 个懒加载 tab + 2 个 modal,首屏体积大幅下降 - 抽取 CapabilityTabs 共享组件,统一专家/子代理能力配置 UI - 后端新增 POST /api/subagents/create、DELETE /api/subagents/delete - SubagentRuntime 新增 create_subagent/delete_subagent,含路径校验与 builtin 保护 - SubagentModal 支持 create/edit 双模式、body 编辑、自身排除防自递归 - SubagentsTab 增加搜索、provider/model 标签、删除二次确认 - 修复 reload() 使用进程 cwd 而非 self.cwd 的隔离缺陷
This commit is contained in:
parent
2e4b1931a6
commit
c2cc072b2e
@ -578,6 +578,7 @@ pub async fn subagents_update(
|
|||||||
capability: updated.capability.clone(),
|
capability: updated.capability.clone(),
|
||||||
provider: updated.provider.clone(),
|
provider: updated.provider.clone(),
|
||||||
model: updated.model.clone(),
|
model: updated.model.clone(),
|
||||||
|
body: updated.body.clone(),
|
||||||
});
|
});
|
||||||
|
|
||||||
Ok(Json(SubagentUpdateResponse {
|
Ok(Json(SubagentUpdateResponse {
|
||||||
@ -587,6 +588,124 @@ pub async fn subagents_update(
|
|||||||
}))
|
}))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[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 =====================
|
// ===================== Experts =====================
|
||||||
|
|
||||||
#[derive(Deserialize)]
|
#[derive(Deserialize)]
|
||||||
|
|||||||
@ -271,6 +271,14 @@ pub async fn run(
|
|||||||
"/api/subagents/update",
|
"/api/subagents/update",
|
||||||
routing::put(http::subagents_update),
|
routing::put(http::subagents_update),
|
||||||
)
|
)
|
||||||
|
.route(
|
||||||
|
"/api/subagents/create",
|
||||||
|
routing::post(http::subagents_create),
|
||||||
|
)
|
||||||
|
.route(
|
||||||
|
"/api/subagents/delete",
|
||||||
|
routing::delete(http::subagents_delete),
|
||||||
|
)
|
||||||
.route("/api/experts", routing::get(http::experts_list))
|
.route("/api/experts", routing::get(http::experts_list))
|
||||||
.route("/api/experts/toggle", routing::post(http::experts_toggle))
|
.route("/api/experts/toggle", routing::post(http::experts_toggle))
|
||||||
.route("/api/experts/create", routing::post(http::experts_create))
|
.route("/api/experts/create", routing::post(http::experts_create))
|
||||||
@ -315,6 +323,14 @@ pub async fn run(
|
|||||||
"/api/subagents/update",
|
"/api/subagents/update",
|
||||||
routing::put(http::subagents_update),
|
routing::put(http::subagents_update),
|
||||||
)
|
)
|
||||||
|
.route(
|
||||||
|
"/api/subagents/create",
|
||||||
|
routing::post(http::subagents_create),
|
||||||
|
)
|
||||||
|
.route(
|
||||||
|
"/api/subagents/delete",
|
||||||
|
routing::delete(http::subagents_delete),
|
||||||
|
)
|
||||||
.route("/api/experts", routing::get(http::experts_list))
|
.route("/api/experts", routing::get(http::experts_list))
|
||||||
.route("/api/experts/toggle", routing::post(http::experts_toggle))
|
.route("/api/experts/toggle", routing::post(http::experts_toggle))
|
||||||
.route("/api/experts/create", routing::post(http::experts_create))
|
.route("/api/experts/create", routing::post(http::experts_create))
|
||||||
|
|||||||
@ -1136,6 +1136,10 @@ pub struct SubagentWithStatus {
|
|||||||
/// 可选的 model 名(引用 config.json 的 models 表)。None 时继承主智能体。
|
/// 可选的 model 名(引用 config.json 的 models 表)。None 时继承主智能体。
|
||||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
pub model: Option<String>,
|
pub model: Option<String>,
|
||||||
|
/// SUBAGENT.md 的 markdown 正文,追加到系统提示词末尾。builtin 子代理为 None。
|
||||||
|
/// 前端编辑模态框需要回显此字段,与专家系统的 body 对齐。
|
||||||
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
|
pub body: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone)]
|
#[derive(Debug, Clone)]
|
||||||
@ -1268,8 +1272,11 @@ impl SubagentRuntime {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// 重新发现子代理并替换内存 catalog(写回 SUBAGENT.md 后调用)。
|
/// 重新发现子代理并替换内存 catalog(写回 SUBAGENT.md 后调用)。
|
||||||
|
///
|
||||||
|
/// 使用 `self.cwd` 而非进程 cwd 进行发现,确保与构造时的 cwd 一致
|
||||||
|
/// (生产环境两者相同,但测试场景使用临时目录时必须用 `self.cwd`)。
|
||||||
pub fn reload(&self) -> Result<(), String> {
|
pub fn reload(&self) -> Result<(), String> {
|
||||||
let new_catalog = SubagentCatalog::discover(&self.config);
|
let new_catalog = SubagentCatalog::discover_with_cwd(&self.config, &self.cwd);
|
||||||
let mut guard = self
|
let mut guard = self
|
||||||
.catalog
|
.catalog
|
||||||
.write()
|
.write()
|
||||||
@ -1301,6 +1308,7 @@ impl SubagentRuntime {
|
|||||||
capability: def.capability.clone(),
|
capability: def.capability.clone(),
|
||||||
provider: def.provider.clone(),
|
provider: def.provider.clone(),
|
||||||
model: def.model.clone(),
|
model: def.model.clone(),
|
||||||
|
body: def.body.clone(),
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
.collect();
|
.collect();
|
||||||
@ -1585,6 +1593,150 @@ impl SubagentRuntime {
|
|||||||
}
|
}
|
||||||
Ok(new_def)
|
Ok(new_def)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 创建子代理(在指定 scope 下创建 SUBAGENT.md 文件)。
|
||||||
|
/// 对齐 `ExpertRuntime::create_expert`。
|
||||||
|
/// - `name` 不能为空,不能包含路径分隔符或 `..`。
|
||||||
|
/// - `prompt_template` 为空时使用默认模板。
|
||||||
|
/// - `max_execution_secs` 为 None 时不写入 frontmatter。
|
||||||
|
/// - 同名子代理(含 builtin `general`)已存在时返回错误。
|
||||||
|
pub fn create_subagent(
|
||||||
|
&self,
|
||||||
|
scope: SubagentScope,
|
||||||
|
name: &str,
|
||||||
|
description: &str,
|
||||||
|
body: &str,
|
||||||
|
capability: &CapabilityPolicy,
|
||||||
|
provider: &Option<String>,
|
||||||
|
model: &Option<String>,
|
||||||
|
reload: bool,
|
||||||
|
) -> Result<SubagentDef, String> {
|
||||||
|
validate_subagent_name(name)?;
|
||||||
|
{
|
||||||
|
let catalog = self
|
||||||
|
.catalog
|
||||||
|
.read()
|
||||||
|
.expect("subagent catalog rwlock poisoned");
|
||||||
|
if catalog.find(name).is_some() {
|
||||||
|
return Err(format!("subagent '{}' already exists", name));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let source = match scope {
|
||||||
|
SubagentScope::User => SubagentSource::User,
|
||||||
|
SubagentScope::Project => SubagentSource::Project,
|
||||||
|
};
|
||||||
|
let path = subagent_file_path(scope, name, &self.cwd)?;
|
||||||
|
if path.exists() {
|
||||||
|
return Err(format!(
|
||||||
|
"subagent '{}' already exists at {}",
|
||||||
|
name,
|
||||||
|
path.display()
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
// 新建子代理使用默认提示词模板(与 builtin general 一致),不暴露给 UI 编辑
|
||||||
|
let prompt_template = SubagentDef::builtin_general().prompt_template;
|
||||||
|
write_subagent_file(
|
||||||
|
&path,
|
||||||
|
name,
|
||||||
|
description,
|
||||||
|
&prompt_template,
|
||||||
|
body,
|
||||||
|
capability,
|
||||||
|
None,
|
||||||
|
provider,
|
||||||
|
model,
|
||||||
|
)?;
|
||||||
|
|
||||||
|
let def = parse_subagent_file(&path, source)?;
|
||||||
|
if reload {
|
||||||
|
let _ = self.reload();
|
||||||
|
}
|
||||||
|
Ok(def)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 删除子代理(删除 SUBAGENT.md 所在目录)。
|
||||||
|
/// 对齐 `ExpertRuntime::delete_expert`。
|
||||||
|
/// - builtin 子代理(path 为 None)禁止删除。
|
||||||
|
/// - 仅当目录内除 SUBAGENT.md 外无其他文件时才删除目录,避免误删用户附件。
|
||||||
|
pub fn delete_subagent(
|
||||||
|
&self,
|
||||||
|
name: &str,
|
||||||
|
reload: bool,
|
||||||
|
) -> Result<PathBuf, String> {
|
||||||
|
validate_subagent_name(name)?;
|
||||||
|
let path = {
|
||||||
|
let catalog = self
|
||||||
|
.catalog
|
||||||
|
.read()
|
||||||
|
.expect("subagent catalog rwlock poisoned");
|
||||||
|
let def = catalog
|
||||||
|
.find(name)
|
||||||
|
.ok_or_else(|| format!("subagent '{}' not found", name))?;
|
||||||
|
def.path
|
||||||
|
.clone()
|
||||||
|
.ok_or_else(|| format!("builtin subagent '{}' cannot be deleted", name))?
|
||||||
|
};
|
||||||
|
|
||||||
|
if !path.exists() {
|
||||||
|
return Err(format!("subagent file not found at {}", path.display()));
|
||||||
|
}
|
||||||
|
|
||||||
|
let dir = path
|
||||||
|
.parent()
|
||||||
|
.ok_or_else(|| "subagent file has no parent directory".to_string())?;
|
||||||
|
|
||||||
|
// 仅当目录内只有 SUBAGENT.md 时才递归删除目录;
|
||||||
|
// 否则只删除 SUBAGENT.md,保留用户其他文件
|
||||||
|
let only_subagent_file = std::fs::read_dir(dir)
|
||||||
|
.map_err(|err| format!("failed to read subagent directory: {}", err))?
|
||||||
|
.filter_map(|e| e.ok())
|
||||||
|
.filter(|e| e.file_name() != "SUBAGENT.md")
|
||||||
|
.count()
|
||||||
|
== 0;
|
||||||
|
|
||||||
|
if only_subagent_file {
|
||||||
|
std::fs::remove_dir_all(dir)
|
||||||
|
.map_err(|err| format!("failed to delete subagent directory: {}", err))?;
|
||||||
|
} else {
|
||||||
|
std::fs::remove_file(&path)
|
||||||
|
.map_err(|err| format!("failed to delete subagent file: {}", err))?;
|
||||||
|
}
|
||||||
|
|
||||||
|
if reload {
|
||||||
|
let _ = self.reload();
|
||||||
|
}
|
||||||
|
Ok(dir.to_path_buf())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 校验子代理名称:非空、无路径分隔符、无 `..`。
|
||||||
|
/// 对齐 `validate_expert_name`。
|
||||||
|
fn validate_subagent_name(name: &str) -> Result<(), String> {
|
||||||
|
if name.trim().is_empty() {
|
||||||
|
return Err("subagent name cannot be empty".to_string());
|
||||||
|
}
|
||||||
|
if name.contains('/') || name.contains('\\') || name.contains("..") {
|
||||||
|
return Err("subagent name must not contain path separators or '..'".to_string());
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 获取指定 scope 下某子代理的 SUBAGENT.md 路径。
|
||||||
|
/// 对齐 `expert_file_path`。
|
||||||
|
fn subagent_file_path(
|
||||||
|
scope: SubagentScope,
|
||||||
|
name: &str,
|
||||||
|
cwd: &Path,
|
||||||
|
) -> Result<PathBuf, String> {
|
||||||
|
let root = match scope {
|
||||||
|
SubagentScope::User => dirs::home_dir()
|
||||||
|
.map(|p| p.join(".picobot").join("subagents"))
|
||||||
|
.ok_or_else(|| "cannot determine user home directory".to_string())?,
|
||||||
|
SubagentScope::Project => cwd.join(".picobot").join("subagents"),
|
||||||
|
};
|
||||||
|
Ok(root.join(name).join("SUBAGENT.md"))
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 为子代理系统提供索引提示词
|
/// 为子代理系统提供索引提示词
|
||||||
@ -2455,4 +2607,218 @@ mod tests {
|
|||||||
let err = result.unwrap_err();
|
let err = result.unwrap_err();
|
||||||
assert!(err.contains("builtin") || err.contains("not found"));
|
assert!(err.contains("builtin") || err.contains("not found"));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ===== create_subagent / delete_subagent 测试 =====
|
||||||
|
//
|
||||||
|
// create_subagent 写入到 project scope 的固定路径 {cwd}/.picobot/subagents/{name}/SUBAGENT.md,
|
||||||
|
// 与 expert create 一致。测试中以 temp.path() 作为 cwd,project root 即 temp/.picobot/subagents/。
|
||||||
|
|
||||||
|
fn make_runtime_with_cwd(cwd: &Path) -> SubagentRuntime {
|
||||||
|
let config = SubagentsConfig {
|
||||||
|
enabled: true,
|
||||||
|
sources: vec!["project".to_string()],
|
||||||
|
};
|
||||||
|
let catalog = SubagentCatalog::discover_with_cwd(&config, cwd);
|
||||||
|
SubagentRuntime::new(config, catalog, cwd.to_path_buf())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn create_subagent_writes_file_and_appears_in_list() {
|
||||||
|
let temp = tempfile::tempdir().unwrap();
|
||||||
|
let runtime = make_runtime_with_cwd(temp.path());
|
||||||
|
|
||||||
|
let cap = CapabilityPolicy {
|
||||||
|
allowed_skills: None,
|
||||||
|
denied_skills: vec!["skill_x".to_string()],
|
||||||
|
allowed_tools: Some(vec!["read".to_string()]),
|
||||||
|
denied_tools: vec![],
|
||||||
|
allowed_subagents: None,
|
||||||
|
denied_subagents: vec![],
|
||||||
|
};
|
||||||
|
let created = runtime
|
||||||
|
.create_subagent(
|
||||||
|
SubagentScope::Project,
|
||||||
|
"demo-create",
|
||||||
|
"demo create agent",
|
||||||
|
"demo body content",
|
||||||
|
&cap,
|
||||||
|
&None,
|
||||||
|
&None,
|
||||||
|
true,
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(created.name, "demo-create");
|
||||||
|
assert_eq!(created.description, "demo create agent");
|
||||||
|
assert_eq!(created.body.as_deref(), Some("demo body content"));
|
||||||
|
assert_eq!(created.source, SubagentSource::Project);
|
||||||
|
|
||||||
|
// 文件确实创建在 project root 下
|
||||||
|
let file_path = temp
|
||||||
|
.path()
|
||||||
|
.join(".picobot")
|
||||||
|
.join("subagents")
|
||||||
|
.join("demo-create")
|
||||||
|
.join("SUBAGENT.md");
|
||||||
|
assert!(file_path.exists(), "SUBAGENT.md should be created");
|
||||||
|
|
||||||
|
// list_with_status 能看到新子代理
|
||||||
|
let items = runtime.list_with_status();
|
||||||
|
let item = items.iter().find(|i| i.name == "demo-create").unwrap();
|
||||||
|
assert_eq!(item.description, "demo create agent");
|
||||||
|
assert_eq!(item.body.as_deref(), Some("demo body content"));
|
||||||
|
assert_eq!(
|
||||||
|
item.capability.denied_skills,
|
||||||
|
vec!["skill_x".to_string()]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn create_subagent_rejects_duplicate() {
|
||||||
|
let temp = tempfile::tempdir().unwrap();
|
||||||
|
let runtime = make_runtime_with_cwd(temp.path());
|
||||||
|
|
||||||
|
runtime
|
||||||
|
.create_subagent(
|
||||||
|
SubagentScope::Project,
|
||||||
|
"dup",
|
||||||
|
"first",
|
||||||
|
"",
|
||||||
|
&CapabilityPolicy::default(),
|
||||||
|
&None,
|
||||||
|
&None,
|
||||||
|
true,
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
// 同名再次创建应失败
|
||||||
|
let result = runtime.create_subagent(
|
||||||
|
SubagentScope::Project,
|
||||||
|
"dup",
|
||||||
|
"second",
|
||||||
|
"",
|
||||||
|
&CapabilityPolicy::default(),
|
||||||
|
&None,
|
||||||
|
&None,
|
||||||
|
true,
|
||||||
|
);
|
||||||
|
assert!(result.is_err());
|
||||||
|
assert!(result.unwrap_err().contains("already exists"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn create_subagent_rejects_builtin_name() {
|
||||||
|
let temp = tempfile::tempdir().unwrap();
|
||||||
|
let runtime = make_runtime_with_cwd(temp.path());
|
||||||
|
|
||||||
|
// builtin general 已存在,应拒绝
|
||||||
|
let result = runtime.create_subagent(
|
||||||
|
SubagentScope::Project,
|
||||||
|
"general",
|
||||||
|
"hijack",
|
||||||
|
"",
|
||||||
|
&CapabilityPolicy::default(),
|
||||||
|
&None,
|
||||||
|
&None,
|
||||||
|
true,
|
||||||
|
);
|
||||||
|
assert!(result.is_err());
|
||||||
|
assert!(result.unwrap_err().contains("already exists"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn delete_subagent_removes_file_and_directory() {
|
||||||
|
let temp = tempfile::tempdir().unwrap();
|
||||||
|
let runtime = make_runtime_with_cwd(temp.path());
|
||||||
|
|
||||||
|
runtime
|
||||||
|
.create_subagent(
|
||||||
|
SubagentScope::Project,
|
||||||
|
"doomed",
|
||||||
|
"to be deleted",
|
||||||
|
"",
|
||||||
|
&CapabilityPolicy::default(),
|
||||||
|
&None,
|
||||||
|
&None,
|
||||||
|
true,
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
let dir = temp
|
||||||
|
.path()
|
||||||
|
.join(".picobot")
|
||||||
|
.join("subagents")
|
||||||
|
.join("doomed");
|
||||||
|
let file_path = dir.join("SUBAGENT.md");
|
||||||
|
assert!(file_path.exists());
|
||||||
|
|
||||||
|
let deleted_dir = runtime.delete_subagent("doomed", true).unwrap();
|
||||||
|
assert_eq!(deleted_dir, dir);
|
||||||
|
assert!(!dir.exists(), "directory should be removed");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn delete_subagent_rejects_builtin() {
|
||||||
|
let runtime = SubagentRuntime::from_config(SubagentsConfig::default());
|
||||||
|
let result = runtime.delete_subagent("general", false);
|
||||||
|
assert!(result.is_err());
|
||||||
|
let err = result.unwrap_err();
|
||||||
|
assert!(err.contains("builtin") || err.contains("not found"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn delete_subagent_rejects_nonexistent() {
|
||||||
|
let runtime = SubagentRuntime::from_config(SubagentsConfig::default());
|
||||||
|
let result = runtime.delete_subagent("never-existed", false);
|
||||||
|
assert!(result.is_err());
|
||||||
|
assert!(result.unwrap_err().contains("not found"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn delete_subagent_preserves_other_files_in_directory() {
|
||||||
|
let temp = tempfile::tempdir().unwrap();
|
||||||
|
let runtime = make_runtime_with_cwd(temp.path());
|
||||||
|
|
||||||
|
runtime
|
||||||
|
.create_subagent(
|
||||||
|
SubagentScope::Project,
|
||||||
|
"mixed",
|
||||||
|
"has extra files",
|
||||||
|
"",
|
||||||
|
&CapabilityPolicy::default(),
|
||||||
|
&None,
|
||||||
|
&None,
|
||||||
|
true,
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
// 在子代理目录内放一个用户文件
|
||||||
|
let extra_file = temp
|
||||||
|
.path()
|
||||||
|
.join(".picobot")
|
||||||
|
.join("subagents")
|
||||||
|
.join("mixed")
|
||||||
|
.join("notes.txt");
|
||||||
|
std::fs::write(&extra_file, "user notes").unwrap();
|
||||||
|
|
||||||
|
// 删除子代理:应只删 SUBAGENT.md,保留 notes.txt 和目录
|
||||||
|
runtime.delete_subagent("mixed", true).unwrap();
|
||||||
|
assert!(extra_file.exists(), "user file should be preserved");
|
||||||
|
assert!(
|
||||||
|
temp.path()
|
||||||
|
.join(".picobot")
|
||||||
|
.join("subagents")
|
||||||
|
.join("mixed")
|
||||||
|
.exists(),
|
||||||
|
"directory should be preserved when it has other files"
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
!temp.path()
|
||||||
|
.join(".picobot")
|
||||||
|
.join("subagents")
|
||||||
|
.join("mixed")
|
||||||
|
.join("SUBAGENT.md")
|
||||||
|
.exists(),
|
||||||
|
"SUBAGENT.md should be removed"
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -11,6 +11,8 @@ export const API = {
|
|||||||
subagents: '/api/subagents',
|
subagents: '/api/subagents',
|
||||||
subagentsToggle: '/api/subagents/toggle',
|
subagentsToggle: '/api/subagents/toggle',
|
||||||
subagentsUpdate: '/api/subagents/update',
|
subagentsUpdate: '/api/subagents/update',
|
||||||
|
subagentsCreate: '/api/subagents/create',
|
||||||
|
subagentsDelete: '/api/subagents/delete',
|
||||||
experts: '/api/experts',
|
experts: '/api/experts',
|
||||||
expertsToggle: '/api/experts/toggle',
|
expertsToggle: '/api/experts/toggle',
|
||||||
expertsCreate: '/api/experts/create',
|
expertsCreate: '/api/experts/create',
|
||||||
|
|||||||
@ -17,6 +17,22 @@ export async function toggleSubagent(
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function createSubagent(payload: {
|
||||||
|
name: string;
|
||||||
|
description: string;
|
||||||
|
body: string;
|
||||||
|
scope: string;
|
||||||
|
capability?: CapabilityPolicy;
|
||||||
|
provider?: string;
|
||||||
|
model?: string;
|
||||||
|
}): Promise<Response> {
|
||||||
|
return fetch(API.subagentsCreate, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify(payload),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
export async function updateSubagent(payload: {
|
export async function updateSubagent(payload: {
|
||||||
name: string;
|
name: string;
|
||||||
description?: string;
|
description?: string;
|
||||||
@ -31,3 +47,8 @@ export async function updateSubagent(payload: {
|
|||||||
body: JSON.stringify(payload),
|
body: JSON.stringify(payload),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function deleteSubagent(name: string): Promise<Response> {
|
||||||
|
const params = new URLSearchParams({ name });
|
||||||
|
return fetch(`${API.subagentsDelete}?${params}`, { method: 'DELETE' });
|
||||||
|
}
|
||||||
|
|||||||
167
web/src/components/Settings/CapabilityTabs.tsx
Normal file
167
web/src/components/Settings/CapabilityTabs.tsx
Normal file
@ -0,0 +1,167 @@
|
|||||||
|
// CapabilityTabs - 技能/工具/子代理能力配置的共享 Tab 组件
|
||||||
|
// 供 ExpertModal 和 SubagentModal 复用,将三组能力配置从平铺 SectionCard
|
||||||
|
// 改为内嵌 Tab 切换,节省垂直空间
|
||||||
|
import { useState } from 'react';
|
||||||
|
import { Wrench, Bot, Sparkles } from 'lucide-react';
|
||||||
|
import { Field, CheckboxList } from './ui';
|
||||||
|
import type { CheckboxListOption } from './ui';
|
||||||
|
|
||||||
|
/** 三组能力配置(白名单 + 黑名单)的集合,与 CapabilityPolicy 一一对应 */
|
||||||
|
export interface CapabilityState {
|
||||||
|
allowedSkills: string[];
|
||||||
|
deniedSkills: string[];
|
||||||
|
allowedTools: string[];
|
||||||
|
deniedTools: string[];
|
||||||
|
allowedSubagents: string[];
|
||||||
|
deniedSubagents: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CapabilityTabsProps {
|
||||||
|
value: CapabilityState;
|
||||||
|
onChange: (next: CapabilityState) => void;
|
||||||
|
/** 技能/工具/子代理的可选列表(来自后端发现) */
|
||||||
|
skillOptions: CheckboxListOption[];
|
||||||
|
toolOptions: CheckboxListOption[];
|
||||||
|
subagentOptions: CheckboxListOption[];
|
||||||
|
/** 各列表为空时的提示文案 */
|
||||||
|
skillEmptyHint?: string;
|
||||||
|
toolEmptyHint?: string;
|
||||||
|
subagentEmptyHint?: string;
|
||||||
|
/** 子代理副标题:限制可加载的孙代理 / 限制可加载的子代理 */
|
||||||
|
subagentsSubtitle?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
type TabKey = 'skills' | 'tools' | 'subagents';
|
||||||
|
|
||||||
|
interface TabDef {
|
||||||
|
key: TabKey;
|
||||||
|
label: string;
|
||||||
|
icon: typeof Wrench;
|
||||||
|
}
|
||||||
|
|
||||||
|
const TABS: TabDef[] = [
|
||||||
|
{ key: 'skills', label: '技能', icon: Sparkles },
|
||||||
|
{ key: 'tools', label: '工具', icon: Wrench },
|
||||||
|
{ key: 'subagents', label: '子代理', icon: Bot },
|
||||||
|
];
|
||||||
|
|
||||||
|
export function CapabilityTabs({
|
||||||
|
value,
|
||||||
|
onChange,
|
||||||
|
skillOptions,
|
||||||
|
toolOptions,
|
||||||
|
subagentOptions,
|
||||||
|
skillEmptyHint,
|
||||||
|
toolEmptyHint,
|
||||||
|
subagentEmptyHint,
|
||||||
|
subagentsSubtitle = '限制可加载的孙代理',
|
||||||
|
}: CapabilityTabsProps) {
|
||||||
|
const [active, setActive] = useState<TabKey>('skills');
|
||||||
|
|
||||||
|
// 子代理勾选列表通常排除自身(在调用方已过滤),此处不重复处理
|
||||||
|
const setField = <K extends keyof CapabilityState>(key: K, v: CapabilityState[K]) =>
|
||||||
|
onChange({ ...value, [key]: v });
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="rounded-xl border border-[var(--border-color)] overflow-hidden">
|
||||||
|
{/* Tab 头 */}
|
||||||
|
<div className="flex border-b border-[var(--border-color)] bg-[var(--bg-tertiary)]">
|
||||||
|
{TABS.map((tab) => {
|
||||||
|
const Icon = tab.icon;
|
||||||
|
const isActive = active === tab.key;
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={tab.key}
|
||||||
|
type="button"
|
||||||
|
onClick={() => setActive(tab.key)}
|
||||||
|
className={`flex-1 flex items-center justify-center gap-1.5 px-3 py-2.5 text-xs font-medium transition-colors ${
|
||||||
|
isActive
|
||||||
|
? 'text-[var(--accent-cyan)] bg-[var(--bg-secondary)] border-b-2 border-[var(--accent-cyan)] -mb-px'
|
||||||
|
: 'text-[var(--text-muted)] hover:text-[var(--text-secondary)] hover:bg-[var(--overlay-hover)]'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<Icon className="h-3.5 w-3.5" />
|
||||||
|
{tab.label}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Tab 内容:白名单 / 黑名单两列 */}
|
||||||
|
<div className="p-4">
|
||||||
|
{active === 'skills' && (
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-3">
|
||||||
|
<Field label="允许的技能(白名单)" hint="留空表示不限。仅勾选的 SKILL.md 技能可见">
|
||||||
|
<CheckboxList
|
||||||
|
options={skillOptions}
|
||||||
|
selected={value.allowedSkills}
|
||||||
|
onChange={(v) => setField('allowedSkills', v)}
|
||||||
|
extraSelected={value.allowedSkills}
|
||||||
|
emptyHint={skillEmptyHint}
|
||||||
|
groupBy={(o) => o.group ?? '其他'}
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
<Field label="禁用的技能(黑名单)" hint="在白名单之后应用">
|
||||||
|
<CheckboxList
|
||||||
|
options={skillOptions}
|
||||||
|
selected={value.deniedSkills}
|
||||||
|
onChange={(v) => setField('deniedSkills', v)}
|
||||||
|
extraSelected={value.deniedSkills}
|
||||||
|
emptyHint={skillEmptyHint}
|
||||||
|
groupBy={(o) => o.group ?? '其他'}
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{active === 'tools' && (
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-3">
|
||||||
|
<Field label="允许的工具(白名单)" hint="留空表示不限。覆盖内置 + MCP 工具">
|
||||||
|
<CheckboxList
|
||||||
|
options={toolOptions}
|
||||||
|
selected={value.allowedTools}
|
||||||
|
onChange={(v) => setField('allowedTools', v)}
|
||||||
|
extraSelected={value.allowedTools}
|
||||||
|
emptyHint={toolEmptyHint}
|
||||||
|
groupBy={(o) => o.group ?? '其他'}
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
<Field label="禁用的工具" hint="在白名单之后应用">
|
||||||
|
<CheckboxList
|
||||||
|
options={toolOptions}
|
||||||
|
selected={value.deniedTools}
|
||||||
|
onChange={(v) => setField('deniedTools', v)}
|
||||||
|
extraSelected={value.deniedTools}
|
||||||
|
emptyHint={toolEmptyHint}
|
||||||
|
groupBy={(o) => o.group ?? '其他'}
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{active === 'subagents' && (
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-3">
|
||||||
|
<Field label="允许的子代理(白名单)" hint={`留空表示不限。${subagentsSubtitle}`}>
|
||||||
|
<CheckboxList
|
||||||
|
options={subagentOptions}
|
||||||
|
selected={value.allowedSubagents}
|
||||||
|
onChange={(v) => setField('allowedSubagents', v)}
|
||||||
|
extraSelected={value.allowedSubagents}
|
||||||
|
emptyHint={subagentEmptyHint}
|
||||||
|
groupBy={(o) => o.group ?? '其他'}
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
<Field label="禁用的子代理(黑名单)" hint="在白名单之后应用">
|
||||||
|
<CheckboxList
|
||||||
|
options={subagentOptions}
|
||||||
|
selected={value.deniedSubagents}
|
||||||
|
onChange={(v) => setField('deniedSubagents', v)}
|
||||||
|
extraSelected={value.deniedSubagents}
|
||||||
|
emptyHint={subagentEmptyHint}
|
||||||
|
groupBy={(o) => o.group ?? '其他'}
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
File diff suppressed because it is too large
Load Diff
@ -1,5 +1,6 @@
|
|||||||
import { useState, useEffect } from 'react';
|
// 网关连接设置的 localStorage 工具函数
|
||||||
import { X, Wifi, RotateCcw } from 'lucide-react';
|
// 原 SettingsModal 组件已删除(功能由 ConfigPage 的 Connection Tab 承担),
|
||||||
|
// 仅保留 App.tsx 使用的 getGatewaySettings / buildWsUrl / GatewaySettings 工具函数
|
||||||
|
|
||||||
export interface GatewaySettings {
|
export interface GatewaySettings {
|
||||||
host: string;
|
host: string;
|
||||||
@ -23,155 +24,3 @@ export function getGatewaySettings(): GatewaySettings {
|
|||||||
export function buildWsUrl(settings: GatewaySettings): string {
|
export function buildWsUrl(settings: GatewaySettings): string {
|
||||||
return `ws://${settings.host}:${settings.port}/ws`;
|
return `ws://${settings.host}:${settings.port}/ws`;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface SettingsModalProps {
|
|
||||||
onClose: () => void;
|
|
||||||
onSave: (settings: GatewaySettings) => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function SettingsModal({ onClose, onSave }: SettingsModalProps) {
|
|
||||||
const [host, setHost] = useState(DEFAULT_HOST);
|
|
||||||
const [port, setPort] = useState(String(DEFAULT_PORT));
|
|
||||||
const [error, setError] = useState('');
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
const settings = getGatewaySettings();
|
|
||||||
setHost(settings.host);
|
|
||||||
setPort(String(settings.port));
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
const handleKeyDown = (e: KeyboardEvent) => {
|
|
||||||
if (e.key === 'Escape') onClose();
|
|
||||||
};
|
|
||||||
document.addEventListener('keydown', handleKeyDown);
|
|
||||||
return () => document.removeEventListener('keydown', handleKeyDown);
|
|
||||||
}, [onClose]);
|
|
||||||
|
|
||||||
const handleSave = () => {
|
|
||||||
const trimmedHost = host.trim();
|
|
||||||
const portNum = parseInt(port, 10);
|
|
||||||
|
|
||||||
if (!trimmedHost) {
|
|
||||||
setError('主机地址不能为空');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (isNaN(portNum) || portNum < 1 || portNum > 65535) {
|
|
||||||
setError('端口号必须在 1-65535 之间');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
setError('');
|
|
||||||
localStorage.setItem('picobot-gateway-host', trimmedHost);
|
|
||||||
localStorage.setItem('picobot-gateway-port', String(portNum));
|
|
||||||
onSave({ host: trimmedHost, port: portNum });
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleReset = () => {
|
|
||||||
setHost(DEFAULT_HOST);
|
|
||||||
setPort(String(DEFAULT_PORT));
|
|
||||||
setError('');
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
className="fixed inset-0 z-50 flex items-center justify-center bg-black/80 backdrop-blur-sm animate-fade-in"
|
|
||||||
onClick={onClose}
|
|
||||||
>
|
|
||||||
{/* Modal container */}
|
|
||||||
<div
|
|
||||||
className="relative w-[90vw] max-w-md rounded-2xl border border-[var(--border-color)] bg-[var(--bg-secondary)] shadow-2xl flex flex-col overflow-hidden animate-scale-in"
|
|
||||||
onClick={(e) => e.stopPropagation()}
|
|
||||||
>
|
|
||||||
{/* Header */}
|
|
||||||
<div className="flex items-center gap-3 shrink-0 px-6 py-4 border-b border-[var(--border-color)] bg-[var(--bg-tertiary)]/50">
|
|
||||||
<Wifi className="h-5 w-5 text-[var(--accent-cyan)]" />
|
|
||||||
<span className="text-lg font-semibold text-[var(--text-primary)]">连接设置</span>
|
|
||||||
<button
|
|
||||||
onClick={onClose}
|
|
||||||
className="ml-auto p-2 rounded-lg text-[var(--text-muted)] hover:text-[var(--text-primary)] hover:bg-[var(--overlay-hover)] transition-colors"
|
|
||||||
aria-label="关闭"
|
|
||||||
title="关闭 (Esc)"
|
|
||||||
>
|
|
||||||
<X className="h-5 w-5" />
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Body */}
|
|
||||||
<div className="flex-1 p-6 space-y-5">
|
|
||||||
{/* Host */}
|
|
||||||
<div>
|
|
||||||
<label className="block text-sm font-medium text-[var(--text-secondary)] mb-1.5">
|
|
||||||
主机地址
|
|
||||||
</label>
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
value={host}
|
|
||||||
onChange={(e) => {
|
|
||||||
setHost(e.target.value);
|
|
||||||
setError('');
|
|
||||||
}}
|
|
||||||
placeholder="127.0.0.1"
|
|
||||||
className="w-full px-3 py-2.5 rounded-xl bg-[var(--bg-tertiary)] border border-[var(--border-color)] text-[var(--text-primary)] text-sm placeholder:text-[var(--text-muted)] focus:outline-none focus:border-[var(--accent-cyan)] focus:ring-2 focus:ring-[var(--focus-ring)] transition-colors"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Port */}
|
|
||||||
<div>
|
|
||||||
<label className="block text-sm font-medium text-[var(--text-secondary)] mb-1.5">
|
|
||||||
端口号
|
|
||||||
</label>
|
|
||||||
<input
|
|
||||||
type="number"
|
|
||||||
value={port}
|
|
||||||
onChange={(e) => {
|
|
||||||
setPort(e.target.value);
|
|
||||||
setError('');
|
|
||||||
}}
|
|
||||||
placeholder="19876"
|
|
||||||
min={1}
|
|
||||||
max={65535}
|
|
||||||
className="w-full px-3 py-2.5 rounded-xl bg-[var(--bg-tertiary)] border border-[var(--border-color)] text-[var(--text-primary)] text-sm placeholder:text-[var(--text-muted)] focus:outline-none focus:border-[var(--accent-cyan)] focus:ring-2 focus:ring-[var(--focus-ring)] transition-colors"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Error */}
|
|
||||||
{error && (
|
|
||||||
<div className="text-sm text-red-400 bg-red-500/10 border border-red-500/20 rounded-lg px-3 py-2">
|
|
||||||
{error}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Current URL preview */}
|
|
||||||
<div className="text-xs text-[var(--text-muted)] bg-[var(--overlay-dim)] rounded-lg px-3 py-2 font-mono">
|
|
||||||
ws://{host.trim() || '...'}:{port || '...'}/ws
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Footer */}
|
|
||||||
<div className="shrink-0 px-6 py-3 border-t border-[var(--border-color)] bg-[var(--bg-tertiary)]/30 flex items-center gap-3">
|
|
||||||
<button
|
|
||||||
onClick={handleReset}
|
|
||||||
className="flex items-center gap-1.5 px-3 py-2 rounded-lg text-sm text-[var(--text-muted)] hover:text-[var(--text-secondary)] hover:bg-[var(--overlay-hover)] transition-colors"
|
|
||||||
>
|
|
||||||
<RotateCcw className="h-3.5 w-3.5" />
|
|
||||||
重置默认
|
|
||||||
</button>
|
|
||||||
<div className="flex-1" />
|
|
||||||
<button
|
|
||||||
onClick={onClose}
|
|
||||||
className="px-4 py-2 rounded-lg text-sm text-[var(--text-secondary)] hover:text-[var(--text-primary)] hover:bg-[var(--overlay-hover)] transition-colors"
|
|
||||||
>
|
|
||||||
取消
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
onClick={handleSave}
|
|
||||||
className="px-5 py-2 rounded-lg text-sm font-medium text-white bg-[var(--accent-cyan)]/20 border border-[var(--accent-cyan)]/30 hover:bg-[var(--accent-cyan)]/30 hover:border-[var(--accent-cyan)]/50 transition-all"
|
|
||||||
>
|
|
||||||
保存并重连
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|||||||
@ -17,24 +17,54 @@ import {
|
|||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import type { TabId } from './types';
|
import type { TabId } from './types';
|
||||||
|
|
||||||
export const TABS: { id: TabId; label: string; icon: typeof Settings }[] = [
|
export interface TabDef {
|
||||||
{ id: 'providers', label: '服务商', icon: Cpu },
|
id: TabId;
|
||||||
{ id: 'models', label: '模型', icon: Brain },
|
label: string;
|
||||||
{ id: 'agents', label: '代理', icon: Bot },
|
icon: typeof Settings;
|
||||||
{ id: 'mcp', label: 'MCP 服务器', icon: Plug },
|
}
|
||||||
{ id: 'skills', label: '技能', icon: Wrench },
|
|
||||||
{ id: 'subagents', label: '子代理', icon: Bot },
|
export interface TabGroup {
|
||||||
{ id: 'experts', label: '专家', icon: UserCheck },
|
label: string;
|
||||||
{ id: 'channels', label: '渠道', icon: Radio },
|
tabs: TabDef[];
|
||||||
{ id: 'tools', label: '工具', icon: Settings },
|
}
|
||||||
{ id: 'memory', label: '记忆维护', icon: Users },
|
|
||||||
{ id: 'scheduler', label: '调度器', icon: Calendar },
|
// 按功能分组展示,便于快速定位
|
||||||
{ id: 'image', label: '图片上下文', icon: Image },
|
export const TAB_GROUPS: TabGroup[] = [
|
||||||
{ id: 'time', label: '时间', icon: Clock },
|
{
|
||||||
{ id: 'connection', label: '连接', icon: Wifi },
|
label: 'AI 核心',
|
||||||
{ id: 'gateway', label: '网关', icon: Server },
|
tabs: [
|
||||||
|
{ id: 'providers', label: '服务商', icon: Cpu },
|
||||||
|
{ id: 'models', label: '模型', icon: Brain },
|
||||||
|
{ id: 'agents', label: '代理', icon: Bot },
|
||||||
|
{ id: 'experts', label: '专家', icon: UserCheck },
|
||||||
|
{ id: 'subagents', label: '子代理', icon: Bot },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: '集成',
|
||||||
|
tabs: [
|
||||||
|
{ id: 'mcp', label: 'MCP 服务器', icon: Plug },
|
||||||
|
{ id: 'skills', label: '技能', icon: Wrench },
|
||||||
|
{ id: 'tools', label: '工具', icon: Settings },
|
||||||
|
{ id: 'channels', label: '渠道', icon: Radio },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: '系统',
|
||||||
|
tabs: [
|
||||||
|
{ id: 'gateway', label: '网关', icon: Server },
|
||||||
|
{ id: 'scheduler', label: '调度器', icon: Calendar },
|
||||||
|
{ id: 'memory', label: '记忆维护', icon: Users },
|
||||||
|
{ id: 'image', label: '图片上下文', icon: Image },
|
||||||
|
{ id: 'time', label: '时间', icon: Clock },
|
||||||
|
{ id: 'connection', label: '连接', icon: Wifi },
|
||||||
|
],
|
||||||
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
|
// 扁平化,便于按 id 查找
|
||||||
|
export const TABS: TabDef[] = TAB_GROUPS.flatMap((g) => g.tabs);
|
||||||
|
|
||||||
export const inputCls =
|
export const inputCls =
|
||||||
'w-full px-3 py-2 rounded-lg bg-[var(--bg-tertiary)] border border-[var(--border-color)] text-[var(--text-primary)] text-sm placeholder:text-[var(--text-muted)] focus:outline-none focus:border-[var(--accent-cyan)] focus:ring-1 focus:ring-[var(--focus-ring)] transition-colors';
|
'w-full px-3 py-2 rounded-lg bg-[var(--bg-tertiary)] border border-[var(--border-color)] text-[var(--text-primary)] text-sm placeholder:text-[var(--text-muted)] focus:outline-none focus:border-[var(--accent-cyan)] focus:ring-1 focus:ring-[var(--focus-ring)] transition-colors';
|
||||||
export const selectCls = inputCls;
|
export const selectCls = inputCls;
|
||||||
|
|||||||
304
web/src/components/Settings/modals/ExpertModal.tsx
Normal file
304
web/src/components/Settings/modals/ExpertModal.tsx
Normal file
@ -0,0 +1,304 @@
|
|||||||
|
// ExpertModal - 专家编辑/创建模态框
|
||||||
|
// 挂载时通过 useSharedModalData 加载 skills/tools/subagents/modelOptions
|
||||||
|
import { useState, useEffect } from 'react';
|
||||||
|
import { UserCheck, Save, Loader2 } from 'lucide-react';
|
||||||
|
import { Field, SectionCard, ModalHeader, ModalFooter } from '../ui';
|
||||||
|
import { inputCls, selectCls } from '../constants';
|
||||||
|
import { ErrorBanner } from '../shared';
|
||||||
|
import { useSharedModalData } from '../useSharedModalData';
|
||||||
|
import { CapabilityTabs, type CapabilityState } from '../CapabilityTabs';
|
||||||
|
import {
|
||||||
|
createExpert,
|
||||||
|
updateExpert,
|
||||||
|
} from '../../../api/experts';
|
||||||
|
import type { CapabilityPolicy, ExpertItem } from '../types';
|
||||||
|
|
||||||
|
export interface ExpertDraft {
|
||||||
|
mode: 'create' | 'edit';
|
||||||
|
name?: string;
|
||||||
|
scope: string;
|
||||||
|
nameField: string;
|
||||||
|
description: string;
|
||||||
|
body: string;
|
||||||
|
provider: string;
|
||||||
|
model: string;
|
||||||
|
allowedSkills: string[];
|
||||||
|
deniedSkills: string[];
|
||||||
|
allowedTools: string[];
|
||||||
|
deniedTools: string[];
|
||||||
|
allowedSubagents: string[];
|
||||||
|
deniedSubagents: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
draft: ExpertDraft;
|
||||||
|
onClose: () => void;
|
||||||
|
onSaved: () => void;
|
||||||
|
setToast: (msg: string) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ExpertModal({ draft, onClose, onSaved, setToast }: Props) {
|
||||||
|
const [form, setForm] = useState<ExpertDraft>(draft);
|
||||||
|
const [error, setError] = useState('');
|
||||||
|
const [saving, setSaving] = useState(false);
|
||||||
|
const shared = useSharedModalData(true);
|
||||||
|
|
||||||
|
// ESC 仅关闭本模态框(capture 阶段拦截,阻止冒泡到 ConfigPage 的全局 ESC)
|
||||||
|
useEffect(() => {
|
||||||
|
const handler = (e: KeyboardEvent) => {
|
||||||
|
if (e.key === 'Escape') {
|
||||||
|
e.stopPropagation();
|
||||||
|
e.preventDefault();
|
||||||
|
onClose();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
window.addEventListener('keydown', handler, true);
|
||||||
|
return () => window.removeEventListener('keydown', handler, true);
|
||||||
|
}, [onClose]);
|
||||||
|
|
||||||
|
const isEdit = form.mode === 'edit';
|
||||||
|
const canSave = form.nameField.trim().length > 0 && form.description.trim().length > 0;
|
||||||
|
|
||||||
|
const setField = <K extends keyof ExpertDraft>(key: K, value: ExpertDraft[K]) =>
|
||||||
|
setForm((prev) => ({ ...prev, [key]: value }));
|
||||||
|
|
||||||
|
const handleCapabilityChange = (next: CapabilityState) => {
|
||||||
|
setForm((prev) => ({
|
||||||
|
...prev,
|
||||||
|
allowedSkills: next.allowedSkills,
|
||||||
|
deniedSkills: next.deniedSkills,
|
||||||
|
allowedTools: next.allowedTools,
|
||||||
|
deniedTools: next.deniedTools,
|
||||||
|
allowedSubagents: next.allowedSubagents,
|
||||||
|
deniedSubagents: next.deniedSubagents,
|
||||||
|
}));
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSave = async () => {
|
||||||
|
if (!canSave) return;
|
||||||
|
setSaving(true);
|
||||||
|
setError('');
|
||||||
|
try {
|
||||||
|
// allowed_* 为空时必须传 undefined(后端 None=不限),
|
||||||
|
// 否则空数组会被反序列化为 Some(vec![]) 触发白名单空集语义(全禁)。
|
||||||
|
// denied_* 为 Vec<String>,空数组即"不禁",可直接传。
|
||||||
|
const capability: CapabilityPolicy = {
|
||||||
|
allowed_skills: form.allowedSkills.length > 0 ? form.allowedSkills : undefined,
|
||||||
|
denied_skills: form.deniedSkills,
|
||||||
|
allowed_tools: form.allowedTools.length > 0 ? form.allowedTools : undefined,
|
||||||
|
denied_tools: form.deniedTools,
|
||||||
|
allowed_subagents:
|
||||||
|
form.allowedSubagents.length > 0 ? form.allowedSubagents : undefined,
|
||||||
|
denied_subagents: form.deniedSubagents,
|
||||||
|
};
|
||||||
|
const payload = {
|
||||||
|
name: form.nameField,
|
||||||
|
description: form.description,
|
||||||
|
body: form.body,
|
||||||
|
capability,
|
||||||
|
provider: form.provider || undefined,
|
||||||
|
model: form.model || undefined,
|
||||||
|
};
|
||||||
|
const resp = isEdit
|
||||||
|
? await updateExpert({ ...payload, scope: 'project' })
|
||||||
|
: await createExpert({ ...payload, scope: 'project' });
|
||||||
|
const data = await resp.json().catch(() => ({}));
|
||||||
|
if (!resp.ok) {
|
||||||
|
setError(data.error || data.message || '保存失败');
|
||||||
|
setSaving(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setToast(isEdit ? '专家已更新' : '专家已创建');
|
||||||
|
onSaved();
|
||||||
|
} catch (e: unknown) {
|
||||||
|
setError(e instanceof Error ? e.message : '网络错误');
|
||||||
|
} finally {
|
||||||
|
setSaving(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const skillOptions = (shared.skillList?.skills ?? []).map((s) => ({
|
||||||
|
key: s.name,
|
||||||
|
label: s.name,
|
||||||
|
description: s.description,
|
||||||
|
group: s.source,
|
||||||
|
}));
|
||||||
|
const toolOptions = (shared.toolList?.tools ?? []).map((t) => ({
|
||||||
|
key: t.name,
|
||||||
|
label: t.name,
|
||||||
|
description: t.description,
|
||||||
|
group: t.source,
|
||||||
|
}));
|
||||||
|
const subagentOptions = (shared.subagentList?.subagents ?? []).map((s) => ({
|
||||||
|
key: s.name,
|
||||||
|
label: s.name,
|
||||||
|
description: s.description,
|
||||||
|
group: s.source,
|
||||||
|
}));
|
||||||
|
const skillEmptyHint = shared.loading ? '加载中...' : '未发现任何技能,请先在技能页配置来源目录';
|
||||||
|
const toolEmptyHint = shared.loading ? '加载中...' : '未发现任何工具';
|
||||||
|
const subagentEmptyHint = shared.loading ? '加载中...' : '未发现任何子代理';
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className="absolute inset-0 z-20 flex items-center justify-center bg-black/50 backdrop-blur-sm rounded-2xl"
|
||||||
|
onClick={onClose}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className="bg-[var(--bg-secondary)] border border-[var(--border-color)] rounded-xl w-[90%] max-w-3xl mx-4 shadow-2xl animate-[scaleIn_0.2s_ease-out] max-h-[90%] flex flex-col overflow-hidden"
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
>
|
||||||
|
<ModalHeader
|
||||||
|
icon={<UserCheck className="h-5 w-5 text-[var(--accent-cyan)]" />}
|
||||||
|
title={isEdit ? '编辑专家' : '添加专家'}
|
||||||
|
onClose={onClose}
|
||||||
|
/>
|
||||||
|
<div className="flex-1 overflow-y-auto p-6 space-y-4">
|
||||||
|
<SectionCard title="基本信息">
|
||||||
|
<Field
|
||||||
|
label="名称"
|
||||||
|
hint="创建后不可修改。仅当正文为空时,与描述一起生成兜底提示词;正文非空时不注入"
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
value={form.nameField}
|
||||||
|
onChange={(e) => setField('nameField', e.target.value)}
|
||||||
|
disabled={isEdit}
|
||||||
|
placeholder="如 translator"
|
||||||
|
className={inputCls + (isEdit ? ' opacity-60 cursor-not-allowed' : '')}
|
||||||
|
autoFocus={!isEdit}
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
<Field
|
||||||
|
label="描述"
|
||||||
|
hint="必填。仅当正文为空时,与名称一起生成兜底提示词;正文非空时不注入"
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
value={form.description}
|
||||||
|
onChange={(e) => setField('description', e.target.value)}
|
||||||
|
placeholder="如 翻译专家"
|
||||||
|
className={inputCls}
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
<Field
|
||||||
|
label="专家提示词正文"
|
||||||
|
hint="markdown 格式。非空时仅注入正文;为空时自动用“名称+描述”生成兜底提示词"
|
||||||
|
>
|
||||||
|
<textarea
|
||||||
|
value={form.body}
|
||||||
|
onChange={(e) => setField('body', e.target.value)}
|
||||||
|
placeholder="你是一名专业翻译..."
|
||||||
|
className={inputCls + ' min-h-[160px] resize-y font-mono text-xs'}
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
</SectionCard>
|
||||||
|
<SectionCard title="模型配置" subtitle="留空继承默认">
|
||||||
|
<Field label="Provider" hint="留空继承默认配置">
|
||||||
|
<select
|
||||||
|
value={form.provider}
|
||||||
|
onChange={(e) => setField('provider', e.target.value)}
|
||||||
|
className={selectCls}
|
||||||
|
>
|
||||||
|
<option value="">继承默认</option>
|
||||||
|
{(shared.modelOptions?.providers ?? []).map((p) => (
|
||||||
|
<option key={p} value={p}>
|
||||||
|
{p}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</Field>
|
||||||
|
<Field label="Model" hint="留空继承默认配置">
|
||||||
|
<select
|
||||||
|
value={form.model}
|
||||||
|
onChange={(e) => setField('model', e.target.value)}
|
||||||
|
className={selectCls}
|
||||||
|
>
|
||||||
|
<option value="">继承默认</option>
|
||||||
|
{(shared.modelOptions?.models ?? []).map((m) => (
|
||||||
|
<option key={m} value={m}>
|
||||||
|
{m}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</Field>
|
||||||
|
</SectionCard>
|
||||||
|
<SectionCard title="能力配置" subtitle="白名单取交集,黑名单扣除">
|
||||||
|
<CapabilityTabs
|
||||||
|
value={{
|
||||||
|
allowedSkills: form.allowedSkills,
|
||||||
|
deniedSkills: form.deniedSkills,
|
||||||
|
allowedTools: form.allowedTools,
|
||||||
|
deniedTools: form.deniedTools,
|
||||||
|
allowedSubagents: form.allowedSubagents,
|
||||||
|
deniedSubagents: form.deniedSubagents,
|
||||||
|
}}
|
||||||
|
onChange={handleCapabilityChange}
|
||||||
|
skillOptions={skillOptions}
|
||||||
|
toolOptions={toolOptions}
|
||||||
|
subagentOptions={subagentOptions}
|
||||||
|
skillEmptyHint={skillEmptyHint}
|
||||||
|
toolEmptyHint={toolEmptyHint}
|
||||||
|
subagentEmptyHint={subagentEmptyHint}
|
||||||
|
subagentsSubtitle="仅勾选的子代理可被加载"
|
||||||
|
/>
|
||||||
|
</SectionCard>
|
||||||
|
<ErrorBanner>{error}</ErrorBanner>
|
||||||
|
</div>
|
||||||
|
<ModalFooter>
|
||||||
|
<button
|
||||||
|
onClick={onClose}
|
||||||
|
className="px-4 py-2 rounded-lg text-sm text-[var(--text-secondary)] hover:text-[var(--text-primary)] hover:bg-[var(--overlay-hover)] transition-colors"
|
||||||
|
>
|
||||||
|
取消
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={handleSave}
|
||||||
|
disabled={!canSave || saving}
|
||||||
|
className="flex items-center gap-2 px-4 py-2 rounded-lg text-sm font-medium text-white bg-[var(--accent-cyan)]/20 border border-[var(--accent-cyan)]/30 hover:bg-[var(--accent-cyan)]/30 hover:border-[var(--accent-cyan)]/50 transition-all disabled:opacity-40 disabled:cursor-not-allowed"
|
||||||
|
>
|
||||||
|
{saving ? <Loader2 className="h-4 w-4 animate-spin" /> : <Save className="h-4 w-4" />}
|
||||||
|
{saving ? '保存中...' : '保存'}
|
||||||
|
</button>
|
||||||
|
</ModalFooter>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 从已有 ExpertItem 构造编辑用 draft */
|
||||||
|
export function expertToDraft(expert: ExpertItem): ExpertDraft {
|
||||||
|
return {
|
||||||
|
mode: 'edit',
|
||||||
|
name: expert.name,
|
||||||
|
scope: 'project',
|
||||||
|
nameField: expert.name,
|
||||||
|
description: expert.description,
|
||||||
|
body: expert.body ?? '',
|
||||||
|
provider: expert.provider ?? '',
|
||||||
|
model: expert.model ?? '',
|
||||||
|
allowedSkills: expert.capability?.allowed_skills ?? [],
|
||||||
|
deniedSkills: expert.capability?.denied_skills ?? [],
|
||||||
|
allowedTools: expert.capability?.allowed_tools ?? [],
|
||||||
|
deniedTools: expert.capability?.denied_tools ?? [],
|
||||||
|
allowedSubagents: expert.capability?.allowed_subagents ?? [],
|
||||||
|
deniedSubagents: expert.capability?.denied_subagents ?? [],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 构造创建用空 draft */
|
||||||
|
export function emptyExpertDraft(): ExpertDraft {
|
||||||
|
return {
|
||||||
|
mode: 'create',
|
||||||
|
scope: 'project',
|
||||||
|
nameField: '',
|
||||||
|
description: '',
|
||||||
|
body: '',
|
||||||
|
provider: '',
|
||||||
|
model: '',
|
||||||
|
allowedSkills: [],
|
||||||
|
deniedSkills: [],
|
||||||
|
allowedTools: [],
|
||||||
|
deniedTools: [],
|
||||||
|
allowedSubagents: [],
|
||||||
|
deniedSubagents: [],
|
||||||
|
};
|
||||||
|
}
|
||||||
326
web/src/components/Settings/modals/SubagentModal.tsx
Normal file
326
web/src/components/Settings/modals/SubagentModal.tsx
Normal file
@ -0,0 +1,326 @@
|
|||||||
|
// SubagentModal - 子代理编辑/创建模态框
|
||||||
|
// 挂载时通过 useSharedModalData 加载 skills/tools/subagents/modelOptions
|
||||||
|
// 与 ExpertModal 对齐:支持 body 编辑、create/edit 双模式、能力配置用 CapabilityTabs
|
||||||
|
import { useState, useEffect } from 'react';
|
||||||
|
import { Bot, Save, Loader2 } from 'lucide-react';
|
||||||
|
import { Field, SectionCard, ModalHeader, ModalFooter } from '../ui';
|
||||||
|
import { inputCls, selectCls } from '../constants';
|
||||||
|
import { ErrorBanner } from '../shared';
|
||||||
|
import { useSharedModalData } from '../useSharedModalData';
|
||||||
|
import { CapabilityTabs, type CapabilityState } from '../CapabilityTabs';
|
||||||
|
import { createSubagent, updateSubagent } from '../../../api/subagents';
|
||||||
|
import type { CapabilityPolicy, SubagentItem } from '../types';
|
||||||
|
|
||||||
|
export interface SubagentDraft {
|
||||||
|
mode: 'create' | 'edit';
|
||||||
|
name: string;
|
||||||
|
description: string;
|
||||||
|
body: string;
|
||||||
|
provider: string;
|
||||||
|
model: string;
|
||||||
|
allowedSkills: string[];
|
||||||
|
deniedSkills: string[];
|
||||||
|
allowedTools: string[];
|
||||||
|
deniedTools: string[];
|
||||||
|
allowedSubagents: string[];
|
||||||
|
deniedSubagents: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
draft: SubagentDraft;
|
||||||
|
onClose: () => void;
|
||||||
|
onSaved: () => void;
|
||||||
|
setToast: (msg: string) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function SubagentModal({ draft, onClose, onSaved, setToast }: Props) {
|
||||||
|
const [form, setForm] = useState<SubagentDraft>(draft);
|
||||||
|
const [error, setError] = useState('');
|
||||||
|
const [saving, setSaving] = useState(false);
|
||||||
|
const shared = useSharedModalData(true);
|
||||||
|
|
||||||
|
// ESC 仅关闭本模态框(capture 阶段拦截,阻止冒泡到 ConfigPage 的全局 ESC)
|
||||||
|
useEffect(() => {
|
||||||
|
const handler = (e: KeyboardEvent) => {
|
||||||
|
if (e.key === 'Escape') {
|
||||||
|
e.stopPropagation();
|
||||||
|
e.preventDefault();
|
||||||
|
onClose();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
window.addEventListener('keydown', handler, true);
|
||||||
|
return () => window.removeEventListener('keydown', handler, true);
|
||||||
|
}, [onClose]);
|
||||||
|
|
||||||
|
const isEdit = form.mode === 'edit';
|
||||||
|
const canSave = form.name.trim().length > 0 && form.description.trim().length > 0;
|
||||||
|
// 排除自身,避免子代理勾选自己造成意外自递归(max_nesting_depth 仍兜底)
|
||||||
|
const selfName = draft.name;
|
||||||
|
|
||||||
|
const setField = <K extends keyof SubagentDraft>(key: K, value: SubagentDraft[K]) =>
|
||||||
|
setForm((prev) => ({ ...prev, [key]: value }));
|
||||||
|
|
||||||
|
const handleCapabilityChange = (next: CapabilityState) => {
|
||||||
|
setForm((prev) => ({
|
||||||
|
...prev,
|
||||||
|
allowedSkills: next.allowedSkills,
|
||||||
|
deniedSkills: next.deniedSkills,
|
||||||
|
allowedTools: next.allowedTools,
|
||||||
|
deniedTools: next.deniedTools,
|
||||||
|
allowedSubagents: next.allowedSubagents,
|
||||||
|
deniedSubagents: next.deniedSubagents,
|
||||||
|
}));
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSave = async () => {
|
||||||
|
if (!canSave) return;
|
||||||
|
setSaving(true);
|
||||||
|
setError('');
|
||||||
|
try {
|
||||||
|
// 与专家一致:allowed_* 为空时传 undefined(None=不限),denied_* 空数组即"不禁"
|
||||||
|
// 必须包含全部 6 个字段,否则后端 #[serde(default)] 会让缺失字段变为 None/vec![],
|
||||||
|
// 经 next_capability 完全覆盖原 capability,导致既有策略被清空(数据丢失)
|
||||||
|
const capability: CapabilityPolicy = {
|
||||||
|
allowed_skills: form.allowedSkills.length > 0 ? form.allowedSkills : undefined,
|
||||||
|
denied_skills: form.deniedSkills,
|
||||||
|
allowed_tools: form.allowedTools.length > 0 ? form.allowedTools : undefined,
|
||||||
|
denied_tools: form.deniedTools,
|
||||||
|
allowed_subagents:
|
||||||
|
form.allowedSubagents.length > 0 ? form.allowedSubagents : undefined,
|
||||||
|
denied_subagents: form.deniedSubagents,
|
||||||
|
};
|
||||||
|
|
||||||
|
if (isEdit) {
|
||||||
|
const resp = await updateSubagent({
|
||||||
|
name: form.name,
|
||||||
|
description: form.description,
|
||||||
|
body: form.body,
|
||||||
|
capability,
|
||||||
|
provider: form.provider || undefined,
|
||||||
|
model: form.model || undefined,
|
||||||
|
});
|
||||||
|
const data = await resp.json().catch(() => ({}));
|
||||||
|
if (!resp.ok) {
|
||||||
|
setError(data.error || data.message || '保存失败');
|
||||||
|
setSaving(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setToast('子代理已更新');
|
||||||
|
} else {
|
||||||
|
const resp = await createSubagent({
|
||||||
|
name: form.name.trim(),
|
||||||
|
description: form.description,
|
||||||
|
body: form.body,
|
||||||
|
scope: 'project',
|
||||||
|
capability,
|
||||||
|
provider: form.provider || undefined,
|
||||||
|
model: form.model || undefined,
|
||||||
|
});
|
||||||
|
const data = await resp.json().catch(() => ({}));
|
||||||
|
if (!resp.ok) {
|
||||||
|
setError(data.error || data.message || '创建失败');
|
||||||
|
setSaving(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setToast('子代理已创建');
|
||||||
|
}
|
||||||
|
onSaved();
|
||||||
|
} catch (e: unknown) {
|
||||||
|
setError(e instanceof Error ? e.message : '网络错误');
|
||||||
|
} finally {
|
||||||
|
setSaving(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const skillOptions = (shared.skillList?.skills ?? []).map((s) => ({
|
||||||
|
key: s.name,
|
||||||
|
label: s.name,
|
||||||
|
description: s.description,
|
||||||
|
group: s.source,
|
||||||
|
}));
|
||||||
|
const toolOptions = (shared.toolList?.tools ?? []).map((t) => ({
|
||||||
|
key: t.name,
|
||||||
|
label: t.name,
|
||||||
|
description: t.description,
|
||||||
|
group: t.source,
|
||||||
|
}));
|
||||||
|
const subagentOptionsAll = (shared.subagentList?.subagents ?? []).map((s) => ({
|
||||||
|
key: s.name,
|
||||||
|
label: s.name,
|
||||||
|
description: s.description,
|
||||||
|
group: s.source,
|
||||||
|
}));
|
||||||
|
// 子代理勾选列表排除自身(仅 edit 模式,create 模式自身还不存在)
|
||||||
|
const subagentOptions = isEdit
|
||||||
|
? subagentOptionsAll.filter((o) => o.key !== selfName)
|
||||||
|
: subagentOptionsAll;
|
||||||
|
const skillEmptyHint = shared.loading ? '加载中...' : '未发现任何技能,请先在技能页配置来源目录';
|
||||||
|
const toolEmptyHint = shared.loading ? '加载中...' : '未发现任何工具';
|
||||||
|
const subagentEmptyHint = shared.loading ? '加载中...' : '未发现任何子代理';
|
||||||
|
|
||||||
|
const capabilityValue: CapabilityState = {
|
||||||
|
allowedSkills: form.allowedSkills,
|
||||||
|
deniedSkills: form.deniedSkills,
|
||||||
|
allowedTools: form.allowedTools,
|
||||||
|
deniedTools: form.deniedTools,
|
||||||
|
allowedSubagents: form.allowedSubagents,
|
||||||
|
deniedSubagents: form.deniedSubagents,
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className="absolute inset-0 z-20 flex items-center justify-center bg-black/50 backdrop-blur-sm rounded-2xl"
|
||||||
|
onClick={onClose}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className="bg-[var(--bg-secondary)] border border-[var(--border-color)] rounded-xl w-[90%] max-w-3xl mx-4 shadow-2xl animate-[scaleIn_0.2s_ease-out] max-h-[90%] flex flex-col overflow-hidden"
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
>
|
||||||
|
<ModalHeader
|
||||||
|
icon={<Bot className="h-5 w-5 text-[var(--accent-cyan)]" />}
|
||||||
|
title={isEdit ? '编辑子代理' : '添加子代理'}
|
||||||
|
onClose={onClose}
|
||||||
|
/>
|
||||||
|
<div className="flex-1 overflow-y-auto p-6 space-y-4">
|
||||||
|
{/* 基础配置:名称 + 描述 + provider + model 合并到一个 SectionCard */}
|
||||||
|
<SectionCard title="基础配置">
|
||||||
|
<Field
|
||||||
|
label="名称"
|
||||||
|
hint={isEdit ? '创建后不可修改' : '仅字母、数字、下划线、连字符。创建后不可修改'}
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
value={form.name}
|
||||||
|
onChange={(e) => setField('name', e.target.value)}
|
||||||
|
disabled={isEdit}
|
||||||
|
placeholder="如 researcher"
|
||||||
|
className={inputCls + (isEdit ? ' opacity-60 cursor-not-allowed' : '')}
|
||||||
|
autoFocus={!isEdit}
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
<Field label="描述" hint="子代理的简短描述,用于主智能体选择">
|
||||||
|
<input
|
||||||
|
value={form.description}
|
||||||
|
onChange={(e) => setField('description', e.target.value)}
|
||||||
|
className={inputCls}
|
||||||
|
placeholder="如 研究专家"
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-3">
|
||||||
|
<Field label="Provider" hint="留空继承默认配置">
|
||||||
|
<select
|
||||||
|
value={form.provider}
|
||||||
|
onChange={(e) => setField('provider', e.target.value)}
|
||||||
|
className={selectCls}
|
||||||
|
>
|
||||||
|
<option value="">继承默认</option>
|
||||||
|
{(shared.modelOptions?.providers ?? []).map((p) => (
|
||||||
|
<option key={p} value={p}>
|
||||||
|
{p}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</Field>
|
||||||
|
<Field label="Model" hint="留空继承默认配置">
|
||||||
|
<select
|
||||||
|
value={form.model}
|
||||||
|
onChange={(e) => setField('model', e.target.value)}
|
||||||
|
className={selectCls}
|
||||||
|
>
|
||||||
|
<option value="">继承默认</option>
|
||||||
|
{(shared.modelOptions?.models ?? []).map((m) => (
|
||||||
|
<option key={m} value={m}>
|
||||||
|
{m}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</Field>
|
||||||
|
</div>
|
||||||
|
</SectionCard>
|
||||||
|
|
||||||
|
{/* 提示词正文:独立 SectionCard,与 ExpertModal 对齐 */}
|
||||||
|
<SectionCard
|
||||||
|
title="提示词正文"
|
||||||
|
subtitle="markdown 格式,追加到系统提示词末尾。留空则不追加"
|
||||||
|
>
|
||||||
|
<Field label="Body" hint="子代理的具体指令。支持 markdown,会拼接到默认系统提示词之后">
|
||||||
|
<textarea
|
||||||
|
value={form.body}
|
||||||
|
onChange={(e) => setField('body', e.target.value)}
|
||||||
|
placeholder="你是一个专注的研究助手..."
|
||||||
|
className={inputCls + ' min-h-[160px] resize-y font-mono text-xs'}
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
</SectionCard>
|
||||||
|
|
||||||
|
{/* 能力配置:内嵌 Tab,节省垂直空间 */}
|
||||||
|
<SectionCard title="能力配置" subtitle="白名单取交集,黑名单扣除">
|
||||||
|
<CapabilityTabs
|
||||||
|
value={capabilityValue}
|
||||||
|
onChange={handleCapabilityChange}
|
||||||
|
skillOptions={skillOptions}
|
||||||
|
toolOptions={toolOptions}
|
||||||
|
subagentOptions={subagentOptions}
|
||||||
|
skillEmptyHint={skillEmptyHint}
|
||||||
|
toolEmptyHint={toolEmptyHint}
|
||||||
|
subagentEmptyHint={subagentEmptyHint}
|
||||||
|
subagentsSubtitle="仅勾选的子代理可被加载为孙代理"
|
||||||
|
/>
|
||||||
|
</SectionCard>
|
||||||
|
<ErrorBanner>{error}</ErrorBanner>
|
||||||
|
</div>
|
||||||
|
<ModalFooter>
|
||||||
|
<button
|
||||||
|
onClick={onClose}
|
||||||
|
className="px-4 py-2 rounded-lg text-sm text-[var(--text-secondary)] hover:text-[var(--text-primary)] hover:bg-[var(--overlay-hover)] transition-colors"
|
||||||
|
>
|
||||||
|
取消
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={handleSave}
|
||||||
|
disabled={!canSave || saving}
|
||||||
|
className="flex items-center gap-2 px-4 py-2 rounded-lg text-sm font-medium text-white bg-[var(--accent-cyan)]/20 border border-[var(--accent-cyan)]/30 hover:bg-[var(--accent-cyan)]/30 hover:border-[var(--accent-cyan)]/50 transition-all disabled:opacity-40 disabled:cursor-not-allowed"
|
||||||
|
>
|
||||||
|
{saving ? <Loader2 className="h-4 w-4 animate-spin" /> : <Save className="h-4 w-4" />}
|
||||||
|
{saving ? '保存中...' : '保存'}
|
||||||
|
</button>
|
||||||
|
</ModalFooter>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 从已有 SubagentItem 构造编辑用 draft */
|
||||||
|
export function subagentToDraft(subagent: SubagentItem): SubagentDraft {
|
||||||
|
return {
|
||||||
|
mode: 'edit',
|
||||||
|
name: subagent.name,
|
||||||
|
description: subagent.description,
|
||||||
|
body: subagent.body ?? '',
|
||||||
|
provider: subagent.provider ?? '',
|
||||||
|
model: subagent.model ?? '',
|
||||||
|
allowedSkills: subagent.capability?.allowed_skills ?? [],
|
||||||
|
deniedSkills: subagent.capability?.denied_skills ?? [],
|
||||||
|
allowedTools: subagent.capability?.allowed_tools ?? [],
|
||||||
|
deniedTools: subagent.capability?.denied_tools ?? [],
|
||||||
|
allowedSubagents: subagent.capability?.allowed_subagents ?? [],
|
||||||
|
deniedSubagents: subagent.capability?.denied_subagents ?? [],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 构造创建用空 draft */
|
||||||
|
export function emptySubagentDraft(): SubagentDraft {
|
||||||
|
return {
|
||||||
|
mode: 'create',
|
||||||
|
name: '',
|
||||||
|
description: '',
|
||||||
|
body: '',
|
||||||
|
provider: '',
|
||||||
|
model: '',
|
||||||
|
allowedSkills: [],
|
||||||
|
deniedSkills: [],
|
||||||
|
allowedTools: [],
|
||||||
|
deniedTools: [],
|
||||||
|
allowedSubagents: [],
|
||||||
|
deniedSubagents: [],
|
||||||
|
};
|
||||||
|
}
|
||||||
37
web/src/components/Settings/shared.tsx
Normal file
37
web/src/components/Settings/shared.tsx
Normal file
@ -0,0 +1,37 @@
|
|||||||
|
// Shared types and small components for Tab components
|
||||||
|
import type { ReactNode } from 'react';
|
||||||
|
import { Plus } from 'lucide-react';
|
||||||
|
import type { AppConfig } from './types';
|
||||||
|
|
||||||
|
/** 所有 Tab 组件共享的 props:完整 config + 顶层 update 回调 */
|
||||||
|
export interface TabProps {
|
||||||
|
config: AppConfig;
|
||||||
|
update: <K extends keyof AppConfig>(key: K, value: AppConfig[K]) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 用于显示 toast 消息的回调(如切换失败提示) */
|
||||||
|
export interface ToastProps {
|
||||||
|
setToast: (msg: string) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 通用「添加 XXX」虚线按钮,统一各 Tab 样式 */
|
||||||
|
export function AddButton({ label, onClick }: { label: string; onClick: () => void }) {
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
onClick={onClick}
|
||||||
|
className="flex items-center gap-2 px-4 py-2.5 rounded-xl border border-dashed border-[var(--border-color)] text-[var(--text-muted)] hover:text-[var(--accent-cyan)] hover:border-[var(--accent-cyan)]/30 transition-colors text-sm w-full justify-center"
|
||||||
|
>
|
||||||
|
<Plus className="h-4 w-4" /> {label}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 简单错误/提示气泡,统一红色错误样式 */
|
||||||
|
export function ErrorBanner({ children }: { children: ReactNode }) {
|
||||||
|
if (!children) return null;
|
||||||
|
return (
|
||||||
|
<div className="text-sm text-red-400 bg-red-500/10 border border-red-500/20 rounded-lg px-3 py-2">
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
97
web/src/components/Settings/tabs/AgentsTab.tsx
Normal file
97
web/src/components/Settings/tabs/AgentsTab.tsx
Normal file
@ -0,0 +1,97 @@
|
|||||||
|
// AgentsTab - 代理配置(Map 编辑器模式)
|
||||||
|
import { Field, MapEntryHeader } from '../ui';
|
||||||
|
import { inputCls, selectCls } from '../constants';
|
||||||
|
import { AddButton } from '../shared';
|
||||||
|
import { useMapEditor } from '../useMapEditor';
|
||||||
|
import type { TabProps } from '../shared';
|
||||||
|
import type { AgentConfig } from '../types';
|
||||||
|
|
||||||
|
export function AgentsTab({ config, update }: TabProps) {
|
||||||
|
const providerNames = Object.keys(config.providers);
|
||||||
|
const modelNames = Object.keys(config.models);
|
||||||
|
const editors = useMapEditor<AgentConfig>(config.agents, (v) => update('agents', v));
|
||||||
|
|
||||||
|
const addAgent = () => {
|
||||||
|
const name = prompt('Agent 名称:')?.trim();
|
||||||
|
if (name && !editors.has(name)) {
|
||||||
|
editors.add(name, {
|
||||||
|
provider: providerNames[0] || '',
|
||||||
|
model: modelNames[0] || '',
|
||||||
|
max_tool_iterations: 100,
|
||||||
|
tool_result_max_chars: 100000,
|
||||||
|
context_tool_result_trim_chars: 2000,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
const delAgent = (name: string) => {
|
||||||
|
if (confirm(`删除 Agent "${name}"?`)) editors.remove(name);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-4">
|
||||||
|
{editors.entries.map(([name, a]) => (
|
||||||
|
<div
|
||||||
|
key={name}
|
||||||
|
className="rounded-xl border border-[var(--border-color)] bg-[var(--bg-secondary)]/60 overflow-hidden"
|
||||||
|
>
|
||||||
|
<MapEntryHeader name={name} onDelete={() => delAgent(name)} />
|
||||||
|
<div className="p-4 space-y-3">
|
||||||
|
<Field label="Provider">
|
||||||
|
<select
|
||||||
|
value={a.provider}
|
||||||
|
onChange={(e) => editors.patch(name, { provider: e.target.value })}
|
||||||
|
className={selectCls}
|
||||||
|
>
|
||||||
|
{providerNames.map((p) => (
|
||||||
|
<option key={p} value={p}>
|
||||||
|
{p}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</Field>
|
||||||
|
<Field label="Model">
|
||||||
|
<select
|
||||||
|
value={a.model}
|
||||||
|
onChange={(e) => editors.patch(name, { model: e.target.value })}
|
||||||
|
className={selectCls}
|
||||||
|
>
|
||||||
|
{modelNames.map((m) => (
|
||||||
|
<option key={m} value={m}>
|
||||||
|
{m}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</Field>
|
||||||
|
<Field label="最大工具迭代次数">
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
value={a.max_tool_iterations}
|
||||||
|
onChange={(e) => editors.patch(name, { max_tool_iterations: +e.target.value })}
|
||||||
|
className={inputCls}
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
<Field label="工具结果最大字符数">
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
value={a.tool_result_max_chars}
|
||||||
|
onChange={(e) => editors.patch(name, { tool_result_max_chars: +e.target.value })}
|
||||||
|
className={inputCls}
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
<Field label="上下文工具结果裁剪字符数">
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
value={a.context_tool_result_trim_chars}
|
||||||
|
onChange={(e) =>
|
||||||
|
editors.patch(name, { context_tool_result_trim_chars: +e.target.value })
|
||||||
|
}
|
||||||
|
className={inputCls}
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
<AddButton label="添加代理" onClick={addAgent} />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
157
web/src/components/Settings/tabs/ChannelsTab.tsx
Normal file
157
web/src/components/Settings/tabs/ChannelsTab.tsx
Normal file
@ -0,0 +1,157 @@
|
|||||||
|
// ChannelsTab - 渠道配置(Map 编辑器模式)
|
||||||
|
import { Field, Toggle, MapEntryHeader } from '../ui';
|
||||||
|
import { inputCls, selectCls } from '../constants';
|
||||||
|
import { AddButton } from '../shared';
|
||||||
|
import { useMapEditor } from '../useMapEditor';
|
||||||
|
import type { TabProps } from '../shared';
|
||||||
|
import type { ChannelConfig } from '../types';
|
||||||
|
|
||||||
|
function getChannelType(ch: ChannelConfig): string {
|
||||||
|
if (ch.type) return ch.type;
|
||||||
|
if ('app_id' in ch || 'app_secret' in ch) return 'feishu';
|
||||||
|
if ('cred_path' in ch) return 'wechat';
|
||||||
|
return 'feishu';
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ChannelsTab({ config, update }: TabProps) {
|
||||||
|
const editors = useMapEditor<ChannelConfig>(config.channels, (v) => update('channels', v));
|
||||||
|
|
||||||
|
const addChannel = () => {
|
||||||
|
const name = prompt('渠道名称:')?.trim();
|
||||||
|
if (name && !editors.has(name)) {
|
||||||
|
editors.add(name, { type: 'feishu', enabled: false, app_id: '', app_secret: '' });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
const delChannel = (name: string) => {
|
||||||
|
if (confirm(`删除渠道 "${name}"?`)) editors.remove(name);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-4">
|
||||||
|
{editors.entries.map(([name, ch]) => {
|
||||||
|
const chType = getChannelType(ch);
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={name}
|
||||||
|
className="rounded-xl border border-[var(--border-color)] bg-[var(--bg-secondary)]/60 overflow-hidden"
|
||||||
|
>
|
||||||
|
<MapEntryHeader name={name} onDelete={() => delChannel(name)} />
|
||||||
|
<div className="p-4 space-y-3">
|
||||||
|
<Field label="渠道类型">
|
||||||
|
<select
|
||||||
|
value={chType}
|
||||||
|
onChange={(e) => editors.patch(name, { type: e.target.value })}
|
||||||
|
className={selectCls}
|
||||||
|
>
|
||||||
|
<option value="feishu">飞书 (Feishu)</option>
|
||||||
|
<option value="wechat">微信 (WeChat)</option>
|
||||||
|
</select>
|
||||||
|
</Field>
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<span className="text-sm text-[var(--text-secondary)]">启用</span>
|
||||||
|
<Toggle
|
||||||
|
checked={!!ch.enabled}
|
||||||
|
onChange={(v) => editors.patch(name, { enabled: v })}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
{chType === 'feishu' && (
|
||||||
|
<>
|
||||||
|
<Field label="App ID">
|
||||||
|
<input
|
||||||
|
value={ch.app_id ?? ''}
|
||||||
|
onChange={(e) => editors.patch(name, { app_id: e.target.value })}
|
||||||
|
className={inputCls}
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
<Field label="App Secret">
|
||||||
|
<input
|
||||||
|
type="password"
|
||||||
|
value={ch.app_secret ?? ''}
|
||||||
|
onChange={(e) => editors.patch(name, { app_secret: e.target.value })}
|
||||||
|
className={inputCls}
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
<Field label="绑定 Agent" hint="留空使用 default">
|
||||||
|
<select
|
||||||
|
value={ch.agent ?? ''}
|
||||||
|
onChange={(e) => editors.patch(name, { agent: e.target.value })}
|
||||||
|
className={selectCls}
|
||||||
|
>
|
||||||
|
<option value="">default</option>
|
||||||
|
{Object.keys(config.agents).map((a) => (
|
||||||
|
<option key={a} value={a}>
|
||||||
|
{a}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</Field>
|
||||||
|
<Field label="最大消息字符数">
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
value={ch.max_message_chars ?? 20000}
|
||||||
|
onChange={(e) =>
|
||||||
|
editors.patch(name, { max_message_chars: +e.target.value })
|
||||||
|
}
|
||||||
|
className={inputCls}
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
<Field label="回复上下文最大字符数">
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
value={ch.reply_context_max_chars ?? 20000}
|
||||||
|
onChange={(e) =>
|
||||||
|
editors.patch(name, { reply_context_max_chars: +e.target.value })
|
||||||
|
}
|
||||||
|
className={inputCls}
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
{chType === 'wechat' && (
|
||||||
|
<>
|
||||||
|
<Field label="凭证文件路径">
|
||||||
|
<input
|
||||||
|
value={ch.cred_path ?? ''}
|
||||||
|
onChange={(e) => editors.patch(name, { cred_path: e.target.value })}
|
||||||
|
className={inputCls}
|
||||||
|
placeholder="~/.picobot/wechat/credentials.json"
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
<Field label="Base URL">
|
||||||
|
<input
|
||||||
|
value={ch.base_url ?? 'https://ilinkai.weixin.qq.com'}
|
||||||
|
onChange={(e) => editors.patch(name, { base_url: e.target.value })}
|
||||||
|
className={inputCls}
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
<Field label="绑定 Agent" hint="留空使用 default">
|
||||||
|
<select
|
||||||
|
value={ch.agent ?? ''}
|
||||||
|
onChange={(e) => editors.patch(name, { agent: e.target.value })}
|
||||||
|
className={selectCls}
|
||||||
|
>
|
||||||
|
<option value="">default</option>
|
||||||
|
{Object.keys(config.agents).map((a) => (
|
||||||
|
<option key={a} value={a}>
|
||||||
|
{a}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</Field>
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<span className="text-sm text-[var(--text-secondary)]">强制重新登录</span>
|
||||||
|
<Toggle
|
||||||
|
checked={!!ch.force_login}
|
||||||
|
onChange={(v) => editors.patch(name, { force_login: v })}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
<AddButton label="添加渠道" onClick={addChannel} />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
89
web/src/components/Settings/tabs/ConnectionTab.tsx
Normal file
89
web/src/components/Settings/tabs/ConnectionTab.tsx
Normal file
@ -0,0 +1,89 @@
|
|||||||
|
// ConnectionTab - WebSocket 连接设置(localStorage 持久化,不走后端 config)
|
||||||
|
import { useState } from 'react';
|
||||||
|
import { Wifi } from 'lucide-react';
|
||||||
|
import { Field, SectionCard } from '../ui';
|
||||||
|
import { inputCls } from '../constants';
|
||||||
|
import { ErrorBanner } from '../shared';
|
||||||
|
|
||||||
|
interface ConnectionTabProps {
|
||||||
|
onSaveConnection?: (host: string, port: number) => void;
|
||||||
|
setToast: (msg: string) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ConnectionTab({ onSaveConnection, setToast }: ConnectionTabProps) {
|
||||||
|
const [connHost, setConnHost] = useState(() => {
|
||||||
|
try {
|
||||||
|
return localStorage.getItem('picobot-gateway-host') || '127.0.0.1';
|
||||||
|
} catch {
|
||||||
|
return '127.0.0.1';
|
||||||
|
}
|
||||||
|
});
|
||||||
|
const [connPort, setConnPort] = useState(() => {
|
||||||
|
try {
|
||||||
|
const p = parseInt(localStorage.getItem('picobot-gateway-port') || '19876', 10);
|
||||||
|
return isNaN(p) ? 19876 : p;
|
||||||
|
} catch {
|
||||||
|
return 19876;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
const [connError, setConnError] = useState('');
|
||||||
|
|
||||||
|
const handleSaveConnection = () => {
|
||||||
|
const host = connHost.trim();
|
||||||
|
if (!host) {
|
||||||
|
setConnError('主机地址不能为空');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (connPort < 1 || connPort > 65535) {
|
||||||
|
setConnError('端口号必须在 1-65535 之间');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setConnError('');
|
||||||
|
localStorage.setItem('picobot-gateway-host', host);
|
||||||
|
localStorage.setItem('picobot-gateway-port', String(connPort));
|
||||||
|
onSaveConnection?.(host, connPort);
|
||||||
|
setToast('连接设置已保存,正在重连...');
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-5">
|
||||||
|
<SectionCard title="WebSocket 连接">
|
||||||
|
<Field label="主机地址">
|
||||||
|
<input
|
||||||
|
value={connHost}
|
||||||
|
onChange={(e) => {
|
||||||
|
setConnHost(e.target.value);
|
||||||
|
setConnError('');
|
||||||
|
}}
|
||||||
|
className={inputCls}
|
||||||
|
placeholder="127.0.0.1"
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
<Field label="端口号">
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
value={connPort}
|
||||||
|
onChange={(e) => {
|
||||||
|
setConnPort(+e.target.value);
|
||||||
|
setConnError('');
|
||||||
|
}}
|
||||||
|
min={1}
|
||||||
|
max={65535}
|
||||||
|
className={inputCls}
|
||||||
|
placeholder="19876"
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
<ErrorBanner>{connError}</ErrorBanner>
|
||||||
|
<div className="text-xs text-[var(--text-muted)] bg-[var(--overlay-dim)] rounded-lg px-3 py-2 font-mono">
|
||||||
|
ws://{connHost.trim() || '...'}:{connPort || '...'}/ws
|
||||||
|
</div>
|
||||||
|
</SectionCard>
|
||||||
|
<button
|
||||||
|
onClick={handleSaveConnection}
|
||||||
|
className="flex items-center gap-2 px-5 py-2.5 rounded-xl text-sm font-medium text-white bg-[var(--accent-cyan)]/20 border border-[var(--accent-cyan)]/30 hover:bg-[var(--accent-cyan)]/30 transition-all"
|
||||||
|
>
|
||||||
|
<Wifi className="h-4 w-4" /> 保存并重连
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
171
web/src/components/Settings/tabs/ExpertsTab.tsx
Normal file
171
web/src/components/Settings/tabs/ExpertsTab.tsx
Normal file
@ -0,0 +1,171 @@
|
|||||||
|
// ExpertsTab - 专家系统配置(来源目录 + 已发现专家 + 编辑/创建模态框)
|
||||||
|
import { useState, useEffect, useCallback } from 'react';
|
||||||
|
import { Loader2, Pencil, Trash2 } from 'lucide-react';
|
||||||
|
import { Toggle, SectionCard, SourceEditor } from '../ui';
|
||||||
|
import { AddButton } from '../shared';
|
||||||
|
import { ExpertModal, expertToDraft, emptyExpertDraft, type ExpertDraft } from '../modals/ExpertModal';
|
||||||
|
import {
|
||||||
|
listExperts,
|
||||||
|
toggleExpert,
|
||||||
|
deleteExpert,
|
||||||
|
} from '../../../api/experts';
|
||||||
|
import type { TabProps, ToastProps } from '../shared';
|
||||||
|
import type { KnownSource, ExpertListResponse } from '../types';
|
||||||
|
|
||||||
|
type Props = TabProps & ToastProps;
|
||||||
|
|
||||||
|
const EXPERT_KNOWN_SOURCES: KnownSource[] = [
|
||||||
|
{ key: 'user', label: '用户专家', description: '~/.picobot/experts' },
|
||||||
|
{ key: 'project', label: '项目专家', description: '.picobot/experts' },
|
||||||
|
];
|
||||||
|
|
||||||
|
export function ExpertsTab({ config, update, setToast }: Props) {
|
||||||
|
const [expertList, setExpertList] = useState<ExpertListResponse | null>(null);
|
||||||
|
const [expertListLoading, setExpertListLoading] = useState(false);
|
||||||
|
const [editingExpert, setEditingExpert] = useState<ExpertDraft | null>(null);
|
||||||
|
|
||||||
|
const fetchExpertList = useCallback(async () => {
|
||||||
|
setExpertListLoading(true);
|
||||||
|
const data = await listExperts();
|
||||||
|
if (data) setExpertList(data);
|
||||||
|
setExpertListLoading(false);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
fetchExpertList();
|
||||||
|
}, [fetchExpertList]);
|
||||||
|
|
||||||
|
const handleToggle = async (name: string, currentlyEnabled: boolean) => {
|
||||||
|
if (!expertList) return;
|
||||||
|
const prevList = expertList;
|
||||||
|
setExpertList({
|
||||||
|
...expertList,
|
||||||
|
experts: expertList.experts.map((e) =>
|
||||||
|
e.name === name ? { ...e, disabled_in_scopes: currentlyEnabled ? ['project'] : [] } : e,
|
||||||
|
),
|
||||||
|
});
|
||||||
|
try {
|
||||||
|
const resp = await toggleExpert(name, 'project', !currentlyEnabled);
|
||||||
|
const data = await resp.json();
|
||||||
|
if (!resp.ok || !data.success) {
|
||||||
|
setExpertList(prevList);
|
||||||
|
setToast(data.error || '切换专家状态失败');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setExpertList({
|
||||||
|
...prevList,
|
||||||
|
experts: prevList.experts.map((e) =>
|
||||||
|
e.name === name ? { ...e, disabled_in_scopes: data.disabled_in_scopes || [] } : e,
|
||||||
|
),
|
||||||
|
});
|
||||||
|
} catch {
|
||||||
|
setExpertList(prevList);
|
||||||
|
setToast('网络错误,切换专家状态失败');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDelete = async (name: string) => {
|
||||||
|
if (!confirm(`确定删除专家 "${name}" 吗?此操作将删除对应文件。`)) return;
|
||||||
|
try {
|
||||||
|
const resp = await deleteExpert(name, 'project');
|
||||||
|
const data = await resp.json();
|
||||||
|
if (!resp.ok || !data.success) {
|
||||||
|
setToast(data.error || '删除专家失败');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setToast('专家已删除');
|
||||||
|
fetchExpertList();
|
||||||
|
} catch {
|
||||||
|
setToast('网络错误,删除专家失败');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-5">
|
||||||
|
<SectionCard title="专家系统">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<span className="text-sm text-[var(--text-secondary)]">启用专家系统</span>
|
||||||
|
<Toggle
|
||||||
|
checked={config.experts.enabled}
|
||||||
|
onChange={(v) => update('experts', { ...config.experts, enabled: v })}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</SectionCard>
|
||||||
|
<SectionCard title="来源目录">
|
||||||
|
<SourceEditor
|
||||||
|
sources={config.experts.sources}
|
||||||
|
onChange={(v) => update('experts', { ...config.experts, sources: v })}
|
||||||
|
knownSources={EXPERT_KNOWN_SOURCES}
|
||||||
|
examplePaths={['D:\\my-experts', '/home/user/shared-experts']}
|
||||||
|
/>
|
||||||
|
</SectionCard>
|
||||||
|
{expertList && expertList.experts_system_enabled && (
|
||||||
|
<SectionCard title="已发现专家" subtitle="即时生效">
|
||||||
|
{expertListLoading && expertList.experts.length === 0 ? (
|
||||||
|
<div className="flex items-center gap-2 text-sm text-[var(--text-muted)]">
|
||||||
|
<Loader2 className="h-4 w-4 animate-spin" /> 加载中...
|
||||||
|
</div>
|
||||||
|
) : expertList.experts.length === 0 ? (
|
||||||
|
<p className="text-sm text-[var(--text-muted)]">未发现任何专家</p>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-1">
|
||||||
|
{expertList.experts.map((expert) => {
|
||||||
|
const isEnabled = expert.disabled_in_scopes.length === 0;
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={expert.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)]">
|
||||||
|
{expert.name}
|
||||||
|
</span>
|
||||||
|
<span className="text-[10px] px-1.5 py-0.5 rounded bg-[var(--bg-tertiary)] text-[var(--text-muted)] uppercase tracking-wider">
|
||||||
|
{expert.source}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<p className="text-xs text-[var(--text-muted)] truncate mt-0.5">
|
||||||
|
{expert.description}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={() => setEditingExpert(expertToDraft(expert))}
|
||||||
|
className="p-1 rounded text-[var(--text-muted)] hover:text-[var(--accent-cyan)] hover:bg-[var(--overlay-hover)] transition-colors"
|
||||||
|
title="编辑"
|
||||||
|
>
|
||||||
|
<Pencil className="h-3.5 w-3.5" />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => handleDelete(expert.name)}
|
||||||
|
className="p-1 rounded text-[var(--text-muted)] hover:text-red-400 hover:bg-red-500/10 transition-colors"
|
||||||
|
title="删除"
|
||||||
|
>
|
||||||
|
<Trash2 className="h-3.5 w-3.5" />
|
||||||
|
</button>
|
||||||
|
<Toggle
|
||||||
|
checked={isEnabled}
|
||||||
|
onChange={() => handleToggle(expert.name, isEnabled)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</SectionCard>
|
||||||
|
)}
|
||||||
|
<AddButton label="添加专家" onClick={() => setEditingExpert(emptyExpertDraft())} />
|
||||||
|
{editingExpert && (
|
||||||
|
<ExpertModal
|
||||||
|
draft={editingExpert}
|
||||||
|
onClose={() => setEditingExpert(null)}
|
||||||
|
onSaved={() => {
|
||||||
|
setEditingExpert(null);
|
||||||
|
fetchExpertList();
|
||||||
|
}}
|
||||||
|
setToast={setToast}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
69
web/src/components/Settings/tabs/GatewayTab.tsx
Normal file
69
web/src/components/Settings/tabs/GatewayTab.tsx
Normal file
@ -0,0 +1,69 @@
|
|||||||
|
// GatewayTab - 网关配置
|
||||||
|
import { Field, Toggle, SectionCard } from '../ui';
|
||||||
|
import { inputCls } from '../constants';
|
||||||
|
import type { TabProps } from '../shared';
|
||||||
|
|
||||||
|
export function GatewayTab({ config, update }: TabProps) {
|
||||||
|
return (
|
||||||
|
<div className="space-y-5">
|
||||||
|
<SectionCard title="连接">
|
||||||
|
<Field label="主机地址">
|
||||||
|
<input
|
||||||
|
value={config.gateway.host}
|
||||||
|
onChange={(e) => update('gateway', { ...config.gateway, host: e.target.value })}
|
||||||
|
className={inputCls}
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
<Field label="端口">
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
value={config.gateway.port}
|
||||||
|
onChange={(e) => update('gateway', { ...config.gateway, port: +e.target.value })}
|
||||||
|
className={inputCls}
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
</SectionCard>
|
||||||
|
<SectionCard title="行为">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<span className="text-sm text-[var(--text-secondary)]">显示工具结果</span>
|
||||||
|
<Toggle
|
||||||
|
checked={config.gateway.show_tool_results}
|
||||||
|
onChange={(v) => update('gateway', { ...config.gateway, show_tool_results: v })}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<Field label="Agent Prompt 重新注入间隔" hint="每多少轮对话重新注入系统提示">
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
value={config.gateway.agent_prompt_reinject_every}
|
||||||
|
onChange={(e) =>
|
||||||
|
update('gateway', { ...config.gateway, agent_prompt_reinject_every: +e.target.value })
|
||||||
|
}
|
||||||
|
className={inputCls}
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
<Field label="最大并发请求数">
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
value={config.gateway.max_concurrent_requests}
|
||||||
|
onChange={(e) =>
|
||||||
|
update('gateway', { ...config.gateway, max_concurrent_requests: +e.target.value })
|
||||||
|
}
|
||||||
|
className={inputCls}
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
<Field label="Session TTL (小时)" hint="留空表示不过期">
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
value={config.gateway.session_ttl_hours ?? ''}
|
||||||
|
onChange={(e) => {
|
||||||
|
const v = e.target.value;
|
||||||
|
update('gateway', { ...config.gateway, session_ttl_hours: v ? +v : undefined });
|
||||||
|
}}
|
||||||
|
className={inputCls}
|
||||||
|
placeholder="24"
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
</SectionCard>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
37
web/src/components/Settings/tabs/ImageTab.tsx
Normal file
37
web/src/components/Settings/tabs/ImageTab.tsx
Normal file
@ -0,0 +1,37 @@
|
|||||||
|
// ImageTab - 图片上下文配置
|
||||||
|
import { Field, SectionCard } from '../ui';
|
||||||
|
import { inputCls } from '../constants';
|
||||||
|
import type { TabProps } from '../shared';
|
||||||
|
|
||||||
|
export function ImageTab({ config, update }: TabProps) {
|
||||||
|
return (
|
||||||
|
<SectionCard title="图片上下文">
|
||||||
|
<Field label="上下文中最大图片数" hint="发送给模型的图片数量上限">
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
value={config.image_context.max_images_in_context}
|
||||||
|
onChange={(e) =>
|
||||||
|
update('image_context', {
|
||||||
|
...config.image_context,
|
||||||
|
max_images_in_context: +e.target.value,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
className={inputCls}
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
<Field label="图片最大存活轮次" hint="超过此轮次后不再提交给模型">
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
value={config.image_context.max_image_age_rounds}
|
||||||
|
onChange={(e) =>
|
||||||
|
update('image_context', {
|
||||||
|
...config.image_context,
|
||||||
|
max_image_age_rounds: +e.target.value,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
className={inputCls}
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
</SectionCard>
|
||||||
|
);
|
||||||
|
}
|
||||||
240
web/src/components/Settings/tabs/McpTab.tsx
Normal file
240
web/src/components/Settings/tabs/McpTab.tsx
Normal file
@ -0,0 +1,240 @@
|
|||||||
|
// McpTab - MCP 服务器配置(Map 编辑器 + 状态刷新)
|
||||||
|
import { useState, useEffect, useCallback } from 'react';
|
||||||
|
import { RefreshCw } from 'lucide-react';
|
||||||
|
import { Field, Toggle, MapEntryHeader } from '../ui';
|
||||||
|
import { inputCls, selectCls } from '../constants';
|
||||||
|
import { AddButton } from '../shared';
|
||||||
|
import { useMapEditor } from '../useMapEditor';
|
||||||
|
import { getMcpStatus } from '../../../api/mcp';
|
||||||
|
import type { TabProps, ToastProps } from '../shared';
|
||||||
|
import type { McpServerConfig, McpStatusResponse } from '../types';
|
||||||
|
|
||||||
|
type Props = TabProps & ToastProps;
|
||||||
|
|
||||||
|
export function McpTab({ config, update, setToast }: Props) {
|
||||||
|
const [mcpStatus, setMcpStatus] = useState<McpStatusResponse | null>(null);
|
||||||
|
const editors = useMapEditor<McpServerConfig>(config.mcpServers, (v) => update('mcpServers', v));
|
||||||
|
|
||||||
|
const fetchMcpStatus = useCallback(async () => {
|
||||||
|
const data = await getMcpStatus();
|
||||||
|
if (data) setMcpStatus(data);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
fetchMcpStatus();
|
||||||
|
}, [fetchMcpStatus]);
|
||||||
|
|
||||||
|
const addMcp = () => {
|
||||||
|
const name = prompt('MCP 服务器名称(仅字母、数字、下划线、连字符):')?.trim();
|
||||||
|
if (!name) return;
|
||||||
|
if (!/^[a-zA-Z0-9_-]+$/.test(name)) {
|
||||||
|
setToast('名称只能包含字母、数字、下划线和连字符');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (editors.has(name)) {
|
||||||
|
setToast('该名称已存在');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
editors.add(name, { type: 'stdio', is_active: true, command: '', args: [] });
|
||||||
|
};
|
||||||
|
const delMcp = (name: string) => {
|
||||||
|
if (confirm(`删除 MCP 服务器 "${name}"?`)) editors.remove(name);
|
||||||
|
};
|
||||||
|
const renameMcp = (oldName: string, newName: string) => {
|
||||||
|
const trimmed = newName.trim();
|
||||||
|
if (trimmed === oldName || !trimmed) return;
|
||||||
|
if (!/^[a-zA-Z0-9_-]+$/.test(trimmed)) {
|
||||||
|
setToast('名称只能包含字母、数字、下划线和连字符');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (editors.has(trimmed)) {
|
||||||
|
setToast('该名称已存在');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
editors.rename(oldName, trimmed);
|
||||||
|
};
|
||||||
|
|
||||||
|
const statusFor = (key: string) => mcpStatus?.servers?.find((s) => s.key === key);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-4">
|
||||||
|
{mcpStatus && mcpStatus.enabled && (
|
||||||
|
<div className="flex items-center gap-3 p-3 rounded-lg bg-[var(--bg-tertiary)] text-xs">
|
||||||
|
<div className="flex items-center gap-1.5">
|
||||||
|
<span
|
||||||
|
className={`inline-block w-2 h-2 rounded-full ${mcpStatus.connected_servers > 0 ? 'bg-green-400' : 'bg-gray-400'}`}
|
||||||
|
/>
|
||||||
|
<span className="text-[var(--text-secondary)]">
|
||||||
|
{mcpStatus.connected_servers}/{mcpStatus.total_servers} 已连接
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
{mcpStatus.failed_servers > 0 && (
|
||||||
|
<span className="text-red-400">{mcpStatus.failed_servers} 失败</span>
|
||||||
|
)}
|
||||||
|
<span className="text-[var(--text-muted)]">{mcpStatus.total_tools} 个工具</span>
|
||||||
|
<button
|
||||||
|
onClick={fetchMcpStatus}
|
||||||
|
className="ml-auto px-2 py-1 rounded text-[var(--text-muted)] hover:text-[var(--accent-cyan)] transition-colors"
|
||||||
|
title="刷新状态"
|
||||||
|
>
|
||||||
|
<RefreshCw className="h-3 w-3" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{editors.entries.map(([name, s]) => {
|
||||||
|
const st = statusFor(name);
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={name}
|
||||||
|
className="rounded-xl border border-[var(--border-color)] bg-[var(--bg-secondary)]/60 overflow-hidden"
|
||||||
|
>
|
||||||
|
{st && (
|
||||||
|
<div className="flex items-center gap-2 px-4 py-2">
|
||||||
|
{st.connected ? (
|
||||||
|
<span className="inline-flex items-center gap-1 text-xs text-green-400">
|
||||||
|
<span className="w-2 h-2 rounded-full bg-green-400" /> {st.tool_count} 工具
|
||||||
|
</span>
|
||||||
|
) : st.error ? (
|
||||||
|
<span
|
||||||
|
className="inline-flex items-center gap-1 text-xs text-red-400"
|
||||||
|
title={st.error}
|
||||||
|
>
|
||||||
|
<span className="w-2 h-2 rounded-full bg-red-400" /> 错误
|
||||||
|
</span>
|
||||||
|
) : (
|
||||||
|
<span className="inline-flex items-center gap-1 text-xs text-gray-400">
|
||||||
|
<span className="w-2 h-2 rounded-full bg-gray-400" /> 未连接
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<MapEntryHeader
|
||||||
|
name={name}
|
||||||
|
onDelete={() => delMcp(name)}
|
||||||
|
onRename={(n) => renameMcp(name, n)}
|
||||||
|
/>
|
||||||
|
<div className="p-4 space-y-3">
|
||||||
|
<Field label="传输类型">
|
||||||
|
<select
|
||||||
|
value={s.type}
|
||||||
|
onChange={(e) =>
|
||||||
|
editors.patch(name, { type: e.target.value as McpServerConfig['type'] })
|
||||||
|
}
|
||||||
|
className={selectCls}
|
||||||
|
>
|
||||||
|
<option value="stdio">stdio (本地命令)</option>
|
||||||
|
<option value="streamableHttp">streamableHttp (HTTP)</option>
|
||||||
|
</select>
|
||||||
|
</Field>
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<span className="text-sm text-[var(--text-secondary)]">启用</span>
|
||||||
|
<Toggle
|
||||||
|
checked={s.is_active}
|
||||||
|
onChange={(v) => editors.patch(name, { is_active: v })}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<Field label="描述">
|
||||||
|
<input
|
||||||
|
value={s.description ?? ''}
|
||||||
|
onChange={(e) =>
|
||||||
|
editors.patch(name, { description: e.target.value || undefined })
|
||||||
|
}
|
||||||
|
className={inputCls}
|
||||||
|
placeholder="可选描述"
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
{s.type === 'stdio' && (
|
||||||
|
<>
|
||||||
|
<Field label="命令" hint="如 npx, node, cargo, uv">
|
||||||
|
<input
|
||||||
|
value={s.command ?? ''}
|
||||||
|
onChange={(e) => editors.patch(name, { command: e.target.value })}
|
||||||
|
className={inputCls}
|
||||||
|
placeholder="npx"
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
<Field label="参数" hint="空格分隔">
|
||||||
|
<input
|
||||||
|
value={(s.args ?? []).join(' ')}
|
||||||
|
onChange={(e) =>
|
||||||
|
editors.patch(name, {
|
||||||
|
args: e.target.value ? e.target.value.split(/\s+/) : [],
|
||||||
|
})
|
||||||
|
}
|
||||||
|
className={inputCls}
|
||||||
|
placeholder="-y @modelcontextprotocol/server-filesystem /tmp"
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
<Field
|
||||||
|
label="工作目录 (cwd)"
|
||||||
|
hint="可选。子进程运行目录,常用于 uv/python 项目解析 pyproject.toml 或 venv"
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
value={s.cwd ?? ''}
|
||||||
|
onChange={(e) => editors.patch(name, { cwd: e.target.value || undefined })}
|
||||||
|
className={inputCls}
|
||||||
|
placeholder="E:\code_project\my-mcp-server"
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
<Field label="环境变量" hint="KEY=VALUE,每行一个">
|
||||||
|
<textarea
|
||||||
|
value={Object.entries(s.env ?? {})
|
||||||
|
.map(([k, v]) => `${k}=${v}`)
|
||||||
|
.join('\n')}
|
||||||
|
onChange={(e) => {
|
||||||
|
const lines = e.target.value.split('\n').filter((l) => l.includes('='));
|
||||||
|
const env: Record<string, string> = {};
|
||||||
|
lines.forEach((l) => {
|
||||||
|
const [k, ...rest] = l.split('=');
|
||||||
|
if (k) env[k.trim()] = rest.join('=').trim();
|
||||||
|
});
|
||||||
|
editors.patch(name, {
|
||||||
|
env: Object.keys(env).length > 0 ? env : undefined,
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
className={inputCls + ' min-h-[60px] resize-y font-mono text-xs'}
|
||||||
|
placeholder="API_KEY=xxx"
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
{(s.type === 'streamableHttp' || s.type === 'http') && (
|
||||||
|
<>
|
||||||
|
<Field label="Base URL">
|
||||||
|
<input
|
||||||
|
value={s.base_url ?? ''}
|
||||||
|
onChange={(e) => editors.patch(name, { base_url: e.target.value })}
|
||||||
|
className={inputCls}
|
||||||
|
placeholder="http://localhost:3000/mcp"
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
<Field label="请求头" hint="KEY=VALUE,每行一个,支持 ${ENV_VAR}">
|
||||||
|
<textarea
|
||||||
|
value={Object.entries(s.headers ?? {})
|
||||||
|
.map(([k, v]) => `${k}=${v}`)
|
||||||
|
.join('\n')}
|
||||||
|
onChange={(e) => {
|
||||||
|
const lines = e.target.value.split('\n').filter((l) => l.includes('='));
|
||||||
|
const headers: Record<string, string> = {};
|
||||||
|
lines.forEach((l) => {
|
||||||
|
const [k, ...rest] = l.split('=');
|
||||||
|
if (k) headers[k.trim()] = rest.join('=').trim();
|
||||||
|
});
|
||||||
|
editors.patch(name, {
|
||||||
|
headers: Object.keys(headers).length > 0 ? headers : undefined,
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
className={inputCls + ' min-h-[60px] resize-y font-mono text-xs'}
|
||||||
|
placeholder="Authorization=Bearer ${TOKEN}"
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
<AddButton label="添加 MCP 服务器" onClick={addMcp} />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
53
web/src/components/Settings/tabs/MemoryTab.tsx
Normal file
53
web/src/components/Settings/tabs/MemoryTab.tsx
Normal file
@ -0,0 +1,53 @@
|
|||||||
|
// MemoryTab - 记忆维护配置
|
||||||
|
import { Field, SectionCard } from '../ui';
|
||||||
|
import { inputCls } from '../constants';
|
||||||
|
import type { TabProps } from '../shared';
|
||||||
|
|
||||||
|
export function MemoryTab({ config, update }: TabProps) {
|
||||||
|
return (
|
||||||
|
<SectionCard title="记忆维护">
|
||||||
|
<Field label="最大合并比例" hint="0.0 - 1.0,单次最多合并/删除的记忆比例">
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
step="0.05"
|
||||||
|
min="0"
|
||||||
|
max="1"
|
||||||
|
value={config.memory_maintenance.max_merge_ratio}
|
||||||
|
onChange={(e) =>
|
||||||
|
update('memory_maintenance', {
|
||||||
|
...config.memory_maintenance,
|
||||||
|
max_merge_ratio: +e.target.value,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
className={inputCls}
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
<Field label="最小保留记忆数">
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
value={config.memory_maintenance.min_memories_to_keep}
|
||||||
|
onChange={(e) =>
|
||||||
|
update('memory_maintenance', {
|
||||||
|
...config.memory_maintenance,
|
||||||
|
min_memories_to_keep: +e.target.value,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
className={inputCls}
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
<Field label="单组最大合并数">
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
value={config.memory_maintenance.max_merge_per_group}
|
||||||
|
onChange={(e) =>
|
||||||
|
update('memory_maintenance', {
|
||||||
|
...config.memory_maintenance,
|
||||||
|
max_merge_per_group: +e.target.value,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
className={inputCls}
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
</SectionCard>
|
||||||
|
);
|
||||||
|
}
|
||||||
89
web/src/components/Settings/tabs/ModelsTab.tsx
Normal file
89
web/src/components/Settings/tabs/ModelsTab.tsx
Normal file
@ -0,0 +1,89 @@
|
|||||||
|
// ModelsTab - 模型配置(Map 编辑器模式)
|
||||||
|
import { Field, MapEntryHeader } from '../ui';
|
||||||
|
import { inputCls } from '../constants';
|
||||||
|
import { AddButton } from '../shared';
|
||||||
|
import { useMapEditor } from '../useMapEditor';
|
||||||
|
import type { TabProps } from '../shared';
|
||||||
|
import type { ModelConfig } from '../types';
|
||||||
|
|
||||||
|
export function ModelsTab({ config, update }: TabProps) {
|
||||||
|
const editors = useMapEditor<ModelConfig>(config.models, (v) => update('models', v));
|
||||||
|
|
||||||
|
const addModel = () => {
|
||||||
|
const name = prompt('Model 名称:')?.trim();
|
||||||
|
if (name && !editors.has(name)) {
|
||||||
|
editors.add(name, { model_id: name });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
const delModel = (name: string) => {
|
||||||
|
if (confirm(`删除 Model "${name}"?`)) editors.remove(name);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-4">
|
||||||
|
{editors.entries.map(([name, m]) => (
|
||||||
|
<div
|
||||||
|
key={name}
|
||||||
|
className="rounded-xl border border-[var(--border-color)] bg-[var(--bg-secondary)]/60 overflow-hidden"
|
||||||
|
>
|
||||||
|
<MapEntryHeader name={name} onDelete={() => delModel(name)} />
|
||||||
|
<div className="p-4 space-y-3">
|
||||||
|
<Field label="Model ID">
|
||||||
|
<input
|
||||||
|
value={m.model_id}
|
||||||
|
onChange={(e) => editors.patch(name, { model_id: e.target.value })}
|
||||||
|
className={inputCls}
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
<Field
|
||||||
|
label="Temperature"
|
||||||
|
hint="控制回复随机性,0 表示确定性输出,值越大越随机。留空使用模型默认值"
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
step="0.1"
|
||||||
|
value={m.temperature ?? ''}
|
||||||
|
onChange={(e) =>
|
||||||
|
editors.patch(name, { temperature: e.target.value ? +e.target.value : undefined })
|
||||||
|
}
|
||||||
|
className={inputCls}
|
||||||
|
placeholder="0.7"
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
<Field
|
||||||
|
label="Max Tokens"
|
||||||
|
hint="模型单次回复最大生成 token 数,超出会被截断。留空使用模型默认值(如 4096/8192)"
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
value={m.max_tokens ?? ''}
|
||||||
|
onChange={(e) =>
|
||||||
|
editors.patch(name, { max_tokens: e.target.value ? +e.target.value : undefined })
|
||||||
|
}
|
||||||
|
className={inputCls}
|
||||||
|
placeholder="4096"
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
<Field
|
||||||
|
label="Context Window Tokens"
|
||||||
|
hint="模型上下文窗口大小,用于内部历史消息压缩/裁剪计算。留空默认 128000"
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
value={m.context_window_tokens ?? ''}
|
||||||
|
onChange={(e) =>
|
||||||
|
editors.patch(name, {
|
||||||
|
context_window_tokens: e.target.value ? +e.target.value : undefined,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
className={inputCls}
|
||||||
|
placeholder="128000"
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
<AddButton label="添加模型" onClick={addModel} />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
97
web/src/components/Settings/tabs/ProvidersTab.tsx
Normal file
97
web/src/components/Settings/tabs/ProvidersTab.tsx
Normal file
@ -0,0 +1,97 @@
|
|||||||
|
// ProvidersTab - 服务商配置(Map 编辑器模式)
|
||||||
|
import { Field, MapEntryHeader } from '../ui';
|
||||||
|
import { inputCls, selectCls } from '../constants';
|
||||||
|
import { AddButton } from '../shared';
|
||||||
|
import { useMapEditor } from '../useMapEditor';
|
||||||
|
import type { TabProps } from '../shared';
|
||||||
|
import type { ProviderConfig } from '../types';
|
||||||
|
|
||||||
|
export function ProvidersTab({ config, update }: TabProps) {
|
||||||
|
const editors = useMapEditor<ProviderConfig>(config.providers, (v) => update('providers', v));
|
||||||
|
|
||||||
|
const addProvider = () => {
|
||||||
|
const name = prompt('Provider 名称:')?.trim();
|
||||||
|
if (name && !editors.has(name)) {
|
||||||
|
editors.add(name, {
|
||||||
|
type: 'openai',
|
||||||
|
base_url: '',
|
||||||
|
api_key: '',
|
||||||
|
extra_headers: {},
|
||||||
|
llm_timeout_secs: 120,
|
||||||
|
memory_maintenance_timeout_secs: 600,
|
||||||
|
max_retries: 3,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
const delProvider = (name: string) => {
|
||||||
|
if (confirm(`删除 Provider "${name}"?`)) editors.remove(name);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-4">
|
||||||
|
{editors.entries.map(([name, p]) => (
|
||||||
|
<div
|
||||||
|
key={name}
|
||||||
|
className="rounded-xl border border-[var(--border-color)] bg-[var(--bg-secondary)]/60 overflow-hidden"
|
||||||
|
>
|
||||||
|
<MapEntryHeader name={name} onDelete={() => delProvider(name)} />
|
||||||
|
<div className="p-4 space-y-3">
|
||||||
|
<Field label="类型">
|
||||||
|
<select
|
||||||
|
value={p.type}
|
||||||
|
onChange={(e) => editors.patch(name, { type: e.target.value })}
|
||||||
|
className={selectCls}
|
||||||
|
>
|
||||||
|
<option value="openai">OpenAI</option>
|
||||||
|
<option value="anthropic">Anthropic</option>
|
||||||
|
</select>
|
||||||
|
</Field>
|
||||||
|
<Field label="Base URL">
|
||||||
|
<input
|
||||||
|
value={p.base_url}
|
||||||
|
onChange={(e) => editors.patch(name, { base_url: e.target.value })}
|
||||||
|
className={inputCls}
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
<Field label="API Key">
|
||||||
|
<input
|
||||||
|
type="password"
|
||||||
|
value={p.api_key}
|
||||||
|
onChange={(e) => editors.patch(name, { api_key: e.target.value })}
|
||||||
|
className={inputCls}
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
<Field label="LLM 超时 (秒)">
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
value={p.llm_timeout_secs}
|
||||||
|
onChange={(e) => editors.patch(name, { llm_timeout_secs: +e.target.value })}
|
||||||
|
className={inputCls}
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
<Field label="记忆维护超时 (秒)">
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
value={p.memory_maintenance_timeout_secs}
|
||||||
|
onChange={(e) =>
|
||||||
|
editors.patch(name, { memory_maintenance_timeout_secs: +e.target.value })
|
||||||
|
}
|
||||||
|
className={inputCls}
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
<Field label="最大重试次数">
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
min={0}
|
||||||
|
value={p.max_retries}
|
||||||
|
onChange={(e) => editors.patch(name, { max_retries: +e.target.value })}
|
||||||
|
className={inputCls}
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
<AddButton label="添加服务商" onClick={addProvider} />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
56
web/src/components/Settings/tabs/SchedulerTab.tsx
Normal file
56
web/src/components/Settings/tabs/SchedulerTab.tsx
Normal file
@ -0,0 +1,56 @@
|
|||||||
|
// SchedulerTab - 调度器配置
|
||||||
|
import { Field, Toggle, SectionCard } from '../ui';
|
||||||
|
import { inputCls, selectCls } from '../constants';
|
||||||
|
import type { TabProps } from '../shared';
|
||||||
|
import type { SchedulerConfig } from '../types';
|
||||||
|
|
||||||
|
export function SchedulerTab({ config, update }: TabProps) {
|
||||||
|
return (
|
||||||
|
<div className="space-y-5">
|
||||||
|
<SectionCard title="调度器">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<span className="text-sm text-[var(--text-secondary)]">启用调度器</span>
|
||||||
|
<Toggle
|
||||||
|
checked={config.scheduler.enabled}
|
||||||
|
onChange={(v) => update('scheduler', { ...config.scheduler, enabled: v })}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<Field label="Tick 分辨率 (ms)">
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
value={config.scheduler.tick_resolution_ms}
|
||||||
|
onChange={(e) =>
|
||||||
|
update('scheduler', { ...config.scheduler, tick_resolution_ms: +e.target.value })
|
||||||
|
}
|
||||||
|
className={inputCls}
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
<Field label="工作队列容量">
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
value={config.scheduler.worker_queue_capacity}
|
||||||
|
onChange={(e) =>
|
||||||
|
update('scheduler', { ...config.scheduler, worker_queue_capacity: +e.target.value })
|
||||||
|
}
|
||||||
|
className={inputCls}
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
<Field label="Misfire 策略">
|
||||||
|
<select
|
||||||
|
value={config.scheduler.misfire_policy}
|
||||||
|
onChange={(e) =>
|
||||||
|
update('scheduler', {
|
||||||
|
...config.scheduler,
|
||||||
|
misfire_policy: e.target.value as SchedulerConfig['misfire_policy'],
|
||||||
|
})
|
||||||
|
}
|
||||||
|
className={selectCls}
|
||||||
|
>
|
||||||
|
<option value="skip">跳过 (Skip)</option>
|
||||||
|
<option value="catch_up">追赶 (Catch Up)</option>
|
||||||
|
</select>
|
||||||
|
</Field>
|
||||||
|
</SectionCard>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
149
web/src/components/Settings/tabs/SkillsTab.tsx
Normal file
149
web/src/components/Settings/tabs/SkillsTab.tsx
Normal file
@ -0,0 +1,149 @@
|
|||||||
|
// SkillsTab - 技能配置(来源目录 + 已发现技能列表)
|
||||||
|
import { useState, useEffect, useCallback } from 'react';
|
||||||
|
import { Loader2 } from 'lucide-react';
|
||||||
|
import { Field, Toggle, SectionCard, SourceEditor } from '../ui';
|
||||||
|
import { inputCls } from '../constants';
|
||||||
|
import type { TabProps, ToastProps } from '../shared';
|
||||||
|
import type { KnownSource, SkillListResponse } from '../types';
|
||||||
|
import { listSkills, toggleSkill } from '../../../api/skills';
|
||||||
|
|
||||||
|
type Props = TabProps & ToastProps;
|
||||||
|
|
||||||
|
const SKILL_KNOWN_SOURCES: KnownSource[] = [
|
||||||
|
{ key: 'user', label: '用户技能', description: '~/.picobot/skills' },
|
||||||
|
{ key: 'user_agent', label: '用户 Agent 技能', description: '~/.agents/skills' },
|
||||||
|
{ key: 'user_openclaw', label: '用户 OpenClaw 技能', description: '~/.openclaw/skills' },
|
||||||
|
{ key: 'project', label: '项目技能', description: '.picobot/skills' },
|
||||||
|
{ key: 'project_agent', label: '项目 Agent 技能', description: '.agents/skills' },
|
||||||
|
{ key: 'project_openclaw', label: '项目 OpenClaw 技能', description: '.openclaw/skills' },
|
||||||
|
];
|
||||||
|
|
||||||
|
export function SkillsTab({ config, update, setToast }: Props) {
|
||||||
|
const [skillList, setSkillList] = useState<SkillListResponse | null>(null);
|
||||||
|
const [skillListLoading, setSkillListLoading] = useState(false);
|
||||||
|
|
||||||
|
const fetchSkillList = useCallback(async () => {
|
||||||
|
setSkillListLoading(true);
|
||||||
|
const data = await listSkills();
|
||||||
|
if (data) setSkillList(data);
|
||||||
|
setSkillListLoading(false);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
fetchSkillList();
|
||||||
|
}, [fetchSkillList]);
|
||||||
|
|
||||||
|
const handleToggle = async (name: string, currentlyEnabled: boolean) => {
|
||||||
|
if (!skillList) return;
|
||||||
|
const skills = skillList.skills;
|
||||||
|
const prevSkillList = skillList;
|
||||||
|
setSkillList({
|
||||||
|
...skillList,
|
||||||
|
skills: skills.map((s) =>
|
||||||
|
s.name === name ? { ...s, disabled_in_scopes: currentlyEnabled ? ['project'] : [] } : s,
|
||||||
|
),
|
||||||
|
});
|
||||||
|
|
||||||
|
try {
|
||||||
|
const resp = await toggleSkill(name, 'project', !currentlyEnabled);
|
||||||
|
const data = await resp.json();
|
||||||
|
if (!resp.ok || !data.success) {
|
||||||
|
setSkillList(prevSkillList);
|
||||||
|
setToast(data.error || '切换技能状态失败');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setSkillList({
|
||||||
|
...prevSkillList,
|
||||||
|
skills: prevSkillList.skills.map((s) =>
|
||||||
|
s.name === name ? { ...s, disabled_in_scopes: data.disabled_in_scopes || [] } : s,
|
||||||
|
),
|
||||||
|
});
|
||||||
|
} catch {
|
||||||
|
setSkillList(prevSkillList);
|
||||||
|
setToast('网络错误,切换技能状态失败');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-5">
|
||||||
|
<SectionCard title="技能系统">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<span className="text-sm text-[var(--text-secondary)]">启用技能</span>
|
||||||
|
<Toggle
|
||||||
|
checked={config.skills.enabled}
|
||||||
|
onChange={(v) => update('skills', { ...config.skills, enabled: v })}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<Field label="最大索引字符数">
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
value={config.skills.max_index_chars}
|
||||||
|
onChange={(e) =>
|
||||||
|
update('skills', { ...config.skills, max_index_chars: +e.target.value })
|
||||||
|
}
|
||||||
|
className={inputCls}
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
<Field label="最大展示技能数">
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
value={config.skills.max_listed_skills}
|
||||||
|
onChange={(e) =>
|
||||||
|
update('skills', { ...config.skills, max_listed_skills: +e.target.value })
|
||||||
|
}
|
||||||
|
className={inputCls}
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
</SectionCard>
|
||||||
|
<SectionCard title="来源目录">
|
||||||
|
<SourceEditor
|
||||||
|
sources={config.skills.sources}
|
||||||
|
onChange={(v) => update('skills', { ...config.skills, sources: v })}
|
||||||
|
knownSources={SKILL_KNOWN_SOURCES}
|
||||||
|
examplePaths={['D:\\my-skills', '/home/user/shared-skills']}
|
||||||
|
/>
|
||||||
|
</SectionCard>
|
||||||
|
{skillList && skillList.skills_system_enabled && (
|
||||||
|
<SectionCard title="已发现技能" subtitle="即时生效">
|
||||||
|
{skillListLoading && skillList.skills.length === 0 ? (
|
||||||
|
<div className="flex items-center gap-2 text-sm text-[var(--text-muted)]">
|
||||||
|
<Loader2 className="h-4 w-4 animate-spin" /> 加载中...
|
||||||
|
</div>
|
||||||
|
) : skillList.skills.length === 0 ? (
|
||||||
|
<p className="text-sm text-[var(--text-muted)]">未发现任何技能,请检查来源目录配置</p>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-1">
|
||||||
|
{skillList.skills.map((skill) => {
|
||||||
|
const isEnabled = skill.disabled_in_scopes.length === 0;
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={skill.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)]">
|
||||||
|
{skill.name}
|
||||||
|
</span>
|
||||||
|
<span className="text-[10px] px-1.5 py-0.5 rounded bg-[var(--bg-tertiary)] text-[var(--text-muted)] uppercase tracking-wider">
|
||||||
|
{skill.source}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<p className="text-xs text-[var(--text-muted)] truncate mt-0.5">
|
||||||
|
{skill.description}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<Toggle
|
||||||
|
checked={isEnabled}
|
||||||
|
onChange={() => handleToggle(skill.name, isEnabled)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</SectionCard>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
270
web/src/components/Settings/tabs/SubagentsTab.tsx
Normal file
270
web/src/components/Settings/tabs/SubagentsTab.tsx
Normal file
@ -0,0 +1,270 @@
|
|||||||
|
// SubagentsTab - 子代理配置(来源目录 + 已发现子代理 + 编辑/创建/删除 + 搜索)
|
||||||
|
import { useState, useEffect, useCallback, useMemo } from 'react';
|
||||||
|
import { Loader2, Pencil, Trash2, Search } from 'lucide-react';
|
||||||
|
import { Toggle, SectionCard, SourceEditor } from '../ui';
|
||||||
|
import { AddButton } from '../shared';
|
||||||
|
import {
|
||||||
|
SubagentModal,
|
||||||
|
subagentToDraft,
|
||||||
|
emptySubagentDraft,
|
||||||
|
type SubagentDraft,
|
||||||
|
} from '../modals/SubagentModal';
|
||||||
|
import { listSubagents, toggleSubagent, deleteSubagent } from '../../../api/subagents';
|
||||||
|
import type { TabProps, ToastProps } from '../shared';
|
||||||
|
import type { KnownSource, SubagentListResponse } from '../types';
|
||||||
|
|
||||||
|
type Props = TabProps & ToastProps;
|
||||||
|
|
||||||
|
const SUBAGENT_KNOWN_SOURCES: KnownSource[] = [
|
||||||
|
{ key: 'user', label: '用户子代理', description: '~/.picobot/subagents' },
|
||||||
|
{ key: 'project', label: '项目子代理', description: '.picobot/subagents' },
|
||||||
|
];
|
||||||
|
|
||||||
|
// 工具名 → 友好标签(用于在列表项展示 allowed/denied 工具)
|
||||||
|
const KNOWN_TOOL_LABELS: Record<string, string> = {
|
||||||
|
read: 'Read',
|
||||||
|
edit: 'Edit',
|
||||||
|
write: 'Write',
|
||||||
|
bash: 'Bash',
|
||||||
|
http_request: 'HTTP Request',
|
||||||
|
web_fetch: 'Web Fetch',
|
||||||
|
memory_search: 'Memory Search',
|
||||||
|
get_time: 'Get Time',
|
||||||
|
calculator: 'Calculator',
|
||||||
|
skill_activate: 'Skill Activate',
|
||||||
|
skill_list: 'Skill List',
|
||||||
|
send_session_message: 'Send Session Message',
|
||||||
|
};
|
||||||
|
|
||||||
|
function toolLabel(key: string): string {
|
||||||
|
return KNOWN_TOOL_LABELS[key] ?? key;
|
||||||
|
}
|
||||||
|
|
||||||
|
function ToolTag({
|
||||||
|
label,
|
||||||
|
tools,
|
||||||
|
tone,
|
||||||
|
}: {
|
||||||
|
label: string;
|
||||||
|
tools: string[] | undefined;
|
||||||
|
tone: 'allow' | 'deny';
|
||||||
|
}) {
|
||||||
|
if (!tools || tools.length === 0) return null;
|
||||||
|
const tagCls =
|
||||||
|
tone === 'allow'
|
||||||
|
? 'bg-emerald-500/10 text-emerald-600 dark:text-emerald-400'
|
||||||
|
: 'bg-rose-500/10 text-rose-600 dark:text-rose-400';
|
||||||
|
return (
|
||||||
|
<div className="flex items-center gap-1 flex-wrap mt-1">
|
||||||
|
<span className="text-[10px] text-[var(--text-muted)]">{label}:</span>
|
||||||
|
{tools.map((t) => (
|
||||||
|
<span key={t} className={`text-[10px] px-1.5 py-0.5 rounded ${tagCls}`}>
|
||||||
|
{toolLabel(t)}
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function ModelTag({ provider, model }: { provider?: string; model?: string }) {
|
||||||
|
if (!provider && !model) return null;
|
||||||
|
const text = [provider, model].filter(Boolean).join(' / ');
|
||||||
|
return (
|
||||||
|
<span className="text-[10px] px-1.5 py-0.5 rounded bg-blue-500/10 text-blue-600 dark:text-blue-400">
|
||||||
|
{text}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function SubagentsTab({ config, update, setToast }: Props) {
|
||||||
|
const [subagentList, setSubagentList] = useState<SubagentListResponse | null>(null);
|
||||||
|
const [subagentListLoading, setSubagentListLoading] = useState(false);
|
||||||
|
const [editingSubagent, setEditingSubagent] = useState<SubagentDraft | null>(null);
|
||||||
|
const [searchQuery, setSearchQuery] = useState('');
|
||||||
|
|
||||||
|
const fetchSubagentList = useCallback(async () => {
|
||||||
|
setSubagentListLoading(true);
|
||||||
|
const data = await listSubagents();
|
||||||
|
if (data) setSubagentList(data);
|
||||||
|
setSubagentListLoading(false);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
fetchSubagentList();
|
||||||
|
}, [fetchSubagentList]);
|
||||||
|
|
||||||
|
const handleToggle = async (name: string, currentlyEnabled: boolean) => {
|
||||||
|
if (!subagentList) return;
|
||||||
|
const prevList = subagentList;
|
||||||
|
setSubagentList({
|
||||||
|
...subagentList,
|
||||||
|
subagents: subagentList.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 || '切换子代理状态失败');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setSubagentList({
|
||||||
|
...prevList,
|
||||||
|
subagents: prevList.subagents.map((s) =>
|
||||||
|
s.name === name ? { ...s, disabled_in_scopes: data.disabled_in_scopes || [] } : s,
|
||||||
|
),
|
||||||
|
});
|
||||||
|
} catch {
|
||||||
|
setSubagentList(prevList);
|
||||||
|
setToast('网络错误,切换子代理状态失败');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDelete = async (name: string) => {
|
||||||
|
if (!confirm(`确定删除子代理 "${name}" 吗?此操作将删除对应文件。`)) return;
|
||||||
|
try {
|
||||||
|
const resp = await deleteSubagent(name);
|
||||||
|
const data = await resp.json().catch(() => ({}));
|
||||||
|
if (!resp.ok || !data.success) {
|
||||||
|
setToast(data.error || '删除子代理失败');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setToast('子代理已删除');
|
||||||
|
fetchSubagentList();
|
||||||
|
} catch {
|
||||||
|
setToast('网络错误,删除子代理失败');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// 按名称/描述过滤
|
||||||
|
const filteredSubagents = useMemo(() => {
|
||||||
|
if (!subagentList) return [];
|
||||||
|
const q = searchQuery.trim().toLowerCase();
|
||||||
|
if (!q) return subagentList.subagents;
|
||||||
|
return subagentList.subagents.filter(
|
||||||
|
(s) =>
|
||||||
|
s.name.toLowerCase().includes(q) || s.description.toLowerCase().includes(q),
|
||||||
|
);
|
||||||
|
}, [subagentList, searchQuery]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-5">
|
||||||
|
<SectionCard title="子代理">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<span className="text-sm text-[var(--text-secondary)]">启用子代理发现</span>
|
||||||
|
<Toggle
|
||||||
|
checked={config.subagents.enabled}
|
||||||
|
onChange={(v) => update('subagents', { ...config.subagents, enabled: v })}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</SectionCard>
|
||||||
|
<SectionCard title="来源目录">
|
||||||
|
<SourceEditor
|
||||||
|
sources={config.subagents.sources}
|
||||||
|
onChange={(v) => update('subagents', { ...config.subagents, sources: v })}
|
||||||
|
knownSources={SUBAGENT_KNOWN_SOURCES}
|
||||||
|
examplePaths={['D:\\my-subagents', '/home/user/shared-agents']}
|
||||||
|
/>
|
||||||
|
</SectionCard>
|
||||||
|
{subagentList && subagentList.subagents_system_enabled && (
|
||||||
|
<SectionCard title="已发现子代理" subtitle="即时生效">
|
||||||
|
{/* 搜索框 */}
|
||||||
|
{subagentList.subagents.length > 0 && (
|
||||||
|
<div className="relative mb-3">
|
||||||
|
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-3.5 w-3.5 text-[var(--text-muted)]" />
|
||||||
|
<input
|
||||||
|
value={searchQuery}
|
||||||
|
onChange={(e) => setSearchQuery(e.target.value)}
|
||||||
|
placeholder="搜索子代理名称或描述..."
|
||||||
|
className="w-full pl-9 pr-3 py-2 rounded-lg bg-[var(--bg-tertiary)] border border-[var(--border-color)] text-[var(--text-primary)] text-sm placeholder:text-[var(--text-muted)] focus:outline-none focus:border-[var(--accent-cyan)] focus:ring-1 focus:ring-[var(--focus-ring)] transition-colors"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{subagentListLoading && subagentList.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>
|
||||||
|
) : subagentList.subagents.length === 0 ? (
|
||||||
|
<p className="text-sm text-[var(--text-muted)]">未发现任何子代理</p>
|
||||||
|
) : filteredSubagents.length === 0 ? (
|
||||||
|
<p className="text-sm text-[var(--text-muted)]">未找到匹配的子代理</p>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-1">
|
||||||
|
{filteredSubagents.map((subagent) => {
|
||||||
|
const isEnabled = subagent.disabled_in_scopes.length === 0;
|
||||||
|
const isBuiltin = subagent.source === 'builtin';
|
||||||
|
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 flex-wrap">
|
||||||
|
<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>
|
||||||
|
<ModelTag provider={subagent.provider} model={subagent.model} />
|
||||||
|
</div>
|
||||||
|
<p className="text-xs text-[var(--text-muted)] truncate mt-0.5">
|
||||||
|
{subagent.description}
|
||||||
|
</p>
|
||||||
|
<ToolTag
|
||||||
|
label="允许"
|
||||||
|
tools={subagent.capability?.allowed_tools}
|
||||||
|
tone="allow"
|
||||||
|
/>
|
||||||
|
<ToolTag
|
||||||
|
label="禁用"
|
||||||
|
tools={subagent.capability?.denied_tools}
|
||||||
|
tone="deny"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
{!isBuiltin && (
|
||||||
|
<>
|
||||||
|
<button
|
||||||
|
onClick={() => setEditingSubagent(subagentToDraft(subagent))}
|
||||||
|
className="p-1 rounded text-[var(--text-muted)] hover:text-[var(--accent-cyan)] hover:bg-[var(--overlay-hover)] transition-colors"
|
||||||
|
title="编辑"
|
||||||
|
>
|
||||||
|
<Pencil className="h-3.5 w-3.5" />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => handleDelete(subagent.name)}
|
||||||
|
className="p-1 rounded text-[var(--text-muted)] hover:text-red-400 hover:bg-red-500/10 transition-colors"
|
||||||
|
title="删除"
|
||||||
|
>
|
||||||
|
<Trash2 className="h-3.5 w-3.5" />
|
||||||
|
</button>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
<Toggle
|
||||||
|
checked={isEnabled}
|
||||||
|
onChange={() => handleToggle(subagent.name, isEnabled)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</SectionCard>
|
||||||
|
)}
|
||||||
|
<AddButton label="添加子代理" onClick={() => setEditingSubagent(emptySubagentDraft())} />
|
||||||
|
{editingSubagent && (
|
||||||
|
<SubagentModal
|
||||||
|
draft={editingSubagent}
|
||||||
|
onClose={() => setEditingSubagent(null)}
|
||||||
|
onSaved={() => {
|
||||||
|
setEditingSubagent(null);
|
||||||
|
fetchSubagentList();
|
||||||
|
}}
|
||||||
|
setToast={setToast}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
24
web/src/components/Settings/tabs/TimeTab.tsx
Normal file
24
web/src/components/Settings/tabs/TimeTab.tsx
Normal file
@ -0,0 +1,24 @@
|
|||||||
|
// TimeTab - 时区设置
|
||||||
|
import { Field, SectionCard } from '../ui';
|
||||||
|
import { inputCls, TIMEZONE_OPTIONS } from '../constants';
|
||||||
|
import type { TabProps } from '../shared';
|
||||||
|
|
||||||
|
export function TimeTab({ config, update }: TabProps) {
|
||||||
|
return (
|
||||||
|
<SectionCard title="时区设置">
|
||||||
|
<Field label="时区" hint="IANA 格式">
|
||||||
|
<select
|
||||||
|
value={config.time.timezone}
|
||||||
|
onChange={(e) => update('time', { timezone: e.target.value })}
|
||||||
|
className={inputCls}
|
||||||
|
>
|
||||||
|
{TIMEZONE_OPTIONS.map((tz) => (
|
||||||
|
<option key={tz.value} value={tz.value}>
|
||||||
|
{tz.label}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</Field>
|
||||||
|
</SectionCard>
|
||||||
|
);
|
||||||
|
}
|
||||||
100
web/src/components/Settings/tabs/ToolsTab.tsx
Normal file
100
web/src/components/Settings/tabs/ToolsTab.tsx
Normal file
@ -0,0 +1,100 @@
|
|||||||
|
// ToolsTab - 工具配置
|
||||||
|
import { Field, Toggle, SectionCard, TagEditor, SourceEditor } from '../ui';
|
||||||
|
import { inputCls } from '../constants';
|
||||||
|
import type { TabProps } from '../shared';
|
||||||
|
import type { KnownSource } from '../types';
|
||||||
|
|
||||||
|
const TASK_KNOWN_TOOLS: KnownSource[] = [
|
||||||
|
{ key: 'read', label: 'Read', description: '读取文件' },
|
||||||
|
{ key: 'edit', label: 'Edit', description: '编辑文件' },
|
||||||
|
{ key: 'write', label: 'Write', description: '写入文件' },
|
||||||
|
{ key: 'bash', label: 'Bash', description: '执行 Shell 命令' },
|
||||||
|
{ key: 'http_request', label: 'HTTP Request', description: '发送 HTTP 请求' },
|
||||||
|
{ key: 'web_fetch', label: 'Web Fetch', description: '抓取网页内容' },
|
||||||
|
{ key: 'memory_search', label: 'Memory Search', description: '搜索记忆' },
|
||||||
|
{ key: 'get_time', label: 'Get Time', description: '获取当前时间' },
|
||||||
|
{ key: 'calculator', label: 'Calculator', description: '计算器' },
|
||||||
|
{ key: 'skill_activate', label: 'Skill Activate', description: '激活技能' },
|
||||||
|
{ key: 'skill_list', label: 'Skill List', description: '列出技能' },
|
||||||
|
{ key: 'send_session_message', label: 'Send Session Message', description: '发送会话消息' },
|
||||||
|
];
|
||||||
|
|
||||||
|
export function ToolsTab({ config, update }: TabProps) {
|
||||||
|
return (
|
||||||
|
<div className="space-y-5">
|
||||||
|
<SectionCard title="禁用工具列表">
|
||||||
|
<TagEditor
|
||||||
|
tags={config.tools.disabled}
|
||||||
|
onChange={(v) => update('tools', { ...config.tools, disabled: v })}
|
||||||
|
/>
|
||||||
|
</SectionCard>
|
||||||
|
<SectionCard title="Task 子代理">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<span className="text-sm text-[var(--text-secondary)]">启用 Task 工具</span>
|
||||||
|
<Toggle
|
||||||
|
checked={config.tools.task.enabled}
|
||||||
|
onChange={(v) =>
|
||||||
|
update('tools', { ...config.tools, task: { ...config.tools.task, enabled: v } })
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<Field label="最大执行时间 (秒)">
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
value={config.tools.task.max_execution_secs}
|
||||||
|
onChange={(e) =>
|
||||||
|
update('tools', {
|
||||||
|
...config.tools,
|
||||||
|
task: { ...config.tools.task, max_execution_secs: +e.target.value },
|
||||||
|
})
|
||||||
|
}
|
||||||
|
className={inputCls}
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
<Field label="TTL (小时)">
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
value={config.tools.task.ttl_hours}
|
||||||
|
onChange={(e) =>
|
||||||
|
update('tools', {
|
||||||
|
...config.tools,
|
||||||
|
task: { ...config.tools.task, ttl_hours: +e.target.value },
|
||||||
|
})
|
||||||
|
}
|
||||||
|
className={inputCls}
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
<Field
|
||||||
|
label="最大嵌套深度"
|
||||||
|
hint="允许的子代理最大嵌套层数。1=仅子代理,2=子代理+孙代理(默认),0=禁止嵌套。深度达上限时移除 task 工具以防无限递归"
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
min={0}
|
||||||
|
value={config.tools.task.max_nesting_depth}
|
||||||
|
onChange={(e) =>
|
||||||
|
update('tools', {
|
||||||
|
...config.tools,
|
||||||
|
task: {
|
||||||
|
...config.tools.task,
|
||||||
|
max_nesting_depth: Math.max(0, +e.target.value || 0),
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
className={inputCls}
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
</SectionCard>
|
||||||
|
<SectionCard title="允许的工具列表">
|
||||||
|
<SourceEditor
|
||||||
|
sources={config.tools.task.allowed_tools}
|
||||||
|
onChange={(v) =>
|
||||||
|
update('tools', { ...config.tools, task: { ...config.tools.task, allowed_tools: v } })
|
||||||
|
}
|
||||||
|
knownSources={TASK_KNOWN_TOOLS}
|
||||||
|
showCustom={false}
|
||||||
|
/>
|
||||||
|
</SectionCard>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -137,6 +137,8 @@ export interface SubagentItem {
|
|||||||
capability?: CapabilityPolicy;
|
capability?: CapabilityPolicy;
|
||||||
provider?: string;
|
provider?: string;
|
||||||
model?: string;
|
model?: string;
|
||||||
|
/** SUBAGENT.md 的 markdown 正文。builtin 子代理可能为 undefined。 */
|
||||||
|
body?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface SubagentListResponse {
|
export interface SubagentListResponse {
|
||||||
|
|||||||
69
web/src/components/Settings/useMapEditor.ts
Normal file
69
web/src/components/Settings/useMapEditor.ts
Normal file
@ -0,0 +1,69 @@
|
|||||||
|
// 通用 Map 编辑 hook:封装 providers/models/agents/mcp/channels 五处重复的 add/del/rename/upd 模式
|
||||||
|
import { useCallback } from 'react';
|
||||||
|
|
||||||
|
export interface MapEditor<T> {
|
||||||
|
entries: [string, T][];
|
||||||
|
add: (name: string, value: T) => void;
|
||||||
|
remove: (name: string) => void;
|
||||||
|
rename: (oldName: string, newName: string) => void;
|
||||||
|
patch: (name: string, partial: Partial<T>) => void;
|
||||||
|
has: (name: string) => boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 用法:
|
||||||
|
* const editors = useMapEditor(config.providers, (v) => update('providers', v));
|
||||||
|
* editors.add('openai', { type: 'openai', ... });
|
||||||
|
* editors.patch('openai', { api_key: 'sk-xxx' });
|
||||||
|
*
|
||||||
|
* rename 在内部按插入顺序重建 map,保持其他 key 不变。
|
||||||
|
*/
|
||||||
|
export function useMapEditor<T>(
|
||||||
|
map: Record<string, T>,
|
||||||
|
setMap: (next: Record<string, T>) => void,
|
||||||
|
): MapEditor<T> {
|
||||||
|
const entries = Object.entries(map);
|
||||||
|
|
||||||
|
const add = useCallback(
|
||||||
|
(name: string, value: T) => {
|
||||||
|
if (map[name]) return;
|
||||||
|
setMap({ ...map, [name]: value });
|
||||||
|
},
|
||||||
|
[map, setMap],
|
||||||
|
);
|
||||||
|
|
||||||
|
const remove = useCallback(
|
||||||
|
(name: string) => {
|
||||||
|
const { [name]: _omit, ...rest } = map;
|
||||||
|
void _omit;
|
||||||
|
setMap(rest);
|
||||||
|
},
|
||||||
|
[map, setMap],
|
||||||
|
);
|
||||||
|
|
||||||
|
const rename = useCallback(
|
||||||
|
(oldName: string, newName: string) => {
|
||||||
|
const trimmed = newName.trim();
|
||||||
|
if (trimmed === oldName || !trimmed || map[trimmed]) return;
|
||||||
|
const next: Record<string, T> = {};
|
||||||
|
for (const [k, v] of Object.entries(map)) {
|
||||||
|
next[k === oldName ? trimmed : k] = v;
|
||||||
|
}
|
||||||
|
setMap(next);
|
||||||
|
},
|
||||||
|
[map, setMap],
|
||||||
|
);
|
||||||
|
|
||||||
|
const patch = useCallback(
|
||||||
|
(name: string, partial: Partial<T>) => {
|
||||||
|
const cur = map[name];
|
||||||
|
if (!cur) return;
|
||||||
|
setMap({ ...map, [name]: { ...cur, ...partial } });
|
||||||
|
},
|
||||||
|
[map, setMap],
|
||||||
|
);
|
||||||
|
|
||||||
|
const has = useCallback((name: string) => !!map[name], [map]);
|
||||||
|
|
||||||
|
return { entries, add, remove, rename, patch, has };
|
||||||
|
}
|
||||||
55
web/src/components/Settings/useSharedModalData.ts
Normal file
55
web/src/components/Settings/useSharedModalData.ts
Normal file
@ -0,0 +1,55 @@
|
|||||||
|
// 共享数据 hook:专家/子代理模态框打开时加载能力勾选列表所需的数据
|
||||||
|
// 在模态框挂载时触发,避免在 ConfigPage 顶层预加载
|
||||||
|
import { useEffect, useState } from 'react';
|
||||||
|
import { listSkills } from '../../api/skills';
|
||||||
|
import { listTools } from '../../api/tools';
|
||||||
|
import { listSubagents } from '../../api/subagents';
|
||||||
|
import { listModelOptions } from '../../api/experts';
|
||||||
|
import type {
|
||||||
|
SkillListResponse,
|
||||||
|
ToolsListResponse,
|
||||||
|
SubagentListResponse,
|
||||||
|
ModelOptionsResponse,
|
||||||
|
} from './types';
|
||||||
|
|
||||||
|
export interface SharedModalData {
|
||||||
|
skillList: SkillListResponse | null;
|
||||||
|
toolList: ToolsListResponse | null;
|
||||||
|
subagentList: SubagentListResponse | null;
|
||||||
|
modelOptions: ModelOptionsResponse | null;
|
||||||
|
loading: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 在模态框挂载时一次性加载 skills/tools/subagents/modelOptions。
|
||||||
|
* 用于专家/子代理编辑模态框的能力勾选列表与 provider/model 下拉框。
|
||||||
|
*/
|
||||||
|
export function useSharedModalData(enabled: boolean): SharedModalData {
|
||||||
|
const [skillList, setSkillList] = useState<SkillListResponse | null>(null);
|
||||||
|
const [toolList, setToolList] = useState<ToolsListResponse | null>(null);
|
||||||
|
const [subagentList, setSubagentList] = useState<SubagentListResponse | null>(null);
|
||||||
|
const [modelOptions, setModelOptions] = useState<ModelOptionsResponse | null>(null);
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!enabled) return;
|
||||||
|
let cancelled = false;
|
||||||
|
setLoading(true);
|
||||||
|
Promise.all([listSkills(), listTools(), listSubagents(), listModelOptions()])
|
||||||
|
.then(([s, t, sub, m]) => {
|
||||||
|
if (cancelled) return;
|
||||||
|
if (s) setSkillList(s);
|
||||||
|
if (t) setToolList(t);
|
||||||
|
if (sub) setSubagentList(sub);
|
||||||
|
if (m) setModelOptions(m);
|
||||||
|
})
|
||||||
|
.finally(() => {
|
||||||
|
if (!cancelled) setLoading(false);
|
||||||
|
});
|
||||||
|
return () => {
|
||||||
|
cancelled = true;
|
||||||
|
};
|
||||||
|
}, [enabled]);
|
||||||
|
|
||||||
|
return { skillList, toolList, subagentList, modelOptions, loading };
|
||||||
|
}
|
||||||
Loading…
x
Reference in New Issue
Block a user