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
|
- **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
|
||||||
|
|||||||
@ -1,6 +1,6 @@
|
|||||||
[package]
|
[package]
|
||||||
name = "picobot"
|
name = "picobot"
|
||||||
version = "1.13.1"
|
version = "1.15.0"
|
||||||
edition = "2024"
|
edition = "2024"
|
||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
|
|||||||
@ -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`:
|
||||||
|
|
||||||
|
|||||||
@ -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。
|
||||||
|
|||||||
@ -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` 继续可用。
|
||||||
|
|||||||
@ -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 > 配置目录
|
||||||
|
|||||||
@ -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` | 命令参数 |
|
||||||
|
|||||||
@ -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 会拒绝启动。
|
||||||
|
|
||||||
|
|||||||
@ -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: 如何查看历史会话?
|
||||||
|
|
||||||
|
|||||||
@ -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)]
|
||||||
|
|||||||
@ -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()),
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@ -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(
|
||||||
|
|||||||
@ -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(),
|
||||||
|
¤t,
|
||||||
|
&workspace,
|
||||||
|
)
|
||||||
|
.unwrap_err();
|
||||||
|
assert!(
|
||||||
|
error
|
||||||
|
.to_string()
|
||||||
|
.contains("session_db_path cannot be reloaded")
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
|
|||||||
@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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",
|
||||||
|
|||||||
@ -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"));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
4
webui/package-lock.json
generated
4
webui/package-lock.json
generated
@ -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",
|
||||||
|
|||||||
@ -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"
|
||||||
|
|||||||
@ -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>
|
||||||
|
|||||||
@ -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>
|
||||||
|
<div class="card-actions">
|
||||||
{#if skill.always}<span class="badge ok">always</span>{/if}
|
{#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>
|
||||||
<span class="badge" class:ok={server.connected} class:fail={!server.connected}>{server.connected ? "已连接" : "未连接"}</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>
|
||||||
|
{#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; }
|
||||||
}
|
}
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user