--- title: 全栈插件 description: 实验性。一个 PluginContribution 可以同时交付带工作区页面和会话动作的浏览器代码、带认证的后端动作和模型工具。本章涵盖每个字段和校验规则、两种浏览器传输方式(内联模块和清单列出的静态资源)、浏览器 API、HTTP 端点与缓存、安全模型,并逐步讲解书签示例。 --- import { Callout } from "nextra/components"; # 全栈插件 **实验性。** 插件契约为 `api_version` 1。内联的 `BrowserModule` 传输方式随 `deerflow-extension-api` 0.2.2 引入,基于清单的 `BrowserAssets` 传输方式随 **0.2.3** 引入。两者共享页面和动作接口,这些接口在 1.0 之前仍可能变化。 其他贡献类型改变的是宿主在幕后做的事。**插件**则增加一个用户看得见、用得上的功能:工作区里的一个页面、会话菜单里的一个入口、页面可以调用的后端动作,以及模型可以调用的工具。这一切都来自一个通过常规扩展流程安装的 Python 包,不需要重新构建 DeerFlow 前端,宿主里也没有专门针对某个插件的代码。 ## 用户看到什么 | 界面 | 出现位置 | | ------------------ | --------------------------------------------------------------------------------------------------------- | | **扩展**标签页 | 能力中心(`/workspace/capabilities?tab=extensions`)。每个已安装插件一张只读卡片:标题、描述,以及"已启用 · 由管理员管理"或"已停用 · 由管理员管理" | | 工作区页面 | `/workspace/extensions/{namespace}/{surface id}`,可选侧边栏入口。URL 由宿主生成,插件无法占用任意路径 | | 会话动作 | 聊天工具栏和侧边栏每个会话菜单里的一个菜单组 | | 模型工具 | Agent 常规工具集的一部分,与其他工具一样受分组过滤和策略约束 | 界面里没有任何地方能安装、启用或配置插件。目录对所有人都是只读的,包括管理员。浏览器会一直沿用它发现的插件集合,直到用户手动刷新页面。 ## 贡献声明 `install()` 构建一个 `PluginContribution` 并传给 `registry.plugin()`: | 字段 | 类型 | 默认值 | 含义 | | ------------- | ------------------------ | ------------ | ------------------------------------------------------------------------------------- | | `namespace` | `str` | 必填 | 唯一标识,匹配 `[a-z][a-z0-9_.-]{0,95}`,例如 `community.bookmarks`。用于 URL 和工具名 | | `title` | `str` | 必填 | 卡片和页面标题,不能为空 | | `description` | `str` | `""` | 卡片文字 | | `enabled` | `bool` | **`False`** | 插件开关。为 `False` 时动作和工具拒绝运行。见[启用插件](#启用插件) | | `fields` | `tuple[SettingsField]` | `()` | 非机密的部署设置。见[设置](#设置) | | `frontend` | `BrowserModule \| BrowserAssets \| None` | `None` | 浏览器代码。见[浏览器代码](#浏览器代码) | | `backend` | `tuple[BackendAction]` | `()` | 浏览器代码可以调用的动作 | | `tools` | `tuple[ModelTool]` | `()` | 模型可以调用的工具 | | `api_version` | `int` | `1` | 必须为 `1` | 一个贡献至少要提供 `frontend`、`backend`、`tools` 之一。注册表会在存储任何内容之前校验整个贡献,遇到第一处违规即抛出 `ValueError`: | 规则 | 错误信息 | | ------------------------------------------------------------------------------ | ------------------------------------------------------------- | | `api_version` 为 `1` | `Unsupported plugin contract` | | 至少有一个浏览器模块、后端动作或工具 | `A plugin must contribute a browser module, backend action or tool` | | 工具名匹配 `[a-z][a-z0-9_]{0,63}`、互不重复,且有描述和 `async` 处理函数 | `Model tools require unique names, descriptions and async handlers` | | 工具 schema 是合法、内联、object 类型的 JSON Schema(见[模型工具](#模型工具)) | `Invalid plugin object schema` | | 动作名匹配 `[a-z][a-z0-9_-]{0,63}`、互不重复,且有 `async` 处理函数 | `Backend actions require unique names and async handlers` | | `frontend` 是 `BrowserModule`、`BrowserAssets` 或 `None` | `Unsupported browser transport` | | 内联代码非空,UTF-8 编码后不超过 512 KiB | `Browser code must be nonempty and at most 512 KiB` | | 资源清单及其列出的每个文件都符合[资源规则](#清单规则) | `Unsupported browser asset manifest; ...`、`Browser assets must not contain symlinks` 等 | | 命名空间、设置字段和模块标识合法(见[设置](#设置)) | `Invalid settings contribution or namespace`、`Invalid or duplicate settings field` 等 | | 命名空间和浏览器模块标识在所有已加载插件中唯一 | `Duplicate plugin namespace`、`Duplicate browser module` | 从 `install()` 抛出的任何异常,包括这些 `ValueError`,以及清单列出的文件不存在时抛出的 `FileNotFoundError`,都会让这个扩展整体加载失败:它注册的一切都会回滚,Gateway 日志会记录原因(见[运行时](/docs/harness/extensions/runtime))。 ### 检查返回值 宿主接受贡献时,`registry.plugin()` 返回 `True`。契约的默认实现返回 `False`,不支持插件的旧宿主就是这样。一个离开界面就没有意义的插件应该明确报错,而不是只装上一半: ```python if registry.plugin(contribution) is not True: raise RuntimeError("this extension requires a host with full-stack plugin support") ``` ## 启用插件 插件有**两个**开关,都要打开: - `plugins:` 记录里的 `enabled` 决定 Gateway 是否导入这个包。扩展管理器的 `enable` 和 `disable` 命令切换的就是它。 - `PluginContribution.enabled` 控制这个贡献本身。它默认为 `False`,而且没有运行时覆盖存储,所以从不设置它的插件会一直处于停用状态:`GET /api/plugins` 报告 `enabled: false`,浏览器不加载它的模块,动作返回 `403 Plugin disabled by administrator.`,它的工具也不会进入任何 Agent。 惯例是从包的私有配置里读取第二个开关,让运维人员在 `config.yaml` 里控制它: ```python enabled=config.get("enabled", False) is True, ``` ```yaml plugins: - name: notes use: acme_notes:install enabled: true # 加载这个包 config: enabled: true # 打开这个贡献 ``` 两者都只在启动时读取,改动任何一个都需要重启 Gateway。 ## 设置 `SettingsField` 声明一个非机密的部署值,动作和工具会收到它,浏览器也可以选择性地看到: | 字段 | 默认值 | 含义 | | --------------------- | -------- | ------------------------------------------------------ | | `key` | 必填 | 匹配 `[a-z][a-z0-9_]{0,63}`。`enabled` 是保留键 | | `title` | 必填 | 显示标签 | | `kind` | 必填 | `"boolean"`、`"integer"` 或 `"string"` | | `default` | 必填 | 取值。必须符合 `kind` 和下面的边界 | | `description` | `""` | | | `minimum` / `maximum` | `None` | 整数边界 | | `max_length` | `256` | 字符串长度上限,1 到 4096 | 宿主会自己加上布尔字段 `enabled`。算上它,一个插件最多 32 个字段,留给你的是 31 个。取值只来自包本身:`default` 就是每个动作、工具和浏览器收到的值。没有设置 API,也不能在线编辑,所以运维需要调整的值,请在 `install()` 里从 `config` 推导默认值。 设置不是机密存储。不要把凭据放进 `SettingsField`。在 `install()` 里从环境变量或私有 `config` 读取机密,并保存在你自己的 Python 对象中。 浏览器只能看到 `enabled` 和浏览器声明的 `public_fields` 中列出的键(`BrowserModule` 和 `BrowserAssets` 都有这个字段)。列出一个未声明的字段会导致校验失败。 ## 后端动作 ```python BackendAction(name: str, handler: async (payload, context) -> Any) ``` 浏览器经由宿主调用动作,处理函数收到: - `payload`:从 JSON 请求体解析出的只读映射。宿主只保证它是一个不超过 256 KiB 的 JSON **对象**,每个字段都要自己校验。 - `context`:一个 `ActionContext`,包含 `principal`(已认证调用方的 `ExtensionPrincipal`:`user_id`、`is_admin`、`is_internal`、`roles`)和 `settings`(只读的设置映射)。 返回值必须可以 JSON 序列化。宿主把结果映射成 HTTP 状态码,不会暴露你的异常文本: | 结果 | 状态码 | 响应体 `detail` | | ------------------------------------- | ------ | ---------------------------------------------- | | 处理函数正常返回 | `200` | 你的返回值 | | 处理函数抛出 `ValueError` | `422` | `Invalid plugin action input.` | | 处理函数抛出其他异常 | `502` | `Plugin action failed.`(日志中记录异常类型) | | 处理函数运行超过 30 秒 | `504` | `Plugin action timed out.` | | 请求体不是 JSON 对象 | `422` | `Plugin action requires a JSON object.` | | 请求体超过 256 KiB | `413` | `Plugin action input exceeds 256 KiB.` | | 插件已停用 | `403` | `Plugin disabled by administrator.` | | 未知的命名空间或动作 | `404` | `Plugin is not installed.` / `Plugin action is not installed.` | | viewer 请求头与会话不一致 | `409` | `Account changed; reload this plugin view.` | | 没有已认证的调用方 | `401` | `Authentication required.` | 处理函数无法自己决定状态码,因为就连抛出 `HTTPException` 也会变成 `502`。输入错误时抛出 `ValueError`;浏览器需要错误详情时,把它放在返回体里。超时会取消处理函数,但不会回滚外部副作用,也不会中止已经在工作线程里运行的任务。 **授权由你负责。** 宿主负责认证调用方,但不知道哪些记录属于谁。每次读写都要按 `context.principal.user_id` 限定范围,就像书签示例在每条查询里都带上属主条件一样。 ## 模型工具 ```python ModelTool(name, description, input_schema, handler, group="extensions") ``` - **名称。** 模型看到的是由命名空间派生的名字:`ext___`。其中命名空间被清洗为 `[a-z0-9_]` 并截断到 20 个字符,名称截断到 25 个字符,再加 12 位哈希。例如 `acme.notes` 里的 `count_notes` 会变成 `ext_acme_notes_count_notes_d8aad91f72da`。哈希覆盖完整的命名空间和名称,所以截断不会让两个工具合并;万一两个插件工具仍然同名,工具组装会直接报错而不是二选一。与普通工具重名时,保留普通工具,跳过插件工具并记录警告。 - **Schema。** `input_schema` 必须是合法的 JSON Schema(draft 2020-12),`"type": "object"`,而且完全内联:`$ref`、`$dynamicRef`、`$recursiveRef` 都会被拒绝,因此校验永远不会解析引用或访问网络。参数名 `runtime` 和 `config` 是保留的。 - **限制。** 宿主会按 schema 校验每次调用的输入,上限 256 KiB。处理函数必须在 30 秒内返回,JSON 编码后的结果不能超过 64 KiB。 - **上下文。** 处理函数收到一个 `ToolContext`:`settings`、`thread_id`(不在会话中时为 `None`)和 `principal`。对工具来说,principal **只**带 `user_id`,`is_admin` 恒为 `False`,`roles` 为空。不要让工具的授权依赖它们。 - **错误。** 任何失败都以通用工具错误的形式交给模型:`Invalid plugin tool input.`、`Plugin result exceeds 64 KiB.`、`Plugin disabled by administrator.` 或 `Plugin tool unavailable or input rejected.`。Gateway 日志会记录插件、工具和异常类型。 - **组装与策略。** 插件工具加入常规的工具组装流程。通过 `tool_groups` 限制过的 Agent,只有在列表包含该工具的 `group`(默认为 `extensions`)时才会拿到它。宿主之后的授权和技能工具策略与其他工具一样适用。 工具描述就是提示词。写清楚工具返回什么、返回的是数据而不是指令,以及它是否只读。 ## 浏览器代码 插件用两种传输方式之一交付浏览器代码。两者产出的东西相同:一个默认导出符合[浏览器 API](#浏览器-api) 的 ES 模块。`GET /api/plugins` 会标出每个插件的传输方式,浏览器据此决定如何加载。 | 传输方式 | 声明 | 引入版本 | 适用场景 | | ----------- | ---------------- | -------- | ---------------------------------------------------------------------------- | | `inline-v1` | `BrowserModule` | 0.2.2 | 单个不超过 512 KiB 的自包含文件:没有相对导入、CSS 文件、图片或 WASM | | `assets-v1` | `BrowserAssets` | 0.2.3 | 清单中列出的一个目录的文件:拆分的模块、样式表、图片、字体、WASM、source map | 两种方式中,`module` 都是匹配 `[a-z][a-z0-9.-]{0,95}` 的标识,例如 `bookmarks.v1`,绝不是 URL,并且必须与默认导出里的 `module` 相同。使用 `BrowserAssets` 的插件需要 `deerflow-extension-api>=0.2.3`,宿主的前端和后端都必须是对应版本。旧版前端无法加载 `assets-v1` 插件,但失败只影响这一个插件。 ### 内联模块 ```python BrowserModule(module: str, code: str, public_fields: tuple[str, ...] = ()) ``` `code` 是一个自包含 ES 模块的完整文本。宿主带着会话获取它,再从临时 Blob URL 导入,所以不能有相对导入,也不能有相对于 `import.meta.url` 解析的资源。把它打进 wheel,在 `install()` 里读取: ```python BrowserModule("notes.v1", Path(__file__).with_name("client.mjs").read_text(encoding="utf-8")) ``` 配置了内容安全策略(CSP)的部署必须在 `script-src` 中允许 `blob:`。 ### 静态资源 ```python BrowserAssets(module: str, root: str | Path, manifest: str = "ui_manifest.json", public_fields: tuple[str, ...] = ()) ``` `root` 是已安装包内的一个目录,通常是 `Path(__file__).parent`。清单文件相对于 `root`,列出浏览器可以加载的每一个文件: ```json filename="ui_manifest.json" { "schema_version": 1, "entry": "static/dist/index.mjs", "files": [ "static/dist/index.mjs", "static/dist/chunks/page.mjs", "static/dist/notes.css" ] } ``` 入口模块可以用相对路径导入同级模块,并用 `new URL(..., import.meta.url)` 定位资源: ```js filename="static/dist/index.mjs" import { mountNotes } from "./chunks/page.mjs"; export default { apiVersion: 1, module: "notes.v1", surfaces: [ { id: "notes", slot: "page", title: "Notes", navigation: { label: "My notes", labelZh: "我的笔记" }, mount: mountNotes, }, ], }; ``` ```js filename="static/dist/chunks/page.mjs" export function mountNotes(root, context) { const style = document.createElement("link"); style.rel = "stylesheet"; style.crossOrigin = "use-credentials"; style.href = new URL("../notes.css", import.meta.url).href; const list = document.createElement("ul"); root.append(style, list); context .callBackend("list", {}) .then(({ notes }) => { for (const note of notes) { const item = document.createElement("li"); item.textContent = note; list.append(item); } }) .catch(() => {}); return { dispose: () => root.replaceChildren() }; } ``` ```python frontend=BrowserAssets("notes.v1", Path(__file__).parent), ``` 把清单和列出的每个文件都打进 wheel。用 hatchling 打包 Python 包目录时,不需要额外配置就会包含它们。第三方依赖要打包进来:宿主不解析裸 npm 导入,也不提供共享的 React 实例。让打包工具输出相对 URL,而不是 `/assets/...` 这样的绝对路径。 #### 清单规则 注册表在 `install()` 期间读取、校验每个列出的文件,并把它们快照到内存。任何违规都会让扩展加载失败: | 规则 | 错误信息 | | ------------------------------------------------------------------------------------------- | --------------------------------------------------------------- | | 清单是只含 `schema_version`(整数 `1`)、`entry` 和 `files` 三个键的 JSON 对象 | `Unsupported browser asset manifest; expected schema_version 1` | | 没有重复的键 | `Duplicate browser manifest key` | | `files` 列出 1 到 256 个互不重复的合法路径 | `Browser manifest must list unique asset paths` | | `entry` 是 `files` 之一,并以 `.js` 或 `.mjs` 结尾 | `Browser manifest entry must be a listed JavaScript module` | | 每个文件的类型都受支持(见下) | `Unsupported browser asset file type` | | `root` 不是符号链接,列出路径的任何一级都不是符号链接 | `Browser asset root must not be a symlink`、`Browser assets must not contain symlinks` | | 每个列出的文件都是 `root` 内的普通文件 | `Browser asset must be a regular file inside its package`(文件不存在时抛出 `FileNotFoundError`) | | 清单不超过 64 KiB,单个文件不超过 4 MiB,所有文件合计不超过 16 MiB | `Browser asset size limit exceeded` | 合法路径最长 512 个字符,由 `/` 分隔的段组成;每段只能用 ASCII 字母、数字、`_`、`-` 和 `.`,并以字母、数字、`_` 或 `-` 开头。因此点文件、`.` 和 `..` 段、空段、反斜杠、百分号编码和查询字符串都不被允许。 支持的类型(括号内为返回的 MIME 类型):`.js` 和 `.mjs`(`text/javascript`)、`.css`、`.json` 和 `.map`(`application/json`)、`.wasm`、`.png`、`.jpg`/`.jpeg`、`.gif`、`.webp`、`.svg`、`.ico`、`.woff`、`.woff2`、`.ttf` 和 `.otf`。HTML 和服务端文件永远不会被提供。 #### 修订版本与缓存 宿主对清单和每个文件的内容哈希计算一个 SHA-256 **修订版本**,每个 URL 都包含它:`/api/plugins/{namespace}/assets/{revision}/{path}`。任何一个文件变了,所有文件的修订版本都会变。请求由启动时的快照响应,从不读取文件系统,所以修改已安装的文件在重启前不会生效。 资源响应带有 `Cache-Control: private, max-age=31536000, immutable` 和 `Vary: Cookie, Authorization`。未知的路径或修订版本返回 `404`,带 `private, no-store`。宿主不保留历史快照,也没有公开 CDN 的约定。升级之后,仍持有旧修订版本的页面对未缓存的内容会得到 `404`,必须刷新。反过来,插件被移除后,浏览器仍可以继续使用已缓存的代码,所以移除并不等于立即撤销缓存。 #### 浏览器如何加载资源 宿主插入一个指向入口 URL、带 `crossorigin="use-credentials"` 的原生模块脚本,再从文档的模块映射里读取导出。因此相对的静态和动态导入都会在同一个修订版本内解析,并带上会话 cookie。URL 遵循 `NEXT_PUBLIC_BACKEND_BASE_URL`,包括路径前缀。 - **超时。** 宿主等待 30 秒后停止等待,并把该插件标为不可用。这是等待的期限,不是取消:模块之后仍可能完成求值。顶层代码不要有副作用,UI 工作放到 `mount` 里开始。 - **模块状态是共享的。** 一个模块实例由整个文档共享。按查看者区分的数据放在每次挂载的状态里,并在 `dispose()` 中清除。不要在模块级别跨账号切换缓存 principal 或私有结果。 - **其他资源由你负责。** 你创建的样式表和图片要设置 `crossOrigin = "use-credentials"`,就像 `page.mjs` 那样;JSON、WASM 或二进制数据用 `fetch(url, { credentials: "include" })`。CSS 里的字体和背景 URL 并不总会带上跨域 cookie。对这些资源,优先使用同源部署,或者带凭据获取后构造 `FontFace` 或 Blob URL,并在 dispose 时释放。 - **CSP 与 CORS。** 后端来源必须在相应的 `script-src`、`style-src`、`img-src`、`font-src` 和 `connect-src` 指令中被允许。前后端分离部署需要精确来源的带凭据 CORS,以及可用的会话 cookie。 ### 浏览器 API 模块的默认导出必须符合宿主的浏览器 API v1: ```js export default { apiVersion: 1, // 必填 module: "notes.v1", // 必填,与声明里的 module 相同 icon: "file-text", // 可选 surfaces: [/* 页面 surface,最多 16 个 */], conversationActions(t, locale) {/* 返回一个动作组 */}, }; ``` 如果 `apiVersion` 或 `module` 不匹配,或者任何 surface、导航项格式不对,宿主会拒绝这个模块,并在它的卡片上显示"当前页面加载失败"。 ### 页面 surface | 字段 | 规则 | | ------------ | ------------------------------------------------------------------- | | `id` | `[a-z][a-z0-9-]{0,63}`,模块内唯一 | | `slot` | `"page"`(目前唯一的 slot) | | `title` | 非空字符串 | | `navigation` | 可选的 `{ label, labelZh?, icon? }`,添加侧边栏入口。标签最多 120 个字符 | | `mount` | `(root, context) => { dispose }`,同步调用 | 宿主把页面挂载到一个 Shadow DOM 根节点,并传入上下文: | 上下文成员 | 含义 | | ------------------------------ | --------------------------------------------------------------------------- | | `namespace`、`locale` | 插件命名空间和界面语言,例如 `en-US` 或 `zh-CN` | | `settings` | `enabled` 加上 `public_fields` | | `signal` | 卸载或切换账号时中止。传给事件监听和请求 | | `callBackend(action, payload)` | 向本插件声明过的某个动作发 POST,解析为 JSON 响应。非 2xx 响应和未声明的动作会被拒绝 | | `openConversation(threadId)` | 可选。先通过认证 API 解析会话再跳转;会话不存在或无权访问时拒绝,不会跳转 | `mount` 必须返回一个带同步 `dispose()` 的对象。卸载时宿主先中止 `signal` 再调用它,并拦截之后才到达的 `callBackend` 结果。 ### 会话动作 `conversationActions(t, locale)` 必须是同步的,返回 `{ label, icon, actions }`。每个动作有 `id`、`label`、`icon`,一个返回布尔值的同步 `available(settings)`,以及 `execute(context, services)`: - `context.thread` 是当前会话;宿主手上已有消息时,`context.messages` 就是这些消息。 - `services.callBackend` 与上面相同。`services.latestVisibleAnswer(context)` 返回最后一条可见的助手消息 `{ id, text }` 或 `null`。`services.conversationText(context)` 返回可见的对话文本。两者都复用宿主的导出清洗逻辑,会排除隐藏消息、工具输出和推理内容。`services.showMessage(text)` 弹出一条提示。 每个插件的动作都在各自的错误边界里求值。格式错误或抛出异常的动作组会被省略并记录到浏览器控制台,其他插件的动作和会话页面都不受影响。 ### 图标 宿主把图标名映射到一个固定集合:`bell`、`bookmark`、`download`、`file-json` 和 `file-text`。其他名字都会显示为通用的拼图图标。 ## HTTP 端点 宿主通过四个需要认证的 Gateway 路由提供插件,它们遵循常规的会话和 CSRF 策略;没有已认证的调用方时都返回 `401`: | 路由 | 用途 | | ------------------------------------------------------- | ------------------------------------------------------------------------------------------- | | `GET /api/plugins` | 所有已加载插件:`namespace`、`title`、`description`、`viewer_id`、`module`、`entry`、`transport`(`inline-v1`、`assets-v1` 或 `null`)、公开的 `settings`,以及 `backend_actions` 名称 | | `GET /api/plugins/modules/{module}/{sha256}.mjs` | 按内容哈希寻址的内联模块。`text/javascript`、`nosniff`、`private, no-store` | | `GET /api/plugins/{namespace}/assets/{revision}/{path}` | 清单列出的一个文件。声明的 MIME 类型、`nosniff`、`Content-Security-Policy: sandbox`,以及[修订版本与缓存](#修订版本与缓存)中描述的不可变私有缓存 | | `POST /api/plugins/{namespace}/actions/{name}` | 调用一个后端动作(见[后端动作](#后端动作)) | 内联哈希或资源修订版本过期时返回 `404`,需要刷新页面。两个下载路由在插件停用时仍然会提供它的代码。 浏览器会带上 `X-Deerflow-Plugin-Viewer` 请求头,值为它发现插件时得到的 `viewer_id`,所以在另一个账号下打开的视图发起的动作会以 `409` 被拒绝。 ## 安全模型 浏览器模块和 Python 处理函数都是运维人员安装的受信任代码。浏览器代码运行在**主页面里**, 拥有当前登录用户的同源能力;Shadow DOM 隔离的是 CSS,不是权限。Python 处理函数在 Gateway 进程中以 Gateway 权限运行。 宿主确实保证的: - 它从不从浏览器提供的路径、导入字符串或远程 URL 加载代码,只提供已安装、经内容哈希校验的内联模块,或清单列出、从启动快照读取的文件。它从不提供目录列表或未列出的文件。 - 资源响应带有 `Content-Security-Policy: sandbox`,所以直接打开一个资源(比如内含脚本的 SVG)不能以 Gateway 的来源运行脚本。这限制的是被当作文档打开的资源,并不会把宿主加载进页面的插件 JavaScript 放进沙箱。 - 每个端点都要求已认证的调用方,而调用方身份来自会话,不来自请求体。 - `callBackend` 绑定在插件自己的命名空间和它声明过的动作名上。 - 只有 `enabled` 和显式公开的字段会到达浏览器。 任何已认证用户都可以下载清单列出的全部资源,包括 source map,即使插件已停用。不要在清单里列出机密或私有源码。 仍然是插件自己的职责:按 `principal.user_id` 对自己的数据做授权,校验每个 payload,渲染用户和模型文本时做转义(用 `textContent`,绝不用 `innerHTML`),并把工具结果当作不可信数据。 ## 逐步讲解:书签示例 [`examples/deerflow-extension-bookmarks`](https://github.com/bytedance/deer-flow/tree/main/examples/deerflow-extension-bookmarks)(0.2.0 版,需要 `deerflow-extension-api>=0.2.3`)是一个完整的插件:用户在会话菜单里收藏最后一条可见回答,然后在 **我的书签** 页面搜索、重命名、打开或删除书签,Agent 也可以搜索这些书签。它用到了本章的每一部分: 1. **配置和状态。** `install()` 要求 `config.enabled`(布尔值)和一个绝对路径 `config.storage_path`,并在那里打开 SQLite 存储。容器部署时要为它挂载持久目录。 2. **贡献声明。** 命名空间 `community.bookmarks`,`enabled=config["enabled"]`,`BrowserAssets("bookmarks.v1", Path(__file__).parent)`,五个 `BackendAction`(`save`、`search`、`get`、`rename`、`delete`),以及一个只读的 `ModelTool`:`search_bookmarks`,模型看到的名字是 `ext_community_bookmarks_search_bookmarks_`。 3. **检查返回值。** 如果 `registry.plugin(...)` 不是 `True`,它会抛出 `RuntimeError`。 4. **数据归属。** 每条 SQL 语句都带有 `owner = context.principal.user_id`,每个动作只接受一组确定的 payload 键。模型工具从不接受用户 ID,所以用户永远只能搜索自己的书签。 5. **资源。** `ui_manifest.json` 列出 `static/dist/` 下的四个文件:入口 `index.mjs`、页面模块 `chunks/bookmarks.mjs`、`styles.css` 和 `bookmark.svg`。它们随 Python 包一起发布,不需要 JavaScript 构建步骤。 6. **入口模块。** `index.mjs` 从 `./chunks/bookmarks.mjs` 导入 `mountBookmarks`,导出一个 `page` surface `library`,带 `navigation: { label: "My bookmarks", labelZh: "我的书签", icon: "bookmark" }`,地址为 `/workspace/extensions/community.bookmarks/library`;还导出一个"书签 → 收藏最后一条回答"会话动作,先调用 `services.latestVisibleAnswer`,再调用 `callBackend("save", ...)`。 7. **页面模块。** `chunks/bookmarks.mjs` 相对于 `import.meta.url` 解析 `../styles.css` 和 `../bookmark.svg`,并用 `crossorigin="use-credentials"` 加载它们,因此在前后端分离部署下也能工作。它监听 `context.signal`,用 `textContent` 渲染文本,并在 `dispose()` 中清空自己的 DOM。 部署记录如下: ```yaml plugins: - name: bookmarks use: deerflow_extension_bookmarks:install enabled: true config: enabled: true storage_path: /var/lib/deerflow/bookmarks.sqlite ``` 重启并刷新浏览器后,扩展标签页里会出现它的卡片,侧边栏多出 **我的书签**,会话菜单多出 **书签**。这个示例使用单机 SQLite 存储,不是多节点存储的范式。 ## 最小插件 最小的实用形态是一个页面、两个动作和一个只读工具。这个例子使用内联传输方式,所以只有一个 `client.mjs`;要拆成多个文件,请改用[静态资源](#静态资源),就像那一节里的 `acme.notes` 那样。下面每一部分都已经在宿主的注册表和路由上实际跑过: ```python """A per-user scratchpad: a page, one backend action, one read-only model tool.""" from __future__ import annotations from collections.abc import Mapping from pathlib import Path from typing import Any from deerflow_extension_api import ( ActionContext, BackendAction, BrowserModule, ModelTool, PluginContribution, SettingsField, ToolContext, extension, ) NOTES: dict[str, list[str]] = {} # demo only: in-memory, single process async def add_note(payload: Mapping[str, Any], context: ActionContext) -> dict[str, Any]: text = payload.get("text") if not isinstance(text, str) or not text.strip(): raise ValueError("text is required") # becomes HTTP 422 notes = NOTES.setdefault(context.principal.user_id, []) if len(notes) >= context.settings["max_notes"]: raise ValueError("note limit reached") notes.append(text.strip()) return {"count": len(notes)} async def list_notes(payload: Mapping[str, Any], context: ActionContext) -> dict[str, Any]: return {"notes": NOTES.get(context.principal.user_id, [])} async def count_notes(payload: Mapping[str, Any], context: ToolContext) -> dict[str, Any]: return {"count": len(NOTES.get(context.principal.user_id, []))} @extension(api="0.2.2", name="notes") def install(registry, config: Mapping[str, Any]) -> None: accepted = registry.plugin( PluginContribution( namespace="acme.notes", title="Notes", description="A private scratchpad for each user.", enabled=config.get("enabled", False) is True, fields=(SettingsField("max_notes", "Maximum notes", "integer", 50, minimum=1, maximum=500),), frontend=BrowserModule( "notes.v1", Path(__file__).with_name("client.mjs").read_text(encoding="utf-8"), public_fields=("max_notes",), ), backend=(BackendAction("add", add_note), BackendAction("list", list_notes)), tools=( ModelTool( "count_notes", "Count the current user's saved notes. Read-only.", {"type": "object", "properties": {}, "additionalProperties": False}, count_notes, ), ), ) ) if accepted is not True: raise RuntimeError("acme-notes requires a host with full-stack plugin support") ``` 它的 `client.mjs` 放在 `__init__.py` 旁边,并打进 wheel: ```js function mountNotes(root, context) { const list = document.createElement("ul"); const input = document.createElement("input"); const add = document.createElement("button"); add.textContent = context.locale.startsWith("zh") ? "添加" : "Add"; root.append(input, add, list); const render = async () => { const { notes } = await context.callBackend("list", {}); list.replaceChildren( ...notes.map((note) => { const item = document.createElement("li"); item.textContent = note; // textContent, never innerHTML return item; }), ); }; add.addEventListener( "click", async () => { await context.callBackend("add", { text: input.value }); input.value = ""; await render(); }, { signal: context.signal }, ); render().catch(() => {}); return { dispose: () => root.replaceChildren() }; } export default { apiVersion: 1, module: "notes.v1", surfaces: [ { id: "notes", slot: "page", title: "Notes", navigation: { label: "My notes", labelZh: "我的笔记" }, mount: mountNotes, }, ], conversationActions(_t, locale = "en") { const zh = locale.startsWith("zh"); return { label: zh ? "笔记" : "Notes", icon: "file-text", actions: [ { id: "save-answer", label: zh ? "把最后一条回答存为笔记" : "Save last answer as a note", icon: "file-text", available: (settings) => settings.enabled === true, async execute(context, services) { const answer = await services.latestVisibleAnswer?.(context); if (!answer) return services.showMessage(zh ? "没有可保存的回答" : "No answer to save"); await services.callBackend("add", { text: answer.text.slice(0, 2000) }); services.showMessage(zh ? "已保存" : "Saved"); }, }, ], }; }, }; ``` 内存字典只用于示例。真实插件需要能在重启后保留、并在多个 Gateway worker 之间共享的存储。 ## 生命周期 - 安装、升级、启用、停用和任何 `config` 改动都需要重启 Gateway,然后刷新浏览器。没有热加载,也没有热卸载。 - 重启之后,已打开的页面可能指向已不存在的模块哈希、资源修订版本或动作。这些请求会明确失败(`404`),直到用户刷新页面。 - 已停用或已移除的插件在刷新后没有侧边栏入口。直接访问它的页面 URL 会显示"扩展页面不可用",不会挂载任何内容。 运维命令见[运维](/docs/harness/extensions/operations)。