From 38d92d5883b94ae91734d474f4cc4bdcbfb5e2b4 Mon Sep 17 00:00:00 2001 From: xiaoxixi Date: Thu, 13 Aug 2026 21:58:20 +0800 Subject: [PATCH] feat: move default db to config dir, add skill/mcp enable toggles - Store default SQLite database at /data/picobot.db instead of the workspace, keeping session data independent of the workspace; the reload equivalence check and docs follow the new default - Add per-skill enable/disable persisted in /skills_state.json; skills default to enabled, disabled skills are excluded from prompts, listings, and get_skill at load time - Add mcp.servers[].enabled (default true); disabled servers are skipped at activation and by health checks - WebUI Tools page: switches for Skills and MCP servers, plus a concrete MCP tool list with connection status and errors - Add PUT /api/skills/{name} API and expose enabled/tool details in the skills/status APIs - Bump version to 1.15.0 --- AGENTS.md | 4 +- Cargo.toml | 2 +- README.md | 2 +- docs/ARCHITECTURE.md | 2 +- docs/CONFIG_HOT_RELOAD_DESIGN.md | 2 +- .../about-picobot/references/architecture.md | 2 +- .../skills/about-picobot/references/config.md | 3 +- .../about-picobot/references/db-schema.md | 2 +- .../skills/about-picobot/references/faq.md | 6 +- src/config/mod.rs | 9 ++ src/gateway/http.rs | 49 +++++- src/gateway/mod.rs | 15 +- src/gateway/reload.rs | 36 ++++- src/health.rs | 3 + src/mcp/mod.rs | 8 + src/skills/mod.rs | 141 ++++++++++++++++- webui/package-lock.json | 4 +- webui/package.json | 2 +- webui/src/App.svelte | 2 +- webui/src/pages/ToolsPage.svelte | 142 ++++++++++++++++-- 20 files changed, 405 insertions(+), 31 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 7196988..fa23188 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -108,6 +108,8 @@ Scheduler → SessionManager scheduled execution → AgentLoop → Scheduler del - **Tools** are executed by `AgentLoop`; every invocation is normalized to `ToolOutput` and passes through `ToolOutputProcessor`. Plain `ToolResult` implementations use the default conversion, while artifact-producing tools declare model/user audience explicitly; model capability checks, final-reply attachment, channel delivery, and Provider serialization stay outside tools - **Delegated tool access**: a named Agent's tool set is decided solely by its definition file (admin-authored). `delegate`, `emit_signal`, `get_skill` and `agent_task` are runtime-injected and must never be declared in `tools` (`get_skill` is the scoped-skill switch); `allowed_tools` can only narrow the definition, never expand it - **No foreground wait tool**: Agents wait for asynchronous work by ending the Turn and letting queued completions/signals open a continuation Turn, or by polling status tools; there is no model-callable `sleep`/wait tool. Cancelling a Turn must still normalize active tool blocks to `Cancelled` +- **Skill enable/disable**: skills default to enabled; user-disabled skill names are persisted in `/skills_state.json` (skill files are never modified), and disabled skills are excluded from prompts, listings, and `get_skill` at load time +- **MCP enable/disable**: `mcp.servers[].enabled` defaults to true; disabled servers are skipped at activation (no connection attempt) and by health checks - **Stateful tools** receive `ToolExecutionContext`; browser calls without `persistent_id` map each PicoBot dialog to an opaque transient agent-browser session. For long-running work an Agent may autonomously create a persistent identity and must pass its validated ID on every related action; the same ID shares one agent-browser session and serialization gate across dialogs, while different IDs have independent sessions/gates. `browser_profiles` may create IDs, persist bounded semantic labels, list, or delete only validated IDs beneath the configured profile root. Do not introduce a global persistence mode switch, a default persistent ID, automatic per-dialog persistent Profiles, Fantoccini, ChromeDriver, WebDriver, model-controlled raw agent-browser session IDs, or arbitrary Profile paths - **Health diagnostics** are read-only and share `HealthService` across `picobot health`, the `health` tool, and `/health`; checks must not install/fix dependencies, call Provider APIs, or expose secrets @@ -131,7 +133,7 @@ Scheduler → SessionManager scheduled execution → AgentLoop → Scheduler del ### Key Constraints - Gateway **changes working directory** to workspace in `GatewayState::new` (`src/gateway/mod.rs`) -- Session/message persistence uses SQLite via `sqlx`; DB stored in workspace as `picobot.db` by default +- Session/message persistence uses SQLite via `sqlx`; DB stored in `/data/picobot.db` by default (`config_dir` is `~/.picobot`), independent of the workspace - `ChannelManager` owns the `MessageBus` and all channel instances - `OutboundDispatcher` routes outbound messages to the correct channel via `ChannelManager` - Layered config/workspace `.env` loading uses `unsafe { env::set_var(...) }` during single-threaded startup — don't move it after Gateway tasks are spawned or refactor it without understanding process-wide side effects diff --git a/Cargo.toml b/Cargo.toml index 87799a3..2469764 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "picobot" -version = "1.13.1" +version = "1.15.0" edition = "2024" [dependencies] diff --git a/README.md b/README.md index ca4ce1e..61cb34d 100644 --- a/README.md +++ b/README.md @@ -90,7 +90,7 @@ Gateway 首次启动时会把模板释放到 `~/.picobot/config.example.json`。 cargo run -- gateway ``` -默认监听 `127.0.0.1:19876`。Gateway 启动后会把进程工作目录切到 `workspace_dir`,默认 SQLite 数据库也会写到该 workspace 下的 `picobot.db`。 +默认监听 `127.0.0.1:19876`。Gateway 启动后会把进程工作目录切到 `workspace_dir`,默认 SQLite 数据库写到配置目录(`~/.picobot`)`data/` 下的 `picobot.db`,与 workspace 相互独立。 监听地址可通过配置文件或命令行覆盖。命令行参数优先于 `config.json`: diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 2adf2c9..7d19868 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -212,7 +212,7 @@ SessionManager 负责组装会话上下文:系统提示、Skills、召回的 K ## 6. 持久化 -`Storage` 使用 SQLx + SQLite,默认数据库为 `{workspace_dir}/picobot.db`。连接启用: +`Storage` 使用 SQLx + SQLite,默认数据库为 `{config_dir}/data/picobot.db`(`config_dir` 默认 `~/.picobot`),与 workspace 相互独立。连接启用: - WAL journal mode。 - foreign keys。 diff --git a/docs/CONFIG_HOT_RELOAD_DESIGN.md b/docs/CONFIG_HOT_RELOAD_DESIGN.md index 31526f8..3a39d73 100644 --- a/docs/CONFIG_HOT_RELOAD_DESIGN.md +++ b/docs/CONFIG_HOT_RELOAD_DESIGN.md @@ -322,7 +322,7 @@ WebUI `PUT /api/config` 只负责原子写文件、恢复被掩码的 secret 并 - 候选配置允许 Provider/模型等运行时字段变化。 - 相对 `workspace_dir` 按启动 cwd 正确解析。 - workspace 变化被拒绝且返回明确错误。 -- `None` 与 `./picobot.db` 指向同一有效数据库路径时允许重载。 +- `None` 与显式指向同一有效数据库路径(默认 `{config_dir}/data/picobot.db`)时允许重载。 - admission 关闭后拒绝新工作,并等待现有 activity guard 释放。 - command output 在 dispatcher 明确确认投递前不会释放处理任务。 - 真实子进程 Gateway 可完成 generation 2 切换;无效候选返回 400 且旧代 `/health` 继续可用。 diff --git a/resources/skills/about-picobot/references/architecture.md b/resources/skills/about-picobot/references/architecture.md index 9b58189..6ec2b97 100644 --- a/resources/skills/about-picobot/references/architecture.md +++ b/resources/skills/about-picobot/references/architecture.md @@ -59,7 +59,7 @@ Scheduler → SessionManager.handle_cron_message → AgentLoop → send_message ## 关键约束 - Gateway 启动时切换到 workspace 目录 -- SQLite 数据在 `{workspace}/picobot.db` +- SQLite 数据在 `{config_dir}/data/picobot.db`(`config_dir` 默认 `~/.picobot`),与 workspace 相互独立 - ChannelManager 持有 MessageBus 和所有 channel - OutboundDispatcher 通过 ChannelManager 路由出站消息 - 配置目录 `.env` 与 workspace `.env` 仅在单线程启动阶段分层加载,并使用 `unsafe { env::set_var(...) }` 写入进程环境;优先级为既有进程环境 > workspace > 配置目录 diff --git a/resources/skills/about-picobot/references/config.md b/resources/skills/about-picobot/references/config.md index f7700b0..1591ca1 100644 --- a/resources/skills/about-picobot/references/config.md +++ b/resources/skills/about-picobot/references/config.md @@ -80,7 +80,7 @@ Gateway WebUI 的“配置”页可以编辑实际加载的配置文件。读取 | `port` | int | 19876 | 监听端口 | | `require_pairing` | bool | true | 是否要求 WebUI 与 CLI 设备先使用一次性代码配对 | | `session_ttl_hours` | int | - | 兼容/预留字段;当前没有会话 TTL 清理循环 | -| `session_db_path` | string | - | SQLite 数据库路径,默认在 workspace 下 | +| `session_db_path` | string | - | SQLite 数据库路径,默认在配置目录 `data/` 下 | | `cleanup_interval_minutes` | int | - | 兼容/预留字段;当前没有按此间隔运行的 session 清理任务 | | `scheduler` | object | - | 调度器配置 | @@ -139,6 +139,7 @@ MCP 服务器单条配置: | 字段 | 说明 | |------|------| | `name` | 服务器名称 | +| `enabled` | 是否启用,默认 true;关闭后启动/重载时不连接该服务器 | | `transport` | 传输方式: `stdio`、`sse`、`streamable-http` | | `command` | 启动命令(stdio 模式) | | `args` | 命令参数 | diff --git a/resources/skills/about-picobot/references/db-schema.md b/resources/skills/about-picobot/references/db-schema.md index a9a7969..c0ef89e 100644 --- a/resources/skills/about-picobot/references/db-schema.md +++ b/resources/skills/about-picobot/references/db-schema.md @@ -1,6 +1,6 @@ # PicoBot 数据库表结构 -数据库为 SQLite,默认位于 workspace 下的 `picobot.db`。 +数据库为 SQLite,默认位于配置目录(`~/.picobot`)`data/` 下的 `picobot.db`,与 workspace 相互独立。 连接启用 WAL、`synchronous=NORMAL`、foreign keys、5 秒 busy timeout,连接池最多 8 个连接。当前 `PRAGMA user_version=8`;启动时会在事务内补齐旧库字段和索引,遇到比程序更新的 schema version 会拒绝启动。 diff --git a/resources/skills/about-picobot/references/faq.md b/resources/skills/about-picobot/references/faq.md index ccfa29e..b7a1368 100644 --- a/resources/skills/about-picobot/references/faq.md +++ b/resources/skills/about-picobot/references/faq.md @@ -34,7 +34,11 @@ docker compose exec picobot picobot pair --gateway-url http://127.0.0.1:19876 ## Q: 数据库文件在哪里? -默认 `{workspace}/picobot.db`,workspace 默认 `~/.picobot/workspace/`。 +默认 `{config_dir}/data/picobot.db`,`config_dir` 默认 `~/.picobot`,与 workspace 相互独立。 + +## Q: 如何禁用某个 skill 或 MCP 服务器? + +Skill 安装后默认启用,可在 WebUI「工具 → Skills」页用开关禁用;禁用状态记录在 `~/.picobot/skills_state.json`,被禁用的 skill 不再进入提示词、列表和 `get_skill`。MCP 服务器在配置 `mcp.servers[].enabled`(默认 true)中控制,可在 WebUI「工具 → MCP」页开关;关闭后下次启动/重载时不连接该服务器。 ## Q: 如何查看历史会话? diff --git a/src/config/mod.rs b/src/config/mod.rs index da62ffb..58d438a 100644 --- a/src/config/mod.rs +++ b/src/config/mod.rs @@ -17,6 +17,13 @@ pub fn get_default_workspace_dir() -> PathBuf { get_user_config_dir().join("workspace") } +/// Get the default session database path (`/data/picobot.db`). +/// Kept under the user config directory rather than the workspace so session +/// data is independent of the workspace directory. +pub fn get_default_db_path() -> PathBuf { + get_user_config_dir().join("data").join("picobot.db") +} + /// Expand ~ in path to user home directory pub fn expand_path(path: &str) -> PathBuf { if let Some(path) = path.strip_prefix("~/") { @@ -528,6 +535,8 @@ impl Default for McpConfig { #[derive(Debug, Clone, Deserialize, Serialize)] pub struct McpServerConfig { pub name: String, + #[serde(default = "default_true")] + pub enabled: bool, #[serde(default = "default_mcp_transport")] pub transport: McpTransport, #[serde(default)] diff --git a/src/gateway/http.rs b/src/gateway/http.rs index d46e23c..efda05e 100644 --- a/src/gateway/http.rs +++ b/src/gateway/http.rs @@ -791,8 +791,17 @@ pub async fn get_status(State(state): State>) -> Result>(), }) }) .collect::>(); @@ -1129,6 +1138,44 @@ pub async fn get_skills(State(state): State>) -> Result>, + Path(name): Path, + Json(update): Json, +) -> Result, ApiError> { + let loader = state.session_manager.skills_loader(); + let known = loader.get_loaded_skills().iter().any(|s| s.name == name); + if !known { + return Err(ApiError::not_found(format!("skill {name} not found"))); + } + loader + .set_enabled(&name, update.enabled) + .map_err(ApiError::bad_request)?; + let skills: Vec = loader + .get_loaded_skills() + .iter() + .map(|s| { + json!({ + "name": s.name, + "description": s.description, + "always": s.always, + "enabled": loader.is_enabled(&s.name), "source": loader.source_of(s.path.as_deref()), }) }) diff --git a/src/gateway/mod.rs b/src/gateway/mod.rs index 037abf5..4259431 100644 --- a/src/gateway/mod.rs +++ b/src/gateway/mod.rs @@ -123,8 +123,17 @@ impl GatewayState { let db_path = if let Some(ref path) = config.gateway.session_db_path { std::path::PathBuf::from(path) } else { - workspace_path.join("picobot.db") + crate::config::get_default_db_path() }; + if let Some(parent) = db_path.parent() { + std::fs::create_dir_all(parent).map_err(|e| { + format!( + "Failed to create database directory {}: {}", + parent.display(), + e + ) + })?; + } let storage = Arc::new( crate::storage::Storage::new(&db_path) .await @@ -683,6 +692,10 @@ fn build_router(state: Arc) -> Router { .route("/api/status", routing::get(http::get_status)) .route("/api/tools", routing::get(http::get_tools)) .route("/api/skills", routing::get(http::get_skills)) + .route( + "/api/skills/{name}", + routing::put(http::put_skill_enabled), + ) .route("/api/jobs", routing::get(http::get_jobs)) .route("/api/jobs/{id}/runs", routing::get(http::get_job_runs)) .route( diff --git a/src/gateway/reload.rs b/src/gateway/reload.rs index b6c5a44..41ae6f6 100644 --- a/src/gateway/reload.rs +++ b/src/gateway/reload.rs @@ -319,7 +319,7 @@ fn effective_db_path(config: &Config, workspace: &Path) -> PathBuf { .session_db_path .as_deref() .map(crate::config::expand_path) - .unwrap_or_else(|| workspace.join("picobot.db")); + .unwrap_or_else(crate::config::get_default_db_path); let path = if path.is_relative() { workspace.join(path) } else { @@ -395,18 +395,22 @@ mod tests { } #[test] - fn equivalent_default_database_paths_are_reloadable() { + fn default_database_path_equivalence_is_reloadable() { let temp = tempfile::tempdir().unwrap(); let workspace = temp.path().join("workspace"); std::fs::create_dir_all(&workspace).unwrap(); - std::fs::write(workspace.join("picobot.db"), []).unwrap(); let config_path = temp.path().join("config.json"); let current: Config = serde_json::from_str(&config_json(&workspace, "old-model")).unwrap(); + + // The implicit default (no session_db_path) is the config-dir data + // path; an explicit path resolving to the same file stays reloadable. + let default_db = crate::config::get_default_db_path(); let mut candidate: serde_json::Value = serde_json::from_str(&config_json(&workspace, "new-model")).unwrap(); - candidate["gateway"] = serde_json::json!({ "session_db_path": "./picobot.db" }); + candidate["gateway"] = serde_json::json!({ + "session_db_path": default_db.to_string_lossy() + }); std::fs::write(&config_path, serde_json::to_vec(&candidate).unwrap()).unwrap(); - load_candidate( &config_path, &HashMap::new(), @@ -415,6 +419,28 @@ mod tests { &workspace, ) .unwrap(); + + // A different explicit path (e.g. the old workspace location) must be + // rejected as restart-only to protect the shared database identity. + let mut candidate: serde_json::Value = + serde_json::from_str(&config_json(&workspace, "new-model")).unwrap(); + candidate["gateway"] = serde_json::json!({ + "session_db_path": workspace.join("picobot.db").to_string_lossy() + }); + std::fs::write(&config_path, serde_json::to_vec(&candidate).unwrap()).unwrap(); + let error = load_candidate( + &config_path, + &HashMap::new(), + temp.path(), + ¤t, + &workspace, + ) + .unwrap_err(); + assert!( + error + .to_string() + .contains("session_db_path cannot be reloaded") + ); } #[tokio::test] diff --git a/src/health.rs b/src/health.rs index 28f3ce0..7197a7e 100644 --- a/src/health.rs +++ b/src/health.rs @@ -145,6 +145,9 @@ impl HealthService { let mut seen = HashSet::new(); let mut checks = Vec::new(); for server in &self.config.mcp.servers { + if !server.enabled { + continue; + } if !matches!(server.transport, McpTransport::Stdio) { continue; } diff --git a/src/mcp/mod.rs b/src/mcp/mod.rs index a699b16..26c77e1 100644 --- a/src/mcp/mod.rs +++ b/src/mcp/mod.rs @@ -129,6 +129,14 @@ pub async fn connect_all(config: &McpConfig) -> Vec { let mut server_statuses = Vec::new(); for server_config in &config.servers { + if !server_config.enabled { + tracing::info!( + server = %server_config.name, + "MCP server disabled by config, skipping" + ); + continue; + } + let transport_str = match server_config.transport { McpTransport::Stdio => "stdio", McpTransport::Sse => "sse", diff --git a/src/skills/mod.rs b/src/skills/mod.rs index 2d0f2d5..ea7f544 100644 --- a/src/skills/mod.rs +++ b/src/skills/mod.rs @@ -4,6 +4,7 @@ mod embedded { include!(concat!(env!("OUT_DIR"), "/embedded_skills.rs")); } +use std::collections::HashSet; use std::path::{Path, PathBuf}; use std::sync::{Arc, Mutex}; use std::time::SystemTime; @@ -28,9 +29,13 @@ struct SkillMarkdownMeta { #[derive(Clone)] struct SkillsState { loaded_skills: Vec, + /// Skill names explicitly disabled by the user; everything else is + /// enabled by default. + disabled_skills: HashSet, last_picobot_mtime: Option, last_agent_mtime: Option, last_workspace_mtime: Option, + last_state_mtime: Option, last_load_time: SystemTime, } @@ -38,9 +43,11 @@ impl Default for SkillsState { fn default() -> Self { Self { loaded_skills: Vec::new(), + disabled_skills: HashSet::new(), last_picobot_mtime: None, last_agent_mtime: None, last_workspace_mtime: None, + last_state_mtime: None, last_load_time: SystemTime::now(), } } @@ -52,6 +59,9 @@ pub struct SkillsLoader { picobot_skills_dir: PathBuf, agent_skills_dir: PathBuf, workspace_skills_dir: Option, + /// Path of the JSON state file recording user-disabled skills + /// (`/skills_state.json`). + state_path: PathBuf, state: Arc>, } @@ -60,6 +70,10 @@ impl SkillsLoader { pub fn new() -> Self { let home = dirs::home_dir().unwrap_or_else(|| PathBuf::from(".")); let picobot_skills_dir = home.join(".picobot/skills"); + let state_path = picobot_skills_dir + .parent() + .unwrap_or(home.as_path()) + .join("skills_state.json"); builtin::install_builtin_skills(&picobot_skills_dir); @@ -67,16 +81,22 @@ impl SkillsLoader { picobot_skills_dir, agent_skills_dir: home.join(".agents/skills"), workspace_skills_dir: None, + state_path, state: Arc::new(Mutex::new(SkillsState::default())), } } #[cfg(test)] pub(crate) fn new_for_testing(picobot_dir: PathBuf, agent_dir: PathBuf) -> Self { + let state_path = picobot_dir + .parent() + .unwrap_or(picobot_dir.as_path()) + .join("skills_state.json"); Self { picobot_skills_dir: picobot_dir, agent_skills_dir: agent_dir, workspace_skills_dir: None, + state_path, state: Arc::new(Mutex::new(SkillsState::default())), } } @@ -89,7 +109,9 @@ impl SkillsLoader { /// Load all skills from all directories and record modification times. /// Priority: workspace > ~/.picobot/skills > ~/.agents/skills. /// Same-name skills from higher-priority directories replace lower-priority ones. + /// User-disabled skills (see `set_enabled`) are excluded from the result. pub fn load_skills(&self) { + self.load_state(); let mut state = self.state.lock().unwrap(); state.loaded_skills.clear(); @@ -170,6 +192,11 @@ impl SkillsLoader { state.last_load_time = SystemTime::now(); + let disabled_skills = state.disabled_skills.clone(); + state + .loaded_skills + .retain(|skill| !disabled_skills.contains(&skill.name)); + if state.loaded_skills.is_empty() { tracing::debug!("No skills found in any skills directory"); } else { @@ -209,7 +236,9 @@ impl SkillsLoader { false }; - picobot_changed || agent_changed || workspace_changed + let state_changed = Self::get_file_mtime(&self.state_path) != state.last_state_mtime; + + picobot_changed || agent_changed || workspace_changed || state_changed } /// Reload skills if changes are detected @@ -248,6 +277,78 @@ impl SkillsLoader { max_mtime } + /// Get the modification time of a single file (missing file -> None). + fn get_file_mtime(path: &Path) -> Option { + std::fs::metadata(path).and_then(|metadata| metadata.modified()).ok() + } + + /// Read the disabled-skills state file into `state.disabled_skills`. + /// A missing or malformed file resets to "everything enabled". + fn load_state(&self) { + let mut state = self.state.lock().unwrap(); + match std::fs::read_to_string(&self.state_path) { + Ok(content) => { + let mut disabled = HashSet::new(); + if let Ok(value) = serde_json::from_str::(&content) + && let Some(items) = value.get("disabled").and_then(|v| v.as_array()) + { + disabled = items + .iter() + .filter_map(serde_json::Value::as_str) + .map(str::to_string) + .collect(); + } + state.disabled_skills = disabled; + } + Err(_) => { + state.disabled_skills.clear(); + } + } + state.last_state_mtime = Self::get_file_mtime(&self.state_path); + } + + /// Persist the disabled-skills state atomically. + fn save_state(&self) -> Result<(), String> { + let mut disabled: Vec = { + let state = self.state.lock().unwrap(); + state.disabled_skills.iter().cloned().collect() + }; + disabled.sort(); + let content = serde_json::json!({ "disabled": disabled }).to_string(); + + let parent = self + .state_path + .parent() + .unwrap_or_else(|| Path::new(".")); + std::fs::create_dir_all(parent).map_err(|e| format!("create state dir: {e}"))?; + let temp = parent.join(".skills_state.json.tmp"); + std::fs::write(&temp, &content).map_err(|e| format!("write state: {e}"))?; + std::fs::rename(&temp, &self.state_path).map_err(|e| format!("rename state: {e}")) + } + + /// Whether a skill is enabled. Skills are enabled by default; only names + /// explicitly disabled by the user return false. + pub fn is_enabled(&self, name: &str) -> bool { + let state = self.state.lock().unwrap(); + !state.disabled_skills.contains(name) + } + + /// Enable or disable a skill and persist the change. Disabled skills are + /// excluded from prompts, listings, and `get_skill`. + pub fn set_enabled(&self, name: &str, enabled: bool) -> Result<(), String> { + { + let mut state = self.state.lock().unwrap(); + if enabled { + state.disabled_skills.remove(name); + } else { + state.disabled_skills.insert(name.to_string()); + } + } + self.save_state()?; + self.load_skills(); + Ok(()) + } + pub fn source_of(&self, path: Option<&Path>) -> &'static str { let Some(path) = path else { return "unknown"; @@ -654,4 +755,42 @@ This is the content. "other" ); } + + #[test] + fn test_set_enabled_persists_and_filters() { + let temp = tempfile::tempdir().unwrap(); + let picobot_dir = temp.path().join("picobot"); + let agent_dir = temp.path().join("agents"); + for name in ["alpha", "beta"] { + let skill_dir = picobot_dir.join(name); + std::fs::create_dir_all(&skill_dir).unwrap(); + std::fs::write( + skill_dir.join("SKILL.md"), + format!("---\nname: {name}\ndescription: {name}\n---\ncontent"), + ) + .unwrap(); + } + + let loader = SkillsLoader::new_for_testing(picobot_dir.clone(), agent_dir.clone()); + loader.load_skills(); + assert_eq!(loader.get_loaded_skills().len(), 2); + assert!(loader.is_enabled("beta")); + + loader.set_enabled("beta", false).unwrap(); + let loaded = loader.get_loaded_skills(); + assert_eq!(loaded.len(), 1); + assert_eq!(loaded[0].name, "alpha"); + assert!(!loader.is_enabled("beta")); + assert!(loader.is_enabled("alpha")); + + // State survives a fresh loader instance reading the same state file. + let loader2 = SkillsLoader::new_for_testing(picobot_dir, agent_dir); + loader2.load_skills(); + assert_eq!(loader2.get_loaded_skills().len(), 1); + assert!(!loader2.is_enabled("beta")); + + loader2.set_enabled("beta", true).unwrap(); + assert_eq!(loader2.get_loaded_skills().len(), 2); + assert!(loader2.is_enabled("beta")); + } } diff --git a/webui/package-lock.json b/webui/package-lock.json index 06a363e..73a7453 100644 --- a/webui/package-lock.json +++ b/webui/package-lock.json @@ -1,12 +1,12 @@ { "name": "picobot-webui", - "version": "1.13.1", + "version": "1.15.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "picobot-webui", - "version": "1.13.1", + "version": "1.15.0", "dependencies": { "bits-ui": "^2.0.0", "dompurify": "^3.4.12", diff --git a/webui/package.json b/webui/package.json index e5be8c7..e79b2b5 100644 --- a/webui/package.json +++ b/webui/package.json @@ -1,7 +1,7 @@ { "name": "picobot-webui", "private": true, - "version": "1.13.1", + "version": "1.15.0", "type": "module", "engines": { "node": ">=20" diff --git a/webui/src/App.svelte b/webui/src/App.svelte index ab5fc9a..bf09bba 100644 --- a/webui/src/App.svelte +++ b/webui/src/App.svelte @@ -152,7 +152,7 @@ {:else if current === "tasks"} {:else if current === "settings"} toast.show(text, error)} /> {:else if current === "overview"} - {:else if current === "tools"} + {:else if current === "tools"} toast.show(text, error)} /> {:else if current === "agents"} {:else}
即将上线
{/if} diff --git a/webui/src/pages/ToolsPage.svelte b/webui/src/pages/ToolsPage.svelte index cb3935c..49bbe92 100644 --- a/webui/src/pages/ToolsPage.svelte +++ b/webui/src/pages/ToolsPage.svelte @@ -4,11 +4,14 @@ import { api } from "../lib/api.js"; import Icon from "../lib/Icon.svelte"; + let { notify } = $props(); let tab = $state("tools"); let tools = $state([]); let skills = $state([]); let servers = $state([]); + let config = $state(null); let loading = $state(true); + let saving = $state(false); let error = $state(""); let query = $state(""); let filter = $state("all"); @@ -40,6 +43,28 @@ const connected = $derived(servers.filter((server) => server.connected).length); + // Merge MCP servers from the loaded config (source of truth, includes + // disabled servers) with live connection status by name. + const mcpList = $derived.by(() => { + const byName = new Map(servers.map((server) => [server.name, server])); + return (config?.mcp?.servers || []).map((server) => { + const status = byName.get(server.name); + return { + name: server.name, + transport: server.transport || "stdio", + enabled: server.enabled !== false, + connected: status?.connected ?? false, + error: status?.error || "", + tools: status?.tools || [] + }; + }); + }); + + const mcpConnected = $derived(mcpList.filter((server) => server.connected).length); + const mcpToolCount = $derived( + mcpList.reduce((sum, server) => sum + (server.tools?.length || 0), 0) + ); + function schema(tool) { return tool.parameters_schema ? JSON.stringify(tool.parameters_schema, null, 2) : "{}"; } @@ -48,14 +73,16 @@ loading = true; error = ""; try { - const [toolRes, skillRes, status] = await Promise.all([ + const [toolRes, skillRes, status, cfg] = await Promise.all([ api("/api/tools"), api("/api/skills"), - api("/api/status") + api("/api/status"), + api("/api/config") ]); tools = toolRes.tools || []; skills = skillRes.skills || []; servers = status.mcp || []; + config = cfg.config || null; } catch (caught) { error = caught.message; } finally { @@ -63,6 +90,46 @@ } } + async function toggleSkill(skill) { + saving = true; + try { + const result = await api(`/api/skills/${encodeURIComponent(skill.name)}`, { + method: "PUT", + body: JSON.stringify({ enabled: !skill.enabled }) + }); + skills = result.skills || skills; + notify(skill.enabled ? `已禁用 Skill「${skill.name}」` : `已启用 Skill「${skill.name}」`); + } catch (caught) { + notify(caught.message, true); + } finally { + saving = false; + } + } + + async function toggleMcp(server) { + if (!config?.mcp?.servers) return; + saving = true; + try { + const next = JSON.parse(JSON.stringify(config)); + const target = next.mcp.servers.find((item) => item.name === server.name); + if (!target) return; + target.enabled = !server.enabled; + const result = await api("/api/config", { method: "PUT", body: JSON.stringify({ config: next }) }); + config = result.config || next; + await api("/api/config/reload", { method: "POST" }); + notify( + server.enabled + ? `已禁用 MCP「${server.name}」,重载后生效` + : `已启用 MCP「${server.name}」,重载后生效` + ); + setTimeout(load, 1500); + } catch (caught) { + notify(caught.message, true); + } finally { + saving = false; + } + } + function changeTab(value) { tab = value; } @@ -137,7 +204,18 @@

{skill.description}

{skill.source}
- {#if skill.always}always{/if} +
+ {#if skill.always}always{/if} + +
{:else} @@ -146,20 +224,56 @@ {:else}
-
{servers.length}MCP 服务器
-
{connected}已连接
-
{servers.reduce((sum, server) => sum + (server.tools || 0), 0)}工具总数
+
{mcpList.length}MCP 服务器
+
{mcpConnected}已连接
+
{mcpToolCount}工具总数
- {#each servers as server (server.name)} -
+ {#each mcpList as server (server.name)} +

{server.name}

-
{server.tools || 0} 个工具
+
+ {server.transport} + {server.tools?.length || 0} 个工具 +
+
+
+ {#if !server.enabled} + 已禁用 + {:else if server.connected} + 已连接 + {:else} + 未连接 + {/if} +
- {server.connected ? "已连接" : "未连接"}
+ {#if server.error}
{server.error}
{/if} + {#if server.tools?.length} +
+ {server.tools.length} 个工具 +
    + {#each server.tools as tool (tool.name)} +
  • + {tool.name} + {#if tool.description}{tool.description}{/if} +
  • + {/each} +
+
+ {:else if server.enabled} +

连接后无可用工具

+ {/if}
{:else}
暂无 MCP 服务器
@@ -177,6 +291,14 @@ .schema-wrap { margin-top: 12px; border-top: 1px solid var(--line); padding-top: 10px; } .schema-wrap summary { cursor: pointer; color: var(--muted); font-size: 12px; } .schema-wrap pre { max-height: 300px; margin: 8px 0 0; padding: 10px 11px; overflow: auto; border: 1px solid var(--line); border-radius: 8px; color: var(--text-soft); background: var(--code-bg); font-size: 12px; line-height: 1.6; white-space: pre-wrap; overflow-wrap: anywhere; } + .card-actions { display: flex; align-items: center; gap: 10px; } + .card.disabled { opacity: 0.65; } + .mcp-tools { display: grid; gap: 7px; margin: 10px 0 0; padding: 0; list-style: none; } + .mcp-tools li { display: flex; flex-direction: column; gap: 2px; padding: 8px 10px; border: 1px solid var(--line); border-radius: 8px; } + .mcp-tools code { font: 600 12px/1.5 var(--font-mono); color: var(--text); } + .mcp-tools span { font-size: 12px; color: var(--muted); line-height: 1.5; } + .mcp-error { margin-top: 10px; padding: 8px 10px; border-radius: 8px; background: var(--danger-soft); color: var(--danger); font-size: 12px; line-height: 1.5; word-break: break-all; } + .mcp-empty { margin: 10px 0 0; color: var(--muted); font-size: 12px; } @media (max-width: 800px) { .tool-grid { grid-template-columns: 1fr; } }