UI 扩展

UI 扩展通过挂钩到 SillyTavern 的事件和 API 来扩展其功能。它们运行在浏览器上下文中,几乎可以不受限制地访问 DOM、JavaScript API 和 SillyTavern 上下文。扩展可以修改 UI、调用内部 API 并与聊天数据交互。本指南介绍如何创建你自己的扩展(需要具备 JavaScript 知识)。

若要扩展 Node.js 服务器的功能,请参阅服务器插件页面。

不会写 JavaScript?

  • 可以考虑使用 STscript 作为编写完整扩展的更简单替代方案。
  • 学习 MDN 教程,学完后再回来。

扩展提交

想把你的扩展贡献到官方内容仓库?欢迎联系我们!

为确保所有扩展都安全且易用,我们有以下几项要求:

  1. 你的扩展必须是开源的,并使用自由许可证(参见 Choose a License)。如果不确定,AGPLv3 是个不错的选择。
  2. 扩展必须兼容 SillyTavern 的最新发布版本。如果核心代码发生变更,请准备好更新你的扩展。
  3. 扩展必须有完善的文档,包括一个 README 文件,其中应包含安装说明、使用示例和功能列表。
  4. 依赖服务器插件才能运行的扩展将不予接受。

示例

查看简单 SillyTavern 扩展的实际示例:

打包

扩展也可以利用打包来与其它模块隔离,并使用 NPM 中的任意依赖项,包括 Vue、React 等 UI 框架。

要从打包产物中使用相对导入,你可能需要创建一个导入包装器。下面是 Webpack 的一个示例:

/**
 * Import a member from a module by URL, bypassing webpack.
 * @param {string} url URL to import from
 * @param {string} what Name of the member to import
 * @param {any} defaultValue Fallback value
 * @returns {Promise<any>} Imported member
 */
export async function importFromUrl(url, what, defaultValue = null) {
    try {
        const module = await import(/* webpackIgnore: true */ url);
        if (!Object.hasOwn(module, what)) {
            throw new Error(`No ${what} in module`);
        }
        return module[what];
    } catch (error) {
        console.error(`Failed to import ${what} from ${url}: ${error}`);
        return defaultValue;
     }
}

// Import a function from 'script.js' module
const generateRaw = await importFromUrl('/script.js', 'generateRaw');

manifest.json

每个扩展必须在 data/<user-handle>/extensions 中拥有一个文件夹,并包含一个 manifest.json 文件,其中包含扩展的元数据以及指向扩展入口点的 JS 脚本文件路径。

可下载的扩展在通过 HTTP 提供服务时会挂载到 /scripts/extensions/third-party 文件夹中,因此应基于此路径使用相对导入。为便于本地开发,建议将你的扩展仓库放在 /scripts/extensions/third-party 文件夹中(即“为所有用户安装”选项)。

{
    "display_name": "The name of the extension",
    "loading_order": 1,
    "requires": [],
    "optional": [],
    "dependencies": [],
    "js": "index.js",
    "css": "style.css",
    "author": "Your name",
    "version": "1.0.0",
    "homePage": "https://github.com/your/extension",
    "auto_update": true,
    "minimum_client_version": "1.0.0",
    "i18n": {
        "de-de": "i18n/de-de.json"
    },
    "hooks": {
        "install": "onInstall",
        "update": "onUpdate",
        "delete": "onDelete",
        "enable": "onEnable",
        "disable": "onDisable",
        "activate": "onActivate"
    }
}

清单字段

  • display_name 是必填项,显示在“管理扩展”菜单中。
  • loading_order 是可选项,数值越大加载越晚。
  • js 是主 JS 文件引用,为必填项。
  • css 是可选的样式文件引用。
  • author 是必填项,应包含作者的名称或联系方式。
  • auto_update 设为 true 时,扩展会在 ST 包版本变更时自动更新。
  • i18n 是一个可选对象,用于指定支持的区域设置及其对应的 JSON 文件(见下文)。
  • dependencies 是一个可选的字符串数组,指定本扩展所依赖的其它扩展
  • generate_interceptor 是一个可选字符串,用于指定在文本生成请求时调用的全局函数名称。
  • minimum_client_version 是一个可选字符串,用于指定本扩展正常工作所需的最低 SillyTavern 版本。
  • hooks 是一个可选对象,用于指定从 JS 入口点模块导出的生命周期钩子函数名称。

依赖项

扩展还可以依赖于其它 SillyTavern 扩展。如果这些依赖缺失或被禁用,扩展将不会加载。

依赖项通过其在 public/extensions 目录中显示的文件夹名来指定。

示例:

  • 内置扩展:"vectors""caption"
  • 第三方扩展:"third-party/Extension-WebLLM""third-party/Extension-Mermaid"

已弃用字段

  • requires 是一个可选的字符串数组,用于指定所需的 Extras 模块。如果所连接的 Extras API 未提供所有列出的模块,扩展将不会被加载。
  • optional 是一个可选的字符串数组,用于指定可选的 Extras 模块。即使这些模块缺失,扩展仍会加载,扩展应优雅地处理它们缺失的情况。

要检查当前连接的 Extras API 提供了哪些模块,请从 scripts/extensions.js 导入 modules 数组。

脚本编写

扩展初始化的最佳实践

  • 对于需要在 SillyTavern 加载阶段(阻塞式加载器处于活动状态时)运行的同步设置,请使用 activate 钩子。
  • 对于应在所有扩展和 UI 元素加载并设置完成、但加载器仍在阻塞时运行的设置,请使用 APP_INITIALIZED 事件。
  • 对于不需要阻塞 SillyTavern 进入可用状态的异步设置,请使用 APP_READY 事件。它应使用定时器或类似机制来推迟处理,因为事件处理函数会被 await。

使用 getContext

SillyTavern 全局对象中的 getContext() 函数可让你访问 SillyTavern 上下文,它是所有主要应用状态对象、实用函数和工具的集合。

const context = SillyTavern.getContext();
context.chat; // Chat log - MUTABLE
context.characters; // Character list
context.characterId; // Index of the current character
context.groups; // Group list
context.groupId; // ID of the current group
// And many more...

你可以在 SillyTavern 源代码中找到完整的可用属性和函数列表。

共享库

SillyTavern 前端内部使用的大多数 npm 库都共享在 SillyTavern 全局对象的 libs 属性中。

  • lodash - 实用工具库。文档
  • Fuse - 模糊搜索库。文档
  • DOMPurify - HTML 净化库。文档
  • hljs - 语法高亮库。文档
  • localforage - 浏览器存储库(IndexedDB/localStorage 抽象)。文档
  • Handlebars - 模板库。文档
  • css - CSS 解析/字符串化工具。文档
  • Bowser - 浏览器/平台检测库。文档
  • DiffMatchPatch - 文本差异、匹配与补丁库。文档
  • Readability / isProbablyReaderable - Mozilla 的文章提取库。文档
  • SVGInject - 内联 SVG 注入库。文档
  • showdown - Markdown 转换库。文档
  • moment - 日期/时间操作库。文档
  • seedrandom - 带种子的随机数生成器。文档
  • Popper - 工具提示/弹出层定位引擎。文档
  • droll - 掷骰子库。文档
  • morphdom - 快速 DOM 差异比对/补丁库。文档
  • slideToggle - 原生 JS 滑动切换动画。文档
  • chalk - 终端字符串样式库(在浏览器中用途有限)。文档
  • yaml - YAML 解析器与字符串化器。文档
  • chevrotain - 解析器构建工具包。文档
  • gzipSync / gzip - 来自 fflate 的快速压缩工具。文档

你可以在 SillyTavern 源代码中找到完整的导出库列表。

示例: 使用 DOMPurify 库。

const { DOMPurify } = SillyTavern.libs;

const sanitizedHtml = DOMPurify.sanitize('<script>"dirty HTML"</script>');

TypeScript 说明

如果你想为 SillyTavern 全局对象中的所有方法(你大概会需要)获得自动补全,包括 getContext()libs,你应当添加一个 TypeScript .d.ts 模块声明。该声明应从 SillyTavern 的源代码导入全局类型,具体路径取决于你的扩展所在位置。下面是一个同时适用于“所有用户”和“当前用户”两种安装方式的示例。

global.d.ts - 将此文件放在扩展目录的根目录中(与 manifest.json 同级):

export {};

// 1. Import for user-scoped extensions
import '../../../../public/global';
// 2. Import for server-scoped extensions
import '../../../../global';

// Define additional types if needed...
declare global {
    // Add global type declarations here
}

HTML 模板

扩展可以使用 Handlebars HTML 模板来构建其 UI。将 .html 模板文件放在扩展目录中,并使用 getContext() 中的 renderExtensionTemplateAsync() 函数来渲染它们。

该函数接收扩展的文件夹名称、模板文件名(不含 .html)以及一个用于 Handlebars 模板变量的可选数据对象。返回的 HTML 会自动经过 DOMPurify 净化,并通过 data-i18n 属性进行本地化。

const { renderExtensionTemplateAsync } = SillyTavern.getContext();

// Renders 'third-party/my-extension/settings.html' with the given data
const settingsHtml = await renderExtensionTemplateAsync(
    'third-party/my-extension',
    'settings',
    { title: 'My Extension', version: '1.0', defaultValue: 'test' }
);

// Append to the extensions settings panel
$('#extensions_settings2').append(settingsHtml);

模板文件示例settings.html):

<div class="my-extension-settings">
    <div class="inline-drawer">
        <div class="inline-drawer-toggle inline-drawer-header">
            <b data-i18n="{{title}}">{{title}}</b>
            <div class="inline-drawer-icon fa-solid fa-circle-chevron-down down"></div>
        </div>
        <div class="inline-drawer-content">
            <label for="my_ext_option">
                <span data-i18n="Option">Option</span>
            </label>
            <input id="my_ext_option" type="text" value="{{defaultValue}}" />
        </div>
    </div>
</div>

从其它文件导入

除非你在构建打包扩展,否则可以从其它 JS 文件导入变量和函数。

例如,以下代码片段会在后台使用当前选定的 API 生成一条回复:

import { generateQuietPrompt } from "../../../../script.js";

async function handleMessage(data) {
    const text = data.message;
    const translated = await generateQuietPrompt({ quietPrompt: text });
    // ...
}

状态管理

持久化设置

当扩展需要持久化其状态时,可以使用 getContext() 函数中的 extensionSettings 对象来存储和读取数据。扩展可以在设置对象中存储任何可 JSON 序列化的数据,并且必须使用唯一的键以避免与其它扩展冲突。

要持久化设置,请使用 saveSettingsDebounced() 函数,它会将设置保存到服务器。

const { extensionSettings, saveSettingsDebounced } = SillyTavern.getContext();

// Define a unique identifier for your extension
const MODULE_NAME = 'my_extension';

// Define default settings
const defaultSettings = Object.freeze({
    enabled: false,
    option1: 'default',
    option2: 5
});

// Define a function to get or initialize settings
function getSettings() {
    // Initialize settings if they don't exist
    if (!extensionSettings[MODULE_NAME]) {
        extensionSettings[MODULE_NAME] = structuredClone(defaultSettings);
    }

    // Ensure all default keys exist (helpful after updates)
    for (const key of Object.keys(defaultSettings)) {
        if (!Object.hasOwn(extensionSettings[MODULE_NAME], key)) {
            extensionSettings[MODULE_NAME][key] = defaultSettings[key];
        }
    }

    return extensionSettings[MODULE_NAME];
}

// Use the settings
const settings = getSettings();
settings.option1 = 'new value';

// Save the settings
saveSettingsDebounced();

聊天元数据

要将某些数据绑定到特定对话,可以使用 getContext() 函数中的 chatMetadata 对象。该对象允许你存储与对话关联的任意数据,可用于存储扩展特定的状态。

要持久化元数据,请使用 saveMetadata() 函数,它会将元数据保存到服务器。

const { chatMetadata, saveMetadata } = SillyTavern.getContext();

// Set some metadata for the current chat
chatMetadata['my_key'] = 'my_value';

// Get the metadata for the current chat
const value = chatMetadata['my_key'];

// Save the metadata to the server
await saveMetadata();

角色卡

SillyTavern 完全支持角色卡 V2 规范,该规范允许在角色卡 JSON 数据中存储任意数据。

这对于需要存储与角色关联的额外数据,并在导出角色卡时使其可共享的扩展非常有用。

要向角色卡的 extensions 数据字段写入数据,请使用 getContext() 函数中的 writeExtensionField 函数。该函数接收一个角色 ID、一个字符串键以及一个要写入的值。该值必须是可 JSON 序列化的。

const { writeExtensionField, characterId } = SillyTavern.getContext();

// Write some data to the character card
await writeExtensionField(characterId, 'my_extension_key', {
    someData: 'value',
    anotherData: 42
});

// Read the data back from the character card
const character = SillyTavern.getContext().characters[characterId];
// The data is stored in the `extensions` object of the character's data
const myData = character.data?.extensions?.my_extension_key;

设置预设

任意 JSON 数据可以存储在主 API 类型的设置预设中。它会随预设 JSON 一起导出和导入,因此你可以用它来存储预设的扩展特定设置。以下 API 类型支持设置预设中的数据扩展:

  • 聊天补全
  • 文本补全
  • NovelAI
  • KoboldAI / AI Horde

要读取或写入数据,首先需要从上下文获取 PresetManager 实例:

const { getPresetManager } = SillyTavern.getContext();

// Get the preset manager for the current API type
const pm = getPresetManager();

// Write data to the preset extension field:
// - path: the path to the field in the preset data
// - value: the value to write
// - name (optional): the name of the preset to write to, defaults to the currently selected preset
await pm.writePresetExtensionField({ path: 'hello', value: 'world' });

// Read data from the preset extension field:
// - path: the path to the field in the preset data
// - name (optional): the name of the preset to read from, defaults to the currently selected preset
const value = pm.readPresetExtensionField({ path: 'hello' });

国际化

扩展可以提供额外的本地化字符串,供 ttranslate 函数以及 HTML 模板中的 data-i18n 属性使用。

请在此处查看支持的区域设置列表(lang 键):https://github.com/SillyTavern/SillyTavern/blob/release/public/locales/lang.json

直接调用 addLocaleData

将一个区域代码和一个包含翻译的对象传递给 addLocaleData 函数。不允许覆盖已有的键。如果传入的区域代码并非当前选定的区域,该数据将被静默忽略。

SillyTavern.getContext().addLocaleData('fr-fr', { 'Hello': 'Bonjour' });
SillyTavern.getContext().addLocaleData('de-de', { 'Hello': 'Hallo' });

通过扩展清单

在清单中添加一个 i18n 对象,其中包含支持的区域设置列表及其对应的 JSON 文件路径(相对于扩展目录)。

{
  "display_name": "Foobar",
  "js": "index.js",
  // rest of the fields
  "i18n": {
    "fr-fr": "i18n/french.json",
    "de-de": "i18n/german.json"
  }
}

注册斜杠命令(新方式)

虽然 registerSlashCommand 仍为向后兼容而保留,但新的斜杠命令现在应通过 SlashCommandParser.addCommandObject() 注册,以便向解析器(进而向自动补全和命令帮助)提供关于命令及其参数的扩展详情。

SlashCommandParser.addCommandObject(SlashCommand.fromProps({ name: 'repeat',
    callback: (namedArgs, unnamedArgs) => {
        return Array(namedArgs.times ?? 5)
            .fill(unnamedArgs.toString())
            .join(isTrueBoolean(namedArgs.space.toString()) ? ' ' : '')
        ;
    },
    aliases: ['example-command'],
    returns: 'the repeated text',
    namedArgumentList: [
        SlashCommandNamedArgument.fromProps({ name: 'times',
            description: 'number of times to repeat the text',
            typeList: ARGUMENT_TYPE.NUMBER,
            defaultValue: '5',
        }),
        SlashCommandNamedArgument.fromProps({ name: 'space',
            description: 'whether to separate the texts with a space',
            typeList: ARGUMENT_TYPE.BOOLEAN,
            defaultValue: 'off',
            enumList: ['on', 'off'],
        }),
    ],
    unnamedArgumentList: [
        SlashCommandArgument.fromProps({ description: 'the text to repeat',
            typeList: ARGUMENT_TYPE.STRING,
            isRequired: true,
        }),
    ],
    helpString: `
        <div>
            Repeats the provided text a number of times.
        </div>
        <div>
            <strong>Example:</strong>
            <ul>
                <li>
                    <pre><code class="language-stscript">/repeat foo</code></pre>
                    returns "foofoofoofoofoo"
                </li>
                <li>
                    <pre><code class="language-stscript">/repeat times=3 space=on bar</code></pre>
                    returns "bar bar bar"
                </li>
            </ul>
        </div>
    `,
}));

所有已注册的命令都可以在 STscript 中以任意方式使用。

事件

监听事件

使用 eventSource.on(eventType, eventHandler) 来监听事件:

const { eventSource, event_types } = SillyTavern.getContext();

eventSource.on(event_types.MESSAGE_RECEIVED, handleIncomingMessage);

function handleIncomingMessage(data) {
    // Handle message
}

主要的事件类型如下:

应用生命周期:

  • APP_INITIALIZED:应用已初始化,接近就绪,但加载器仍可见。可以在此处进行 UI 修改。当应用初始化完成后再附加新的监听器,该事件会自动触发。
  • APP_READY:应用已完全加载并可用。当应用就绪后再附加新的监听器,该事件会自动触发。

消息:

  • MESSAGE_SENT:用户发送了消息并记录到 chat 对象中,但尚未在 UI 中渲染。
  • MESSAGE_RECEIVED:LLM 消息已生成并记录到 chat 对象中,但尚未在 UI 中渲染。
  • USER_MESSAGE_RENDERED:用户发送的消息已在 UI 中渲染。
  • CHARACTER_MESSAGE_RENDERED:生成的 LLM 消息已在 UI 中渲染。
  • MESSAGE_EDITED:一条消息已被用户编辑。
  • MESSAGE_DELETED:一条消息已被删除。
  • MESSAGE_SWIPED:一条消息的滑动(swipe)已被触发。
  • STREAM_TOKEN_RECEIVED:在流式生成过程中收到了一个新的 token。

生成:

  • GENERATION_AFTER_COMMANDS:在处理完斜杠命令后,生成即将开始。
  • GENERATION_STARTED:生成已开始。
  • GENERATION_STOPPED:生成被用户停止。
  • GENERATION_ENDED:生成已完成或出错。

对话:

  • CHAT_CHANGED:对话已切换(例如切换到另一个角色,或加载了另一个对话)。
  • CHAT_CREATED:创建了一个新对话。
  • CHAT_DELETED:一个对话已被删除。

角色:

  • CHARACTER_EDITED:角色的数据已变更。
  • CHARACTER_DELETED:一个角色已被删除。
  • CHARACTER_DUPLICATED:一个角色已被复制。

人格:

  • PERSONA_CHANGED:活动人格已变更。
  • PERSONA_CREATED:创建了一个新人格。
  • PERSONA_UPDATED:一个人格已更新。
  • PERSONA_RENAMED:一个人格已被重命名。
  • PERSONA_DELETED:一个人格已被删除。

设置与预设:

  • SETTINGS_UPDATED:应用设置已更新。
  • PRESET_CHANGED:活动预设已变更。
  • MAIN_API_CHANGED:主 API 类型已切换。
  • CHATCOMPLETION_SOURCE_CHANGED:聊天补全来源已变更。
  • CHATCOMPLETION_MODEL_CHANGED:聊天补全模型已变更。
  • CONNECTION_PROFILE_LOADED:已加载一个连接配置。

世界书:

  • WORLDINFO_UPDATED:世界书数据已更新。
  • WORLDINFO_SETTINGS_UPDATED:世界书设置已变更。

工具调用:

  • TOOL_CALLS_PERFORMED:工具调用已执行。
  • TOOL_CALLS_RENDERED:工具调用结果已在聊天中渲染。

文本转语音:

  • TTS_JOB_STARTED:一个 TTS 任务已开始。
  • TTS_AUDIO_READY:TTS 音频数据已准备好可播放。
  • TTS_JOB_COMPLETE:一个 TTS 任务已完成。

完整的事件类型列表可在源代码中找到。

发出事件

你可以通过调用 eventSource.emit(eventType, ...eventData) 从扩展中产生任何应用事件,包括自定义事件:

const { eventSource } = SillyTavern.getContext();

// Can be a built-in event_types field or any string.
const eventType = 'myCustomEvent';

// Use `await` to ensure all event handlers complete before continuing execution.
await eventSource.emit(eventType, { data: 'custom event data' });

提示词拦截器

提示词拦截器提供了一种方式,让扩展可以在发起文本生成请求之前执行任意活动,例如修改聊天数据、添加注入或中止生成。

来自不同扩展的拦截器会按顺序运行。顺序由各自 manifest.json 文件中的 loading_order 字段决定。loading_order 值较小的扩展运行得更早。如果未指定 loading_order,则以 display_name 作为回退。如果两者都未指定,则顺序不确定。

注册拦截器

要定义一个提示词拦截器,请在扩展的 manifest.json 文件中添加一个 generate_interceptor 字段。其值应为一个全局函数的名称,SillyTavern 将调用该函数。

{
    "display_name": "My Interceptor Extension",
    "loading_order": 10, // Affects execution order
    "generate_interceptor": "myCustomInterceptorFunction",
    // ... other manifest properties
}

拦截器函数

generate_interceptor 函数是一个全局函数,会在非试运行的生成请求时被调用。它必须定义在全局作用域中(例如 globalThis.myCustomInterceptorFunction = async function(...) { ... }),如果需要执行任何异步操作,则可以返回一个 Promise

拦截器函数接收以下参数:

  • chat:表示将用于提示词构建的聊天历史的消息对象数组。你可以直接修改此数组(例如添加、删除或更改消息)。请注意,消息是可变的,因此你对数组所做的任何更改都会反映到实际的聊天历史中。如果你希望更改是临时的,请使用 structuredClone 创建消息对象的深拷贝。
  • contextSize:一个数字,表示为即将到来的生成所计算的当前上下文大小(以 token 为单位)。
  • abort:一个函数,调用时会发出信号阻止文本生成继续进行。它接受一个布尔参数,如果为 true,则阻止任何后续拦截器运行。
  • type:一个字符串,表示生成的类型或触发方式(例如 'quiet''regenerate''impersonate''swipe' 等)。这有助于拦截器根据生成的发起方式有条件地应用逻辑。

实现示例:

globalThis.myCustomInterceptorFunction = async function(chat, contextSize, abort, type) {
    // Example: Add a system note before the last user message
    const systemNote = {
        is_user: false,
        name: "System Note",
        send_date: Date.now(),
        mes: "This was added by my extension!"
    };
    // Insert before the last message
    chat.splice(chat.length - 1, 0, systemNote);
}

生命周期钩子

扩展可以在 manifest.json 中定义生命周期钩子,这些钩子会在扩展生命周期的特定时刻被调用。每个钩子都映射到扩展 JS 入口点模块(即 js 字段指定的文件)中的一个导出函数

所有钩子都是可选的。钩子函数可以返回一个会被 await 的 Promise(带有 5 秒超时)。如果钩子超过超时时间,会记录一条警告并继续执行。钩子中的错误会被捕获并记录,而不会阻塞操作。

可用钩子

钩子 调用时机
activate 当扩展在页面加载期间被成功激活时
install 在扩展安装完成且其设置已加载之后
update 在扩展更新成功之后(在显示重新加载的 toast 通知之前)
delete 在扩展从服务器删除之前
enable 在扩展被启用且设置保存之前
disable 在扩展被禁用且设置保存之前
clean 当用户在扩展管理器中点击“清理扩展数据”按钮时,或在删除扩展时选择清理选项

清单配置

在你的 manifest.json 中添加一个 hooks 对象,将钩子名称映射到导出的函数名称:

{
    "display_name": "My Extension",
    "js": "index.js",
    // Other fields here...
    "hooks": {
        "install": "onInstall",
        "update": "onUpdate",
        "delete": "onDelete",
        "enable": "onEnable",
        "disable": "onDisable",
        "activate": "onActivate",
        "clean": "onClean"
    }
}

名称可以自由选择,只要是合法的 JS 函数名即可。 你可以配置任意数量的钩子,无需全部填写和实现。

实现

从你的主 JS 入口点导出钩子函数。每个函数不接收参数,并可选择返回一个 Promise

// index.js - your extension's entry point

export async function onInstall() {
    console.log('Extension installed! Performing first-time setup...');
    // e.g., initialize default data, create storage entries
}

export async function onActivate() {
    console.log('Extension activated during page load');
}

export async function onUpdate() {
    console.log('Extension updated! Running migrations...');
    // e.g., migrate data from old format to new format
}

export async function onDelete() {
    console.log('Extension about to be deleted. Cleaning up...');
    // e.g., remove stored data, clean up localStorage
    const { localforage } = SillyTavern.libs;
    await localforage.removeItem('my_extension_data');
}

export function onEnable() {
    console.log('Extension enabled');
}

export function onDisable() {
    console.log('Extension disabled');
}

export async function onClean() {
    console.log('Extension data cleaned');
    // e.g., cleanup of the extension's data here
}

生成文本

SillyTavern 提供了多个函数,可使用当前选定的 LLM API 在不同上下文中生成文本。这些函数允许你在对话上下文中、在没有任何上下文的原始生成中、或使用结构化输出来生成文本。

在对话上下文中

generateQuietPrompt() 函数用于在带有附加“静默”提示词(历史后指令)的对话上下文中于后台生成文本(输出不会在 UI 中渲染)。这对于在不打断用户体验、同时保持相关聊天和角色数据不变的情况下生成文本非常有用,例如生成摘要或图像提示词。

const { generateQuietPrompt } = SillyTavern.getContext();

const quietPrompt = 'Generate a summary of the chat history.';

const result = await generateQuietPrompt({
    quietPrompt,
});

原始生成

generateRaw() 函数用于在没有任何对话上下文的情况下生成文本。当你想完全控制提示词构建过程时,它非常有用。

它接受一个作为文本补全字符串或聊天补全对象数组的 prompt,并根据选定的 API 类型以适当的格式构造请求,例如在聊天/文本模式之间转换、应用指令格式化等。你还可以向该函数传递一个额外的 systemPrompt 和一个 prefill,以进一步控制生成过程。

const { generateRaw } = SillyTavern.getContext();

const systemPrompt = 'You are a helpful assistant.';
const prompt = 'Generate a story about a brave knight.';
const prefill = 'Once upon a time,';

/*
In Chat Completion mode, will produce a prompt like this:
[
  {role: 'system', content: 'You are a helpful assistant.'},
  {role: 'user', content: 'Generate a story about a brave knight.'},
  {role: 'assistant', content: 'Once upon a time,'}
]
*/

/*
In Text Completion mode (no instruct), will produce a prompt like this:
"You are a helpful assistant.\nGenerate a story about a brave knight.\nOnce upon a time,"
*/

const result = await generateRaw({
    systemPrompt,
    prompt,
    prefill,
});

结构化输出

你可以使用结构化输出功能来确保模型生成一个符合所提供的 JSON Schema 的有效 JSON 对象。这对于需要结构化数据的扩展非常有用,例如状态跟踪、数据分类等。

要使用结构化输出,你必须向 generateRaw()generateQuietPrompt() 传递一个 JSON schema 对象。模型随后会生成一个符合该 schema 的响应,并以字符串化的 JSON 对象形式返回。

const { generateRaw, generateQuietPrompt } = SillyTavern.getContext();

// Define a JSON schema for the expected output
const jsonSchema = {
    // Required: a name for the schema
    name: 'StoryStateModel',
    // Optional: a description of the schema
    description: 'A schema for a story state with location, plans, and memories.',
    // Optional:  the schema will be used in strict mode, meaning that only the fields defined in the schema will be allowed
    strict: true,
    // Required: a definition of the schema
    value: {
        '$schema': 'http://json-schema.org/draft-04/schema#',
        'type': 'object',
        'properties': {
            'location': {
                'type': 'string'
            },
            'plans': {
                'type': 'string'
            },
            'memories': {
                'type': 'string'
            }
        },
        'required': [
            'location',
            'plans',
            'memories'
        ],
    },
};

const prompt = 'Generate a story state with location, plans, and memories. Output as a JSON object.';

const rawResult = await generateRaw({
    prompt,
    jsonSchema,
});

const quietResult = await generateQuietPrompt({
    quietPrompt: prompt,
    jsonSchema,
});

注册自定义宏

你可以注册自定义宏,这些宏可在任何支持宏替换的地方使用,例如角色卡字段、STscript 命令、提示词模板等。

新宏系统

注册宏的推荐方式是通过 SillyTavern.getContext() 提供的 macros.register() 函数。该系统支持参数、类别、描述和丰富的文档元数据。

const { macros } = SillyTavern.getContext();

// Simple macro with a handler function
macros.register('tomorrow', {
    description: 'Returns tomorrow\'s date',
    handler: () => {
        return new Date(Date.now() + 24 * 60 * 60 * 1000).toLocaleDateString();
    },
});

// Macro with unnamed arguments and a category
macros.register('greet', {
    description: 'Generates a greeting for the given name',
    category: macros.category.UTILITY,
    unnamedArgs: [
        { name: 'name', description: 'The name to greet' },
    ],
    handler: ({ unnamedArgs }) => {
        const [name] = unnamedArgs;
        return `Hello, ${name}!`;
    },
});

handler 函数接收一个 MacroExecutionContext 对象,其中包含

  • args - 传递给宏的所有无名参数。
  • unnamedArgs - 与所定义参数列表匹配的位置参数。
  • list - 列表参数(在无名参数之后),如果未启用列表则为 null
  • env - 宏环境,可访问角色数据、聊天状态等。
  • resolve(text) - 用于解析文本中嵌套宏的函数(当 delayArgResolutiontrue 时)。

以及更多。

处理函数将同步运行,因此它们永远不能返回 Promise 或同步调用异步操作。

要注销一个宏:

const { macros } = SillyTavern.getContext();

macros.registry.unregisterMacro('greet');

你还可以为已有的宏注册别名:

const { macros } = SillyTavern.getContext();

macros.registerAlias('greet', 'hello', { visible: true });

旧版宏系统(已弃用)

旧版 API 仍可用于向后兼容,但将在未来版本中移除:

const { registerMacro, unregisterMacro } = SillyTavern.getContext();

// Simple string macro
registerMacro('fizz', 'buzz');
// Function macro (must be synchronous)
registerMacro('tomorrow', () => {
    return new Date(Date.now() + 24 * 60 * 60 * 1000).toLocaleDateString();
});

// Unregister
unregisterMacro('fizz');

消息格式化钩子

扩展可以挂入消息格式化流水线,在消息文本到达 DOM 之前对其进行转换。这对于添加标注(ruby 标签、工具提示)、高亮或自定义文本转换非常有用。

流水线阶段

钩子可以注册到三个流水线阶段。所有阶段都运行在 DOMPurify 净化之前,因此输出始终是安全的:

阶段 运行时机 文本格式
beforeRegex 在提示词偏置剥离之后,自定义正则规则之前 纯文本
afterRegex 在自定义正则规则之后,Markdown 转换之前 纯文本
afterMarkdown 在 Markdown 到 HTML 转换(showdown)之后,DOMPurify 之前 HTML 字符串

afterMarkdown 阶段是默认值,也是想要标注已渲染 HTML 的扩展最常用的插入点。

注册钩子

通过 getContext() 访问 messageFormatter

const { messageFormatter } = SillyTavern.getContext();

// Simple hook - transforms message text
messageFormatter.addHook((mes, ctx) => {
    // Skip user messages
    if (ctx.isUser) return mes;

    // Add furigana to Japanese text
    return addFurigana(mes);
});

// Hook with explicit stage and order
messageFormatter.addHook((mes, ctx) => {
    // Transform after Markdown conversion but before sanitization
    return mes.replace(/\*\*(.+?)\*\*/g, '<mark>$1</mark>');
}, {
    stage: messageFormatter.stage.AFTER_MARKDOWN,
    order: messageFormatter.order.EARLY,
});

钩子上下文

钩子接收一个不可变的上下文对象,其中包含消息元数据:

属性 类型 说明
characterName string 与消息关联的角色名
isSystem boolean 该消息是否为系统消息
isUser boolean 该消息是否由用户发送
messageId number 消息在聊天数组中的索引,对于临时消息(流式预览)为 -1
isReasoning boolean 该消息是否为推理/思考输出
stage string 当前正在执行的流水线阶段

该上下文对象通过 Object.freeze() 冻结——尝试修改它不会产生任何效果。

钩子排序

同一阶段内的钩子按升序运行。使用 order 选项来控制执行顺序:

const { hook_order } = messageFormatter;

// Predefined constants
hook_order.EARLIEST;  // 0
hook_order.EARLY;     // 10
hook_order.NORMAL;    // 50 (default)
hook_order.LATE;      // 90
hook_order.LATEST;    // 100

// Custom numeric value
messageFormatter.addHook(myHook, { order: 25 });

数值越小越先运行。当多个扩展转换同一段文本时,这非常有用——例如,一个扩展可能较早地提取数据,而另一个扩展可能稍后对其进行格式化。

错误处理

钩子的执行被包裹在 try/catch 中。如果某个钩子抛出异常,它会被跳过并记录一条控制台错误——流水线会继续处理剩余的钩子。

如果某个钩子返回非字符串值(包括 undefinedPromise),会发出一条控制台警告并忽略该返回值。流水线将以未更改的先前文本继续。

完整流水线顺序

供参考,完整的消息格式化流水线如下:

  1. 提示词偏置剥离(仅消息 0)
  2. 注释/隐藏消息规范化
  3. beforeRegex 扩展钩子
  4. 自定义正则规则(getRegexedString
  5. afterRegex 扩展钩子
  6. Markdown 自动修复(fixMarkdown
  7. HTML 标签编码(encode_tags
  8. Showdown Markdown → HTML 转换
  9. afterMarkdown 扩展钩子
  10. 名称前缀剥离(allow_name2_display
  11. DOMPurify 净化

所有扩展钩子(第 3、5、9 步)都运行在 DOMPurify 之前,因此它们的输出始终会被净化。

函数工具调用

扩展可以注册自定义函数工具,供 LLM 在聊天补全期间调用。这使你的扩展能够对来自模型的结构化数据做出反应——例如查询 API、执行计算或触发扩展功能。

有关包含前置条件、受支持的 API、注册字段和提示的完整指南,请参阅专门的函数调用页面。

快速示例:

const { registerFunctionTool } = SillyTavern.getContext();

registerFunctionTool({
    name: 'get_weather',
    displayName: 'Get Weather',
    description: 'Get the current weather for a given location',
    parameters: {
        $schema: 'http://json-schema.org/draft-04/schema#',
        type: 'object',
        properties: {
            location: { type: 'string', description: 'City name' },
        },
        required: ['location'],
    },
    action: async ({ location }) => {
        const data = await fetchWeatherData(location);
        return JSON.stringify(data);
    },
});

动作加载器

动作加载器为长时间运行的操作提供了加载遮罩和 toast 通知系统。它取代了已弃用的 showLoader() / hideLoader() 函数。

通过 getContext() 中的 loader 访问它:

const { loader } = SillyTavern.getContext();

// Basic blocking loader with a stoppable toast
const handle = loader.show({ message: 'Processing data...' });
try {
    const result = await someExpensiveOperation();
} finally {
    await handle.hide();
}

选项

选项 默认值 说明
blocking true 显示一个阻止交互的全屏遮罩
message 'Generating...' toast 通知中显示的消息
title '' toast 的可选标题
toastMode 'stoppable' 'stoppable'(带停止按钮)、'static'(无按钮)或 'none'(无 toast)
stopTooltip 'Stop' 停止按钮的工具提示文本
onStop null 自定义停止处理函数。默认为 stopGeneration()
onHide null 在加载器被隐藏(非停止)时调用
overlayContent null 替换默认旋转图标的自定义 HTML 元素或字符串

叠加加载器

可以同时激活多个加载器。只要至少有一个阻塞式加载器处于活动状态,遮罩就会保持可见:

const { loader } = SillyTavern.getContext();

const loader1 = loader.show({ message: 'Task 1...' });
const loader2 = loader.show({ message: 'Task 2...' });
await loader1.hide(); // Overlay stays — loader2 is still active
await loader2.hide(); // Now overlay hides

非阻塞加载器

对于不应阻塞 UI 的后台任务:

const { loader } = SillyTavern.getContext();

const handle = loader.show({
    blocking: false,
    message: 'Downloading in background...',
    onStop: () => abortDownload(),
});

弹窗与用户反馈

弹窗辅助函数

SillyTavern 通过 getContext() 中的 Popup.show 提供便捷的弹窗辅助函数:

const { Popup } = SillyTavern.getContext();

// Confirmation dialog — returns POPUP_RESULT.AFFIRMATIVE or POPUP_RESULT.NEGATIVE
const confirmed = await Popup.show.confirm('Confirm Action', 'Are you sure you want to proceed?');

// Text input dialog — returns the entered string, or null if cancelled
const userInput = await Popup.show.input('Enter Name', 'Please provide a name:', 'default value');

// Information display — returns the clicked button result
await Popup.show.text('Info', 'Operation completed successfully.');

自定义弹窗

对于更复杂的弹窗,可直接实例化 Popup 并使用完整选项:

const { Popup, POPUP_TYPE, POPUP_RESULT } = SillyTavern.getContext();

const popup = new Popup(
    '<div>Custom HTML content here</div>',
    POPUP_TYPE.TEXT,
    '',
    {
        wide: true,              // Wide display mode
        okButton: 'Save',       // Custom OK button text
        cancelButton: 'Discard', // Custom Cancel button text
        customButtons: [
            {
                text: 'Export',
                icon: 'fa-download',
                result: POPUP_RESULT.CUSTOM1,
            },
        ],
        customInputs: [
            {
                id: 'my_checkbox',
                label: 'Enable feature',
                type: 'checkbox',
                defaultState: false,
            },
        ],
        allowVerticalScrolling: true,
    }
);

const result = await popup.show();

if (result === POPUP_RESULT.AFFIRMATIVE) {
    // OK was clicked
} else if (result === POPUP_RESULT.CUSTOM1) {
    // Export button was clicked
}

// Read custom input values
const checkboxValue = popup.inputResults?.get('my_checkbox');

弹窗类型

类型 说明
POPUP_TYPE.TEXT 带按钮的通用内容弹窗
POPUP_TYPE.CONFIRM 是/否确认对话框
POPUP_TYPE.INPUT 带文本输入字段的弹窗
POPUP_TYPE.DISPLAY 仅含内容并带关闭按钮的弹窗
POPUP_TYPE.CROP 图像裁剪弹窗

Toast 通知

对于轻量级反馈,请使用 toastr(全局可用):

toastr.success('Data saved successfully');
toastr.error('Failed to connect to API');
toastr.warning('This feature is experimental');
toastr.info('Processing...');

数据银行抓取器

扩展可以为数据银行功能注册自定义数据抓取器。抓取器提供了一种从自定义来源(例如网页、API、文件格式)导入数据的方式:

const { registerDataBankScraper } = SillyTavern.getContext();

await registerDataBankScraper({
    id: 'my_scraper',
    name: 'My Data Source',
    description: 'Import data from My Data Source',
    iconClass: 'fa-solid fa-database',
    iconAvailable: true,
    isAvailable: async () => true,
    scrape: async () => {
        // Return an array of File objects
        const content = await fetchDataFromSource();
        return [new File([content], 'data.txt', { type: 'text/plain' })];
    },
});

调试函数

扩展可以注册自定义调试函数,它们会出现在调试菜单中(可通过高级用户设置访问)。这对于在开发期间暴露诊断工具、缓存/清理功能或手动触发器非常有用:

const { registerDebugFunction } = SillyTavern.getContext();

registerDebugFunction(
    'my_ext_clear_cache',        // Unique function ID
    'Clear My Extension Cache',   // Display name
    'Clears all cached data for My Extension', // Description
    async () => {
        const { localforage } = SillyTavern.libs;
        await localforage.removeItem('my_extension_cache');
        toastr.success('Cache cleared');
    }
);

发起 Extras 请求

doExtrasFetch() 函数允许你向 SillyTavern Extras API 服务器发起请求。

例如,要调用 /api/summarize 端点:

import { getApiUrl, doExtrasFetch } from "../../extensions.js";

const url = new URL(getApiUrl());
url.pathname = '/api/summarize';

const apiResult = await doExtrasFetch(url, {
    method: 'POST',
    headers: {
        'Content-Type': 'application/json',
        'Bypass-Tunnel-Reminder': 'bypass',
    },
    body: JSON.stringify({
        // Request body
    })
});

getApiUrl() 返回 Extras 服务器的基础 URL。

doExtrasFetch() 函数:

  • 添加 AuthorizationBypass-Tunnel-Reminder
  • 处理获取结果
  • 返回结果(响应对象)

这让你可以轻松地从扩展中调用 Extras API。

你可以指定:

  • 请求方法:GET、POST 等
  • 额外的请求头
  • POST 请求的请求体
  • 任何其它 fetch 选项

最佳实践

安全

切勿在 extensionSettings 中存储 API 密钥或机密

扩展设置对所有其它扩展可见,并以明文形式存储。不要在客户端存储敏感数据:

// BAD - Don't do this!
extensionSettings[MODULE_NAME].apiKey = 'secret_key_123';

// NOTE: There is no secure way to store secrets in client-side extensions.
// If you need to handle sensitive data, use server plugins instead.
// See: https://docs.sillytavern.app/for-contributors/server-plugins/

净化用户输入

在将用户输入的数据用于命令、API 调用或 DOM 操作之前,始终进行验证和净化:

// Validate input type first
if (typeof userInput !== 'string') {
    toastr.error('Invalid input type');
    return;
}
// Use DOMPurify to sanitize HTML input
const { DOMPurify } = SillyTavern.libs;
const cleanInput = DOMPurify.sanitize(userInput);

避免使用 eval()Function() 构造函数

它们可以执行任意代码并带来安全风险。如果需要动态求值,请使用更安全的替代方案,或严格限制输入。

性能

不要在 extensionSettings 中存储大量数据

扩展设置会被加载到内存中并频繁保存。大量数据可能导致性能问题:

// BAD - Don't store large data
extensionSettings[MODULE_NAME].largeDataset = { /* megabytes of data */ };

// GOOD - Use localforage (abstraction over IndexedDB/localStorage)
const { localforage } = SillyTavern.libs;
await localforage.setItem(`${MODULE_NAME}_data`, largeData);

// Or use localStorage for smaller data
localStorage.setItem(`${MODULE_NAME}_data`, JSON.stringify(smallData));

清理事件监听器

当不再需要事件监听器时将其移除,以防止内存泄漏:

function cleanup() {
    eventSource.removeListener(event_types.MESSAGE_RECEIVED, handleMessage);
    document.getElementById('myElement').removeEventListener('click', handleClick);
}

不要阻塞 UI 线程

对于繁重的操作,请使用 async/await 或 Web Worker:

// Use async for I/O operations
async function processData() {
    const result = await fetch('/api/process');
    return result.json();
}

// Break up heavy computations
async function heavyComputation(data) {
    for (let i = 0; i < data.length; i++) {
        // Process chunk
        if (i % 1000 === 0) {
            await new Promise(resolve => setTimeout(resolve, 0)); // Yield to UI
        }
    }
}

兼容性

优先使用 getContext() 而非直接导入

上下文 API 更稳定,不太可能在 SillyTavern 更新时失效:

// GOOD - Stable API
const { chat, characters, saveSettingsDebounced } = SillyTavern.getContext();

// AVOID - May break with internal changes
import { chat, characters } from '../../../../script.js';

使用唯一的模块名称

使用具有描述性且唯一的模块名称,以避免与其它扩展冲突:

// GOOD - Specific and unique
const MODULE_NAME = 'my_extension_name';

// BAD - Too generic, likely to conflict
const MODULE_NAME = 'settings';

用户体验

提供清晰的反馈

使用 toastr 进行轻量级通知,使用 Popup 进行重要的用户交互。完整详情请参阅弹窗与用户反馈一节。

对于长时间运行的操作,请使用动作加载器,而不是静默地阻塞 UI。

提供有用的控制台消息

为控制台日志使用一致的前缀。但不要在生产环境中用过多的日志刷屏:

const MODULE_NAME = 'MyExtension';

console.log(`[${MODULE_NAME}] Extension loaded`);
console.debug(`[${MODULE_NAME}] Processing data:`, data);
console.error(`[${MODULE_NAME}] Error occurred:`, error);

代码质量

使用 lib.js 中已打包的库

在添加新的依赖项之前,请先查看共享库一节——SillyTavern 打包了许多常用库(lodash、Fuse、DOMPurify、moment、yaml 等),可通过 SillyTavern.libs 使用。

正确初始化设置

始终提供默认值并处理缺失的键:

function loadSettings() {
    // Merge with defaults to handle new keys after updates and initialize if it doesn't exist.
    extensionSettings[MODULE_NAME] = SillyTavern.libs.lodash.merge(
        structuredClone(defaultSettings),
        extensionSettings[MODULE_NAME]
    );
}