Agent Zero 内置插件 Pin to Top:侧边栏聊天与任务置顶功能的实现全解
本文基于 Agent Zero 仓库中内置插件 _pin_to_top 的 README、插件 DOX 及其源码,完整讲解该插件如何实现"将聊天(chat)与定时任务(task)固定到侧边栏列表顶部"的功能。读完本文,你将掌握该插件的持久化数据格式、API 契约、前端排序/分隔线回调机制、输入校验规则,以及侧边栏行列表扩展(row list extension)的注册方式,足以理解一个"无配置、始终启用"的 WebUI 插件在 Agent Zero 中的标准落地形态。
一、插件职责:为侧边栏行提供置顶能力
根据插件 README(plugins/_pin_to_top/README.md),Pin to Top 是 Agent Zero 的内置插件,核心职责可归纳为四点:
- 上下文感知的菜单动作:在侧边栏行菜单(row menu)中添加 "Pin to Top" / "Unpin from Top" 动作,菜单标签与图标会随当前行是否已置顶而变化;
- 置顶顺序与原始顺序:已置顶条目按置顶先后顺序排列(先置顶的排在前),未置顶条目之间保持原有顺序;
- 分隔线:置顶组与未置顶组之间用侧边栏标准分隔线隔开;
- 持久化:置顶状态保存在 Agent Zero 的用户键值存储(user key-value storage)中,重启后不丢失。
插件的启用策略非常明确:始终启用(always enabled),且没有配置界面。这在 plugins/_pin_to_top/plugin.yaml 中定义:
name: _pin_to_top
title: Pin to Top
description: Pin chats and scheduled tasks to the top of their sidebar lists.
version: 1.0.0
settings_sections: [] # 无设置分区
per_project_config: false # 无项目级配置
per_agent_config: false # 无 Agent 级配置
always_enabled: true # 始终启用
二、模块划分与所有权约定
插件的 DOX 文档(plugins/_pin_to_top/AGENTS.md)明确了各文件的职责边界,这也是理解整个插件代码组织方式的钥匙:
| 文件 | 职责 |
|---|---|
| plugin.yaml | 始终启用的插件元数据 |
| helpers/pins.py | 持久化置顶状态的读写逻辑 |
| api/get_pins.py / api/toggle_pin.py | 经过认证的读取/切换端点 |
| webui/pin-to-top-store.js | 前端行排序与分隔线回调 |
| extensions/webui/sidebar-row-actions-menu/ | 下拉菜单中的置顶动作按钮 |
DOX 还给出了一条关键架构约束:插件必须通过注册侧边栏"行列表回调"(row list callbacks)来影响排序,不得直接 patch 聊天/任务 store 或向行中注入控件。这一约束在后文前端部分会得到印证。
三、持久化状态:KVP 键、数据格式与线程安全
置顶状态的核心读写逻辑集中在 plugins/_pin_to_top/helpers/pins.py。
3.1 存储键与数据结构
状态通过共享的持久化 KVP 助手(helpers/kvp.py 中的 kvp.get_persistent / kvp.set_persistent)保存在固定键 plugin_pin_to_top 下(pins.py 第 11 行 STORE_KEY = "plugin_pin_to_top")。DOX 文档说明运行时状态经由该共享助手保存在 usr/ 目录中。
存储值是一个按列表类型分组的字典,每个条目是 条目ID -> 置顶时间戳(float) 的映射:
PinKind = Literal["chat", "task"]
STORE_KEY = "plugin_pin_to_top"
KINDS: tuple[PinKind, ...] = ("chat", "task")
即聊天与任务的置顶互不干扰,分别存放在 chat 和 task 两个子组里。
3.2 归一化:防御脏数据
_normalize(pins.py 第 43–61 行)在每次读取后对历史数据做清洗,规则是:
- 顶层不是字典时直接返回空的
{"chat": {}, "task": {}}; - 每个 kind 下,条目时间戳必须能转成
float且 大于 0,条目 ID 去除首尾空白后必须非空,否则该条目被丢弃。
这个 > 0 的校验与取消置顶的实现相呼应:toggle_pin 在取消置顶时返回的时间戳为 0.0,而持久化结构中不会写入 0 值条目,从源头保证存储里只有"当前已置顶"的项。
3.3 toggle_pin:切换语义与并发保护
toggle_pin(pins.py 第 22–40 行)是整个插件的核心函数:
def toggle_pin(kind: str, item_id: str) -> tuple[bool, float]:
"""Toggle a pin and return its new state and timestamp."""
normalized_kind = _require_kind(kind)
normalized_id = _require_item_id(item_id)
with _lock:
pins = _normalize(kvp.get_persistent(STORE_KEY, {}))
kind_pins = pins[normalized_kind]
if normalized_id in kind_pins:
del kind_pins[normalized_id]
timestamp = 0.0
pinned = False
else:
timestamp = time.time()
kind_pins[normalized_id] = timestamp
pinned = True
kvp.set_persistent(STORE_KEY, pins)
return pinned, timestamp
要点:
- 以模块级
threading.RLock()保护"读-改-写"全过程,避免并发切换时相互覆盖; - 已存在则删除并返回
(False, 0.0),不存在则写入time.time()时间戳并返回(True, timestamp);时间戳同时充当排序依据; - 输入校验失败时抛出
ValueError,由 API 层转换为 400 响应。
校验规则(_require_kind / _require_item_id,pins.py 第 64–76 行):
kind只能是chat或task,否则报kind must be 'chat' or 'task';item_id去除首尾空白后必须非空,且长度不超过 512,否则报item_id is required/item_id is too long。
四、API 端点:get_pins 与 toggle_pin
两个端点均为 Agent Zero 的标准 ApiHandler 实现,URL 前缀为 /plugins/_pin_to_top/...(前端调用路径见第 5 节 store 代码)。
4.1 GET /plugins/_pin_to_top/get_pins
api/get_pins.py 直接返回归一化后的全量状态:
return {"ok": True, "pins": get_pins()}
4.2 POST /plugins/_pin_to_top/toggle_pin
api/toggle_pin.py 从请求体取 kind 与 item_id 两个字段,调用 toggle_pin:
try:
pinned, timestamp = toggle_pin(
str(input.get("kind", "")),
str(input.get("item_id", "")),
)
except ValueError as error:
return Response(str(error), 400)
return {"ok": True, "pinned": pinned, "timestamp": timestamp}
- 成功时返回
{"ok": true, "pinned": <bool>, "timestamp": <float>},前端凭pinned字段决定是否在本地增删对应条目; - 校验失败(非法 kind、空 ID、超长 ID)时返回 400,错误消息即
ValueError的文本。
4.3 认证与 CSRF
tests/test_pins.py 第 58–61 行的断言确认了两个端点都保持框架默认的认证与 CSRF 保护:
def test_pin_endpoints_keep_default_auth_and_csrf_protection():
for handler in (GetPins, TogglePin):
assert handler.requires_auth() is True
assert handler.requires_csrf() is True
这意味着置顶状态是用户维度的私有数据,所有读写都要求已登录并携带有效 CSRF token。
五、前端状态机:pin-to-top-store.js
前端逻辑集中在 plugins/_pin_to_top/webui/pin-to-top-store.js,它是一个 Alpine.js 响应式 store(名称 pinToTop),职责包括初始化、拉取状态、切换置顶、排序和分隔线判定。
5.1 init:注册行列表扩展
init()(第 12–25 行)对 chat 和 task 两种行列表各注册一个扩展:
for (const kind of ["chat", "task"]) {
sidebarStore.registerRowListExtension(kind, PLUGIN_ID, {
sort: (items) => this.sortItems(kind, items),
dividerBefore: (item, index, items) =>
this.dividerBefore(kind, item, index, items),
});
}
这正是 DOX 约定"只注册回调、不直接改 store"的落地:侧边栏 store(webui/components/sidebar/sidebar-store.js 第 141–146 行)以 rowListExtensions: { chat: {}, task: {} } 结构按 kind 聚合各插件注册的扩展,渲染时依次应用所有扩展的 sort/dividerBefore 回调。插件以 PLUGIN_ID = "_pin_to_top" 命名注册,天然支持多个插件对同一列表叠加不同的排序修饰。
5.2 排序算法:置顶在前、按时间戳升序、组内保序
sortItems(第 72–86 行)实现了 README 承诺的顺序语义:
sortItems(kind, items) {
const kindPins = this.pins[kind] || {};
return items
.map((item, index) => ({ item, index }))
.sort((left, right) => {
const leftPin = kindPins[left.item.id];
const rightPin = kindPins[right.item.id];
const leftPinned = leftPin !== undefined;
const rightPinned = rightPin !== undefined;
if (leftPinned !== rightPinned) return leftPinned ? -1 : 1; // 1. 置顶组整体在前
if (leftPinned && leftPin !== rightPin) return leftPin - rightPin; // 2. 置顶组内:时间戳小(更早置顶)在前
return left.index - right.index; // 3. 同组其余项保持原始顺序
})
.map(({ item }) => item);
}
三级比较函数与"先置顶的排最前(older pins remain first)、未置顶组保持原顺序"的 DOX 契约一一对应。
5.3 分隔线判定
dividerBefore(第 88–92 行)只有一条规则:当前项未置顶、且它前一项已置顶(且不是第一项)时,在当前位置画分隔线:
dividerBefore(kind, item, index, items) {
return index > 0
&& !this.isPinned(kind, item.id)
&& this.isPinned(kind, items[index - 1]?.id);
}
由于 sortItems 已保证置顶组整体靠前,该判定恰好只在"置顶组末尾 → 未置顶组开头"这一条边界上触发,与 README 中"用标准侧边栏分隔线分隔两组"的描述一致。
5.4 与后端同步
loadPins()调用callJsonApi("/plugins/_pin_to_top/get_pins", {})拉取全量状态,并对响应做防御性展开(response?.pins?.chat || {}),加载失败时通过toastFrontendError弹出 "Failed to load pinned items." 提示;toggleFromMenu(menuId, kind)从菜单 ID 中解析出条目 ID——itemIdFromMenu(第 57–62 行)要求 menuId 形如chat:<id>或task:<id>,前缀即 kind;随后 POSTtoggle_pin,并根据响应里的pinned字段在本地 pins 上增删条目,保证 UI 立即重排;isPinned/isMenuItemPinned供菜单按钮做状态展示。
六、菜单入口:上下文感知的 Pin / Unpin 按钮
下拉菜单项由 WebUI 扩展文件 extensions/webui/sidebar-row-actions-menu/pin-to-top.html 提供,它是一个 Alpine 模板片段:
<button type="button" class="dropdown-item" role="menuitem"
@click="$store.pinToTop.toggleFromMenu($store.sidebar.rowMenuOpenId, $store.sidebar.rowMenuKind); $store.sidebar.rowMenuClose()">
<x-icon
:name="$store.pinToTop.isMenuItemPinned($store.sidebar.rowMenuOpenId, $store.sidebar.rowMenuKind) ? 'keep_off' : 'push_pin'"></x-icon>
<span
x-text="$store.pinToTop.isMenuItemPinned($store.sidebar.rowMenuOpenId, $store.sidebar.rowMenuKind) ? 'Unpin from Top' : 'Pin to Top'"></span>
</button>
可以看出"上下文感知"的具体实现:点击时读取侧边栏 store 的 rowMenuOpenId(当前打开菜单的菜单 ID)与 rowMenuKind(当前是聊天行还是任务行),据此决定 pin/unpin 目标;图标在 push_pin(未置顶)与 keep_off(已置顶)之间切换,文案同步显示 "Pin to Top" 或 "Unpin from Top"。x-init="$store.pinToTop.init()" 保证 store 在首行渲染前完成扩展注册与状态加载。
七、测试与验证
插件自带测试位于 plugins/_pin_to_top/tests/test_pins.py,通过 monkeypatch 把 kvp.get_persistent/set_persistent 替换为内存字典来隔离验证后端逻辑,覆盖了:
- 持久化与分组隔离:连续置顶
chat-1与task-1后,get_pins()返回{"chat": {"chat-1": 100.0}, "task": {"task-1": 200.0}};再次切换chat-1则变回(False, 0.0)且chat组为空; - 非法输入拒绝:参数化用例覆盖
kind="unknown"、item_id=""、item_id长 513 字符三种情况,均应抛ValueError; - API 层 400 转换:
TogglePin处理非法输入时返回status_code == 400; - 默认安全策略:
GetPins与TogglePin的requires_auth()与requires_csrf()均为True。
DOX 文档给出的完整验证命令还包含侧边栏行菜单的宿主测试:
pytest plugins/_pin_to_top/tests tests/test_sidebar_row_actions.py
其中 tests/test_sidebar_row_actions.py 验证行菜单宿主(row actions)与插件菜单项的集成行为。
八、小结:一个"零配置插件"的完整形态
Pin to Top 插件虽然功能单一,却是 Agent Zero WebUI 插件体系的典型样本,其设计约束值得参考:
- 元数据即策略:
plugin.yaml用always_enabled: true与空settings_sections声明"无需配置、无法关闭",功能对所有用户开箱即用; - 状态最小化:全部运行时状态压缩为一个 KVP 键下的
{chat: {}, task: {}}结构,时间戳同时承担"是否置顶"与"置顶先后"两个语义,且读取路径有归一化兜底,脏数据不会破坏 UI; - 前后端职责清晰:后端只管校验与持久化(
toggle_pin/get_pins),前端 store 管排序、分隔线与乐观更新,二者通过两个 JSON 端点解耦; - 扩展点而非侵入:通过
sidebarStore.registerRowListExtension注册sort/dividerBefore回调影响渲染,不修改聊天/任务 store 本身,从而与侧边栏宿主(webui/components/sidebar/sidebar-store.js)保持松耦合; - 安全不缩水:尽管没有配置界面,两个端点仍保留默认的登录认证与 CSRF 校验。
结合 README 的功能描述、DOX 契约 的实现约束、pins.py 的状态逻辑与 test_pins.py 的行为验证,本文覆盖了该插件从持久化格式到 UI 呈现的完整链路,可作为理解 Agent Zero 内置 WebUI 插件结构与开发规范(参考 plugins/AGENTS.md 及 docs/developer/plugins.md)的入门案例。
atomcodeClaude Code 的开源替代方案。连接任意大模型,编辑代码,运行命令,自动验证 — 全自动执行。用 Rust 构建,极致性能。 | An open-source alternative to Claude Code. Connect any LLM, edit code, run commands, and verify changes — autonomously. Built in Rust for speed. Get StartedRust4.24 K638- DDeepSeek-V4.1-FlashDeepSeek-V4.1-Flash 是一个多模态混合专家(MoE)模型,拥有 5520 亿骨干参数,并支持最多一百万 token 的上下文长度。该模型原生支持图像和文本输入,并以自回归方式生成文本Python670
SlideSCIPPT插件,支持素材库、AI助手、一键添加图片标题,复制粘贴位置、一键图片对齐、一键插入Markdown(加粗、超链接等行内样式、代码块、LaTeX等块级样式)、便捷导出图片!C#230
hello-agents📚 《从零开始构建智能体》——从零开始的智能体原理与实践教程Python52874
new-apiAI模型聚合管理中转分发系统,一个应用管理您的所有AI模型,支持将多种大模型转为统一格式调用,支持OpenAI、Claude、Gemini等格式,可供个人或者企业内部管理与分发渠道使用。🍥 A Unified AI Model Management & Distribution System. Aggregate all your LLMs into one app and access them via an OpenAI-compatible API, with native support for Claude (Messages) and Gemini formats.Go22545
JeecgBoot🔥企业级低代码平台集成了AI应用平台,帮助企业快速实现低代码开发和构建AI应用!前后端分离架构 SpringBoot,SpringCloud、Mybatis,Ant Design4、 Vue3.0、TS+vite!强大的代码生成器让前后端代码一键生成,无需写任何代码! 引领AI低代码开发模式: AI生成->OnlineCoding-> 代码生成-> 手工MERGE,显著的提高效率,又不失灵活~Java36351