From c2cc072b2e08c03d17f16386414513c3a1fef9b5 Mon Sep 17 00:00:00 2001 From: oudecheng <13802883547@139.com> Date: Tue, 4 Aug 2026 21:06:17 +0800 Subject: [PATCH] =?UTF-8?q?feat(settings):=20=E6=8B=86=E5=88=86=E8=AE=BE?= =?UTF-8?q?=E7=BD=AE=E9=A1=B5=E4=B8=BA=E6=87=92=E5=8A=A0=E8=BD=BD=E6=A0=87?= =?UTF-8?q?=E7=AD=BE=E9=A1=B5=E5=B9=B6=E6=94=AF=E6=8C=81=E5=AD=90=E4=BB=A3?= =?UTF-8?q?=E7=90=86=E5=88=9B=E5=BB=BA/=E5=88=A0=E9=99=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 的隔离缺陷 --- src/gateway/http.rs | 119 + src/gateway/mod.rs | 16 + src/tools/task/runtime.rs | 368 ++- web/src/api/client.ts | 2 + web/src/api/subagents.ts | 21 + .../components/Settings/CapabilityTabs.tsx | 167 ++ web/src/components/Settings/ConfigPage.tsx | 2484 +---------------- web/src/components/Settings/SettingsModal.tsx | 157 +- web/src/components/Settings/constants.ts | 62 +- .../Settings/modals/ExpertModal.tsx | 304 ++ .../Settings/modals/SubagentModal.tsx | 326 +++ web/src/components/Settings/shared.tsx | 37 + .../components/Settings/tabs/AgentsTab.tsx | 97 + .../components/Settings/tabs/ChannelsTab.tsx | 157 ++ .../Settings/tabs/ConnectionTab.tsx | 89 + .../components/Settings/tabs/ExpertsTab.tsx | 171 ++ .../components/Settings/tabs/GatewayTab.tsx | 69 + web/src/components/Settings/tabs/ImageTab.tsx | 37 + web/src/components/Settings/tabs/McpTab.tsx | 240 ++ .../components/Settings/tabs/MemoryTab.tsx | 53 + .../components/Settings/tabs/ModelsTab.tsx | 89 + .../components/Settings/tabs/ProvidersTab.tsx | 97 + .../components/Settings/tabs/SchedulerTab.tsx | 56 + .../components/Settings/tabs/SkillsTab.tsx | 149 + .../components/Settings/tabs/SubagentsTab.tsx | 270 ++ web/src/components/Settings/tabs/TimeTab.tsx | 24 + web/src/components/Settings/tabs/ToolsTab.tsx | 100 + web/src/components/Settings/types.ts | 2 + web/src/components/Settings/useMapEditor.ts | 69 + .../components/Settings/useSharedModalData.ts | 55 + 30 files changed, 3338 insertions(+), 2549 deletions(-) create mode 100644 web/src/components/Settings/CapabilityTabs.tsx create mode 100644 web/src/components/Settings/modals/ExpertModal.tsx create mode 100644 web/src/components/Settings/modals/SubagentModal.tsx create mode 100644 web/src/components/Settings/shared.tsx create mode 100644 web/src/components/Settings/tabs/AgentsTab.tsx create mode 100644 web/src/components/Settings/tabs/ChannelsTab.tsx create mode 100644 web/src/components/Settings/tabs/ConnectionTab.tsx create mode 100644 web/src/components/Settings/tabs/ExpertsTab.tsx create mode 100644 web/src/components/Settings/tabs/GatewayTab.tsx create mode 100644 web/src/components/Settings/tabs/ImageTab.tsx create mode 100644 web/src/components/Settings/tabs/McpTab.tsx create mode 100644 web/src/components/Settings/tabs/MemoryTab.tsx create mode 100644 web/src/components/Settings/tabs/ModelsTab.tsx create mode 100644 web/src/components/Settings/tabs/ProvidersTab.tsx create mode 100644 web/src/components/Settings/tabs/SchedulerTab.tsx create mode 100644 web/src/components/Settings/tabs/SkillsTab.tsx create mode 100644 web/src/components/Settings/tabs/SubagentsTab.tsx create mode 100644 web/src/components/Settings/tabs/TimeTab.tsx create mode 100644 web/src/components/Settings/tabs/ToolsTab.tsx create mode 100644 web/src/components/Settings/useMapEditor.ts create mode 100644 web/src/components/Settings/useSharedModalData.ts diff --git a/src/gateway/http.rs b/src/gateway/http.rs index a5ce87e..f251de8 100644 --- a/src/gateway/http.rs +++ b/src/gateway/http.rs @@ -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, + #[serde(default)] + pub model: Option, +} + +#[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, +} + +/// POST /api/subagents/create — Create a new subagent (writes SUBAGENT.md) +pub async fn subagents_create( + State(state): State>, + Json(req): Json, +) -> Result, (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>, + Query(req): Query, +) -> Result, (StatusCode, Json)> { + 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)] diff --git a/src/gateway/mod.rs b/src/gateway/mod.rs index f3d3973..0a5e01b 100644 --- a/src/gateway/mod.rs +++ b/src/gateway/mod.rs @@ -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)) diff --git a/src/tools/task/runtime.rs b/src/tools/task/runtime.rs index 3acf74c..2c20113 100644 --- a/src/tools/task/runtime.rs +++ b/src/tools/task/runtime.rs @@ -1136,6 +1136,10 @@ pub struct SubagentWithStatus { /// 可选的 model 名(引用 config.json 的 models 表)。None 时继承主智能体。 #[serde(default, skip_serializing_if = "Option::is_none")] pub model: Option, + /// SUBAGENT.md 的 markdown 正文,追加到系统提示词末尾。builtin 子代理为 None。 + /// 前端编辑模态框需要回显此字段,与专家系统的 body 对齐。 + #[serde(default, skip_serializing_if = "Option::is_none")] + pub body: Option, } #[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, + model: &Option, + reload: bool, + ) -> Result { + 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 { + 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 { + 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" + ); + } } diff --git a/web/src/api/client.ts b/web/src/api/client.ts index 3d04d90..a44f8f1 100644 --- a/web/src/api/client.ts +++ b/web/src/api/client.ts @@ -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', diff --git a/web/src/api/subagents.ts b/web/src/api/subagents.ts index 30771ef..dca4998 100644 --- a/web/src/api/subagents.ts +++ b/web/src/api/subagents.ts @@ -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 { + 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 { + const params = new URLSearchParams({ name }); + return fetch(`${API.subagentsDelete}?${params}`, { method: 'DELETE' }); +} diff --git a/web/src/components/Settings/CapabilityTabs.tsx b/web/src/components/Settings/CapabilityTabs.tsx new file mode 100644 index 0000000..682fbe6 --- /dev/null +++ b/web/src/components/Settings/CapabilityTabs.tsx @@ -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('skills'); + + // 子代理勾选列表通常排除自身(在调用方已过滤),此处不重复处理 + const setField = (key: K, v: CapabilityState[K]) => + onChange({ ...value, [key]: v }); + + return ( +
+ {/* Tab 头 */} +
+ {TABS.map((tab) => { + const Icon = tab.icon; + const isActive = active === tab.key; + return ( + + ); + })} +
+ + {/* Tab 内容:白名单 / 黑名单两列 */} +
+ {active === 'skills' && ( +
+ + setField('allowedSkills', v)} + extraSelected={value.allowedSkills} + emptyHint={skillEmptyHint} + groupBy={(o) => o.group ?? '其他'} + /> + + + setField('deniedSkills', v)} + extraSelected={value.deniedSkills} + emptyHint={skillEmptyHint} + groupBy={(o) => o.group ?? '其他'} + /> + +
+ )} + {active === 'tools' && ( +
+ + setField('allowedTools', v)} + extraSelected={value.allowedTools} + emptyHint={toolEmptyHint} + groupBy={(o) => o.group ?? '其他'} + /> + + + setField('deniedTools', v)} + extraSelected={value.deniedTools} + emptyHint={toolEmptyHint} + groupBy={(o) => o.group ?? '其他'} + /> + +
+ )} + {active === 'subagents' && ( +
+ + setField('allowedSubagents', v)} + extraSelected={value.allowedSubagents} + emptyHint={subagentEmptyHint} + groupBy={(o) => o.group ?? '其他'} + /> + + + setField('deniedSubagents', v)} + extraSelected={value.deniedSubagents} + emptyHint={subagentEmptyHint} + groupBy={(o) => o.group ?? '其他'} + /> + +
+ )} +
+
+ ); +} diff --git a/web/src/components/Settings/ConfigPage.tsx b/web/src/components/Settings/ConfigPage.tsx index 56a531f..a378e62 100644 --- a/web/src/components/Settings/ConfigPage.tsx +++ b/web/src/components/Settings/ConfigPage.tsx @@ -1,232 +1,79 @@ -import { useState, useEffect, useCallback } from 'react'; +// 配置页主组件:外壳布局 + Tab 导航(分组)+ 全局保存/重启/Toast +// 每个 Tab 的渲染逻辑、状态与数据获取已下沉到 tabs/ 下独立组件 +import { useState, useEffect, useCallback, useRef, lazy, Suspense } from 'react'; import { Settings, Save, X, - Plus, - Trash2, AlertTriangle, Loader2, - Wifi, CheckCircle, RefreshCw, - UserCheck, - Pencil, - Bot, } from 'lucide-react'; -// ── Extracted modules ───────────────────────────────── -import type { - AppConfig, - ConfigPageProps, - TabId, - ProviderConfig, - ModelConfig, - AgentConfig, - McpServerConfig, - McpStatusResponse, - SkillListResponse, - SubagentListResponse, - SubagentItem, - ToolsListResponse, - ExpertItem, - ExpertListResponse, - CapabilityPolicy, - KnownSource, - SchedulerConfig, - ChannelConfig, - ModelOptionsResponse, -} from './types'; -import { TABS, inputCls, selectCls, TIMEZONE_OPTIONS } from './constants'; -import { - Field, - Toggle, - TagEditor, - SectionCard, - SourceEditor, - MapEntryHeader, - CheckboxList, - ModalHeader, - ModalFooter, -} from './ui'; +import type { AppConfig, ConfigPageProps, TabId } from './types'; +import { TAB_GROUPS } from './constants'; import { getAppConfig, updateAppConfig, restartGateway, checkHealth } from '../../api/config'; -import { listSkills, toggleSkill } from '../../api/skills'; -import { listTools } from '../../api/tools'; -import { listSubagents, toggleSubagent, updateSubagent } from '../../api/subagents'; -import { - listExperts, - toggleExpert, - createExpert, - updateExpert, - deleteExpert, - listModelOptions, -} from '../../api/experts'; -import { getMcpStatus } from '../../api/mcp'; -export { getSelectedExpert, selectExpert } from '../../api/experts'; -// ── Main Component ───────────────────────────────────── +// Tab 组件懒加载:减少首屏 JS 体积,仅打开对应 Tab 时才加载 +const ProvidersTab = lazy(() => import('./tabs/ProvidersTab').then((m) => ({ default: m.ProvidersTab }))); +const ModelsTab = lazy(() => import('./tabs/ModelsTab').then((m) => ({ default: m.ModelsTab }))); +const AgentsTab = lazy(() => import('./tabs/AgentsTab').then((m) => ({ default: m.AgentsTab }))); +const McpTab = lazy(() => import('./tabs/McpTab').then((m) => ({ default: m.McpTab }))); +const SkillsTab = lazy(() => import('./tabs/SkillsTab').then((m) => ({ default: m.SkillsTab }))); +const SubagentsTab = lazy(() => import('./tabs/SubagentsTab').then((m) => ({ default: m.SubagentsTab }))); +const ExpertsTab = lazy(() => import('./tabs/ExpertsTab').then((m) => ({ default: m.ExpertsTab }))); +const ChannelsTab = lazy(() => import('./tabs/ChannelsTab').then((m) => ({ default: m.ChannelsTab }))); +const ToolsTab = lazy(() => import('./tabs/ToolsTab').then((m) => ({ default: m.ToolsTab }))); +const MemoryTab = lazy(() => import('./tabs/MemoryTab').then((m) => ({ default: m.MemoryTab }))); +const SchedulerTab = lazy(() => import('./tabs/SchedulerTab').then((m) => ({ default: m.SchedulerTab }))); +const ImageTab = lazy(() => import('./tabs/ImageTab').then((m) => ({ default: m.ImageTab }))); +const TimeTab = lazy(() => import('./tabs/TimeTab').then((m) => ({ default: m.TimeTab }))); +const GatewayTab = lazy(() => import('./tabs/GatewayTab').then((m) => ({ default: m.GatewayTab }))); +const ConnectionTab = lazy(() => import('./tabs/ConnectionTab').then((m) => ({ default: m.ConnectionTab }))); + +// Tab 切换时的加载占位 +function TabLoading() { + return ( +
+ 加载中... +
+ ); +} + export function ConfigPage({ onClose, onSaveConnection, initialTab }: ConfigPageProps) { const [config, setConfig] = useState(null); const [activeTab, setActiveTab] = useState(initialTab ?? 'providers'); const [loading, setLoading] = useState(true); - // Connection settings (localStorage-based) - 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 [saving, setSaving] = useState(false); const [error, setError] = useState(''); - const [toast, setToast] = useState(''); + const [toast, setToastState] = useState(''); const [dirty, setDirty] = useState(false); const [showRestartDialog, setShowRestartDialog] = useState(false); const [restarting, setRestarting] = useState(false); - const [mcpStatus, setMcpStatus] = useState(null); - const [skillList, setSkillList] = useState(null); - const [skillListLoading, setSkillListLoading] = useState(false); - const [toolList, setToolList] = useState(null); - const [toolListLoading, setToolListLoading] = useState(false); - const [subagentList, setSubagentList] = useState(null); - const [subagentListLoading, setSubagentListLoading] = useState(false); - const [expertList, setExpertList] = useState(null); - const [expertListLoading, setExpertListLoading] = useState(false); - const [modelOptions, setModelOptions] = useState(null); - const [editingExpert, setEditingExpert] = useState<{ - 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[]; - } | null>(null); - const [editingExpertError, setEditingExpertError] = useState(''); - const [savingExpert, setSavingExpert] = useState(false); - const [editingSubagent, setEditingSubagent] = useState<{ - name: string; - description: string; - provider: string; - model: string; - allowedSkills: string[]; - deniedSkills: string[]; - allowedTools: string[]; - deniedTools: string[]; - allowedSubagents: string[]; - deniedSubagents: string[]; - } | null>(null); - const [editingSubagentError, setEditingSubagentError] = useState(''); - const [savingSubagent, setSavingSubagent] = useState(false); - const fetchMcpStatus = useCallback(async () => { - const data = await getMcpStatus(); - if (data) setMcpStatus(data); + // toast 自动清除:每次设置非空 toast 时启动 3s 计时器 + const toastTimerRef = useRef(null); + const showToast = useCallback((msg: string) => { + setToastState(msg); + if (toastTimerRef.current !== null) { + clearTimeout(toastTimerRef.current); + } + if (msg) { + toastTimerRef.current = window.setTimeout(() => { + setToastState(''); + toastTimerRef.current = null; + }, 3000); + } }, []); - const fetchSkillList = useCallback(async () => { - setSkillListLoading(true); - const data = await listSkills(); - if (data) setSkillList(data); - setSkillListLoading(false); - }, []); - - const fetchToolList = useCallback(async () => { - setToolListLoading(true); - const data = await listTools(); - if (data) setToolList(data); - setToolListLoading(false); - }, []); - - const toggleSkillCb = useCallback(async (name: string, scope: string, enabled: boolean) => { - return toggleSkill(name, scope, enabled); - }, []); - - const fetchSubagentList = useCallback(async () => { - setSubagentListLoading(true); - const data = await listSubagents(); - if (data) setSubagentList(data); - setSubagentListLoading(false); - }, []); - - const toggleSubagentCb = useCallback(async (name: string, scope: string, enabled: boolean) => { - return toggleSubagent(name, scope, enabled); - }, []); - - const updateSubagentCb = useCallback( - async (payload: { - name: string; - description?: string; - body?: string; - capability?: CapabilityPolicy; - provider?: string; - model?: string; - }) => { - return updateSubagent(payload); - }, - [], - ); - - const fetchExpertList = useCallback(async () => { - setExpertListLoading(true); - const data = await listExperts(); - if (data) setExpertList(data); - setExpertListLoading(false); - }, []); - - const toggleExpertCb = useCallback(async (name: string, scope: string, enabled: boolean) => { - return toggleExpert(name, scope, enabled); - }, []); - - const createExpertCb = useCallback( - async (payload: { - name: string; - description: string; - body: string; - scope: string; - capability?: CapabilityPolicy; - provider?: string; - model?: string; - }) => { - return createExpert(payload); - }, - [], - ); - - const updateExpertCb = useCallback( - async (payload: { - name: string; - scope: string; - description?: string; - body?: string; - capability?: CapabilityPolicy; - provider?: string; - model?: string; - }) => { - return updateExpert(payload); - }, - [], - ); - - const deleteExpertCb = useCallback(async (name: string, scope: string) => { - return deleteExpert(name, scope); + // 组件卸载时清理 toast 计时器,避免在已卸载组件上调用 setState + useEffect(() => { + return () => { + if (toastTimerRef.current !== null) { + clearTimeout(toastTimerRef.current); + } + }; }, []); const handleClose = useCallback(() => { @@ -243,44 +90,7 @@ export function ConfigPage({ onClose, onSaveConnection, initialTab }: ConfigPage }); }, []); - // Fetch MCP status when MCP tab is selected - useEffect(() => { - if (activeTab === 'mcp') fetchMcpStatus(); - }, [activeTab, fetchMcpStatus]); - - // Fetch skill list when skills tab is selected - useEffect(() => { - if (activeTab === 'skills') fetchSkillList(); - }, [activeTab, fetchSkillList]); - - // Fetch subagent list when subagents tab is selected - useEffect(() => { - if (activeTab === 'subagents') fetchSubagentList(); - }, [activeTab, fetchSubagentList]); - - // Fetch skills + tools + model options when experts/subagents tab is selected (for capability CheckboxList & provider/model dropdowns) - useEffect(() => { - if (activeTab === 'experts' || activeTab === 'subagents') { - if (!skillList) fetchSkillList(); - if (!toolList) fetchToolList(); - if (!modelOptions) - listModelOptions().then((data) => { - if (data) setModelOptions(data); - }); - } - }, [activeTab, fetchSkillList, fetchToolList, skillList, toolList, modelOptions]); - - // experts tab 编辑专家时也需要子代理勾选列表,按需加载(subagents tab 由下方独立 useEffect 刷新) - useEffect(() => { - if (activeTab === 'experts' && !subagentList) fetchSubagentList(); - }, [activeTab, fetchSubagentList, subagentList]); - - // Fetch expert list when experts tab is selected - useEffect(() => { - if (activeTab === 'experts') fetchExpertList(); - }, [activeTab, fetchExpertList]); - - // ESC to close + // ESC 关闭(子模态框的 ESC 在 capture 阶段已 stopPropagation,不会触发此处) useEffect(() => { const h = (e: KeyboardEvent) => { if (e.key === 'Escape') handleClose(); @@ -289,23 +99,6 @@ export function ConfigPage({ onClose, onSaveConnection, initialTab }: ConfigPage return () => document.removeEventListener('keydown', h); }, [handleClose]); - // 子模态框打开时,ESC 键仅关闭子模态框(阻止冒泡到 ConfigPage 全局 ESC,避免关闭整个配置页) - // 必须放在所有条件 return 之前,否则 loading 首次渲染时不执行此 hook, - // config 加载后重新渲染才执行 → hooks 数量不一致 → React 崩溃 - useEffect(() => { - if (!editingExpert && !editingSubagent) return; - const handler = (e: KeyboardEvent) => { - if (e.key === 'Escape') { - e.stopPropagation(); - e.preventDefault(); - setEditingExpert(null); - setEditingSubagent(null); - } - }; - window.addEventListener('keydown', handler, true); - return () => window.removeEventListener('keydown', handler, true); - }, [editingExpert, editingSubagent]); - const update = useCallback((key: K, value: AppConfig[K]) => { setConfig((prev) => (prev ? { ...prev, [key]: value } : prev)); setDirty(true); @@ -322,7 +115,6 @@ export function ConfigPage({ onClose, onSaveConnection, initialTab }: ConfigPage // Config is now synced to both disk and in-memory state, // so the local state is already correct. No need to re-fetch. setDirty(false); - // Show restart confirmation dialog setShowRestartDialog(true); } setSaving(false); @@ -334,29 +126,25 @@ export function ConfigPage({ onClose, onSaveConnection, initialTab }: ConfigPage try { const { status, data } = await restartGateway(); if (status === 409) { - setToast(data.message || '有任务运行中,请等待完成后再试'); + showToast(data.message || '有任务运行中,请等待完成后再试'); setRestarting(false); - setTimeout(() => setToast(''), 5000); return; } if (status < 200 || status >= 300) throw new Error(data.message || '重启失败'); - setToast('服务正在重启,页面将自动重连...'); - // Poll /health until gateway is back + showToast('服务正在重启,页面将自动重连...'); const poll = async () => { for (let i = 0; i < 30; i++) { await new Promise((r) => setTimeout(r, 1000)); if (await checkHealth()) { const [refreshed] = await getAppConfig(); if (refreshed) setConfig(refreshed); - setToast('服务已重启,配置已生效'); + showToast('服务已重启,配置已生效'); setRestarting(false); - setTimeout(() => setToast(''), 3000); return; } } - setToast('重启超时,请手动刷新页面'); + showToast('重启超时,请手动刷新页面'); setRestarting(false); - setTimeout(() => setToast(''), 5000); }; poll(); } catch (e: unknown) { @@ -365,7 +153,6 @@ export function ConfigPage({ onClose, onSaveConnection, initialTab }: ConfigPage } }; - // ── Render sections ────────────────────────────────── if (loading) return (
@@ -389,2107 +176,39 @@ export function ConfigPage({ onClose, onSaveConnection, initialTab }: ConfigPage
); - 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('连接设置已保存,正在重连...'); - setTimeout(() => setToast(''), 3000); - }; - - const renderConnection = () => ( -
- - - { - setConnHost(e.target.value); - setConnError(''); - }} - className={inputCls} - placeholder="127.0.0.1" - /> - - - { - setConnPort(+e.target.value); - setConnError(''); - }} - min={1} - max={65535} - className={inputCls} - placeholder="19876" - /> - - {connError && ( -
- {connError} -
- )} -
- ws://{connHost.trim() || '...'}:{connPort || '...'}/ws -
-
- -
- ); - - const renderGateway = () => ( -
- - - update('gateway', { ...config.gateway, host: e.target.value })} - className={inputCls} - /> - - - update('gateway', { ...config.gateway, port: +e.target.value })} - className={inputCls} - /> - - - -
- 显示工具结果 - update('gateway', { ...config.gateway, show_tool_results: v })} - /> -
- - - update('gateway', { ...config.gateway, agent_prompt_reinject_every: +e.target.value }) - } - className={inputCls} - /> - - - - update('gateway', { ...config.gateway, max_concurrent_requests: +e.target.value }) - } - className={inputCls} - /> - - - { - const v = e.target.value; - update('gateway', { ...config.gateway, session_ttl_hours: v ? +v : undefined }); - }} - className={inputCls} - placeholder="24" - /> - -
-
- ); - - const renderProviders = () => { - const entries = Object.entries(config.providers); - const addProvider = () => { - const name = prompt('Provider 名称:')?.trim(); - if (name && !config.providers[name]) { - update('providers', { - ...config.providers, - [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}"?`)) { - const { [name]: _, ...rest } = config.providers; - update('providers', rest); - } - }; - const renameProvider = (oldName: string, newName: string) => { - if (newName === oldName || !newName) return; - const entries = Object.entries(config.providers); - const newMap: Record = {}; - for (const [k, v] of entries) { - newMap[k === oldName ? newName : k] = v; - } - update('providers', newMap); - }; - const updProvider = (name: string, patch: Partial) => { - update('providers', { ...config.providers, [name]: { ...config.providers[name], ...patch } }); - }; - return ( -
- {entries.map(([name, p]) => ( -
- delProvider(name)} - onRename={(n) => renameProvider(name, n)} - /> -
- - - - - updProvider(name, { base_url: e.target.value })} - className={inputCls} - /> - - - updProvider(name, { api_key: e.target.value })} - className={inputCls} - /> - - - updProvider(name, { llm_timeout_secs: +e.target.value })} - className={inputCls} - /> - - - - updProvider(name, { memory_maintenance_timeout_secs: +e.target.value }) - } - className={inputCls} - /> - - - updProvider(name, { max_retries: +e.target.value })} - className={inputCls} - /> - -
-
- ))} - -
- ); - }; - - const renderModels = () => { - const entries = Object.entries(config.models); - const addModel = () => { - const name = prompt('Model 名称:')?.trim(); - if (name && !config.models[name]) - update('models', { ...config.models, [name]: { model_id: name } }); - }; - const delModel = (name: string) => { - if (confirm(`删除 Model "${name}"?`)) { - const { [name]: _, ...rest } = config.models; - update('models', rest); - } - }; - const updModel = (name: string, patch: Partial) => - update('models', { ...config.models, [name]: { ...config.models[name], ...patch } }); - return ( -
- {entries.map(([name, m]) => ( -
- delModel(name)} /> -
- - updModel(name, { model_id: e.target.value })} - className={inputCls} - /> - - - - updModel(name, { temperature: e.target.value ? +e.target.value : undefined }) - } - className={inputCls} - placeholder="0.7" - /> - - - - updModel(name, { max_tokens: e.target.value ? +e.target.value : undefined }) - } - className={inputCls} - placeholder="4096" - /> - - - - updModel(name, { - context_window_tokens: e.target.value ? +e.target.value : undefined, - }) - } - className={inputCls} - placeholder="128000" - /> - -
-
- ))} - -
- ); - }; - - const renderAgents = () => { - const entries = Object.entries(config.agents); - const providerNames = Object.keys(config.providers); - const modelNames = Object.keys(config.models); - const addAgent = () => { - const name = prompt('Agent 名称:')?.trim(); - if (name && !config.agents[name]) - update('agents', { - ...config.agents, - [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}"?`)) { - const { [name]: _, ...rest } = config.agents; - update('agents', rest); - } - }; - const updAgent = (name: string, patch: Partial) => - update('agents', { ...config.agents, [name]: { ...config.agents[name], ...patch } }); - return ( -
- {entries.map(([name, a]) => ( -
- delAgent(name)} /> -
- - - - - - - - updAgent(name, { max_tool_iterations: +e.target.value })} - className={inputCls} - /> - - - updAgent(name, { tool_result_max_chars: +e.target.value })} - className={inputCls} - /> - - - - updAgent(name, { context_tool_result_trim_chars: +e.target.value }) - } - className={inputCls} - /> - -
-
- ))} - -
- ); - }; - - const renderTime = () => ( - - - - - - ); - - const renderScheduler = () => ( -
- -
- 启用调度器 - update('scheduler', { ...config.scheduler, enabled: v })} - /> -
- - - update('scheduler', { ...config.scheduler, tick_resolution_ms: +e.target.value }) - } - className={inputCls} - /> - - - - update('scheduler', { ...config.scheduler, worker_queue_capacity: +e.target.value }) - } - className={inputCls} - /> - - - - -
-
- ); - - 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' }, - ]; - - const SUBAGENT_KNOWN_SOURCES: KnownSource[] = [ - { key: 'user', label: '用户子代理', description: '~/.picobot/subagents' }, - { key: 'project', label: '项目子代理', description: '.picobot/subagents' }, - ]; - - const renderSkills = () => ( -
- -
- 启用技能 - update('skills', { ...config.skills, enabled: v })} - /> -
- - - update('skills', { ...config.skills, max_index_chars: +e.target.value }) - } - className={inputCls} - /> - - - - update('skills', { ...config.skills, max_listed_skills: +e.target.value }) - } - className={inputCls} - /> - -
- - update('skills', { ...config.skills, sources: v })} - knownSources={SKILL_KNOWN_SOURCES} - examplePaths={['D:\\my-skills', '/home/user/shared-skills']} - /> - - {renderDiscoveredSkills()} -
- ); - - const renderDiscoveredSkills = () => { - if (!skillList || !skillList.skills_system_enabled) return null; - - const skills = skillList.skills; - - const handleToggle = async (name: string, currentlyEnabled: boolean) => { - // Optimistic update - const prevSkillList = skillList; - setSkillList({ - ...skillList, - skills: skills.map((s) => - s.name === name ? { ...s, disabled_in_scopes: currentlyEnabled ? ['project'] : [] } : s, - ), - }); - - try { - const resp = await toggleSkillCb(name, 'project', !currentlyEnabled); - const data = await resp.json(); - if (!resp.ok || !data.success) { - // Rollback - setSkillList(prevSkillList); - setToast(data.error || '切换技能状态失败'); - setTimeout(() => setToast(''), 3000); - return; - } - // Update with server response - setSkillList({ - ...prevSkillList, - skills: prevSkillList.skills.map((s) => - s.name === name ? { ...s, disabled_in_scopes: data.disabled_in_scopes || [] } : s, - ), - }); - } catch { - // Rollback - setSkillList(prevSkillList); - setToast('网络错误,切换技能状态失败'); - setTimeout(() => setToast(''), 3000); - } - }; - - return ( - - {skillListLoading && skills.length === 0 ? ( -
- 加载中... -
- ) : skills.length === 0 ? ( -

未发现任何技能,请检查来源目录配置

- ) : ( -
- {skills.map((skill) => { - const isEnabled = skill.disabled_in_scopes.length === 0; - return ( -
-
-
- - {skill.name} - - - {skill.source} - -
-

- {skill.description} -

-
- handleToggle(skill.name, isEnabled)} - /> -
- ); - })} -
- )} -
- ); - }; - - 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: '发送会话消息' }, - ]; - - const renderTools = () => ( -
- - update('tools', { ...config.tools, disabled: v })} - /> - - -
- 启用 Task 工具 - - update('tools', { ...config.tools, task: { ...config.tools.task, enabled: v } }) - } - /> -
- - - update('tools', { - ...config.tools, - task: { ...config.tools.task, max_execution_secs: +e.target.value }, - }) - } - className={inputCls} - /> - - - - update('tools', { - ...config.tools, - task: { ...config.tools.task, ttl_hours: +e.target.value }, - }) - } - className={inputCls} - /> - - - - update('tools', { - ...config.tools, - task: { - ...config.tools.task, - max_nesting_depth: Math.max(0, +e.target.value || 0), - }, - }) - } - className={inputCls} - /> - -
- - - update('tools', { ...config.tools, task: { ...config.tools.task, allowed_tools: v } }) - } - knownSources={TASK_KNOWN_TOOLS} - showCustom={false} - /> - -
- ); - - const renderMemory = () => ( - - - - update('memory_maintenance', { - ...config.memory_maintenance, - max_merge_ratio: +e.target.value, - }) - } - className={inputCls} - /> - - - - update('memory_maintenance', { - ...config.memory_maintenance, - min_memories_to_keep: +e.target.value, - }) - } - className={inputCls} - /> - - - - update('memory_maintenance', { - ...config.memory_maintenance, - max_merge_per_group: +e.target.value, - }) - } - className={inputCls} - /> - - - ); - - const renderImage = () => ( - - - - update('image_context', { - ...config.image_context, - max_images_in_context: +e.target.value, - }) - } - className={inputCls} - /> - - - - update('image_context', { - ...config.image_context, - max_image_age_rounds: +e.target.value, - }) - } - className={inputCls} - /> - - - ); - - const renderSubagents = () => ( -
- -
- 启用子代理发现 - update('subagents', { ...config.subagents, enabled: v })} - /> -
-
- - update('subagents', { ...config.subagents, sources: v })} - knownSources={SUBAGENT_KNOWN_SOURCES} - examplePaths={['D:\\my-subagents', '/home/user/shared-agents']} - /> - - {renderDiscoveredSubagents()} - {renderSubagentModal()} -
- ); - - const renderDiscoveredSubagents = () => { - if (!subagentList || !subagentList.subagents_system_enabled) return null; - - const subagents = subagentList.subagents; - - const handleToggle = async (name: string, currentlyEnabled: boolean) => { - const prevList = subagentList; - setSubagentList({ - ...subagentList, - subagents: subagents.map((s) => - s.name === name ? { ...s, disabled_in_scopes: currentlyEnabled ? ['project'] : [] } : s, - ), - }); - - try { - const resp = await toggleSubagentCb(name, 'project', !currentlyEnabled); - const data = await resp.json(); - if (!resp.ok || !data.success) { - setSubagentList(prevList); - setToast(data.error || '切换子代理状态失败'); - setTimeout(() => setToast(''), 3000); - return; - } - setSubagentList({ - ...prevList, - subagents: prevList.subagents.map((s) => - s.name === name ? { ...s, disabled_in_scopes: data.disabled_in_scopes || [] } : s, - ), - }); - } catch { - setSubagentList(prevList); - setToast('网络错误,切换子代理状态失败'); - setTimeout(() => setToast(''), 3000); - } - }; - - const toolLabel = (key: string): string => { - const known = TASK_KNOWN_TOOLS.find((t) => t.key === key); - return known ? known.label : key; - }; - - const renderToolTags = (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 ( -
- {label}: - {tools.map((t) => ( - - {toolLabel(t)} - - ))} -
- ); - }; - - const handleEditSubagent = (subagent: SubagentItem) => { - setEditingSubagentError(''); - setEditingSubagent({ - name: subagent.name, - description: subagent.description, - 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 ?? [], - }); - }; - - return ( - - {subagentListLoading && subagents.length === 0 ? ( -
- 加载中... -
- ) : subagents.length === 0 ? ( -

未发现任何子代理

- ) : ( -
- {subagents.map((subagent) => { - const isEnabled = subagent.disabled_in_scopes.length === 0; - const isBuiltin = subagent.source === 'builtin'; - return ( -
-
-
- - {subagent.name} - - - {subagent.source} - -
-

- {subagent.description} -

- {renderToolTags('允许', subagent.capability?.allowed_tools, 'allow')} - {renderToolTags('禁用', subagent.capability?.denied_tools, 'deny')} -
- {!isBuiltin && ( - - )} - handleToggle(subagent.name, isEnabled)} - /> -
- ); - })} -
- )} -
- ); - }; - - const EXPERT_KNOWN_SOURCES: KnownSource[] = [ - { key: 'user', label: '用户专家', description: '~/.picobot/experts' }, - { key: 'project', label: '项目专家', description: '.picobot/experts' }, - ]; - - const renderExperts = () => ( -
- -
- 启用专家系统 - update('experts', { ...config.experts, enabled: v })} - /> -
-
- - update('experts', { ...config.experts, sources: v })} - knownSources={EXPERT_KNOWN_SOURCES} - examplePaths={['D:\\my-experts', '/home/user/shared-experts']} - /> - - {renderDiscoveredExperts()} - - {renderExpertModal()} -
- ); - - const renderDiscoveredExperts = () => { - if (!expertList || !expertList.experts_system_enabled) return null; - - const experts = expertList.experts; - - const handleToggle = async (name: string, currentlyEnabled: boolean) => { - const prevList = expertList; - setExpertList({ - ...expertList, - experts: experts.map((e) => - e.name === name ? { ...e, disabled_in_scopes: currentlyEnabled ? ['project'] : [] } : e, - ), - }); - - try { - const resp = await toggleExpertCb(name, 'project', !currentlyEnabled); - const data = await resp.json(); - if (!resp.ok || !data.success) { - setExpertList(prevList); - setToast(data.error || '切换专家状态失败'); - setTimeout(() => setToast(''), 3000); - return; - } - setExpertList({ - ...prevList, - experts: prevList.experts.map((e) => - e.name === name ? { ...e, disabled_in_scopes: data.disabled_in_scopes || [] } : e, - ), - }); - } catch { - setExpertList(prevList); - setToast('网络错误,切换专家状态失败'); - setTimeout(() => setToast(''), 3000); - } - }; - - const handleEdit = (expert: ExpertItem) => { - setEditingExpertError(''); - setEditingExpert({ - 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 ?? [], - }); - }; - - const handleDelete = async (name: string) => { - if (!confirm(`确定删除专家 "${name}" 吗?此操作将删除对应文件。`)) return; - try { - const resp = await deleteExpertCb(name, 'project'); - const data = await resp.json(); - if (!resp.ok || !data.success) { - setToast(data.error || '删除专家失败'); - setTimeout(() => setToast(''), 3000); - return; - } - setToast('专家已删除'); - setTimeout(() => setToast(''), 3000); - fetchExpertList(); - } catch { - setToast('网络错误,删除专家失败'); - setTimeout(() => setToast(''), 3000); - } - }; - - return ( - - {expertListLoading && experts.length === 0 ? ( -
- 加载中... -
- ) : experts.length === 0 ? ( -

未发现任何专家

- ) : ( -
- {experts.map((expert) => { - const isEnabled = expert.disabled_in_scopes.length === 0; - return ( -
-
-
- - {expert.name} - - - {expert.source} - -
-

- {expert.description} -

-
- - - handleToggle(expert.name, isEnabled)} - /> -
- ); - })} -
- )} -
- ); - }; - - // 技能/工具勾选选项(专家与子代理编辑模态框共用) - const skillOptions = (skillList?.skills ?? []).map((s) => ({ - key: s.name, - label: s.name, - description: s.description, - group: s.source, - })); - const toolOptions = (toolList?.tools ?? []).map((t) => ({ - key: t.name, - label: t.name, - description: t.description, - group: t.source, - })); - const subagentOptions = (subagentList?.subagents ?? []).map((s) => ({ - key: s.name, - label: s.name, - description: s.description, - group: s.source, - })); - const skillEmptyHint = skillListLoading - ? '加载中...' - : '未发现任何技能,请先在技能页配置来源目录'; - const toolEmptyHint = toolListLoading ? '加载中...' : '未发现任何工具'; - const subagentEmptyHint = subagentListLoading ? '加载中...' : '未发现任何子代理'; - - const renderExpertModal = () => { - if (!editingExpert) return null; - const isEdit = editingExpert.mode === 'edit'; - const canSave = editingExpert.nameField.trim() && editingExpert.description.trim(); - - const handleSave = async () => { - if (!canSave) return; - setSavingExpert(true); - setEditingExpertError(''); - try { - // allowed_* 为空时必须传 undefined(后端 None=不限), - // 否则空数组会被反序列化为 Some(vec![]) 触发白名单空集语义(全禁)。 - // denied_* 为 Vec,空数组即"不禁",可直接传。 - const capability: CapabilityPolicy = { - allowed_skills: - editingExpert.allowedSkills.length > 0 ? editingExpert.allowedSkills : undefined, - denied_skills: editingExpert.deniedSkills, - allowed_tools: - editingExpert.allowedTools.length > 0 ? editingExpert.allowedTools : undefined, - denied_tools: editingExpert.deniedTools, - allowed_subagents: - editingExpert.allowedSubagents.length > 0 ? editingExpert.allowedSubagents : undefined, - denied_subagents: editingExpert.deniedSubagents, - }; - const payload = { - name: editingExpert.nameField, - description: editingExpert.description, - body: editingExpert.body, - capability, - provider: editingExpert.provider || undefined, - model: editingExpert.model || undefined, - }; - const resp = isEdit - ? await updateExpertCb({ ...payload, scope: 'project' }) - : await createExpertCb({ ...payload, scope: 'project' }); - const data = await resp.json().catch(() => ({})); - if (!resp.ok) { - setEditingExpertError(data.error || data.message || '保存失败'); - setSavingExpert(false); - return; - } - setToast(isEdit ? '专家已更新' : '专家已创建'); - setTimeout(() => setToast(''), 3000); - setEditingExpert(null); - fetchExpertList(); - } catch (e: unknown) { - setEditingExpertError(e instanceof Error ? e.message : '网络错误'); - } finally { - setSavingExpert(false); - } - }; - - return ( -
setEditingExpert(null)} - > -
e.stopPropagation()} - > - } - title={isEdit ? '编辑专家' : '添加专家'} - onClose={() => setEditingExpert(null)} - /> -
- - - - setEditingExpert((prev) => - prev ? { ...prev, nameField: e.target.value } : prev, - ) - } - disabled={isEdit} - placeholder="如 translator" - className={inputCls + (isEdit ? ' opacity-60 cursor-not-allowed' : '')} - autoFocus={!isEdit} - /> - - - - setEditingExpert((prev) => - prev ? { ...prev, description: e.target.value } : prev, - ) - } - placeholder="如 翻译专家" - className={inputCls} - /> - - -