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(),
|
||||
provider: updated.provider.clone(),
|
||||
model: updated.model.clone(),
|
||||
body: updated.body.clone(),
|
||||
});
|
||||
|
||||
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 =====================
|
||||
|
||||
#[derive(Deserialize)]
|
||||
|
||||
@ -271,6 +271,14 @@ pub async fn run(
|
||||
"/api/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/toggle", routing::post(http::experts_toggle))
|
||||
.route("/api/experts/create", routing::post(http::experts_create))
|
||||
@ -315,6 +323,14 @@ pub async fn run(
|
||||
"/api/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/toggle", routing::post(http::experts_toggle))
|
||||
.route("/api/experts/create", routing::post(http::experts_create))
|
||||
|
||||
@ -1136,6 +1136,10 @@ pub struct SubagentWithStatus {
|
||||
/// 可选的 model 名(引用 config.json 的 models 表)。None 时继承主智能体。
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
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)]
|
||||
@ -1268,8 +1272,11 @@ impl SubagentRuntime {
|
||||
}
|
||||
|
||||
/// 重新发现子代理并替换内存 catalog(写回 SUBAGENT.md 后调用)。
|
||||
///
|
||||
/// 使用 `self.cwd` 而非进程 cwd 进行发现,确保与构造时的 cwd 一致
|
||||
/// (生产环境两者相同,但测试场景使用临时目录时必须用 `self.cwd`)。
|
||||
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
|
||||
.catalog
|
||||
.write()
|
||||
@ -1301,6 +1308,7 @@ impl SubagentRuntime {
|
||||
capability: def.capability.clone(),
|
||||
provider: def.provider.clone(),
|
||||
model: def.model.clone(),
|
||||
body: def.body.clone(),
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
@ -1585,6 +1593,150 @@ impl SubagentRuntime {
|
||||
}
|
||||
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();
|
||||
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',
|
||||
subagentsToggle: '/api/subagents/toggle',
|
||||
subagentsUpdate: '/api/subagents/update',
|
||||
subagentsCreate: '/api/subagents/create',
|
||||
subagentsDelete: '/api/subagents/delete',
|
||||
experts: '/api/experts',
|
||||
expertsToggle: '/api/experts/toggle',
|
||||
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: {
|
||||
name: string;
|
||||
description?: string;
|
||||
@ -31,3 +47,8 @@ export async function updateSubagent(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';
|
||||
import { X, Wifi, RotateCcw } from 'lucide-react';
|
||||
// 网关连接设置的 localStorage 工具函数
|
||||
// 原 SettingsModal 组件已删除(功能由 ConfigPage 的 Connection Tab 承担),
|
||||
// 仅保留 App.tsx 使用的 getGatewaySettings / buildWsUrl / GatewaySettings 工具函数
|
||||
|
||||
export interface GatewaySettings {
|
||||
host: string;
|
||||
@ -23,155 +24,3 @@ export function getGatewaySettings(): GatewaySettings {
|
||||
export function buildWsUrl(settings: GatewaySettings): string {
|
||||
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';
|
||||
import type { TabId } from './types';
|
||||
|
||||
export const TABS: { id: TabId; label: string; icon: typeof Settings }[] = [
|
||||
{ id: 'providers', label: '服务商', icon: Cpu },
|
||||
{ id: 'models', label: '模型', icon: Brain },
|
||||
{ id: 'agents', label: '代理', icon: Bot },
|
||||
{ id: 'mcp', label: 'MCP 服务器', icon: Plug },
|
||||
{ id: 'skills', label: '技能', icon: Wrench },
|
||||
{ id: 'subagents', label: '子代理', icon: Bot },
|
||||
{ id: 'experts', label: '专家', icon: UserCheck },
|
||||
{ id: 'channels', label: '渠道', icon: Radio },
|
||||
{ id: 'tools', label: '工具', icon: Settings },
|
||||
{ id: 'memory', label: '记忆维护', icon: Users },
|
||||
{ id: 'scheduler', label: '调度器', icon: Calendar },
|
||||
{ id: 'image', label: '图片上下文', icon: Image },
|
||||
{ id: 'time', label: '时间', icon: Clock },
|
||||
{ id: 'connection', label: '连接', icon: Wifi },
|
||||
{ id: 'gateway', label: '网关', icon: Server },
|
||||
export interface TabDef {
|
||||
id: TabId;
|
||||
label: string;
|
||||
icon: typeof Settings;
|
||||
}
|
||||
|
||||
export interface TabGroup {
|
||||
label: string;
|
||||
tabs: TabDef[];
|
||||
}
|
||||
|
||||
// 按功能分组展示,便于快速定位
|
||||
export const TAB_GROUPS: TabGroup[] = [
|
||||
{
|
||||
label: 'AI 核心',
|
||||
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 =
|
||||
'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;
|
||||
|
||||
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;
|
||||
provider?: string;
|
||||
model?: string;
|
||||
/** SUBAGENT.md 的 markdown 正文。builtin 子代理可能为 undefined。 */
|
||||
body?: string;
|
||||
}
|
||||
|
||||
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