插件开发

自 v0.91.0 起

Tavo 插件是 zip 格式的 .tpg 包。

插件能做什么

插件可以把可复用的玩法和工具加到 Tavo 中:

  • 添加自己的设置页,让用户调整开关、选项、文本、滑杆等配置。
  • 在聊天输入框 + 菜单、右侧边栏或最后一条稳定消息的 action bar 加入动作。
  • 感知 chat/message 变化,并改写或取消输入框发送。
  • 在聊天页挂载 HTML 片段,例如状态栏、悬浮面板或消息装饰。
  • 通过 TavoJS API 操作输入框、消息、变量、生成等能力。

如果一个扩展需要跨角色、用户身份或聊天复用,就适合做成插件;如果行为只属于某一张角色卡,通常直接写在角色卡的 TavoJS 里更合适。

使用前提

插件需要在聊天中开启高级渲染才能生效。插件代码独立于角色卡和消息内容中的 JavaScript;聊天内容的 JavaScript 设置不会关闭已启用插件的代码。

包结构

先创建一个文件夹,再把文件夹内容压缩为 .tpg 文件。manifest.json 必须位于插件包根目录。如果插件声明了输入动作、右侧边栏动作或最后一条消息动作,entry 指向的文件也必须存在于插件包内。

my-plugin/
├── manifest.json
├── entry.js
├── locales/
│   ├── en.json
│   └── zh-CN.json
├── ui/
│   └── panel.html
└── cover.png
cd my-plugin
zip -r ../my-plugin.tpg manifest.json entry.js locales ui cover.png

Tavo 会拒绝缺失 manifest、路径不安全、specVersion 不支持或入口脚本文件不存在的插件包。如果插件使用的 specVersion 高于已安装 Tavo 支持的版本,应升级 Tavo,而不是把 manifest 改成字段语义可能不同的旧版本。插件路径是插件包内的虚拟相对路径,所有平台都必须使用正斜杠 /,包括 Windows。不要把 OS 路径分隔符或原始 path.join() 输出写进 manifest 路径;不要在包里放绝对路径、Windows 反斜杠路径,也不要依赖 ../ 路径跳出插件目录。

选择插件入口

先按用户要完成的动作选择插件入口,再写 manifest.json。一个插件可以同时声明多个入口;常见做法是用 settings.schema 保存配置,再用一个或多个入口触发或展示能力。

入口适合场景注意
contributes.inputActions用户正在编辑输入框、需要主动点一下完成的即时动作:生成回答草稿、改写当前输入、插入模板、追加提示词、发送前辅助。菜单文案单行显示;handler 当前不接收参数;没有当前消息上下文,tavo.message.current() 返回 null
contributes.sidebar聊天级、低频但明确的工具动作:总结聊天、导出或保存、批处理消息、刷新插件状态、打开一次性管理流程。这是右侧边栏里的原生动作,不是内嵌面板;第一版不支持图标、描述、动态状态或聊天类型过滤。
contributes.lastMessageActions针对当前聊天绝对最后一条稳定消息的快捷动作,例如检查回复、保存要点或运行后处理。只在高级渲染的消息 action bar 中显示。role 默认 character,也可为 characteruserany。点击时 handler 收到 { chatId, messageId, role }tavo.message.current() 返回 null。action bar overflow 不是消息 context menu。
contributes.htmlFragments + /chat聊天页级常驻 UI:状态栏、悬浮面板、全局控制条、样式注入、读取当前聊天或输入框后展示的页面级信息。显示在聊天页面中,没有当前消息上下文。保持轻量,避免遮挡聊天 UI。
contributes.htmlFragments + /messages单条消息附近的 UI:消息装饰、状态标签、每条消息按钮、只挂最后一条角色消息的状态块。每条匹配消息都会渲染一次,可用 role / position 过滤;拥有 tavo.message.current()。状态请使用 chat 作用域,可保存紧凑的当前状态,或以稳定消息 id 为 key 的小型映射。同时也需要控制 DOM 和脚本开销。

避免插件变量值过大

插件变量可保存任意受支持的可序列化值,但不宜存放过大的数据,尤其是图片、音频的 Base64 或 data URL,否则可能拖慢加载甚至导致内存溢出。大内容请使用 tavo.file.*,变量只保存虚拟路径。

manifest.json

manifest.json 描述插件身份、入口脚本、权限声明、设置表单、输入动作、右侧边栏动作、最后一条消息动作和 HTML 片段。

{
  "specVersion": 2,
  "id": "com.example.quick-note",
  "name": { "$t": "plugin.name" },
  "version": "1.0.0",
  "entry": "entry.js",
  "author": "Example Author",
  "description": { "$t": "plugin.description" },
  "releaseNotes": { "$t": "releaseNotes.1_0_0" },
  "localization": {
    "defaultLocale": "en",
    "resources": {
      "en": "locales/en.json",
      "zh-CN": "locales/zh-CN.json"
    }
  },
  "cover": "cover.png",
  "permissions": ["input", "message", "tts"],
  "contributes": {
    "inputActions": [
      { "id": "insert-note", "label": { "$t": "actions.insertNote" } }
    ],
    "sidebar": [
      { "id": "append-note", "label": { "$t": "actions.appendNote" } }
    ],
    "lastMessageActions": [
      {
        "id": "inspect",
        "label": { "$t": "actions.inspect" },
        "icon": "icons/inspect.png",
        "role": "character"
      }
    ],
    "htmlFragments": [
      { "id": "chat-note-panel", "src": "ui/panel.html", "mount": "/chat/body/end" }
    ],
    "settings": {
      "schema": [
        { "type": "info", "text": { "$t": "settings.info" }, "icon": "info" },
        { "key": "enabled", "type": "switch", "label": { "$t": "settings.enabled" }, "default": true },
        {
          "key": "mode",
          "type": "select",
          "label": { "$t": "settings.mode.label" },
          "default": "short",
          "options": [
            { "value": "short", "label": { "$t": "settings.mode.short" } },
            { "value": "detailed", "label": { "$t": "settings.mode.detailed" } }
          ]
        },
        { "key": "strength", "type": "slider", "label": { "$t": "settings.strength" }, "min": 0, "max": 1, "step": 0.1, "default": 0.5 },
        { "type": "break" },
        { "key": "template", "type": "textarea", "label": { "$t": "settings.template.label" }, "default": { "$t": "settings.template.default" } }
      ]
    }
  }
}

根字段

Manifest v2 与 minAppVersion:自 v0.93.0 起

字段必填说明
id小写插件 id。可使用字母、数字、._-,例如 com.author.my-plugin。Tavo 会将它归一为小写。
name显示名称。v2 可写字面字符串或 { "$t": "key" }
versionv2 必须是合法 SemVer,如 1.0.01.0.0-beta.1;v1 仍兼容任意非空版本字符串。
specVersion新插件是使用 2。缺省或显式 1 仍可本地安装,但没有插件包国际化 API。
entry条件必填当插件声明 contributes.inputActionscontributes.sidebarcontributes.lastMessageActions 时必填。它指向插件入口脚本,通常是 entry.js。路径必须是相对路径,不能跳出插件包,并使用 / 分隔。旧版 scripts.actions manifest 仍会作为兼容别名生效;两者同时存在时优先使用 entry
authorv2 是作者名,会显示在插件详情中。必须是 1–32 个用户可见字符的单行文本,不可国际化。
descriptionv2 是插件的详细介绍,会显示在列表和详情中;可国际化并支持 Markdown。
releaseNotes同级 version 对应的更新说明。v2 可国际化并支持 Markdown。每个插件包只描述当前版本,不在字段里重复版本号,也不嵌入完整历史。
localizationv2 是必须有 defaultLocale;可选 resources 把 locale 映射到插件包内 JSON catalog。
cover插件封面图片的相对路径。
minAppVersionv2 可选;声明时必须是合法 SemVer,安装前会与当前 App 版本比较。v1 不执行最低版本拦截。
permissions字符串数组,用于说明插件需要使用的能力,例如 inputmessagegeneratevariablefilenetworktts
contributes声明式插件贡献。

用户可见文本限制

Manifest v2 对字面声明和 $t 解析后的当前语言文本使用同一组限制。字符数按 用户感知字符(Unicode grapheme cluster)计算,而不是 UTF-16 code unit。

位置v2 限制
manifest name必填,1–64 个字符,单行
manifest author必填,1–32 个字符,单行
manifest description必填,1–10,000 个字符,且 UTF-8 不超过 64 KiB;允许多行
manifest releaseNotes可选,1–5,000 个字符,且 UTF-8 不超过 32 KiB;允许多行
input/sidebar/last-message action label1–48 个字符,单行
settings 字段和结构化 select option label1–80 个字符,单行
settings info.text1–500 个字符,允许多行纯文本

所有值都不能只包含空白,也不能包含不安全控制字符;单行字段不能包含换行。 Catalog 中某条翻译违反对应限制时,Tavo 会忽略该条翻译并继续沿 $t 回退。

description 应完整说明插件用途、使用方式、能力与注意事项。建议至少写 200 个字符,同时遵守硬上限。它支持并推荐使用 GitHub Flavored Markdown 的标题、列表、强调和链接;原始 HTML 不会执行。插件列表不新增 summary 字段,而是把当前语言的 description 转成纯文本、折叠空白并取前 120 个 字符显示。

releaseNotes 只说明同级 version 的本次变化。不要在字段中重复版本号,也 不要把完整发布历史复制到每个插件包。它可以使用 GitHub Flavored Markdown; 省略不会阻止校验、安装或运行。

发布新版时

准备发布新版本时,应检查 releaseNotes,尽量改写为本版的实际变化,避免 无意中原样沿用上一版说明。使用 $t 时,推荐使用与当前版本对应的 key,方便 维护各版本文案。releaseNotes 仍是可选字段,不会阻止校验、打包或发布。

版本与 App 兼容性

specVersion: 2version 和可选 minAppVersion 使用 SemVer。版本必须 包含 major.minor.patch,例如 1.2.0v1.2.01.2 和含前导零的版本 都无效。预发布版本按 SemVer 排序,例如 1.0.0-rc.1 < 1.0.0

缺省 specVersion 和显式 specVersion: 1 保持兼容:versionminAppVersion 继续接受原有非空字符串,且不会执行最低 App 版本拦截。

国际化

自 v0.93.0 起

Manifest v2 使用同一套插件包内 catalog,同时服务 Tavo 原生插件 UI 和插件 HTML/JavaScript。localization.defaultLocale 必填;可选 resources 使用 enen-USzh-Hanszh-CN 这样的连字符 locale。 zh_CN 这样的下划线标签无效。Catalog 路径必须遵守和 entry、 HTML fragment 相同的插件包内相对路径规则。

强烈建议新插件至少支持 en,再根据目标用户增加至少一种常用语言。如果 维护能力允许,也可以继续支持更多语言。额外语言应根据真实用户群选择, 不需要把某一种固定语言当成所有插件的第二语言。

{
  "localization": {
    "defaultLocale": "en",
    "resources": {
      "en": "locales/en.json",
      "zh-CN": "locales/zh-CN.json"
    }
  }
}

每个 catalog 都是扁平的 UTF-8 JSON object,key 必须是非空字符串,value 必须是字符串:

{
  "plugin.name": "快速备注",
  "plugin.description": "在聊天中添加快速备注面板。",
  "releaseNotes.1_0_0": "## 首次发布\n\n- 新增快速备注面板。",
  "actions.insertNote": "插入备注",
  "actions.appendNote": "写入备注",
  "actions.inspect": "检查",
  "settings.info": "设置备注面板。",
  "settings.enabled": "启用",
  "settings.mode.label": "模式",
  "settings.mode.short": "简短",
  "settings.mode.detailed": "详细",
  "settings.strength": "强度",
  "settings.template.label": "模板",
  "settings.template.default": "记住:",
  "runtime.panel.title": "快速备注",
  "runtime.greeting": "你好,{name}!",
  "runtime.input.rememberPrefix": "记住:",
  "runtime.input.sidebarNote": "来自侧栏插件的备注",
  "runtime.input.draft": "草稿:\n{input}"
}

需要始终原样显示的文案直接写普通字符串。只有严格符合 { "$t": "key" } 的 object 才会查询国际化;以 $ 开头的字符串也仍是 字面文本。Key 可以是 plugin.name 这样的语义名,也可以是 快速备注 这样的原文。推荐使用语义 key,但不强制。Tavo 不自动翻译,也不要求 所有 catalog 拥有相同 key。整条回退链都没有找到 $t 时,会显示 key 本身。普通安装与运行时不会因此阻止插件;下文的 tavo_plugin_audit 会比较 catalog key 并给出非阻塞告警。

支持国际化的位置有:

  • manifest namedescriptionreleaseNotes
  • input action、sidebar action 和 last-message action 的 label
  • settings 字段 label 和 info 元素 text
  • 结构化 select option 的 label
  • texttextarea setting 的 default

每个 key 会先查找请求 locale 和兼容 locale,再查找 English,然后是插件 defaultLocale,最后显示 key 本身。App 跟随系统语言时,插件使用设备原始 locale,即使 Tavo 自身界面不支持该语言。

插件 HTML 与 JavaScript 国际化

tavo.plugin.i18n:自 v0.93.0 起

本地化 v2 插件的 entry、action handler、/chat fragment 和 /messages fragment 中都会提供同步 tavo.plugin.i18n API:

const i18n = tavo.plugin.i18n;

function render() {
  document.querySelector('#title').textContent =
    i18n.t('runtime.panel.title');
  const greeting = i18n.t('runtime.greeting', { name: 'Colin' });
  document.querySelector('#greeting').textContent = greeting;
}

render();
const unsubscribe = i18n.onChange((event) => {
  console.log(event.requestedLocale, event.locale);
  render();
});
  • requestedLocalelocaledefaultLocale 都是 live getter。
  • supportedLocales 是防修改的数组拷贝。
  • t(key, params?) 同步返回字符串;缺 key 时原样返回 key,并支持 {name} 这样的简单占位符,参数可为字符串、有限数字和布尔值。例如 catalog 中的 你好,{name}! 配合 { name: 'Colin' } 会返回 你好,Colin!
  • onChange(handler) 返回 unsubscribe function。handler 运行前,getter 和 t() 已经使用新语言。

切换语言不会重新执行插件 entry。Tavo 也不会自动翻译或修改已有 插件 DOM;请在 onChange handler 中自行 rerender。该 namespace 只属于插件, 普通角色卡或消息 TavoJS 无法使用。

国际化应作为插件完成标准,而不只是 manifest 配置。所有用户可见的 manifest、settings、HTML 和 JavaScript 文案都应进入 catalog,并按所在位置 通过 { "$t": "key" }tavo.plugin.i18n.t() 读取。这包括 HTML 文本节点、 按钮、placeholder、titlearia-label、加载和空状态、错误、确认提示及 Toast 文案。品牌名、协议 token、标识符等明确不随语言变化的内容可以保留 为字面值。每个国际化 HTML fragment 都应先按当前语言完整渲染一次,再订阅 tavo.plugin.i18n.onChange(),确保切换语言时完整重渲染用户可见内容。

把插件国际化拆成三个必须分别检查的表面:

  1. 宿主原生 UI:manifest、动作标签和 settings 使用 $t
  2. 插件运行时 UI:HTML、Toast、错误和动态文案使用 tavo.plugin.i18n.t(),HTML 订阅 onChange() 后完整重渲染。
  3. 插件生成或插入的可见内容:明确选择跟随当前对话语言、跟随 App / 插件 locale,或使用一个稳定的用户设置。隐藏提示词本身可以保持作者使用的语言, 但必须明确约束用户最终看到的输出语言。

不要用 navigator.language、私有 localStorage 或插件自己的一套语言开关 替代 Tavo 提供的 locale。它们会让插件 UI 与 App 语言产生漂移。

Entry 脚本

自 v0.92.0 起

entry 是插件的主脚本,用于注册已声明的输入 / 侧边栏 / 最后一条消息动作 handler 和插件 Hooks。旧版 scripts.actions 仍会作为兼容别名生效。

Action handler 使用 TavoJS API 与 Tavo 交互。下面只展示插件事件注册,更多输入框、消息、变量和生成接口请参考完整 API 文档。

tavo.plugin.onInputAction('insert-note', async () => {
  await tavo.input.append(tavo.plugin.i18n.t('runtime.input.rememberPrefix'));
});

tavo.plugin.onSidebarAction('append-note', async () => {
  const note = tavo.plugin.i18n.t('runtime.input.sidebarNote');
  await tavo.input.append(`\n\n${note}`);
});

tavo.plugin.onLastMessageAction(
  'inspect',
  async ({ chatId, messageId, role }) => {
    const message = await tavo.message.get(messageId);
    await tavo.utils.toast(`${role}:${chatId}:${message.id}`);
  },
);

如果插件只提供设置、HTML 片段或其它声明式贡献,可以省略 entry,除非它还需要执行 JavaScript。

使用 tavo

entry/chat HTML 片段和 /messages HTML 片段中直接使用 tavo,例如 await tavo.input.get()。Tavo 会自动让它对应当前插件。不要使用 window.tavoglobalThis.tavo,它们不是插件 API 的一部分。

/messages HTML 片段可以通过 tavo.message.current() 读取当前消息;在 entry、输入框动作、侧边栏动作、最后一条消息 action handler 和 /chat HTML 片段中,该方法返回 null

// 推荐:在所有插件入口中都使用未限定的 tavo。
tavo.plugin.onInputAction('guide', async () => {
  const input = await tavo.input.get();
  await tavo.input.set(tavo.plugin.i18n.t('runtime.input.draft', { input }));
});

// 不建议:插件代码不要使用 window/globalThis。
// const input = await window.tavo.input.get();
// const cfg = globalThis.tavo.plugin.config.get('basePrompt');

读取插件设置

contributes.settings.schema 声明设置页的表单结构和默认值;插件代码通过 tavo.plugin.config 读取该插件的有效配置值。这两个方法是同步、只读 API,不需要 await

const enabled = tavo.plugin.config.get('enabled');
const config = tavo.plugin.config.all();
  • get(key) 返回用户已保存的值;没有用户值时回退到 schema 中该字段的 default。key 不存在且没有默认值时返回 null
  • all() 返回该插件全部有效配置值的浅拷贝,包括 schema 默认值和用户覆盖值。修改返回对象不会保存或改变插件设置。
  • tavo.plugin.config 可在 entry、输入框 / 侧边栏 / 最后一条消息 action handler、/chat/messages HTML 片段中使用,并且只会读取当前插件的配置。
  • 该 API 不返回原始 contributes.settings.schema 定义,也不提供写入方法。schema 仍由插件自己的 manifest.json 声明,用户通过 Tavo 的插件设置页修改配置。

动作图标资源

contributes.inputActions[].iconcontributes.lastMessageActions[].icon 都使用插件包内的 PNG 或 WebP 相对路径。推荐使用透明背景的正方形图标,尺寸为 64 × 64128 × 128。建议不要超过 512 × 512 或约 256 KB

Tavo 会将图片等比例缩放到对应的菜单或消息 action bar 图标槽位,源图片尺寸不会改变菜单或 action bar 的布局。这些数值是性能建议,不是安装时的硬性限制;过大的图片会增加资源加载和处理开销,影响运行性能。

输入动作

contributes.inputActions 用来声明聊天输入框 + 菜单里的动作。

{
  "contributes": {
    "inputActions": [
      { "id": "insert-note", "label": { "$t": "actions.insertNote" } }
    ]
  }
}
字段必填说明
id稳定的动作 id。
label菜单里显示的文案。
icon可选的插件包内 PNG/WebP 相对路径。规格建议见动作图标资源

输入动作的 handler 写在 entry 指向的文件里。推荐使用 onInputAction;它会注册底层事件 inputActions:<action-id>

tavo.plugin.onInputAction('insert-note', async () => {
  await tavo.input.append(tavo.plugin.i18n.t('runtime.input.rememberPrefix'));
});

也可以使用底层事件形式:tavo.plugin.on('inputActions:<id>', handler)。新插件推荐优先使用 onInputAction(id, handler);它会校验 id,并注册到底层 inputActions:<id> 事件。

侧边栏动作

contributes.sidebar 用来声明聊天右侧边栏里的动作。

{
  "contributes": {
    "sidebar": [
      { "id": "append-note", "label": { "$t": "actions.appendNote" } }
    ]
  }
}
字段必填说明
id稳定的动作 id。
label右侧边栏行里显示的文案。

侧边栏动作的 handler 写在 entry 指向的文件里。推荐使用 onSidebarAction;它会注册底层事件 sidebar:<action-id>

tavo.plugin.onSidebarAction('append-note', async () => {
  const note = tavo.plugin.i18n.t('runtime.input.sidebarNote');
  await tavo.input.append(`\n\n${note}`);
});

也可以使用底层事件形式:tavo.plugin.on('sidebar:<id>', handler)。新插件推荐优先使用 onSidebarAction(id, handler);它会校验 id,并注册到底层 sidebar:<id> 事件。

原生输入框、侧边栏和最后一条消息派发不区分 handler 是通过 helper 还是 plugin.on 注册的;它只查找最终事件名。使用 plugin.on 时,<id> 必须和对应 contribution 的 id 完全一致,包括大小写、连字符等字符。

同一个插件声明多个侧边栏动作时,Tavo 会把它们显示在同一个分组里,标题为 插件 · 插件名

最后一条消息动作

contributes.lastMessageActions 为当前聊天的绝对最后一条稳定消息的 action bar 声明动作。流式、加载或临时消息不会成为目标,之后又追加消息时旧目标不再显示这些动作。此入口只在高级渲染时存在,基础渲染没有对应入口。

{
  "contributes": {
    "lastMessageActions": [
      {
        "id": "inspect",
        "label": { "$t": "actions.inspect" },
        "icon": "icons/inspect.png",
        "role": "character"
      }
    ]
  }
}
字段必填说明
id稳定动作 id,必须与注册的 handler id 完全一致。
labelaction bar 文案,遵守原生 action 单行限制。
icon必需的安全插件包内 PNG/WebP 图片相对路径。规格建议见动作图标资源
role目标角色,默认 character,只允许 characteruserany

entry 中用 tavo.plugin.onLastMessageAction(id, handler) 注册,也可用底层 tavo.plugin.on('lastMessageActions:<id>', handler)。handler 在点击时只收到 { chatId, messageId, role },绝不包含消息内容。tavo.message.current() 在这个 entry handler 中仍为 null,请用 tavo.message.get(messageId) 读取目标。

原生控件始终优先,插件动作随后按稳定插件和 manifest 顺序排列。宽度不足时,只有插件动作进入 action bar overflow,绝不挤走原生控件。这个 overflow 不是消息 context menu。等待 handler 时整个 bar 都会禁用,任何成功、失败、取消或异常退出后都会恢复。没有 handler 或 handler 抛错会报告插件动作失败。MCP 可以检查、校验和审计这些贡献,不能执行。

插件 Hooks

entry 脚本通过 tavo.plugin.on(type, handler) 注册 Hooks。

Chat 与 Message 通知

自 v0.92.0 起

这些 Hooks 用来感知聊天和消息变化,不能修改或阻止聊天与生成流程。单个 handler 报错不会影响聊天或其它插件。

事件触发时机
chat:opened当前聊天打开时。
chat:closed离开当前聊天或切换到其它聊天时。
chat:updated当前 chat 的元数据更新,例如标题、角色、persona、preset、lorebooks、memory 或背景变化。
chat:changedchat:updated 的兼容别名;handler 收到的 event.type 仍为 chat:updated
message:added一条消息添加并保存到当前 chat 后;流式生成过程中不会重复触发。
message:updated当前 chat 中已保存消息的内容或元数据发生变化时。
message:deleted一条消息从当前 chat 中删除时。
message:changedmessage:addedmessage:updatedmessage:deleted 之后触发的 umbrella 事件。

具体的 message 事件会先触发,随后触发 message:changed。如果插件启用时用户已经打开聊天,也会收到一次 chat:opened

所有事件对象都包含 typepluginId 和 ISO 时间字符串 at。chat 事件还包含 chatIdchat;message 事件还包含 chatIdchangemessage

tavo.plugin.on('chat:opened', async (event) => {
  console.log('打开聊天', event.chat?.name || event.chatId);
});

tavo.plugin.on('message:changed', async (event) => {
  console.log(event.type, event.change, event.message?.id, event.at);
});

生成生命周期 Hooks

自 v0.92.0 起

这些 Hooks 只能在已安装插件的 entry 脚本中通过 tavo.plugin.on(...) 注册;HTML 片段、角色卡和消息内容中的 TavoJS 不能注册或接收这些事件。manifest 应声明 "permissions": ["generate"]

tavo.plugin.on('generation:prepare', async (event) => {
  event.text = '[Model-only context]\n' + event.text;
});

tavo.plugin.on('generation:success', async (event) => {
  event.text = event.text.trim();
});

tavo.plugin.on('generation:error', async (event) => {
  console.error(event.error.code, event.error.message);
});

tavo.plugin.on('generation:cancelled', async (event) => {
  console.log('stopped', event.partial);
});

每个事件都有只读的 generationIdchatIdsourceattypepluginId。目前会响应 replygroupReplycontinuationothersContinuationregeneration;不会响应图片、语音、总结、独立生成或纯 TavoJS/JSAPI 发起的生成。

  • generation:prepare 在用户消息已经保存并显示后、模型请求开始前运行。handler 可以是同步函数,也可以返回 Promise;Tavo 会在开始模型请求前等待 Promise。event.text 是本次请求发送给模型的最后一条用户消息;修改只影响本次模型请求,不会修改聊天中已保存的消息,并且可以设为空。
  • generation:success 在生成和 extension 处理完成后、角色消息保存前运行;可改写的最终正文必须 非空,空改写会被丢弃。
  • generation:error 在生成失败时通知插件,event.error 提供 codemessage
  • generation:cancelled 是含布尔 partial 的非阻塞终止通知。partial: true 仍会保存半截响应, 随后触发现有的 message:addedpartial: false 不会保存消息。

generation:prepare 的 handler 按注册顺序运行,整条 pipeline 共享 55 秒预算,而不是每个 handler 各有 55 秒。handler 报错、超时或写入无效文本时,Tavo 会忽略该 handler 的修改并继续生成;预算耗尽时会跳过尚未开始的 handler。用户停止生成会立即结束宿主等待并忽略迟到结果,但不会强制取消插件已经发出的网络请求;需要取消自身 I/O 的插件应使用 AbortControllergeneration:success 仍是每个 handler 最多等待 5 秒。这两个 Hooks 不能通过 event.cancel() 取消生成。每次生成只会触发 generation:successgeneration:errorgeneration:cancelled 中的一个。

发送阶段如何选择 Hook

需求使用的 Hook
快速校验、取消发送、修改聊天中实际保存的用户文本input:beforeSend
embedding、向量召回、外部 API、只对模型可见的上下文注入generation:prepare
快速整理模型最终正文generation:success

不要用 input:beforeSend → event.cancel() → 等待外部 API → tavo.input.send() 实现记忆召回。这种拦截-重发模式会让用户消息在计算完成前无法显示,并有递归发送或重复副作用风险。请迁移到 generation:prepare

tavo.plugin.on('generation:prepare', async (event) => {
  const original = event.text;
  const recalled = await recallMemory(original); // 插件自己的召回实现
  if (!recalled) return;
  event.text = ['<memory>', recalled, '</memory>', original].join('\n');
});

发送顺序是:input:beforeSend → 用户消息保存并上屏 → generation:prepare → 构建模型请求 → 开始生成。prepare 只能改写本次请求中的最后一条用户文本,不能新增独立 system/context 消息,也不会修改已经显示的用户消息。

输入发送 Hooks

自 v0.92.0 起

input:beforeSend 拦截发送按钮 / 回车、tavo.input.send() 和 MCP tavo_input_sendinput:afterSend 在 Tavo 接受输入后通知插件。manifest 应声明 "permissions": ["input"]

tavo.plugin.on('input:beforeSend', async (event) => {
  event.text = event.text.trim();
  if (!event.text.includes(':')) event.cancel('请补充角色名');
});

tavo.plugin.on('input:afterSend', async (event) => {
  console.log('输入已接受:', event.text);
});

before-send 在 macros 展开和 slash command 解析前运行。typepluginIdchatIdsourceat 只读;text 是唯一可修改字段且必须保持字符串。sourceuitavojsmcp。handler 返回值会被忽略;请调用 event.cancel(reason?) 显式取消。

handler 按插件和注册顺序运行,每个最多等待 5 秒。handler 报错、超时或把 text 改成非字符串时,Tavo 会忽略该 handler 的修改并继续发送。显式取消会停止后续 handler,并保留之前 handler 已完成的文本修改和附件。input:afterSend 不等待模型回复或生图完成。

HTML 片段

contributes.htmlFragments 用来声明显示在聊天页或消息附近的本地 HTML 文件。

HTML 片段中的脚本属于已安装插件,不受聊天内容的 JavaScript 设置影响;该设置只控制角色卡、模型输出和其它消息气泡内容中的脚本。

{
  "contributes": {
    "htmlFragments": [
      { "id": "chat-panel", "src": "ui/panel.html", "mount": "/chat/body/end" },
      { "id": "message-tail", "src": "ui/tail.html", "mount": "/messages/end?role=character&position=last" }
    ]
  }
}
字段必填说明
id稳定的片段 id。
src插件包内的相对文件路径。不能是绝对路径,不能包含 \,不能是 URL,也不能用 ../ 跳出目录。
mount挂载位置。当前支持 /chat/.../messages...

聊天页悬浮控件

/chat HTML 片段如果使用 position: fixedposition: absolute 或类似定位在聊天页上放置悬浮按钮,该按钮必须允许用户拖动,避免多个插件的控件相互重叠或遮挡 Tavo 的聊天界面。

  • 同时支持鼠标和触摸拖动;有条件时也应支持触控笔。
  • 区分点击和拖动,避免用户尝试移动按钮时误触原操作。
  • 建议按稳定的插件 id 和片段 id 记住上一次拖动位置,并在窗口尺寸、屏幕方向或安全区变化后将恢复位置限制在当前可视区域内。
  • 初始位置和恢复位置不得遮挡输入、发送、返回等宿主关键控件,并应在常见手机、平板和桌面尺寸下验证。
  • 应提供可发现的重置位置方式,作为持久化数据无效或用户无法继续拖动时的恢复手段。

支持的聊天挂载点:

  • /chat
  • /chat/head/start
  • /chat/head/end
  • /chat/body/start
  • /chat/body/end

支持的消息挂载点:

  • /messages/start
  • /messages/end
  • /messages/start?role=user
  • /messages/end?role=character
  • /messages/end?position=last
  • /messages/end?role=character&position=last

消息挂载点中,role 可为 usercharacterposition 可为 firstlast

布局边界与键盘避让

聊天页插件可以使用以下四个布局边界。它们以聊天页面视口中的 CSS 像素计量,分别从顶部或底部向内计算距离。

CSS 变量JS 字段含义
--tavo-inset-top-safetopSafe顶部到系统状态栏下方安全边界的距离
--tavo-inset-top-bartopBar顶部到当前聊天页顶部导航栏下方的距离,已包含顶部安全区
--tavo-inset-bottom-inputbottomInput底部到键盘收起时聊天输入区域上沿的距离,已包含底部安全区
--tavo-inset-bottom-safebottomSafe底部系统安全区的距离

同一侧的两个值是可选边界,不应相加。面板需要避开聊天输入框时选择 bottomInput,只需避开底部系统安全区时选择 bottomSafe。标题、大小、圆角、关闭按钮和动画由插件决定,Tavo 不提供或强制面板容器。

下面的面板上下留出 8px,并让内容在高度不足时滚动:

.my-plugin-panel {
  position: fixed;
  top: calc(var(--tavo-inset-top-bar, 0px) + 8px);
  bottom: calc(var(--tavo-inset-bottom-input, 0px) + 8px);
  left: 12px;
  right: 12px;
  overflow-y: auto;
}

悬浮入口的初始位置也可使用这些变量。拖动和恢复位置时,使用 JS 边界将控件限制在可用区域内,并扣除控件自身尺寸:

if (tavo.ui && typeof tavo.ui.getInsets === 'function') {
  const applyInsets = (insets) => {
    // 使用 insets.topBar、insets.bottomInput 等更新自己的布局或拖动范围。
    console.log(insets);
  };
  applyInsets(tavo.ui.getInsets());
  const unsubscribe = tavo.ui.onInsetsChanged(applyInsets);
  // 片段销毁或重新初始化前调用 unsubscribe()。
}

getInsets() 同步返回只读快照。onInsetsChanged(handler) 只在值发生变化后通知,不会立即回调,返回取消订阅函数。首次宿主布局到达前可能为 0。尺寸、顶栏显示状态或输入区域内容高度变化会更新边界,键盘本身的高度和动画不计入这四个值,无需自行叠加键盘高度。

Tavo 内置键盘避让,插件无需额外引入脚本或监听键盘。 输入框获得焦点后,Tavo 会尝试滚动内容,必要时调整固定面板,让输入框露出。插件只需保证内容超出面板时可以正常滚动,无需让整个插件跟随键盘移动。该机制适用于常见布局,复杂的自定义布局仍需在设备上验证输入框能否正常露出。

适用范围是高级渲染中的聊天页插件:/chat HTML 片段可使用 CSS 变量,聊天页运行的插件 entry/action 可通过 tavo.ui 读取边界。消息内嵌内容(/messages)暂不支持这组接口和 CSS 变量。这不是模型 Tool Calling 或 MCP 工具接口。旧版 Tavo 需要检测 tavo.ui 是否存在,并提供保守的布局降级,CSS 示例中的 0px 仅用于变量缺失时的语法回退,不保证旧版安全距离。

插件遮罩仅覆盖聊天内容区域,不影响应用顶部导航栏。顶部导航栏占用的区域对插件来说是不可操作区域,放在其中的插件按钮、输入框等控件无法正常响应用户操作,提高 z-index 也无法解决。请将需要用户操作的插件内容放在 topBar 边界下方。宿主只发布边界,不扫描插件元素或强制它们遵守边界。更新按帧合并,只有变化的值才写入 CSS 和通知订阅者,插件回调应避免无条件重绘整个面板。

设置 Schema

contributes.settings.schema 是一个平铺数组,Tavo 会按顺序渲染到插件设置页。

这里声明的是设置表单 schema。插件代码应使用 tavo.plugin.config.get(key) / all() 读取合并 schema 默认值与用户保存值后的有效配置;插件 API 不提供原始 schema 查询。

类型必填字段可选字段说明
switchkey, labeldefault布尔开关。
selectkey, label, optionsdefaultoptions 可包含兼容字符串或结构化 { value, label } object。
sliderkey, label, min, maxstep, defaultmax 必须大于 minstep 必须大于 0。
textkey, labeldefault单行文本输入。
textareakey, labeldefault多行文本输入。
infotexticonicon 可为 infowarning
divider整宽分割线。
break开始新的设置分组,不渲染可见控件。

字段类元素用 key 作为存储配置的键。发布后尽量不要改 key,除非你有意重置用户已有配置。

字符串 select option 的存储值和字面 label 相同。结构化 option 用稳定、 不可国际化的 value 作为存储值,label 可写字面文本或 $t object。 语言切换只更新 label,不会改变用户已保存的 value。

texttextareadefault 也可写 $t object。只有用户没有保存 覆盖值时,国际化默认值才跟随语言变化;切换语言不会覆盖用户值。 重置字段会删除覆盖值,重新使用当前语言的默认值。

使用 AI Agent 开发

AI Agent 指能直接帮你操作项目文件和工具的编码助手,例如 Codex、Claude Code、Trae、CodeBuddy 等。连接到 Tavo 的 MCP Server 后,它可以读取当前 App 暴露的资源文档和工具,并按你的需求创建插件。

  1. 在 Tavo 中打开 设置 -> MCP Server,启用 MCP Server。
  2. 按照 MCP Server 中的说明连接 AI Agent。
  3. 让 AI Agent 先读取 MCP Server 内部的插件资源文档,例如 tavo://docs/plugins
  4. 直接提出插件需求,并要求它依次调用 tavo_plugin_validate_manifesttavo_plugin_audittavo_plugin_package
  5. 审阅审计结果,在 Tavo 中启用插件后先在备份聊天中切换支持的语言测试。
  6. 如果想要发布插件,可以让 AI Agent 打包出 .tpg 文件。

可以从这样的提示词开始:

先读取 Tavo MCP Server 内部的插件资源文档,例如 tavo://docs/plugins。
帮我做一个名为 Quick Note 的 Tavo 插件。
manifest 使用 specVersion 2,entry.js 放在插件包根目录。
localization.defaultLocale 设为 en,至少支持 en,再根据目标用户增加一种常用语言,
也可以继续增加更多语言。所有用户可见的 manifest、settings、HTML 和 JavaScript
文案都要使用 catalog,HTML 需要首次渲染并在语言变化时完整重渲染。
为生成或插入的用户可见内容明确语言策略,不要读取 navigator.language 或保存
另一套插件语言。完成源文件后依次调用 tavo_plugin_validate_manifest、
tavo_plugin_audit 和 tavo_plugin_package,并逐条审阅 localizationAudit;不要在仍有
未审阅问题时宣称国际化已经完成。
插件需要一个名为 insert-note 的输入动作、一个名为 enabled 的 switch 设置,
并提供挂载到 /chat/body/end 的 HTML 片段。
完成后先在 Tavo 里安装、启用并测试;如果我要发布,请帮我打包成 .tpg 文件。

可以直接从这组通用、可复制的双语模板开始: manifest.jsonentry.jsHTML fragmentEnglish catalog简体中文 catalog

Agent 的工具调用顺序应为:

tavo_plugin_validate_manifest -> tavo_plugin_audit -> tavo_plugin_package -> tavo_plugin_install

使用对话式 AI 辅助开发

对话式 AI 如 ChatGPT、Claude、豆包、DeepSeek 等,是主要通过聊天窗口协作的 AI。它们适合帮你设计插件思路、生成 manifest.json、编写 entry.js 和 HTML 片段,再由你复制到本地打包测试。

文档每一页都有 复制本页 按钮,可以直接复制当前页面的 Markdown 内容,粘贴给对话式 AI 作为上下文。建议把你的插件目标、目标 Tavo 版本、需要的入口和设置项一起发给它。

  1. 点击本页的 复制本页
  2. 把复制出的 Markdown 和你的插件需求一起发给对话式 AI。
  3. 要求它输出文件树、manifest.jsonentry.js 和需要的 HTML 片段。
  4. 把生成的文件复制到本地插件文件夹,打包为 .tpg(zip 格式)后在备份聊天中测试。
  5. 如果安装或运行失败,把 Tavo 的报错和当前文件内容贴回去,让它继续修改。

发布前检查

分享插件前:

  • 在干净的 Tavo 配置或备份聊天中安装一次。
  • 确认插件包根目录包含 manifest.json;如果插件声明了输入动作、侧边栏动作或最后一条消息动作,也要确认 entry 指向的文件存在。
  • 确认所有 HTML fragment 的 src 文件都存在。
  • 依次运行 tavo_plugin_validate_manifesttavo_plugin_audittavo_plugin_package,逐条处理或审阅 localizationAudit 中的问题。
  • 确认所有用户可见的 manifest、settings、HTML 和 JavaScript 文案都已进入 catalog, 没有意外硬编码。
  • 确认每个国际化 HTML fragment 都有首次渲染和 tavo.plugin.i18n.onChange() 重渲染。
  • 在 Tavo 中切换插件支持的语言,确认原生 UI 和全部 HTML surface 都已更新, 没有原始 key、上一种语言残留或意外回退语言。
  • 确认生成或插入的用户可见内容有明确语言策略,并在目标对话语言下测试。
  • 确认每个设置字段都有稳定的 key
  • 确认权限列表没有超出实际需要。
  • 保留生成 .tpg 的源文件夹,方便之后维护。

目录