首页
/ Agent Zero 内置插件 Pin to Top:侧边栏聊天与任务置顶功能的实现全解

Agent Zero 内置插件 Pin to Top:侧边栏聊天与任务置顶功能的实现全解

2026-09-13 15:02:40作者:范靓好Udolf

本文基于 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 的内置插件,核心职责可归纳为四点:

  1. 上下文感知的菜单动作:在侧边栏行菜单(row menu)中添加 "Pin to Top" / "Unpin from Top" 动作,菜单标签与图标会随当前行是否已置顶而变化;
  2. 置顶顺序与原始顺序:已置顶条目按置顶先后顺序排列(先置顶的排在前),未置顶条目之间保持原有顺序;
  3. 分隔线:置顶组与未置顶组之间用侧边栏标准分隔线隔开;
  4. 持久化:置顶状态保存在 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")

即聊天与任务的置顶互不干扰,分别存放在 chattask 两个子组里。

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 只能是 chattask,否则报 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 从请求体取 kinditem_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 行)对 chattask 两种行列表各注册一个扩展:

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;随后 POST toggle_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-1task-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
  • 默认安全策略GetPinsTogglePinrequires_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.yamlalways_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.mddocs/developer/plugins.md)的入门案例。

登录后查看全文
热门项目推荐
相关项目推荐

项目优选

收起
kernelkernel
deepin linux kernel
C
34
18
ops-transformerops-transformer
本项目是CANN提供的transformer类大模型算子库,实现网络在NPU上加速计算。
C++
1.21 K
2.81 K
ops-nnops-nn
本项目是CANN提供的神经网络类计算算子库,实现网络在NPU上加速计算。
C++
945
1.86 K
docsdocs
暂无描述
Markdown
906
5.84 K
kernelkernel
openEuler内核是openEuler操作系统的核心,既是系统性能与稳定性的基石,也是连接处理器、设备与服务的桥梁。
C
537
607
pytorchpytorch
作为 Ascend for PyTorch 社区的核心组件,TorchNPU 是昇腾专为 PyTorch 打造的深度学习适配插件,使 PyTorch 框架能够直接调用昇腾 NPU,为开发者提供昇腾 AI 处理器的超强算力。
Python
864
1.36 K
jiuwenswarmjiuwenswarm
JiuwenSwarm 是一款基于openJiuwen开发的智能AI Agent,它能够将大语言模型的强大能力,通过你日常使用的各类通讯应用,直接延伸至你的指尖。
Python
4.28 K
1.03 K
ops-mathops-math
本项目是CANN提供的数学类基础计算算子库,实现网络在NPU上加速计算。
C++
1.39 K
1.48 K
AscendNPU-IRAscendNPU-IR
AscendNPU-IR是基于MLIR(Multi-Level Intermediate Representation)构建的,面向昇腾亲和算子编译时使用的中间表示,提供昇腾完备表达能力,通过编译优化提升昇腾AI处理器计算效率,支持通过生态框架使能昇腾AI处理器与深度调优
C++
550
401
flutter_flutterflutter_flutter
本仓库是 Flutter SDK 与 Flutter Engine 的 OpenHarmony 适配版本,由 CPF-Flutter 团队维护。开发者可使用熟悉的 Flutter 技术栈开发 OpenHarmony 应用,3.35.7 及以后的适配版本可基于本仓库源码构建支持 OpenHarmony 的 Flutter Engine。
Dart
1.19 K
347