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:
parent
7de7a40de7
commit
38d92d5883
@ -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 `<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
|
||||
- **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 `<config_dir>/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
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "picobot"
|
||||
version = "1.13.1"
|
||||
version = "1.15.0"
|
||||
edition = "2024"
|
||||
|
||||
[dependencies]
|
||||
|
||||
@ -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`:
|
||||
|
||||
|
||||
@ -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。
|
||||
|
||||
@ -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` 继续可用。
|
||||
|
||||
@ -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 > 配置目录
|
||||
|
||||
@ -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` | 命令参数 |
|
||||
|
||||
@ -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 会拒绝启动。
|
||||
|
||||
|
||||
@ -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: 如何查看历史会话?
|
||||
|
||||
|
||||
@ -17,6 +17,13 @@ pub fn get_default_workspace_dir() -> PathBuf {
|
||||
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
|
||||
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)]
|
||||
|
||||
@ -791,8 +791,17 @@ pub async fn get_status(State(state): State<Arc<GatewayState>>) -> Result<Json<V
|
||||
.map(|status| {
|
||||
json!({
|
||||
"name": status.name,
|
||||
"transport": status.transport,
|
||||
"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<_>>();
|
||||
@ -1129,6 +1138,44 @@ pub async fn get_skills(State(state): State<Arc<GatewayState>>) -> Result<Json<V
|
||||
"name": s.name,
|
||||
"description": s.description,
|
||||
"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()),
|
||||
})
|
||||
})
|
||||
|
||||
@ -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<GatewayState>) -> 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(
|
||||
|
||||
@ -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]
|
||||
|
||||
@ -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;
|
||||
}
|
||||
|
||||
@ -129,6 +129,14 @@ pub async fn connect_all(config: &McpConfig) -> Vec<ToolInfo> {
|
||||
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",
|
||||
|
||||
@ -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>,
|
||||
/// Skill names explicitly disabled by the user; everything else is
|
||||
/// enabled by default.
|
||||
disabled_skills: HashSet<String>,
|
||||
last_picobot_mtime: Option<SystemTime>,
|
||||
last_agent_mtime: Option<SystemTime>,
|
||||
last_workspace_mtime: Option<SystemTime>,
|
||||
last_state_mtime: Option<SystemTime>,
|
||||
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<PathBuf>,
|
||||
/// Path of the JSON state file recording user-disabled skills
|
||||
/// (`<config_dir>/skills_state.json`).
|
||||
state_path: PathBuf,
|
||||
state: Arc<Mutex<SkillsState>>,
|
||||
}
|
||||
|
||||
@ -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<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 {
|
||||
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"));
|
||||
}
|
||||
}
|
||||
|
||||
4
webui/package-lock.json
generated
4
webui/package-lock.json
generated
@ -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",
|
||||
|
||||
@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "picobot-webui",
|
||||
"private": true,
|
||||
"version": "1.13.1",
|
||||
"version": "1.15.0",
|
||||
"type": "module",
|
||||
"engines": {
|
||||
"node": ">=20"
|
||||
|
||||
@ -152,7 +152,7 @@
|
||||
{:else if current === "tasks"}<TasksPage />
|
||||
{:else if current === "settings"}<SettingsPage notify={(text, error) => toast.show(text, error)} />
|
||||
{: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}<div class="empty-card">即将上线</div>{/if}
|
||||
</main>
|
||||
|
||||
@ -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 @@
|
||||
<p>{skill.description}</p>
|
||||
<div class="meta"><span>{skill.source}</span></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>
|
||||
</article>
|
||||
{:else}
|
||||
@ -146,20 +224,56 @@
|
||||
</div>
|
||||
{:else}
|
||||
<div class="metrics">
|
||||
<div class="metric"><b>{servers.length}</b><small>MCP 服务器</small></div>
|
||||
<div class="metric"><b>{connected}</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>{mcpList.length}</b><small>MCP 服务器</small></div>
|
||||
<div class="metric"><b>{mcpConnected}</b><small>已连接</small></div>
|
||||
<div class="metric"><b>{mcpToolCount}</b><small>工具总数</small></div>
|
||||
</div>
|
||||
<div class="cards">
|
||||
{#each servers as server (server.name)}
|
||||
<article class="card">
|
||||
{#each mcpList as server (server.name)}
|
||||
<article class="card" class:disabled={!server.enabled}>
|
||||
<div class="card-row">
|
||||
<div>
|
||||
<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>
|
||||
<span class="badge" class:ok={server.connected} class:fail={!server.connected}>{server.connected ? "已连接" : "未连接"}</span>
|
||||
</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>
|
||||
{:else}
|
||||
<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 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; }
|
||||
}
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user