插件系统审计与补全计划
状态:设计草案 日期:2026-06-02 最近更新:2026-06-02 目标:审计 plugin.yaml 各字段的运行时实际利用率,补全空壳功能,新增 WebUI 插件管理面板。 相关文档:
1. 审计摘要
对 plugin.yaml 的 13 个字段和 PermissionChecker 的 8 个方法进行了端到端追踪。结论:插件系统骨架完整,但约 38% 的 manifest 字段和 38% 的权限检查方法属于"解析入库但无运行时效果"的空壳。
1.1 Manifest 字段利用率
| 字段 | 解析 | 运行时消费 | 消费位置 |
|---|---|---|---|
id | ✅ | ✅ | manager.py 主键、事件 payload、权限错误信息、工具/命令归属 |
name | ✅ | ✅ | 日志输出、PluginPayload.plugin_name、config schema UI |
version | ✅ | ✅ | 日志输出、PluginPayload.plugin_version |
description | ✅ | ❌ | 全代码库零引用——未被日志、事件、UI 任何地方消费 |
entrypoint | ✅ | ✅ | loader.py:66-73 模块导入和类查找,loader.py:142 卸载时模块路径提取 |
nahida_bot_version | ✅ | ❌ | 从未与实际 bot 版本比较——无兼容性门禁 |
sdk_version | ✅ | ❌ | 从未校验——等 SDK 独立包抽离后再实现 |
load_phase | ✅ | ✅ | manager.py 分阶段加载过滤,api_bridge.py provider 注册前置校验 |
permissions | ✅ | ⚠️ | 5/8 子检查有调用链(见 §1.2),3 个完全死方法 |
capabilities | ✅ | ❌ | 全代码库零引用——Capabilities.tools 和 Capabilities.subscribes_to 无任何代码读取 |
config | ✅ | ✅ | app.py 配置注入合并,各插件 self.manifest.config 运行时读取 |
config_schema | ✅ | ✅ | config_schema.py Web 图形化配置编辑器使用 |
depends_on | ✅ | ❌ | PluginManager 无拓扑排序——加载顺序完全依赖文件系统扫描顺序 |
统计:13 个字段中 5 个是空壳(description、nahida_bot_version、sdk_version、capabilities、depends_on),占比 38%。
1.2 PermissionChecker 方法利用率
| 方法 | 调用者 | 实际触发 |
|---|---|---|
check_network_outbound(url) | send_message() | ⚠️ 有调用但语义错误——传 chat address 做 URL glob 匹配 |
check_network_inbound() | 无 | ❌ |
check_filesystem_read("workspace") | workspace_read(), resolve_workspace_path() | ✅ builtin 插件大量调用 |
check_filesystem_write("workspace") | workspace_write() | ✅ builtin 插件大量调用 |
check_memory_read() | memory_search() | ✅ builtin 插件调用 |
check_memory_write() | memory_store() | ✅ builtin 插件调用 |
check_subprocess() | 无 | ❌ |
check_env_var(key) | 无 | ❌ |
check_signal_handlers | 不存在 | ❌ 模型有字段但 PermissionChecker 没有方法 |
额外问题:14 个 RealBotAPI 方法完全没有权限检查——register_tool、register_command、register_channel、subscribe、publish_event 等全部裸奔。
统计:8 个权限维度中 3 个完全死方法 + 1 个不存在的方法,覆盖率 50%。即便是已覆盖的维度,检查也只贴在 5 条 API 路径上。
1.3 已有插件对 self.api 的实际使用模式
审计了所有 5 个已有插件的 self.api 调用:
| 插件 | 使用的 API |
|---|---|
| builtin/commands | register_command、register_tool、workspace_read、workspace_write、memory_search、memory_store、send_message、record_session_event、clear_session、start_new_session、get_session_info、get_session_run_status、list_models、set_session_model、update_runtime_settings、list_commands、resolve_workspace_path、scheduler_service |
| mcp | register_tool |
| channels/onebot | register_channel、publish_event、register_tool |
| channels/milky | register_channel、publish_event、register_tool |
| channels/telegram | register_channel、publish_event、register_tool |
关键发现:只有 builtin 插件真正走 workspace_read/write 和 memory_search/store 的权限检查路径。Channel 插件主要用 register_channel + publish_event + register_tool——这三条路径全部无权限检查。
2. 待补全功能清单
2.1 P0:nahida-bot-sdk 独立包抽离 + 插件测试控制台
现状:虽然 Plugin、BotAPI、PluginManifest、OutboundMessage 等类型在设计上属于 SDK 层,但它们目前全部混在 nahida_bot/plugins/ 目录中,与 RealBotAPI、PluginManager、PermissionChecker 等运行时实现共存于同一个包内。这意味着:
- 插件开发者必须
pip install nahida-bot整个项目才能获得类型定义,拉入了aiosqlite、fastapi、uvicorn、httpx、mcp等全部重型运行时依赖。 - 插件测试需要启动完整的 bot 或手动 mock 内部对象。
- 插件代码与 bot 内部实现之间没有强制的 import 边界——插件随时可以
from nahida_bot.core import ...绕过 BotAPI。
本轮测试目标:在一个完全隔离的目录中编写一个插件(不依赖 nahida-bot 源码),该插件能注册 command 和工具,并通过 uv pip install 安装后在 bot 中运行。
仓库结构(uv workspace monorepo,主包不移动):
nahida-bot/ ← git 仓库根(也是 nahida-bot 主包根)
├── pyproject.toml ← nahida-bot 主包 + workspace 声明
├── nahida_bot/ ← 主包代码(不挪位置)
├── uv.lock ← 统一 lockfile
│
├── nahida-bot-sdk/ ← workspace 子成员(只新增此目录)
│ ├── pyproject.toml ← nahida-bot-sdk 包
│ ├── nahida_bot_sdk/
│ │ ├── __init__.py ← 公开 API re-export
│ │ ├── plugin.py ← Plugin 基类
│ │ ├── api.py ← BotAPI 协议 (Protocol, runtime_checkable)
│ │ ├── manifest.py ← PluginManifest 及所有权限/能力模型
│ │ ├── messaging.py ← InboundMessage, OutboundMessage, CommandResult
│ │ ├── events.py ← 事件类型定义 (MessageReceived 等)
│ │ ├── logging.py ← PluginLogger 协议
│ │ │
│ │ └── testing/
│ │ ├── __init__.py
│ │ ├── mock_api.py ← MockBotAPI(无需启动 bot)
│ │ └── console.py ← 插件测试控制台(见下文)
│ │
│ └── tests/
│ └── test_mock_api.py
│
├── docs/
├── tests/
└── ...配置方式:
根 pyproject.toml 新增 workspace 声明和 SDK 依赖源:
# 根 pyproject.toml(nahida-bot 主包,位置和内容基本不变)
[project]
name = "nahida-bot"
dependencies = [
"nahida-bot-sdk",
# ... 其余依赖不变
]
[tool.uv.sources]
nahida-bot-sdk = { workspace = true }
[tool.uv.workspace]
members = ["nahida-bot-sdk"]SDK 的 pyproject.toml:
# nahida-bot-sdk/pyproject.toml
[project]
name = "nahida-bot-sdk"
version = "0.1.0"
description = "Plugin SDK for nahida-bot"
requires-python = ">=3.12"
dependencies = [
"pydantic>=2.0",
]
[build-system]
requires = ["hatchling"]
build-backend = "hatchling.build"为什么不把主包也挪进子目录:移动 nahida_bot/ 到 packages/nahida-bot/ 下会产生巨大的重命名 diff,破坏 git blame/blame 历史和所有 PR 的上下文。uv workspace 的 members 不要求主包在子目录里——主包就是仓库根本身,只有 SDK 是子成员。零移动、零重命名。
依赖约束:nahida-bot-sdk 只依赖 pydantic>=2.0 和 typing_extensions。不引入任何网络、数据库、Web 框架。
开发与发布:
- 开发:
uv sync一步装好主包 + SDK,workspace 内依赖自动联动。 - 发布 SDK:
cd nahida-bot-sdk && uv build && uv publish,产出独立的 wheel/tarball 上传 PyPI。 - 发布主包:
uv build && uv publish,PyPI 从name字段区分包,不看仓库结构。 - CI 自动化:打 tag 后 GitHub Actions 先发 SDK 再发主包(SDK 版本号更新后主包才能引用到新版本)。
迁移路径(从当前混合状态到 uv workspace):
- 根
pyproject.toml新增[tool.uv.workspace]和[tool.uv.sources]段。 - 创建
nahida-bot-sdk/目录,初始化其pyproject.toml。 - 将
nahida_bot/plugins/base.py中的Plugin、BotAPI、OutboundMessage、InboundMessage、CommandResult等纯类型定义移动到 SDK。 - 将
nahida_bot/plugins/manifest.py中的PluginManifest及权限/能力模型移动到 SDK。 - 在 SDK 中实现
MockBotAPI(已有设计稿,见docs/architecture/plugin-system.md§3.5)。 - nahida-bot 改为
from nahida_bot_sdk import Plugin, BotAPI, ...。 - 删除
nahida_bot/plugins/中的重复定义,保留RealBotAPI、PluginManager、PermissionChecker等运行时实现。 - 现有 5 个插件改为依赖
nahida-bot-sdk而非nahida-bot。 uv sync验证 workspace 正常工作。
插件测试控制台:
为降低插件开发门槛,在 SDK 的 testing 模块中提供一个简单的命令行 REPL 作为"假的聊天界面":
$ python -m nahida_bot_sdk.testing.console ./plugins/my-plugin
╔══════════════════════════════════════╗
║ nahida-bot-sdk Plugin Console ║
║ Plugin: my-plugin v0.1.0 ║
║ Type /help for commands ║
╚══════════════════════════════════════╝
You: /hello
Bot: Hello! I'm a test plugin.
You: trigger tool:my_tool {"arg": "value"}
[Tool my_tool returned]: {"result": "ok"}
You: event:MessageReceived {"text": "hello"}
[Plugin handler called with MessageReceived]
You: /quit
Goodbye!控制台的功能边界:
| 功能 | 说明 |
|---|---|
/help | 列出可用命令 |
/quit | 退出控制台 |
| 直接输入文本 | 如果插件注册了 message handler,模拟触发 MessageReceived 事件 |
tool:<name> <json> | 手动调用已注册的工具,打印返回值 |
event:<type> <json> | 手动触发任意事件 |
/commands | 列出插件注册的命令 |
/tools | 列出插件注册的工具 |
这不是喧宾夺主——它是纯粹的开发工具,类似于 pytest、uvicorn --reload、或者 Rasa 的 rasa shell。它的价值在于:
- 插件开发者不需要启动完整的 nahida-bot(数据库、channel、provider、agent loop)就能测试命令和工具的基本逻辑。
- MockBotAPI 提供可控的假数据(假 session、假 workspace 文件、假 memory),测试可重复。
- 控制台本身不到 200 行代码,不引入新依赖(纯
input()/asyncio)。
涉及文件:
nahida-bot-sdk/pyproject.toml—— 新建nahida-bot-sdk/nahida_bot_sdk/—— SDK 包主体nahida-bot-sdk/nahida_bot_sdk/testing/mock_api.py—— MockBotAPInahida-bot-sdk/nahida_bot_sdk/testing/console.py—— 测试控制台nahida_bot/plugins/base.py—— 改为从 SDK re-exportnahida_bot/plugins/manifest.py—— 改为从 SDK re-exportnahida_bot/pyproject.toml—— 新增nahida-bot-sdk依赖
2.2 P1:插件外部 Python 依赖管理 —— 每个插件是一个 Python package
现状:插件系统完全没有处理外部 Python 库依赖的机制。当前 5 个已有插件的第三方依赖(httpx、aiohttp、aiogram 等)全部作为 nahida-bot 自身的依赖写在根 pyproject.toml 中。这对第三方插件不可行。
设计决策:每个插件自带 pyproject.toml,成为一个标准 Python package。这不是替代 plugin.yaml,而是双文件分工。
插件命名规范:参考 NoneBot2 和 AstrBot 的实践,采用 hyphen 用于 PyPI 包名、underscore 用于 Python 模块名、前缀标识归属的约定:
| 用途 | 格式 | 示例 |
|---|---|---|
| PyPI 包名 / 目录名 | nahida-plugin-{name} | nahida-plugin-image-gen |
| Python 模块名 | nahida_plugin_{name} | nahida_plugin_image_gen |
| 插件 ID | {name} | image-gen |
前缀 nahida-plugin- / nahida_plugin_ 的作用:(1) PyPI 上可发现;(2) import 时自文档化——一眼可见是 nahida-bot 插件;(3) 避免与通用 Python 包命名冲突。插件加载器不强制检查命名格式——任何目录下包含合法 plugin.yaml 均可加载——但脚手架自动生成合规名称,插件商店收录时也会校验。
plugins/my-plugin/
├── plugin.yaml ← nahida-bot 特有元数据(id、entrypoint、permissions、capabilities…)
├── pyproject.toml ← Python 标准打包元数据(name、version、dependencies、requires-python)
├── nahida_plugin_my_plugin/
│ ├── __init__.py
│ └── plugin.py ← class MyPlugin(Plugin): ...
└── README.mdpyproject.toml 示例:
[project]
name = "nahida-plugin-image-gen"
version = "0.1.0"
description = "AI image generation plugin for nahida-bot"
requires-python = ">=3.12"
dependencies = [
"nahida-bot-sdk>=0.1.0", # SDK 版本约束(替代 sdk_version)
"nahida-bot>=0.1.0", # bot 版本约束(替代 nahida_bot_version)
"pillow>=10.0",
"httpx>=0.27",
]
[project.optional-dependencies]
dev = ["pytest>=8.0", "pytest-asyncio>=0.24"]
[build-system]
requires = ["hatchling"]
build-backend = "hatchling.build"plugin.yaml 对应精简后的样子:
id: "com.example.image-gen"
name: "Image Generator"
version: "0.1.0"
description: "AI image generation via Stable Diffusion"
entrypoint: "nahida_plugin_image_gen.plugin:ImageGenPlugin"
load_phase: "post-agent"
permissions:
network:
outbound: ["https://api.stability.ai/*"]
filesystem:
read: ["workspace"]
write: ["workspace"]
capabilities:
tools:
- name: "generate_image"
description: "Generate an image from a text prompt"
depends_on:
- id: "builtin-commands"
version: ">=0.1.0"
config:
api_key: ""
default_size: "1024x1024"
config_schema:
type: "object"
properties:
api_key:
type: "string"
description: "Stability AI API key"
secret: true
default_size:
type: "string"
default: "1024x1024"
enum: ["512x512", "768x768", "1024x1024"]
required: ["api_key"]字段分工原则:
| 来源 | 包含内容 | 读者 |
|---|---|---|
pyproject.toml | Python 包名、版本、Python 依赖、可选依赖组、构建系统 | pip / uv / PyPI |
plugin.yaml | nahida-bot 插件 ID、入口点、权限、能力声明、插件间依赖、配置项 | PluginManager / WebUI |
允许少量重复(name、version、description 在两个文件中都出现)——pyproject.toml 的值用于 PyPI 发布,plugin.yaml 的值用于运行时日志和 WebUI 展示。如果用户觉得维护两份有些冗余,后续可以加一个 parse_manifest_from_pyproject() 自动从 pyproject.toml 中提取 name/version/description 作为 fallback。
安装流程:
# 用户获取插件(git clone / 下载 zip / pip install)
git clone https://github.com/user/nahida-plugin-image-gen plugins/image-gen
# 安装插件及其 Python 依赖(uv 一步完成)
uv pip install ./plugins/image-gen
# 启动 bot,PluginManager 扫描 plugin.yaml 并加载
nahida runPluginManager 侧的改动:
PluginLoader.load()中如果importlib.import_module()失败,解析ImportError/ModuleNotFoundError给出友好提示:Plugin 'image-gen' failed to load: missing dependency 'pillow'. Install it with: uv pip install ./plugins/image-genPluginManager在发现插件目录中存在pyproject.toml时,可以解析dependencies列表并在 WebUI 中展示,但不自动安装。- 是否需要检查依赖是否满足(
importlib.metadata校验版本)留到后续 Phase——初期只做友好的错误提示。
关于安全性:PluginManager 永远不自动执行 pip install 或 uv pip install。依赖安装是管理员的显式操作。WebUI 可以在插件详情面板中显示"缺失依赖:pillow>=10.0"并指引管理员手动安装。
对现有字段的影响:
nahida_bot_version→ 主要版本门禁改为pyproject.toml中的nahida-bot>=X.Y依赖声明。manifest 中的nahida_bot_version降级为辅助字段(warn-only)。sdk_version→ 被pyproject.toml中的nahida-bot-sdk>=X.Y替代,manifest 字段保留但意义不大。requires→ 不需要在plugin.yaml中新增此字段,pyproject.toml的dependencies已经是标准方案。
2.3 P0:depends_on —— 插件依赖拓扑加载
现状:PluginDependency 模型完整定义(含 id 和 version),PluginManifest.depends_on 字段就位。但 PluginManager 按 _records 插入顺序(即文件系统扫描顺序)加载,不做任何依赖排序。
影响:插件 A 依赖插件 B 先注册某个 provider type 或 tool,但 B 的目录名按字母序排在 A 后面时,A 的 on_load 可能找不到所需资源。当前靠运气和命名约定规避。
实现要点:
- 在
PluginManager中新增_resolve_load_order(plugins: list[str]) -> list[str],使用 Kahn 算法做拓扑排序。 - 循环依赖检测:如果存在环,将所有循环参与者标记为
ERROR,并给出清晰的错误信息列出环中所有插件。 - 缺失依赖检测:如果某个插件依赖的
plugin_id不存在于已发现的插件列表中,拒绝加载并报错。 - 版本约束匹配:使用
packaging库(Python 标准库候选)做>=1.0,<2.0风格的版本约束校验。初始版本可以宽松匹配——只 warn 不 block。 - 将
load_all()和enable_all()中的遍历顺序从list(self._records)改为self._resolve_load_order(...)。
依赖标识方式:depends_on 通过目标插件的 id 字段来标识依赖。例如,如果插件 A 需要在插件 B(其 plugin.yaml 中 id: "builtin-commands")之后加载:
# 插件 A 的 plugin.yaml
depends_on:
- id: "builtin-commands"
version: ">=0.1.0"其中 id 必须精确匹配目标插件的 plugin.yaml 中声明的 id 字段。version 使用 PEP 440 约束语法,为空字符串时表示任意版本。
涉及文件:
nahida_bot/plugins/manager.py—— 新增拓扑排序和依赖校验逻辑nahida_bot/plugins/manifest.py—— 可能需要补充PluginDependency的版本解析辅助方法tests/test_plugin_manager.py—— 新增依赖排序测试用例
2.4 P1:nahida_bot_version —— 版本兼容性门禁
现状:字段解析后从未与运行时 bot 版本比较。
影响:插件声明 nahida_bot_version: ">=0.2.0" 但运行在 0.1.0 上时,静默加载,可能在运行时因 API 不兼容而崩溃。
实现要点:
- 从
nahida_bot.__version__获取当前 bot 版本。 - 在
PluginManager.load()中,PermissionChecker创建之前,解析并校验版本约束。 - 使用 PEP 440 版本规范 +
packaging.specifiers.SpecifierSet或简化版手动解析。 - 不匹配时的行为:默认 warn + 继续加载(兼容现有行为),可配置为
strict模式拒绝加载。 nahida_bot_version为空字符串时不检查(向后兼容当前所有插件)。
涉及文件:
nahida_bot/plugins/manager.py——load()方法中新增版本检查nahida_bot/__init__.py—— 确认__version__存在nahida_bot/plugins/manifest.py—— 可选:新增check_version_compatibility()静态方法
2.5 P1:capabilities —— 工具声明校验与文档生成
现状:Capabilities.tools 和 Capabilities.subscribes_to 完全无人读取。builtin 插件在 yaml 中写了 13 个 tool 的描述,但与 register_tool 的实际注册没有任何关联。
设计决策:capabilities 不应该作为"替代注册"的机制(工具注册必须在代码中完成,因为需要 handler 函数)。它的正确用途是:
- 静态声明:让插件在加载前就能被检查"声称提供什么能力"。
- 冲突检测:
PluginManager.enable()时可以对比 manifest 声明的 tools 和ToolRegistry中已注册的 tools,提前发现命名冲突。 - WebUI 文档:插件面板展示每个插件提供了哪些工具、监听了哪些事件。
- 可选:加载后做 reconciliation——实际注册的工具集 vs manifest 声明的工具集是否一致。
实现要点:
- 在
PluginManager.enable()成功后做 reconciliation:对比capabilities.tools中声明的 name 和通过self.api.register_tool实际注册的 tool name。- 声明了但未注册 → warning 日志。
- 注册了但未声明 → info 日志(不强制,很多插件动态注册工具)。
- 冲突检测:加载前检查 manifest 的
capabilities.tools[].name是否与ToolRegistry中已有工具名冲突,提前报错而非等到register_tool时抛KeyError。 - 将
capabilities数据暴露到 PluginManager 的公开 API,供 WebUI 插件面板消费。
涉及文件:
nahida_bot/plugins/manager.py—— enable 后 reconciliation + 预检查nahida_bot/plugins/registry.py—— 可选新增has_tool(name) -> bool- WebUI 新增的插件 API(见 §3)
2.6 P2:权限系统补全
2.6.1 修复 send_message 的权限检查
问题:send_message(target, ...) 将 chat address(如 "group:12345")传给 check_network_outbound(),后者用 URL glob 匹配。对于声明了精确 URL 而非 "*" 的插件,发消息会直接 PermissionDenied。
修复:send_message 应改用 channel 级别的权限声明,或者在当前模型下直接移除错误的 check_network_outbound 调用。发消息走 channel service,不是网络请求。如果未来需要限制"哪些插件可以向哪些 chat 发消息",应新增 permissions.messaging 维度。
2.6.2 补全缺失的权限检查
| 新增检查 | 应贴在哪些 API 方法上 | 优先度 |
|---|---|---|
check_subprocess() | 目前无对应 API 方法。等 exec 类 API 加入 BotAPI 后接入。 | P2 |
check_env_var(key) | 等 api.get_env(key) 加入 BotAPI 后接入。 | P2 |
check_signal_handlers() | 新增 PermissionChecker.check_signal_handlers() 方法。等需要时接入。 | P3 |
check_network_inbound() | register_channel() 调用前检查 channel 插件是否声明了 inbound 权限。 | P1 |
2.6.3 为关键注册方法加入权限门禁
当前 register_tool、register_command、register_channel 等方法无任何权限检查。但这些操作影响全局状态——任意插件都可以注册与核心命令同名的命令来覆盖行为。
建议:
register_command应至少校验插件声明了某种能力(从capabilities或新增permissions.agent维度)。register_tool同上。register_channel应校验permissions.network.inbound。subscribe(某些高频事件如MessageReceived)可考虑加入声明校验,防止插件静默监听所有消息。
这是一个需要权衡的设计决策:过严会降低插件开发体验,过松会留下隐患。建议先做 register_channel → check_network_inbound 这条最明确的链路。
2.7 P2:description —— 日志与 UI 展示
现状:manifest.description 全代码库零引用。
改进:
- 在
PluginManager.load()的日志中加入 description(截断到 80 字符)。 - 在 WebUI 插件面板中作为插件的说明文字展示。
- 在
nahida bot plugins listCLI 命令中展示。
2.8 未来探索:AstrBot 兼容层
AstrBot 是另一个 Python LLM bot 框架,有自己的插件 API(astrbot.core.plugin、on_message 装饰器、Context 对象等)。如果能在 nahida-bot 的 BotAPI 协议之上构建一个 AstrBot 兼容适配层,可以让现有的 AstrBot 插件以最小修改运行在 nahida-bot 上。
可行性分析:
| 概念 | nahida-bot | AstrBot | 兼容难度 |
|---|---|---|---|
| 插件基类 | Plugin(ABC) + on_load() | StarlettePlugin + run() | 中——生命周期模型不同,需要适配器 |
| 消息接收 | MessageReceived 事件 | on_message() 装饰器 | 低——事件模型可映射 |
| 发送消息 | api.send_message(target, msg) | context.send(msg) | 低——语义相近 |
| 命令注册 | api.register_command(name, handler) | 装饰器或 register_command | 低——直接映射 |
| 工具注册 | api.register_tool(name, desc, params, handler) | 无直接等价物 | 高——AstrBot 没有 tool 概念 |
| 配置 | plugin.yaml + config | config.yml | 中——格式不同但概念相同 |
| 权限 | manifest permissions 声明式 | 无 | 高——AstrBot 无权限模型 |
结论:消息收发和命令注册层面兼容是可行的,但 AstrBot 没有 tool 系统和权限模型,这两个概念无法映射。建议将 AstrBot 兼容层作为一个独立的适配器插件来实现——它本身是一个 nahida-bot 插件,加载后提供 AstrBot API 的模拟层,让 AstrBot 插件在 nahida-bot 的 PluginManager 管理下运行。此项不在当前路线图中,列为未来探索方向。
3. WebUI 插件管理面板
3.1 目标
在 WebUI 中新增一个独立的 "Plugins" 页面,提供插件发现、状态监控、配置管理的可视化界面。
自定义插件业务页面(例如 RSS notifier 的订阅管理面板)不应硬编码进主 WebUI。本节只覆盖通用插件管理页;插件自带 Web 面板、iframe bridge 和插件 Admin API 详见 plugin-web-panels.md。
3.2 信息架构
页面结构:
┌─ Plugins ──────────────────────────────────────────────┐
│ 搜索: [____________] 状态过滤: [全部▾] 阶段: [全部▾] │
│ │
│ ┌─────────────────────────────────────────────────────┐ │
│ │ 📦 builtin-commands enabled ✅ │ │
│ │ Builtin Commands │ │
│ │ Core commands, workspace tools, exec, web fetch │ │
│ │ v0.1.0 | post-agent | 13 tools | 0 events │ │
│ │ [Disable] [Reload] [Config] │ │
│ ├─────────────────────────────────────────────────────┤ │
│ │ 🔌 onebot enabled ✅ │ │
│ │ OneBot Channel │ │
│ │ QQ and other IM channels via OneBot v11 protocol │ │
│ │ v0.1.0 | post-agent | 1 tool | channel │ │
│ │ [Disable] [Reload] [Config] │ │
│ ├─────────────────────────────────────────────────────┤ │
│ │ ... │ │
│ └─────────────────────────────────────────────────────┘ │
│ │
│ 插件详情面板(点击展开) │
│ ┌─────────────────────────────────────────────────────┐ │
│ │ Manifest / Tools / Commands / Events / Config│ │
│ └─────────────────────────────────────────────────────┘ │
└─────────────────────────────────────────────────────────┘3.3 后端 API
需要新增以下端点:
GET /api/plugins # 列出所有插件及状态
GET /api/plugins/{plugin_id} # 单个插件详情
POST /api/plugins/{plugin_id}/enable # 启用插件
POST /api/plugins/{plugin_id}/disable # 禁用插件
POST /api/plugins/{plugin_id}/reload # 热重载插件
GET /api/plugins/{plugin_id}/config # 获取插件配置
PUT /api/plugins/{plugin_id}/config # 更新插件配置
GET /api/plugins/discovery # 触发重新扫描插件目录GET /api/plugins 响应模型
{
"plugins": [
{
"id": "builtin-commands",
"name": "Builtin Commands",
"version": "0.1.0",
"description": "Core commands, workspace tools, exec, web fetch, plan, cron, and agent orchestration",
"state": "enabled",
"load_phase": "post-agent",
"error_message": "",
"capabilities": {
"tools": [
{"name": "workspace_read", "description": "Read a text file from the active workspace"},
{"name": "workspace_write", "description": "Write a text file to the active workspace"}
],
"subscribes_to": []
},
"dependencies": [],
"permissions_summary": {
"network_outbound": ["*"],
"filesystem_read": ["workspace"],
"filesystem_write": ["workspace"],
"memory_read": true,
"memory_write": true,
"subprocess": true
},
"tool_count": 13,
"command_count": 12,
"event_subscription_count": 0,
"channel_count": 0
}
],
"summary": {
"total": 5,
"enabled": 4,
"disabled": 0,
"error": 1,
"loaded": 0
}
}GET /api/plugins/{plugin_id} 响应
在列表响应基础上追加:
{
"registered_tools": ["workspace_read", "workspace_write", "exec", "web_fetch", "plan", ...],
"registered_commands": ["/plan", "/memory", "/model", "/cron", "/stop", ...],
"event_subscriptions": ["MessageReceived"],
"channels": [],
"config_schema": { ... },
"config_current": { ... },
"has_pyproject_toml": true,
"python_dependencies": ["nahida-bot-sdk>=0.1.0", "pillow>=10.0"],
"python_dependencies_satisfied": true
}新增字段说明:
has_pyproject_toml:插件目录中是否存在pyproject.toml。python_dependencies:从pyproject.toml[project] dependencies解析的依赖列表(仅展示,不自动安装)。python_dependencies_satisfied:是否所有依赖已安装(用importlib.metadata校验),用于在 WebUI 中显示"缺失依赖"警告。
3.4 前端页面
位置:webui/src/features/plugins/
主要组件:
| 组件 | 功能 |
|---|---|
PluginsPage.vue | 页面顶层:搜索、过滤、插件卡片列表 |
PluginCard.vue | 单个插件卡片:名称、状态、版本、capabilities 摘要、操作按钮 |
PluginDetailPanel.vue | 侧边或展开面板:Tab 切换 manifest / tools / commands / events / config |
PluginConfigTab.vue | 复用现有 config schema 表单组件,渲染插件配置 |
PluginToolList.vue | 展示插件注册的工具列表(名称 + 描述) |
PluginEventList.vue | 展示插件订阅的事件列表 |
交互:
- 卡片点击展开详情面板。
- Enable / Disable / Reload 按钮带二次确认(Disable 和 Reload 是破坏性操作)。
- Config tab 如果插件有
config_schema,用 schema-driven 表单渲染;没有则退化为 YAML 编辑器。 - 搜索框支持按插件 id、name、description 过滤。
- 状态过滤:All / Enabled / Disabled / Error。
3.5 实施阶段
Phase A:后端 API(与 §2 的 P0/P1 并行)
- 在
PluginManager上新增公开查询方法(如需要):get_plugin_summary(plugin_id) -> dictget_plugins_summary() -> dict
- 新增
nahida_bot/gateway/routes/plugins.py - 注册到 Gateway router
- 新增认证/授权中间件挂载
Phase B:前端页面
- 新增
webui/src/features/plugins/目录和组件 - 新增
webui/src/api/plugins.tsqueries - 注册路由
/plugins - 注册到导航栏
Phase C:配置编辑集成
- 插件 Config tab 集成 schema-driven 表单(复用配置页的
_walk_json_schema逻辑) - 保存插件配置(直接通过现有
PATCH /api/config/current或专用PUT /api/plugins/{id}/config)
4. 实施路线图
Phase A:nahida-bot-sdk 独立包 + 测试控制台
Phase B:插件依赖拓扑加载 depends_on
Phase C:版本兼容性门禁 + capabilities 校验
Phase D:权限系统补全
Phase E:WebUI 插件管理面板
Phase F:锦上添花
Phase G:插件 LLM 访问能力
G.1 动机
当前插件层完全没有任何 LLM 交互能力。BotAPI 有 list_models() 和 set_session_model(),但这些都是模型管理接口而非使用接口。插件只能通过注册 tool/command 间接参与 agent loop,不能自己发起 LLM 调用。
这限制了插件的应用场景:很多插件想做"用 LLM 总结一段文本"、"判断消息意图"、"跑一个固定 workflow"之类的任务,但没有途径。
G.2 设计:两层 API
插件对 LLM 的需求分为两个层次,需要分别提供:
| 层次 | API | 场景 | 特点 |
|---|---|---|---|
| Provider 层 | llm_chat() | 分类、总结、翻译、提取、意图判断 | 单轮调用,插件自己控制流程 |
| Agent 层 | run_subagent() | 多步推理、研究分析、带工具调用的 workflow | 多轮 loop,bot 管理工具执行和重试 |
如果只给 Agent 层,简单任务也要走 loop 的开销。如果只给 Provider 层,复杂任务插件要自己重写 tool-calling loop。两层各司其职,同时提供。
G.3 模型路由
两个 API 的 model 参数都走现有的 ModelRouter.resolve(),接受三种形式:
| 形式 | 示例 | 解析方式 |
|---|---|---|
| Tag | "cheap", "vision", "primary" | 扫描所有 provider slot 的 tags_by_model |
| 裸模型名 | "deepseek-chat" | ProviderManager.resolve_model_selection() |
provider/model | "openai/gpt-4o" | ProviderManager.resolve_model_selection() |
不传 model(空字符串)时使用默认 provider 的默认模型。
插件不需要知道具体用了哪个 provider 哪个 model——它只说需求,bot 配置决定实际路由。这是 plugin 和 provider 配置之间的解耦层。
G.4 API 签名
llm_chat() — 单轮 LLM 调用
async def llm_chat(
self,
messages: list[dict[str, str]],
*,
model: str = "",
temperature: float | None = None,
max_tokens: int | None = None,
tools: list[dict[str, Any]] | None = None,
) -> LLMResponse:
"""Send a single-turn chat request to an LLM.
Args:
messages: List of {"role": "...", "content": "..."} dicts.
model: Model spec — tag, bare model name, or ``provider/model``.
Empty string uses the default provider's default model.
temperature: Optional sampling temperature.
max_tokens: Optional max tokens for the response.
tools: Optional tool definitions (JSON Schema format). When
provided, the response may contain tool_calls that the
plugin handles itself.
Returns:
LLMResponse with content, tool_calls, model, provider, usage, etc.
"""
...run_subagent() — 多轮子代理
async def run_subagent(
self,
prompt: str,
*,
model: str = "",
system_prompt: str = "",
tools: list[str] | None = None,
max_steps: int = 10,
timeout_seconds: int = 300,
) -> SubagentResult:
"""Run a multi-turn subagent with optional tool access.
The subagent runs in an isolated child session. It can call any
tool listed in *tools* (the tool must be registered in ToolRegistry).
Args:
prompt: The task description.
model: Model spec (same format as llm_chat).
system_prompt: Optional system prompt override.
tools: Tool names to grant (empty list or None = no tools).
max_steps: Max agent loop iterations.
timeout_seconds: Hard timeout for the entire run.
Returns:
SubagentResult with final_response, status, steps, usage, etc.
"""
...G.5 返回类型(SDK 数据类)
@dataclass(slots=True)
class LLMUsage:
input_tokens: int = 0
output_tokens: int = 0
cached_tokens: int = 0
reasoning_tokens: int = 0
@dataclass(slots=True)
class LLMResponse:
content: str
model: str = ""
provider: str = ""
finish_reason: str = ""
usage: LLMUsage | None = None
tool_calls: list[dict[str, Any]] = field(default_factory=list)
@dataclass(slots=True)
class SubagentResult:
final_response: str
status: str = "succeeded" # succeeded | failed | timed_out | cancelled
model: str = ""
provider: str = ""
steps: int = 0
usage: LLMUsage | None = None
error: str = ""G.6 权限模型
在 Permissions 模型中新增 llm_access: bool = False。插件必须在 manifest 中显式声明:
permissions:
llm_access: trueRealBotAPI.llm_chat() 和 RealBotAPI.run_subagent() 在被调用时首先执行 PermissionChecker.check_llm_access()。未声明 llm_access: true 的插件调用时直接抛出 PermissionDenied。
为什么需要权限检查:LLM 调用消耗 token(= 费用),不是所有插件都应该默认能用。插件作者必须显式 opt-in,管理员在审查 plugin.yaml 时能看到这个权限声明。
G.7 实现方案
llm_chat 实现路径
Plugin (SDK)
└─> api.llm_chat(messages, model="cheap")
└─> RealBotAPI.llm_chat()
├─> PermissionChecker.check_llm_access()
├─> ModelRouter.resolve(model) → RoutedModel(slot, model, reason)
├─> 将 dict messages 转换为 ContextMessage 列表
├─> slot.provider.chat(messages=..., model=..., tools=..., ...)
└─> 将 ProviderResponse 转换为 LLMResponse 返回RealBotAPI 已有 _provider_manager(通过 set_runtime_services() 注入),但 ModelRouter 未注入。需要在 RealBotAPI 中新增 _model_router 字段并在 set_runtime_services() 中注入。
run_subagent 实现路径
Plugin (SDK)
└─> api.run_subagent(prompt="...", tools=["workspace_read"])
└─> RealBotAPI.run_subagent()
├─> PermissionChecker.check_llm_access()
├─> 构造 SubagentSpec(task=prompt, model=..., tool_allowlist=..., notify_policy="silent")
├─> AgentOrchestrator.spawn_subagent(spec) → BackgroundTask
├─> AgentOrchestrator.wait_for_task(task.task_id, timeout=...)
└─> 将 BackgroundTask 转换为 SubagentResult 返回需要当前存在 session context(current_session.get())。如果插件在 sessionless 上下文中调用,抛出 RuntimeError。
ModelRouter 注入
ModelRouter 当前只在 Application._init_agent_subsystem() 中创建并用于内部 task 路由,未暴露给插件层。需要在 PluginManager 加载插件时通过 set_runtime_services() 注入。
G.8 涉及文件
| 文件 | 改动 |
|---|---|
nahida-bot-sdk/nahida_bot_sdk/api.py | 新增 LLMResponse, SubagentResult, LLMUsage 数据类;BotAPI 协议新增 llm_chat() 和 run_subagent() 方法签名 |
nahida-bot-sdk/nahida_bot_sdk/manifest.py | Permissions 模型新增 llm_access: bool = False |
nahida-bot-sdk/nahida_bot_sdk/__init__.py | 导出新数据类 |
nahida_bot/plugins/api_bridge.py | RealBotAPI 实现 llm_chat() 和 run_subagent();新增 _model_router 注入 |
nahida_bot/plugins/permissions.py | PermissionChecker 新增 check_llm_access() 方法 |
nahida_bot/plugins/manager.py | PluginManager 注入 ModelRouter 到 API bridge |
G.9 使用示例
示例 1:消息意图分类
class ModerationPlugin(Plugin):
async def on_load(self):
@self.api.on_event(MessageReceived)
async def check_toxicity(event):
response = await self.api.llm_chat(
messages=[
{"role": "system", "content": "Classify: safe, spam, or harmful. Reply with one word."},
{"role": "user", "content": event.text},
],
model="cheap", # 走 tag 路由到便宜模型
)
if response.content.strip().lower() == "harmful":
await self.api.send_message(event.chat_id, "⚠️ Harmful content detected")示例 2:带工具的研究 workflow
class ResearchPlugin(Plugin):
async def on_load(self):
@self.api.register_command("research")
async def research_cmd(args, sender, chat_id):
topic = args.strip()
result = await self.api.run_subagent(
prompt=f"Research: {topic}. Write a 3-paragraph summary.",
model="primary",
tools=["web_search", "workspace_read", "workspace_write"],
max_steps=15,
timeout_seconds=600,
)
if result.status == "succeeded":
await self.api.send_message(chat_id, result.final_response)
else:
await self.api.send_message(chat_id, f"Research failed: {result.error}")依赖关系
Phase A (SDK 包) ───────── 基础依赖,必须先做
│
├──> Phase B (depends_on)
├──> Phase C (capabilities) ──┐
├──> Phase D (permissions) ──┼──> Phase E (WebUI 面板)
├──> Phase G (LLM access) ───┘ (依赖 ProviderManager + ModelRouter 已就位)
│
Phase F (锦上添花) ────────────── 最后做5. 设计约束
- 向后兼容:所有新增校验默认 warn 不 block。现有 5 个插件的
plugin.yaml不需要修改即可通过所有新检查。 - 不改变插件编写方式:
capabilitiesreconciliation 是 observation 而非 enforcement——声明了但没注册只打 warning,注册了但没声明只打 info。 - WebUI 插件面板遵循现有 WebUI 设计规范:Vue 3 + shadcn-vue/Reka UI,API-first,所有 mutation 走 REST。
- CLI 不重复造轮子:如果增加
nahida plugins list命令,它应调用PluginManager的公开方法而非直接读文件系统。