feat: move default db to config dir, add skill/mcp enable toggles

- Store default SQLite database at <config_dir>/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 <config_dir>/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
This commit is contained in:
xiaoxixi 2026-08-13 21:58:20 +08:00
parent 7de7a40de7
commit 38d92d5883
20 changed files with 405 additions and 31 deletions

View File

@ -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 - **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 - **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` - **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 `<config_dir>/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 - **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 - **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 ### Key Constraints
- Gateway **changes working directory** to workspace in `GatewayState::new` (`src/gateway/mod.rs`) - 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 `<config_dir>/data/picobot.db` by default (`config_dir` is `~/.picobot`), independent of the workspace
- `ChannelManager` owns the `MessageBus` and all channel instances - `ChannelManager` owns the `MessageBus` and all channel instances
- `OutboundDispatcher` routes outbound messages to the correct channel via `ChannelManager` - `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 - 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

View File

@ -1,6 +1,6 @@
[package] [package]
name = "picobot" name = "picobot"
version = "1.13.1" version = "1.15.0"
edition = "2024" edition = "2024"
[dependencies] [dependencies]

View File

@ -90,7 +90,7 @@ Gateway 首次启动时会把模板释放到 `~/.picobot/config.example.json`。
cargo run -- gateway 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` 监听地址可通过配置文件或命令行覆盖。命令行参数优先于 `config.json`

View File

@ -212,7 +212,7 @@ SessionManager 负责组装会话上下文系统提示、Skills、召回的 K
## 6. 持久化 ## 6. 持久化
`Storage` 使用 SQLx + SQLite默认数据库为 `{workspace_dir}/picobot.db`。连接启用: `Storage` 使用 SQLx + SQLite默认数据库为 `{config_dir}/data/picobot.db``config_dir` 默认 `~/.picobot`),与 workspace 相互独立。连接启用:
- WAL journal mode。 - WAL journal mode。
- foreign keys。 - foreign keys。

View File

@ -322,7 +322,7 @@ WebUI `PUT /api/config` 只负责原子写文件、恢复被掩码的 secret 并
- 候选配置允许 Provider/模型等运行时字段变化。 - 候选配置允许 Provider/模型等运行时字段变化。
- 相对 `workspace_dir` 按启动 cwd 正确解析。 - 相对 `workspace_dir` 按启动 cwd 正确解析。
- workspace 变化被拒绝且返回明确错误。 - workspace 变化被拒绝且返回明确错误。
- `None` `./picobot.db` 指向同一有效数据库路径时允许重载。 - `None`显式指向同一有效数据库路径(默认 `{config_dir}/data/picobot.db`时允许重载。
- admission 关闭后拒绝新工作,并等待现有 activity guard 释放。 - admission 关闭后拒绝新工作,并等待现有 activity guard 释放。
- command output 在 dispatcher 明确确认投递前不会释放处理任务。 - command output 在 dispatcher 明确确认投递前不会释放处理任务。
- 真实子进程 Gateway 可完成 generation 2 切换;无效候选返回 400 且旧代 `/health` 继续可用。 - 真实子进程 Gateway 可完成 generation 2 切换;无效候选返回 400 且旧代 `/health` 继续可用。

View File

@ -59,7 +59,7 @@ Scheduler → SessionManager.handle_cron_message → AgentLoop → send_message
## 关键约束 ## 关键约束
- Gateway 启动时切换到 workspace 目录 - Gateway 启动时切换到 workspace 目录
- SQLite 数据在 `{workspace}/picobot.db` - SQLite 数据在 `{config_dir}/data/picobot.db``config_dir` 默认 `~/.picobot`),与 workspace 相互独立
- ChannelManager 持有 MessageBus 和所有 channel - ChannelManager 持有 MessageBus 和所有 channel
- OutboundDispatcher 通过 ChannelManager 路由出站消息 - OutboundDispatcher 通过 ChannelManager 路由出站消息
- 配置目录 `.env` 与 workspace `.env` 仅在单线程启动阶段分层加载,并使用 `unsafe { env::set_var(...) }` 写入进程环境;优先级为既有进程环境 > workspace > 配置目录 - 配置目录 `.env` 与 workspace `.env` 仅在单线程启动阶段分层加载,并使用 `unsafe { env::set_var(...) }` 写入进程环境;优先级为既有进程环境 > workspace > 配置目录

View File

@ -80,7 +80,7 @@ Gateway WebUI 的“配置”页可以编辑实际加载的配置文件。读取
| `port` | int | 19876 | 监听端口 | | `port` | int | 19876 | 监听端口 |
| `require_pairing` | bool | true | 是否要求 WebUI 与 CLI 设备先使用一次性代码配对 | | `require_pairing` | bool | true | 是否要求 WebUI 与 CLI 设备先使用一次性代码配对 |
| `session_ttl_hours` | int | - | 兼容/预留字段;当前没有会话 TTL 清理循环 | | `session_ttl_hours` | int | - | 兼容/预留字段;当前没有会话 TTL 清理循环 |
| `session_db_path` | string | - | SQLite 数据库路径,默认在 workspace 下 | | `session_db_path` | string | - | SQLite 数据库路径,默认在配置目录 `data/` 下 |
| `cleanup_interval_minutes` | int | - | 兼容/预留字段;当前没有按此间隔运行的 session 清理任务 | | `cleanup_interval_minutes` | int | - | 兼容/预留字段;当前没有按此间隔运行的 session 清理任务 |
| `scheduler` | object | - | 调度器配置 | | `scheduler` | object | - | 调度器配置 |
@ -139,6 +139,7 @@ MCP 服务器单条配置:
| 字段 | 说明 | | 字段 | 说明 |
|------|------| |------|------|
| `name` | 服务器名称 | | `name` | 服务器名称 |
| `enabled` | 是否启用,默认 true关闭后启动/重载时不连接该服务器 |
| `transport` | 传输方式: `stdio``sse``streamable-http` | | `transport` | 传输方式: `stdio``sse``streamable-http` |
| `command` | 启动命令stdio 模式) | | `command` | 启动命令stdio 模式) |
| `args` | 命令参数 | | `args` | 命令参数 |

View File

@ -1,6 +1,6 @@
# PicoBot 数据库表结构 # 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 会拒绝启动。 连接启用 WAL、`synchronous=NORMAL`、foreign keys、5 秒 busy timeout连接池最多 8 个连接。当前 `PRAGMA user_version=8`;启动时会在事务内补齐旧库字段和索引,遇到比程序更新的 schema version 会拒绝启动。

View File

@ -34,7 +34,11 @@ docker compose exec picobot picobot pair --gateway-url http://127.0.0.1:19876
## Q: 数据库文件在哪里? ## 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: 如何查看历史会话? ## Q: 如何查看历史会话?

View File

@ -17,6 +17,13 @@ pub fn get_default_workspace_dir() -> PathBuf {
get_user_config_dir().join("workspace") get_user_config_dir().join("workspace")
} }
/// Get the default session database path (`<config_dir>/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 /// Expand ~ in path to user home directory
pub fn expand_path(path: &str) -> PathBuf { pub fn expand_path(path: &str) -> PathBuf {
if let Some(path) = path.strip_prefix("~/") { if let Some(path) = path.strip_prefix("~/") {
@ -528,6 +535,8 @@ impl Default for McpConfig {
#[derive(Debug, Clone, Deserialize, Serialize)] #[derive(Debug, Clone, Deserialize, Serialize)]
pub struct McpServerConfig { pub struct McpServerConfig {
pub name: String, pub name: String,
#[serde(default = "default_true")]
pub enabled: bool,
#[serde(default = "default_mcp_transport")] #[serde(default = "default_mcp_transport")]
pub transport: McpTransport, pub transport: McpTransport,
#[serde(default)] #[serde(default)]

View File

@ -791,8 +791,17 @@ pub async fn get_status(State(state): State<Arc<GatewayState>>) -> Result<Json<V
.map(|status| { .map(|status| {
json!({ json!({
"name": status.name, "name": status.name,
"transport": status.transport,
"connected": status.connected, "connected": status.connected,
"tools": status.tools.len(), "error": status.error,
"tools": status
.tools
.iter()
.map(|tool| json!({
"name": tool.name,
"description": tool.description,
}))
.collect::<Vec<_>>(),
}) })
}) })
.collect::<Vec<_>>(); .collect::<Vec<_>>();
@ -1129,6 +1138,44 @@ pub async fn get_skills(State(state): State<Arc<GatewayState>>) -> Result<Json<V
"name": s.name, "name": s.name,
"description": s.description, "description": s.description,
"always": s.always, "always": s.always,
"enabled": loader.is_enabled(&s.name),
"source": loader.source_of(s.path.as_deref()),
})
})
.collect();
Ok(Json(json!({ "skills": skills })))
}
#[derive(Deserialize)]
pub struct SkillEnableUpdate {
enabled: bool,
}
/// Enable or disable a skill. The change is persisted to the skills state
/// file and takes effect immediately (disabled skills are excluded from
/// prompts, listings, and `get_skill`).
pub async fn put_skill_enabled(
State(state): State<Arc<GatewayState>>,
Path(name): Path<String>,
Json(update): Json<SkillEnableUpdate>,
) -> Result<Json<Value>, 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<Value> = 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()), "source": loader.source_of(s.path.as_deref()),
}) })
}) })

View File

@ -123,8 +123,17 @@ impl GatewayState {
let db_path = if let Some(ref path) = config.gateway.session_db_path { let db_path = if let Some(ref path) = config.gateway.session_db_path {
std::path::PathBuf::from(path) std::path::PathBuf::from(path)
} else { } 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( let storage = Arc::new(
crate::storage::Storage::new(&db_path) crate::storage::Storage::new(&db_path)
.await .await
@ -683,6 +692,10 @@ fn build_router(state: Arc<GatewayState>) -> Router {
.route("/api/status", routing::get(http::get_status)) .route("/api/status", routing::get(http::get_status))
.route("/api/tools", routing::get(http::get_tools)) .route("/api/tools", routing::get(http::get_tools))
.route("/api/skills", routing::get(http::get_skills)) .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", routing::get(http::get_jobs))
.route("/api/jobs/{id}/runs", routing::get(http::get_job_runs)) .route("/api/jobs/{id}/runs", routing::get(http::get_job_runs))
.route( .route(

View File

@ -319,7 +319,7 @@ fn effective_db_path(config: &Config, workspace: &Path) -> PathBuf {
.session_db_path .session_db_path
.as_deref() .as_deref()
.map(crate::config::expand_path) .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() { let path = if path.is_relative() {
workspace.join(path) workspace.join(path)
} else { } else {
@ -395,18 +395,22 @@ mod tests {
} }
#[test] #[test]
fn equivalent_default_database_paths_are_reloadable() { fn default_database_path_equivalence_is_reloadable() {
let temp = tempfile::tempdir().unwrap(); let temp = tempfile::tempdir().unwrap();
let workspace = temp.path().join("workspace"); let workspace = temp.path().join("workspace");
std::fs::create_dir_all(&workspace).unwrap(); std::fs::create_dir_all(&workspace).unwrap();
std::fs::write(workspace.join("picobot.db"), []).unwrap();
let config_path = temp.path().join("config.json"); let config_path = temp.path().join("config.json");
let current: Config = serde_json::from_str(&config_json(&workspace, "old-model")).unwrap(); 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 = let mut candidate: serde_json::Value =
serde_json::from_str(&config_json(&workspace, "new-model")).unwrap(); 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(); std::fs::write(&config_path, serde_json::to_vec(&candidate).unwrap()).unwrap();
load_candidate( load_candidate(
&config_path, &config_path,
&HashMap::new(), &HashMap::new(),
@ -415,6 +419,28 @@ mod tests {
&workspace, &workspace,
) )
.unwrap(); .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(),
&current,
&workspace,
)
.unwrap_err();
assert!(
error
.to_string()
.contains("session_db_path cannot be reloaded")
);
} }
#[tokio::test] #[tokio::test]

View File

@ -145,6 +145,9 @@ impl HealthService {
let mut seen = HashSet::new(); let mut seen = HashSet::new();
let mut checks = Vec::new(); let mut checks = Vec::new();
for server in &self.config.mcp.servers { for server in &self.config.mcp.servers {
if !server.enabled {
continue;
}
if !matches!(server.transport, McpTransport::Stdio) { if !matches!(server.transport, McpTransport::Stdio) {
continue; continue;
} }

View File

@ -129,6 +129,14 @@ pub async fn connect_all(config: &McpConfig) -> Vec<ToolInfo> {
let mut server_statuses = Vec::new(); let mut server_statuses = Vec::new();
for server_config in &config.servers { 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 { let transport_str = match server_config.transport {
McpTransport::Stdio => "stdio", McpTransport::Stdio => "stdio",
McpTransport::Sse => "sse", McpTransport::Sse => "sse",

View File

@ -4,6 +4,7 @@ mod embedded {
include!(concat!(env!("OUT_DIR"), "/embedded_skills.rs")); include!(concat!(env!("OUT_DIR"), "/embedded_skills.rs"));
} }
use std::collections::HashSet;
use std::path::{Path, PathBuf}; use std::path::{Path, PathBuf};
use std::sync::{Arc, Mutex}; use std::sync::{Arc, Mutex};
use std::time::SystemTime; use std::time::SystemTime;
@ -28,9 +29,13 @@ struct SkillMarkdownMeta {
#[derive(Clone)] #[derive(Clone)]
struct SkillsState { struct SkillsState {
loaded_skills: Vec<Skill>, loaded_skills: Vec<Skill>,
/// Skill names explicitly disabled by the user; everything else is
/// enabled by default.
disabled_skills: HashSet<String>,
last_picobot_mtime: Option<SystemTime>, last_picobot_mtime: Option<SystemTime>,
last_agent_mtime: Option<SystemTime>, last_agent_mtime: Option<SystemTime>,
last_workspace_mtime: Option<SystemTime>, last_workspace_mtime: Option<SystemTime>,
last_state_mtime: Option<SystemTime>,
last_load_time: SystemTime, last_load_time: SystemTime,
} }
@ -38,9 +43,11 @@ impl Default for SkillsState {
fn default() -> Self { fn default() -> Self {
Self { Self {
loaded_skills: Vec::new(), loaded_skills: Vec::new(),
disabled_skills: HashSet::new(),
last_picobot_mtime: None, last_picobot_mtime: None,
last_agent_mtime: None, last_agent_mtime: None,
last_workspace_mtime: None, last_workspace_mtime: None,
last_state_mtime: None,
last_load_time: SystemTime::now(), last_load_time: SystemTime::now(),
} }
} }
@ -52,6 +59,9 @@ pub struct SkillsLoader {
picobot_skills_dir: PathBuf, picobot_skills_dir: PathBuf,
agent_skills_dir: PathBuf, agent_skills_dir: PathBuf,
workspace_skills_dir: Option<PathBuf>, workspace_skills_dir: Option<PathBuf>,
/// Path of the JSON state file recording user-disabled skills
/// (`<config_dir>/skills_state.json`).
state_path: PathBuf,
state: Arc<Mutex<SkillsState>>, state: Arc<Mutex<SkillsState>>,
} }
@ -60,6 +70,10 @@ impl SkillsLoader {
pub fn new() -> Self { pub fn new() -> Self {
let home = dirs::home_dir().unwrap_or_else(|| PathBuf::from(".")); let home = dirs::home_dir().unwrap_or_else(|| PathBuf::from("."));
let picobot_skills_dir = home.join(".picobot/skills"); 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); builtin::install_builtin_skills(&picobot_skills_dir);
@ -67,16 +81,22 @@ impl SkillsLoader {
picobot_skills_dir, picobot_skills_dir,
agent_skills_dir: home.join(".agents/skills"), agent_skills_dir: home.join(".agents/skills"),
workspace_skills_dir: None, workspace_skills_dir: None,
state_path,
state: Arc::new(Mutex::new(SkillsState::default())), state: Arc::new(Mutex::new(SkillsState::default())),
} }
} }
#[cfg(test)] #[cfg(test)]
pub(crate) fn new_for_testing(picobot_dir: PathBuf, agent_dir: PathBuf) -> Self { 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 { Self {
picobot_skills_dir: picobot_dir, picobot_skills_dir: picobot_dir,
agent_skills_dir: agent_dir, agent_skills_dir: agent_dir,
workspace_skills_dir: None, workspace_skills_dir: None,
state_path,
state: Arc::new(Mutex::new(SkillsState::default())), state: Arc::new(Mutex::new(SkillsState::default())),
} }
} }
@ -89,7 +109,9 @@ impl SkillsLoader {
/// Load all skills from all directories and record modification times. /// Load all skills from all directories and record modification times.
/// Priority: workspace > ~/.picobot/skills > ~/.agents/skills. /// Priority: workspace > ~/.picobot/skills > ~/.agents/skills.
/// Same-name skills from higher-priority directories replace lower-priority ones. /// 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) { pub fn load_skills(&self) {
self.load_state();
let mut state = self.state.lock().unwrap(); let mut state = self.state.lock().unwrap();
state.loaded_skills.clear(); state.loaded_skills.clear();
@ -170,6 +192,11 @@ impl SkillsLoader {
state.last_load_time = SystemTime::now(); 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() { if state.loaded_skills.is_empty() {
tracing::debug!("No skills found in any skills directory"); tracing::debug!("No skills found in any skills directory");
} else { } else {
@ -209,7 +236,9 @@ impl SkillsLoader {
false 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 /// Reload skills if changes are detected
@ -248,6 +277,78 @@ impl SkillsLoader {
max_mtime max_mtime
} }
/// Get the modification time of a single file (missing file -> None).
fn get_file_mtime(path: &Path) -> Option<SystemTime> {
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::<serde_json::Value>(&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<String> = {
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 { pub fn source_of(&self, path: Option<&Path>) -> &'static str {
let Some(path) = path else { let Some(path) = path else {
return "unknown"; return "unknown";
@ -654,4 +755,42 @@ This is the content.
"other" "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"));
}
} }

View File

@ -1,12 +1,12 @@
{ {
"name": "picobot-webui", "name": "picobot-webui",
"version": "1.13.1", "version": "1.15.0",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "picobot-webui", "name": "picobot-webui",
"version": "1.13.1", "version": "1.15.0",
"dependencies": { "dependencies": {
"bits-ui": "^2.0.0", "bits-ui": "^2.0.0",
"dompurify": "^3.4.12", "dompurify": "^3.4.12",

View File

@ -1,7 +1,7 @@
{ {
"name": "picobot-webui", "name": "picobot-webui",
"private": true, "private": true,
"version": "1.13.1", "version": "1.15.0",
"type": "module", "type": "module",
"engines": { "engines": {
"node": ">=20" "node": ">=20"

View File

@ -152,7 +152,7 @@
{:else if current === "tasks"}<TasksPage /> {:else if current === "tasks"}<TasksPage />
{:else if current === "settings"}<SettingsPage notify={(text, error) => toast.show(text, error)} /> {:else if current === "settings"}<SettingsPage notify={(text, error) => toast.show(text, error)} />
{:else if current === "overview"}<OverviewPage /> {:else if current === "overview"}<OverviewPage />
{:else if current === "tools"}<ToolsPage /> {:else if current === "tools"}<ToolsPage notify={(text, error) => toast.show(text, error)} />
{:else if current === "agents"}<AgentsPage /> {:else if current === "agents"}<AgentsPage />
{:else}<div class="empty-card">即将上线</div>{/if} {:else}<div class="empty-card">即将上线</div>{/if}
</main> </main>

View File

@ -4,11 +4,14 @@
import { api } from "../lib/api.js"; import { api } from "../lib/api.js";
import Icon from "../lib/Icon.svelte"; import Icon from "../lib/Icon.svelte";
let { notify } = $props();
let tab = $state("tools"); let tab = $state("tools");
let tools = $state([]); let tools = $state([]);
let skills = $state([]); let skills = $state([]);
let servers = $state([]); let servers = $state([]);
let config = $state(null);
let loading = $state(true); let loading = $state(true);
let saving = $state(false);
let error = $state(""); let error = $state("");
let query = $state(""); let query = $state("");
let filter = $state("all"); let filter = $state("all");
@ -40,6 +43,28 @@
const connected = $derived(servers.filter((server) => server.connected).length); 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) { function schema(tool) {
return tool.parameters_schema ? JSON.stringify(tool.parameters_schema, null, 2) : "{}"; return tool.parameters_schema ? JSON.stringify(tool.parameters_schema, null, 2) : "{}";
} }
@ -48,14 +73,16 @@
loading = true; loading = true;
error = ""; error = "";
try { try {
const [toolRes, skillRes, status] = await Promise.all([ const [toolRes, skillRes, status, cfg] = await Promise.all([
api("/api/tools"), api("/api/tools"),
api("/api/skills"), api("/api/skills"),
api("/api/status") api("/api/status"),
api("/api/config")
]); ]);
tools = toolRes.tools || []; tools = toolRes.tools || [];
skills = skillRes.skills || []; skills = skillRes.skills || [];
servers = status.mcp || []; servers = status.mcp || [];
config = cfg.config || null;
} catch (caught) { } catch (caught) {
error = caught.message; error = caught.message;
} finally { } 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) { function changeTab(value) {
tab = value; tab = value;
} }
@ -137,7 +204,18 @@
<p>{skill.description}</p> <p>{skill.description}</p>
<div class="meta"><span>{skill.source}</span></div> <div class="meta"><span>{skill.source}</span></div>
</div> </div>
{#if skill.always}<span class="badge ok">always</span>{/if} <div class="card-actions">
{#if skill.always}<span class="badge ok">always</span>{/if}
<button
class="switch"
data-state={skill.enabled ? "checked" : "unchecked"}
aria-label={skill.enabled ? `禁用 ${skill.name}` : `启用 ${skill.name}`}
onclick={() => toggleSkill(skill)}
disabled={saving}
>
<span class="switch-thumb" data-state={skill.enabled ? "checked" : "unchecked"}></span>
</button>
</div>
</div> </div>
</article> </article>
{:else} {:else}
@ -146,20 +224,56 @@
</div> </div>
{:else} {:else}
<div class="metrics"> <div class="metrics">
<div class="metric"><b>{servers.length}</b><small>MCP 服务器</small></div> <div class="metric"><b>{mcpList.length}</b><small>MCP 服务器</small></div>
<div class="metric"><b>{connected}</b><small>已连接</small></div> <div class="metric"><b>{mcpConnected}</b><small>已连接</small></div>
<div class="metric"><b>{servers.reduce((sum, server) => sum + (server.tools || 0), 0)}</b><small>工具总数</small></div> <div class="metric"><b>{mcpToolCount}</b><small>工具总数</small></div>
</div> </div>
<div class="cards"> <div class="cards">
{#each servers as server (server.name)} {#each mcpList as server (server.name)}
<article class="card"> <article class="card" class:disabled={!server.enabled}>
<div class="card-row"> <div class="card-row">
<div> <div>
<h3 class="mono">{server.name}</h3> <h3 class="mono">{server.name}</h3>
<div class="meta"><span>{server.tools || 0} 个工具</span></div> <div class="meta">
<span>{server.transport}</span>
<span>{server.tools?.length || 0} 个工具</span>
</div>
</div>
<div class="card-actions">
{#if !server.enabled}
<span class="badge fail">已禁用</span>
{:else if server.connected}
<span class="badge ok">已连接</span>
{:else}
<span class="badge fail">未连接</span>
{/if}
<button
class="switch"
data-state={server.enabled ? "checked" : "unchecked"}
aria-label={server.enabled ? `禁用 ${server.name}` : `启用 ${server.name}`}
onclick={() => toggleMcp(server)}
disabled={saving}
>
<span class="switch-thumb" data-state={server.enabled ? "checked" : "unchecked"}></span>
</button>
</div> </div>
<span class="badge" class:ok={server.connected} class:fail={!server.connected}>{server.connected ? "已连接" : "未连接"}</span>
</div> </div>
{#if server.error}<div class="mcp-error">{server.error}</div>{/if}
{#if server.tools?.length}
<details class="schema-wrap">
<summary>{server.tools.length} 个工具</summary>
<ul class="mcp-tools">
{#each server.tools as tool (tool.name)}
<li>
<code>{tool.name}</code>
{#if tool.description}<span>{tool.description}</span>{/if}
</li>
{/each}
</ul>
</details>
{:else if server.enabled}
<p class="mcp-empty">连接后无可用工具</p>
{/if}
</article> </article>
{:else} {:else}
<div class="empty-card">暂无 MCP 服务器</div> <div class="empty-card">暂无 MCP 服务器</div>
@ -177,6 +291,14 @@
.schema-wrap { margin-top: 12px; border-top: 1px solid var(--line); padding-top: 10px; } .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 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; } .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) { @media (max-width: 800px) {
.tool-grid { grid-template-columns: 1fr; } .tool-grid { grid-template-columns: 1fr; }
} }