🎨 优化扩展模块,完成ai接入和对话功能

This commit is contained in:
2026-02-12 23:12:28 +08:00
parent 4e611d3a5e
commit 572f3aa15b
779 changed files with 194400 additions and 3136 deletions

View File

@@ -0,0 +1,58 @@
# EditorConfig 配置文件
# https://editorconfig.org
root = true
# 通用设置,针对所有文件
[*]
# 使用空格缩进(对应 prettier.useTabs = false
indent_style = space
# 缩进为 2 个空格(对应 prettier.tabWidth = 2
indent_size = 2
# 若编辑器支持,统一制表符宽度也设为 2
tab_width = 2
# 文件编码(推荐统一为 UTF-8无 BOM
charset = utf-8
# 行尾符统一使用 LFLinux / macOS 风格)
end_of_line = lf
# 删除行尾多余空格
trim_trailing_whitespace = true
# 文件末尾一定要空一行
insert_final_newline = true
# 控制最大行长度,建议配合编辑器可视化标尺(对应 prettier.printWidth = 120
# 该字段并非官方标准,但常见于 EditorConfig 扩展
max_line_length = 120
# 注意:以下 prettier 专有配置项需保留在 .prettierrc 或相关配置文件内
# arrowParens = avoid
# bracketSpacing = true
# jsxBracketSameLine = false
# proseWrap = always
# quoteProps = as-needed
# semi = true
# singleQuote = true
# trailingComma = all
###############################
# 针对特定文件类型的覆盖
###############################
# Markdown 文件通常不删除行尾空白,以保留软换行格式
[*.md]
trim_trailing_whitespace = false
# JSON 文件统一双引号,不按 prettier.singleQuote
[*.json]
indent_style = space
indent_size = 2
# YAML 文件也保留双引号,且通常可以不删除行尾空白(可视项目需求)
[*.yml]
indent_style = space
indent_size = 2
trim_trailing_whitespace = false

View File

@@ -0,0 +1,5 @@
* text=auto eol=lf
*.{cmd,[cC][mM][dD]} text eol=crlf
*.{bat,[bB][aA][tT]} text eol=crlf
dist/** merge=ours

View File

@@ -0,0 +1,42 @@
name: bump_deps
on:
schedule:
- cron: '0 0 */3 * *'
workflow_dispatch:
jobs:
bump_deps:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
with:
token: ${{ secrets.GITHUB_TOKEN }}
- name: Use Node.js
uses: actions/setup-node@v4
with: { node-version: 22 }
- name: Setup pnpm
uses: pnpm/action-setup@v4.1.0
with: { version: 10 }
- run: |
pnpm update
pnpm install --lockfile-only
- name: Check if only pnpm-lock.yaml is changed
id: should_commit
run: |-
export changed_files=$(git diff --name-only HEAD | grep -v '^$')
export num_changed_files=$(echo "$changed_files" | wc -l)
if [ "$num_changed_files" -eq 1 ] && [ "$changed_files" = "pnpm-lock.yaml" ]; then
echo "result=false" >> "$GITHUB_OUTPUT"
else
echo "result=true" >> "$GITHUB_OUTPUT"
fi
shell: bash
- name: Commit if not only pnpm-lock.yaml is changed
if: steps.should_commit.outputs.result == 'true'
uses: EndBug/add-and-commit@v9.1.3
with:
default_author: github_actions
message: '[bot] Bump deps'

View File

@@ -0,0 +1,120 @@
name: bundle
on:
push:
branches:
- main
- dev
paths-ignore:
- dist/**
workflow_dispatch:
permissions:
actions: read
contents: write
concurrency:
group: ${{ github.workflow }}
jobs:
bundle:
runs-on: ubuntu-latest
steps:
# checkout
- uses: actions/checkout@v4
with:
token: ${{ secrets.GITHUB_TOKEN }}
- uses: JesseTG/rm@v1.0.3
with:
path: dist
# tag if [release...] is in the commit message subject
- id: autotag_check
shell: bash
run: |-
message=$(git log -n 1 --pretty=format:"%s")
if [[ $message == *"[release]"* || $message == *"[release patch]"* ]]; then
echo "should_tag=true" >> "$GITHUB_OUTPUT"
echo "bump=patch" >> "$GITHUB_OUTPUT"
elif [[ $message == *"[release minor]"* ]]; then
echo "should_tag=true" >> "$GITHUB_OUTPUT"
echo "bump=minor" >> "$GITHUB_OUTPUT"
elif [[ $message == *"[release major]"* ]]; then
echo "should_tag=true" >> "$GITHUB_OUTPUT"
echo "bump=major" >> "$GITHUB_OUTPUT"
else
echo "should_tag=false" >> "$GITHUB_OUTPUT"
echo "bump=" >> "$GITHUB_OUTPUT"
fi
- if: ${{ github.ref != 'refs/heads/dev' && github.ref_type != 'tag' && steps.autotag_check.outputs.should_tag == 'true' }}
id: autotag
uses: phish108/autotag-action@v1.1.64
with:
github-token: ${{ secrets.GITHUB_TOKEN }}
bump: ${{ steps.autotag_check.outputs.bump }}
release-branch: ''
dry-run: true
- if: ${{ github.ref != 'refs/heads/dev' && github.ref_type == 'tag' }}
id: manual_tag
env:
VALUE: ${{ github.ref }}
run: |-
export TAG=${VALUE##refs/tags/}
echo result=$TAG >> "$GITHUB_OUTPUT"
- if: ${{ github.ref != 'refs/heads/dev' && github.ref_type == 'tag' || steps.autotag_check.outputs.should_tag == 'true' }}
id: package_version
run: |-
echo 'result="version": "${{ github.ref_type == 'tag' && steps.manual_tag.outputs.result || steps.autotag.outputs.new-tag }}"' >> "$GITHUB_OUTPUT"
- if: ${{ github.ref != 'refs/heads/dev' && github.ref_type == 'tag' || steps.autotag_check.outputs.should_tag == 'true' }}
uses: jacobtomlinson/gha-find-replace@v3
with:
include: manifest.json
find: '"version": "\d+\.\d+\.\d+"'
replace: ${{ steps.package_version.outputs.result }}
regex: true
- if: ${{ github.ref != 'refs/heads/dev' && github.ref_type == 'tag' || steps.autotag_check.outputs.should_tag == 'true' }}
uses: jacobtomlinson/gha-find-replace@v3
with:
include: package.json
find: '"version": "\d+\.\d+\.\d+"'
replace: ${{ steps.package_version.outputs.result }}
regex: true
# build after tag change
- uses: actions/setup-node@v4
with:
node-version: 22
- uses: pnpm/action-setup@v4.1.0
with:
version: 10
- run: pnpm i
- run: pnpm build
- name: Merge @types files to a single file
run: |-
mkdir -p dist
cat @types/**/*.d.ts > dist/@types.txt
- name: Compress @types files to a zip
run: |-
tar \
--sort=name \
--mtime='UTC 1980-02-01' \
--owner=0 --group=0 --numeric-owner \
--pax-option=exthdr.name=%d/PaxHeaders/%f,delete=atime,delete=ctime \
-caf ../dist/@types.zip .
working-directory: '@types'
# commit
- uses: EndBug/add-and-commit@v9.1.3
with:
default_author: github_actions
message: '[bot] Bundle'
- if: ${{ github.ref_type == 'tag' }}
shell: bash
run: |-
git tag -d ${{ steps.manual_tag.outputs.result }}
git push --delete origin ${{ steps.manual_tag.outputs.result }}
- if: ${{ github.ref_type == 'tag' || steps.autotag_check.outputs.should_tag == 'true' }}
shell: bash
run: |-
git tag ${{ github.ref_type == 'tag' && steps.manual_tag.outputs.result || steps.autotag.outputs.new-tag }}
git push --tags

View File

@@ -0,0 +1,22 @@
name: sync
on:
workflow_run:
workflows:
- bundle
types:
- completed
workflow_dispatch:
concurrency:
group: ${{github.workflow}}
jobs:
sync:
runs-on: ubuntu-latest
steps:
- uses: keninkujovic/gitlab-sync@2.0.0
with:
gitlab_url: https://gitlab.com/novi028/JS-Slash-Runner.git
username: novi028
gitlab_pat: ${{ secrets.GITLAB_PAT }}

View File

@@ -0,0 +1,14 @@
/node_modules
.DS_Store
Thumbs.db
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
auto-imports.d.ts
components.d.ts

View File

@@ -0,0 +1 @@
dist

View File

@@ -0,0 +1,12 @@
{
"arrowParens": "avoid",
"bracketSpacing": true,
"printWidth": 120,
"proseWrap": "always",
"quoteProps": "as-needed",
"semi": true,
"singleQuote": true,
"tabWidth": 2,
"trailingComma": "all",
"useTabs": false
}

View File

@@ -0,0 +1,98 @@
{
"version": "0.2.0",
"configurations": [
{
"name": "编译代码并调试酒馆网页 (Chrome)",
"type": "chrome",
"request": "launch",
"preLaunchTask": "开始监听源代码并编译",
"postDebugTask": "结束监听源代码并编译",
"url": "http://localhost:8000",
"disableNetworkCache": true,
"internalConsoleOptions": "neverOpen",
"webRoot": "${workspaceFolder}/../../../../",
"cwd": "${workspaceFolder}",
"timeout": 1000000,
"skipFiles": [
"**/jquery*.min.js",
"**/node_modules/**"
],
},
{
"name": "编译代码并调试酒馆网页 (Edge)",
"type": "msedge",
"request": "launch",
"preLaunchTask": "开始监听源代码并编译",
"postDebugTask": "结束监听源代码并编译",
"url": "http://localhost:8000",
"disableNetworkCache": true,
"internalConsoleOptions": "neverOpen",
"webRoot": "${workspaceFolder}/../../../../",
"cwd": "${workspaceFolder}",
"timeout": 1000000,
"skipFiles": [
"**/jquery*.min.js",
"**/node_modules/**"
],
},
{
"name": "编译代码并调试酒馆网页 (Firefox)",
"type": "firefox",
"request": "launch",
"preLaunchTask": "开始监听源代码并编译",
"postDebugTask": "结束监听源代码并编译",
"url": "http://localhost:8000",
"internalConsoleOptions": "neverOpen",
"webRoot": "${workspaceFolder}/../../../../",
"suggestPathMappingWizard": true,
"timeout": 1000000,
"skipFiles": [
"**/jquery*.min.js",
"**/node_modules/**"
],
},
{
"name": "仅调试酒馆网页 (Chrome)",
"type": "chrome",
"request": "launch",
"url": "http://localhost:8000",
"disableNetworkCache": true,
"internalConsoleOptions": "neverOpen",
"webRoot": "${workspaceFolder}/../../../../",
"cwd": "${workspaceFolder}",
"timeout": 1000000,
"skipFiles": [
"**/jquery*.min.js",
"**/node_modules/**"
],
},
{
"name": "仅调试酒馆网页 (Edge)",
"type": "msedge",
"request": "launch",
"url": "http://localhost:8000",
"disableNetworkCache": true,
"internalConsoleOptions": "neverOpen",
"webRoot": "${workspaceFolder}/../../../../",
"cwd": "${workspaceFolder}",
"timeout": 1000000,
"skipFiles": [
"**/jquery*.min.js",
"**/node_modules/**"
],
},
{
"name": "仅调试酒馆网页 (Firefox)",
"type": "firefox",
"request": "launch",
"url": "http://localhost:8000",
"internalConsoleOptions": "neverOpen",
"webRoot": "${workspaceFolder}/../../../../",
"suggestPathMappingWizard": true,
"skipFiles": [
"**/jquery*.min.js",
"**/node_modules/**"
],
}
]
}

View File

@@ -0,0 +1,53 @@
{
"editor.formatOnSave": true,
"editor.codeActionsOnSave": {
"source.addMissingImports.ts": "explicit",
"source.convertImportFormat": "explicit",
"source.fixAll": "explicit",
"source.organizeImports": "explicit",
"source.sortImports": "explicit"
},
"prettier.arrowParens": "avoid",
"prettier.bracketSameLine": false,
"prettier.bracketSpacing": true,
"prettier.printWidth": 120,
"prettier.proseWrap": "always",
"prettier.quoteProps": "as-needed",
"prettier.semi": true,
"prettier.tabWidth": 2,
"prettier.singleQuote": true,
"prettier.trailingComma": "all",
"prettier.useTabs": false,
"typescript.inlayHints.enumMemberValues.enabled": true,
"typescript.inlayHints.functionLikeReturnTypes.enabled": true,
"typescript.inlayHints.parameterNames.enabled": "literals",
"typescript.inlayHints.parameterNames.suppressWhenArgumentMatchesName": true,
"typescript.inlayHints.parameterTypes.enabled": false,
"typescript.inlayHints.propertyDeclarationTypes.enabled": true,
"typescript.inlayHints.variableTypes.enabled": true,
"typescript.inlayHints.variableTypes.suppressWhenTypeMatchesName": true,
"css.lint.unknownAtRules": "ignore",
"scss.lint.unknownAtRules": "ignore",
"files.associations": {
"*.css": "tailwindcss"
},
"[javascript]": {
"editor.defaultFormatter": "esbenp.prettier-vscode"
},
"[typescript]": {
"editor.defaultFormatter": "esbenp.prettier-vscode"
},
"[html]": {
"editor.defaultFormatter": "esbenp.prettier-vscode"
},
"[css]": {
"editor.defaultFormatter": "esbenp.prettier-vscode"
},
"[yaml]": {
"editor.defaultFormatter": "redhat.vscode-yaml"
},
"[vue]": {
"editor.defaultFormatter": "esbenp.prettier-vscode"
},
"typescript.tsdk": "node_modules/typescript/lib"
}

View File

@@ -0,0 +1,57 @@
{
"version": "2.0.0",
"tasks": [
{
"label": "开始监听源代码并编译",
"type": "shell",
"command": "pnpm",
"args": [
"watch"
],
"problemMatcher": [
{
"pattern": [
{
"regexp": ".*",
"file": 1,
"location": 2,
"message": 3
}
],
"background": {
"activeOnStart": true,
"beginsPattern": ".*",
"endsPattern": "built in \\d+ms."
}
}
],
"group": {
"kind": "build",
"isDefault": true
},
"presentation": {
"echo": true,
"reveal": "always",
"focus": false,
"panel": "shared",
"showReuseMessage": true,
"clear": true,
"revealProblems": "never"
},
"isBackground": true
},
{
"label": "结束监听源代码并编译",
"command": "echo ${input:terminate}",
"type": "shell"
}
],
"inputs": [
{
"id": "terminate",
"type": "command",
"command": "workbench.action.tasks.terminate",
"args": "terminateAll"
}
]
}

View File

@@ -0,0 +1,105 @@
type Audio = {
/** 标题 */
title: string;
/** 音频的网络链接 */
url: string;
};
type AudioWithOptionalTitle = {
/** 标题 */
title?: string;
/** 音频的网络链接 */
url: string;
};
/**
* 播放给定的音频; 如果该音频没在播放列表中, 则会加入到播放列表.
*
* @param type 背景音乐 ('bgm') 或音效 ('ambient')
* @param audio 要播放的音频; 如果音频没有设置标题 (`title`), 则会从链接 (`url`) 提取文件名作为标题
*
* @example
* // 将给定链接作为背景音乐播放
* playAudio('bgm', { url: 'http://commondatastorage.googleapis.com/codeskulptor-demos/DDR_assets/Kangaroo_MusiQue_-_The_Neverwritten_Role_Playing_Game.mp3' });
*
* @example
* // 为给定链接设置标题, 并作为背景音乐播放
* playAudio('bgm', { title: 'Kangaroo Music', url: 'http://commondatastorage.googleapis.com/codeskulptor-demos/DDR_assets/Kangaroo_MusiQue_-_The_Neverwritten_Role_Playing_Game.mp3' });
*/
declare function playAudio(type: 'bgm' | 'ambient', audio: AudioWithOptionalTitle): void;
/**
* 暂停音乐
*
* @param type 背景音乐 ('bgm') 或音效 ('ambient')
*/
declare function pauseAudio(type: 'bgm' | 'ambient'): void;
/**
* 获取播放列表
*
* @param type 背景音乐 ('bgm') 或音效 ('ambient')
* @returns 播放列表
*/
declare function getAudioList(type: 'bgm' | 'ambient'): Audio[];
/**
* 完全替换播放列表为 `audio_list`
*
* @param type 背景音乐 ('bgm') 或音效 ('ambient')
* @param audio_list 新的播放列表; 如果其中音频没有设置标题 (`title`), 则会从链接 (`url`) 提取文件名作为标题
*/
declare function replaceAudioList(type: 'bgm' | 'ambient', audio_list: AudioWithOptionalTitle[]): void;
/**
* 向播放列表末尾添加不存在的音频, 不会重复添加同 `title` 或 `url` 的音频
*
* @param type 背景音乐 ('bgm') 或音效 ('ambient')
* @param audio_list 要插入的音频列表; 如果其中音频没有设置标题 (`title`), 则会从链接 (`url`) 提取文件名作为标题
*/
declare function appendAudioList(type: 'bgm' | 'ambient', audio_list: AudioWithOptionalTitle[]): void;
type AudioSettings = {
/** 是否启用 */
enabled: boolean;
/**
* 当前播放模式
* - repeat_one: 单曲循环
* - repeat_all: 全部循环
* - shuffle: 随机播放
* - play_one_and_stop: 播放一首后停止
*/
mode: 'repeat_one' | 'repeat_all' | 'shuffle' | 'play_one_and_stop';
/** 是否静音 */
muted: boolean;
/** 当前音量 (0-100) */
volume: number;
};
/**
* 获取音频设置
*
* @param type 背景音乐 ('bgm') 或音效 ('ambient')
* @returns 音频设置
*/
declare function getAudioSettings(type: 'bgm' | 'ambient'): AudioSettings;
/**
* 修改音频设置, 如果某字段不存在, 则使用原本的设置.
*
* @param type 背景音乐 ('bgm') 或音效 ('ambient')
* @param settings 要修改的音频设置
*
* @example
* // 将背景音乐设置为单曲循环
* setAudioSettings('bgm', { mode: 'repeat_one' });
*
* @example
* // 将音效设置为静音
* setAudioSettings('ambient', { muted: true });
*
* @example
* // 将背景音乐音量设置为 50%
* setAudioSettings('bgm', { volume: 50 });
*/
declare function setAudioSettings(type: 'bgm' | 'ambient', settings: Partial<AudioSettings>): void;

View File

@@ -0,0 +1,85 @@
declare const builtin: {
/**
* 向网页添加一条楼层渲染
*
* @param mes 要渲染的楼层数据
* @param options 可选选项
* - `type`: 楼层类型; 默认为 `'normal'`
* - `insertAfter`: 插入到指定楼层后; 默认为 `null`
* - `scroll`: 是否滚动到新楼层; 默认为 `true`
* - `insertBefore`: 插入到指定楼层前; 默认为 `null`
* - `forceId`: 强制使用指定楼层号; 默认为 `null`
* - `showSwipes`: 是否显示滑动按钮; 默认为 `true`
*/
addOneMessage: (
mes: Record<string, any>,
options?: {
type?: string;
insertAfter?: number;
scroll?: boolean;
insertBefore?: number;
forceId?: number;
showSwipes?: boolean;
},
) => void;
/**
* 复制文本到剪贴板
*
* @param text 要复制的文本
*/
copyText: (text: string) => void;
duringGenerating: () => boolean;
getImageTokenCost: (data_url: string, quality: 'low' | 'auto' | 'high') => Promise<number>;
getVideoTokenCost: (data_url: string) => Promise<number>;
parseRegexFromString: (regex: string) => RegExp | null;
promptManager: {
messages: Array<{
collection: Array<{
identifier: string;
role: 'user' | 'assistant' | 'system';
content: string;
tokens: number;
}>;
identifier: string;
}>;
getPromptCollection: () => {
collection: Array<{
identifier: string;
name: string;
enabled?: boolean;
injection_position: 0 | 1;
injection_depth: number;
injection_order: number;
role: 'user' | 'assistant' | 'system';
content: string;
system_prompt: boolean;
marker?: boolean;
extra?: Record<string, any>;
forbid_overrides?: boolean;
}>;
[key: string]: any;
};
[key: string]: any;
};
/** 刷新当前聊天并触发 CHARACTER_MESSAGE_RENDERED 和 USER_MESSAGE_RENDERED 事件从而重新渲染 */
reloadAndRenderChatWithoutEvents: () => Promise<void>;
/** 刷新当前聊天但不触发任何事件 */
reloadChatWithoutEvents: () => Promise<void>;
/** 刷新世界书编辑器的显示 */
reloadEditor: (file: string, load_if_not_selected?: boolean) => void;
/** 刷新世界书编辑器的显示 (防抖) */
reloadEditorDebounced: (file: string, load_if_not_selected?: boolean) => void;
/** 将 markdown 渲染成 html */
renderMarkdown: (string: string) => string;
/** 刷新预设提示词列表 */
renderPromptManager: (after_try_generate?: boolean) => void;
/** 刷新预设提示词列表 (防抖) */
renderPromptManagerDebounced: (after_try_generate?: boolean) => void;
saveSettings: () => Promise<void>;
uuidv4: () => string;
};

View File

@@ -0,0 +1,167 @@
type Character = {
avatar: `${string}.png` | Blob;
version: string;
creator: string;
creator_notes: string;
worldbook: string | null;
description: string;
first_messages: string[];
extensions: {
regex_scripts: TavernRegex[];
tavern_helper: {
scripts: Record<string, any>[];
variables: Record<string, any>;
};
[other: string]: any;
};
};
/**
* 获取角色卡名称列表
*
* @returns 角色卡名称列表
*/
declare function getCharacterNames(): string[];
/**
* 获取当前角色卡名称
*
* @returns 当前角色卡名称, 如果当前没有角色卡, 则返回 `null`
*/
declare function getCurrentCharacterName(): string | null;
/**
* 新建 `character_name` 角色卡, 内容为 `character`
*
* @param character_name 角色卡名称
* @param character 角色卡数据; 不填则使用默认数据
*
* @returns 是否成功创建, 如果已经存在同名角色卡或尝试创建名为 `'current'` 的角色卡会失败
*
* @throws 如果访问后端失败, 将会抛出异常
*/
declare function createCharacter(
character_name: Exclude<string, 'current'>,
character?: PartialDeep<Character>,
): Promise<boolean>;
/**
* 创建或替换名为 `character_name` 的角色卡, 内容为 `character`
*
* @param character_name 角色卡名称
* @param character 角色卡数据; 不填则使用默认数据
* @param options 可选选项
* - `render:'debounced'|'immediate'|'none'`: 酒馆网页应该防抖渲染 (debounced)、立即渲染 (immediate) 还是不刷新前端显示 (none)? 默认为性能更好的防抖渲染
*
* @returns 如果发生创建, 则返回 `true`; 如果发生替换, 则返回 `false`
*
* @throws 如果访问后端失败, 将会抛出异常
*/
declare function createOrReplaceCharacter(
character_name: Exclude<string, 'current'>,
character?: PartialDeep<Character>,
options?: ReplaceCharacterOptions,
): Promise<boolean>;
/**
* 删除 `character_name` 角色卡
*
* @param character_name 角色卡名称
*
* @returns 是否成功删除, 可能因角色卡不存在等原因而失败
*/
declare function deleteCharacter(character_name: LiteralUnion<'current', string>): Promise<boolean>;
/**
* 获取 `character_name` 角色卡的内容
*
* @param character_name 角色卡名称
*
* @returns 角色卡内容
*
* @throws 如果角色卡不存在, 将会抛出异常
*/
declare function getCharacter(character_name: LiteralUnion<'current', string>): Promise<Character>;
type ReplaceCharacterOptions = {
/** 酒馆网页应该防抖渲染 (debounced)、立即渲染 (immediate) 还是不刷新前端显示 (none)? 默认为性能更好的防抖渲染 */
render?: 'debounced' | 'immediate' | 'none';
};
/**
* 完全替换 `character_name` 角色卡的内容为 `character`
*
* @param character_name 角色卡名称
* @param character 角色卡数据
* @param options 可选选项
* - `render:'debounced'|'immediate'|'none'`: 酒馆网页应该防抖渲染 (debounced)、立即渲染 (immediate) 还是不刷新前端显示 (none)? 默认为性能更好的防抖渲染
*
* @throws 如果角色卡不存在, 将会抛出异常
* @throws 如果访问后端失败, 将会抛出异常
*
* @example
* // 为角色卡更改开场白
* const character = await getCharacter('角色卡名称');
* character.first_messages = ['新的开场白1', '新的开场白2'];
* await replaceCharacter('角色卡名称', character);
*
* @example
* // 清空角色卡的局部正则
* const character = await getCharacter('角色卡名称');
* character.extensions.regex_scripts = [];
* await replaceCharacter('角色卡名称', character);
*
* @example
* // 更换角色卡头像
* const character = await getCharacter('角色卡名称');
* character.avatar = await fetch('https://example.com/avatar.png').then(response => response.blob());
* await replaceCharacter('角色卡名称', character);
*/
declare function replaceCharacter(
character_name: Exclude<string, 'current'>,
character: PartialDeep<Character>,
options?: ReplaceCharacterOptions,
): Promise<void>;
type CharacterUpdater = ((character: Character) => Character) | ((character: Character) => Promise<Character>);
/**
* 用 `updater` 函数更新 `character_name` 角色卡
*
* @param character_name 角色卡名称
* @param updater 用于更新角色卡的函数. 它应该接收角色卡内容作为参数, 并返回更新后的角色卡内容.
* @param options 可选选项
* - `render:'debounced'|'immediate'|'none'`: 如果对角色卡进行操作, 应该防抖渲染 (debounced)、立即渲染 (immediate) 还是不刷新前端显示 (none)? 默认为性能更好的防抖渲染
*
* @returns 更新后的角色卡内容
*
* @throws 如果角色卡不存在, 将会抛出异常
* @throws 如果访问后端失败, 将会抛出异常
*
* @example
* // 为角色卡添加一个开场白
* await updateCharacterWith('角色卡名称', character => {
* character.first_messages.push('新的开场白');
* return character;
* });
*
* @example
* // 清空角色卡的局部正则
* await updateCharacterWith('角色卡名称', character => {
* character.extensions.regex_scripts = [];
* return character;
* });
*
* @example
* // 更换角色卡头像
* await updateCharacterWith('角色卡名称', async character => {
* character.avatar = await fetch('https://example.com/avatar.png').then(response => response.blob());
* return character;
* });
*/
declare function updateCharacterWith(
character_name: LiteralUnion<'current', string>,
updater: CharacterUpdater,
): Promise<Character>;

View File

@@ -0,0 +1,234 @@
type ChatMessage = {
message_id: number;
name: string;
role: 'system' | 'assistant' | 'user';
is_hidden: boolean;
message: string;
data: Record<string, any>;
extra: Record<string, any>;
};
type ChatMessageSwiped = {
message_id: number;
name: string;
role: 'system' | 'assistant' | 'user';
is_hidden: boolean;
swipe_id: number;
swipes: string[];
swipes_data: Record<string, any>[];
swipes_info: Record<string, any>[];
};
type GetChatMessagesOption = {
/** 按 role 筛选消息; 默认为 `'all'` */
role?: 'all' | 'system' | 'assistant' | 'user';
/** 按是否被隐藏筛选消息; 默认为 `'all'` */
hide_state?: 'all' | 'hidden' | 'unhidden';
/** 是否包含未被 AI 使用的消息页信息, 如没选择的开局、通过点击箭头重 roll 的楼层. 如果不包含则返回类型为 `ChatMessage`, 否则返回类型为 `ChatMessageSwiped`; 默认为 `false` */
include_swipes?: boolean;
};
/**
* 获取聊天消息, 仅获取每楼被 AI 使用的消息页
*
* @param range 要获取的消息楼层号或楼层范围, 如 `0`, `'0-{{lastMessageId}}'`, `-1` 等. 负数表示深度, 如 `-1` 表示最新的消息楼层, `-2` 表示倒数第二条消息楼层.
* @param option 可选选项
* - `role:'all'|'system'|'assistant'|'user'`: 按 role 筛选消息; 默认为 `'all'`
* - `hide_state:'all'|'hidden'|'unhidden'`: 按是否被隐藏筛选消息; 默认为 `'all'`
* - `include_swipes:false`: 不包含未被 AI 使用的消息页信息
*
* @returns 一个 `ChatMessage` 数组, 包含指定楼层范围内实际存在的所有楼层, 依据 message_id 从低到高排序; 如果范围内完全不存在楼层 (如目前只有 3 楼, 但范围为 `4-5`), 则返回空数组
*
* @example
* // 仅获取第 10 楼被 AI 使用的消息页
* const chat_messages = getChatMessages(10);
* const chat_messages = getChatMessages('10');
* const chat_messages = getChatMessages('10', { include_swipes: false });
*
* @example
* // 获取最新楼层被 AI 使用的消息页
* const chat_message = getChatMessages(-1)[0]; // 或 getChatMessages('{{lastMessageId}}')[0]
*
* @example
* // 获取所有楼层被 AI 使用的消息页
* const chat_messages = getChatMessages('0-{{lastMessageId}}');
*/
declare function getChatMessages(
range: string | number,
{ role, hide_state, include_swipes }?: Omit<GetChatMessagesOption, 'include_swipes'> & { include_swipes?: false },
): ChatMessage[];
/**
* 获取聊天消息, 获取每楼所有的消息页, 包含未被 AI 使用的消息页消息
*
* @param range 要获取的消息楼层号或楼层范围, 如 `0`, `'0-{{lastMessageId}}'`, `-1` 等. 负数表示深度, 如 `-1` 表示最新的消息楼层, `-2` 表示倒数第二条消息楼层.
* @param option 可选选项
* - `role:'all'|'system'|'assistant'|'user'`: 按 role 筛选消息; 默认为 `'all'`
* - `hide_state:'all'|'hidden'|'unhidden'`: 按是否被隐藏筛选消息; 默认为 `'all'`
* - `include_swipes:true`: 包含未被 AI 使用的消息页信息
*
* @returns 一个 `ChatMessage` 数组, 包含指定楼层范围内实际存在的所有楼层, 依据 message_id 从低到高排序; 如果范围内完全不存在楼层 (如目前只有 3 楼, 但范围为 `4-5`), 则返回空数组
*
* @example
* // 获取第 10 楼所有的消息页
* const chat_messages = getChatMessages(10, { include_swipes: true });
* const chat_messages = getChatMessages('10', { include_swipes: true });
*
* @example
* // 获取最新楼层所有的消息页
* const chat_message = getChatMessages(-1, { include_swipes: true })[0]; // 或 getChatMessages('{{lastMessageId}}', { include_swipes: true })[0]
*
* @example
* // 获取所有楼层所有的消息页
* const chat_messages = getChatMessages('0-{{lastMessageId}}', { include_swipes: true });
*/
declare function getChatMessages(
range: string | number,
{ role, hide_state, include_swipes }?: Omit<GetChatMessagesOption, 'include_swipes'> & { include_swipes?: true },
): ChatMessageSwiped[];
/**
* 获取聊天消息
*
* @param range 要获取的消息楼层号或楼层范围, 如 `0`, `'0-{{lastMessageId}}'`, `-1` 等. 负数表示深度, 如 `-1` 表示最新的消息楼层, `-2` 表示倒数第二条消息楼层.
* @param option 可选选项
* - `role:'all'|'system'|'assistant'|'user'`: 按 role 筛选消息; 默认为 `'all'`
* - `hide_state:'all'|'hidden'|'unhidden'`: 按是否被隐藏筛选消息; 默认为 `'all'`
* - `include_swipes:boolean`: 是否包含未被 AI 使用的消息页信息, 如没选择的开局、通过点击箭头重 roll 的楼层. 如果不包含则返回类型为 `ChatMessage`, 否则返回类型为 `ChatMessageSwiped`; 默认为 `false`
*
* @returns 一个数组, 数组的元素是每楼的消息, 依据 message_id 从低到高排序, 类型为 `ChatMessage` 或 `ChatMessageSwiped` (取决于 `include_swipes` 的值, 默认为 `ChatMessage`).
*/
declare function getChatMessages(
range: string | number,
{ role, hide_state, include_swipes }?: GetChatMessagesOption,
): (ChatMessage | ChatMessageSwiped)[];
type SetChatMessagesOption = {
/**
* 是否更新楼层在页面上的显示; 默认为 `'affected'`
* - `'none'`: 不更新页面的显示
* - `'affected'`: 仅更新被影响楼层的显示, 更新显示时会发送 `tavern_events.USER_MESSAGE_RENDERED` 或 `tavern_events.CHARACTER_MESSAGE_RENDERED` 事件
* - `'all'`: 重新载入整个聊天消息, 将会触发 `tavern_events.CHAT_CHANGED` 事件
*/
refresh?: 'none' | 'affected' | 'all';
};
/**
* 修改聊天消息的数据
*
* @param chat_messages 要修改的消息, 必须包含 `message_id` 字段
* @param option 可选选项
* - `refresh:'none'|'affected'|'all'`: 是否更新楼层在页面上的显示; 默认为 `'affected'`
*
* @example
* // 修改第 10 楼被 AI 使用的消息页的正文
* await setChatMessages([{message_id: 10, message: '新的消息'}]);
*
* @example
* // 设置开局
* await setChatMessages([{message_id: 0, swipes: ['开局1', '开局2']}])
*
* @example
* // 切换为开局 3
* await setChatMessages([{message_id: 0, swipe_id: 2}]);
*
* @example
* // 重新渲染第 4 楼的前端界面 (利用 `{render: 'affected'}`)
* await setChatMessages([{message_id: 4}]);
*
* @example
* // 补充倒数第二楼的楼层变量
* const chat_message = getChatMessages(-2)[0];
* _.set(chat_message.data, '神乐光好感度', 5);
* await setChatMessages([{message_id: 0, data: chat_message.data}], {refresh: 'none'});
*
* @example
* // 隐藏所有楼层
* const last_message_id = getLastMessageId();
* await setChatMessages(_.range(last_message_id + 1).map(message_id => ({message_id, is_hidden: true})));
*/
declare function setChatMessages(
chat_messages: Array<{ message_id: number } & (Partial<ChatMessage> | Partial<ChatMessageSwiped>)>,
{ refresh }?: SetChatMessagesOption,
): Promise<void>;
type ChatMessageCreating = {
name?: string;
role: 'system' | 'assistant' | 'user';
is_hidden?: boolean;
message: string;
data?: Record<string, any>;
extra?: Record<string, any>;
};
type CreateChatMessagesOption = SetChatMessagesOption & {
/** @deprecated 请使用 `insert_before` */
insert_at?: number | 'end';
/** 插入到指定楼层前或末尾; 默认为末尾 */
insert_before?: number | 'end';
};
/**
* 创建聊天消息
*
* @param chat_messages 要创建的消息, 必须包含 `role` 和 `message` 字段
* @param option 可选选项
* - `insert_before:number|'end'`: 插入到指定楼层前或末尾; 默认为末尾
* - `refresh:'none'|'affected'|'all'`: 是否更新楼层在页面上的显示; 默认为 `'affected'`
*
* @example
* // 在第 10 楼前插入一条消息
* await createChatMessages([{role: 'user', message: '你好'}], {insert_at: 10});
*
* @example
* // 在末尾插入一条消息
* await createChatMessages([{role: 'user', message: '你好'}]);
*/
declare function createChatMessages(
chat_messages: ChatMessageCreating[],
{ insert_before, refresh }?: CreateChatMessagesOption,
): Promise<void>;
/**
* 删除聊天消息
*
* @param message_ids 要删除的消息楼层号数组
* @param option 可选选项
* - `refresh:'none'|'affected'|'all'`: 是否更新楼层在页面上的显示; 默认为 `'affected'`
*
* @example
* // 删除第 10 楼、第 15 楼、倒数第二楼和最后一楼
* await deleteChatMessages([10, 15, -2, getLastMessageId()]);
*
* @example
* // 删除所有楼层
* await deleteChatMessages(_.range(getLastMessageId() + 1));
*/
declare function deleteChatMessages(message_ids: number[], { refresh }?: SetChatMessagesOption): Promise<void>;
/**
* 将原本顺序是 `[begin, middle) [middle, end)` 的楼层旋转为 `[middle, end) [begin, middle)`
*
* @param begin 旋转前开头楼层的楼层号
* @param middle 旋转后将会被放到最开头的楼层号
* @param end 旋转前结尾楼层的楼层号 + 1
* @param option 可选选项
* - `refresh:'none'|'affected'|'all'`: 是否更新楼层在页面上的显示; 默认为 `'affected'`
*
* @example
* // 将最后一楼放到第 5 楼之前
* await rotateChatMessages(5, getLastMessageId(), getLastMessageId() + 1);
*
* // 将最后 3 楼放到第 1 楼之前
* await rotateChatMessages(1, getLastMessageId() - 2, getLastMessageId() + 1);
*
* // 将前 3 楼放到最后
* await rotateChatMessages(0, 3, getLastMessageId() + 1);
*/
declare function rotateChatMessages(
begin: number,
middle: number,
end: number,
{ refresh }?: SetChatMessagesOption,
): Promise<void>;

View File

@@ -0,0 +1,70 @@
/**
* 获取消息楼层号对应的消息内容 JQuery 实例
*
* 相比于一个实用函数, 这更像是一个告诉你可以用 JQuery 的示例
*
* @param message_id 要获取的消息楼层号, 必须要酒馆页面显示了该消息楼层才能获取到
* @returns 如果能获取到该消息楼层的 html, 则返回对应的 JQuery; 否则返回空 JQuery
*
* @example
* // 获取第 0 楼的消息内容文本
* const text = retrieveDisplayedMessage(0).text();
*
* @example
* // 修改第 0 楼的消息内容文本
* // - 这样的修改只会影响本次显示, 不会保存到消息文件中, 因此重新加载消息或刷新网页等操作后就会回到原样;
* // - 如果需要实际修改消息文件, 请使用 `setChatMessage`
* retrieveDisplayedMessage(0).text("new text");
* retrieveDisplayedMessage(0).append("<pre>new text</pre>");
* retrieveDisplayedMessage(0).append(formatAsDisplayedMessage("{{char}} speaks in {{lastMessageId}}"));
*/
declare function retrieveDisplayedMessage(message_id: number): JQuery<HTMLDivElement>;
type FormatAsDisplayedMessageOption = {
/** 消息所在的楼层, 要求该楼层已经存在, 即在 `[0, getLastMessageId()]` 范围内; 默认为 'last' */
message_id?: 'last' | 'last_user' | 'last_char' | number;
};
/**
* 将字符串处理为酒馆用于显示的 html 格式. 将会,
* 1. 替换字符串中的酒馆宏
* 2. 对字符串应用对应的酒馆正则
* 3. 将字符串调整为 html 格式
*
* @param text 要处理的字符串
* @param option 可选选项
* - `message_id?:number`: 消息所在的楼层, 要求该楼层已经存在, 即在 `[0, getLastMessageId()]` 范围内; 默认为最新楼层
*
* @returns 处理结果
*
* @throws 如果提供的消息楼层号 `message_id` 不在 `[0, getLastMessageId()]` 范围内, 将会抛出错误
*
* @example
* const text = formatAsDisplayedMessage("{{char}} speaks in {{lastMessageId}}");
* => "<p>少女歌剧 speaks in 5</p>";
*/
declare function formatAsDisplayedMessage(text: string, { message_id }?: FormatAsDisplayedMessageOption): string;
/**
* 刷新或替换单个楼层的显示, 如果该楼层并没有显示在网页上则什么也不做
*
* @param message_id 要刷新的消息楼层号
* @param $mes 要刷新的消息楼层对应的 JQuery 实例, 如果未提供则自动通过 `message_id` 获取
*
* @example
* // 刷新第 0 楼的显示
* await refreshOneMessage(0);
*
* @example
* // 刷新最新楼层的显示
* await refreshOneMessage(getLastMessageId());
*
* @example
* // 强行让第 5 楼显示第 0 楼的消息, 这只会影响网页显示, 不会影响实际聊天记录
* await refreshOneMessage(0, $('#chat > .mes[mesid="5"]'));
*
* @example
* // 强行让最后一楼显示第 0 楼的消息, 这只会影响网页显示, 不会影响实际聊天记录
* await refreshOneMessage(0, $('#chat > .mes.last_mes'));
*/
declare function refreshOneMessage(message_id: number, $mes?: JQuery): Promise<void>;

View File

@@ -0,0 +1,104 @@
/** 检查当前用户是否为管理员, 只有管理员能更新全局扩展 */
declare function isAdmin(): boolean;
/** 获取酒馆助手扩展 id */
declare function getTavernHelperExtensionId(): string;
/**
* 获取已安装扩展的类型
* - `'local'`: 本地扩展, 仅当前用户可用
* - `'global'`: 全局扩展, 酒馆所有用户可用
* - `'system'`: 酒馆内置扩展, 如正则等
*
* @param extension_id 扩展 id, 一般是扩展文件夹名
*/
declare function getExtensionType(extension_id: string): 'local' | 'global' | 'system' | null;
type ExtensionInstallationInfo = {
current_branch_name: string;
current_commit_hash: string;
is_up_to_date: boolean;
remote_url: string;
};
/**
* 获取扩展安装信息
*
* @param extension_id 扩展 id, 一般是扩展文件夹名
*/
declare function getExtensionInstallationInfo(extension_id: string): Promise<ExtensionInstallationInfo | null>;
/**
* 检查是否已安装某一扩展
*
* @param extension_id 扩展 id, 一般是扩展文件夹名
*
* @example
* // 检查是否已安装酒馆助手
* const is_installed = isInstalledExtension(getTavernHelperExtensionId());
*/
declare function isInstalledExtension(extension_id: string): boolean;
/**
* 安装扩展; 新安装的扩展需要刷新页面 (`triggerSlash('/reload-page')`) 才生效
*
* @param url 扩展 URL
* @param type 要安装成的扩展类型
* - `'local'`: 本地扩展, 仅当前用户可用
* - `'global'`: 全局扩展, 酒馆所有用户可用
* @returns 对安装的响应情况
*
* @example
* // 安装酒馆助手
* const response = await installExtension('https://github.com/n0vi028/JS-Slash-Runner', 'local');
* if (response.ok) {
* toastr.success(`成功安装酒馆助手, 准备刷新页面以生效...`);
* _.delay(() => triggerSlash('/reload-page'), 3000);
* }
*/
declare function installExtension(url: string, type: 'local' | 'global'): Promise<Response>;
/**
* 卸载扩展; 卸载后需要刷新页面 (`triggerSlash('/reload-page')`) 才生效
*
* @param extension_id 扩展 id, 一般是扩展文件夹名
*
* @example
* // 卸载酒馆助手
* const response = await uninstallExtension('JS-Slash-Runner');
* if (response.ok) {
* toastr.success(`成功卸载酒馆助手, 准备刷新页面以生效...`);
* _.delay(() => triggerSlash('/reload-page'), 3000);
* }
*/
declare function uninstallExtension(extension_id: string): Promise<Response>;
/**
* 重新安装扩展; 重新安装后需要刷新页面 (`triggerSlash('/reload-page')`) 才生效
*
* @param extension_id 扩展 id, 一般是扩展文件夹名
*
* @example
* // 重新安装酒馆助手
* const response = await reinstallExtension('JS-Slash-Runner');
* if (response.ok) {
* toastr.success(`成功重新安装酒馆助手, 准备刷新页面以生效...`);
* _.delay(() => triggerSlash('/reload-page'), 3000);
* }
*/
declare function reinstallExtension(extension_id: string): Promise<Response>;
/**
* 更新扩展; 更新后需要刷新页面 (`triggerSlash('/reload-page')`) 才生效
*
* @param extension_id 扩展 id, 一般是扩展文件夹名
*
* @example
* // 更新酒馆助手
* const response = await updateExtension('JS-Slash-Runner');
* if (response.ok) {
* toastr.success(`成功更新酒馆助手, 准备刷新页面以生效...`);
* _.delay(() => triggerSlash('/reload-page'), 3000);
* }
*/
declare function updateExtension(extension_id: string): Promise<Response>;

View File

@@ -0,0 +1,284 @@
/**
* 使用酒馆当前启用的预设, 让 AI 生成一段文本.
*
* 该函数在执行过程中将会发送以下事件:
* - `iframe_events.GENERATION_STARTED`: 生成开始
* - 若启用流式传输, `iframe_events.STREAM_TOKEN_RECEIVED_FULLY`: 监听它可以得到流式传输的当前完整文本 ("这是", "这是一条", "这是一条流式传输")
* - 若启用流式传输, `iframe_events.STREAM_TOKEN_RECEIVED_INCREMENTALLY`: 监听它可以得到流式传输的当前增量文本 ("这是", "一条", "流式传输")
* - `iframe_events.GENERATION_ENDED`: 生成结束, 监听它可以得到生成的最终文本 (当然也能通过函数返回值获得)
*
* @param config 提示词和生成方式设置
* - `user_input?:string`: 用户输入
* - `should_stream?:boolean`: 是否启用流式传输; 默认为 'false'
* - `should_silence?:boolean`: 是否静默生成; 默认为 'false'
* - `image?:File|string`: 图片输入
* - `overrides?:Overrides`: 覆盖选项. 若设置, 则 `overrides` 中给出的字段将会覆盖对应的提示词. 如 `overrides.char_description = '覆盖的角色描述';` 将会覆盖角色描述
* - `injects?:Omit<InjectionPrompt, 'id'>[]`: 要额外注入的提示词
* - `max_chat_history?:'all'|number`: 最多使用多少条聊天历史
* @returns 生成的最终文本
*
* @example
* // 请求生成
* const result = await generate({ user_input: '你好' });
* console.info('收到回复: ', result);
*
* @example
* // 图片输入
* const result = await generate({ user_input: '你好', image: 'https://example.com/image.jpg' });
* console.info('收到回复: ', result);
*
* @example
* // 注入、覆盖提示词
* const result = await generate({
* user_input: '你好',
* injects: [{ role: 'system', content: '思维链...', position: 'in_chat', depth: 0, should_scan: true, }]
* overrides: {
* char_personality: '温柔',
* world_info_before: '',
* chat_history: {
* prompts: [],
* }
* }
* });
* console.info('收到回复: ', result);
*
* @example
* // 使用自定义API
* const result = await generate({
* user_input: '你好',
* custom_api: {
* apiurl: 'https://your-proxy-url.com',
* key: 'your-api-key',
* model: 'gpt-4',
* source: 'openai'
* }
* });
* console.info('收到回复: ', result);
*
* @example
* // 流式生成
*
* // 需要预先监听事件来接收流式回复
* eventOn(iframe_events.STREAM_TOKEN_RECEIVED_FULLY, text => {
* console.info('收到流式回复: ', text);
* });
*
* // 然后进行生成
* const result = await generate({ user_input: '你好', should_stream: true });
* console.info('收到最终回复: ', result);
*/
declare function generate(config: GenerateConfig): Promise<string>;
/**
* 不使用酒馆当前启用的预设, 让 AI 生成一段文本.
*
* 该函数在执行过程中将会发送以下事件:
* - `iframe_events.GENERATION_STARTED`: 生成开始
* - 若启用流式传输, `iframe_events.STREAM_TOKEN_RECEIVED_FULLY`: 监听它可以得到流式传输的当前完整文本 ("这是", "这是一条", "这是一条流式传输")
* - 若启用流式传输, `iframe_events.STREAM_TOKEN_RECEIVED_INCREMENTALLY`: 监听它可以得到流式传输的当前增量文本 ("这是", "一条", "流式传输")
* - `iframe_events.GENERATION_ENDED`: 生成结束, 监听它可以得到生成的最终文本 (当然也能通过函数返回值获得)
*
* @param config 提示词和生成方式设置
* - `user_input?:string`: 用户输入
* - `should_stream?:boolean`: 是否启用流式传输; 默认为 'false'
* - `should_silence?:boolean`: 是否静默生成; 默认为 'false'
* - `image?:File|string`: 图片输入
* - `overrides?:Overrides`: 覆盖选项. 若设置, 则 `overrides` 中给出的字段将会覆盖对应的提示词. 如 `overrides.char_description = '覆盖的角色描述';` 将会覆盖角色描述
* - `injects?:Omit<InjectionPrompt, 'id'>[]`: 要额外注入的提示词
* - `max_chat_history?:'all'|number`: 最多使用多少条聊天历史
* - `ordered_prompts?:(BuiltinPrompt|RolePrompt)[]`: 一个提示词数组, 数组元素将会按顺序发给 AI, 因而相当于自定义预设
* @returns 生成的最终文本
*
* @example
* // 自定义内置提示词顺序, 未在 ordered_prompts 中给出的将不会被使用
* const result = await generateRaw({
* user_input: '你好',
* ordered_prompts: [
* 'char_description',
* { role: 'system', content: '系统提示' },
* 'chat_history',
* 'user_input',
* ]
* })
* console.info('收到回复: ', result);
*
* @example
* // 使用自定义API和自定义提示词顺序
* const result = await generateRaw({
* user_input: '你好',
* custom_api: {
* apiurl: 'https://your-proxy-url.com',
* key: 'your-api-key',
* model: 'gpt-4',
* source: 'openai'
* },
* ordered_prompts: [
* 'char_description',
* 'chat_history',
* 'user_input',
* ]
* })
* console.info('收到回复: ', result);
*/
declare function generateRaw(config: GenerateRawConfig): Promise<string>;
/**
* 获取模型列表
*
* @param custom_api 自定义API配置
* @returns Promise<string[]> 模型列表
* @throws 获取模型列表失败
*/
declare function getModelList(custom_api: { apiurl: string; key?: string }): Promise<string[]>;
/**
* 根据生成请求唯一标识符停止特定的生成请求
*
* @param generation_id 生成请求唯一标识符, 用于标识要停止的生成请求
* @returns boolean 是否成功停止生成
*/
declare function stopGenerationById(generation_id: string): boolean;
/**
* 停止所有正在进行的生成请求
*
* @returns boolean 是否成功停止所有生成
*/
declare function stopAllGeneration(): boolean;
type GenerateConfig = {
/**
* 请求生成的唯一标识符, 不设置则默认生成一个随机标识符.
*
* 当有多个 generate/generateRaw 同时请求生成时, 可以为每个请求指定唯一标识符, 从而能用 `stopGenerationById` 停止特定生成请求, 或正确监听对应的生成事件.
*/
generation_id?: string;
/** 用户输入 */
user_input?: string;
/**
* 图片输入,支持以下格式:
* - File 对象:通过 input[type="file"] 获取的文件对象
* - Base64 字符串:图片的 base64 编码
* - URL 字符串:图片的在线地址
*/
image?: File | string | (File | string)[];
/**
* 是否启用流式传输; 默认为 `false`.
*
* 若启用流式传输, 每次得到流式传输结果时, 函数将会发送事件:
* - `iframe_events.STREAM_TOKEN_RECEIVED_FULLY`: 监听它可以得到流式传输的当前完整文本 ("这是", "这是一条", "这是一条流式传输")
* - `iframe_events.STREAM_TOKEN_RECEIVED_INCREMENTALLY`: 监听它可以得到流式传输的当前增量文本 ("这是", "一条", "流式传输")
*
* @example
* eventOn(iframe_events.STREAM_TOKEN_RECEIVED_FULLY, text => console.info(text));
*/
should_stream?: boolean;
/**
* 是否静默生成; 默认为 `false`.
* - `false`: 酒馆页面的发送按钮将会变为停止按钮, 点击停止按钮会中断所有非静默生成请求
* - `true`: 不影响酒馆停止按钮状态, 点击停止按钮不会中断该生成
*
* 虽然静默生成不能通过停止按钮中断, 但可以在代码中使用以下方式停止生成:
* - 使用该生成请求的 `generation_id` 调用 `stopGenerationById`
* - 调用 `stopAllGeneration`
*/
should_silence?: boolean;
/**
* 覆盖选项. 若设置, 则 `overrides` 中给出的字段将会覆盖对应的提示词.
* 如 `overrides.char_description = '覆盖的角色描述';` 将会覆盖角色描述.
*/
overrides?: Overrides;
/** 要额外注入的提示词 */
injects?: Omit<InjectionPrompt, 'id'>[];
/** 最多使用多少条聊天历史; 默认为 'all' */
max_chat_history?: 'all' | number;
/** 自定义API配置 */
custom_api?: CustomApiConfig;
};
type GenerateRawConfig = GenerateConfig & {
/**
* 一个提示词数组, 数组元素将会按顺序发给 AI, 因而相当于自定义预设. 该数组允许存放两种类型:
* - `BuiltinPrompt`: 内置提示词. 由于不使用预设, 如果需要 "角色描述" 等提示词, 你需要自己指定要用哪些并给出顺序
* 如果不想自己指定, 可通过 `builtin_prompt_default_order` 得到酒馆默认预设所使用的顺序 (但对于这种情况, 也许你更应该用 `generate`).
* - `RolePrompt`: 要额外给定的提示词.
*/
ordered_prompts?: (BuiltinPrompt | RolePrompt)[];
};
/**
* 预设为内置提示词设置的默认顺序
*/
declare const builtin_prompt_default_order: BuiltinPrompt[];
type BuiltinPrompt =
| 'world_info_before'
| 'persona_description'
| 'char_description'
| 'char_personality'
| 'scenario'
| 'world_info_after'
| 'dialogue_examples'
| 'chat_history'
| 'user_input';
type RolePrompt = {
role: 'system' | 'assistant' | 'user';
content: string;
image?: File | string | (File | string)[];
};
type Overrides = {
world_info_before?: string;
persona_description?: string;
char_description?: string;
char_personality?: string;
scenario?: string;
world_info_after?: string;
dialogue_examples?: string;
/**
* 聊天历史
* - `with_depth_entries`: 是否启用世界书中按深度插入的条目; 默认为 `true`
* - `author_note`: 若设置, 覆盖 "作者注释" 为给定的字符串
* - `prompts`: 若设置, 覆盖 "聊天历史" 为给定的提示词
*/
chat_history?: {
with_depth_entries?: boolean;
author_note?: string;
prompts?: RolePrompt[];
};
};
/**
* 自定义API配置
*/
type CustomApiConfig = {
/** 自定义API地址 */
apiurl: string;
/** API密钥 */
key?: string;
/** 模型名称 */
model: string;
/** API源, 默认为 'openai'. 目前支持的源请查看酒馆官方代码[`SillyTavern/src/constants.js`](https://github.com/SillyTavern/SillyTavern/blob/2e3dff73a127679f643e971801cd51173c2c34e7/src/constants.js#L164) */
source?: string;
/** 最大回复 tokens 度 */
max_tokens?: 'same_as_preset' | 'unset' | number;
/** 温度 */
temperature?: 'same_as_preset' | 'unset' | number;
/** 频率惩罚 */
frequency_penalty?: 'same_as_preset' | 'unset' | number;
/** 存在惩罚 */
presence_penalty?: 'same_as_preset' | 'unset' | number;
top_p?: 'same_as_preset' | 'unset' | number;
top_k?: 'same_as_preset' | 'unset' | number;
};

View File

@@ -0,0 +1,27 @@
/**
* 将接口共享到全局, 使其可以在其他前端界面或脚本中使用.
*
* 其他前端界面或脚本将能通过 `await waitGlobalInitialized(global)` 来等待初始化完毕, 从而用 `global` 为变量名访问该接口.
*
* @param global 要共享的接口名称
* @param value 要共享的接口内容
*
* @example
* // 共享 Mvu 接口到全局
* initializeGlobal('Mvu', Mvu);
* // 此后其他前端界面或脚本中可以通过 `await waitGlobalInitialized('Mvu')` 来等待初始化完毕, 从而用 `Mvu` 为变量名访问该接口
*/
declare function initializeGlobal(global: LiteralUnion<'Mvu', string>, value: any): void;
/**
* 等待其他前端界面或脚本中共享出来的全局接口初始化完毕, 并使之在当前前端界面或脚本中可用.
*
* 这需要其他前端界面或脚本通过 `initializeGlobal(global, value)` 来共享接口.
*
* @param global 要初始化的全局接口名称
*
* @example
* await waitGlobalInitialized('Mvu');
* ...此后可以直接使用 Mvu 接口
*/
declare function waitGlobalInitialized<T>(global: LiteralUnion<'Mvu', string>): Promise<T>;

View File

@@ -0,0 +1,66 @@
/**
* 像酒馆界面里那样导入新角色/更新现有角色卡
*
* @param filename 角色卡名
* @param content 角色卡文件内容
*
* @example
* // 从网络链接导入新角色/更新现有角色卡
* const response = await fetch(角色卡网络链接);
* await importRawCharacter(角色卡名, await response.blob());
*/
declare function importRawCharacter(filename: string, content: Blob): Promise<Response>;
/**
* 像酒馆界面里那样导入聊天文件, 目前仅能导入到当前选择的角色卡
*
* @param filename 聊天文件名, 由于酒馆限制, 它实际不会作为最终导入的聊天文件名称
* @param content 聊天文件内容
*
* @throws 如果未选择角色卡, 将会抛出错误
*
* @example
* // 从网络链接导入聊天文件
* const response = await fetch(聊天文件网络链接);
* await importRawChat(聊天文件名, await response.text());
*/
declare function importRawChat(filename: string, content: string): Promise<Response>;
/**
* 像酒馆界面里那样导入新预设/更新现有预设
*
* @param filename 预设名
* @param content 预设文件内容
*
* @example
* // 从网络链接导入新预设/更新现有预设
* const response = await fetch(预设网络链接);
* await importRawChat(预设名, await response.text());
*/
declare function importRawPreset(filename: string, content: string): Promise<boolean>;
/**
* 像酒馆界面里那样导入新世界书/更新现有世界书
*
* @param filename 世界书名
* @param content 世界书文件内容
*
* @example
* // 从网络链接导入新世界书/更新现有世界书
* const response = await fetch(世界书网络链接);
* await importRawChat(世界书名, await response.text());
*/
declare function importRawWorldbook(filename: string, content: string): Promise<Response>;
/**
* 像酒馆界面里那样导入酒馆正则
*
* @param filename 酒馆正则名
* @param content 酒馆正则文件内容
*
* @example
* // 从网络链接导入酒馆正则
* const response = await fetch(酒馆正则网络链接);
* await importRawChat(酒馆正则名, await response.text());
*/
declare function importRawTavernRegex(filename: string, content: string): boolean;

View File

@@ -0,0 +1,169 @@
interface Window {
/**
* 酒馆助手提供的额外功能, 具体内容见于 https://n0vi028.github.io/JS-Slash-Runner-Doc
* 你也可以在酒馆页面按 f12, 在控制台中输入 `window.TavernHelper` 来查看当前酒馆助手所提供的接口
*/
TavernHelper: {
// audio
readonly playAudio: typeof playAudio;
readonly pauseAudio: typeof pauseAudio;
readonly getAudioList: typeof getAudioList;
readonly replaceAudioList: typeof replaceAudioList;
readonly insertAudioList: typeof insertAudioList;
readonly getAudioSettings: typeof getAudioSettings;
readonly setAudioSettings: typeof setAudioSettings;
// builtin
readonly builtin: typeof builtin;
// character
readonly getCharacterNames: typeof getCharacterNames;
readonly createCharacter: typeof createCharacter;
readonly createOrReplaceCharacter: typeof createOrReplaceCharacter;
readonly deleteCharacter: typeof deleteCharacter;
readonly getCharacter: typeof getCharacter;
readonly replaceCharacter: typeof replaceCharacter;
readonly updateCharacterWith: typeof updateCharacterWith;
// chat_message
readonly getChatMessages: typeof getChatMessages;
readonly setChatMessages: typeof setChatMessages;
readonly createChatMessages: typeof createChatMessages;
readonly deleteChatMessages: typeof deleteChatMessages;
readonly rotateChatMessages: typeof rotateChatMessages;
// displayed_message
readonly formatAsDisplayedMessage: typeof formatAsDisplayedMessage;
readonly retrieveDisplayedMessage: typeof retrieveDisplayedMessage;
readonly refreshOneMessage: typeof refreshOneMessage;
// extension
readonly isAdmin: typeof isAdmin;
readonly getExtensionType: typeof getExtensionType;
readonly getExtensionStatus: typeof getExtensionInstallationInfo;
readonly isInstalledExtension: typeof isInstalledExtension;
readonly installExtension: typeof installExtension;
readonly uninstallExtension: typeof uninstallExtension;
readonly reinstallExtension: typeof reinstallExtension;
readonly updateExtension: typeof updateExtension;
// generate
readonly builtin_prompt_default_order: typeof builtin_prompt_default_order;
readonly generate: typeof generate;
readonly generateRaw: typeof generateRaw;
readonly getModelList: typeof getModelList;
readonly stopGenerationById: typeof stopGenerationById;
readonly stopAllGeneration: typeof stopAllGeneration;
// global
readonly initializeGlobal: typeof initializeGlobal;
readonly waitGlobalInitialized: typeof waitGlobalInitialized;
// import_raw
readonly importRawCharacter: typeof importRawCharacter;
readonly importRawChat: typeof importRawChat;
readonly importRawPreset: typeof importRawPreset;
readonly importRawWorldbook: typeof importRawWorldbook;
readonly importRawTavernRegex: typeof importRawTavernRegex;
// inject
readonly injectPrompts: typeof injectPrompts;
readonly uninjectPrompts: typeof uninjectPrompts;
// lorebook_entry
readonly getLorebookEntries: typeof getLorebookEntries;
readonly replaceLorebookEntries: typeof replaceLorebookEntries;
readonly updatelorebookEntriesWith: typeof updateLorebookEntriesWith;
readonly setLorebookEntries: typeof setLorebookEntries;
readonly createLorebookEntries: typeof createLorebookEntries;
readonly deleteLorebookEntries: typeof deleteLorebookEntries;
// lorebook
readonly getLorebookSettings: typeof getLorebookSettings;
readonly setLorebookSettings: typeof setLorebookSettings;
readonly getLorebooks: typeof getLorebooks;
readonly deleteLorebook: typeof deleteLorebook;
readonly createLorebook: typeof createLorebook;
readonly getCharLorebooks: typeof getCharLorebooks;
readonly setCurrentCharLorebooks: typeof setCurrentCharLorebooks;
readonly getCurrentCharPrimaryLorebook: typeof getCurrentCharPrimaryLorebook;
readonly getOrCreateChatLorebook: typeof getOrCreateChatLorebook;
// macrolike
readonly registerMacroLike: typeof registerMacroLike;
// preset
readonly isPresetNormalPrompt: typeof isPresetNormalPrompt;
readonly isPresetSystemPrompt: typeof isPresetSystemPrompt;
readonly isPresetPlaceholderPrompt: typeof isPresetPlaceholderPrompt;
readonly default_preset: typeof default_preset;
readonly getPresetNames: typeof getPresetNames;
readonly getLoadedPresetName: typeof getLoadedPresetName;
readonly loadPreset: typeof loadPreset;
readonly createPreset: typeof createPreset;
readonly createOrReplacePreset: typeof createOrReplacePreset;
readonly deletePreset: typeof deletePreset;
readonly renamePreset: typeof renamePreset;
readonly getPreset: typeof getPreset;
readonly replacePreset: typeof replacePreset;
readonly updatePresetWith: typeof updatePresetWith;
readonly setPreset: typeof setPreset;
// raw_character
readonly RawCharacter: typeof RawCharacter;
readonly getCharData: typeof getCharData;
readonly getCharAvatarPath: typeof getCharAvatarPath;
readonly getChatHistoryBrief: typeof getChatHistoryBrief;
readonly getChatHistoryDetail: typeof getChatHistoryDetail;
// script
readonly getAllEnabledScriptButtons: typeof getAllEnabledScriptButtons;
// slash
readonly triggerSlash: typeof triggerSlash;
// tavern_regex
readonly formatAsTavernRegexedString: typeof formatAsTavernRegexedString;
readonly isCharacterTavernRegexesEnabled: typeof isCharacterTavernRegexesEnabled;
readonly getTavernRegexes: typeof getTavernRegexes;
readonly replaceTavernRegexes: typeof replaceTavernRegexes;
readonly updateTavernRegexesWith: typeof updateTavernRegexesWith;
// util
readonly substitudeMacros: typeof substitudeMacros;
readonly getLastMessageId: typeof getLastMessageId;
readonly errorCatched: typeof errorCatched;
readonly getMessageId: typeof getMessageId;
// variables
readonly getVariables: typeof getVariables;
readonly replaceVariables: typeof replaceVariables;
readonly updateVariablesWith: typeof updateVariablesWith;
readonly insertOrAssignVariables: typeof insertOrAssignVariables;
readonly insertVariables: typeof insertVariables;
readonly deleteVariable: typeof deleteVariable;
// version
readonly getTavernHelperVersion: typeof getTavernHelperVersion;
readonly getTavernHelperExtensionId: typeof getTavernHelperExtensionId;
readonly getTavernVersion: typeof getTavernVersion;
// worldbook
readonly getWorldbookNames: typeof getWorldbookNames;
readonly getGlobalWorldbookNames: typeof getGlobalWorldbookNames;
readonly rebindGlobalWorldbooks: typeof rebindGlobalWorldbooks;
readonly getCharWorldbookNames: typeof getCharWorldbookNames;
readonly rebindCharWorldbooks: typeof rebindCharWorldbooks;
readonly getChatWorldbookName: typeof getChatWorldbookName;
readonly rebindChatWorldbook: typeof rebindChatWorldbook;
readonly getOrCreateChatWorldbook: typeof getOrCreateChatWorldbook;
readonly createWorldbook: typeof createWorldbook;
readonly createOrReplaceWorldbook: typeof createOrReplaceWorldbook;
readonly deleteWorldbook: typeof deleteWorldbook;
readonly getWorldbook: typeof getWorldbook;
readonly replaceWorldbook: typeof replaceWorldbook;
readonly updateWorldbookWith: typeof updateWorldbookWith;
readonly createWorldbookEntries: typeof createWorldbookEntries;
readonly deleteWorldbookEntries: typeof deleteWorldbookEntries;
};
}

View File

@@ -0,0 +1,46 @@
type InjectionPrompt = {
id: string;
/**
* 要注入的位置
* - 'in_chat': 插入到聊天中
* - 'none': 不会发给 AI, 但能用来激活世界书条目.
*/
position: 'in_chat' | 'none';
depth: number;
role: 'system' | 'assistant' | 'user';
content: string;
/** 提示词在什么情况下启用; 默认为始终 */
filter?: (() => boolean) | (() => Promise<boolean>);
/** 是否作为欲扫描文本, 加入世界书绿灯条目扫描文本中; 默认为任意 */
should_scan?: boolean;
};
type injectPromptsOptions = {
/** 是否只在下一次请求生成中有效; 默认为 false */
once?: boolean;
};
/**
* 注入提示词
*
* 这样注入的提示词仅在当前聊天文件中有效,
* - 如果需要跨聊天文件注入或在新开聊天时重新注入, 你可以监听 `tavern_events.CHAT_CHANGED` 事件.
* - 或者, 可以监听 `tavern_events.GENERATION_AFTER_COMMANDS` 事件, 在生成前注入.
*
* @param prompts 要注入的提示词
* @param options 可选选项
* - `once:boolean`: 是否只在下一次请求生成中有效; 默认为 false
*
* @returns 后续操作
* - `uninject`: 取消这个提示词的注入
*/
declare function injectPrompts(prompts: InjectionPrompt[], options?: injectPromptsOptions): { uninject: () => void };
/**
* 移除注入的提示词
*
* @param ids 要移除的提示词的 id 列表
*/
declare function uninjectPrompts(ids: string[]): void;

View File

@@ -0,0 +1,61 @@
/** @deprecated 请使用内置库 "世界书强制用推荐的全局设置" */
type LorebookSettings = {
selected_global_lorebooks: string[];
scan_depth: number;
context_percentage: number;
budget_cap: number;
min_activations: number;
max_depth: number;
max_recursion_steps: number;
insertion_strategy: 'evenly' | 'character_first' | 'global_first';
include_names: boolean;
recursive: boolean;
case_sensitive: boolean;
match_whole_words: boolean;
use_group_scoring: boolean;
overflow_alert: boolean;
}
/** @deprecated 请使用内置库 "世界书强制用推荐的全局设置" */
declare function getLorebookSettings(): LorebookSettings;
/** @deprecated 请使用内置库 "世界书强制用推荐的全局设置" */
declare function setLorebookSettings(settings: Partial<LorebookSettings>): void;
/** @deprecated 请使用 `getWorldbookNames` */
declare function getLorebooks(): string[];
/** @deprecated 请使用 `deleteWorldbook` */
declare function deleteLorebook(lorebook: string): Promise<boolean>;
/** @deprecated 请使用 `createWorldbook` */
declare function createLorebook(lorebook: string): Promise<boolean>;
/** @deprecated 请使用 `getCharWorldbookNames` */
type CharLorebooks = {
primary: string | null;
additional: string[];
}
/** @deprecated 请使用 `getCharWorldbookNames` */
type GetCharLorebooksOption = {
name?: string;
type?: 'all' | 'primary' | 'additional';
}
/** @deprecated 请使用 `getCharWorldbookNames` */
declare function getCharLorebooks({ name, type }?: GetCharLorebooksOption): CharLorebooks;
/** @deprecated 请使用 `getCharWorldbookNames` */
declare function getCurrentCharPrimaryLorebook(): string | null;
/** @deprecated 请使用 `rebindCharWorldbook` */
declare function setCurrentCharLorebooks(lorebooks: Partial<CharLorebooks>): Promise<void>;
/** @deprecated 请使用 `getChatWorldbook` */
declare function getChatLorebook(): string | null;
/** @deprecated 请使用 `rebindChatWorldbook` */
declare function setChatLorebook(lorebook: string | null): Promise<void>;
/** @deprecated 请使用 `getOrCreateChatWorldbook` */
declare function getOrCreateChatLorebook(lorebook?: string): Promise<string>;

View File

@@ -0,0 +1,76 @@
/** @deprecated 请使用 `WolrdbookEntry` */
type LorebookEntry = {
uid: number;
display_index: number;
comment: string;
enabled: boolean;
type: 'constant' | 'selective' | 'vectorized';
position:
| 'before_character_definition'
| 'after_character_definition'
| 'before_example_messages'
| 'after_example_messages'
| 'before_author_note'
| 'after_author_note'
| 'at_depth_as_system'
| 'at_depth_as_assistant'
| 'at_depth_as_user';
depth: number | null;
order: number;
probability: number;
keys: string[];
logic: 'and_any' | 'and_all' | 'not_all' | 'not_any';
filters: string[];
scan_depth: 'same_as_global' | number;
case_sensitive: 'same_as_global' | boolean;
match_whole_words: 'same_as_global' | boolean;
use_group_scoring: 'same_as_global' | boolean;
automation_id: string | null;
exclude_recursion: boolean;
prevent_recursion: boolean;
delay_until_recursion: boolean | number;
content: string;
group: string;
group_prioritized: boolean;
group_weight: number;
sticky: number | null;
cooldown: number | null;
delay: number | null;
};
/** @deprecated 请使用 `getWorldbook` */
type GetLorebookEntriesOption = {
filter?: 'none' | Partial<LorebookEntry>;
};
/** @deprecated 请使用 `getWorldbook` */
declare function getLorebookEntries(lorebook: string): Promise<LorebookEntry[]>;
/** @deprecated 请使用 `replaceWorldbook` */
declare function replaceLorebookEntries(lorebook: string, entries: Partial<LorebookEntry>[]): Promise<void>;
/** @deprecated 请使用 `updateWorldbookWith` */
type LorebookEntriesUpdater =
| ((entries: LorebookEntry[]) => Partial<LorebookEntry>[])
| ((entries: LorebookEntry[]) => Promise<Partial<LorebookEntry>[]>);
/** @deprecated 请使用 `updateWorldbookWith` */
declare function updateLorebookEntriesWith(lorebook: string, updater: LorebookEntriesUpdater): Promise<LorebookEntry[]>;
/** @deprecated 请使用 `replaceWorldbook` */
declare function setLorebookEntries(
lorebook: string,
entries: Array<Pick<LorebookEntry, 'uid'> & Partial<LorebookEntry>>,
): Promise<LorebookEntry[]>;
/** @deprecated 请使用 `createWorldbookEntries` */
declare function createLorebookEntries(
lorebook: string,
entries: Partial<LorebookEntry>[],
): Promise<{ entries: LorebookEntry[]; new_uids: number[] }>;
/** @deprecated 请使用 `deleteWorldbookEntries` */
declare function deleteLorebookEntries(
lorebook: string,
uids: number[],
): Promise<{ entries: LorebookEntry[]; delete_occurred: boolean }>;

View File

@@ -0,0 +1,37 @@
type MacroLikeContext = {
message_id?: number;
role?: 'user' | 'assistant' | 'system';
};
type RegisterMacroLikeReturn = {
/** 取消注册 */
unregister: () => void;
};
/**
* 注册一个新的助手宏
*
* @param regex 匹配的正则表达式
* @param replace 针对匹配到的文本所要进行的替换
*
* @example
* // 注册一个统计行数的宏
* registerMacros(
* /<count_lines>(.*?)<count_lines>/gi,
* context => content.split('\n').length
* );
*
* @returns 后续操作
* - `unregister`: 取消注册
*/
declare function registerMacroLike(
regex: RegExp,
replace: (context: MacroLikeContext, substring: string, ...args: any[]) => string,
): RegisterMacroLikeReturn;
/**
* 取消注册一个助手宏
*
* @param regex 助手宏的正则表达式
*/
declare function unregisterMacroLike(regex: RegExp): void;

View File

@@ -0,0 +1,365 @@
type Preset = {
settings: {
/** 最大上下文 token 数 */
max_context: number;
/** 最大回复 token 数 */
max_completion_tokens: number;
/** 每次生成几个回复 */
reply_count: number;
/** 是否流式传输 */
should_stream: boolean;
/** 温度 */
temperature: number;
/** 频率惩罚 */
frequency_penalty: number;
/** 存在惩罚 */
presence_penalty: number;
top_p: number;
/** 重复惩罚 */
repetition_penalty: number;
min_p: number;
top_k: number;
top_a: number;
/** 种子, -1 表示随机 */
seed: number;
/** 压缩系统消息: 将连续的系统消息合并为一条消息 */
squash_system_messages: boolean;
/** 推理强度, 即内置思维链的投入程度. 例如, 如果酒馆直连 gemini-2.5-flash, 则 `min` 将会不使用内置思维链 */
reasoning_effort: 'auto' | 'min' | 'low' | 'medium' | 'high' | 'max';
/** 请求思维链: 允许模型返回内置思维链的思考过程; 注意这只影响内置思维链显不显示, 不决定模型是否使用内置思维链 */
request_thoughts: boolean;
/** 请求图片: 允许模型在回复中返回图片 */
request_images: boolean;
/** 启用函数调用: 允许模型使用函数调用功能; 比如 cursor 借此在回复中读写文件、运行命令 */
enable_function_calling: boolean;
/** 启用网络搜索: 允许模型使用网络搜索功能 */
enable_web_search: boolean;
/** 是否允许发送图片作为提示词 */
allow_sending_images: 'disabled' | 'auto' | 'low' | 'high';
/** 是否允许发送视频作为提示词 */
allow_sending_videos: boolean;
/**
* 角色名称前缀: 是否要为消息添加角色名称前缀, 以及怎么添加
* - `none`: 不添加
* - `default`: 为与角色卡不同名的消息添加角色名称前缀, 添加到 `content` 字段开头 (即发送的消息内容是 `角色名: 消息内容`)
* - `content`: 为所有消息添加角色名称前缀, 添加到 `content` 字段开头 (即发送的消息内容是 `角色名: 消息内容`)
* - `completion`: 在发送给模型时, 将角色名称写入到 `name` 字段; 仅支持字母数字和下划线, 不适用于 Claude、Google 等模型
*/
character_name_prefix: 'none' | 'default' | 'content' | 'completion';
/** 用引号包裹用户消息: 在发送给模型之前, 将所有用户消息用引号包裹 */
wrap_user_messages_in_quotes: boolean;
};
/** 提示词列表里已经添加的提示词 */
prompts: PresetPrompt[];
/** 下拉框里的, 没有添加进提示词列表的提示词 */
prompts_unused: PresetPrompt[];
/** 额外字段, 用于为预设绑定额外数据 */
extensions: {
regex_scripts?: TavernRegex[];
tavern_helper?: {
scripts: Record<string, any>[];
variales: Record<string, any>;
};
[other: string]: any;
};
};
type PresetPrompt = {
/**
* 根据 id, 预设提示词分为以下三类:
* - 普通提示词 (`isPresetNormalPrompt`): 预设界面上可以手动添加的提示词
* - 系统提示词 (`isPresetSystemPrompt`): 酒馆所设置的系统提示词, 但其实相比于手动添加的提示词没有任何优势, 分为 `main`、`nsfw`、`jailbreak`、`enhance_definitions`
* - 占位符提示词 (`isPresetPlaceholderPrompt`): 用于表示世界书条目、角色卡、玩家角色、聊天记录等提示词的插入位置, 分为 `world_info_before`、`persona_description`、`char_description`、`char_personality`、`scenario`、`world_info_after`、`dialogue_examples`、`chat_history`
*/
id: LiteralUnion<
| 'main'
| 'nsfw'
| 'jailbreak'
| 'enhanceDefinitions'
| 'worldInfoBefore'
| 'personaDescription'
| 'charDescription'
| 'charPersonality'
| 'scenario'
| 'worldInfoAfter'
| 'dialogueExamples'
| 'chatHistory',
string
>;
name: string;
enabled: boolean;
/**
* 插入位置, 仅用于普通和占位符提示词
* - `'relative'`: 按提示词相对位置插入
* - `'in_chat'`: 插入到聊天记录的对应深度, 需要设置对应的深度 `depth` 和顺序 `order`
*/
position:
| {
type: 'relative';
depth?: never;
order?: never;
}
| { type: 'in_chat'; depth: number; order: number };
role: 'system' | 'user' | 'assistant';
/** 仅用于普通和系统提示词 */
content?: string;
/** 额外字段, 用于为预设提示词绑定额外数据 */
extra?: Record<string, any>;
};
type PresetNormalPrompt = SetRequired<{ id: string } & Omit<PresetPrompt, 'id'>, 'position' | 'content'>;
type PresetSystemPrompt = SetRequired<
{ id: 'main' | 'nsfw' | 'jailbreak' | 'enhanceDefinitions' } & Omit<PresetPrompt, 'id'>,
'content'
>;
type PresetPlaceholderPrompt = SetRequired<
{
id:
| 'worldInfoBefore'
| 'personaDescription'
| 'charDescription'
| 'charPersonality'
| 'scenario'
| 'worldInfoAfter'
| 'dialogueExamples'
| 'chatHistory';
} & Omit<PresetPrompt, 'id'>,
'position'
>;
declare function isPresetNormalPrompt(prompt: PresetPrompt): prompt is PresetNormalPrompt;
declare function isPresetSystemPrompt(prompt: PresetPrompt): prompt is PresetSystemPrompt;
declare function isPresetPlaceholderPrompt(prompt: PresetPrompt): prompt is PresetPlaceholderPrompt;
declare const default_preset: Preset;
/**
* 获取预设名称列表
*
* @returns 预设名称列表
*/
declare function getPresetNames(): string[];
/**
* 获取酒馆正在使用的预设 (`'in_use'`) 是从哪个预设加载来的.
*
* 请务必注意这个说法, `'in_use'` 预设虽然是从 `getLoadedPresetName()` 预设加载而来, 但它的预设内容可能与 `getLoadedPresetName()` 预设不同.
* 请回忆一下: 在酒馆中编辑预设后, 编辑结果会立即在聊天中生效 (`'in_use'` 预设被更改),
* 但我们没有点击保存按钮 (将 `'in_use'` 预设内容保存回 `getLoadedPresetName()` 预设), 一旦切换预设, 编辑结果就会丢失.
*
* @returns 预设名称
*/
declare function getLoadedPresetName(): string;
/**
* 加载 `preset_name` 预设作为酒馆正在使用的预设 (`'in_use'`)
*
* @param preset_name 预设名称
* @returns 是否成功切换, 可能因预设不存在等原因而失败
*/
declare function loadPreset(preset_name: Exclude<string, 'in_use'>): boolean;
/**
* 新建 `preset_name` 预设, 内容为 `preset`
*
* @param preset_name 预设名称
* @param preset 预设内容; 不填则使用默认内容
*
* @returns 是否成功创建, 如果已经存在同名预设或尝试创建名为 `'in_use'` 的预设会失败
*
* @throws 如果创建的预设内容中存在重复的系统/占位提示词, 将会抛出异常
*/
declare function createPreset(preset_name: Exclude<string, 'in_use'>, preset?: Preset): Promise<boolean>;
/**
* 创建或替换名为 `preset_name` 的预设, 内容为 `preset`
*
* @param preset_name 预设名称
* @param preset 预设内容; 不填则使用默认内容
* @param options 可选选项
* - `render:'debounced'|'immediate'`: 如果对 `'in_use'` 预设进行操作, 应该防抖重新渲染 (debounced) 还是立即重新渲染 (immediate) 预设界面? 默认为性能更好的防抖渲染
*
* @returns 如果发生创建, 则返回 `true`; 如果发生替换, 则返回 `false`
*/
declare function createOrReplacePreset(
preset_name: LiteralUnion<'in_use', string>,
preset?: Preset,
{ render }?: ReplacePresetOptions,
): Promise<boolean>;
/**
* 删除 `preset_name` 预设
*
* @param preset_name 预设名称
*
* @returns 是否成功删除, 可能因预设不存在等原因而失败
*/
declare function deletePreset(preset_name: Exclude<string, 'in_use'>): Promise<boolean>;
/**
* 重命名 `preset_name` 预设为 `new_name`
*
* @param preset_name 预设名称
* @param new_name 新名称
*
* @returns 是否成功重命名, 可能因预设不存在等原因而失败
*/
declare function renamePreset(preset_name: Exclude<string, 'in_use'>, new_name: string): Promise<boolean>;
/**
* 获取 `preset_name` 预设的内容
*
* @param preset_name 预设名称
*
* @returns 预设内容
*
* @throws 如果预设不存在, 将会抛出异常
*/
declare function getPreset(preset_name: LiteralUnion<'in_use', string>): Preset;
type ReplacePresetOptions = {
/** 如果对 `'in_use'` 预设进行操作, 应该防抖渲染 (debounced)、立即渲染 (immediate) 还是不刷新前端显示 (none)? 默认为性能更好的防抖渲染 */
render?: 'debounced' | 'immediate' | 'none';
};
/**
* 完全替换 `preset_name` 预设的内容为 `preset`
*
* @param preset_name 预设名称
* @param preset 预设内容
* @param options 可选选项
* - `render:'debounced'|'immediate'`: 如果对 `'in_use'` 预设进行操作, 应该防抖渲染 (debounced) 还是立即渲染 (immediate)? 默认为性能更好的防抖渲染
*
* @throws 如果预设不存在, 将会抛出异常
* @throws 如果替换的预设内容中存在重复的系统/占位提示词, 将会抛出异常
*
* @example
* // 为酒馆正在使用的预设开启流式传输
* const preset = getPreset('in_use');
* preset.settings.should_stream = true;
* await replacePreset('in_use', preset);
*
* @example
* // 关闭酒馆正在使用的预设中名字包含 "COT" 的条目
* const preset = getPreset('in_use');
* preset.prompts.filter(prompt => prompt.name.includes('COT')).forEach(prompt => prompt.enabled = false);
* await replacePreset('in_use', preset);
*
* @example
* // 为酒馆正在使用的预设添加一个提示词条目
* const preset = getPreset('in_use');
* preset.prompts.push({
* id: 'new_prompt',
* name: '新提示词',
* enabled: true,
* position: { type: 'relative' },
* role: 'user',
* content: '新提示词内容',
* });
* await replacePreset('in_use', preset);
*
* @example
* // 将 '预设A' 的条目按顺序复制到 '预设B' 开头
* const preset_a = getPreset('预设A');
* const preset_b = getPreset('预设B');
* preset_b.prompts = [...preset_a.prompts, ...preset_b.prompts];
* await replacePreset('预设B', preset_b);
*/
declare function replacePreset(
preset_name: LiteralUnion<'in_use', string>,
preset: Preset,
{ render }?: ReplacePresetOptions,
): Promise<void>;
type PresetUpdater = ((preset: Preset) => Preset) | ((preset: Preset) => Promise<Preset>);
/**
* 用 `updater` 函数更新 `preset_name` 预设
*
* @param preset_name 预设名称
* @param updater 用于更新预设的函数. 它应该接收预设内容作为参数, 并返回更新后的预设内容.
* @param options 可选选项
* - `render:'debounced'|'immediate'`: 如果对 `'in_use'` 预设进行操作, 应该防抖渲染 (debounced) 还是立即渲染 (immediate)? 默认为性能更好的防抖渲染
*
* @returns 更新后的预设内容
*
* @throws 如果预设不存在, 将会抛出异常
* @throws 如果替换的预设内容中存在重复的系统/占位提示词, 将会抛出异常
*
* @example
* // 为酒馆正在使用的预设开启流式传输
* await updatePresetWith('in_use', preset => {
* preset.settings.should_stream = true;
* return preset;
* });
*
* @example
* // 关闭酒馆正在使用的预设中名字包含 "COT" 的条目
* await updatePresetWith('in_use', preset => {
* preset.prompts.filter(prompt => prompt.name.includes('COT')).forEach(prompt => prompt.enabled = false);
* return preset;
* });
*
* @example
* // 为酒馆正在使用的预设添加一个提示词条目
* await updatePresetWith('in_use', preset => {
* preset.prompts.push({
* id: 'new_prompt',
* name: '新提示词',
* enabled: true,
* position: { type: 'relative' },
* role: 'user',
* content: '新提示词内容',
* });
* return preset;
* });
*
* @example
* // 将 '预设A' 的条目按顺序复制到 '预设B' 开头
* await updatePresetWith('预设B', preset => {
* const another_preset = getPreset('预设A');
* preset.prompts = [...another_preset.prompts, ...preset.prompts];
* return preset;
* });
*/
declare function updatePresetWith(
preset_name: LiteralUnion<'in_use', string>,
updater: PresetUpdater,
{ render }?: ReplacePresetOptions,
): Promise<Preset>;
/**
* 将预设内容修改回预设中, 如果某个内容不存在, 则该内容将会采用原来的值
*
* @param preset_name 预设名称
* @param preset 预设内容
* @param options 可选选项
* - `render:'debounced'|'immediate'`: 如果对 `'in_use'` 预设进行操作, 应该防抖渲染 (debounced) 还是立即渲染 (immediate)? 默认为性能更好的防抖渲染
*
* @returns 更新后的预设内容
*
* @throws 如果预设不存在, 将会抛出异常
* @throws 如果替换的预设内容中存在重复的系统/占位提示词, 将会抛出异常
*
* @example
* // 为酒馆正在使用的预设开启流式传输
* await setPreset('in_use', { settings: { should_stream: true } });
*
* @example
* // 将 '预设A' 的条目按顺序复制到 '预设B' 开头
* await setPreset('预设B', {
* prompts: [...getPreset('预设A').prompts, ...getPreset('预设B').prompts],
* });
*/
declare function setPreset(
preset_name: LiteralUnion<'in_use', string>,
preset: PartialDeep<Preset>,
{ render }?: ReplacePresetOptions,
): Promise<Preset>;

View File

@@ -0,0 +1,132 @@
/**
* 角色卡管理类
* 用于封装角色卡数据操作和提供便捷的访问方法
*/
declare class RawCharacter {
constructor(characterData: SillyTavern.v1CharData);
/**
* 根据名称或头像id查找角色卡数据
* @param options 查找选项
* @returns 找到的角色卡数据找不到为null
*/
static find({
name,
allowAvatar,
}?: {
name: LiteralUnion<'current', string>;
allowAvatar?: boolean;
}): SillyTavern.v1CharData;
/**
* 根据名称查找角色卡数据在characters数组中的索引类似this_chid
* @param name 角色名称
* @returns 角色卡数据在characters数组中的索引未找到返回-1
*/
static findCharacterIndex(name: string): any;
/**
* 从服务器获取每个聊天文件的聊天内容,并将其编译成字典。
* 该函数遍历提供的聊天元数据列表,并请求每个聊天的实际聊天内容,
*
* @param {Array} data - 包含每个聊天的元数据的数组,例如文件名。
* @param {boolean} isGroupChat - 一个标志,指示聊天是否为群组聊天。
* @returns {Promise<Object>} chat_dict - 一个字典,其中每个键是文件名,值是
* 从服务器获取的相应聊天内容。
*/
static getChatsFromFiles(data: any[], isGroupChat: boolean): Promise<Record<string, any>>;
/**
* 获取角色管理内的数据
* @returns 完整的角色管理内的数据对象
*/
getCardData(): SillyTavern.v1CharData;
/**
* 获取角色头像ID
* @returns 头像ID/文件名
*/
getAvatarId(): string;
/**
* 获取正则脚本
* @returns 正则脚本数组
*/
getRegexScripts(): Array<{
id: string;
scriptName: string;
findRegex: string;
replaceString: string;
trimStrings: string[];
placement: number[];
disabled: boolean;
markdownOnly: boolean;
promptOnly: boolean;
runOnEdit: boolean;
substituteRegex: number | boolean;
minDepth: number;
maxDepth: number;
}>;
/**
* 获取角色书
* @returns 角色书数据对象或null
*/
getCharacterBook(): {
name: string;
entries: Array<{
keys: string[];
secondary_keys?: string[];
comment: string;
content: string;
constant: boolean;
selective: boolean;
insertion_order: number;
enabled: boolean;
position: string;
extensions: any;
id: number;
}>;
} | null;
/**
* 获取角色世界名称
* @returns 世界名称
*/
getWorldName(): string;
}
/**
* 获取角色卡数据
* @param name 角色名称或头像ID
* @param allowAvatar 是否允许通过头像ID查找
* @returns 角色卡数据
*/
declare function getCharData(name: LiteralUnion<'current', string>): SillyTavern.v1CharData | null;
/**
* 获取角色头像路径
* @param name 角色名称或头像ID
* @param allowAvatar 是否允许通过头像ID查找
* @returns 角色头像路径
*/
declare function getCharAvatarPath(name: LiteralUnion<'current', string>): string | null;
/**
* 获取角色聊天历史摘要
* @param name 角色名称或头像ID
* @param allowAvatar 是否允许通过头像ID查找
* @returns 聊天历史摘要数组
*/
declare function getChatHistoryBrief(
name: LiteralUnion<'current', string>,
allowAvatar?: boolean,
): Promise<any[] | null>;
/**
* 获取聊天历史详情
* @param data 聊天数据数组
* @param isGroupChat 是否为群组聊天
* @returns 聊天历史详情
*/
declare function getChatHistoryDetail(data: any[], isGroupChat?: boolean): Promise<Record<string, any> | null>;

View File

@@ -0,0 +1,4 @@
/**
* 获取所有处于启用状态的酒馆助手脚本按钮, 主要是方便 QR 助手等兼容脚本按钮
*/
declare function getAllEnabledScriptButtons(): { [script_id: string]: { button_id: string; button_name: string }[] };

View File

@@ -0,0 +1,29 @@
/**
* 运行 Slash 命令, 注意如果命令写错了将不会有任何反馈
*
* 能使用的命令请参考[编写模板](https://stagedog.github.io/青空莉/工具经验/实时编写前端界面或脚本/)的 `slash_command.txt` 或[命令手册](https://rentry.org/sillytavern-script-book).
*
* @param command 要运行的 Slash 命令
* @returns Slash 管道结果, 如果命令出错或执行了 `/abort` 则返回 `undefined`
*
* @throws Slash 命令出错时, 将会抛出错误
*
* @example
* // 在酒馆界面弹出提示语 `运行成功!`
* triggerSlash('/echo severity=success 运行成功!');
* // 但更建议你直接用 toastr 弹出提示
* toastr.success('运行成功!');
*
* @example
* // 获取当前聊天消息最后一条消息对应的 id
* const last_message_id = await triggerSlash('/pass {{lastMessageId}}');
* // 但更建议你用酒馆助手函数
* const last_message = getLastMessageId();
*
* @example
* // 创建一条用户输入到消息楼层末尾
* await createChatMessages([{ role: 'user', content: '你好' }]);
* // 触发 AI 回复
* await triggerSlash('/trigger');
*/
declare function triggerSlash(command: string): Promise<string>;

View File

@@ -0,0 +1,121 @@
type FormatAsTavernRegexedStringOption = {
/** 文本所在的深度; 不填则不考虑酒馆正则的`深度`选项: 无论该深度是否在酒馆正则的`最小深度`和`最大深度`范围内都生效 */
depth?: number;
/** 角色卡名称; 不填则使用当前角色卡名称 */
character_name?: string;
}
/**
* 对 `text` 应用酒馆正则
*
* @param text 要应用酒馆正则的文本
* @param source 文本来源, 例如来自用户输入或 AI 输出. 对应于酒馆正则的`作用范围`选项.
* @param destination 文本将作为什么而使用, 例如用于显示或作为提示词. 对应于酒馆正则的`仅格式显示`和`仅格式提示词`选项.
* @param option 可选选项
* - `depth?:number`: 文本所在的深度; 不填则不考虑酒馆正则的`深度`选项: 无论该深度是否在酒馆正则的`最小深度`和`最大深度`范围内都生效
* - `character_name?:string`: 角色卡名称; 不填则使用当前角色卡名称
*
* @example
* // 获取最后一楼文本, 将它视为将会作为显示的 AI 输出, 对它应用酒馆正则
* const message = getChatMessages(-1)[0];
* const result = formatAsTavernRegexedString(message.message, 'ai_output', 'display', { depth: 0 });
*/
declare function formatAsTavernRegexedString(
text: string,
source: 'user_input' | 'ai_output' | 'slash_command' | 'world_info' | 'reasoning',
destination: 'display' | 'prompt',
{ depth, character_name }?: FormatAsTavernRegexedStringOption,
);
type TavernRegex = {
id: string;
script_name: string;
enabled: boolean;
scope: 'global' | 'character';
find_regex: string;
replace_string: string;
source: {
user_input: boolean;
ai_output: boolean;
slash_command: boolean;
world_info: boolean;
};
destination: {
display: boolean;
prompt: boolean;
};
run_on_edit: boolean;
min_depth: number | null;
max_depth: number | null;
}
/**
* 判断局部正则是否启用
*/
declare function isCharacterTavernRegexesEnabled(): boolean;
type GetTavernRegexesOption = {
scope?: 'all' | 'global' | 'character';
enable_state?: 'all' | 'enabled' | 'disabled';
}
/**
* 获取酒馆正则
*
* @param option 可选选项
* - `scope?:'all'|'global'|'character'`: // 按所在区域筛选酒馆正则; 默认为 `'all'`
* - `enable_state?:'all'|'enabled'|'disabled'`: // 按是否被开启筛选酒馆正则; 默认为 `'all'`
*
* @returns 一个数组, 数组的元素是酒馆正则 `TavernRegex`. 该数组依据正则作用于文本的顺序排序, 也就是酒馆显示正则的地方从上到下排列.
*/
declare function getTavernRegexes({ scope, enable_state }?: GetTavernRegexesOption): TavernRegex[];
type ReplaceTavernRegexesOption = {
scope?: 'all' | 'global' | 'character';
}
/**
* 完全替换酒馆正则为 `regexes`.
* - **这是一个很慢的操作!** 尽量对正则做完所有事后再一次性 replaceTavernRegexes.
* - **为了重新应用正则, 它会重新载入整个聊天消息**, 将会触发 `tavern_events.CHAT_CHANGED` 进而重新加载楼层消息.
*
* 之所以提供这么直接的函数, 是因为你可能需要调换正则顺序等.
*
* @param regexes 要用于替换的酒馆正则
* @param option 可选选项
* - scope?: 'all' | 'global' | 'character'; // 要替换的酒馆正则部分; 默认为 'all'
*/
declare function replaceTavernRegexes(regexes: TavernRegex[], { scope }: ReplaceTavernRegexesOption): Promise<void>;
type TavernRegexUpdater =
| ((regexes: TavernRegex[]) => TavernRegex[])
| ((regexes: TavernRegex[]) => Promise<TavernRegex[]>);
/**
* 用 `updater` 函数更新酒馆正则
*
* @param updater 用于更新酒馆正则的函数. 它应该接收酒馆正则作为参数, 并返回更新后的酒馆正则.
* @param option 可选选项
* - scope?: 'all' | 'global' | 'character'; // 要替换的酒馆正则部分; 默认为 'all'
*
* @returns 更新后的酒馆正则
*
* @example
* // 开启所有名字里带 "舞台少女" 的正则
* await updateTavernRegexesWith(regexes => {
* regexes.forEach(regex => {
* if (regex.script_name.includes('舞台少女')) {
* regex.enabled = true;
* }
* });
* return regexes;
* });
*/
declare function updateTavernRegexesWith(
updater: TavernRegexUpdater,
option?: ReplaceTavernRegexesOption,
): Promise<TavernRegex[]>;

View File

@@ -0,0 +1,43 @@
/**
* 替换字符串中的酒馆宏
*
* @param text 要替换的字符串
* @returns 替换结果
*
* @example
* const text = substitudeMacros("{{char}} speaks in {{lastMessageId}}");
* text == "少女歌剧 speaks in 5";
*/
declare function substitudeMacros(text: string): string;
/**
* 获取最新楼层 id
*
* @returns 最新楼层id
*/
declare function getLastMessageId(): number;
/**
* 包装任意函数,返回一个会将报错消息通过酒馆通知显示出来的同功能函数
*
* @param fn 要包装的函数
* @returns 包装后的函数
*
* @example
* // 包装 `test` 函数从而在酒馆通知中显示 'test' 文本
* function test() {
* throw Error(`test`);
* }
* errorCatched(test)();
*/
declare function errorCatched<T extends any[], U>(fn: (...args: T) => U): (...args: T) => U;
/**
* 从前端界面的 iframe 标识名称 `iframe_name` 获取它所在楼层的楼层号, **只能对前端界面 iframe 标识名称使用**
*
* @param iframe_name 前端界面的 iframe 标识名称
* @returns 楼层号
*
* @throws 如果提供的 `iframe_name` 不是前端界面 iframe 标识名称, 将会抛出错误
*/
declare function getMessageId(iframe_name: string): number;

View File

@@ -0,0 +1,206 @@
type VariableOptionNormal = {
/** 对聊天变量 (`'chat'`)、当前预设 (`'preset'`) 或全局变量 (`'global'`) 进行操作 */
type: 'chat' | 'preset' | 'global';
};
type VariableOptionCharacter = {
/**
* 对当前角色卡 (`'character'`) 进行操作
*
* @throws 如果没有打开角色卡, 将会抛出错误
*/
type: 'character';
};
type VariableOptionMessage = {
/** 对消息楼层变量 (`message`) 进行操作 */
type: 'message';
/**
* 指定要获取变量的消息楼层号, 如果为负数则为深度索引, 例如 `-1` 表示获取最新的消息楼层; 默认为 `'latest'`
*
* @throws 如果提供的消息楼层号 `message_id` 超出了范围 `[-chat.length, chat.length)`, 将会抛出错误
*/
message_id?: number | 'latest';
};
type VariableOptionScript = {
/** 对脚本变量 (`'script'`) 进行操作 */
type: 'script';
/** 指定要操作变量的脚本 ID; 如果在脚本内调用, 则无须指定, 当然你也可以用 `getScriptId()` 获取该脚本 ID */
script_id?: string;
};
type VariableOptionExtension = {
/** 对扩展变量 (`'extension'`) 进行操作 */
type: 'extension';
/** 指定要操作变量的扩展 ID */
extension_id: string;
};
type VariableOption = VariableOptionNormal | VariableOptionCharacter | VariableOptionMessage | VariableOptionScript | VariableOptionExtension;
/**
* 获取变量表
*
* @param option 要操作的变量类型
*
* @returns 变量表
*
* @example
* // 获取所有聊天变量并弹窗输出结果
* const variables = getVariables({type: 'chat'});
* alert(variables);
*
* @example
* // 获取所有全局变量
* const variables = getVariables({type: 'global'});
* // 酒馆助手内置了 lodash 库, 你能用它做很多事, 比如查询某个变量是否存在
* if (_.has(variables, "神乐光.好感度")) {
* ...
* }
*
* @example
* // 获取倒数第二楼层的聊天变量
* const variables = getVariables({type: 'message', message_id: -2});
*
* @example
* // 在脚本内获取该脚本绑定的变量
* const variables = getVariables({type: 'script'});
*/
declare function getVariables(option: VariableOption): Record<string, any>;
/**
* 完全替换变量表为 `variables`
*
* 之所以提供这么直接的函数, 是因为酒馆助手内置了 lodash 库:
* `insertOrAssignVariables` 等函数其实就是先 `getVariables` 获取变量表, 用 lodash 库处理, 再 `replaceVariables` 替换变量表.
*
* @param variables 要用于替换的变量表
* @param option 要操作的变量类型
*
* @example
* // 执行前的聊天变量: `{爱城华恋: {好感度: 5}}`
* replaceVariables({神乐光: {好感度: 5, 认知度: 0}});
* // 执行后的聊天变量: `{神乐光: {好感度: 5, 认知度: 0}}`
*
* @example
* // 删除 `{神乐光: {好感度: 5}}` 变量
* let variables = getVariables();
* _.unset(variables, "神乐光.好感度");
* replaceVariables(variables);
*
* @example
* // 在脚本内替换该脚本绑定的变量
* replaceVariables({神乐光: {好感度: 5, 认知度: 0}}, {type: 'script'});
*/
declare function replaceVariables(variables: Record<string, any>, option: VariableOption): void;
/**
* 用 `updater` 函数更新变量表
*
* @param updater 用于更新变量表的函数. 它应该接收变量表作为参数, 并返回更新后的变量表.
* @param option 要操作的变量类型
*
* @returns 更新后的变量表
*
* @example
* // 删除 `{神乐光: {好感度: 5}}` 变量
* updateVariablesWith(variables => {
* _.unset(variables, "神乐光.好感度");
* return variables;
* });
*
* @example
* // 更新 "爱城华恋.好感度" 为原来的 2 倍, 如果该变量不存在则设置为 0
* updateVariablesWith(variables => _.update(variables, "爱城华恋.好感度", value => value ? value * 2 : 0), {type: 'chat'});
*/
declare function updateVariablesWith(
updater: (variables: Record<string, any>) => Record<string, any>,
option: VariableOption,
): Record<string, any>;
/**
* 用 `updater` 函数更新变量表
*
* @param updater 用于更新变量表的函数. 它应该接收变量表作为参数, 并返回更新后的变量表.
* @param option 要操作的变量类型
*
* @returns 更新后的变量表
*
* @example
* await updateVariablesWith(async variables => {await update(variables); return variables;}, {type: 'chat'});
*/
declare function updateVariablesWith(
updater: (variables: Record<string, any>) => Promise<Record<string, any>>,
option: VariableOption,
): Promise<Record<string, any>>;
/**
* 插入或修改变量值, 取决于变量是否存在.
*
* @param variables 要更新的变量
* - 如果变量不存在, 则新增该变量
* - 如果变量已经存在, 则修改该变量的值
* @param option 要操作的变量类型
*
* @returns 更新后的变量表
*
* @example
* // 执行前变量: `{爱城华恋: {好感度: 5}}`
* await insertOrAssignVariables({爱城华恋: {好感度: 10}, 神乐光: {好感度: 5, 认知度: 0}}, {type: 'chat'});
* // 执行后变量: `{爱城华恋: {好感度: 10}, 神乐光: {好感度: 5, 认知度: 0}}`
*/
declare function insertOrAssignVariables(variables: Record<string, any>, option: VariableOption): Record<string, any>;
/**
* 插入新变量, 如果变量已经存在则什么也不做
*
* @param variables 要插入的变量
* - 如果变量不存在, 则新增该变量
* - 如果变量已经存在, 则什么也不做
* @param option 要操作的变量类型
*
* @returns 更新后的变量表
*
* @example
* // 执行前变量: `{爱城华恋: {好感度: 5}}`
* await insertVariables({爱城华恋: {好感度: 10}, 神乐光: {好感度: 5, 认知度: 0}}, {type: 'chat'});
* // 执行后变量: `{爱城华恋: {好感度: 5}, 神乐光: {好感度: 5, 认知度: 0}}`
*/
declare function insertVariables(variables: Record<string, any>, option: VariableOption): Record<string, any>;
/**
* 删除变量, 如果变量不存在则什么也不做
*
* @param variable_path 要删除的变量路径
* - 如果变量不存在, 则什么也不做
* - 如果变量已经存在, 则删除该变量
* @param option 要操作的变量类型
*
* @returns 更新后的变量表, 以及是否成功删除变量
*
* @example
* // 执行前变量: `{爱城华恋: {好感度: 5}}`
* await deleteVariable("爱城华恋.好感度", {type: 'chat'});
* // 执行后变量: `{爱城华恋: {}}`
*/
declare function deleteVariable(
variable_path: string,
option: VariableOption,
): { variables: Record<string, any>; delete_occurred: boolean };
/**
* 为变量管理器注册一个变量结构. 注册后, 变量管理器上将会按变量结构对变量进行校验
*
* **这只是方便使用变量管理器这一 UI 查看和管理变量, 对于代码层面没有任何影响**
*
* @param schema zod 库表示的变量结构
* @param option 要注册变量结构的变量类型
*
* @example
* // 注册消息楼层变量的结构为 stat_data 内有一个 好感度 数值变量
* registerVariableSchema(z.object({
* stat_data: z.object({
* 好感度: z.number(),
* }),
* }), {type: 'message'});
*/
function registerVariableSchema(
schema: z.ZodType<any>,
option: { type: 'global' | 'preset' | 'character' | 'chat' | 'message' },
): void;

View File

@@ -0,0 +1,9 @@
/**
* 获取酒馆助手版本号
*/
declare function getTavernHelperVersion(): string;
/**
* 获取酒馆版本号
*/
declare function getTavernVersion(): string;

View File

@@ -0,0 +1,310 @@
/**
* 获取世界书名称列表
*
* @returns 世界书名称列表
*/
declare function getWorldbookNames(): string[];
/**
* 获取当前全局开启的世界书名称列表
*
* @returns 全局世界书名称列表
*/
declare function getGlobalWorldbookNames(): string[];
/**
* 重新绑定全局世界书
*
* @param worldbook_names 要全局开启的世界书
*/
declare function rebindGlobalWorldbooks(worldbook_names: string[]): Promise<void>;
type CharWorldbooks = {
primary: string | null;
additional: string[];
};
/**
* 获取角色卡绑定的世界书
*
* @param character_name 要查询的角色卡名称, 'current' 表示当前打开的角色卡
*
* @returns 角色卡绑定的世界书
*/
declare function getCharWorldbookNames(character_name: LiteralUnion<'current' | string>): CharWorldbooks;
/**
* 重新绑定角色卡世界书
*
* @param character_name 角色卡名称, 'current' 表示当前打开的角色卡
* @param char_worldbooks 要对该角色卡绑定的世界书
*/
declare function rebindCharWorldbooks(character_name: 'current', char_worldbooks: CharWorldbooks): Promise<void>;
/**
* 获取聊天文件绑定的世界书
*
* @param chat_name 聊天文件名称
*
* @returns 聊天文件绑定的世界书, 如果没有则为 `null`
*/
declare function getChatWorldbookName(chat_name: 'current'): string | null;
/**
* 重新绑定聊天文件世界书
*
* @param character_name 聊天文件名称, 'current' 表示当前打开的聊天
* @param char_worldbooks 要对该聊天文件绑定的世界书
*/
declare function rebindChatWorldbook(chat_name: 'current', worldbook_name: string): Promise<void>;
/**
* 获取或新建聊天文件世界书
*
* @param chat_name 聊天文件名称, 'current' 表示当前打开的聊天
* @param worldbook_name 世界书名称; 不填则根据当前时间创建
*/
declare function getOrCreateChatWorldbook(chat_name: 'current', worldbook_name?: string): Promise<string>;
type WorldbookEntry = {
/** uid 是相对于世界书内部的, 不要跨世界书使用 */
uid: number;
name: string;
enabled: boolean;
/** 激活策略: 条目应该何时激活 */
strategy: {
/**
* 激活策略类型:
* - `'constant'`: 常量🔵, 俗称蓝灯. 只需要满足 "启用"、"激活概率%" 等别的要求即可.
* - `'selective'`: 可选项🟢, 俗称绿灯. 除了蓝灯条件, 还需要满足 `keys` 扫描条件
* - `'vectorized'`: 向量化🔗. 一般不使用
*/
type: 'constant' | 'selective' | 'vectorized';
/** 主要关键字. 绿灯条目必须在欲扫描文本中扫描到其中任意一个关键字才能激活 */
keys: (string | RegExp)[];
/**
* 次要关键字. 如果次要关键字的 `keys` 数组不为空, 则条目除了在主要关键字中匹配到任意一个关键字外, 还需要满足 `logic`:
* - `'and_any'`: 次要关键字中任意一个关键字能在欲扫描文本中匹配到
* - `'and_all'`: 次要关键字中所有关键字都能在欲扫描文本中匹配到
* - `'not_all'`: 次要关键字中至少有一个关键字没能在欲扫描文本中匹配到
* - `'not_any'`: 次要关键字中所有关键字都没能欲扫描文本中匹配到
*/
keys_secondary: { logic: 'and_any' | 'and_all' | 'not_all' | 'not_any'; keys: (string | RegExp)[] };
/** 扫描深度: 1 为仅扫描最后一个楼层, 2 为扫描最后两个楼层, 以此类推 */
scan_depth: 'same_as_global' | number;
};
/** 插入位置: 如果条目激活应该插入到什么地方 */
position: {
/**
* 位置类型:
* - `'before_character_definition'`: 角色定义之前
* - `'after_character_definition'`: 角色定义之后
* - `'before_example_messages'`: 示例消息之前
* - `'after_example_messages'`: 示例消息之后
* - `'before_author_note'`: 作者注释之前
* - `'after_author_note'`: 作者注释之后
* - `'at_depth'`: 插入到指定深度
*/
type:
| 'before_character_definition'
| 'after_character_definition'
| 'before_example_messages'
| 'after_example_messages'
| 'before_author_note'
| 'after_author_note'
| 'at_depth';
/** 该条目的消息身份, 仅位置类型为 `'at_depth'` 时有效 */
role: 'system' | 'assistant' | 'user';
/** 该条目要插入的深度, 仅位置类型为 `'at_depth'` 时有效 */
depth: number;
// TODO: 世界书条目的插入: 文档链接
order: number;
};
content: string;
probability: number;
/** 递归表示某世界书条目被激活后, 该条目的提示词又激活了其他条目 */
recursion: {
/** 禁止其他条目递归激活本条目 */
prevent_incoming: boolean;
/** 禁止本条目递归激活其他条目 */
prevent_outgoing: boolean;
/** 延迟到第 n 级递归检查时才能激活本条目 */
delay_until: null | number;
};
effect: {
/** 黏性: 条目激活后, 在之后 n 条消息内始终激活, 无视激活策略、激活概率% */
sticky: null | number;
/** 冷却: 条目激活后, 在之后 n 条消息内不能再激活 */
cooldown: null | number;
/** 延迟: 聊天中至少有 n 楼消息时, 才能激活条目 */
delay: null | number;
};
/** 额外字段, 用于为世界书条目绑定额外数据 */
extra?: Record<string, any>;
};
/**
* 创建新的世界书
*
* @param worldbook_name 世界书名称
* @param worldbook 世界书内容; 不填则没有任何条目
*
* @returns 如果发生创建, 则返回 `true`; 如果发生替换, 则返回 `false`
*/
declare function createWorldbook(worldbook_name: string, worldbook?: WorldbookEntry[]): Promise<boolean>;
/**
* 创建或替换名为 `worldbook_name` 的世界书, 内容为 `worldbook`
*
* @param worldbook_name 世界书名称
* @param worldbook 世界书内容; 不填则没有任何条目
* @param options 可选选项
* - `render:'debounced'|'immediate'|'none'`: 对于对世界书的更改, 世界书编辑器应该防抖渲染 (debounced)、立即渲染 (immediate) 还是不刷新前端显示 (none)? 默认为性能更好的防抖渲染
*
* @returns 如果发生创建, 则返回 `true`; 如果发生替换, 则返回 `false`
*/
declare function createOrReplaceWorldbook(
worldbook_name: string,
worldbook?: PartialDeep<WorldbookEntry>[],
{ render }?: ReplaceWorldbookOptions,
): Promise<boolean>;
/**
* 删除 `worldbook_name` 世界书
*
* @param worldbook_name 世界书名称
*
* @returns 是否成功删除, 可能因世界书不存在等原因而失败
*/
declare function deleteWorldbook(worldbook_name: string): Promise<boolean>;
// TODO: rename 需要处理世界书绑定
// export function renameWorldbook(old_name: string, new_name: string): boolean;
/**
* 获取 `worldbook_name` 世界书的内容
*
* @param worldbook_name 世界书名称
*
* @returns 世界书内容
*
* @throws 如果世界书不存在, 将会抛出错误
*/
declare function getWorldbook(worldbook_name: string): Promise<WorldbookEntry[]>;
interface ReplaceWorldbookOptions {
/** 对于对世界书的更改, 世界书编辑器应该防抖渲染 (debounced) 还是立即渲染 (immediate)? 默认为性能更好的防抖渲染 */
render?: 'debounced' | 'immediate';
}
/**
* 完全替换 `worldbook_name` 世界书的内容为 `worldbook`
*
* @param worldbook_name 世界书名称
* @param worldbook 世界书内容
* @param options 可选选项
* - `render:'debounced'|'immediate'`: 对于对世界书的更改, 世界书编辑器应该防抖渲染 (debounced) 还是立即渲染 (immediate)? 默认为性能更好的防抖渲染
*
* @throws 如果世界书不存在, 将会抛出错误
*
* @example
* // 禁止所有条目递归, 保持其他设置不变
* const worldbook = await getWorldbook("eramgt少女歌剧");
* await replaceWorldbook(
* 'eramgt少女歌剧',
* worldbook.map(entry => ({
* ...entry,
* recursion: { prevent_incoming: true, prevent_outgoing: true, delay_until: null },
* })),
* );
*
* @example
* // 删除所有名字中包含 `'神乐光'` 的条目
* const worldbook = await getWorldbook("eramgt少女歌剧");
* _.remove(worldbook, entry => entry.name.includes('神乐光'));
* await replaceWorldbook("eramgt少女歌剧", worldbook);
*/
declare function replaceWorldbook(
worldbook_name: string,
worldbook: PartialDeep<WorldbookEntry>[],
{ render }?: ReplaceWorldbookOptions,
): Promise<void>;
type WorldbookUpdater =
| ((worldbook: WorldbookEntry[]) => PartialDeep<WorldbookEntry>[])
| ((worldbook: WorldbookEntry[]) => Promise<PartialDeep<WorldbookEntry>[]>);
/**
* 用 `updater` 函数更新世界书 `worldbook_name`
*
* @param worldbook_name 世界书名称
* @param updater 用于更新世界书的函数. 它应该接收世界书条目作为参数, 并返回更新后的世界书条目
* @param options 可选选项
* - `render:'debounced'|'immediate'`: 对于对世界书的更改, 世界书编辑器应该防抖渲染 (debounced) 还是立即渲染 (immediate)? 默认为性能更好的防抖渲染
*
* @returns 更新后的世界书条目
*
* @throws 如果世界书不存在, 将会抛出错误
*
* @example
* // 禁止所有条目递归, 保持其他设置不变
* await updateWorldbookWith('eramgt少女歌剧', worldbook => {
* return worldbook.map(entry => ({
* ...entry,
* recursion: { prevent_incoming: true, prevent_outgoing: true, delay_until: null },
* }));
* });
*
* @example
* // 删除所有名字中包含 "神乐光" 的条目
* await updateWorldbookWith('eramgt少女歌剧', worldbook => {
* _.remove(worldbook, entry => entry.name.includes('神乐光'));
* return worldbook;
* });
*/
declare function updateWorldbookWith(
worldbook_name: string,
updater: WorldbookUpdater,
{ render }?: ReplaceWorldbookOptions,
): Promise<WorldbookEntry[]>;
/**
* 向世界书中新增条目
*
* @param worldbook_name 世界书名称
* @param new_entries 要新增的条目, 对于不设置的字段将会采用酒馆给的默认值
* @param options 可选选项
* - `render:'debounced'|'immediate'`: 对于对世界书的更改, 世界书编辑器应该防抖渲染 (debounced) 还是立即渲染 (immediate)? 默认为性能更好的防抖渲染
*
* @returns 更新后的世界书条目, 以及新增条目补全字段后的结果
*
* @throws 如果世界书不存在, 将会抛出错误
*
* @example
* // 创建两个条目, 一个标题叫 `'神乐光'`, 一个留白
* const { worldbook, new_entries } = await createWorldbookEntries('eramgt少女歌剧', [{ name: '神乐光' }, {}]);
*/
declare function createWorldbookEntries(
worldbook_name: string,
new_entries: PartialDeep<WorldbookEntry>[],
{ render }?: ReplaceWorldbookOptions,
): Promise<{ worldbook: WorldbookEntry[]; new_entries: WorldbookEntry[] }>;
/**
* 删除世界书中的条目
*
* @param worldbook_name 世界书名称
* @param predicate 判断函数, 如果返回 `true` 则删除该条目
* @param options 可选选项
* - `render:'debounced'|'immediate'`: 对于对世界书的更改, 世界书编辑器应该防抖渲染 (debounced) 还是立即渲染 (immediate)? 默认为性能更好的防抖渲染
*
* @returns 更新后的世界书条目, 以及被删除的条目
*
* @throws 如果世界书不存在, 将会抛出错误
*
* @example
* // 删除所有名字中包含 `'神乐光'` 的条目
* const { worldbook, deleted_entries } = await deleteWorldbookEntries('eramgt少女歌剧', entry => entry.name.includes('神乐光'));
*/
declare function deleteWorldbookEntries(
worldbook_name: string,
predicate: (entry: WorldbookEntry) => boolean,
{ render }?: ReplaceWorldbookOptions,
): Promise<{ worldbook: WorldbookEntry[]; deleted_entries: WorldbookEntry[] }>;

View File

@@ -0,0 +1,521 @@
/* eslint-disable @typescript-eslint/no-unsafe-function-type */
/**
* 事件可以是
* - `iframe_events` 中的 iframe 事件
* - `tavern_events` 中的酒馆事件
* - 自定义的字符串事件
*/
type EventType = IframeEventType | TavernEventType | string;
type EventOnReturn = {
/** 取消监听 */
stop: () => void;
};
/**
* 让 `listener` 监听 `event_type`, 当事件发生时自动运行 `listener`;
* 如果 `listener` 已经在监听 `event_type`, 则调用本函数不会有任何效果.
*
* 当 `eventOn` 所在的前端界面/脚本关闭时, 监听将会自动卸载.
*
* @param event_type 要监听的事件
* @param listener 要注册的函数
*
* @example
* function hello() { alert("hello"); }
* eventOn(要监听的事件, hello);
*
* @example
* // 监听消息接收并弹出 `'hello'`
* eventOn(tavern_events.MESSAGE_RECEIVED, () => alert('hello'));
*
* @example
* // 消息被修改时监听是哪一条消息被修改
* // 酒馆事件 tavern_events.MESSAGE_UPDATED 会传递被更新的楼层 id
* eventOn(tavern_events.MESSAGE_UPDATED, message_id => {
* alert(`你刚刚更新了第 ${message_id} 条聊天消息对吧😡`);
* });
*
* @returns 后续操作
* - `stop`: 取消这个监听
*/
declare function eventOn<T extends EventType>(event_type: T, listener: ListenerType[T]): EventOnReturn;
/** @deprecated 请使用 `eventOn(getButtonEvent('按钮名称'), 函数)` 代替 */
declare function eventOnButton<T extends EventType>(event_type: T, listener: ListenerType[T]): void;
/**
* 让 `listener` 监听 `event_type`, 当事件发生时自动在最后运行 `listener`;
* 如果 `listener` 已经在监听 `event_type`, 则调用本函数会将 `listener` 调整为最后运行.
*
* 当 `eventMakeLast` 所在的前端界面/脚本关闭时, 监听将会自动卸载.
*
* @param event_type 要监听的事件
* @param listener 要注册/调整到最后运行的函数
*
* @example
* eventMakeLast(要监听的事件, 要注册的函数);
*
* @returns 后续操作
* - `stop`: 取消这个监听
*/
declare function eventMakeLast<T extends EventType>(event_type: T, listener: ListenerType[T]): EventOnReturn;
/**
* 让 `listener` 监听 `event_type`, 当事件发生时自动在最先运行 `listener`;
* 如果 `listener` 已经在监听 `event_type`, 则调用本函数会将 `listener` 调整为最先运行.
*
* 当 `eventMakeFirst` 所在的前端界面/脚本关闭时, 监听将会自动卸载.
*
* @param event_type 要监听的事件
* @param listener 要注册/调整为最先运行的函数
*
* @example
* eventMakeFirst(要监听的事件, 要注册的函数);
*
* @returns 后续操作
* - `stop`: 取消这个监听
*/
declare function eventMakeFirst<T extends EventType>(event_type: T, listener: ListenerType[T]): EventOnReturn;
/**
* 让 `listener` 仅监听下一次 `event_type`, 当该次事件发生时运行 `listener`, 此后取消监听;
* 如果 `listener` 已经在监听 `event_type`, 则调用本函数不会有任何效果.
*
* 当 `eventOnce` 所在的前端界面/脚本关闭时, 监听将会自动卸载.
*
* @param event_type 要监听的事件
* @param listener 要注册的函数
*
* @example
* eventOnce(要监听的事件, 要注册的函数);
*
* @returns 后续操作
* - `stop`: 取消这个监听
*/
declare function eventOnce<T extends EventType>(event_type: T, listener: ListenerType[T]): EventOnReturn;
/**
* 发送 `event_type` 事件, 同时可以发送一些数据 `data`.
*
* 所有正在监听 `event_type` 消息频道的都会收到该消息并接收到 `data`.
*
* @param event_type 要发送的事件
* @param data 要随着事件发送的数据
*
* @example
* // 发送 "角色阶段更新完成" 事件, 所有监听该事件的 `listener` 都会被运行
* eventEmit("角色阶段更新完成");
*
* @example
* // 发送 "存档" 事件, 并等待所有 `listener` (也许是负责存档的函数) 执行完毕后才继续
* await eventEmit("存档");
*
* @example
* // 发送时携带数据 ["你好", 0]
* eventEmit("事件", "你好", 0);
*/
declare function eventEmit<T extends EventType>(event_type: T, ...data: Parameters<ListenerType[T]>): Promise<void>;
/**
* 携带 `data` 而发送 `event_type` 事件并等待事件处理结束.
*
* @param event_type 要发送的事件
* @param data 要随着事件发送的数据
*/
declare function eventEmitAndWait<T extends EventType>(event_type: T, ...data: Parameters<ListenerType[T]>): void;
/**
* 让 `listener` 取消对 `event_type` 的监听; 如果 `listener` 没有监听 `event_type`, 则调用本函数不会有任何效果.
*
* 前端界面/脚本关闭时会自动卸载所有的事件监听, 你不必手动调用 `eventRemoveListener` 来移除.
*
* @param event_type 要监听的事件
* @param listener 要取消注册的函数
*
* @example
* eventRemoveListener(要监听的事件, 要取消注册的函数);
*/
declare function eventRemoveListener<T extends EventType>(event_type: T, listener: ListenerType[T]): void;
/**
* 取消本 iframe 中对 `event_type` 的所有监听
*
* 前端界面/脚本关闭时会自动卸载所有的事件监听, 你不必手动调用 `eventClearEvent` 来移除.
*
* @param event_type 要取消监听的事件
*/
declare function eventClearEvent(event_type: EventType): void;
/**
* 取消本 iframe 中 `listener` 的的所有监听
*
* 前端界面/脚本关闭时会自动卸载所有的事件监听, 你不必手动调用 `eventClearListener` 来移除.
*
* @param listener 要取消注册的函数
*/
declare function eventClearListener(listener: Function): void;
/**
* 取消本 iframe 中对所有事件的所有监听
*
* 前端界面/脚本关闭时会自动卸载所有的事件监听, 你不必手动调用 `eventClearAll` 来移除.
*/
declare function eventClearAll(): void;
//------------------------------------------------------------------------------------------------------------------------
// 以下是可用的事件, 你可以发送和监听它们
type IframeEventType = (typeof iframe_events)[keyof typeof iframe_events];
// iframe 事件
declare const iframe_events: {
MESSAGE_IFRAME_RENDER_STARTED: 'message_iframe_render_started';
MESSAGE_IFRAME_RENDER_ENDED: 'message_iframe_render_ended';
/** `generate` 函数开始生成 */
GENERATION_STARTED: 'js_generation_started';
/** 启用流式传输的 `generate` 函数传输当前完整文本: "这是", "这是一条", "这是一条流式传输" */
STREAM_TOKEN_RECEIVED_FULLY: 'js_stream_token_received_fully';
/** 启用流式传输的 `generate` 函数传输当前增量文本: "这是", "一条", "流式传输" */
STREAM_TOKEN_RECEIVED_INCREMENTALLY: 'js_stream_token_received_incrementally';
/** `generate` 函数完成生成 */
GENERATION_ENDED: 'js_generation_ended';
};
type TavernEventType = (typeof tavern_events)[keyof typeof tavern_events];
// 酒馆事件. **不建议自己发送酒馆事件, 因为你并不清楚它需要发送什么数据**
declare const tavern_events: {
APP_READY: 'app_ready';
EXTRAS_CONNECTED: 'extras_connected';
MESSAGE_SWIPED: 'message_swiped';
MESSAGE_SENT: 'message_sent';
MESSAGE_RECEIVED: 'message_received';
MESSAGE_EDITED: 'message_edited';
MESSAGE_DELETED: 'message_deleted';
MESSAGE_UPDATED: 'message_updated';
MESSAGE_FILE_EMBEDDED: 'message_file_embedded';
MESSAGE_REASONING_EDITED: 'message_reasoning_edited';
MESSAGE_REASONING_DELETED: 'message_reasoning_deleted';
/** since SillyTavern v1.13.5 */
MESSAGE_SWIPE_DELETED: 'message_swipe_deleted';
MORE_MESSAGES_LOADED: 'more_messages_loaded';
IMPERSONATE_READY: 'impersonate_ready';
CHAT_CHANGED: 'chat_id_changed';
GENERATION_AFTER_COMMANDS: 'GENERATION_AFTER_COMMANDS';
GENERATION_STARTED: 'generation_started';
GENERATION_STOPPED: 'generation_stopped';
GENERATION_ENDED: 'generation_ended';
SD_PROMPT_PROCESSING: 'sd_prompt_processing';
EXTENSIONS_FIRST_LOAD: 'extensions_first_load';
EXTENSION_SETTINGS_LOADED: 'extension_settings_loaded';
SETTINGS_LOADED: 'settings_loaded';
SETTINGS_UPDATED: 'settings_updated';
MOVABLE_PANELS_RESET: 'movable_panels_reset';
SETTINGS_LOADED_BEFORE: 'settings_loaded_before';
SETTINGS_LOADED_AFTER: 'settings_loaded_after';
CHATCOMPLETION_SOURCE_CHANGED: 'chatcompletion_source_changed';
CHATCOMPLETION_MODEL_CHANGED: 'chatcompletion_model_changed';
OAI_PRESET_CHANGED_BEFORE: 'oai_preset_changed_before';
OAI_PRESET_CHANGED_AFTER: 'oai_preset_changed_after';
OAI_PRESET_EXPORT_READY: 'oai_preset_export_ready';
OAI_PRESET_IMPORT_READY: 'oai_preset_import_ready';
WORLDINFO_SETTINGS_UPDATED: 'worldinfo_settings_updated';
WORLDINFO_UPDATED: 'worldinfo_updated';
/** since SillyTavern v1.13.5 */
CHARACTER_EDITOR_OPENED: 'character_editor_opened';
CHARACTER_EDITED: 'character_edited';
CHARACTER_PAGE_LOADED: 'character_page_loaded';
USER_MESSAGE_RENDERED: 'user_message_rendered';
CHARACTER_MESSAGE_RENDERED: 'character_message_rendered';
FORCE_SET_BACKGROUND: 'force_set_background';
CHAT_DELETED: 'chat_deleted';
CHAT_CREATED: 'chat_created';
GENERATE_BEFORE_COMBINE_PROMPTS: 'generate_before_combine_prompts';
GENERATE_AFTER_COMBINE_PROMPTS: 'generate_after_combine_prompts';
GENERATE_AFTER_DATA: 'generate_after_data';
WORLD_INFO_ACTIVATED: 'world_info_activated';
TEXT_COMPLETION_SETTINGS_READY: 'text_completion_settings_ready';
CHAT_COMPLETION_SETTINGS_READY: 'chat_completion_settings_ready';
CHAT_COMPLETION_PROMPT_READY: 'chat_completion_prompt_ready';
CHARACTER_FIRST_MESSAGE_SELECTED: 'character_first_message_selected';
CHARACTER_DELETED: 'characterDeleted';
CHARACTER_DUPLICATED: 'character_duplicated';
CHARACTER_RENAMED: 'character_renamed';
CHARACTER_RENAMED_IN_PAST_CHAT: 'character_renamed_in_past_chat';
SMOOTH_STREAM_TOKEN_RECEIVED: 'stream_token_received';
STREAM_TOKEN_RECEIVED: 'stream_token_received';
STREAM_REASONING_DONE: 'stream_reasoning_done';
FILE_ATTACHMENT_DELETED: 'file_attachment_deleted';
WORLDINFO_FORCE_ACTIVATE: 'worldinfo_force_activate';
OPEN_CHARACTER_LIBRARY: 'open_character_library';
ONLINE_STATUS_CHANGED: 'online_status_changed';
IMAGE_SWIPED: 'image_swiped';
CONNECTION_PROFILE_LOADED: 'connection_profile_loaded';
CONNECTION_PROFILE_CREATED: 'connection_profile_created';
CONNECTION_PROFILE_DELETED: 'connection_profile_deleted';
CONNECTION_PROFILE_UPDATED: 'connection_profile_updated';
TOOL_CALLS_PERFORMED: 'tool_calls_performed';
TOOL_CALLS_RENDERED: 'tool_calls_rendered';
CHARACTER_MANAGEMENT_DROPDOWN: 'charManagementDropdown';
SECRET_WRITTEN: 'secret_written';
SECRET_DELETED: 'secret_deleted';
SECRET_ROTATED: 'secret_rotated';
SECRET_EDITED: 'secret_edited';
PRESET_CHANGED: 'preset_changed';
PRESET_DELETED: 'preset_deleted';
/** since SillyTavern v1.13.5 */
PRESET_RENAMED: 'preset_renamed';
/** since SillyTavern v1.13.5 */
PRESET_RENAMED_BEFORE: 'preset_renamed_before';
MAIN_API_CHANGED: 'main_api_changed';
WORLDINFO_ENTRIES_LOADED: 'worldinfo_entries_loaded';
WORLDINFO_SCAN_DONE: 'worldinfo_scan_done';
/** since SillyTavern v1.14.0 */
MEDIA_ATTACHMENT_DELETED: 'media_attachment_deleted';
};
interface ListenerType {
[iframe_events.MESSAGE_IFRAME_RENDER_STARTED]: (iframe_name: string) => void;
[iframe_events.MESSAGE_IFRAME_RENDER_ENDED]: (iframe_name: string) => void;
[iframe_events.GENERATION_STARTED]: (generation_id: string) => void;
[iframe_events.STREAM_TOKEN_RECEIVED_FULLY]: (full_text: string, generation_id: string) => void;
[iframe_events.STREAM_TOKEN_RECEIVED_INCREMENTALLY]: (incremental_text: string, generation_id: string) => void;
[iframe_events.GENERATION_ENDED]: (text: string, generation_id: string) => void;
[tavern_events.APP_READY]: () => void;
[tavern_events.EXTRAS_CONNECTED]: (modules: any) => void;
[tavern_events.MESSAGE_SWIPED]: (message_id: number) => void;
[tavern_events.MESSAGE_SENT]: (message_id: number) => void;
[tavern_events.MESSAGE_RECEIVED]: (
message_id: number,
type: LiteralUnion<
| 'normal'
| 'quiet'
| 'regenerate'
| 'impersonate'
| 'continue'
| 'swipe'
| 'append'
| 'appendFinal'
| 'first_message'
| 'command'
| 'extension',
string
>,
) => void;
[tavern_events.MESSAGE_EDITED]: (message_id: number) => void;
[tavern_events.MESSAGE_DELETED]: (message_id: number) => void;
[tavern_events.MESSAGE_UPDATED]: (message_id: number) => void;
[tavern_events.MESSAGE_FILE_EMBEDDED]: (message_id: number) => void;
[tavern_events.MESSAGE_REASONING_EDITED]: (message_id: number) => void;
[tavern_events.MESSAGE_REASONING_DELETED]: (message_id: number) => void;
[tavern_events.MESSAGE_SWIPE_DELETED]: (event_data: {
messageId: number;
swipeId: number;
newSwipeId: number;
}) => void;
[tavern_events.MORE_MESSAGES_LOADED]: () => void;
[tavern_events.IMPERSONATE_READY]: (message: string) => void;
[tavern_events.CHAT_CHANGED]: (chat_file_name: string) => void;
[tavern_events.GENERATION_AFTER_COMMANDS]: (
type: string,
option: {
automatic_trigger?: boolean;
force_name2?: boolean;
quiet_prompt?: string;
quietToLoud?: boolean;
skipWIAN?: boolean;
force_chid?: number;
signal?: AbortSignal;
quietImage?: string;
quietName?: string;
depth?: number;
},
dry_run: boolean,
) => void;
[tavern_events.GENERATION_STARTED]: (
type: string,
option: {
automatic_trigger?: boolean;
force_name2?: boolean;
quiet_prompt?: string;
quietToLoud?: boolean;
skipWIAN?: boolean;
force_chid?: number;
signal?: AbortSignal;
quietImage?: string;
quietName?: string;
depth?: number;
},
dry_run: boolean,
) => void;
[tavern_events.GENERATION_STOPPED]: () => void;
[tavern_events.GENERATION_ENDED]: (message_id: number) => void;
[tavern_events.SD_PROMPT_PROCESSING]: (event_data: {
prompt: string;
generationType: number;
message: string;
trigger: string;
}) => void;
[tavern_events.EXTENSIONS_FIRST_LOAD]: () => void;
[tavern_events.EXTENSION_SETTINGS_LOADED]: () => void;
[tavern_events.SETTINGS_LOADED]: () => void;
[tavern_events.SETTINGS_UPDATED]: () => void;
[tavern_events.MOVABLE_PANELS_RESET]: () => void;
[tavern_events.SETTINGS_LOADED_BEFORE]: (settings: object) => void;
[tavern_events.SETTINGS_LOADED_AFTER]: (settings: object) => void;
[tavern_events.CHATCOMPLETION_SOURCE_CHANGED]: (source: string) => void;
[tavern_events.CHATCOMPLETION_MODEL_CHANGED]: (model: string) => void;
[tavern_events.OAI_PRESET_CHANGED_BEFORE]: (result: {
preset: object;
presetName: string;
settingsToUpdate: object;
settings: object;
savePreset: (name: string, settings: Record<string, any>, trigger_ui?: boolean) => Promise<void>;
presetNameBefore: string;
}) => void;
[tavern_events.OAI_PRESET_CHANGED_AFTER]: () => void;
[tavern_events.OAI_PRESET_EXPORT_READY]: (preset: object) => void;
[tavern_events.OAI_PRESET_IMPORT_READY]: (result: { data: object; presetName: string }) => void;
[tavern_events.WORLDINFO_SETTINGS_UPDATED]: () => void;
[tavern_events.WORLDINFO_UPDATED]: (
name: string,
data: { entries: { [uid: number]: SillyTavern.FlattenedWorldInfoEntry } },
) => void;
[tavern_events.CHARACTER_EDITOR_OPENED]: (chid: string) => void;
[tavern_events.CHARACTER_EDITED]: (result: { detail: { id: string; character: SillyTavern.v1CharData } }) => void;
[tavern_events.CHARACTER_PAGE_LOADED]: () => void;
[tavern_events.USER_MESSAGE_RENDERED]: (message_id: number) => void;
[tavern_events.CHARACTER_MESSAGE_RENDERED]: (message_id: number, type: string) => void;
[tavern_events.FORCE_SET_BACKGROUND]: (background: { url: string; path: string }) => void;
[tavern_events.CHAT_DELETED]: (chat_file_name: string) => void;
[tavern_events.CHAT_CREATED]: () => void;
[tavern_events.GENERATE_BEFORE_COMBINE_PROMPTS]: () => void;
[tavern_events.GENERATE_AFTER_COMBINE_PROMPTS]: (result: { prompt: string; dryRun: boolean }) => void;
/** dry_run 只在 SillyTavern 1.13.15 及以后有 */
[tavern_events.GENERATE_AFTER_DATA]: (
generate_data: {
prompt: SillyTavern.SendingMessage[];
},
dry_run: boolean,
) => void;
[tavern_events.WORLD_INFO_ACTIVATED]: (entries: ({ world: string } & SillyTavern.FlattenedWorldInfoEntry)[]) => void;
[tavern_events.TEXT_COMPLETION_SETTINGS_READY]: () => void;
[tavern_events.CHAT_COMPLETION_SETTINGS_READY]: (generate_data: {
messages: SillyTavern.SendingMessage[];
model: string;
temprature: number;
frequency_penalty: number;
presence_penalty: number;
top_p: number;
max_tokens: number;
stream: boolean;
logit_bias: object;
stop: string[];
chat_comletion_source: string;
n?: number;
user_name: string;
char_name: string;
group_names: string[];
include_reasoning: boolean;
reasoning_effort: string;
json_schema: {
name: string;
value: Record<string, any>;
description?: string;
strict?: boolean;
};
[others: string]: any;
}) => void;
[tavern_events.CHAT_COMPLETION_PROMPT_READY]: (event_data: {
chat: SillyTavern.SendingMessage[];
dryRun: boolean;
}) => void;
[tavern_events.CHARACTER_FIRST_MESSAGE_SELECTED]: (event_args: {
input: string;
output: string;
character: object;
}) => void;
[tavern_events.CHARACTER_DELETED]: (result: { id: string; character: SillyTavern.v1CharData }) => void;
[tavern_events.CHARACTER_DUPLICATED]: (result: { oldAvatar: string; newAvatar: string }) => void;
[tavern_events.CHARACTER_RENAMED]: (old_avatar: string, new_avatar: string) => void;
[tavern_events.CHARACTER_RENAMED_IN_PAST_CHAT]: (
current_chat: Record<string, any>,
old_avatar: string,
new_avatar: string,
) => void;
[tavern_events.STREAM_TOKEN_RECEIVED]: (text: string) => void;
[tavern_events.STREAM_REASONING_DONE]: (
reasoning: string,
duration: number | null,
message_id: number,
state: 'none' | 'thinking' | 'done' | 'hidden',
) => void;
[tavern_events.FILE_ATTACHMENT_DELETED]: (url: string) => void;
[tavern_events.WORLDINFO_FORCE_ACTIVATE]: (entries: object[]) => void;
[tavern_events.OPEN_CHARACTER_LIBRARY]: () => void;
[tavern_events.ONLINE_STATUS_CHANGED]: () => void;
[tavern_events.IMAGE_SWIPED]: (result: {
message: object;
element: JQuery<HTMLElement>;
direction: 'left' | 'right';
}) => void;
[tavern_events.CONNECTION_PROFILE_LOADED]: (profile_name: string) => void;
[tavern_events.CONNECTION_PROFILE_CREATED]: (profile: Record<string, any>) => void;
[tavern_events.CONNECTION_PROFILE_DELETED]: (profile: Record<string, any>) => void;
[tavern_events.CONNECTION_PROFILE_UPDATED]: (
old_profile: Record<string, any>,
new_profile: Record<string, any>,
) => void;
[tavern_events.TOOL_CALLS_PERFORMED]: (tool_invocations: object[]) => void;
[tavern_events.TOOL_CALLS_RENDERED]: (tool_invocations: object[]) => void;
[tavern_events.WORLDINFO_ENTRIES_LOADED]: (lores: {
globalLore: ({ world: string } & SillyTavern.FlattenedWorldInfoEntry)[];
characterLore: ({ world: string } & SillyTavern.FlattenedWorldInfoEntry)[];
chatLore: ({ world: string } & SillyTavern.FlattenedWorldInfoEntry)[];
personaLore: ({ world: string } & SillyTavern.FlattenedWorldInfoEntry)[];
}) => void;
[tavern_events.CHARACTER_MANAGEMENT_DROPDOWN]: (target: JQuery) => void;
[tavern_events.SECRET_WRITTEN]: (secret: string) => void;
[tavern_events.SECRET_DELETED]: (secret: string) => void;
[tavern_events.SECRET_ROTATED]: (secret: string) => void;
[tavern_events.SECRET_EDITED]: (secret: string) => void;
[tavern_events.PRESET_CHANGED]: (data: { apiId: string; name: string }) => void;
[tavern_events.PRESET_DELETED]: (data: { apiId: string; name: string }) => void;
[tavern_events.PRESET_RENAMED]: (data: { apiId: string; oldName: string; newName: string }) => void;
[tavern_events.PRESET_RENAMED_BEFORE]: (data: { apiId: string; oldName: string; newName: string }) => void;
[tavern_events.MAIN_API_CHANGED]: (data: { apiId: string }) => void;
[tavern_events.WORLDINFO_ENTRIES_LOADED]: (lores: {
globalLore: ({ world: string } & SillyTavern.FlattenedWorldInfoEntry)[];
characterLore: ({ world: string } & SillyTavern.FlattenedWorldInfoEntry)[];
chatLore: ({ world: string } & SillyTavern.FlattenedWorldInfoEntry)[];
personaLore: ({ world: string } & SillyTavern.FlattenedWorldInfoEntry)[];
}) => void;
[tavern_events.WORLDINFO_SCAN_DONE]: (event_data: {
state: {
current: number;
next: number;
loopCount: number;
};
new: {
all: SillyTavern.FlattenedWorldInfoEntry[];
successful: SillyTavern.FlattenedWorldInfoEntry[];
};
activated: {
entries: Map<`${string}.${string}`, SillyTavern.FlattenedWorldInfoEntry>;
text: string;
};
sortedEntries: SillyTavern.FlattenedWorldInfoEntry[];
recursionDelay: {
availableLevels: number[];
currentLevel: number;
};
budget: {
current: number;
overflowed: boolean;
};
timedEffects: Record<string, any>;
}) => void;
[custom_event: string]: (...args: any) => any;
}

View File

@@ -0,0 +1,124 @@
declare namespace EjsTemplate {
type Features = {
/** 是否启用扩展 */
enabled: boolean;
/** 处理生成内容 */
generate_enabled: boolean;
/** 生成时注入 [GENERATE] 世界书条目 */
generate_loader_enabled: boolean;
/** 生成时注入 @INJECT 世界书条目 */
inject_loader_enabled: boolean;
/** 处理楼层消息 */
render_enabled: boolean;
/** 渲染楼层时注入 [RENDER] 世界书条目 */
render_loader_enabled: boolean;
/** 处理代码块 */
code_blocks_enabled: boolean;
/** 处理原始消息内容 */
raw_message_evaluation_enabled: boolean;
/** 生成时忽略楼层消息处理 */
filter_message_enabled: boolean;
/** 处理楼层深度限制 (-1=无限制) */
depth_limit: number;
/** 自动保存变量更新 */
autosave_enabled: boolean;
/** 立即加载世界书 */
preload_worldinfo_enabled: boolean;
/** 禁用 with 语句块 */
with_context_disabled: boolean;
/** 控制台显示详细信息 */
debug_enabled: boolean;
/** 旧设定兼容模式,世界书中的 GENERATE/RENDER/INJECT 条目禁用时视为启用 */
invert_enabled: boolean;
/** 是否启用后台编译 (用 Web Workers 编译) */
compile_workers: boolean;
/** 是否启用沙盒执行代码 (性能下降, 提升安全性) */
sandbox: boolean;
/** 缓存 (实验性) (0=禁用, 1=全部, 2=仅世界书) */
cache_enabled: number;
/** 缓存大小 */
cache_size: number;
/** 缓存 Hash 函数 */
cache_hasher: 'h32ToString' | 'h64ToString';
};
}
/**
* 提示词模板语法插件所提供的额外功能, 必须额外安装提示词模板语法插件, 具体内容见于 https://github.com/zonde306/ST-Prompt-Template
* 你也可以在酒馆页面按 f12, 在控制台中输入 `window.EjsTemplate` 来查看当前提示词模板语法所提供的接口
*/
declare const EjsTemplate: {
/**
* 对文本进行模板语法处理
* @note `context` 一般从 `prepareContext` 获取, 若要修改则应直接修改原始对象
*
* @param code 模板代码
* @param context 执行环境 (上下文)
* @param options ejs 参数
* @returns 对模板进行计算后的内容
*
* @example
* // 使用提示词模板语法插件提供的函数创建一个临时的酒馆正则, 对消息楼层进行一次处理
* await EjsTemplate.evalTemplate('<%_ await activateRegex(/<thinking>.*?<\/thinking>/gs, '') _%>')
*
* @example
* const env = await EjsTemplate.prepareContext({ a: 1 });
* const result = await EjsTemplate.evalTemplate('a is <%= a _%>', env);
* => result === 'a is 1'
* // 但这种用法更推荐用 _.template 来做, 具体见于 https://lodash.com/docs/4.17.15#template
* const compiled = _.template('hello <%= user %>!');
* const result = compiled({ 'user': 'fred' });;
* => result === 'hello user!'
*/
evaltemplate: (code: string, context?: Record<string, any>, options?: Record<string, any>) => Promise<string>;
/**
* 创建模板语法处理使用的执行环境 (上下文)
*
* @param additional_context 附加的执行环境 (上下文)
* @param last_message_id 合并消息变量的最大 ID; 默认为所有
* @returns 执行环境 (上下文)
*/
prepareContext: (additional_context?: Record<string, any>, last_message_id?: number) => Promise<Record<string, any>>;
/**
* 检查模板是否存在语法错误
* 并不会实际执行
*
* @param content 模板代码
* @param output_line_count 发生错误时输出的附近行数; 默认为 4
* @returns 语法错误信息, 无错误返回空字符串
*/
getSyntaxErrorInfo: (code: string, output_line_count?: number) => Promise<string>;
/**
* 获取全局变量、聊天变量、消息楼层变量的并集
*
* @param end_message_id 要合并的消息楼层变量最大楼层数
* @returns 合并后的变量
*/
allVariables: (end_message_id?: number) => Record<string, any>;
/**
* 获取提示词模板语法插件的设置
*
* @returns 设置情况
*/
getFeatures: () => EjsTemplate.Features;
/**
* 设置提示词模板语法插件的设置
*
* @param features 设置
*/
setFeatures: (features: Partial<EjsTemplate.Features>) => void;
/**
* 重置提示词模板语法插件的设置
*/
resetFeatures: () => void;
};

View File

@@ -0,0 +1,189 @@
declare namespace Mvu {
type MvuData = {
/** 已被 mvu 初始化 initvar 条目的世界书列表 */
initialized_lorebooks: Record<string, any[]>;
/** 实际的变量数据 */
stat_data: Record<string, any>;
[key: string]: any;
};
type CommandInfo = SetCommandInfo | InsertCommandInfo | DeleteCommandInfo | AddCommandInfo | MoveCommandInfo;
type SetCommandInfo = {
type: 'set';
full_match: string;
args:
| [path: string, new_value_literal: string]
| [path: string, expected_old_value_literal: string, new_value_literal: string];
reason: string;
};
type InsertCommandInfo = {
type: 'insert';
full_match: string;
args:
| [path: string, value_literal: string] // 在尾部追加值
| [path: string, index_or_key_literal: string, value_literal: string]; // 在指定索引/键处插入值
reason: string;
};
type DeleteCommandInfo = {
type: 'delete';
full_match: string;
args: [path: string] | [path: string, index_or_key_or_value_literal: string];
reason: string;
};
type AddCommandInfo = {
type: 'add';
full_match: string;
args: [path: string, delta_or_toggle_literal: string];
reason: string;
};
type MoveCommandInfo = {
type: 'move';
full_match: string;
args: [from: string, to: string];
reason: string;
};
}
/**
* mvu 变量框架脚本提供的额外功能, 必须额外安装 mvu 变量框架脚本, 具体内容见于 https://github.com/MagicalAstrogy/MagVarUpdate/blob/master/src/export_globals.ts
* **在使用它之前, 你应该先通过 `await waitGlobalInitialized('Mvu')` 来等待 Mvu 初始化完毕**
* 你也可以在酒馆页面按 f12, 在控制台中输入 `window.Mvu` 来查看当前 Mvu 变量框架所提供的接口
*/
declare const Mvu: {
events: {
/** 新开聊天对变量初始化时触发的事件 */
VARIABLE_INITIALIZED: 'mag_variable_initiailized';
/** 某轮变量更新开始时触发的事件 */
VARIABLE_UPDATE_STARTED: 'mag_variable_update_started';
/**
* 某轮变量更新过程中, 对文本成功解析了所有更新命令时触发的事件
*
* @example
* // 修复 gemini 在中文间加入的 '-'', 如将 '角色.络-络' 修复为 '角色.络络'
* eventOn(Mvu.events.COMMAND_PARSED, commands => {
* commands.forEach(command => {
* command.args[0] = command.args[0].replace(/-/g, '');
* });
* });
*
* @example
* // 修复繁体字, 如将 '絡絡' 修复为 '络络'
* eventOn(Mvu.events.COMMAND_PARSED, commands => {
* commands.forEach(command => {
* command.args[0] = command.args[0].replaceAll('絡絡', '络络');
* });
* });
*
* @example
* // 添加新的更新命令
* eventOn(Mvu.events.COMMAND_PARSED, commands => {
* commands.push({
* type: 'set',
* full_match: `_.set('络络.好感度', 5)`,
* args: ['络络.好感度', 5],
* reason: '脚本强行更新',
* });
* });
*/
COMMAND_PARSED: 'mag_command_parsed';
/**
* 某轮变量更新结束时触发的事件
*
* @example
* // 保持好感度不低于 0
* eventOn(Mvu.events.VARIABLE_UPDATE_ENDED, variables => {
* if (_.get(variables, 'stat_data.角色.络络.好感度') < 0) {
* _.set(variables, 'stat_data.角色.络络.好感度', 0);
* }
* })
*
* @example
* // 保持好感度增幅不超过 3
* eventOn(Mvu.events.VARIABLE_UPDATE_ENDED, (variables, variables_before_update) => {
* const old_value = _.get(variables_before_update, 'stat_data.角色.络络.好感度');
* const new_value = _.get(variables, 'stat_data.角色.络络.好感度');
*
* // 新的好感度必须在 旧好感度-3 和 旧好感度+3 之间
* _.set(variables, 'stat_data.角色.络络.好感度', _.clamp(new_value, old_value - 3, old_value + 3));
* });
*/
VARIABLE_UPDATE_ENDED: 'mag_variable_update_ended';
/** 即将用更新后的变量更新楼层时触发的事件 */
BEFORE_MESSAGE_UPDATE: 'mag_before_message_update';
};
/**
* 获取变量表, 并将其视为包含 mvu 数据的 MvuData
*
* @param 可选选项
* - `type?:'message'|'chat'|'character'|'global'`: 对某一楼层的聊天变量 (`message`)、聊天变量表 (`'chat'`)、角色卡变量 (`'character'`) 或全局变量表 (`'global'`) 进行操作, 默认为 `'chat'`
* - `message_id?:number|'latest'`: 当 `type` 为 `'message'` 时, 该参数指定要获取的消息楼层号, 如果为负数则为深度索引, 例如 `-1` 表示获取最新的消息楼层; 默认为 `'latest'`
* - `script_id?:string`: 当 `type` 为 `'script'` 时, 该参数指定要获取的脚本 ID; 如果在脚本内调用, 则你可以用 `getScriptId()` 获取该脚本 ID
*
* @returns MvuData 数据表
*
* @example
* // 获取最新消息楼层的 mvu 数据
* const message_data = Mvu.getMvuData({ type: 'message', message_id: 'latest' });
*
* // 在消息楼层 iframe 内获取该 iframe 所在楼层的 mvu 数据
* const message_data = Mvu.getMvuData({ type: 'message', message_id: getCurrentMessageId() });
*/
getMvuData: (options: VariableOption) => Mvu.MvuData;
/**
* 完全替换变量表为包含 mvu 数据的 `mvu_data` (但如果没用 `parseMessages` 自行处理变量, 则更建议监听 mvu 事件来修改 mvu 数据!)
*
* @param variables 要用于替换的变量表
* @param option 可选选项
* - `type?:'message'|'chat'|'character'|'global'`: 对某一楼层的聊天变量 (`message`)、聊天变量表 (`'chat'`)、角色卡变量 (`'character'`) 或全局变量表 (`'global'`) 进行操作, 默认为 `'chat'`
* - `message_id?:number|'latest'`: 当 `type` 为 `'message'` 时, 该参数指定要获取的消息楼层号, 如果为负数则为深度索引, 例如 `-1` 表示获取最新的消息楼层; 默认为 `'latest'`
* - `script_id?:string`: 当 `type` 为 `'script'` 时, 该参数指定要获取的脚本 ID; 如果在脚本内调用, 则你可以用 `getScriptId()` 获取该脚本 ID
*
* @example
* // 修改络络好感度为 30
* const mvu_data = Mvu.getMvuData({ type: 'message', message_id: 'latest' });
* _.set(mvu_data, 'stat_data.角色.络络.好感度', 30);
* await Mvu.replaceMvuData(mvu_data, { type: 'message', message_id: 'latest' });
*/
replaceMvuData: (mvu_data: Mvu.MvuData, options: VariableOption) => Promise<void>;
/**
* 解析包含变量更新命令 (`_.set`) 的消息 `message`, 根据它更新 `old_data` 中的 mvu 变量数据
*
* @param message 包含 _.set() 命令的消息字符串
* @param old_data 当前的 MvuData 数据
*
* @returns 如果有变量被更新则返回新的 MvuData, 否则返回 `undefined`
*
* @example
* // 修改络络好感度为 30
* const old_data = Mvu.getMvuData({ type: 'message', message_id: 'latest' });
* const new_data = await Mvu.parseMessage("_.set('角色.络络.好感度', 30); // 强制修改", old_data);
* await Mvu.replaceMvuData(new_data, { type: 'message', message_id: 'latest' });
*/
parseMessage: (message: string, old_data: Mvu.MvuData) => Promise<Mvu.MvuData>;
/**
* 酒馆是否正在进行额外模型解析
*/
isDuringExtraAnalysis: () => boolean;
};
interface ListenerType {
[Mvu.events.VARIABLE_INITIALIZED]: (variables: Mvu.MvuData, swipe_id: number) => void;
[Mvu.events.VARIABLE_UPDATE_STARTED]: (variables: Mvu.MvuData) => void;
[Mvu.events.COMMAND_PARSED]: (variables: Mvu.MvuData, commands: Mvu.CommandInfo[], message_content: string) => void;
[Mvu.events.VARIABLE_UPDATE_ENDED]: (variables: Mvu.MvuData, variables_before_update: Mvu.MvuData) => void;
[Mvu.events.BEFORE_MESSAGE_UPDATE]: (context: { variables: Mvu.MvuData; message_content: string }) => void;
}

View File

@@ -0,0 +1,697 @@
/* eslint-disable @typescript-eslint/no-unsafe-function-type */
declare namespace SillyTavern {
type ChatMessage = {
name: string;
/**
* 实际的 role 为:
* - 'system': extra?.type === 'narrator' && !is_user
* - 'user': extra?.type !== 'narrator' && is_user
* - 'assistant': extra?.type !== 'narrator' && !is_user
*/
is_user: boolean;
/**
* 实际是表示消息是否被隐藏不会发给 llm
*/
is_system: boolean;
mes: string;
swipe_id?: number;
swipes?: string[];
swipe_info?: Record<string, any>[];
extra?: Record<string, any>;
variables?: Record<string, any>[] | { [swipe_id: number]: Record<string, any> };
};
type SendingMessage = {
role: 'user' | 'assistant' | 'system';
content:
| string
| Array<
| { type: 'text'; text: string }
| { type: 'image_url'; image_url: { url: string; detail: 'auto' | 'low' | 'high' } }
| { type: 'video_url'; video_url: { url: string } }
>;
};
type FlattenedWorldInfoEntry = {
uid: number;
displayIndex: number;
comment?: string;
disable: boolean;
constant: boolean;
selective: boolean;
key: string[];
/** 0: and_any, 1: not_all, 2: not_any, 3: and_all */
selectiveLogic: 0 | 1 | 2 | 3;
keysecondary: string[];
scanDepth: number | null;
vectorized: boolean;
position: 0 | 1 | 2 | 3 | 4 | 5 | 6;
/** 0: system, 1: user, 2: assistant */
role: 0 | 1 | 2 | null;
depth: number;
order: number;
content: string;
useProbability: boolean;
probability: number;
excludeRecursion: boolean;
preventRecursion: boolean;
delayUntilRecursion: boolean | number;
sticky: number | null;
cooldown: number | null;
delay: number | null;
extra?: Record<string, any>;
};
/**
* V1 character data structure.
*/
type v1CharData = {
/** the name of the character */
name: string;
/** the description of the character */
description: string;
/** a short personality description of the character */
personality: string;
/** a scenario description of the character */
scenario: string;
/** the first message in the conversation */
first_mes: string;
/** the example message in the conversation */
mes_example: string;
/** creator's notes of the character */
creatorcomment: string;
/** the tags of the character */
tags: string[];
/** talkativeness */
talkativeness: number;
/** fav */
fav: boolean | string;
/** create_date */
create_date: string;
/** v2 data extension */
data: v2CharData;
// Non-standard extensions added by the ST server (not part of the original data)
/** name of the current chat file chat */
chat: string;
/** file name of the avatar image (acts as a unique identifier) */
avatar: string;
/** the full raw JSON data of the character */
json_data: string;
/** if the data is shallow (lazy-loaded) */
shallow?: boolean;
};
/**
* V2 character data structure.
*/
type v2CharData = {
/** The character's name. */
name: string;
/** A brief description of the character. */
description: string;
/** The character's data version. */
character_version: string;
/** A short summary of the character's personality traits. */
personality: string;
/** A description of the character's background or setting. */
scenario: string;
/** The character's opening message in a conversation. */
first_mes: string;
/** An example message demonstrating the character's conversation style. */
mes_example: string;
/** Internal notes or comments left by the character's creator. */
creator_notes: string;
/** A list of keywords or labels associated with the character. */
tags: string[];
/** The system prompt used to interact with the character. */
system_prompt: string;
/** Instructions for handling the character's conversation history. */
post_history_instructions: string;
/** The name of the person who created the character. */
creator: string;
/** Additional greeting messages the character can use. */
alternate_greetings: string[];
/** Data about the character's world or story (if applicable). */
character_book: v2WorldInfoBook;
/** Additional details specific to the character. */
extensions: v2CharDataExtensionInfos;
};
/**
* A world info book containing entries.
*/
type v2WorldInfoBook = {
/** the name of the book */
name: string;
/** the entries of the book */
entries: v2DataWorldInfoEntry[];
};
/**
* A world info entry object.
*/
type v2DataWorldInfoEntry = {
/** An array of primary keys associated with the entry. */
keys: string[];
/** An array of secondary keys associated with the entry (optional). */
secondary_keys: string[];
/** A human-readable description or explanation for the entry. */
comment: string;
/** The main content or data associated with the entry. */
content: string;
/** Indicates if the entry's content is fixed and unchangeable. */
constant: boolean;
/** Indicates if the entry's inclusion is controlled by specific conditions. */
selective: boolean;
/** Defines the order in which the entry is inserted during processing. */
insertion_order: number;
/** Controls whether the entry is currently active and used. */
enabled: boolean;
/** Specifies the location or context where the entry applies. */
position: string;
/** An object containing additional details for extensions associated with the entry. */
extensions: v2DataWorldInfoEntryExtensionInfos;
/** A unique identifier assigned to the entry. */
id: number;
};
/**
* An object containing additional details for extensions associated with the entry.
*/
type v2DataWorldInfoEntryExtensionInfos = {
/** The order in which the extension is applied relative to other extensions. */
position: number;
/** Prevents the extension from being applied recursively. */
exclude_recursion: boolean;
/** The chance (between 0 and 1) of the extension being applied. */
probability: number;
/** Determines if the `probability` property is used. */
useProbability: boolean;
/** The maximum level of nesting allowed for recursive application of the extension. */
depth: number;
/** Defines the logic used to determine if the extension is applied selectively. */
selectiveLogic: number;
/** A category or grouping for the extension. */
group: string;
/** Overrides any existing group assignment for the extension. */
group_override: boolean;
/** A value used for prioritizing extensions within the same group. */
group_weight: number;
/** Completely disallows recursive application of the extension. */
prevent_recursion: boolean;
/** Will only be checked during recursion. */
delay_until_recursion: boolean;
/** The maximum depth to search for matches when applying the extension. */
scan_depth: number;
/** Specifies if only entire words should be matched during extension application. */
match_whole_words: boolean;
/** Indicates if group weight is considered when selecting extensions. */
use_group_scoring: boolean;
/** Controls whether case sensitivity is applied during matching for the extension. */
case_sensitive: boolean;
/** An identifier used for automation purposes related to the extension. */
automation_id: string;
/** The specific function or purpose of the extension. */
role: number;
/** Indicates if the extension is optimized for vectorized processing. */
vectorized: boolean;
/** The order in which the extension should be displayed for user interfaces. */
display_index: number;
/** Wether to match against the persona description. */
match_persona_description: boolean;
/** Wether to match against the persona description. */
match_character_description: boolean;
/** Wether to match against the character personality. */
match_character_personality: boolean;
/** Wether to match against the character depth prompt. */
match_character_depth_prompt: boolean;
/** Wether to match against the character scenario. */
match_scenario: boolean;
/** Wether to match against the character creator notes. */
match_creator_notes: boolean;
};
/**
* Additional details specific to the character.
*/
type v2CharDataExtensionInfos = {
/** A numerical value indicating the character's propensity to talk. */
talkativeness: number;
/** A flag indicating whether the character is a favorite. */
fav: boolean;
/** The fictional world or setting where the character exists (if applicable). */
world: string;
/** Prompts used to explore the character's depth and complexity. */
depth_prompt: {
/** The level of detail or nuance targeted by the prompt. */
depth: number;
/** The actual prompt text used for deeper character interaction. */
prompt: string;
/** The role the character takes on during the prompted interaction (system, user, or assistant). */
role: 'system' | 'user' | 'assistant';
};
/** Custom regex scripts for the character. */
regex_scripts: RegexScriptData[];
// Non-standard extensions added by external tools
/** The unique identifier assigned to the character by the Pygmalion.chat. */
pygmalion_id?: string;
/** The gitHub repository associated with the character. */
github_repo?: string;
/** The source URL associated with the character. */
source_url?: string;
/** The Chub-specific data associated with the character. */
chub?: { full_path: string };
/** The RisuAI-specific data associated with the character. */
risuai?: { source: string[] };
/** SD-specific data associated with the character. */
sd_character_prompt?: { positive: string; negative: string };
};
/**
* Regex script data for character processing.
*/
type RegexScriptData = {
/** UUID of the script */
id: string;
/** The name of the script */
scriptName: string;
/** The regex to find */
findRegex: string;
/** The string to replace */
replaceString: string;
/** The strings to trim */
trimStrings: string[];
/** The placement of the script */
placement: number[];
/** Whether the script is disabled */
disabled: boolean;
/** Whether the script only applies to Markdown */
markdownOnly: boolean;
/** Whether the script only applies to prompts */
promptOnly: boolean;
/** Whether the script runs on edit */
runOnEdit: boolean;
/** Whether the regex should be substituted */
substituteRegex: number;
/** The minimum depth */
minDepth: number;
/** The maximum depth */
maxDepth: number;
};
type PopupOptions = {
/** Custom text for the OK button, or `true` to use the default (If set, the button will always be displayed, no matter the type of popup) */
okButton?: string | boolean;
/** Custom text for the Cancel button, or `true` to use the default (If set, the button will always be displayed, no matter the type of popup) */
cancelButton?: string | boolean;
/** The number of rows for the input field */
rows?: number;
/** Whether to display the popup in wide mode (wide screen, 1/1 aspect ratio) */
wide?: boolean;
/** Whether to display the popup in wider mode (just wider, no height scaling) */
wider?: boolean;
/** Whether to display the popup in large mode (90% of screen) */
large?: boolean;
/** Whether to display the popup in transparent mode (no background, border, shadow or anything, only its content) */
transparent?: boolean;
/** Whether to allow horizontal scrolling in the popup */
allowHorizontalScrolling?: boolean;
/** Whether to allow vertical scrolling in the popup */
allowVerticalScrolling?: boolean;
/** Whether the popup content should be left-aligned by default */
leftAlign?: boolean;
/** Animation speed for the popup (opening, closing, ...) */
animation?: 'slow' | 'fast' | 'none';
/** The default result of this popup when Enter is pressed. Can be changed from `POPUP_RESULT.AFFIRMATIVE`. */
defaultResult?: number;
/** Custom buttons to add to the popup. If only strings are provided, the buttons will be added with default options, and their result will be in order from `2` onward. */
customButtons?: CustomPopupButton[] | string[];
/** Custom inputs to add to the popup. The display below the content and the input box, one by one. */
customInputs?: CustomPopupInput[];
/** Handler called before the popup closes, return `false` to cancel the close */
onClosing?: (popup: InstanceType<typeof SillyTavern.Popup>) => Promise<boolean | void>;
/** Handler called after the popup closes, but before the DOM is cleaned up */
onClose?: (popup: InstanceType<typeof SillyTavern.Popup>) => Promise<void>;
/** Handler called after the popup opens */
onOpen?: (popup: InstanceType<typeof SillyTavern.Popup>) => Promise<void>;
/** Aspect ratio for the crop popup */
cropAspect?: number;
/** Image URL to display in the crop popup */
cropImage?: string;
};
type CustomPopupButton = {
/** The text of the button */
text: string;
/** The result of the button - can also be a custom result value to make be able to find out that this button was clicked. If no result is specified, this button will **not** close the popup. */
result?: number;
/** Optional custom CSS classes applied to the button */
classes?: string[] | string;
/** Optional action to perform when the button is clicked */
action?: () => void;
/** Whether to append the button to the end of the popup - by default it will be prepended */
appendAtEnd?: boolean;
};
type CustomPopupInput = {
/** The id for the html element */
id: string;
/** The label text for the input */
label: string;
/** Optional tooltip icon displayed behind the label */
tooltip?: string;
/** The default state when opening the popup (false if not set) */
defaultState?: boolean;
/** The type of the input (default is checkbox) */
type?: string;
};
}
/**
* 酒馆提供给插件的稳定接口, 具体内容见于 SillyTavern/public/scripts/st-context.js 或 https://github.com/SillyTavern/SillyTavern/blob/release/public/scripts/st-context.js
* 你也可以在酒馆页面按 f12, 在控制台中输入 `window.SillyTavern.getContext()` 来查看当前酒馆所提供的接口
*/
declare const SillyTavern: {
readonly accountStorage: any;
readonly chat: Array<SillyTavern.ChatMessage>;
readonly characters: SillyTavern.v1CharData[];
readonly groups: any;
readonly name1: string;
readonly name2: string;
/* this_chid */
readonly characterId: string;
readonly groupId: string;
readonly chatId: string;
readonly getCurrentChatId: () => string;
readonly getRequestHeaders: () => {
'Content-Type': string;
'X-CSRF-TOKEN': string;
};
readonly reloadCurrentChat: () => Promise<void>;
readonly renameChat: (old_name: string, new_name: string) => Promise<void>;
readonly saveSettingsDebounced: () => Promise<void>;
readonly onlineStatus: string;
readonly maxContext: number;
/** chat_metadata */
readonly chatMetadata: Record<string, any>;
readonly streamingProcessor: any;
readonly eventSource: {
on: typeof eventOn;
makeLast: typeof eventMakeLast;
makeFirst: typeof eventMakeFirst;
removeListener: typeof eventRemoveListener;
emit: typeof eventEmit;
emitAndWait: typeof eventEmitAndWait;
once: typeof eventOnce;
};
readonly eventTypes: typeof tavern_events;
readonly addOneMessage: (
mes: SillyTavern.ChatMessage,
options?: {
type?: 'swipe';
insertAfter?: number;
scroll?: true;
insertBefore?: number;
forceId?: number;
showSwipes?: boolean;
},
) => JQuery<HTMLElement>;
readonly deleteLastMessage: () => Promise<void>;
readonly generate: Function;
readonly sendStreamingRequest: (type: string, data: object) => Promise<void>;
readonly sendGenerationRequest: (type: string, data: object) => Promise<void>;
readonly stopGeneration: () => boolean;
readonly tokenizers: any;
readonly getTextTokens: (tokenizer_type: number, string: string) => Promise<number>;
readonly getTokenCountAsync: (string: string, padding?: number | undefined) => Promise<number>;
/** `/inject`、`setExtensionPrompt` 等注入的所有额外提示词 */
readonly extensionPrompts: Record<
string,
{
value: string;
position: number;
depth: number;
scan: boolean;
role: number;
filter: () => Promise<boolean> | boolean;
}
>;
/**
* 注入一段额外的提示词
*
* @param prompt_id id, 重复则会替换原本的内容
* @param content 内容
* @param position 位置. -1 为不注入 (配合 scan=true 来仅用于激活绿灯), 1 为插入到聊天中
* @param depth 深度
* @param scan 是否作为欲扫描文本, 加入世界书绿灯条目扫描文本中
* @param role 消息角色. 0 为 system, 1 为 user, 2 为 assistant
* @param filter 提示词在什么情况下启用
*/
readonly setExtensionPrompt: (
prompt_id: string,
content: string,
position: -1 | 1,
depth: number,
scan?: boolean,
role?: number,
filter?: () => Promise<boolean> | boolean,
) => Promise<void>;
readonly updateChatMetadata: (new_values: any, reset: boolean) => void;
readonly saveChat: () => Promise<void>;
readonly openCharacterChat: (file_name: any) => Promise<void>;
readonly openGroupChat: (group_id: any, chat_id: any) => Promise<void>;
readonly saveMetadata: () => Promise<void>;
readonly sendSystemMessage: (type: any, text: any, extra?: any) => Promise<void>;
readonly activateSendButtons: () => void;
readonly deactivateSendButtons: () => void;
readonly saveReply: (options: any, ...args: any[]) => Promise<void>;
readonly substituteParams: (
content: string,
name1?: string,
name2?: string,
original?: string,
group?: string,
replace_character_card?: boolean,
additional_macro?: Record<string, any>,
post_process_function?: (text: string) => string,
) => Promise<void>;
readonly substituteParamsExtended: (
content: string,
additional_macro?: Record<string, any>,
post_process_function?: (text: string) => string,
) => Promise<void>;
readonly SlashCommandParser: any;
readonly SlashCommand: any;
readonly SlashCommandArgument: any;
readonly SlashCommandNamedArgument: any;
readonly ARGUMENT_TYPE: {
STRING: string;
NUMBER: string;
RANGE: string;
BOOLEAN: string;
VARIABLE_NAME: string;
CLOSURE: string;
SUBCOMMAND: string;
LIST: string;
DICTIONARY: string;
};
readonly executeSlashCommandsWithOptions: (
text: string,
options?: any,
) => Promise<{
interrupt: boolean;
pipe: string;
isBreak: boolean;
isAborted: boolean;
isQuietlyAborted: boolean;
abortReason: string;
isError: boolean;
errorMessage: string;
}>;
readonly timestampToMoment: (timestamp: string | number) => any;
readonly registerMacro: (key: string, value: string | ((uid: string) => string), description?: string) => void;
readonly unregisterMacro: (key: string) => void;
readonly registerFunctionTool: (tool: {
/** 工具名称 */
name: string;
/** 工具显示名称 */
displayName: string;
/** 工具描述 */
description: string;
/** 对函数参数的 JSON schema 定义, 可以通过 zod 的 z.toJSONSchema 来得到 */
parameters: Record<string, any>;
/** 要注册的函数调用工具 */
action: ((args: any) => string) | ((args: any) => Promise<string>);
/** 要如何格式化函数调用结果消息; 默认不进行任何操作, 显示为 `'Invoking tool: 工具显示名称'` */
formatMessage?: (args: any) => string;
/** 在下次聊天补全请求时是否注册本工具; 默认为始终注册 */
shouldRegister?: (() => boolean) | (() => Promise<boolean>);
/** 是否不在楼层中用一层楼显示函数调用结果, `true` 则不显示且将不会触发生成; 默认为 false */
stealth?: boolean;
}) => void;
readonly unregisterFunctionTool: (name: string) => void;
readonly isToolCallingSupported: () => boolean;
readonly canPerformToolCalls: (type: string) => boolean;
readonly ToolManager: any;
readonly registerDebugFunction: (function_id: string, name: string, description: string, fn: Function) => void;
readonly renderExtensionTemplateAsync: (
extension_name: string,
template_id: string,
template_data?: object,
sanitize?: boolean,
localize?: boolean,
) => Promise<string>;
readonly registerDataBankScraper: (scraper: any) => Promise<void>;
readonly showLoader: () => void;
readonly hideLoader: () => Promise<any>;
readonly mainApi: any;
/** extension_settings */
readonly extensionSettings: Record<string, any>;
readonly ModuleWorkerWrapper: any;
readonly getTokenizerModel: () => string;
readonly generateQuietPrompt: () => (
quiet_prompt: string,
quiet_to_loud: boolean,
skip_wian: boolean,
quiet_iamge?: string,
quiet_name?: string,
response_length?: number,
force_chid?: number,
) => Promise<string>;
/** 严禁使用本方法修改角色卡的 `extensions` 字段, 它会合并原有值和新值而不是替换; 应该使用 `updateCharacterWith` */
readonly writeExtensionField: (character_id: number, key: string, value: any) => Promise<void>;
readonly getThumbnailUrl: (type: any, file: any) => string;
readonly selectCharacterById: (id: number, { switchMenu }?: { switchMenu?: boolean }) => Promise<void>;
readonly messageFormatting: (
message: string,
ch_name: string,
is_system: boolean,
is_user: boolean,
message_id: number,
sanitizerOverrides?: object,
isReasoning?: boolean,
) => string;
readonly shouldSendOnEnter: () => boolean;
readonly isMobile: () => boolean;
readonly t: (strings: string, ...values: any[]) => string;
readonly translate: (text: string, key?: string | null) => string;
readonly getCurrentLocale: () => string;
readonly addLocaleData: (localeId: string, data: Record<string, string>) => void;
readonly tags: any[];
readonly tagMap: {
[identifier: string]: string[];
};
readonly menuType: any;
readonly createCharacterData: Record<string, any>;
readonly Popup: {
new (
content: JQuery<HTMLElement> | string | Element,
type: number,
inputValue?: string,
popupOptions?: SillyTavern.PopupOptions,
): {
dlg: HTMLDialogElement;
show: () => Promise<void>;
complete: (result: number) => Promise<void>;
completeAffirmative: () => Promise<void>;
completeNegative: () => Promise<void>;
completeCancelled: () => Promise<void>;
};
};
readonly POPUP_TYPE: {
TEXT: number;
CONFIRM: number;
INPUT: number;
DISPLAY: number;
CROP: number;
};
readonly POPUP_RESULT: {
AFFIRMATIVE: number;
NEGATIVE: number;
CANCELLED: number;
CUSTOM1: number;
CUSTOM2: number;
CUSTOM3: number;
CUSTOM4: number;
CUSTOM5: number;
CUSTOM6: number;
CUSTOM7: number;
CUSTOM8: number;
CUSTOM9: number;
};
readonly callGenericPopup: (
content: JQuery<HTMLElement> | string | Element,
type: number,
inputValue?: string,
popupOptions?: SillyTavern.PopupOptions,
) => Promise<number | string | boolean | undefined>;
/** oai_settings */
readonly chatCompletionSettings: any;
/** textgenerationwebui_settings */
readonly textCompletionSettings: any;
/** power_user */
readonly powerUserSettings: any;
readonly getCharacters: () => Promise<void>;
readonly getCharacterCardFields: ({ chid }?: { chid?: number }) => any;
readonly uuidv4: () => string;
readonly humanizedDateTime: () => string;
readonly updateMessageBlock: (
message_id: number,
message: object,
{ rerenderMessage }?: { rerenderMessage?: boolean },
) => void;
readonly appendMediaToMessage: (mes: object, messageElement: JQuery<HTMLElement>, adjust_scroll?: boolean) => void;
readonly loadWorldInfo: (name: string) => Promise<any | null>;
readonly saveWorldInfo: (name: string, data: any, immediately?: boolean) => Promise<void>;
/** reloadEditor */
readonly reloadWorldInfoEditor: (file: string, loadIfNotSelected?: boolean) => void;
readonly updateWorldInfoList: () => Promise<void>;
readonly convertCharacterBook: (character_book: any) => {
entries: Record<string, any>;
originalData: Record<string, any>;
};
readonly getWorldInfoPrompt: (
chat: string[],
max_context: number,
is_dry_run: boolean,
) => Promise<{
worldInfoString: string;
worldInfoBefore: string;
worldInfoAfter: string;
worldInfoExamples: any[];
worldInfoDepth: any[];
anBefore: any[];
anAfter: any[];
}>;
readonly CONNECT_API_MAP: Record<string, any>;
readonly getTextGenServer: (type?: string) => string;
readonly extractMessageFromData: (data: object, activateApi?: string) => string;
readonly getPresetManager: (apiId?: string) => any;
readonly getChatCompletionModel: (source?: string) => string;
readonly printMessages: () => Promise<void>;
readonly clearChat: () => Promise<void>;
readonly ChatCompletionService: any;
readonly TextCompletionService: any;
readonly ConnectionManagerRequestService: any;
readonly updateReasoningUI: (
message_id_or_element: number | JQuery<HTMLElement> | HTMLElement,
{ reset }?: { reset?: boolean },
) => void;
readonly parseReasoningFromString: (string: string, { strict }?: { strict?: boolean }) => any | null;
readonly unshallowCharacter: (character_id?: string) => Promise<void>;
readonly unshallowGroupMembers: (group_id: string) => Promise<void>;
readonly symbols: {
ignore: any;
};
};

View File

@@ -0,0 +1,5 @@
/**
* 酒馆助手提供的额外功能, 具体内容见于 https://n0vi028.github.io/JS-Slash-Runner-Doc
* 你也可以在酒馆页面按 f12, 在控制台中输入 `window.TavernHelper` 来查看当前酒馆助手所提供的接口
*/
declare const TavernHelper: typeof window.TavernHelper;

View File

@@ -0,0 +1,67 @@
/**
* 获取按钮对应的事件类型, **只能在脚本中使用**
*
* @param button_name 按钮名
* @returns 事件类型
*
* @example
* const event_type = getButtonEvent('按钮名');
* eventOn(event_type, () => {
* console.log('按钮被点击了');
* });
*/
declare function getButtonEvent(button_name: string): string;
type ScriptButton = {
name: string;
visible: boolean;
};
/**
* 获取脚本的按钮列表, **只能在脚本中使用**
*
* @returns 按钮数组
*
* @example
* // 在脚本内获取当前脚本的按钮设置
* const buttons = getScriptButtons();
*/
declare function getScriptButtons(): ScriptButton[];
/**
* 完全替换脚本的按钮列表, **只能在脚本中使用**
*
* @param buttons 按钮数组
*
* @example
* // 在脚本内设置脚本按钮为一个"开始游戏"按钮
* replaceScriptButtons([{name: '开始游戏', visible: true}])
*
* @example
* // 点击"前往地点"按钮后,切换为地点选项按钮
* eventOnButton("前往地点" () => {
* replaceScriptButtons([{name: '学校', visible: true}, {name: '商店', visible: true}])
* })
*/
declare function replaceScriptButtons(buttons: ScriptButton[]): void;
/**
* 为脚本按钮列表末尾添加不存在的按钮, 不会重复添加同名按钮, **只能在脚本中使用**
*
* @param buttons
*
* @exmaple
* // 新增 "重新开始" 按钮
* appendInexistentScriptButtons([{name: '重新开始', visible: true}]);
*/
declare function appendInexistentScriptButtons(buttons: ScriptButton[]): void;
/** 获取脚本作者注释 */
declare function getScriptInfo(): string;
/**
* 替换脚本作者注释
*
* @param info 新的作者注释
*/
declare function replaceScriptInfo(info: string): void;

View File

@@ -0,0 +1,55 @@
/**
* 在前端界面或脚本内使用, 从而重新加载前端界面或脚本
*
* 这相当于调用 `window.location.reload()`, 会让分享到全局的接口失效;
* 如果有需要在重新加载前端界面后沿用的数据, 你应该自行编写重新加载方式而不是使用这个函数
*
* @example
* // 当聊天文件变更时, 重新加载前端界面或脚本
* let current_chat_id = SillyTavern.getCurrentChatId();
* eventOn(tavern_events.CHAT_CHANGED, chat_id => {
* if (current_chat_id !== chat_id) {
* current_chat_id = chat_id;
* reloadIframe();
* }
* })
*
* @example
* // 自行编写重新加载方式
* function initailzie() { ... }
* $(initialize);
*
* function destroy() { eventClearAll(); ... }
* $(window).on('pagehide', destroy);
*
* function reload() {
* destory();
* initialize();
* }
*/
declare function reloadIframe(): void;
/**
* 获取前端界面或脚本的标识名称
*
* @returns 对于前端界面是 `TH-message--楼层号--前端界面是该楼层第几个界面`, 对于脚本库是 `TH-script--脚本名称--脚本id`
*/
declare function getIframeName(): string;
/**
* 获取本消息楼层 iframe 所在楼层的楼层 id, **只能对楼层消息 iframe** 使用
*
* @returns 楼层 id
*
* @throws 如果不在楼层消息 iframe 内使用, 将会抛出错误
*/
declare function getCurrentMessageId(): number;
/**
* 获取脚本的脚本库 id, **只能在脚本内使用**
*
* @returns 脚本库的 id
*
* @throws 如果不在脚本内使用, 将会抛出错误
*/
declare function getScriptId(): string;

View File

@@ -0,0 +1,9 @@
/**
* 获取合并后的变量表
* - 如果在消息楼层 iframe 中调用本函数, 则获取 全局→角色卡→聊天→0号消息楼层→中间所有消息楼层→当前消息楼层 的合并结果
* - 如果在全局变量 iframe 中调用本函数, 则获取 全局→角色卡→脚本→聊天→0号消息楼层→中间所有消息楼层→最新消息楼层 的合并结果
*
* @example
* const variables = getAllVariables();
*/
declare function getAllVariables(): Record<string, any>;

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,101 @@
Aladdin Free Public License (AFPL)
Version 9
By Aladdin Enterprises
Alternate Titles: Unknown.
Specific License Author Information: Unknown.
Length: 2,051 Words / 12.65 kB.
This text-file version of the license was generated by the Bakunin Cannabis Engine, Version 1.36, provided by Punkerslut Freethought.
AnarchistRevolt.com - The Theory and Practice of Revolutionary Anarchism.
Punkerslut Freethought provides information, resources, and ideas for changing the world -- everything from organizing worker unions to challenging the power of the state.
||| Aladdin Free Public License
----------------------------------------
----------------------------------------
-- Version 9
........................................
Copyright (C) 1994, 1995, 1997, 1998, 1999, 2000 Aladdin Enterprises, Menlo Park, California, U.S.A. All rights reserved.
NOTE: This License is not the same as any of the GNU Licenses published by the Free Software Foundation. Its terms are substantially different from those of the GNU Licenses. If you are familiar with the GNU Licenses, please read this license with extra care.
Aladdin Enterprises hereby grants to anyone the permission to apply this License to their own work, as long as the entire License (including the above notices and this paragraph) is copied with no changes, additions, or deletions except for changing the first paragraph of Section 0 to include a suitable description of the work to which the license is being applied and of the person or entity that holds the copyright in the work, and, if the License is being applied to a work created in a country other than the United States, replacing the first paragraph of Section 6 with an appropriate reference to the laws of the appropriate country.
This License is not an Open Source license: among other things, it places restrictions on distribution of the Program, specifically including sale of the Program. While Aladdin Enterprises respects and supports the philosophy of the Open Source Definition, and shares the desire of the GNU project to keep licensed software freely redistributable in both source and object form, we feel that Open Source licenses unfairly prevent developers of useful software from being compensated proportionately when others profit financially from their work. This License attempts to ensure that those who receive, redistribute, and contribute to the licensed Program according to the Open Source and Free Software philosophies have the right to do so, while retaining for the developer(s) of the Program the power to make those who use the Program to enhance the value of commercial products pay for the privilege of doing so.
* 0. Subject Matter *
This License applies to the computer programs known as "AFPL Ghostscript", "AFPL Ghostscript PCL5e", "AFPL Ghostscript PCL5c", and "AFPL Ghostscript PXL". The "Program", below, refers to such program. The Program is a copyrighted work whose copyright is held by Artifex Software Inc., located in San Rafael California and artofcode LLC, located in Benicia, California (the "Licensor"). Please note that AFPL Ghostscript is neither the program known as "GNU Ghostscript" nor the version of Ghostscript available for commercial licensing from Artifex Software Inc.
A "work based on the Program" means either the Program or any derivative work of the Program, as defined in the United States Copyright Act of 1976, such as a translation or a modification.
* BY MODIFYING OR DISTRIBUTING THE PROGRAM (OR ANY WORK BASED ON THE PROGRAM), YOU INDICATE YOUR ACCEPTANCE OF THIS LICENSE TO DO SO, AND ALL ITS TERMS AND CONDITIONS FOR COPYING, DISTRIBUTING OR MODIFYING THE PROGRAM OR WORKS BASED ON IT. NOTHING OTHER THAN THIS LICENSE GRANTS YOU PERMISSION TO MODIFY OR DISTRIBUTE THE PROGRAM OR ITS DERIVATIVE WORKS. THESE ACTIONS ARE PROHIBITED BY LAW. IF YOU DO NOT ACCEPT THESE TERMS AND CONDITIONS, DO NOT MODIFY OR DISTRIBUTE THE PROGRAM. *
* 1. Licenses. *
Licensor hereby grants you the following rights, provided that you comply with all of the restrictions set forth in this License and provided, further, that you distribute an unmodified copy of this License with the Program:
(a) You may copy and distribute literal (i.e., verbatim) copies of the Program's source code as you receive it throughout the world, in any medium.
(b) You may modify the Program, create works based on the Program and distribute copies of such throughout the world, in any medium.
* 2. Restrictions. *
This license is subject to the following restrictions:
(a) Distribution of the Program or any work based on the Program by a commercial organization to any third party is prohibited if any payment is made in connection with such distribution, whether directly (as in payment for a copy of the Program) or indirectly (as in payment for some service related to the Program, or payment for some product or service that includes a copy of the Program "without charge"; these are only examples, and not an exhaustive enumeration of prohibited activities). The following methods of distribution involving payment shall not in and of themselves be a violation of this restriction:
(i) Posting the Program on a public access information storage and retrieval service for which a fee is received for retrieving information (such as an on-line service), provided that the fee is not content-dependent (i.e., the fee would be the same for retrieving the same volume of information consisting of random data) and that access to the service and to the Program is available independent of any other product or service. An example of a service that does not fall under this section is an on-line service that is operated by a company and that is only available to customers of that company. (This is not an exhaustive enumeration.)
(ii) Distributing the Program on removable computer-readable media, provided that the files containing the Program are reproduced entirely and verbatim on such media, that all information on such media be redistributable for non-commercial purposes without charge, and that such media are distributed by themselves (except for accompanying documentation) independent of any other product or service. Examples of such media include CD-ROM, magnetic tape, and optical storage media. (This is not intended to be an exhaustive list.) An example of a distribution that does not fall under this section is a CD-ROM included in a book or magazine. (This is not an exhaustive enumeration.)
(b) Activities other than copying, distribution and modification of the Program are not subject to this License and they are outside its scope. Functional use (running) of the Program is not restricted, and any output produced through the use of the Program is subject to this license only if its contents constitute a work based on the Program (independent of having been made by running the Program).
(c) You must meet all of the following conditions with respect to any work that you distribute or publish that in whole or in part contains or is derived from the Program or any part thereof ("the Work"):
(i) If you have modified the Program, you must cause the Work to carry prominent notices stating that you have modified the Program's files and the date of any change. In each source file that you have modified, you must include a prominent notice that you have modified the file, including your name, your e-mail address (if any), and the date and purpose of the change;
(ii) You must cause the Work to be licensed as a whole and at no charge to all third parties under the terms of this License;
(iii) If the Work normally reads commands interactively when run, you must cause it, at each time the Work commences operation, to print or display an announcement including an appropriate copyright notice and a notice that there is no warranty (or else, saying that you provide a warranty). Such notice must also state that users may redistribute the Work only under the conditions of this License and tell the user how to view the copy of this License included with the Work. (Exceptions: if the Program is interactive but normally prints or displays such an announcement only at the request of a user, such as in an "About box", the Work is required to print or display the notice only under the same circumstances; if the Program itself is interactive but does not normally print such an announcement, the Work is not required to print an announcement.);
(iv) You must accompany the Work with the complete corresponding machine-readable source code, delivered on a medium customarily used for software interchange. The source code for a work means the preferred form of the work for making modifications to it. For an executable work, complete source code means all the source code for all modules it contains, plus any associated interface definition files, plus the scripts used to control compilation and installation of the executable code. If you distribute with the Work any component that is normally distributed (in either source or binary form) with the major components (compiler, kernel, and so on) of the operating system on which the executable runs, you must also distribute the source code of that component if you have it and are allowed to do so;
(v) If you distribute any written or printed material at all with the Work, such material must include either a written copy of this License, or a prominent written indication that the Work is covered by this License and written instructions for printing and/or displaying the copy of the License on the distribution medium;
(vi) You may not impose any further restrictions on the recipient's exercise of the rights granted herein.
If distribution of executable or object code is made by offering the equivalent ability to copy from a designated place, then offering equivalent ability to copy the source code from the same place counts as distribution of the source code, even though third parties are not compelled to copy the source code along with the object code.
* 3. Reservation of Rights. *
No rights are granted to the Program except as expressly set forth herein. You may not copy, modify, sublicense, or distribute the Program except as expressly provided under this License. Any attempt otherwise to copy, modify, sublicense or distribute the Program is void, and will automatically terminate your rights under this License. However, parties who have received copies, or rights, from you under this License will not have their licenses terminated so long as such parties remain in full compliance.
* 4. Other Restrictions. *
If the distribution and/or use of the Program is restricted in certain countries for any reason, Licensor may add an explicit geographical distribution limitation excluding those countries, so that distribution is permitted only in or among countries not thus excluded. In such case, this License incorporates the limitation as if written in the body of this License.
* 5. Limitations.
THE PROGRAM IS PROVIDED TO YOU "AS IS," WITHOUT WARRANTY. THERE IS NO WARRANTY FOR THE PROGRAM, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT OF THIRD PARTY RIGHTS. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF ALL NECESSARY SERVICING, REPAIR OR CORRECTION.
IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING WILL LICENSOR, OR ANY OTHER PARTY WHO MAY MODIFY AND/OR REDISTRIBUTE THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS), EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF SUCH DAMAGES.
6. General. *
This License is governed by the laws of the State of California, U.S.A., excluding choice of law rules.
If any part of this License is found to be in conflict with the law, that part shall be interpreted in its broadest meaning consistent with the law, and no other parts of the License shall be affected.
For United States Government users, the Program is provided with RESTRICTED RIGHTS. If you are a unit or agency of the United States Government or are acquiring the Program for any such unit or agency, the following apply:
If the unit or agency is the Department of Defense ("DOD"), the Program and its documentation are classified as "commercial computer software" and "commercial computer software documentation" respectively and, pursuant to DFAR Section 227.7202, the Government is acquiring the Program and its documentation in accordance with the terms of this License. If the unit or agency is other than DOD, the Program and its documentation are classified as "commercial computer software" and "commercial computer software documentation" respectively and, pursuant to FAR Section 12.212, the Government is acquiring the Program and its documentation in accordance with the terms of this License.
[Note: * A license being listed in the Copyleft and Open Source Center does not mean it is endorsed. These licenses are provided as a reference to encourage and promote the Open Source movement. Nothing within these pages should be considered as legal advice. * ]
[This document was generated by the Bakunin Cannabis Engine, Version 1.36.]

View File

@@ -0,0 +1,69 @@
# Tavern-Helper
> [!Warning]
> 执行自定义 JavaScript 代码, 可能会带来安全风险:
>
> - 恶意脚本可能会窃取你的 API 密钥、聊天记录等敏感信息; 修改或破坏你的 SillyTavern 设置
> - 某些脚本可能会执行危险操作, 如发送未经授权的请求
>
> 请在执行任何脚本前:
>
> 1. 仔细检查脚本内容, 确保其来源可信
> 2. 理解脚本的功能和可能的影响
> 3. 如有疑问, 请勿执行来源不明的脚本
>
> 我们不为第三方脚本造成的任何损失负责.
此扩展允许你在 SillyTavern 中运行外部 JavaScript 代码
由于 SillyTavern 默认不支持直接执行 JavaScript 代码, 这个扩展通过使用 iframe 来隔离和执行脚本, 从而让你在某些受限的上下文中运行外部脚本.
## 文档
- [文档](https://n0vi028.github.io/JS-Slash-Runner-Doc/)
## 参与贡献提示
### 项目结构
基于酒馆 UI 插件的项目结构要求, 本项目直接打包源代码在 `dist/` 文件夹中并随仓库上传, 而这会让开发时经常出现分支冲突.
为了解决这一点, 仓库在 `.gitattribute` 中设置了对于 `dist/` 文件夹中的冲突总是使用当前版本. 这不会有什么问题: 在上传后, ci 会将 `dist/` 文件夹重新打包成最新版本, 因而你上传的 `dist/` 文件夹内容如何无关紧要.
为了启用这个功能, 请执行一次以下命令:
```bash
git config --global merge.ours.driver true
```
### 手动编译
你可以参考 [参与前端插件开发的 VSCode 环境配置](https://sillytavern-stage-girls-dog.readthedocs.io/tool_and_experience/js_slash_runner/index.html) 来得到 VSCode 上更详细的配置和使用教程.
你需要先安装有 node 22+ 和 pnpm. 如果已经安装有 node 22+, 则 pnpm 可以按以下步骤安装:
```bash
npm install -g pnpm
```
然后, 用 pnpm 安装本项目的所有依赖:
```bash
pnpm install
```
之后你就可以对本项目进行编译:
```bash
pnpm build
```
或者, 你可以用 `pnpm watch` 来持续监听代码变动. 这样只需刷新酒馆网页, 酒馆就会使用最新的插件代码.
## 许可证
- [Aladdin](LICENSE)
## 参考
见于[文档](https://n0vi028.github.io/JS-Slash-Runner-Doc/)对应部分

View File

@@ -0,0 +1,14 @@
/* eslint-disable */
/* prettier-ignore */
// @ts-nocheck
// noinspection JSUnusedGlobalSymbols
// Generated by unplugin-auto-import
// biome-ignore lint: disable
export { };
declare global {
// @ts-ignore
export * as z from 'zod';
// @ts-ignore
import('zod');
}

View File

@@ -0,0 +1,99 @@
import js from '@eslint/js';
import tsParser from '@typescript-eslint/parser';
import eslintConfigPrettier from 'eslint-config-prettier';
import eslintPluginBetterTailwindcss from 'eslint-plugin-better-tailwindcss';
import importx from 'eslint-plugin-import-x';
import pinia from 'eslint-plugin-pinia';
import vue from 'eslint-plugin-vue';
import { globalIgnores } from 'eslint/config';
import globals from 'globals';
import ts from 'typescript-eslint';
import vueParser from 'vue-eslint-parser';
/** @type {import('@typescript-eslint/utils').TSESLint.FlatConfig.ConfigFile} */
export default [
js.configs.recommended,
...ts.configs.recommended,
importx.flatConfigs.recommended,
importx.flatConfigs.typescript,
...vue.configs['flat/recommended'],
pinia.configs['recommended-flat'],
{
plugins: {
'better-tailwindcss': eslintPluginBetterTailwindcss,
},
rules: {
...eslintPluginBetterTailwindcss.configs['recommended-warn'].rules,
...eslintPluginBetterTailwindcss.configs['recommended-error'].rules,
'better-tailwindcss/enforce-consistent-line-wrapping': ['warn', { preferSingleLine: true, printWidth: 120 }],
'better-tailwindcss/no-unregistered-classes': [
'warn',
{
ignore: [
'TH-*',
'extension_container',
'extensionsMenuExtensionButton',
'fa-*',
'flex-container',
'flexGap5',
'inline-drawer-*',
'interactable',
'list-*',
'menu_button*',
'popup',
'qr--button',
'qr--buttons',
'text_pole',
'note-link-span',
],
},
],
},
settings: {
'better-tailwindcss': {
entryPoint: 'src/global.css',
tailwindConfig: 'tailwind.config.js',
},
},
},
{
languageOptions: {
parser: vueParser,
parserOptions: {
parser: tsParser,
ecmaVersion: 'latest',
sourceType: 'module',
},
ecmaVersion: 'latest',
sourceType: 'module',
globals: {
...globals.browser,
},
},
rules: {
'handle-callback-err': 'off',
'import-x/no-console': 'off',
'import-x/no-cycle': 'error',
'import-x/no-dynamic-require': 'warn',
'import-x/no-nodejs-modules': 'warn',
'no-dupe-class-members': 'off',
'no-empty-function': 'off',
'no-floating-decimal': 'error',
'no-lonely-if': 'error',
'no-multi-spaces': 'error',
'no-redeclare': 'off',
'no-shadow': 'off',
'no-undef': 'off',
'no-unused-vars': 'off',
'no-var': 'error',
'pinia/require-setup-store-properties-export': 'off',
'prefer-const': 'warn',
'vue/multi-word-component-names': 'off',
yoda: 'error',
'@typescript-eslint/no-explicit-any': 'off',
'@typescript-eslint/no-unused-vars': 'off',
},
},
eslintConfigPrettier,
globalIgnores(['dist/**', 'node_modules/**', 'eslint.config.mjs', 'postcss.config.js', 'vite.config.ts']),
];

View File

@@ -0,0 +1,2 @@
declare const hljs: typeof import('highlight.js').default;
declare const Popper: typeof import('@popperjs/core');

View File

@@ -0,0 +1,256 @@
{
"酒馆助手": "Tavern Helper",
"[酒馆助手]": "[Tavern Helper]",
"API 错误": "API Error",
"[酒馆助手]迁移旧数据失败, 将使用空数据": "[Tavern Helper] Failed to migrate old data, using empty data",
"[酒馆助手]读取全局数据失败, 将使用空数据": "[Tavern Helper] Failed to parse global data, using empty data",
"[酒馆助手]读取角色数据失败, 将使用空数据": "[Tavern Helper] Failed to parse character data, using empty data",
"[酒馆助手]读取预设数据失败, 将使用空数据": "[Tavern Helper] Failed to parse preset data, using empty data",
"[酒馆助手]读取聊天数据失败, 将使用空数据": "[Tavern Helper] Failed to parse chat data, using empty data",
"[酒馆助手]连接实时监听功能出错, 尝试重连...": "[Tavern Helper] Error connecting to Real-time Editing Listener, trying to reconnect...",
"[酒馆助手]实时监听器断开连接": "[Tavern Helper] Real-time Editing Listener disconnected",
"获取更新日志失败": "Failed to get the changelog",
"无法找到版本 '${0}' 的日志": "Cannot find the changelog for version '${0}'",
"酒馆助手更新成功, 准备刷新页面以生效...": "Tavern Helper updated successfully, preparing to refresh the page to take effect...",
"正在更新酒馆助手...": "Updating Tavern Helper...",
"酒馆助手已是最新版本, 无需更新": "Tavern Helper is already the latest version, no need to update",
"更新失败: ${0}": "Update failed: ${0}",
"是否尝试通过卸载重装来更新? (以防由于网络问题重装没能成功, 请先复制网址)": "Whether to try to update by uninstalling and reinstalling? (In case that the reinstalling failed due to network issues, please copy the URL first)",
"复制网址": "Copy URL",
"网址已复制": "URL copied",
"正在卸载重装酒馆助手...": "Uninstalling and reinstalling Tavern Helper...",
"更新酒馆助手失败": "Failed to update Tavern Helper",
"路径已复制": "Path copied",
"无法复制路径,请手动复制": "Cannot copy path, please copy manually",
"成功编辑键名": "Successfully edited key name",
"成功编辑值": "Successfully edited value",
"已复制!": "Copied!",
"主设置": "Main",
"扩展设置": "Settings",
"渲染器": "Renderer",
"渲染": "Render",
"脚本库": "Script",
"脚本": "Script",
"工具箱": "Toolbox",
"工具": "Tools",
"基本设置": "Basic Settings",
"渲染优化": "Render Optimization",
"实验功能": "Experimental Features",
"优化": "Optimize",
"开发": "Dev",
"类脑": "ΟΔΥΣΣΕΙΑ",
"旅程": "ΟΡΙΖΟΝΤΑΣ",
"Ver ${0}": "Ver ${0}",
"查看日志": "View Log",
"最新: ${0}": "Latest: ${0}",
"发现新版本: ${0}": "New version available: ${0}",
"更新": "Update",
"开发工具": "Development",
"扩展信息": "Extension Information",
"启用渲染器": "Enable Renderer",
"启用后,符合条件的代码块将被渲染": "When enabled, qualified code blocks will be rendered",
"启用代码折叠": "Enable Code Folding",
"允许流式渲染": "Enable Streaming Rendering",
"在AI流式输出时就渲染某些前端界面可能无法这样渲染。此外这可能与某些脚本、插件、酒馆美化不兼容": "Render during AI streaming output, some frontend interfaces may not be rendered in this way. Also, this may be incompatible with some scripts, plugins, and SillyTavern theme",
"折叠指定类型的代码块,当选择“仅前端”时,将只折叠可渲染成前端界面但没被渲染的代码块": "Fold specified types of code blocks, when selecting \"Only Frontend\", only code blocks that can be rendered into frontend interface but not rendered will be folded",
"启用加载动画": "Enable Loading Animation",
"在前端内字体、图片等资源未加载完成前,显示加载动画而不是显示不完全界面": "Show loading animation instead of incomplete interface before fonts, images and other resources in frontend are loaded",
"启用 Blob URL 渲染": "Enable Blob URL Rendering",
"使用 Blob URL 渲染前端界面,更方便 f12 开发者工具调试;但某些浏览器可能不支持": "Use Blob URL to render frontend interface, easier for F12 developer tool debugging; but some browsers may not support it",
"取消前端代码高亮": "Cancel Frontend Code Highlight",
"避免酒馆对可渲染成前端界面的代码块进行语法高亮,从而提升渲染性能": "Avoid SillyTavern from highlighting code blocks that can be rendered into frontend interface, improving rendering performance",
"渲染深度": "Rendering Depth",
"设置需要渲染的楼层数,从最新楼层开始计数。为 0 时,将渲染所有楼层": "Set the number of floors to render, counting from the latest floor. When 0, all floors will be rendered",
"全部": "All",
"仅前端": "Only Frontend",
"禁用": "Disable",
"搜索(支持普通和/正则/": "Search (supports both plain and /regex/)",
"全局脚本": "Global Script",
"酒馆全局可用": "Available for the entire tavern",
"角色脚本": "Character Script",
"绑定到当前角色卡": "Bind to the current character card",
"预设脚本": "Preset Script",
"绑定到当前预设": "Bind to the current preset",
"关闭前端渲染": "Disable Fontend Renderer",
"开启前端渲染": "Enabled Fontend Renderer",
"提示词查看器": "Prompt Viewer",
"查看当前提示词发送情况,窗口开启时会监听新的发送及时更新显示": "View the current prompt sending situation, the window will listen for new sending and update the display in time",
"变量管理器": "Variable Manager",
"查看和管理全局、角色、聊天、消息楼层变量": "View and manage global, role, chat, message variables",
"作者KAKAA青空莉想做舞台少女的狗": "Author: KAKAA, 青空莉想做舞台少女的狗",
"本扩展免费使用,禁止任何形式的商业用途": "This extension is free to use but prohibited for any commercial use",
"脚本可能存在风险,请确保安全后再运行": "The script may exist risks, please ensure safety before running",
"实时监听": "Real-time Editing Listener",
"连接编写模板,将代码修改实时同步到酒馆": "Connect to the official writing template, and synchronize the code changes in real time to the tavern",
"允许监听": "Enable Listening",
"启用弹窗报错": "Enable Popup Error",
"使用方法": "How to Use",
"刷新间隔 (毫秒)": "Refresh Interval (ms)",
"禁用酒馆助手宏": "Disable Tavern Helper Macro",
"编写变量角色卡而非测试/游玩角色卡时,打开此开关,避免 {{get_message_variable::变量}} 等宏被替换": "When writing variable character cards instead of test/play character cards, enable this to avoid {{get_message_variable::variable}} and other macros being replaced",
"编写参考": "Reference",
"编写脚本的参考文档": "Writing Reference Document",
"查看教程及文档": "View Tutorial and Documentation",
"下载参考文件": "Download Reference File",
"下载STScript参考文件": "Download STScript Reference File",
"下载宏参考文件": "Download Macro Reference File",
"电脑编写模板用": "Use for computer writing template",
"手机或 AI 官网用": "Use for mobile or AI official website",
"酒馆STScript与宏": "Tavern STScript & Macro",
"查看手册": "View Manual",
"<div>更新日志加载中...</div>": "<div>Loading Changelog...</div>",
"关闭": "Close",
"内置库更多是作为脚本能做什么的示例, 更多实用脚本请访问社区的工具区": "Built-in libraries are sort of examples of what scripts can do; to get more practical scripts, please visit the community's tool channel",
"例如我个人除了内置库外还有": "For example, besides the built-in libraries, I've written",
"这些脚本": "these scripts",
"如果需要制作脚本, 建议查看": "If you need to write scripts, please refer to",
"官方编写模板配置教程": "Official writing template configuration tutorial",
"标签化: 随世界书、预设或链接配置自动开关正则、提示词条目": "Tagify: Automatically toggle regular expressions and prompt entries according to worldbook, preset or API configuration names",
"预设防误触": "Prevent mis-touch preset settings",
"世界书强制自定义排序": "Forcely sort world book by \"custom\" order",
"一键禁用条目递归": "Disable recursion of worldbook entries in one click",
"预设条目更多按钮: 一键新增预设条目": "More buttons for preset entries: insert new prompt entries in one click",
"角色卡绑定预设: 切换到某个角色卡时自动切换为对应预设": "Binding preset to character: Automatically switch to the corresponding preset when switching to a character card",
"输入助手": "Input Helper",
"压缩相邻消息: 让 AI 对内容理解更连贯": "Compress prompts: Make AI understand content more coherent",
"深度条目排斥器: 让深度条目只能在 D0 或 D9999": "Depth entry excluder: Force depth entries to be inserted at either D0 or D9999",
"token数过多提醒: 防止玩傻子AI": "Token count overflow reminder: Prevent playing with stupid AI",
"取消代码块高亮": "Cancel code block highlight",
"世界书繁简互换: 一键将繁体/简体世界书翻译成简体/繁体": "Worldbook Traditional/Simplified Chinese conversion: Translate Worldbook in one click",
"正在加载作者备注...": "Loading author note...",
"正在加载脚本...": "Loading scripts...",
"成功导入脚本: '${0}'": "Successfully imported script: '${0}'",
"暂无脚本": "No scripts",
"确认": "Confirm",
"取消": "Cancel",
"编辑文件夹": "Edit Folder",
"创建新文件夹": "Create New Folder",
"文件夹名称:": "Folder Name:",
"文件夹图标:": "Folder Icon:",
"请输入文件夹名称": "Please enter the folder name",
"选择颜色": "Select Color",
"选择图标": "Select Icon",
"<div>确定要删除文件夹及其中所有脚本吗?此操作无法撤销</div>": "<div>Are you sure you want to delete the folder and all its scripts? This operation cannot be undone</div>",
"酒馆助手脚本-${0}.json": "tavern_helper_script-${0}.json",
"包含数据导出": "Export with Data",
"清除数据导出": "Export without Data",
"<div>'${0}' 文件夹中 '${1}' 脚本包含脚本变量, 是否要清除? 如有 API Key 等敏感数据, 注意清除</div>": "<div>'${0}' folder contains scripts that have script variables: '${1}', whether to clear? Please pay attention if there are sensitive data such as API Key</div>",
"编辑脚本": "Edit Script",
"创建新脚本": "Create New Script",
"脚本名称": "Script Name",
"脚本内容": "Script Content",
"脚本的 JavaScript 代码": "JavaScript code",
"作者备注": "Author Note",
"脚本备注, 例如作者名、版本和注意事项等, 支持简单的 markdown 和 html": "Note of this script, such as the author's name, version and notes, etc. Supports simple markdown and html",
"变量列表": "Variable List",
"绑定到脚本的变量, 会随脚本一同导出": "Variables bound to the script will be exported together",
"按钮": "Buttons",
"需配合代码里的 getButtonEvent 使用": "Cooperate with getButtonEvent in the code",
"按钮名称": "Button Name",
"未填写作者备注": "No author note",
"<div>确定要删除脚本吗? 此操作无法撤销</div>": "<div>Are you sure you want to delete the script? This operation cannot be undone</div>",
"<div>'${0}' 脚本包含脚本变量, 是否要清除? 如有 API Key 等敏感数据, 注意清除</div>": "<div>whether to clear the script variables in the '${0}' script? Please pay attention if there are sensitive data such as API Key</div>",
"选择创建目标": "Select target",
"全局脚本库": "Global Script Library",
"角色脚本库": "Character Script Library",
"预设脚本库": "Preset Script Library",
"文件夹": "Folder",
"导入": "Import",
"内置库": "Built-in Library",
"未命名变量": "{{Unnamed Variable}}",
"暂无变量值": "{{No Variable Value}}",
"打开": "Open",
"批量操作": "Batch Operation",
"导入脚本文件 '${0}' 失败": "Failed to import script file '${0}'",
"成功导入脚本文件夹 '${0}'": "Successfully imported script folder '${0}'",
"成功导入脚本 '${0}'": "Successfully imported script '${0}'",
"查看作者备注": "View Author Note",
"更多操作": "More Actions",
"复制脚本": "Copy Script",
"移动脚本": "Move Script",
"导出脚本": "Export Script",
"删除脚本": "Delete Script",
"<div><h4>角色卡 '${0}' 中包含酒馆助手可用的嵌入式脚本</h4><h4>是否现在就启用它们?</h4><small>您可以选择否, 稍后在“酒馆助手-脚本库-角色脚本”中手动启用它们</small></div>": "<div><h4>Character '${0}' contains embedded scripts available for Tavern Helper</h4><h4>Would you like to enable them now?</h4><small>If you want to do it later, enable them in \"Tavern Helper - Script - Character Script\"</small></div>",
"<div><h4>预设 '${0}' 中包含酒馆助手可用的嵌入式脚本</h4><h4>是否现在就启用它们?</h4><small>您可以选择否, 稍后在“酒馆助手-脚本库-预设脚本”中手动启用它们</small></div>": "<div><h4>Preset '${0}' contains embedded scripts available for Tavern Helper</h4><h4>Would you like to enable them now?</h4><small>If you want to do it later, enable them in \"Tavern Helper - Script - Preset Script\"</small></div>",
"播放器": "Audio Player",
"全局音频播放器": "Global audio player",
"音乐": "Music",
"音效": "Sound Effect",
"链接": "Link",
"播放列表": "Play List",
"列表编辑": "Play List Editor",
"导入音频链接": "Import Audio Link",
"暂无音频": "No Audio",
"单个添加": "Single Add",
"批量导入": "Batch Import",
"标题(可选)": "Title (Optional)",
"音频链接 URL": "Audio Link URL",
"添加更多": "Add More",
"编辑音频项": "Edit Audio Item",
"标题": "Title",
"留空将自动从链接中提取文件名": "Leave blank to extract filename from link",
"音频链接": "Audio Link",
"音频标题(可选)": "Audio Title (Optional)",
"重复播放所有曲目": "Repeat all tracks",
"重复播放当前曲目": "Repeat current track",
"随机播放": "Shuffle play",
"播放当前曲目并停止": "Play current track and stop",
"<div>确定要删除音频吗? 此操作无法撤销</div>": "<div>Are you sure you want to delete the audio? This operation cannot be undone</div>",
"每行一个链接可选格式URL 或 URL,标题": "Each line should contain a link, optionally with a title in the format: URL or URL,Title",
"示例:\nhttps://example.com/audio1.mp3\nhttps://example.com/audio2.mp3,我的音乐\nhttps://example.com/audio3.mp3": "Example:\nhttps://example.com/audio1.mp3\nhttps://example.com/audio2.mp3,My Music\nhttps://example.com/audio3.mp3",
"已复制全部提示词到剪贴板": "All prompts have been copied to the clipboard",
"已复制提示词到剪贴板": "Prompt has been copied to the clipboard",
"总token数": "Token Count",
"${0}/${1} 条消息": "${0}/${1} messages",
"搜索消息内容...": "Search message content...",
"过滤": "Filter",
"仅显示匹配": "only-matched",
"等待已有生成请求完成... (或用刷新按钮强制取消它)": "Waiting for the existing generation request to complete... (or use the refresh button to forcely cancel it)",
"正在发送虚假生成请求, 从而获取最新提示词...": "Sending a fake generation request to get the latest prompts...",
"正在获取生成请求中的提示词...": "Getting prompts from the generation request...",
"当前 API 不是聊天补全, 无法使用提示词查看器功能": "The current API is not 'chat completion', cannot use the Prompt Viewer",
"未连接到 API, 提示词查看器将无法获取数据": "Not connected to API, the Prompt Viewer will not retrieve data",
"收起内容": "Collapse Content",
"展开": "Expand",
"行隐藏内容": "Lines of Hidden Content",
"收起图片": "Collapse Images",
"显示图片": "Show Images",
"日志查看器": "Log Viewer",
"查看脚本和渲染界面的控制台日志": "View Script and Render Console Logs",
"清除日志": "Clear Logs",
"所有日志": "All Logs",
"搜索日志内容...": "Search log content...",
"详细": "Debug",
"错误": "Error",
"信息": "Info",
"警告": "Warn",
"消息": "Message",
"第${0}楼-第${1}个界面": "Floor ${0} - View ${1}",
"全局": "Global",
"预设": "Preset",
"角色": "Character",
"聊天": "Chat",
"消息楼层": "Message",
"追踪最新": "Track Latest",
"正序显示": "In Order",
"楼": "message id",
"最新楼层号": "latest message id",
"第 ${0} 楼": "#${0}",
"模型": "Model",
"体验优化": "User Experience Optimization",
"性能": "Performance",
"角色卡": "Character Card",
"世界书": "Worldbook",
"杂项": "Miscellaneous",
"联动和绑定": "Linking & Binding",
"模型上下文处理": "Model Context",
"禁用不兼容选项": "Disable incompatible options",
"[要加载 # 条消息] → [要渲染 # 条消息]": "[# Msg. to Load] → [# Msg. to Render]",
"使用[替换/更新角色卡]功能时更新世界书": "Update worldbook when using [Replace/Update]",
"导出角色卡时始终携带最新世界书": "Always export the latest worldbook when exporting character cards",
"删除角色卡时删除绑定的主要世界书": "Delete bound primary worldbook when deleting character cards",
"强制使用推荐的世界书全局设置": "Forcely use recommended worldbook global settings",
"保存预设条目时直接保存预设": "Save preset when saving preset entries",
"最大化预设上下文长度": "Maximize preset context length",
"切换预设时提醒还没有保存": "Remind saving the current preset before switching to another preset"
}

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@@ -0,0 +1,16 @@
{
"display_name": "酒馆助手",
"loading_order": 100,
"requires": [],
"optional": [],
"js": "dist/index.js",
"css": "dist/index.css",
"author": "KAKAA",
"version": "4.7.7",
"homePage": "https://github.com/N0VI028/JS-Slash-Runner",
"auto_update": true,
"minimum_client_version": "1.12.13",
"i18n": {
"en": "i18n/en.json"
}
}

View File

@@ -0,0 +1,86 @@
{
"name": "JS-Slash-Runner",
"version": "4.7.7",
"scripts": {
"build": "vite build",
"watch": "vite build --watch --mode development",
"format": "prettier --write \"src/**/*.{ts,tsx,js,jsx,css,scss,html,vue}\"",
"lint": "eslint",
"lint:fix": "eslint --fix"
},
"browserslist": [
"defaults and partially supports es6-module"
],
"devDependencies": {
"@eslint/js": "^9.39.2",
"@popperjs/core": "^2.11.8",
"@tailwindcss/postcss": "^4.1.18",
"@types/jquery": "^3.5.33",
"@types/jqueryui": "^1.12.24",
"@types/lodash": "^4.17.23",
"@types/showdown": "^2.0.6",
"@types/sortablejs": "^1.15.9",
"@types/toastr": "^2.1.43",
"@typescript-eslint/parser": "^8.55.0",
"@typescript-eslint/utils": "^8.55.0",
"@vitejs/plugin-vue": "^6.0.4",
"autoprefixer": "^10.4.24",
"eslint": "^9.39.2",
"eslint-config-prettier": "^10.1.8",
"eslint-import-resolver-typescript": "^4.4.4",
"eslint-plugin-better-tailwindcss": "^3.8.0",
"eslint-plugin-import-x": "^4.16.1",
"eslint-plugin-pinia": "^0.4.2",
"eslint-plugin-vue": "^10.7.0",
"globals": "^16.5.0",
"highlight.js": "^11.11.1",
"postcss": "^8.5.6",
"postcss-load-config": "^6.0.1",
"postcss-minify": "^1.2.0",
"prettier": "^3.8.1",
"prettier-plugin-tailwindcss": "^0.6.14",
"tailwindcss": "^4.1.18",
"ts-node": "^10.9.2",
"tslib": "^2.8.1",
"typescript": "6.0.0-dev.20250807",
"typescript-eslint": "^8.55.0",
"unplugin-auto-import": "^20.3.0",
"unplugin-vue-components": "^29.2.0",
"vanilla-jsoneditor": "^3.11.0",
"vite": "^7.3.1",
"vite-plugin-external": "^6.2.2",
"vue-eslint-parser": "^10.2.0",
"vue-style-loader": "^4.1.3",
"vue-tsc": "^3.2.4",
"yaml": "^2.8.2"
},
"dependencies": {
"@vueuse/components": "^13.9.0",
"@vueuse/core": "^13.9.0",
"@vueuse/integrations": "^13.9.0",
"@vueuse/shared": "^13.9.0",
"async-wait-until": "^2.0.31",
"compare-versions": "^6.1.1",
"deep-object-diff": "^1.1.9",
"destr": "^2.0.5",
"emittery": "^1.2.0",
"is-blob": "^3.0.0",
"is-promise": "^4.0.0",
"jszip": "^3.10.1",
"klona": "^2.0.6",
"lodash-omitdeep": "^1.8.0",
"object-to-formdata": "^4.5.1",
"pinia": "^3.0.4",
"socket.io-client": "^4.8.3",
"type-fest": "^4.41.0",
"vue": "^3.5.28",
"vue-draggable-plus": "^0.6.1",
"vue-final-modal": "^4.5.5",
"vue-pick-colors": "^1.8.0",
"vue-router": "^4.6.4",
"vue-tippy": "^6.7.1",
"vue-virt-list": "^1.7.0",
"vue-word-highlighter": "^1.2.6",
"zod": "^4.3.6"
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,6 @@
onlyBuiltDependencies:
- '@parcel/watcher'
- '@tailwindcss/oxide'
- esbuild
- unrs-resolver
- vue-demi

View File

@@ -0,0 +1,6 @@
/** @type {import('postcss-load-config').Config} */
const config = {
plugins: [require('autoprefixer'), require('@tailwindcss/postcss'), require('postcss-minify')],
};
module.exports = config;

View File

@@ -0,0 +1,148 @@
<template>
<div class="inline-drawer">
<div class="inline-drawer-toggle inline-drawer-header">
<b>{{ t`酒馆助手` }} <span v-if="has_update" class="th-text-xs font-bold text-red-500">New!</span></b>
<div class="inline-drawer-icon fa-solid fa-circle-chevron-down"></div>
</div>
<div class="inline-drawer-content TH-custom-tailwind">
<div class="flex flex-col gap-0.5">
<div class="flex items-center justify-between th-text-xs text-(--grey50)">
<span>{{ t`Ver ${current_version}` }}</span>
<div class="flex cursor-pointer items-center justify-end gap-0.25 leading-[1.5]" @click="openInfoModal">
<i class="fa-solid fa-circle-info text-[calc(var(--mainFontSize)*0.7)]" :title="t`扩展信息`"></i>
<span>{{ t`扩展信息` }}</span>
</div>
</div>
<!-- 顶部导航栏 -->
<!-- prettier-ignore-attribute -->
<div
class="
flex w-full items-center rounded-md border border-x-2 border-(--grey5050a) border-x-(--SmartThemeQuoteColor)
p-0.5
"
>
<div
v-for="{ key, name, icon } in tabs"
:key="key"
class="flex h-full flex-1 cursor-pointer items-center justify-center rounded-sm text-(--grey50)"
:class="{
'bg-[color-mix(in_srgb,var(--SmartThemeQuoteColor)_80%,transparent)] transition duration-300 ease-in-out':
active_tab === key,
}"
@click="active_tab = key"
>
<div
class="flex items-center justify-center gap-[3px] leading-[1.5]"
:style="{
color:
active_tab === key
? getSmartThemeQuoteTextColor()
: key === 'developer'
? developIconColor
: undefined,
}"
>
<i class="th-text-xs" :class="icon"></i>
<span class="text-center">{{ name }}</span>
</div>
</div>
</div>
<!-- 更新提示条 -->
<!-- prettier-ignore-attribute -->
<div
v-if="show_update_banner"
class="
flex w-full items-center justify-between rounded-sm border
border-[color-mix(in_srgb,var(--SmartThemeQuoteColor)_20%,transparent)] py-0.25 pr-0.5 pl-1 th-text-sm
text-(--SmartThemeBodyColor)
"
>
<span>
{{ t`发现新版本: ${latest_version}` }}
</span>
<div class="flex items-center gap-0.5">
<!-- prettier-ignore-attribute -->
<div
class="
cursor-pointer rounded-md bg-[color-mix(in_srgb,var(--SmartThemeQuoteColor)_10%,transparent)] px-0.5
py-[3px] th-text-xs
"
@click="openUpdateModal"
>
{{ t`更新` }}
</div>
<i class="fa-solid fa-xmark cursor-pointer p-0.25" @click="show_update_banner = false"></i>
</div>
</div>
<!-- 内容区 -->
<div class="min-w-0">
<template v-for="{ key, component } in tabs" :key="key">
<div v-show="active_tab === key" class="flex flex-col gap-0.25">
<component :is="component" />
</div>
</template>
</div>
</div>
</div>
<ModalsContainer />
</div>
</template>
<script setup lang="ts">
import { getTavernHelperVersion } from '@/function/version';
import { useValidatedTab } from '@/panel/composable/use_validated_tab';
import Developer from '@/panel/Developer.vue';
import { listenerConnected } from '@/panel/developer/listener';
import Info from '@/panel/Info.vue';
import { getLatestVersion, hasUpdate } from '@/panel/info/update';
import Update from '@/panel/info/Update.vue';
import Optimize from '@/panel/Optimize.vue';
import Render from '@/panel/Render.vue';
import Script from '@/panel/Script.vue';
import Toolbox from '@/panel/Toolbox.vue';
import { useGlobalSettingsStore } from '@/store/settings';
import { getSmartThemeQuoteTextColor } from '@/util/color';
import { ModalsContainer } from 'vue-final-modal';
const current_version = getTavernHelperVersion();
// 暴露 Vue 从而让 vue devtool 能正确识别
useScriptTag('https://testingcf.jsdelivr.net/npm/vue/dist/vue.runtime.global.prod.min.js');
const tabs = [
{ key: 'render', name: t`渲染`, icon: 'fa-solid fa-magic-wand-sparkles', component: Render },
{ key: 'script', name: t`脚本`, icon: 'fa-solid fa-dice-d6', component: Script },
{ key: 'toolbox', name: t`工具`, icon: 'fa-solid fa-toolbox', component: Toolbox },
{ key: 'optimize', name: t`优化`, icon: 'fa-solid fa-circle-nodes', component: Optimize },
{ key: 'developer', name: t`开发`, icon: 'fa-solid fa-tools', component: Developer },
] as const;
const active_tab = useValidatedTab('TH-Panel:active_tab', 'render', () => tabs.map(t => t.key));
const listenerEnabled = computed(() => useGlobalSettingsStore().settings.listener.enabled);
/** 开发工具图标颜色:启用监听但未连接时红色,已连接时绿色 */
const developIconColor = computed(() => {
if (!listenerEnabled.value) return undefined;
return listenerConnected.value ? 'green' : 'rgb(170, 0, 0)';
});
const has_update = ref(false);
const show_update_banner = ref(false);
const latest_version = ref('');
const { open: openUpdateModal } = useModal({
component: Update,
});
const { open: openInfoModal } = useModal({
component: Info,
});
onMounted(async () => {
has_update.value = await hasUpdate();
if (has_update.value) {
latest_version.value = await getLatestVersion();
show_update_banner.value = true;
}
});
</script>

View File

@@ -0,0 +1,94 @@
import { useAmbientAudioStore, useBgmAudioStore } from '@/store/audio';
import { AudioMode } from '@/type/settings';
export function get_store_by_type(type: 'bgm' | 'ambient') {
switch (type) {
case 'bgm': {
return useBgmAudioStore();
}
case 'ambient': {
return useAmbientAudioStore();
}
}
}
export function handle_url_to_title(url: string) {
return url.split('/').at(-1)?.split('.').at(0) || url;
}
type Audio = {
title: string;
url: string;
};
type AudioWithOptionalTitle = {
title?: string;
url: string;
};
export function playAudio(type: 'bgm' | 'ambient', audio: AudioWithOptionalTitle): void {
const store = get_store_by_type(type);
audio.title = audio.title || handle_url_to_title(audio.url);
const existing = store.playlist.find(item => item.title === audio.title || item.url === audio.url);
if (!existing) {
store.playlist.push(audio as Audio);
} else {
existing.title = audio.title;
existing.url = audio.url;
}
store.src = audio.url;
store.progress = 0;
store.playing = false;
store.playing = true;
}
export function pauseAudio(type: 'bgm' | 'ambient'): void {
const store = get_store_by_type(type);
store.playing = false;
}
export function getAudioList(type: 'bgm' | 'ambient'): Audio[] {
const store = get_store_by_type(type);
return klona(store.playlist);
}
export function replaceAudioList(type: 'bgm' | 'ambient', audio_list: AudioWithOptionalTitle[]): void {
const store = get_store_by_type(type);
store.playlist = audio_list.map(item => ({ title: item.title || handle_url_to_title(item.url), url: item.url }));
}
export function appendAudioList(type: 'bgm' | 'ambient', audio_list: AudioWithOptionalTitle[]): void {
const store = get_store_by_type(type);
store.playlist.push(
...audio_list.map(item => ({ title: item.title || handle_url_to_title(item.url), url: item.url })),
);
}
type AudioSettings = {
enabled: boolean;
mode: AudioMode;
muted: boolean;
volume: number;
};
export function getAudioSettings(type: 'bgm' | 'ambient'): AudioSettings {
const store = get_store_by_type(type);
return klona(_.pick(store, ['enabled', 'mode', 'muted', 'volume']));
}
export function setAudioSettings(type: 'bgm' | 'ambient', settings: Partial<AudioSettings>): void {
const store = get_store_by_type(type);
if (settings.enabled !== undefined) {
store.enabled = settings.enabled;
}
if (settings.mode !== undefined) {
store.mode = settings.mode;
}
if (settings.muted !== undefined) {
store.muted = settings.muted;
}
if (settings.volume !== undefined) {
store.volume = _.clamp(settings.volume, 0, 100);
}
}

View File

@@ -0,0 +1,31 @@
import { copyText, reloadEditor, reloadEditorDebounced } from '@/util/compatibility';
import {
getImageTokenCost,
getVideoTokenCost,
reloadAndRenderChatWithoutEvents,
reloadChatWithoutEvents,
renderMarkdown,
} from '@/util/tavern';
import { addOneMessage, is_send_press, saveSettings } from '@sillytavern/script';
import { promptManager } from '@sillytavern/scripts/openai';
import { uuidv4 } from '@sillytavern/scripts/utils';
import { parseRegexFromString } from '@sillytavern/scripts/world-info';
export const builtin = {
addOneMessage,
copyText,
duringGenerating: () => is_send_press,
getImageTokenCost,
getVideoTokenCost,
parseRegexFromString,
promptManager,
reloadAndRenderChatWithoutEvents,
reloadChatWithoutEvents,
reloadEditor,
reloadEditorDebounced,
renderMarkdown,
renderPromptManager: promptManager.render,
renderPromptManagerDebounced: promptManager.renderDebounced,
saveSettings,
uuidv4,
};

View File

@@ -0,0 +1,342 @@
import { RawCharacter } from '@/function/raw_character';
import { from_tavern_regex, TavernRegex, to_tavern_regex } from '@/function/tavern_regex';
import { getCharWorldbookNames } from '@/function/worldbook';
import { useCharacterSettingsStore } from '@/store/settings';
import { getFirstMessage } from '@/util/tavern';
import {
characters,
chat,
chat_metadata,
clearChat,
deleteCharacter as deleteCharacterInternal,
event_types,
eventSource,
getCharacters,
getOneCharacter,
getRequestHeaders,
getThumbnailUrl,
name2,
printCharacters,
printMessages,
saveChatConditional,
selectCharacterById,
unshallowCharacter,
} from '@sillytavern/script';
import { v1CharData } from '@sillytavern/scripts/char-data';
import { favsToHotswap } from '@sillytavern/scripts/RossAscends-mods';
import { delay } from '@sillytavern/scripts/utils';
import isBlob from 'is-blob';
import { serialize } from 'object-to-formdata';
import { LiteralUnion, PartialDeep } from 'type-fest';
type Character = {
avatar: `${string}.png` | Blob;
version: string;
creator: string;
creator_notes: string;
worldbook: string | null;
description: string;
first_messages: string[];
extensions: {
regex_scripts: TavernRegex[];
tavern_helper: {
scripts: Record<string, any>[];
variables: Record<string, any>;
};
[other: string]: any;
};
};
export function getCharacterNames(): string[] {
return characters.map(character => character.name);
}
export function getCurrentCharacterName(): string | null {
return name2 === '' ? null : name2;
}
function toCharacter(character: v1CharData): Character {
const data = character.data;
const first_messages = [character.first_mes ?? data.first_mes, ...data.alternate_greetings];
let extensions = klona(data.extensions as Record<string, any>);
if (_.has(extensions, 'regex_scripts')) {
_.set(extensions, 'regex_scripts', _.get(extensions, 'regex_scripts', []).map(to_tavern_regex));
}
if (_.has(extensions, 'tavern_helper')) {
const tavern_helper = _.get(extensions, 'tavern_helper', {});
// 依旧处理一下旧的存储格式, 保证格式正确
_.set(
extensions,
'tavern_helper',
Array.isArray(tavern_helper) ? Object.fromEntries(tavern_helper) : tavern_helper,
);
}
extensions = _.omit(extensions, [
'TavernHelper_scripts',
'TavernHelper_characterScriptVariables',
'fav',
'talkativeness',
'world',
'depth_prompt',
'pygmalion_id',
'github_repo',
'source_url',
'chub',
'risuai',
'sd_character_prompt',
]);
return {
avatar: `${character.name ?? data.name}.png`,
version: data.character_version ?? '',
creator: data.creator ?? '',
creator_notes: character.creatorcomment ?? data.creator_notes ?? '',
description: character.description ?? data.description ?? '',
first_messages: first_messages,
worldbook: getCharWorldbookNames(character.name).primary,
// @ts-expect-error 类型是正确的, extensions 里必然有 regex_scripts 和 tavern_helper
extensions: extensions,
};
}
type Payload = {
ch_name: string;
avatar_url: string;
avatar?: File;
character_version?: string;
creator?: string;
creator_notes?: string;
extensions?: string;
description?: string;
first_mes?: string;
alternate_greetings?: string[];
world?: string;
chat?: string;
create_date?: string;
personality?: string;
scenario?: string;
mes_example?: string;
talkativeness?: number;
fav?: boolean;
tags?: string[];
};
function fromCharacterToPayload(
character_name: string,
new_data: PartialDeep<Character>,
old_data?: v1CharData,
): Payload {
let world = old_data?.data?.extensions?.world;
if (new_data.worldbook !== undefined) {
world = new_data.worldbook || undefined;
}
const extensions = klona({ ...old_data?.data?.extensions, ...new_data.extensions });
if (new_data.extensions?.regex_scripts !== undefined) {
_.set(extensions, 'regex_scripts', new_data.extensions.regex_scripts.map(from_tavern_regex));
}
return {
ch_name: character_name,
avatar_url: character_name + '.png',
avatar: isBlob(new_data.avatar) ? new File([new_data.avatar], character_name + '.png') : undefined,
character_version: new_data.version ?? old_data?.data.character_version,
creator: new_data.creator ?? old_data?.data.creator,
creator_notes: new_data.creator_notes ?? old_data?.data.creator_notes,
description: new_data.description ?? old_data?.data.description,
first_mes: (new_data.first_messages?.[0] ?? old_data?.data.first_mes) || '',
alternate_greetings: (new_data.first_messages?.slice(1) ?? old_data?.data.alternate_greetings) || [],
world,
extensions: JSON.stringify(extensions),
chat: old_data?.chat,
create_date: old_data?.create_date,
personality: old_data?.data?.personality,
scenario: old_data?.data?.scenario,
mes_example: old_data?.data?.mes_example,
talkativeness: old_data?.data?.extensions?.talkativeness,
fav: old_data?.data?.extensions?.fav,
tags: old_data?.data?.tags,
};
}
export async function createCharacter(
character_name: Exclude<string, 'current'>,
character: PartialDeep<Character> = {},
): Promise<boolean> {
if (character_name === 'current' || RawCharacter.findIndex(character_name) !== -1) {
return false;
}
const payload = fromCharacterToPayload(character_name, character);
const headers = getRequestHeaders();
_.unset(headers, 'Content-Type');
const response = await fetch('/api/characters/create', {
method: 'POST',
headers: headers,
body: serialize(payload),
cache: 'no-cache',
});
if (!response.ok) {
throw new Error(`创建角色卡 '${character_name}' 失败: (${response.status}) ${await response.text()}`);
}
await getCharacters();
return true;
}
export async function createOrReplaceCharacter(
character_name: Exclude<string, 'current'>,
character: PartialDeep<Character> = {},
options: ReplaceCharacterOptions = {},
): Promise<boolean> {
const index = RawCharacter.findIndex(character_name);
if (index !== -1) {
await replaceCharacter(character_name, character, options);
return false;
} else {
await createCharacter(character_name, character);
return true;
}
}
export async function deleteCharacter(
character_name: LiteralUnion<'current', string>,
option: { delete_chats?: boolean } = {},
): Promise<boolean> {
const character = RawCharacter.find({ name: character_name });
if (!character) {
return false;
}
await deleteCharacterInternal(character.avatar, { deleteChats: option.delete_chats ?? true });
return true;
}
export async function getCharacter(name: LiteralUnion<'current', string>): Promise<Character> {
const index = RawCharacter.findIndex(name);
if (index === -1) {
throw Error(`角色卡 '${name}' 不存在`);
}
await unshallowCharacter(String(index));
return klona(toCharacter(characters[index]));
}
type ReplaceCharacterOptions = {
render?: 'debounced' | 'immediate' | 'none';
};
export async function render_character(character_name: string, character: PartialDeep<Character>, is_current: boolean) {
if (isBlob(character.avatar)) {
const avatar_url = getThumbnailUrl('avatar', character_name + '.png');
await fetch(avatar_url, {
method: 'GET',
cache: 'reload',
});
$('#add_avatar_button').replaceWith($('#add_avatar_button').val('').clone(true));
const $mes_image = $(`.mes[ch_name=${character_name}]`).find('img');
const $avatar_load_preview = $('#avatar_load_preview');
const default_avatar = 'img/ai4.png';
$avatar_load_preview.attr('src', default_avatar);
$mes_image.attr('src', default_avatar);
await delay(1);
$mes_image.attr('src', avatar_url);
$avatar_load_preview.attr('src', avatar_url);
}
favsToHotswap();
const message = getFirstMessage();
const should_regenerate_message =
message.mes &&
!chat_metadata.tainted &&
(chat.length === 0 || (chat.length === 1 && !chat[0].is_user && !chat[0].is_system));
if (is_current && should_regenerate_message) {
chat.splice(0, chat.length, message);
const message_id = chat.length - 1;
await eventSource.emit(event_types.MESSAGE_RECEIVED, message_id, 'first_message');
await clearChat();
await printMessages();
await eventSource.emit(event_types.CHARACTER_MESSAGE_RENDERED, message_id, 'first_message');
await saveChatConditional();
}
if (is_current) {
await selectCharacterById(RawCharacter.findIndex(character_name));
}
await printCharacters(true);
}
const renderCharacterDebounced = _.debounce(render_character, 1000);
export async function replaceCharacter(
character_name: Exclude<string, 'current'>,
character: PartialDeep<Character>,
{ render = 'debounced' }: ReplaceCharacterOptions = {},
): Promise<void> {
const index = RawCharacter.findIndex(character_name);
if (index === -1) {
throw Error(`角色卡 '${character_name}' 不存在`);
}
const target = characters[index];
const payload = fromCharacterToPayload(character_name, character, target);
const headers = getRequestHeaders();
_.unset(headers, 'Content-Type');
const response = await fetch('/api/characters/edit', {
method: 'POST',
headers: headers,
body: serialize(payload),
cache: 'no-cache',
});
if (!response.ok) {
throw new Error(`修改角色卡 '${character_name}' 失败: (${response.status}) ${await response.text()}`);
}
const store = useCharacterSettingsStore();
const is_current = character_name === store.name;
if (is_current) {
await getOneCharacter(character_name + '.png');
if (character.extensions?.tavern_helper !== undefined) {
store.forceReload();
}
}
switch (render) {
case 'debounced':
renderCharacterDebounced(character_name, character, is_current);
break;
case 'immediate':
await render_character(character_name, character, is_current);
break;
case 'none':
break;
}
}
type CharacterUpdater = ((character: Character) => Character) | ((character: Character) => Promise<Character>);
export async function updateCharacterWith(
character_name: LiteralUnion<'current', string>,
updater: CharacterUpdater,
): Promise<Character> {
const character = await updater(await getCharacter(character_name)!);
await replaceCharacter(character_name, character);
return character;
}

View File

@@ -0,0 +1,562 @@
import { refreshOneMessage } from '@/function/displayed_message';
import { auditChatMessages } from '@/panel/optimize/better_message_to_load';
import { inUnnormalizedMessageRange, normalizeMessageId } from '@/util/message';
import { saveChatConditionalDebounced } from '@/util/tavern';
import {
addOneMessage,
chat,
event_types,
eventSource,
messageFormatting,
name1,
name2,
reloadCurrentChat,
saveChatConditional,
showSwipeButtons,
substituteParamsExtended,
system_message_types,
} from '@sillytavern/script';
type ChatMessage = {
message_id: number;
name: string;
role: 'system' | 'assistant' | 'user';
is_hidden: boolean;
message: string;
data: Record<string, any>;
extra: Record<string, any>;
};
type ChatMessageSwiped = {
message_id: number;
name: string;
role: 'system' | 'assistant' | 'user';
is_hidden: boolean;
swipe_id: number;
swipes: string[];
swipes_data: Record<string, any>[];
swipes_info: Record<string, any>[];
};
type GetChatMessagesOption = {
role?: 'all' | 'system' | 'assistant' | 'user';
hide_state?: 'all' | 'hidden' | 'unhidden';
include_swipes?: boolean;
};
// TODO: 移入 @/util/message.ts
function string_to_range(input: string, min: number, max: number) {
let start, end;
const clamp = (value: number) => _.clamp(value < 0 ? max + value + 1 : value, min, max);
if (input.match(/^(-?\d+)$/)) {
start = end = clamp(Number(input));
} else {
const match = input.match(/^(-?\d+)-(-?\d+)$/);
if (!match) {
return null;
}
[start, end] = _.sortBy([match[1], match[2]].map(Number).map(clamp));
}
if (isNaN(start) || isNaN(end)) {
return null;
}
return { start, end };
}
export function getChatMessages(
range: string | number,
{ role, hide_state, include_swipes }?: Omit<GetChatMessagesOption, 'include_swipes'> & { include_swipes?: false },
): ChatMessage[];
export function getChatMessages(
range: string | number,
{ role, hide_state, include_swipes }?: Omit<GetChatMessagesOption, 'include_swipes'> & { include_swipes?: true },
): ChatMessageSwiped[];
export function getChatMessages(
range: string | number,
{ role = 'all', hide_state = 'all', include_swipes = false }: GetChatMessagesOption = {},
): (ChatMessage | ChatMessageSwiped)[] {
const range_demacroed = substituteParamsExtended(range.toString());
const range_number = string_to_range(range_demacroed, 0, chat.length - 1);
if (!range_number) {
return [];
}
const { start, end } = range_number;
const get_role = (chat_message: any) => {
const is_narrator = chat_message.extra?.type === system_message_types.NARRATOR;
if (is_narrator) {
if (chat_message.is_user) {
return 'unknown';
}
return 'system';
}
if (chat_message.is_user) {
return 'user';
}
return 'assistant';
};
const process_message = (message_id: number): (ChatMessage | ChatMessageSwiped) | null => {
const message = chat[message_id];
if (!message) {
return null;
}
const message_role = get_role(message);
if (role !== 'all' && message_role !== role) {
return null;
}
if (hide_state !== 'all' && (hide_state === 'hidden') !== message.is_system) {
return null;
}
const swipe_id = message?.swipe_id ?? 0;
let swipes: string[] = message?.swipes ?? [message.mes];
let swipes_data: Record<string, any>[] = message?.variables ?? [{}];
let swipes_info: Record<string, any>[] = message?.swipe_info ?? [message?.extra ?? {}];
const swipe_length = swipes.length;
swipes = _.range(0, swipe_length).map(i => swipes[i] ?? '');
swipes_data = _.range(0, swipe_length).map(i => swipes_data[i] ?? {});
swipes_info = _.range(0, swipe_length).map(i => swipes_info[i] ?? {});
const extra = swipes_info[swipe_id];
const data = swipes_data[swipe_id];
if (include_swipes) {
return {
message_id: message_id,
name: message.name,
role: message_role as 'system' | 'assistant' | 'user',
is_hidden: message.is_system,
swipe_id: swipe_id,
swipes: swipes,
swipes_data: swipes_data,
swipes_info: swipes_info,
};
}
return {
message_id: message_id,
name: message.name,
role: message_role as 'system' | 'assistant' | 'user',
is_hidden: message.is_system,
message: message.mes ?? '',
data: data,
extra: extra,
// for compatibility
swipe_id: swipe_id,
swipes: swipes,
swipes_data: swipes_data,
};
};
const chat_messages: (ChatMessage | ChatMessageSwiped)[] = _.range(start, end + 1)
.map(i => process_message(i))
.filter(chat_message => chat_message !== null);
return klona(chat_messages);
}
type SetChatMessagesOption = {
refresh?: 'none' | 'affected' | 'all';
};
async function refreshMessages(refresh: SetChatMessagesOption['refresh'], affected_action: () => Promise<void>) {
if (refresh === 'all') {
await saveChatConditional();
await reloadCurrentChat();
} else {
saveChatConditionalDebounced();
if (refresh === 'affected') {
await affected_action();
const $mes = $('chat > .mes');
$mes.removeClass('last_mes');
$mes.last().addClass('last_mes');
}
}
}
export async function setChatMessages(
chat_messages: Array<{ message_id: number } & (Partial<ChatMessage> | Partial<ChatMessageSwiped>)>,
{ refresh = 'affected' }: SetChatMessagesOption = {},
): Promise<void> {
const convert_and_merge_messages = (
data: Array<{ message_id: number } & (Partial<ChatMessage> | Partial<ChatMessageSwiped>)>,
): any => {
return _(data)
.map(chat_message => ({
...chat_message,
message_id: normalizeMessageId(chat_message.message_id),
}))
.sortBy('message_id')
.groupBy('message_id')
.map(messages => {
return messages.reduce((result, current) => ({ ...result, ...current }), {});
})
.value();
};
const is_chat_message = (
chat_message: { message_id: number } & (Partial<ChatMessage> | Partial<ChatMessageSwiped>),
): chat_message is { message_id: number } & Partial<ChatMessage> => {
return _.has(chat_message, 'message') || _.has(chat_message, 'data');
};
const modify = async (chat_message: { message_id: number } & (Partial<ChatMessage> | Partial<ChatMessageSwiped>)) => {
const data = chat[chat_message.message_id];
if (data === undefined) {
return;
}
// 与提示词模板的兼容性
if (_.isPlainObject(data?.variables)) {
_.set(
data,
'variables',
_.range(0, data.swipes?.length ?? 1).map(i => data.variables[i] ?? {}),
);
}
if (chat_message?.name !== undefined) {
_.set(data, 'name', chat_message.name);
}
if (chat_message?.role !== undefined) {
_.set(data, 'is_user', chat_message.role === 'user');
if (chat_message.role === 'system') {
_.set(data, 'extra.type', system_message_types.NARRATOR);
} else {
_.unset(data, 'extra.type');
}
}
if (chat_message?.is_hidden !== undefined) {
_.set(data, 'is_system', chat_message.is_hidden);
}
if (is_chat_message(chat_message)) {
if (chat_message?.message !== undefined) {
_.set(data, 'mes', chat_message.message);
if (data?.swipes !== undefined) {
_.set(data, ['swipes', data.swipe_id], chat_message.message);
}
}
if (chat_message?.data !== undefined) {
if (data?.variables === undefined) {
_.set(data, 'variables', _.times(data.swipes?.length ?? 1, _.constant({})));
}
_.set(data, ['variables', data.swipe_id ?? 0], chat_message.data);
}
if (chat_message?.extra !== undefined) {
if (data?.swipes_info === undefined) {
_.set(data, 'swipe_info', _.times(data.swipes?.length ?? 1, _.constant({})));
}
_.set(data, 'extra', chat_message?.extra);
_.set(data, ['swipe_info', data.swipe_id ?? 0], chat_message?.extra);
}
} else if (
chat_message?.swipe_id !== undefined ||
chat_message?.swipes !== undefined ||
chat_message?.swipes_data !== undefined ||
chat_message?.swipes_info !== undefined
) {
const max_length =
_.max([chat_message.swipes?.length, chat_message.swipes_data?.length, chat_message.swipes_info?.length]) ??
data.swipes?.length ??
1;
_.set(chat_message, 'swipe_id', _.clamp(chat_message.swipe_id ?? data.swipe_id ?? 0, 0, max_length - 1));
_.set(chat_message, 'swipes', chat_message.swipes ?? data.swipes ?? [data.mes]);
_.set(chat_message, 'swipes_data', chat_message.swipes_data ?? data.variables ?? [{}]);
_.set(chat_message, 'swipes_info', chat_message.swipes_info ?? data.swipe_info ?? [{}]);
chat_message.swipes = _.range(0, max_length).map(i => chat_message.swipes?.[i] ?? '');
chat_message.swipes_data = _.range(0, max_length).map(i => chat_message.swipes_data?.[i] ?? {});
chat_message.swipes_info = _.range(0, max_length).map(i => chat_message.swipes_info?.[i] ?? {});
_.set(data, 'swipes', chat_message.swipes);
_.set(data, 'variables', chat_message.swipes_data);
_.set(data, 'swipe_info', chat_message.swipes_info);
_.set(data, 'swipe_id', chat_message.swipe_id);
_.set(data, 'mes', chat_message.swipes[chat_message.swipe_id as number]);
_.set(data, 'extra', chat_message.swipes_info[chat_message.swipe_id as number]);
}
};
chat_messages = convert_and_merge_messages(chat_messages);
await Promise.all(chat_messages.map(modify));
await refreshMessages(refresh, async () => {
await Promise.all(chat_messages.map(message => refreshOneMessage(message.message_id)));
});
}
type ChatMessageCreating = {
name?: string;
role: 'system' | 'assistant' | 'user';
is_hidden?: boolean;
message: string;
data?: Record<string, any>;
extra?: Record<string, any>;
};
type CreateChatMessagesOption = {
insert_at?: number | 'end';
insert_before?: number | 'end';
refresh?: 'none' | 'affected' | 'all';
};
export async function createChatMessages(
chat_messages: ChatMessageCreating[],
{ insert_at, insert_before = 'end', refresh = 'affected' }: CreateChatMessagesOption = {},
): Promise<void> {
insert_before = insert_at ?? insert_before;
insert_before = insert_before === 'end' ? chat.length : _.clamp(insert_before, -chat.length, chat.length);
const is_at_end = insert_before === chat.length;
const convert = async (chat_message: ChatMessageCreating): Promise<Record<string, any>> => {
let result = _({});
if (chat_message?.name !== undefined) {
result = result.set('name', chat_message.name);
} else if (chat_message.role === 'system') {
result = result.set('name', 'system');
} else if (chat_message.role === 'user') {
result = result.set('name', name1);
} else {
result = result.set('name', name2);
}
result = result.set('is_user', chat_message.role === 'user');
if (chat_message.role === 'system') {
result = result.set('extra.type', system_message_types.NARRATOR);
}
result = result.set('is_system', chat_message.is_hidden ?? false);
result = result.set('mes', chat_message.message);
if (chat_message.data) {
result = result.set(['variables', 0], chat_message.data);
}
if (chat_message.extra) {
result = result.set('extra', chat_message.extra);
}
return result.value();
};
const converted = await Promise.all(chat_messages.map(convert));
console.info(converted);
chat.splice(insert_before, 0, ...converted);
await refreshMessages(refresh, async () => {
await Promise.all(
converted.map(async (message, index) => {
const message_id = insert_before - converted.length + index + 1;
addOneMessage(
message,
is_at_end ? undefined : { insertBefore: insert_before, forceId: message_id, scroll: false },
);
await eventSource.emit(
message.is_user ? event_types.MESSAGE_SENT : event_types.MESSAGE_RECEIVED,
message_id,
'extension',
);
await eventSource.emit(
message.is_user ? event_types.USER_MESSAGE_RENDERED : event_types.CHARACTER_MESSAGE_RENDERED,
message_id,
);
}),
);
if (!is_at_end) {
const dirty_messages = _.range(insert_before, chat.length - converted.length);
await Promise.all(
dirty_messages.map(message_id =>
refreshOneMessage(message_id + converted.length, $(`#chat > .mes[mesid="${message_id}"]`).last()),
),
);
}
});
}
export async function deleteChatMessages(
message_ids: number[],
{ refresh = 'affected' }: SetChatMessagesOption = {},
): Promise<void> {
message_ids = _(message_ids)
.filter(inUnnormalizedMessageRange)
.map(id => normalizeMessageId(id))
.sort()
.sortedUniq()
.value();
if (message_ids.length === 0) {
return;
}
_.pullAt(chat, message_ids);
await refreshMessages(refresh, async () => {
const min_affected = Math.max(Number($(`#chat > .mes`).first().attr('mesid')), _.min(message_ids)!);
const deleted_in_affected = message_ids.filter(message_id => message_id >= min_affected);
const before_after: [number, number][] = _(_.range(min_affected, chat.length + message_ids.length))
.map(
message_id =>
[
message_id,
message_id - deleted_in_affected.reduce((sum, deleted_id) => sum + (deleted_id < message_id ? 1 : 0), 0),
] as [number, number],
)
.value();
await Promise.all(
before_after.map(([before, after]) => {
const $mes = $(`#chat > .mes[mesid="${before}"]`);
if (before === after) {
$mes.remove();
} else {
refreshOneMessage(after, $mes);
}
}),
);
await auditChatMessages();
});
}
export async function rotateChatMessages(
begin: number,
middle: number,
end: number,
{ refresh = 'affected' }: SetChatMessagesOption = {},
): Promise<void> {
begin = _.clamp(normalizeMessageId(begin), 0, chat.length);
end = _.clamp(normalizeMessageId(end), 0, chat.length);
middle = _.clamp(normalizeMessageId(middle), begin, end);
const right_part = chat.splice(middle, end - middle);
chat.splice(begin, 0, ...right_part);
await refreshMessages(refresh, async () => {
await Promise.all(
_.range(Math.max(Number($(`#chat > .mes`).first().attr('mesid')), _.min([begin, middle, end])!), chat.length).map(
message_id => refreshOneMessage(message_id),
),
);
});
}
//----------------------------------------------------------------------------------------------------------------------
/** @deprecated 请使用 `setChatMessages` 代替 */
export async function setChatMessage(
field_values: { message?: string; data?: Record<string, any> },
message_id: number,
{
swipe_id = 'current',
refresh = 'display_and_render_current',
}: {
swipe_id?: 'current' | number;
refresh?: 'none' | 'display_current' | 'display_and_render_current' | 'all';
} = {},
): Promise<void> {
field_values = typeof field_values === 'string' ? { message: field_values } : field_values;
if (typeof swipe_id !== 'number' && swipe_id !== 'current') {
throw Error(`提供的 swipe_id 无效, 请提供 'current' 或序号, 你提供的是: ${swipe_id} `);
}
if (!['none', 'display_current', 'display_and_render_current', 'all'].includes(refresh)) {
throw Error(
`提供的 refresh 无效, 请提供 'none', 'display_current', 'display_and_render_current' 或 'all', 你提供的是: ${refresh} `,
);
}
const chat_message = chat.at(message_id);
if (!chat_message) {
return;
}
const add_swipes_if_required = (): boolean => {
if (swipe_id === 'current') {
return false;
}
// swipe_id 对应的消息页存在
if (swipe_id == 0 || (chat_message.swipes && swipe_id < chat_message.swipes.length)) {
return true;
}
if (!chat_message.swipes) {
chat_message.swipe_id = 0;
chat_message.swipes = [chat_message.mes];
chat_message.variables = [{}];
}
for (let i = chat_message.swipes.length; i <= swipe_id; ++i) {
chat_message.swipes.push('');
chat_message.variables.push({});
}
return true;
};
const swipe_id_previous_index: number = chat_message.swipe_id ?? 0;
const swipe_id_to_set_index: number = swipe_id == 'current' ? swipe_id_previous_index : swipe_id;
const swipe_id_to_use_index: number = refresh != 'none' ? swipe_id_to_set_index : swipe_id_previous_index;
const message: string =
field_values.message ??
(chat_message.swipes ? chat_message.swipes[swipe_id_to_set_index] : undefined) ??
chat_message.mes;
const update_chat_message = () => {
const message_demacroed = substituteParamsExtended(message);
if (field_values.data) {
if (!chat_message.variables) {
chat_message.variables = [];
}
chat_message.variables[swipe_id_to_set_index] = field_values.data;
}
if (chat_message.swipes) {
chat_message.swipes[swipe_id_to_set_index] = message_demacroed;
chat_message.swipe_id = swipe_id_to_use_index;
}
if (swipe_id_to_use_index === swipe_id_to_set_index) {
chat_message.mes = message_demacroed;
}
};
const update_partial_html = async (should_update_swipe: boolean) => {
const mes_html = $(`#chat > .mes[mesid = "${message_id}"]`);
if (!mes_html) {
return;
}
if (should_update_swipe && chat_message.swipes) {
mes_html.find('.swipes-counter').text(`${swipe_id_to_use_index + 1}\u200b/\u200b${chat_message.swipes.length}`);
if (message_id === chat.length - 1) {
showSwipeButtons();
}
}
if (refresh != 'none') {
mes_html
.find('.mes_text')
.empty()
.append(
messageFormatting(message, chat_message.name, chat_message.is_system, chat_message.is_user, message_id),
);
if (refresh === 'display_and_render_current') {
await eventSource.emit(
chat_message.is_user ? event_types.USER_MESSAGE_RENDERED : event_types.CHARACTER_MESSAGE_RENDERED,
message_id,
);
}
}
};
const should_update_swipe: boolean = add_swipes_if_required();
update_chat_message();
await saveChatConditional();
if (refresh == 'all') {
await reloadCurrentChat();
} else {
await update_partial_html(should_update_swipe);
}
}

View File

@@ -0,0 +1,156 @@
import { isFrontend } from '@/util/is_frontend';
import { inMessageRange, normalizeMessageId } from '@/util/message';
import { highlight_code } from '@/util/tavern';
import {
characters,
chat,
default_avatar,
event_types,
eventSource,
getThumbnailUrl,
messageFormatting,
showSwipeButtons,
system_avatar,
this_chid,
user_avatar,
} from '@sillytavern/script';
import { getLastMessageId } from '@sillytavern/scripts/macros';
type FormatAsDisplayedMessageOption = {
message_id?: 'last' | 'last_user' | 'last_char' | number;
};
export function formatAsDisplayedMessage(
text: string,
{ message_id = 'last' }: FormatAsDisplayedMessageOption = {},
): string {
if (typeof message_id !== 'number' && !['last', 'last_user', 'last_char'].includes(message_id)) {
throw Error(
`提供的 message_id 无效, 请提供 'last', 'last_user', 'last_char' 或楼层消息号, 你提供的是: ${message_id}`,
);
}
const last_message_id = getLastMessageId();
if (last_message_id === null) {
throw Error(`未找到任何消息楼层`);
}
switch (message_id) {
case 'last':
message_id = last_message_id;
break;
case 'last_user': {
const last_user_message_id = getLastMessageId({ filter: (m: any) => m.is_user && !m.is_system }) as number;
if (last_user_message_id === null) {
throw Error(`未找到任何 user 消息楼层, 你提供的是: ${message_id}`);
}
message_id = last_user_message_id;
break;
}
case 'last_char': {
const last_char_message_id = getLastMessageId({ filter: (m: any) => !m.is_user && !m.is_system }) as number;
if (last_char_message_id === null) {
throw Error(`未找到任何 char 消息楼层, 你提供的是: ${message_id}`);
}
message_id = last_char_message_id;
break;
}
}
const normalized_message_id = normalizeMessageId(message_id);
if (!inMessageRange(normalized_message_id)) {
throw Error(`提供的 message_id 不在 [${-last_message_id - 1}, ${last_message_id}] 内, 你提供的是: ${message_id}`);
}
const chat_message = chat[normalized_message_id];
const result = messageFormatting(
text,
chat_message.name,
chat_message.is_system,
chat_message.is_user,
normalized_message_id,
);
const $div = $('<div>').append(result);
$div.find('pre code').each((_index, element) => {
const $node = $(element);
if ($node.hasClass('hljs') || isFrontend($node.text())) {
return;
}
hljs.highlightElement(element);
});
return $div.html();
}
export function retrieveDisplayedMessage(message_id: number): JQuery<HTMLDivElement> {
return $(`#chat > .mes[mesid = "${message_id}"]`, window.parent.document).find(`div.mes_text`);
}
export async function refreshOneMessage(message_id: number, $mes?: JQuery<HTMLElement>): Promise<void> {
if ($mes && $mes.length === 0) {
return;
}
$mes = $mes ?? $(`#chat > .mes[mesid = "${message_id}"]`);
if (!$mes) {
return;
}
const chat_message = chat[message_id];
$mes.attr({
mesid: message_id,
swipeid: chat_message.swipe_id ?? 0,
ch_name: chat_message.name,
is_user: chat_message.is_user,
is_system: !!chat_message.is_system,
force_avatar: !!chat_message.force_avatar,
type: chat_message.extra?.type ?? '',
});
$mes
.find('.avatar img')
.attr(
'src',
chat_message.force_avatar
? chat_message.force_avatar
: chat_message.is_user
? getThumbnailUrl('persona', user_avatar)
: this_chid === undefined
? system_avatar
: characters[Number(this_chid)].avatar !== 'none'
? getThumbnailUrl('avatar', characters[Number(this_chid)].avatar)
: default_avatar,
);
$mes.find('.ch_name .name_text').text(chat_message.name);
$mes.find('.mesIDDisplay').text(`#${message_id}`);
if (chat_message.extra?.token_count) {
$mes.find('.tokenCounterDisplay').text(`${chat_message.extra.token_count}t`);
}
if (chat_message.swipes) {
$mes.find('.swipes-counter').text(`${chat_message.swipe_id + 1}\u200b/\u200b${chat_message.swipes.length}`);
if (message_id === chat.length - 1) {
showSwipeButtons();
}
}
$mes
.find('.mes_text')
.empty()
.append(
messageFormatting(chat_message.mes, chat_message.name, chat_message.is_system, chat_message.is_user, message_id),
);
$mes.find('pre code').each((_index, element) => {
highlight_code(element);
if ($(element).is('[data-highlighted="yes"]')) {
$(element).css('position', 'relative');
}
});
await eventSource.emit(
chat_message.is_user ? event_types.USER_MESSAGE_RENDERED : event_types.CHARACTER_MESSAGE_RENDERED,
message_id,
);
}

View File

@@ -0,0 +1,506 @@
/* eslint-disable @typescript-eslint/no-unsafe-function-type */
import { _getButtonEvent } from '@/function/script';
import { _getIframeName } from '@/function/util';
import { getOrSet } from '@/util/algorithm';
import { eventSource } from '@sillytavern/script';
import { LiteralUnion } from 'type-fest';
const iframe_event_listener_wrapper_map: Map<string, Map<string, Map<Function, Function>>> = new Map();
function get_event_listener_wrapper_map(this: Window): Map<string, Map<Function, Function>> {
return getOrSet(
iframe_event_listener_wrapper_map,
_getIframeName.call(this),
() => new Map<string, Map<Function, Function>>(),
);
}
function get_listener_wrapper_map(this: Window, event_type: string): Map<Function, Function> {
const event_listener_wrapper_map = get_event_listener_wrapper_map.call(this);
return getOrSet(event_listener_wrapper_map, event_type, () => new Map<Function, Function>());
}
function register_listener_wrapper(
this: Window,
event_type: string,
listener: Function,
options: { once?: boolean } = {},
): Function {
const listener_wrapper_map = get_listener_wrapper_map.call(this, event_type);
return getOrSet(listener_wrapper_map, listener, () => {
const wrapper = (...args: any[]) => {
const listener_wrapper_map = get_listener_wrapper_map.call(this, event_type);
if (!listener_wrapper_map?.has(listener)) {
eventSource.removeListener(event_type, wrapper);
return;
}
if (
[
tavern_events.MESSAGE_SWIPED,
tavern_events.MESSAGE_SENT,
tavern_events.MESSAGE_RECEIVED,
tavern_events.MESSAGE_EDITED,
tavern_events.MESSAGE_UPDATED,
tavern_events.USER_MESSAGE_RENDERED,
tavern_events.CHARACTER_MESSAGE_RENDERED,
].some(event => event === event_type)
) {
args[0] = parseInt(args[0]);
if (isNaN(args[0])) {
return;
}
}
const result = listener(...args);
if (options.once) {
_eventRemoveListener.call(this, event_type, listener as any);
}
return result;
};
return wrapper;
});
}
type EventOnReturn = {
stop: () => void;
};
function make_event_on_return(this: Window, event_type: string, listener: Function) {
return {
stop: () => _eventRemoveListener.call(this, event_type, listener as any),
};
}
export function _eventOn<T extends EventType>(this: Window, event_type: T, listener: ListenerType[T]): EventOnReturn {
const wrapped = register_listener_wrapper.call(this, event_type, listener);
eventSource.on(event_type, wrapped);
return make_event_on_return.call(this, event_type, wrapped);
}
/** @deprecated */
export function _eventOnButton<T extends EventType>(this: Window, event_type: T, listener: ListenerType[T]): void {
_eventOn.call(this, _getButtonEvent.call(this, event_type), listener);
}
export function _eventMakeLast<T extends EventType>(
this: Window,
event_type: T,
listener: ListenerType[T],
): EventOnReturn {
const wrapped = register_listener_wrapper.call(this, event_type, listener);
eventSource.makeLast(event_type, wrapped);
return make_event_on_return.call(this, event_type, wrapped);
}
export function _eventMakeFirst<T extends EventType>(
this: Window,
event_type: T,
listener: ListenerType[T],
): EventOnReturn {
const wrapped = register_listener_wrapper.call(this, event_type, listener);
eventSource.makeFirst(event_type, wrapped);
return make_event_on_return.call(this, event_type, wrapped);
}
export function _eventOnce<T extends EventType>(this: Window, event_type: T, listener: ListenerType[T]): EventOnReturn {
const wrapped = register_listener_wrapper.call(this, event_type, listener, { once: true });
eventSource.once(event_type, wrapped);
return make_event_on_return.call(this, event_type, wrapped);
}
export async function _eventEmit<T extends EventType>(
this: Window,
event_type: T,
...data: Parameters<ListenerType[T]>
): Promise<void> {
await eventSource.emit(event_type, ...data);
}
export function _eventEmitAndWait<T extends EventType>(
this: Window,
event_type: T,
...data: Parameters<ListenerType[T]>
): void {
eventSource.emitAndWait(event_type, ...data);
}
export function _eventRemoveListener<T extends EventType>(
this: Window,
event_type: T,
listener: ListenerType[T],
): void {
const listener_wrapper_map = get_listener_wrapper_map.call(this, event_type);
if (listener_wrapper_map) {
const wrapper = listener_wrapper_map.get(listener);
if (wrapper) {
listener_wrapper_map.delete(listener);
eventSource.removeListener(event_type, wrapper);
}
}
}
export function _eventClearEvent(this: Window, event_type: EventType): void {
get_listener_wrapper_map.call(this, event_type)?.forEach((_wrapper, listener) => {
_eventRemoveListener.call(this, event_type, listener as any);
});
}
export function _eventClearListener(this: Window, listener: Function): void {
get_event_listener_wrapper_map.call(this).forEach((_listeners, event_type) => {
_eventRemoveListener.call(this, event_type, listener as any);
});
}
export function _eventClearAll(this: Window): void {
get_event_listener_wrapper_map.call(this).forEach((listeners, event_type) => {
listeners.forEach((_wrapper, listener) => {
_eventRemoveListener.call(this, event_type, listener as any);
});
});
}
type EventType = IframeEventType | TavernEventType | string;
type IframeEventType = (typeof iframe_events)[keyof typeof iframe_events];
export const iframe_events = {
MESSAGE_IFRAME_RENDER_STARTED: 'message_iframe_render_started',
MESSAGE_IFRAME_RENDER_ENDED: 'message_iframe_render_ended',
GENERATION_STARTED: 'js_generation_started',
STREAM_TOKEN_RECEIVED_FULLY: 'js_stream_token_received_fully',
STREAM_TOKEN_RECEIVED_INCREMENTALLY: 'js_stream_token_received_incrementally',
GENERATION_ENDED: 'js_generation_ended',
} as const;
type TavernEventType = (typeof tavern_events)[keyof typeof tavern_events];
export const tavern_events = {
APP_READY: 'app_ready',
EXTRAS_CONNECTED: 'extras_connected',
MESSAGE_SWIPED: 'message_swiped',
MESSAGE_SENT: 'message_sent',
MESSAGE_RECEIVED: 'message_received',
MESSAGE_EDITED: 'message_edited',
MESSAGE_DELETED: 'message_deleted',
MESSAGE_UPDATED: 'message_updated',
MESSAGE_FILE_EMBEDDED: 'message_file_embedded',
MESSAGE_REASONING_EDITED: 'message_reasoning_edited',
MESSAGE_REASONING_DELETED: 'message_reasoning_deleted',
MESSAGE_SWIPE_DELETED: 'message_swipe_deleted',
MORE_MESSAGES_LOADED: 'more_messages_loaded',
IMPERSONATE_READY: 'impersonate_ready',
CHAT_CHANGED: 'chat_id_changed',
GENERATION_AFTER_COMMANDS: 'GENERATION_AFTER_COMMANDS',
GENERATION_STARTED: 'generation_started',
GENERATION_STOPPED: 'generation_stopped',
GENERATION_ENDED: 'generation_ended',
SD_PROMPT_PROCESSING: 'sd_prompt_processing',
EXTENSIONS_FIRST_LOAD: 'extensions_first_load',
EXTENSION_SETTINGS_LOADED: 'extension_settings_loaded',
SETTINGS_LOADED: 'settings_loaded',
SETTINGS_UPDATED: 'settings_updated',
MOVABLE_PANELS_RESET: 'movable_panels_reset',
SETTINGS_LOADED_BEFORE: 'settings_loaded_before',
SETTINGS_LOADED_AFTER: 'settings_loaded_after',
CHATCOMPLETION_SOURCE_CHANGED: 'chatcompletion_source_changed',
CHATCOMPLETION_MODEL_CHANGED: 'chatcompletion_model_changed',
OAI_PRESET_CHANGED_BEFORE: 'oai_preset_changed_before',
OAI_PRESET_CHANGED_AFTER: 'oai_preset_changed_after',
OAI_PRESET_EXPORT_READY: 'oai_preset_export_ready',
OAI_PRESET_IMPORT_READY: 'oai_preset_import_ready',
WORLDINFO_SETTINGS_UPDATED: 'worldinfo_settings_updated',
WORLDINFO_UPDATED: 'worldinfo_updated',
CHARACTER_EDITOR_OPENED: 'character_editor_opened',
CHARACTER_EDITED: 'character_edited',
CHARACTER_PAGE_LOADED: 'character_page_loaded',
USER_MESSAGE_RENDERED: 'user_message_rendered',
CHARACTER_MESSAGE_RENDERED: 'character_message_rendered',
FORCE_SET_BACKGROUND: 'force_set_background',
CHAT_DELETED: 'chat_deleted',
CHAT_CREATED: 'chat_created',
GENERATE_BEFORE_COMBINE_PROMPTS: 'generate_before_combine_prompts',
GENERATE_AFTER_COMBINE_PROMPTS: 'generate_after_combine_prompts',
GENERATE_AFTER_DATA: 'generate_after_data',
WORLD_INFO_ACTIVATED: 'world_info_activated',
TEXT_COMPLETION_SETTINGS_READY: 'text_completion_settings_ready',
CHAT_COMPLETION_SETTINGS_READY: 'chat_completion_settings_ready',
CHAT_COMPLETION_PROMPT_READY: 'chat_completion_prompt_ready',
CHARACTER_FIRST_MESSAGE_SELECTED: 'character_first_message_selected',
CHARACTER_DELETED: 'characterDeleted',
CHARACTER_DUPLICATED: 'character_duplicated',
CHARACTER_RENAMED: 'character_renamed',
CHARACTER_RENAMED_IN_PAST_CHAT: 'character_renamed_in_past_chat',
SMOOTH_STREAM_TOKEN_RECEIVED: 'stream_token_received',
STREAM_TOKEN_RECEIVED: 'stream_token_received',
STREAM_REASONING_DONE: 'stream_reasoning_done',
FILE_ATTACHMENT_DELETED: 'file_attachment_deleted',
WORLDINFO_FORCE_ACTIVATE: 'worldinfo_force_activate',
OPEN_CHARACTER_LIBRARY: 'open_character_library',
ONLINE_STATUS_CHANGED: 'online_status_changed',
IMAGE_SWIPED: 'image_swiped',
CONNECTION_PROFILE_LOADED: 'connection_profile_loaded',
CONNECTION_PROFILE_CREATED: 'connection_profile_created',
CONNECTION_PROFILE_DELETED: 'connection_profile_deleted',
CONNECTION_PROFILE_UPDATED: 'connection_profile_updated',
TOOL_CALLS_PERFORMED: 'tool_calls_performed',
TOOL_CALLS_RENDERED: 'tool_calls_rendered',
CHARACTER_MANAGEMENT_DROPDOWN: 'charManagementDropdown',
SECRET_WRITTEN: 'secret_written',
SECRET_DELETED: 'secret_deleted',
SECRET_ROTATED: 'secret_rotated',
SECRET_EDITED: 'secret_edited',
PRESET_CHANGED: 'preset_changed',
PRESET_DELETED: 'preset_deleted',
PRESET_RENAMED: 'preset_renamed',
PRESET_RENAMED_BEFORE: 'preset_renamed_before',
MAIN_API_CHANGED: 'main_api_changed',
WORLDINFO_ENTRIES_LOADED: 'worldinfo_entries_loaded',
WORLDINFO_SCAN_DONE: 'worldinfo_scan_done',
MEDIA_ATTACHMENT_DELETED: 'media_attachment_deleted',
} as const;
export type SendingMessage = {
role: 'user' | 'assistant' | 'system';
content:
| string
| Array<
| { type: 'text'; text: string }
| { type: 'image_url'; image_url: { url: string; detail: 'auto' | 'low' | 'high' } }
| { type: 'video_url'; video_url: { url: string } }
>;
};
export type ListenerType = {
[iframe_events.MESSAGE_IFRAME_RENDER_STARTED]: (iframe_name: string) => void;
[iframe_events.MESSAGE_IFRAME_RENDER_ENDED]: (iframe_name: string) => void;
[iframe_events.GENERATION_STARTED]: (generation_id: string) => void;
[iframe_events.STREAM_TOKEN_RECEIVED_FULLY]: (full_text: string, generation_id: string) => void;
[iframe_events.STREAM_TOKEN_RECEIVED_INCREMENTALLY]: (incremental_text: string, generation_id: string) => void;
[iframe_events.GENERATION_ENDED]: (text: string, generation_id: string) => void;
[tavern_events.APP_READY]: () => void;
[tavern_events.EXTRAS_CONNECTED]: (modules: any) => void;
[tavern_events.MESSAGE_SWIPED]: (message_id: number) => void;
[tavern_events.MESSAGE_SENT]: (message_id: number) => void;
[tavern_events.MESSAGE_RECEIVED]: (
message_id: number,
type: LiteralUnion<
| 'normal'
| 'quiet'
| 'regenerate'
| 'impersonate'
| 'continue'
| 'swipe'
| 'append'
| 'appendFinal'
| 'first_message'
| 'command'
| 'extension',
string
>,
) => void;
[tavern_events.MESSAGE_EDITED]: (message_id: number) => void;
[tavern_events.MESSAGE_DELETED]: (message_id: number) => void;
[tavern_events.MESSAGE_UPDATED]: (message_id: number) => void;
[tavern_events.MESSAGE_FILE_EMBEDDED]: (message_id: number) => void;
[tavern_events.MESSAGE_REASONING_EDITED]: (message_id: number) => void;
[tavern_events.MESSAGE_REASONING_DELETED]: (message_id: number) => void;
[tavern_events.MESSAGE_SWIPE_DELETED]: (event_data: {
messageId: number;
swipeId: number;
newSwipeId: number;
}) => void;
[tavern_events.MORE_MESSAGES_LOADED]: () => void;
[tavern_events.IMPERSONATE_READY]: (message: string) => void;
[tavern_events.CHAT_CHANGED]: (chat_file_name: string) => void;
[tavern_events.GENERATION_AFTER_COMMANDS]: (
type: string,
option: {
automatic_trigger?: boolean;
force_name2?: boolean;
quiet_prompt?: string;
quietToLoud?: boolean;
skipWIAN?: boolean;
force_chid?: number;
signal?: AbortSignal;
quietImage?: string;
quietName?: string;
depth?: number;
},
dry_run: boolean,
) => void;
[tavern_events.GENERATION_STARTED]: (
type: string,
option: {
automatic_trigger?: boolean;
force_name2?: boolean;
quiet_prompt?: string;
quietToLoud?: boolean;
skipWIAN?: boolean;
force_chid?: number;
signal?: AbortSignal;
quietImage?: string;
quietName?: string;
depth?: number;
},
dry_run: boolean,
) => void;
[tavern_events.GENERATION_STOPPED]: () => void;
[tavern_events.GENERATION_ENDED]: (message_id: number) => void;
[tavern_events.SD_PROMPT_PROCESSING]: (event_data: {
prompt: string;
generationType: number;
message: string;
trigger: string;
}) => void;
[tavern_events.EXTENSIONS_FIRST_LOAD]: () => void;
[tavern_events.EXTENSION_SETTINGS_LOADED]: () => void;
[tavern_events.SETTINGS_LOADED]: () => void;
[tavern_events.SETTINGS_UPDATED]: () => void;
[tavern_events.MOVABLE_PANELS_RESET]: () => void;
[tavern_events.SETTINGS_LOADED_BEFORE]: (settings: object) => void;
[tavern_events.SETTINGS_LOADED_AFTER]: (settings: object) => void;
[tavern_events.CHATCOMPLETION_SOURCE_CHANGED]: (source: string) => void;
[tavern_events.CHATCOMPLETION_MODEL_CHANGED]: (model: string) => void;
[tavern_events.OAI_PRESET_CHANGED_BEFORE]: (result: {
preset: object;
presetName: string;
settingsToUpdate: object;
settings: object;
savePreset: Function;
}) => void;
[tavern_events.OAI_PRESET_CHANGED_AFTER]: () => void;
[tavern_events.OAI_PRESET_EXPORT_READY]: (preset: object) => void;
[tavern_events.OAI_PRESET_IMPORT_READY]: (result: { data: object; presetName: string }) => void;
[tavern_events.WORLDINFO_SETTINGS_UPDATED]: () => void;
[tavern_events.WORLDINFO_UPDATED]: (name: string, data: { entries: object[] }) => void;
[tavern_events.CHARACTER_EDITOR_OPENED]: (chid: string) => void;
[tavern_events.CHARACTER_EDITED]: (result: { detail: { id: string; character: object } }) => void;
[tavern_events.CHARACTER_PAGE_LOADED]: () => void;
[tavern_events.USER_MESSAGE_RENDERED]: (message_id: number) => void;
[tavern_events.CHARACTER_MESSAGE_RENDERED]: (message_id: number, type: string) => void;
[tavern_events.FORCE_SET_BACKGROUND]: (background: { url: string; path: string }) => void;
[tavern_events.CHAT_DELETED]: (chat_file_name: string) => void;
[tavern_events.CHAT_CREATED]: () => void;
[tavern_events.GENERATE_BEFORE_COMBINE_PROMPTS]: () => void;
[tavern_events.GENERATE_AFTER_COMBINE_PROMPTS]: (result: { prompt: string; dryRun: boolean }) => void;
/** dry_run 只在 SillyTavern 1.13.15 及以后有 */
[tavern_events.GENERATE_AFTER_DATA]: (
generate_data: {
prompt: SendingMessage[];
},
dry_run: boolean,
) => void;
[tavern_events.WORLD_INFO_ACTIVATED]: (entries: any[]) => void;
[tavern_events.TEXT_COMPLETION_SETTINGS_READY]: () => void;
[tavern_events.CHAT_COMPLETION_SETTINGS_READY]: (generate_data: {
messages: SendingMessage[];
model: string;
temprature: number;
frequency_penalty: number;
presence_penalty: number;
top_p: number;
max_tokens: number;
stream: boolean;
logit_bias: object;
stop: string[];
chat_comletion_source: string;
n?: number;
user_name: string;
char_name: string;
group_names: string[];
include_reasoning: boolean;
reasoning_effort: string;
json_schema: {
name: string;
value: Record<string, any>;
description?: string;
strict?: boolean;
};
[others: string]: any;
}) => void;
[tavern_events.CHAT_COMPLETION_PROMPT_READY]: (event_data: { chat: SendingMessage[]; dryRun: boolean }) => void;
[tavern_events.CHARACTER_FIRST_MESSAGE_SELECTED]: (event_args: {
input: string;
output: string;
character: object;
}) => void;
[tavern_events.CHARACTER_DELETED]: (result: { id: string; character: object }) => void;
[tavern_events.CHARACTER_DUPLICATED]: (result: { oldAvatar: string; newAvatar: string }) => void;
[tavern_events.CHARACTER_RENAMED]: (old_avatar: string, new_avatar: string) => void;
[tavern_events.CHARACTER_RENAMED_IN_PAST_CHAT]: (
current_chat: Record<string, any>,
old_avatar: string,
new_avatar: string,
) => void;
[tavern_events.STREAM_TOKEN_RECEIVED]: (text: string) => void;
[tavern_events.STREAM_REASONING_DONE]: (
reasoning: string,
duration: number | null,
message_id: number,
state: 'none' | 'thinking' | 'done' | 'hidden',
) => void;
[tavern_events.FILE_ATTACHMENT_DELETED]: (url: string) => void;
[tavern_events.WORLDINFO_FORCE_ACTIVATE]: (entries: object[]) => void;
[tavern_events.OPEN_CHARACTER_LIBRARY]: () => void;
[tavern_events.ONLINE_STATUS_CHANGED]: () => void;
[tavern_events.IMAGE_SWIPED]: (result: {
message: object;
element: JQuery<HTMLElement>;
direction: 'left' | 'right';
}) => void;
[tavern_events.CONNECTION_PROFILE_LOADED]: (profile_name: string) => void;
[tavern_events.CONNECTION_PROFILE_CREATED]: (profile: Record<string, any>) => void;
[tavern_events.CONNECTION_PROFILE_DELETED]: (profile: Record<string, any>) => void;
[tavern_events.CONNECTION_PROFILE_UPDATED]: (
old_profile: Record<string, any>,
new_profile: Record<string, any>,
) => void;
[tavern_events.TOOL_CALLS_PERFORMED]: (tool_invocations: object[]) => void;
[tavern_events.TOOL_CALLS_RENDERED]: (tool_invocations: object[]) => void;
[tavern_events.CHARACTER_MANAGEMENT_DROPDOWN]: (target: JQuery) => void;
[tavern_events.SECRET_WRITTEN]: (secret: string) => void;
[tavern_events.SECRET_DELETED]: (secret: string) => void;
[tavern_events.SECRET_ROTATED]: (secret: string) => void;
[tavern_events.SECRET_EDITED]: (secret: string) => void;
[tavern_events.PRESET_CHANGED]: (data: { apiId: string; name: string }) => void;
[tavern_events.PRESET_DELETED]: (data: { apiId: string; name: string }) => void;
[tavern_events.PRESET_RENAMED]: (data: { apiId: string; oldName: string; newName: string }) => void;
[tavern_events.PRESET_RENAMED_BEFORE]: (data: { apiId: string; oldName: string; newName: string }) => void;
[tavern_events.MAIN_API_CHANGED]: (data: { apiId: string }) => void;
[tavern_events.WORLDINFO_ENTRIES_LOADED]: (lores: {
globalLore: Record<string, any>[];
characterLore: Record<string, any>[];
chatLore: Record<string, any>[];
personaLore: Record<string, any>[];
}) => void;
[tavern_events.WORLDINFO_SCAN_DONE]: (event_data: {
state: {
current: number;
next: number;
loopCount: number;
};
new: {
all: Record<string, any>[];
successful: Record<string, any>[];
};
activated: {
entries: Map<`${string}.${string}`, Record<string, any>>;
text: string;
};
sortedEntries: Record<string, any>[];
recursionDelay: {
availableLevels: number[];
currentLevel: number;
};
budget: {
current: number;
overflowed: boolean;
};
timedEffects: Record<string, any>;
}) => void;
[custom_event: string]: (...args: any) => any;
};

View File

@@ -0,0 +1,106 @@
import { getRequestHeaders } from '@sillytavern/script';
import { extensionTypes } from '@sillytavern/scripts/extensions';
import { isAdmin as isAdminImpl } from '@sillytavern/scripts/user';
export const isAdmin = isAdminImpl;
export function getTavernHelperExtensionId(): string {
return 'JS-Slash-Runner';
}
export function getExtensionType(extension_id: string): 'local' | 'global' | 'system' | null {
const result = Object.keys(extensionTypes).find(result => result.endsWith(extension_id));
return result ? (extensionTypes[result] as 'global' | 'local' | 'system') : null;
}
type ExtensionInstallationInfo = {
current_branch_name: string;
current_commit_hash: string;
is_up_to_date: boolean;
remote_url: string;
};
export async function getExtensionInstallationInfo(extension_id: string): Promise<ExtensionInstallationInfo | null> {
const type = getExtensionType(extension_id);
if (!type) {
return null;
}
const response = await fetch('/api/extensions/version', {
method: 'POST',
headers: getRequestHeaders(),
body: JSON.stringify({
extensionName: extension_id,
global: type === 'global',
}),
});
return _.mapKeys(await response.json(), (_value, key) => _.snakeCase(key)) as ExtensionInstallationInfo;
}
export function isInstalledExtension(extension_id: string): boolean {
return getExtensionType(extension_id) !== null;
}
export async function installExtension(url: string, type: 'local' | 'global'): Promise<Response> {
if (!isAdmin() && type === 'global') {
return Response.json({ message: '只有管理员才能安装全局扩展' }, { status: 403 });
}
const response = await fetch('/api/extensions/install', {
method: 'POST',
headers: getRequestHeaders(),
body: JSON.stringify({ url, global: type === 'global' }),
});
return response;
}
export async function uninstallExtension(extension_id: string): Promise<Response> {
const type = getExtensionType(extension_id);
if (!type) {
return Response.json({ message: '扩展不存在' }, { status: 404 });
}
if (!isAdmin() && type === 'global') {
return Response.json({ message: '只有管理员才能卸载全局扩展' }, { status: 403 });
}
const response = await fetch('/api/extensions/delete', {
method: 'POST',
headers: getRequestHeaders(),
body: JSON.stringify({ extensionName: extension_id, global: type === 'global' }),
});
return response;
}
export async function reinstallExtension(extension_id: string): Promise<Response> {
const type = getExtensionType(extension_id);
if (!type) {
return Response.json({ message: '扩展不存在' }, { status: 404 });
}
if (!isAdmin() && type === 'global') {
return Response.json({ message: '只有管理员才能重新安装全局扩展' }, { status: 403 });
}
const status = (await getExtensionInstallationInfo(extension_id))!;
if (status.is_up_to_date) {
return Response.json({ message: '扩展已是最新版本' }, { status: 200 });
}
const response = await uninstallExtension(extension_id);
if (!response.ok) {
return response;
}
return installExtension(status.remote_url, type as 'local' | 'global');
}
export async function updateExtension(extension_id: string): Promise<Response> {
const type = getExtensionType(extension_id);
if (!type) {
return Response.json({ message: '扩展不存在' }, { status: 404 });
}
if (!isAdmin() && type === 'global') {
return Response.json({ message: '只有管理员才能更新全局扩展' }, { status: 403 });
}
const response = await fetch('/api/extensions/update', {
method: 'POST',
headers: getRequestHeaders(),
body: JSON.stringify({ extensionName: extension_id, global: type === 'global' }),
});
return response;
}

View File

@@ -0,0 +1,389 @@
import { detail, RolePrompt } from '@/function/generate/types';
import {
addTemporaryUserMessage,
clearInjectionPrompts,
isPromptFiltered,
parseMesExamples,
removeTemporaryUserMessage,
} from '@/function/generate/utils';
import { injectPrompts } from '@/function/inject';
import {
baseChatReplace,
characters,
chat,
chat_metadata,
extension_prompt_types,
extension_prompts,
getBiasStrings,
getCharacterCardFields,
getExtensionPromptRoleByName,
getMaxContextSize,
name1,
name2,
setExtensionPrompt,
this_chid,
} from '@sillytavern/script';
import { metadata_keys, NOTE_MODULE_NAME, shouldWIAddPrompt } from '@sillytavern/scripts/authors-note';
import { extension_settings } from '@sillytavern/scripts/extensions';
import { getRegexedString, regex_placement } from '@sillytavern/scripts/extensions/regex/engine';
import { setOpenAIMessageExamples, setOpenAIMessages } from '@sillytavern/scripts/openai';
import { persona_description_positions, power_user } from '@sillytavern/scripts/power-user';
import { uuidv4 } from '@sillytavern/scripts/utils';
import { getWorldInfoPrompt, wi_anchor_position, world_info_include_names } from '@sillytavern/scripts/world-info';
/**
* 准备并覆盖数据的核心函数
* @param config 配置参数
* @param processedUserInput 处理后的用户输入
* @returns 包含角色信息、聊天上下文和世界信息的数据对象
*/
export async function prepareAndOverrideData(
config: Omit<detail.GenerateParams, 'user_input' | 'use_preset'>,
processedUserInput: string,
) {
const getOverrideContent = (identifier: string): string | RolePrompt[] | undefined => {
if (!config.overrides) return undefined;
const value = config.overrides[identifier as keyof detail.OverrideConfig];
if (typeof value === 'boolean') return undefined;
return value;
};
// 1. 处理角色卡高级定义角色备注 - 仅在chat_history未被过滤时执行
if (!isPromptFiltered('chat_history', config)) {
handleCharDepthPrompt();
}
// 2. 设置作者注释 - 仅在chat_history未被过滤时执行
if (!isPromptFiltered('chat_history', config) && !isPromptFiltered('author_note', config)) {
setAuthorNotePrompt(config);
}
// 3. 处理user角色描述 - 仅在chat_history和persona_description都未被过滤时执行
if (!isPromptFiltered('chat_history', config) && !isPromptFiltered('persona_description', config)) {
setPersonaDescriptionExtensionPrompt();
}
const character = characters.at(this_chid as unknown as number);
// 4. 获取角色卡基础字段
const charDepthPrompt = baseChatReplace(character?.data?.extensions?.depth_prompt?.prompt?.trim(), name1, name2);
const creatorNotes = baseChatReplace(character?.data?.creator_notes?.trim(), name1, name2);
const {
description: rawDescription,
personality: rawPersonality,
persona: rawPersona,
scenario: rawScenario,
mesExamples: rawMesExamples,
system,
jailbreak,
} = getCharacterCardFields();
// 判断是否被过滤,如果被过滤返回空字符串,否则返回override的值或原始值
const description = isPromptFiltered('char_description', config)
? ''
: (getOverrideContent('char_description') ?? rawDescription);
const personality = isPromptFiltered('char_personality', config)
? ''
: (getOverrideContent('char_personality') ?? rawPersonality);
const persona = isPromptFiltered('persona_description', config)
? ''
: (getOverrideContent('persona_description') ?? rawPersona);
const scenario = isPromptFiltered('scenario', config) ? '' : (getOverrideContent('scenario') ?? rawScenario);
const mesExamples = isPromptFiltered('dialogue_examples', config)
? ''
: ((getOverrideContent('dialogue_examples') as string) ?? rawMesExamples);
let mesExamplesArray = parseMesExamples(mesExamples);
let oaiMessageExamples = [];
oaiMessageExamples = setOpenAIMessageExamples(mesExamplesArray);
// 5. 获取偏置字符串
const { promptBias } = getBiasStrings(processedUserInput, 'normal');
// 6. 处理自定义注入的提示词
if (config.inject) {
await handleInjectedPrompts(config);
}
// 7. 处理聊天记录
let oaiMessages = [];
if (config.overrides?.chat_history) {
oaiMessages = [...config.overrides.chat_history].reverse();
} else {
oaiMessages = setOpenAIMessages(await processChatHistory(chat));
if (config.max_chat_history !== undefined) {
oaiMessages = oaiMessages.slice(0, config.max_chat_history);
}
}
// 添加临时消息用于激活世界书
addTemporaryUserMessage(processedUserInput);
// 8. 处理世界信息
const worldInfo = await processWorldInfo(oaiMessages as RolePrompt[], config, {
description: rawDescription,
personality: rawPersonality,
persona: rawPersona,
scenario: rawScenario,
charDepthPrompt,
creatorNotes,
});
// 移除临时消息
removeTemporaryUserMessage();
// 9. 处理世界书消息示例
mesExamplesArray = !isPromptFiltered('dialogue_examples', config)
? await processMessageExamples(mesExamplesArray, worldInfo.worldInfoExamples)
: [];
return {
characterInfo: {
description,
personality,
persona,
scenario,
system: system,
jailbreak: jailbreak,
},
chatContext: {
oaiMessages,
oaiMessageExamples,
promptBias,
},
worldInfo,
};
}
/**
* 处理角色卡中的深度提示词
*/
function handleCharDepthPrompt() {
const character = characters.at(this_chid as unknown as number);
const depthPromptText =
baseChatReplace(character?.data?.extensions?.depth_prompt?.prompt?.trim(), name1, name2) || '';
const depthPromptDepth = character?.data?.extensions?.depth_prompt?.depth ?? 4;
const depthPromptRole = getExtensionPromptRoleByName(character?.data?.extensions?.depth_prompt?.role ?? 'system');
setExtensionPrompt(
'DEPTH_PROMPT',
depthPromptText,
extension_prompt_types.IN_CHAT,
depthPromptDepth,
// @ts-expect-error 类型正确
extension_settings.note.allowWIScan,
depthPromptRole,
);
}
/**
* 处理作者注释
*/
function setAuthorNotePrompt(config: detail.GenerateParams) {
const authorNoteOverride = config?.overrides?.author_note;
const prompt = authorNoteOverride ?? ($('#extension_floating_prompt').val() as string);
setExtensionPrompt(
NOTE_MODULE_NAME,
prompt,
// @ts-expect-error 类型正确
chat_metadata[metadata_keys.position],
// @ts-expect-error 类型正确
chat_metadata[metadata_keys.depth],
// @ts-expect-error 类型正确
extension_settings.note.allowWIScan,
// @ts-expect-error 类型正确
chat_metadata[metadata_keys.role],
);
}
/**
* 用户角色描述提示词设置为提示词管理器之外的选项的情况
*/
function setPersonaDescriptionExtensionPrompt() {
const description = power_user.persona_description;
const INJECT_TAG = 'PERSONA_DESCRIPTION';
setExtensionPrompt(INJECT_TAG, '', extension_prompt_types.IN_PROMPT, 0);
if (!description || power_user.persona_description_position === persona_description_positions.NONE) {
return;
}
//当user信息在作者注释前后 - 仅在作者注释未被过滤时执行
const promptPositions = [persona_description_positions.BOTTOM_AN, persona_description_positions.TOP_AN];
if (promptPositions.includes(power_user.persona_description_position) && shouldWIAddPrompt) {
const originalAN = _.get(extension_prompts, NOTE_MODULE_NAME) as any;
const ANWithDesc =
power_user.persona_description_position === persona_description_positions.TOP_AN
? `${description}\n${originalAN}`
: `${originalAN}\n${description}`;
setExtensionPrompt(
NOTE_MODULE_NAME,
ANWithDesc,
// @ts-expect-error 类型正确
chat_metadata[metadata_keys.position],
// @ts-expect-error 类型正确
chat_metadata[metadata_keys.depth],
// @ts-expect-error 类型正确
extension_settings.note.allowWIScan,
// @ts-expect-error 类型正确
chat_metadata[metadata_keys.role],
);
}
// user信息深度注入不依赖于作者注释的状态直接应用
if (power_user.persona_description_position === persona_description_positions.AT_DEPTH) {
setExtensionPrompt(
INJECT_TAG,
description,
extension_prompt_types.IN_CHAT,
power_user.persona_description_depth,
true,
power_user.persona_description_role,
);
}
}
/**
* 处理注入的提示词
*/
async function handleInjectedPrompts(promptConfig: Omit<detail.GenerateParams, 'user_input' | 'use_preset'>) {
if (!promptConfig || !Array.isArray(promptConfig.inject)) return;
injectPrompts(
promptConfig.inject.map(prompt => ({ id: uuidv4(), ...prompt })),
{ once: true },
);
}
/**
* 处理聊天记录
*/
async function processChatHistory(chatHistory: any[]) {
const coreChat = chatHistory.filter(x => !x.is_system);
return await Promise.all(
coreChat.map(async (chatItem, index) => {
const message = chatItem.mes;
const regexType = chatItem.is_user ? regex_placement.USER_INPUT : regex_placement.AI_OUTPUT;
const regexedMessage = getRegexedString(message, regexType, {
isPrompt: true,
depth: coreChat.length - index - 1,
});
return {
...chatItem,
mes: regexedMessage,
index,
};
}),
);
}
/**
* 处理世界书
*/
async function processWorldInfo(
oaiMessages: RolePrompt[],
config: Omit<detail.GenerateParams, 'user_input' | 'use_preset'>,
characterInfo: {
description: string;
personality: string;
persona: string;
scenario: string;
charDepthPrompt: string;
creatorNotes: string;
},
) {
const chatForWI = oaiMessages
.filter(x => x.role !== 'system')
.map(x => {
const name = x.role === 'user' ? name1 : name2;
return world_info_include_names ? `${name}: ${x.content}` : x.content;
})
.reverse();
const this_max_context = getMaxContextSize();
const globalScanData = {
personaDescription: config.overrides?.persona_description ?? characterInfo.persona,
characterDescription: config.overrides?.char_description ?? characterInfo.description,
characterPersonality: config.overrides?.char_personality ?? characterInfo.personality,
characterDepthPrompt: characterInfo.charDepthPrompt,
scenario: config.overrides?.scenario ?? characterInfo.scenario,
creatorNotes: characterInfo.creatorNotes,
};
const { worldInfoString, worldInfoBefore, worldInfoAfter, worldInfoExamples, worldInfoDepth } =
// @ts-expect-error 不考虑新的 trigger 字段
await getWorldInfoPrompt(chatForWI, this_max_context, false, globalScanData);
await clearInjectionPrompts(['customDepthWI']);
if (!isPromptFiltered('with_depth_entries', config)) {
processWorldInfoDepth(worldInfoDepth);
}
// 先检查是否被过滤如果被过滤直接返回null
const finalWorldInfoBefore = isPromptFiltered('world_info_before', config)
? null
: config.overrides?.world_info_before !== undefined
? config.overrides.world_info_before
: worldInfoBefore;
const finalWorldInfoAfter = isPromptFiltered('world_info_after', config)
? null
: config.overrides?.world_info_after !== undefined
? config.overrides.world_info_after
: worldInfoAfter;
return {
worldInfoString,
worldInfoBefore: finalWorldInfoBefore,
worldInfoAfter: finalWorldInfoAfter,
worldInfoExamples,
worldInfoDepth: !isPromptFiltered('with_depth_entries', config) ? worldInfoDepth : null,
};
}
/**
* 处理世界信息深度部分
*/
function processWorldInfoDepth(worldInfoDepth: any[]) {
if (Array.isArray(worldInfoDepth)) {
worldInfoDepth.forEach(entry => {
const joinedEntries = entry.entries.join('\n');
setExtensionPrompt(
`customDepthWI-${entry.depth}-${entry.role}`,
joinedEntries,
extension_prompt_types.IN_CHAT,
entry.depth,
false,
entry.role,
);
});
}
}
/**
* 处理世界书中示例前后
*/
async function processMessageExamples(mesExamplesArray: string[], worldInfoExamples: any[]): Promise<string[]> {
// 处理世界信息中的示例
for (const example of worldInfoExamples) {
if (!example.content.length) continue;
const formattedExample = baseChatReplace(example.content, name1, name2);
const cleanedExample = parseMesExamples(formattedExample);
if (example.position === wi_anchor_position.before) {
mesExamplesArray.unshift(...cleanedExample);
} else {
mesExamplesArray.push(...cleanedExample);
}
}
return mesExamplesArray;
}

View File

@@ -0,0 +1,79 @@
import { characters, name2, this_chid } from '@sillytavern/script';
import { getContext } from '@sillytavern/scripts/extensions';
import { prepareOpenAIMessages } from '@sillytavern/scripts/openai';
import { detail } from '@/function/generate/types';
import { convertFileToBase64 } from '@/function/generate/utils';
const dryRun = false;
/**
* 使用预设路径处理生成请求
* @param baseData 基础数据
* @param processedUserInput 处理后的用户输入
* @param config 配置参数
* @returns 生成数据
*/
export async function handlePresetPath(
baseData: any,
processedUserInput: string,
config: Omit<detail.GenerateParams, 'user_input' | 'use_preset'>,
) {
// prepareOpenAIMessages会从设置里读取场景因此临时覆盖
let originalScenario = null;
const character = characters.at(this_chid as unknown as number);
try {
const scenarioOverride = config?.overrides?.scenario;
if (scenarioOverride && character) {
// 保存原始场景
originalScenario = character.scenario || null;
character.scenario = scenarioOverride;
}
// 添加user消息(一次性)
const userMessageTemp = {
role: 'user',
content: processedUserInput,
image: config.image,
};
if (config.image) {
if (Array.isArray(config.image)) {
delete userMessageTemp.image;
} else {
userMessageTemp.image = await convertFileToBase64(config.image);
}
}
baseData.chatContext.oaiMessages.unshift(userMessageTemp);
const messageData = {
name2,
charDescription: baseData.characterInfo.description,
charPersonality: baseData.characterInfo.personality,
Scenario: baseData.characterInfo.scenario,
worldInfoBefore: baseData.worldInfo.worldInfoBefore,
worldInfoAfter: baseData.worldInfo.worldInfoAfter,
extensionPrompts: getContext().extensionPrompts,
bias: baseData.chatContext.promptBias,
type: 'normal',
quietPrompt: '',
quietImage: null,
cyclePrompt: '',
systemPromptOverride: baseData.characterInfo.system,
jailbreakPromptOverride: baseData.characterInfo.jailbreak,
personaDescription: baseData.characterInfo.persona,
messages: baseData.chatContext.oaiMessages,
messageExamples: baseData.chatContext.oaiMessageExamples,
};
const [prompt] = await prepareOpenAIMessages(messageData as any, dryRun);
return { prompt };
} finally {
// 恢复原始场景
if (originalScenario !== null && character) {
character.scenario = originalScenario;
}
}
}

View File

@@ -0,0 +1,455 @@
import {
BaseData,
RolePrompt,
builtin_prompt_default_order,
character_names_behavior,
default_order,
detail,
} from '@/function/generate/types';
import { convertFileToBase64, getPromptRole, isPromptFiltered } from '@/function/generate/utils';
import { InjectionPrompt } from '@/function/inject';
import {
MAX_INJECTION_DEPTH,
eventSource,
event_types,
extension_prompts,
getExtensionPromptByName,
substituteParams,
} from '@sillytavern/script';
import { NOTE_MODULE_NAME } from '@sillytavern/scripts/authors-note';
import {
ChatCompletion,
Message,
MessageCollection,
isImageInliningSupported,
oai_settings,
setupChatCompletionPromptManager,
} from '@sillytavern/scripts/openai';
import { persona_description_positions, power_user } from '@sillytavern/scripts/power-user';
import { Prompt, PromptCollection } from '@sillytavern/scripts/PromptManager';
/**
* @fileoverview 原始生成路径处理模块 - 不使用预设的生成逻辑
* 包含所有不使用预设(use_preset=false)时的提示词处理和聊天完成逻辑
*/
/**
* 将系统提示词转换为集合格式
* 处理内置提示词、自定义注入和对话示例转换为PromptCollection和MessageCollection格式
* @param baseData 包含角色信息和世界书信息的基础数据
* @param promptConfig 提示词配置参数包含order等设置
* @returns Promise<{systemPrompts: PromptCollection, dialogue_examples: MessageCollection}> 返回系统提示词和对话示例的集合
*/
async function convertSystemPromptsToCollection(
baseData: any,
promptConfig: Omit<detail.GenerateParams, 'user_input' | 'use_preset'>,
) {
const promptCollection = new PromptCollection();
const examplesCollection = new MessageCollection('dialogue_examples');
const orderArray = promptConfig.order || builtin_prompt_default_order;
const builtinPromptContents = {
world_info_before: baseData.worldInfo.worldInfoBefore,
persona_description:
power_user.persona_description &&
power_user.persona_description_position === persona_description_positions.IN_PROMPT
? baseData.characterInfo.persona
: null,
char_description: baseData.characterInfo.description,
char_personality: baseData.characterInfo.personality,
scenario: baseData.characterInfo.scenario,
world_info_after: baseData.worldInfo.worldInfoAfter,
};
for (const [index, item] of orderArray.entries()) {
if (typeof item === 'string') {
// 处理内置提示词
const content = builtinPromptContents[item as keyof typeof builtinPromptContents];
if (content) {
promptCollection.add(
new Prompt({
identifier: item,
role: 'system',
content: content,
system_prompt: true,
}),
);
}
} else if (typeof item === 'object' && item.role && item.content) {
// 处理自定义注入
const identifier = `custom_prompt_${index}`;
promptCollection.add(
new Prompt({
identifier: identifier,
role: item.role,
content: item.content,
system_prompt: item.role === 'system',
}),
);
}
}
if (baseData.chatContext.oaiMessageExamples.length > 0) {
// 遍历所有对话示例
for (const dialogue of [...baseData.chatContext.oaiMessageExamples]) {
const dialogueIndex = baseData.chatContext.oaiMessageExamples.indexOf(dialogue);
const chatMessages = [];
for (let promptIndex = 0; promptIndex < dialogue.length; promptIndex++) {
const prompt = dialogue[promptIndex];
const role = 'system';
const content = prompt.content || '';
const identifier = `dialogue_examples ${dialogueIndex}-${promptIndex}`;
const chatMessage = await Message.createAsync(role, content, identifier);
await chatMessage.setName(prompt.name);
chatMessages.push(chatMessage);
}
for (const message of chatMessages) {
examplesCollection.add(message);
}
}
}
return {
systemPrompts: promptCollection,
dialogue_examples: examplesCollection,
};
}
/**
* 处理聊天记录并注入提示词
* 根据order配置处理聊天历史和用户输入并注入各种深度提示词
* @param baseData 包含聊天上下文的基础数据
* @param promptConfig 提示词配置参数
* @param chatCompletion ChatCompletion对象用于管理token预算和消息集合
* @param processedUserInput 经过处理的用户输入文本
* @param processedImageArray 可选的处理后图片数组,用于多图片支持
* @returns Promise<void> 无返回值直接修改chatCompletion对象
*/
async function processChatHistoryAndInject(
baseData: any,
promptConfig: Omit<detail.GenerateParams, 'user_input' | 'use_preset'>,
chatCompletion: ChatCompletion,
processedUserInput: string,
processedImageArray?: { type: string; text?: string; image_url?: { url: string; detail: string } }[] | null,
) {
const orderArray = promptConfig.order || default_order;
const chatHistoryIndex = orderArray.findIndex(
item => typeof item === 'string' && item.toLowerCase() === 'chat_history',
);
const userInputIndex = orderArray.findIndex(item => typeof item === 'string' && item.toLowerCase() === 'user_input');
const hasUserInput = userInputIndex !== -1;
const hasChatHistory = chatHistoryIndex !== -1;
const isChatHistoryFiltered = isPromptFiltered('chat_history', promptConfig);
// 创建用户输入消息
let userMessage: Message;
if (processedImageArray && hasUserInput) {
// 如果有处理后的图片数组,直接使用数组格式创建消息
userMessage = await Message.createAsync('user', processedImageArray as any, 'user_input');
} else {
// 否则使用原有逻辑
userMessage = await Message.createAsync('user', processedUserInput, 'user_input');
if (promptConfig.image && hasUserInput) {
if (!Array.isArray(promptConfig.image)) {
const img = await convertFileToBase64(promptConfig.image);
if (img) {
await userMessage.addImage(img);
}
}
}
}
// 如果聊天记录被过滤或不在order中只处理用户输入
if (isChatHistoryFiltered || !hasChatHistory) {
const insertIndex = hasUserInput ? userInputIndex : orderArray.length;
chatCompletion.add(new MessageCollection('user_input', userMessage), insertIndex);
return;
}
// 处理聊天记录
const chatCollection = new MessageCollection('chat_history');
// 为新聊天指示预留token
const newChat = oai_settings.new_chat_prompt;
const newChatMessage = await Message.createAsync('system', substituteParams(newChat), 'newMainChat');
chatCompletion.reserveBudget(newChatMessage);
// 添加新聊天提示词到集合的最前面
chatCollection.add(newChatMessage);
// 处理空消息替换
const lastChatPrompt = baseData.chatContext.oaiMessages[baseData.chatContext.oaiMessages.length - 1];
const emptyMessage = await Message.createAsync('user', oai_settings.send_if_empty, 'emptyUserMessageReplacement');
if (
lastChatPrompt &&
lastChatPrompt.role === 'assistant' &&
oai_settings.send_if_empty &&
chatCompletion.canAfford(emptyMessage)
) {
chatCollection.add(emptyMessage);
}
// 将用户消息添加到消息数组中准备处理注入
if (!hasUserInput) {
let userPrompt: any;
if (processedImageArray) {
// 如果有处理后的图片数组,使用数组格式
userPrompt = {
role: 'user',
content: processedImageArray,
identifier: 'user_input',
};
} else {
// 否则使用原有逻辑
userPrompt = {
role: 'user',
content: processedUserInput,
identifier: 'user_input',
image:
promptConfig.image && !Array.isArray(promptConfig.image)
? await convertFileToBase64(promptConfig.image)
: undefined,
};
}
baseData.chatContext.oaiMessages.unshift(userPrompt);
}
// 处理注入和添加消息
const messages = (
await populationInjectionPrompts(baseData, baseData.chatContext.oaiMessages, promptConfig.inject, promptConfig)
).reverse();
const imageInlining = isImageInliningSupported();
// 添加聊天记录
const chatPool = [...messages];
for (const chatPrompt of chatPool) {
const prompt = new Prompt(chatPrompt as any);
prompt.identifier = `chat_history-${messages.length - chatPool.indexOf(chatPrompt)}`;
prompt.content = substituteParams(prompt.content);
const chatMessage = await Message.fromPromptAsync(prompt);
const promptManager = setupChatCompletionPromptManager(oai_settings);
if (promptManager) {
// @ts-expect-error 类型正确
if (promptManager.serviceSettings.names_behavior === character_names_behavior.COMPLETION && prompt.name) {
const messageName = promptManager.isValidName(prompt.name)
? prompt.name
: promptManager.sanitizeName(prompt.name);
await chatMessage.setName(messageName);
}
}
if (imageInlining && chatPrompt.image) {
await chatMessage.addImage(chatPrompt.image as string);
}
if (chatCompletion.canAfford(chatMessage)) {
chatCollection.add(chatMessage);
} else {
break;
}
}
// 释放新聊天提示词的预留token
chatCompletion.freeBudget(newChatMessage);
if (hasUserInput) {
// 按各自在order中的位置添加聊天记录和用户输入
chatCompletion.add(chatCollection, chatHistoryIndex);
chatCompletion.add(new MessageCollection('user_input', userMessage), userInputIndex);
} else {
// 聊天记录中已包含用户输入直接添加到chat_history位置
chatCompletion.add(chatCollection, chatHistoryIndex);
}
}
/**
* 处理注入提示词
* 按深度注入各种提示词,包括作者注释、用户描述、世界书深度条目和自定义注入
* @param baseData 包含世界书信息的基础数据
* @param messages 原始消息数组
* @param customInjects 自定义注入提示词数组
* @param config 配置参数,用于过滤检查
* @returns Promise<RolePrompt[]> 处理后的消息数组,包含所有注入的提示词
*/
async function populationInjectionPrompts(
baseData: BaseData,
messages: RolePrompt[],
customInjects: Omit<InjectionPrompt, 'id'>[] = [],
config: Omit<detail.GenerateParams, 'user_input' | 'use_preset'>,
) {
const processedMessages = [...messages];
let totalInsertedMessages = 0;
const injectionPrompts = [];
const authorsNote = _.get(extension_prompts, NOTE_MODULE_NAME, {}) as any;
if (authorsNote && authorsNote.value) {
injectionPrompts.push({
role: getPromptRole(authorsNote.role),
content: authorsNote.value,
identifier: 'authorsNote',
injection_depth: authorsNote.depth,
injected: true,
});
}
if (
power_user.persona_description &&
power_user.persona_description_position === persona_description_positions.AT_DEPTH
) {
injectionPrompts.push({
role: 'system',
content: power_user.persona_description,
identifier: 'persona_description',
injection_depth: power_user.persona_description_depth,
injected: true,
});
}
if (!isPromptFiltered('char_depth_prompt', config)) {
const wiDepthPrompt = baseData.worldInfo.worldInfoDepth;
if (wiDepthPrompt) {
for (const entry of wiDepthPrompt) {
const content = await getExtensionPromptByName(`customDepthWI-${entry.depth}-${entry.role}`);
injectionPrompts.push({
role: getPromptRole(entry.role),
content: content,
injection_depth: entry.depth,
injected: true,
});
}
}
}
// 处理自定义注入
if (Array.isArray(customInjects)) {
for (const inject of customInjects) {
injectionPrompts.push({
identifier: `INJECTION-${inject.role}-${inject.depth}`,
role: inject.role,
content: inject.content,
injection_depth: inject.depth || 0,
injected: true,
});
}
}
for (let i = 0; i <= MAX_INJECTION_DEPTH; i++) {
const depthPrompts = injectionPrompts.filter(prompt => prompt.injection_depth === i && prompt.content);
const roles = ['system', 'user', 'assistant'];
const roleMessages = [];
const separator = '\n';
for (const role of roles) {
// 直接处理当前深度和角色的所有提示词
const rolePrompts = depthPrompts
.filter(prompt => prompt.role === role)
.map(x => x.content.trim())
.join(separator);
if (rolePrompts) {
roleMessages.push({
role: role as 'user' | 'system' | 'assistant',
content: rolePrompts,
injected: true,
});
}
}
if (roleMessages.length) {
const injectIdx = i + totalInsertedMessages;
processedMessages.splice(injectIdx, 0, ...roleMessages);
totalInsertedMessages += roleMessages.length;
}
}
return processedMessages;
}
/**
* 处理原始生成路径(不使用预设)
* 构建ChatCompletion对象按照指定order处理各种提示词管理token预算
* @param baseData 包含角色信息、聊天上下文和世界书信息的基础数据
* @param config 配置参数,包含图片、覆盖设置、注入等选项
* @param processedUserInput 经过处理的用户输入文本
* @returns Promise<{prompt: any}> 包含最终prompt的生成数据对象
*/
export async function handleCustomPath(
baseData: any,
config: Omit<detail.GenerateParams, 'user_input' | 'use_preset'> & {
processedImageArray?: { type: string; text?: string; image_url?: { url: string; detail: string } }[] | null;
},
processedUserInput: string,
) {
const chatCompletion = new ChatCompletion();
chatCompletion.setTokenBudget(oai_settings.openai_max_context, oai_settings.openai_max_tokens);
chatCompletion.reserveBudget(3);
const orderArray = config.order || default_order;
const positionMap: Record<string, number> = orderArray.reduce((acc: Record<string, number>, item, index) => {
if (typeof item === 'string') {
acc[item.toLowerCase()] = index;
} else if (typeof item === 'object') {
acc[`custom_prompt_${index}`] = index;
}
return acc;
}, {});
//转换为集合
const { systemPrompts, dialogue_examples } = await convertSystemPromptsToCollection(baseData, config);
const addToChatCompletionInOrder = async (source: any, index: number) => {
if (typeof source === 'object') {
// 处理自定义注入
const collection = new MessageCollection(`custom_prompt_${index}`);
const message = await Message.createAsync(source.role, source.content, `custom_prompt_${index}`);
collection.add(message);
chatCompletion.add(collection, index);
} else if (systemPrompts.has(source)) {
// 处理普通提示词
const prompt = systemPrompts.get(source);
const collection = new MessageCollection(source);
const message = await Message.fromPromptAsync(prompt);
collection.add(message);
chatCompletion.add(collection, positionMap[source]);
}
};
// 处理所有类型的提示词
for (const [index, item] of orderArray.entries()) {
if (typeof item === 'string') {
if (!isPromptFiltered(item, config)) {
await addToChatCompletionInOrder(item, index);
}
} else if (typeof item === 'object' && item.role && item.content) {
await addToChatCompletionInOrder(item, index);
}
}
const dialogue_examplesIndex = orderArray.findIndex(
item => typeof item === 'string' && item.toLowerCase() === 'dialogue_examples',
);
if (dialogue_examplesIndex !== -1 && !isPromptFiltered('dialogue_examples', config)) {
chatCompletion.add(dialogue_examples, dialogue_examplesIndex);
}
//给user输入预留token
const userInputMessage = await Message.createAsync('user', processedUserInput, 'user_input');
chatCompletion.reserveBudget(userInputMessage);
await processChatHistoryAndInject(baseData, config, chatCompletion, processedUserInput, config.processedImageArray);
chatCompletion.freeBudget(userInputMessage);
//根据当前预设决定是否合并连续系统role消息
if (oai_settings.squash_system_messages) {
await chatCompletion.squashSystemMessages();
}
const prompt = chatCompletion.getChat();
eventSource.emit(event_types.CHAT_COMPLETION_PROMPT_READY, { chat: prompt, dryRun: false });
return { prompt };
}

View File

@@ -0,0 +1,335 @@
import { prepareAndOverrideData } from '@/function/generate/dataProcessor';
import { handlePresetPath } from '@/function/generate/generate';
import { handleCustomPath } from '@/function/generate/generateRaw';
import { processUserInputWithImages } from '@/function/generate/inputProcessor';
import { generateResponse } from '@/function/generate/responseGenerator';
import { detail, GenerateConfig, GenerateRawConfig, Overrides } from '@/function/generate/types';
import { normalizeBaseURL, setupImageArrayProcessing, unblockGeneration } from '@/function/generate/utils';
import {
deactivateSendButtons,
event_types,
eventSource,
getRequestHeaders,
stopGeneration,
} from '@sillytavern/script';
import { uuidv4 } from '@sillytavern/scripts/utils';
declare const $: any;
type GenerationControllerEntry = {
abortController: AbortController;
bindToStopButton: boolean;
};
const generationControllers = new Map<string, GenerationControllerEntry>();
const stopButtonBoundGenerationIds = new Set<string>();
export async function getModelList(custom_api: { apiurl: string; key?: string }): Promise<string[]> {
const url = normalizeBaseURL(custom_api?.apiurl);
const response = await fetch('/api/backends/chat-completions/status', {
method: 'POST',
headers: getRequestHeaders(),
body: JSON.stringify({
reverse_proxy: url,
proxy_password: custom_api.key ?? '',
chat_completion_source: 'openai',
}),
cache: 'no-cache',
});
const json = await response.json();
return _(json?.data ?? [])
.map((model: any) => String(model?.id ?? model?.name ?? '').trim())
.filter(Boolean)
.sort()
.sortedUniq()
.value();
}
/**
* 中断指定的生成请求
* @param id 生成ID
*/
export function stopGenerationById(id: string) {
const entry = generationControllers.get(id);
if (!entry) return false;
entry.abortController.abort(`生成 ID '${id}' 已停止`);
generationControllers.delete(id);
if (entry.bindToStopButton) {
stopButtonBoundGenerationIds.delete(id);
if (stopButtonBoundGenerationIds.size === 0) {
unblockGeneration();
}
}
eventSource.emit(event_types.GENERATION_STOPPED, id);
return true;
}
/**
* 中断所有TH-generate的生成任务
*/
export function stopAllGeneration() {
try {
const hadStopButtonBoundGeneration = stopButtonBoundGenerationIds.size > 0;
for (const [id, entry] of generationControllers.entries()) {
entry.abortController.abort(`生成 ID '${id}' 已停止`);
eventSource.emit(event_types.GENERATION_STOPPED, id);
}
generationControllers.clear();
stopButtonBoundGenerationIds.clear();
if (hadStopButtonBoundGeneration) {
unblockGeneration();
}
return true;
} catch (error) {
console.error(`[TavernHelper][Generate:停止] 中断所有生成任务时出错: ${error}`);
return false;
}
}
/**
* 清理图片处理相关的监听器和Promise
*/
function cleanupImageProcessing(imageProcessingSetup?: ReturnType<typeof setupImageArrayProcessing>): void {
if (imageProcessingSetup) {
try {
imageProcessingSetup.cleanup();
imageProcessingSetup.rejectImageProcessing(new Error('Generation stopped'));
} catch (error) {
console.warn(`[TavernHelper][Generate:停止] 清理图片处理时出错: ${error}`);
}
}
}
/**
* 从Overrides转换为detail.OverrideConfig
* @param overrides 覆盖配置
* @returns detail.OverrideConfig
*/
export function fromOverrides(overrides: Overrides): detail.OverrideConfig {
return {
world_info_before: overrides.world_info_before,
persona_description: overrides.persona_description,
char_description: overrides.char_description,
char_personality: overrides.char_personality,
scenario: overrides.scenario,
world_info_after: overrides.world_info_after,
dialogue_examples: overrides.dialogue_examples,
with_depth_entries: overrides.chat_history?.with_depth_entries,
author_note: overrides.chat_history?.author_note,
chat_history: overrides.chat_history?.prompts,
};
}
/**
* 从GenerateConfig转换为detail.GenerateParams
* @param config 生成配置
* @returns detail.GenerateParams
*/
export function fromGenerateConfig(config: GenerateConfig): detail.GenerateParams {
return {
generation_id: config.generation_id,
user_input: config.user_input,
use_preset: true,
image: config.image,
stream: config.should_stream ?? false,
bindToStopButton: !(config.should_silence ?? false),
overrides: config.overrides !== undefined ? fromOverrides(config.overrides) : undefined,
inject: config.injects,
max_chat_history: typeof config.max_chat_history === 'number' ? config.max_chat_history : undefined,
custom_api: config.custom_api,
};
}
/**
* 从GenerateRawConfig转换为detail.GenerateParams
* @param config 原始生成配置
* @returns detail.GenerateParams
*/
export function fromGenerateRawConfig(config: GenerateRawConfig): detail.GenerateParams {
return {
generation_id: config.generation_id,
user_input: config.user_input,
use_preset: false,
image: config.image,
stream: config.should_stream ?? false,
bindToStopButton: !(config.should_silence ?? false),
max_chat_history: typeof config.max_chat_history === 'number' ? config.max_chat_history : undefined,
overrides: config.overrides ? fromOverrides(config.overrides) : undefined,
inject: config.injects,
order: config.ordered_prompts,
custom_api: config.custom_api,
};
}
/**
* 生成AI响应的核心函数
* @param config 生成配置参数
* @param config.user_input 用户输入文本
* @param config.use_preset 是否使用预设
* @param config.image 图片参数,可以是单个图片(File|string)或图片数组(File|string)[]
* @param config.overrides 覆盖配置
* @param config.max_chat_history 最大聊天历史数量
* @param config.inject 注入的提示词
* @param config.order 提示词顺序
* @param config.stream 是否启用流式传输
* @param config.bindToStopButton 是否绑定到酒馆停止按钮;默认为 true
* @returns Promise<string> 生成的响应文本
*/
async function iframeGenerate({
generation_id,
user_input = '',
use_preset = true,
image = undefined,
overrides = undefined,
max_chat_history = undefined,
inject = [],
order = undefined,
stream = false,
bindToStopButton = true,
custom_api = undefined,
}: detail.GenerateParams = {}): Promise<string> {
const generationId = generation_id || uuidv4();
if (generationControllers.has(generationId)) {
throw new Error(`ID为 '${generationId}' 的请求正在进行中,无法启动用同一 ID 的生成任务`);
}
const abortController = new AbortController();
const shouldBindToStopButton = typeof bindToStopButton === 'boolean' ? bindToStopButton : true;
generationControllers.set(generationId, {
abortController,
bindToStopButton: shouldBindToStopButton,
});
if (shouldBindToStopButton) {
const shouldDeactivateSendButtons = stopButtonBoundGenerationIds.size === 0;
stopButtonBoundGenerationIds.add(generationId);
if (shouldDeactivateSendButtons) {
deactivateSendButtons();
}
}
let imageProcessingSetup: ReturnType<typeof setupImageArrayProcessing> | undefined = undefined;
try {
// 1. 处理用户输入和图片(正则,宏,图片数组)
const inputResult = await processUserInputWithImages(user_input, use_preset, image);
const { processedUserInput, processedImageArray } = inputResult;
imageProcessingSetup = inputResult.imageProcessingSetup;
await eventSource.emit(event_types.GENERATION_AFTER_COMMANDS, 'normal', {}, false);
// 2. 准备过滤后的基础数据
const baseData = await prepareAndOverrideData(
{
overrides,
max_chat_history,
inject,
order,
},
processedUserInput,
);
// 3. 根据 use_preset 分流处理
const generate_data = use_preset
? await handlePresetPath(baseData, processedUserInput, {
image,
overrides,
max_chat_history,
inject,
order,
})
: await handleCustomPath(
baseData,
{
image,
overrides,
max_chat_history,
inject,
order,
processedImageArray,
},
processedUserInput,
);
await eventSource.emit(event_types.GENERATE_AFTER_DATA, generate_data, false);
// 4. 根据 stream 参数决定生成方式
const result = await generateResponse(
generate_data,
stream,
generationId,
imageProcessingSetup,
abortController,
custom_api,
);
return result;
} catch (error) {
if (imageProcessingSetup) {
imageProcessingSetup.rejectImageProcessing(error);
}
throw error;
} finally {
// 清理
cleanupImageProcessing(imageProcessingSetup);
generationControllers.delete(generationId);
if (shouldBindToStopButton) {
stopButtonBoundGenerationIds.delete(generationId);
if (stopButtonBoundGenerationIds.size === 0) {
unblockGeneration();
}
}
}
}
export async function generate(config: GenerateConfig) {
const converted_config = fromGenerateConfig(config);
return await iframeGenerate(converted_config);
}
export async function generateRaw(config: GenerateRawConfig) {
const converted_config = fromGenerateRawConfig(config);
return await iframeGenerate(converted_config);
}
/**
* 点击停止按钮时的逻辑
*/
$(document)
.off('click.tavernhelper_generate', '#mes_stop')
.on('click.tavernhelper_generate', '#mes_stop', function () {
stopGeneration();
if (stopButtonBoundGenerationIds.size === 0) {
return;
}
const idsToAbort = Array.from(stopButtonBoundGenerationIds.values());
for (const id of idsToAbort) {
const entry = generationControllers.get(id);
if (!entry) {
stopButtonBoundGenerationIds.delete(id);
continue;
}
entry.abortController.abort('点击停止按钮');
generationControllers.delete(id);
stopButtonBoundGenerationIds.delete(id);
eventSource.emit(event_types.GENERATION_STOPPED, id);
}
if (stopButtonBoundGenerationIds.size === 0) {
unblockGeneration();
}
});

View File

@@ -0,0 +1,67 @@
import { substituteParams } from '@sillytavern/script';
import { processImageArrayDirectly, processUserInput, setupImageArrayProcessing } from '@/function/generate/utils';
/**
* 用户输入处理结果接口
*/
export interface ProcessedInputResult {
processedUserInput: string;
imageProcessingSetup?: ReturnType<typeof setupImageArrayProcessing>;
processedImageArray?: { type: string; text?: string; image_url?: { url: string; detail: string } }[];
}
/**
* 处理用户输入的第一步
* 包括宏替换、正则处理等预处理操作
* @param user_input 原始用户输入
* @returns 处理后的用户输入
*/
export function processInitialUserInput(user_input = ''): string {
// 1. 处理宏替换
const substitutedInput = substituteParams(user_input);
// 2. 处理正则和其他预处理
const processedUserInput = processUserInput(substitutedInput) || '';
return processedUserInput;
}
/**
* 完整的用户输入和图片处理
* 包括用户输入预处理和图片数组处理逻辑
* @param user_input 用户输入文本
* @param use_preset 是否使用预设
* @param image 图片参数,可以是单个图片(File|string)或图片数组(File|string)[]
* @returns 处理结果,包含处理后的用户输入和图片处理相关数据
*/
export async function processUserInputWithImages(
user_input = '',
use_preset = true,
image: File | string | (File | string)[] | undefined = undefined,
): Promise<ProcessedInputResult> {
// 1. 处理用户输入(正则,宏)
let processedUserInput = processInitialUserInput(user_input);
// 处理可能的图片数组的情况
let imageProcessingSetup: ReturnType<typeof setupImageArrayProcessing> | undefined = undefined;
let processedImageArray: { type: string; text?: string; image_url?: { url: string; detail: string } }[] | undefined =
undefined;
if (Array.isArray(image) && image.length > 0) {
if (use_preset) {
// 使用预设时,采用事件监听方式处理图片数组
imageProcessingSetup = setupImageArrayProcessing(processedUserInput, image);
processedUserInput = imageProcessingSetup.userInputWithMarker;
} else {
// 使用原始模式时,直接处理图片数组
processedImageArray = await processImageArrayDirectly(processedUserInput, image);
// 保持原始用户输入不变,图片数组将在后续步骤中直接使用
}
}
return {
processedUserInput,
imageProcessingSetup,
processedImageArray,
};
}

View File

@@ -0,0 +1,236 @@
import { CustomApiConfig } from '@/function/generate/types';
import {
clearInjectionPrompts,
extractMessageFromData,
normalizeBaseURL,
setupImageArrayProcessing,
} from '@/function/generate/utils';
import { saveChatConditionalDebounced } from '@/util/tavern';
import {
cleanUpMessage,
countOccurrences,
eventSource,
event_types,
isOdd,
} from '@sillytavern/script';
import { oai_settings, sendOpenAIRequest } from '@sillytavern/scripts/openai';
import { power_user } from '@sillytavern/scripts/power-user';
import { Stopwatch, uuidv4 } from '@sillytavern/scripts/utils';
/**
* 流式处理器类
* 处理流式生成的响应数据
*/
class StreamingProcessor {
public generator: () => AsyncGenerator<{ text: string }, void, void>;
public stoppingStrings?: any;
public result: string;
public isStopped: boolean;
public isFinished: boolean;
public abortController: AbortController;
private messageBuffer: string;
private generationId: string;
constructor(generationId: string, abortController: AbortController) {
this.result = '';
this.messageBuffer = '';
this.isStopped = false;
this.isFinished = false;
this.generator = this.nullStreamingGeneration;
this.abortController = abortController;
this.generationId = generationId;
}
onProgressStreaming(data: { text: string; isFinal: boolean }) {
// 计算增量文本
const newText = data.text.slice(this.messageBuffer.length);
this.messageBuffer = data.text;
// @ts-expect-error 兼容酒馆旧版本
let processedText = cleanUpMessage(newText, false, false, !data.isFinal, this.stoppingStrings);
const charsToBalance = ['*', '"', '```'];
for (const char of charsToBalance) {
if (!data.isFinal && isOdd(countOccurrences(processedText, char))) {
const separator = char.length > 1 ? '\n' : '';
processedText = processedText.trimEnd() + separator + char;
}
}
eventSource.emit('js_stream_token_received_fully', data.text, this.generationId);
eventSource.emit('js_stream_token_received_incrementally', processedText, this.generationId);
if (data.isFinal) {
// @ts-expect-error 兼容酒馆旧版本
const message = cleanUpMessage(data.text, false, false, false, this.stoppingStrings);
eventSource.emit('js_generation_before_end', { message }, this.generationId);
eventSource.emit('js_generation_ended', message, this.generationId);
data.text = message;
}
}
onErrorStreaming() {
if (this.abortController) {
this.abortController.abort();
}
this.isStopped = true;
saveChatConditionalDebounced();
}
// eslint-disable-next-line require-yield
async *nullStreamingGeneration(): AsyncGenerator<{ text: string }, void, void> {
throw Error('Generation function for streaming is not hooked up');
}
async generate() {
try {
const sw = new Stopwatch(1000 / power_user.streaming_fps);
for await (const { text } of this.generator()) {
if (this.isStopped) {
this.messageBuffer = '';
return;
}
this.result = text;
await sw.tick(() => this.onProgressStreaming({ text: this.result, isFinal: false }));
}
if (!this.isStopped) {
this.onProgressStreaming({ text: this.result, isFinal: true });
} else {
this.messageBuffer = '';
}
} catch (err) {
if (!this.isFinished) {
this.onErrorStreaming();
throw Error(`Generate method error: ${err}`);
}
this.messageBuffer = '';
return this.result;
}
this.isFinished = true;
return this.result;
}
}
/**
* 处理非流式响应
* @param response API响应对象
* @returns 提取的消息文本
*/
async function handleResponse(response: any, generationId: string) {
if (!response) {
throw Error(`未得到响应`);
}
if (response.error) {
if (response?.response) {
toastr.error(response.response, t`API 错误`, {
preventDuplicates: true,
});
}
throw Error(response?.response);
}
const result = { message: extractMessageFromData(response) };
eventSource.emit('js_generation_before_end', result, generationId);
eventSource.emit('js_generation_ended', result.message, generationId);
return result.message;
}
/**
* 生成响应
* @param generate_data 生成数据
* @param useStream 是否使用流式传输
* @param generationId 生成ID
* @param imageProcessingSetup 图片数组处理设置包含Promise和解析器
* @param abortController 中止控制器
* @param customApi 自定义API配置
* @returns 生成的响应文本
*/
export async function generateResponse(
generate_data: any,
useStream = false,
generationId: string | undefined = undefined,
imageProcessingSetup: ReturnType<typeof setupImageArrayProcessing> | undefined = undefined,
abortController: AbortController,
customApi?: CustomApiConfig,
): Promise<string> {
let result = '';
let customApiEventHandler: ((data: any) => void) | null = null;
try {
// 如果有自定义API配置设置单次事件拦截
if (customApi?.apiurl) {
customApiEventHandler = (data: any) => {
data.reverse_proxy = normalizeBaseURL(customApi.apiurl!);
data.chat_completion_source = customApi.source || 'openai';
data.proxy_password = customApi.key || '';
data.model = customApi.model;
const set_param = (param: keyof CustomApiConfig) => {
const input = customApi[param] ?? 'same_as_preset';
if (input === 'unset') {
_.unset(data, param);
} else if (input !== 'same_as_preset') {
_.set(data, param, input);
}
};
set_param('max_tokens');
set_param('temperature');
set_param('frequency_penalty');
set_param('presence_penalty');
set_param('top_p');
set_param('top_k');
return data;
};
eventSource.once(event_types.CHAT_COMPLETION_SETTINGS_READY, customApiEventHandler);
}
// 如果有图片处理,等待图片处理完成
if (imageProcessingSetup) {
try {
await imageProcessingSetup.imageProcessingPromise;
} catch (imageError: any) {
// 图片处理失败不应该阻止整个生成流程,但需要记录错误
throw new Error(`图片处理失败: ${imageError?.message || '未知错误'}`);
}
}
if (generationId === undefined || generationId === '') {
generationId = uuidv4();
}
eventSource.emit('js_generation_started', generationId);
try {
if (useStream) {
oai_settings.stream_openai = true;
const streamingProcessor = new StreamingProcessor(generationId, abortController);
// @ts-expect-error 类型正确
streamingProcessor.generator = await sendOpenAIRequest('normal', generate_data.prompt, abortController.signal);
result = (await streamingProcessor.generate()) as string;
} else {
oai_settings.stream_openai = false;
const response = await sendOpenAIRequest('normal', generate_data.prompt, abortController.signal);
result = await handleResponse(response, generationId);
}
} finally {
oai_settings.stream_openai = $('#stream_toggle').is(':checked');
}
} catch (error) {
// 如果有图片处理设置但生成失败确保拒绝Promise
if (imageProcessingSetup) {
imageProcessingSetup.rejectImageProcessing(error);
}
throw error;
} finally {
// 清理自定义API事件监听器
if (customApiEventHandler) {
eventSource.removeListener(event_types.CHAT_COMPLETION_SETTINGS_READY, customApiEventHandler);
}
//unblockGeneration();
await clearInjectionPrompts(['INJECTION']);
}
return result;
}

View File

@@ -0,0 +1,229 @@
import { InjectionPrompt } from '@/function/inject';
/**
* 角色类型(复制自@sillytavern/script以避免依赖
*/
export const extension_prompt_roles = {
SYSTEM: 0,
USER: 1,
ASSISTANT: 2,
} as const;
/**
* 自定义API配置接口
*/
export type CustomApiConfig = {
apiurl?: string;
key?: string;
model?: string;
source?: string;
max_tokens?: 'same_as_preset' | 'unset' | number;
temperature?: 'same_as_preset' | 'unset' | number;
frequency_penalty?: 'same_as_preset' | 'unset' | number;
presence_penalty?: 'same_as_preset' | 'unset' | number;
top_p?: 'same_as_preset' | 'unset' | number;
top_k?: 'same_as_preset' | 'unset' | number;
};
/**
* 生成配置接口(使用预设)
*/
export type GenerateConfig = {
generation_id?: string;
user_input?: string;
image?: File | string | (File | string)[];
should_stream?: boolean;
should_silence?: boolean;
overrides?: Overrides;
injects?: Omit<InjectionPrompt, 'id'>[];
max_chat_history?: 'all' | number;
custom_api?: CustomApiConfig;
};
/**
* 原始生成配置接口(不使用预设)
*/
export type GenerateRawConfig = {
generation_id?: string;
user_input?: string;
image?: File | string | (File | string)[];
should_stream?: boolean;
should_silence?: boolean;
overrides?: Overrides;
injects?: Omit<InjectionPrompt, 'id'>[];
ordered_prompts?: (BuiltinPrompt | RolePrompt)[];
max_chat_history?: 'all' | number;
custom_api?: CustomApiConfig;
};
/**
* 角色提示词接口
*/
export type RolePrompt = {
role: 'system' | 'assistant' | 'user';
content: string;
image?: File | string | (File | string)[];
};
/**
* 覆盖配置接口
*/
export type Overrides = {
world_info_before?: string; // 世界书(角色定义前)
persona_description?: string; // 用户描述
char_description?: string; // 角色描述
char_personality?: string; // 角色性格
scenario?: string; // 场景
world_info_after?: string; // 世界书(角色定义后)
dialogue_examples?: string; // 对话示例
chat_history?: {
with_depth_entries?: boolean;
author_note?: string;
prompts?: RolePrompt[];
};
};
/**
* 内置提示词类型
*/
export type BuiltinPrompt =
| 'world_info_before'
| 'persona_description'
| 'char_description'
| 'char_personality'
| 'scenario'
| 'world_info_after'
| 'dialogue_examples'
| 'chat_history'
| 'user_input';
/**
* 默认内置提示词顺序
*/
export const builtin_prompt_default_order: BuiltinPrompt[] = [
'world_info_before',
'persona_description',
'char_description',
'char_personality',
'scenario',
'world_info_after',
'dialogue_examples',
'chat_history',
'user_input',
];
/**
* 基础数据接口
*/
export type BaseData = {
characterInfo: {
description: string;
personality: string;
persona: string;
scenario: string;
system: string;
jailbreak: string;
};
chatContext: {
oaiMessages: RolePrompt[];
oaiMessageExamples: string[];
promptBias: string[];
};
worldInfo: {
worldInfoAfter: Array<string>;
worldInfoBefore: Array<string>;
worldInfoDepth: Array<{ entries: string; depth: number; role: number }>;
worldInfoExamples: Array<string>;
worldInfoString: Array<string>;
};
};
/**
* 详细配置命名空间
*/
// eslint-disable-next-line @typescript-eslint/no-namespace
export namespace detail {
export type CustomPrompt = {
role: 'system' | 'user' | 'assistant';
content: string;
};
// 覆盖配置类型
export type OverrideConfig = {
world_info_before?: string; // 世界书(角色定义之前的部分)
persona_description?: string; // 用户描述
char_description?: string; // 角色描述
char_personality?: string; // 角色高级定义-性格
scenario?: string; // 场景
world_info_after?: string; // 世界书(角色定义之后的部分)
dialogue_examples?: string; // 角色高级定义-对话示例
with_depth_entries?: boolean; // 世界书深度
author_note?: string; // 作者注释
chat_history?: RolePrompt[]; // 聊天历史
};
// 内置提示词条目类型
export type BuiltinPromptEntry =
| 'world_info_before' // 世界书(角色定义前)
| 'persona_description' // 用户描述
| 'char_description' // 角色描述
| 'char_personality' // 角色性格
| 'scenario' // 场景
| 'world_info_after' // 世界书(角色定义后)
| 'dialogue_examples' // 对话示例
| 'chat_history' // 聊天历史
| 'user_input'; // 用户输入
// 生成参数类型
export type GenerateParams = {
generation_id?: string;
user_input?: string;
use_preset?: boolean;
image?: File | string | (File | string)[];
stream?: boolean;
bindToStopButton?: boolean;
overrides?: OverrideConfig;
max_chat_history?: number;
inject?: Omit<InjectionPrompt, 'id'>[];
order?: Array<BuiltinPromptEntry | CustomPrompt>;
custom_api?: CustomApiConfig;
};
}
/**
* 角色类型映射
*/
export const roleTypes: Record<
'system' | 'user' | 'assistant',
(typeof extension_prompt_roles)[keyof typeof extension_prompt_roles]
> = {
system: extension_prompt_roles.SYSTEM,
user: extension_prompt_roles.USER,
assistant: extension_prompt_roles.ASSISTANT,
};
/**
* 默认提示词顺序
*/
export const default_order: detail.BuiltinPromptEntry[] = [
'world_info_before',
'persona_description',
'char_description',
'char_personality',
'scenario',
'world_info_after',
'dialogue_examples',
'chat_history',
'user_input',
];
/**
* 角色名称行为常量
*/
export const character_names_behavior = {
NONE: -1,
DEFAULT: 0,
COMPLETION: 1,
CONTENT: 2,
};

View File

@@ -0,0 +1,361 @@
import { saveChatConditionalDebounced } from '@/util/tavern';
import {
activateSendButtons,
eventSource,
extension_prompt_roles,
extension_prompt_types,
setExtensionPrompt,
setGenerationProgress,
showSwipeButtons,
} from '@sillytavern/script';
import { getContext } from '@sillytavern/scripts/extensions';
import { getRegexedString, regex_placement } from '@sillytavern/scripts/extensions/regex/engine';
import { oai_settings } from '@sillytavern/scripts/openai';
import { flushEphemeralStoppingStrings } from '@sillytavern/scripts/power-user';
import { getBase64Async, isDataURL } from '@sillytavern/scripts/utils';
/**
* 将文件转换为base64
* @param img 文件或图片url
* @returns base64字符串
*/
export async function convertFileToBase64(img: File | string): Promise<string | undefined> {
const isDataUrl = typeof img === 'string' && isDataURL(img);
let processedImg;
if (!isDataUrl) {
try {
if (typeof img === 'string') {
const response = await fetch(img, { method: 'GET', cache: 'force-cache' });
if (!response.ok) throw new Error('Failed to fetch image');
const blob = await response.blob();
processedImg = await getBase64Async(blob);
} else {
processedImg = await getBase64Async(img);
}
} catch (err) {
return undefined;
}
} else {
processedImg = img
}
return processedImg;
}
/**
* 从响应数据中提取消息内容
* @param data 响应数据
* @returns 提取的消息字符串
*/
export function extractMessageFromData(data: any): string {
if (typeof data === 'string') {
return data;
}
return (
data?.choices?.[0]?.message?.content ??
data?.choices?.[0]?.text ??
data?.text ??
data?.message?.content?.[0]?.text ??
data?.message?.tool_plan ??
''
);
}
/**
* 处理对话示例格式
* @param examplesStr 对话示例字符串
* @returns 处理后的对话示例数组
*/
export function parseMesExamples(examplesStr: string): string[] {
if (examplesStr.length === 0 || examplesStr === '<START>') {
return [];
}
if (!examplesStr.startsWith('<START>')) {
examplesStr = '<START>\n' + examplesStr.trim();
}
const blockHeading = '<START>\n';
const splitExamples = examplesStr
.split(/<START>/gi)
.slice(1)
.map(block => `${blockHeading}${block.trim()}\n`);
return splitExamples;
}
/**
* 用户输入先正则处理
* @param user_input 用户输入
* @returns 处理后的用户输入
*/
export function processUserInput(user_input: string): string {
if (user_input === '') {
user_input = oai_settings.send_if_empty.trim();
}
return getRegexedString(user_input, regex_placement.USER_INPUT, {
isPrompt: true,
depth: 0,
});
}
/**
* 获取提示词角色类型
* @param role 角色数字
* @returns 角色字符串
*/
export function getPromptRole(role: number): 'system' | 'user' | 'assistant' {
switch (role) {
case extension_prompt_roles.SYSTEM:
return 'system';
case extension_prompt_roles.USER:
return 'user';
case extension_prompt_roles.ASSISTANT:
return 'assistant';
default:
return 'system';
}
}
/**
* 检查提示词是否被过滤
* @param promptId 提示词ID
* @param config 配置对象
* @returns 是否被过滤
*/
export function isPromptFiltered(promptId: string, config: { overrides?: any }): boolean {
if (!config.overrides) {
return false;
}
if (promptId === 'with_depth_entries') {
return config.overrides.with_depth_entries === false;
}
// 特殊处理 chat_history
if (promptId === 'chat_history') {
const prompts = config.overrides.chat_history;
return prompts !== undefined && prompts.length === 0;
}
// 对于普通提示词,只有当它在 overrides 中存在且为空字符串时才被过滤
const override = config.overrides[promptId as keyof any];
return override !== undefined && override === '';
}
/**
* 添加临时用户消息
* @param userContent 用户内容
*/
export function addTemporaryUserMessage(userContent: string): void {
setExtensionPrompt('TEMP_USER_MESSAGE', userContent, extension_prompt_types.IN_PROMPT, 0, true, 1);
}
/**
* 移除临时用户消息
*/
export function removeTemporaryUserMessage(): void {
setExtensionPrompt('TEMP_USER_MESSAGE', '', extension_prompt_types.IN_PROMPT, 0, true, 1);
}
/**
* 解除生成阻塞状态
*/
export function unblockGeneration(): void {
activateSendButtons();
showSwipeButtons();
setGenerationProgress(0);
flushEphemeralStoppingStrings();
}
/**
* 清理注入提示词
* @param prefixes 前缀数组
*/
export async function clearInjectionPrompts(prefixes: string[]): Promise<void> {
const prompts: Record<string, any> = getContext().extensionPrompts;
Object.keys(prompts)
.filter(key => prefixes.some(prefix => key.startsWith(prefix)))
.forEach(key => delete prompts[key]);
saveChatConditionalDebounced();
}
/**
* 直接处理图片数组转换为prompt格式
* @param processedUserInput 处理后的用户输入
* @param image 图片数组参数
* @returns 包含文本和图片内容的数组格式
*/
export async function processImageArrayDirectly(
processedUserInput: string,
image: (File | string)[],
): Promise<{ type: string; text?: string; image_url?: { url: string; detail: string } }[]> {
const quality = oai_settings.inline_image_quality || 'low';
const imageContents = await Promise.all(
image.map(async img => {
try {
const processedImg = await convertFileToBase64(img);
if (!processedImg) {
console.warn('[TavernHelper][Generate:图片数组处理] 图片处理失败,跳过该图片');
return null;
}
return {
type: 'image_url',
image_url: { url: processedImg, detail: quality },
};
} catch (imgError) {
console.warn('[TavernHelper][Generate:图片数组处理] 图片处理失败,跳过该图片');
return null;
}
}),
);
const validImageContents = imageContents.filter(content => content !== null);
const textContent = {
type: 'text',
text: processedUserInput,
};
return [textContent, ...validImageContents];
}
/**
* 设置图片数组处理逻辑(用于事件监听方式)
* @param processedUserInput 处理后的用户输入
* @param image 图片数组参数
* @returns 包含带标识符的用户输入和Promise解析器的对象
*/
export function setupImageArrayProcessing(
processedUserInput: string,
image: (File | string)[],
): {
userInputWithMarker: string;
imageProcessingPromise: Promise<void>;
resolveImageProcessing: () => void;
rejectImageProcessing: (reason?: any) => void;
cleanup: () => void;
} {
const imageMarker = `__IMG_ARRAY_MARKER_`;
const userInputWithMarker = processedUserInput + imageMarker;
let resolveImageProcessing: () => void;
let rejectImageProcessing: (reason?: any) => void;
const imageProcessingPromise = new Promise<void>((resolve, reject) => {
resolveImageProcessing = resolve;
rejectImageProcessing = reject;
});
let timeoutId: NodeJS.Timeout | null = null;
let isHandlerRegistered = true;
const imageArrayHandler = async (eventData: { chat: { role: string; content: string | any[] }[] }) => {
try {
// 添加超时保护
timeoutId = setTimeout(() => {
console.warn('[TavernHelper][Generate:图片数组处理] 图片处理超时');
rejectImageProcessing(new Error('图片处理超时'));
}, 30000);
for (let i = eventData.chat.length - 1; i >= 0; i--) {
const message = eventData.chat[i];
const contentStr = typeof message.content === 'string' ? message.content : '';
if (message.role === 'user' && contentStr.includes(imageMarker)) {
try {
const quality = oai_settings.inline_image_quality || 'low';
const imageContents = await Promise.all(
image.map(async img => {
try {
const processedImg = await convertFileToBase64(img);
if (!processedImg) {
console.warn('[TavernHelper][Generate:图片数组处理] 图片处理失败,跳过该图片');
return null;
}
return {
type: 'image_url',
image_url: { url: processedImg, detail: quality },
};
} catch (imgError) {
console.warn('[TavernHelper][Generate:图片数组处理] 单个图片处理失败:', imgError);
return null;
}
}),
);
const validImageContents = imageContents.filter(content => content !== null);
const cleanContent = contentStr.replace(imageMarker, '');
const textContent = {
type: 'text',
text: cleanContent,
};
message.content = [textContent, ...validImageContents] as any;
if (timeoutId) {
clearTimeout(timeoutId);
timeoutId = null;
}
resolveImageProcessing();
return;
} catch (error) {
if (timeoutId) {
clearTimeout(timeoutId);
timeoutId = null;
}
console.error('[TavernHelper][Generate:图片数组处理] 处理图片时出错:', error);
rejectImageProcessing(error);
return;
}
}
}
console.warn('[TavernHelper][Generate:图片数组处理] 未找到包含图片标记的用户消息');
resolveImageProcessing();
} catch (error) {
console.error('[TavernHelper][Generate:图片数组处理] imageArrayHandler 异常:', error);
rejectImageProcessing(error);
}
};
eventSource.once('chat_completion_prompt_ready', imageArrayHandler);
const cleanup = () => {
if (timeoutId) {
clearTimeout(timeoutId);
timeoutId = null;
}
if (isHandlerRegistered) {
try {
eventSource.removeListener('chat_completion_prompt_ready', imageArrayHandler);
isHandlerRegistered = false;
} catch (error) {
console.warn('[TavernHelper][Generate:图片数组处理] 清理事件监听器时出错:', error);
}
}
};
return {
userInputWithMarker,
imageProcessingPromise,
resolveImageProcessing: resolveImageProcessing!,
rejectImageProcessing: rejectImageProcessing!,
cleanup,
};
}
export function normalizeBaseURL(api_url: string): string {
api_url = api_url.trim().replace(/\/+$/, '');
if (!api_url) {
return '';
}
if (api_url.endsWith('/models')) {
api_url.replace(/\/models$/, '');
}
if (api_url.endsWith('/chat/completions')) {
api_url.replace(/\/chat\/completions$/, '');
}
return api_url.endsWith('/v1') ? api_url : `${api_url}/v1`;
}

View File

@@ -0,0 +1,59 @@
import { _eventEmit, _eventOnce } from '@/function/event';
import { eventSource } from '@sillytavern/script';
import { waitUntil } from 'async-wait-until';
import { LiteralUnion } from 'type-fest';
import { get_variables_without_clone } from './variables';
export function initializeGlobal(global: LiteralUnion<'Mvu', string>, value: any): void {
_.set(window, global, value);
eventSource.emit(`global_${global}_initialized`);
}
export function _initializeGlobal(this: Window, global: LiteralUnion<'Mvu', string>, value: any): void {
_.set(window, global, value);
_eventEmit.call(this, `global_${global}_initialized`);
}
export async function waitGlobalInitialized(global: LiteralUnion<'Mvu', string>): Promise<void> {
if (_.has(window, global)) {
return;
}
return new Promise(resolve => {
eventSource.once(`global_${global}_initialized`, () => {
resolve();
});
});
}
export async function _waitGlobalInitialized(this: Window, global: LiteralUnion<'Mvu', string>): Promise<void> {
if (_.has(window, global)) {
Object.defineProperty(this, global, {
get: () => _.get(window, global),
configurable: true,
});
if (global === 'Mvu') {
try {
await waitUntil(() => _.has(get_variables_without_clone({ type: 'message', message_id: 0 }), 'stat_data'));
} catch (error) {
/** 只是作为保险, 忽略超时时的报错 */
}
}
return;
}
return new Promise(resolve => {
_eventOnce.call(this, `global_${global}_initialized`, async () => {
Object.defineProperty(this, global, {
get: () => _.get(window, global),
configurable: true,
});
if (global === 'Mvu') {
try {
await waitUntil(() => _.has(get_variables_without_clone({ type: 'message', message_id: 0 }), 'stat_data'));
} catch (error) {
/** 只是作为保险, 忽略超时时的报错 */
}
}
resolve();
});
});
}

View File

@@ -0,0 +1,110 @@
import { getCharacter, render_character } from '@/function/character';
import { RawCharacter } from '@/function/raw_character';
import { render_tavern_regexes_debounced } from '@/function/tavern_regex';
import { useCharacterSettingsStore } from '@/store/settings';
import { reloadEditor } from '@/util/compatibility';
import { preset_manager } from '@/util/tavern';
import { characters, getCharacters, getOneCharacter, getRequestHeaders, name1, this_chid } from '@sillytavern/script';
import { extension_settings } from '@sillytavern/scripts/extensions';
import { uuidv4 } from '@sillytavern/scripts/utils';
import { convertCharacterBook, saveWorldInfo, world_names } from '@sillytavern/scripts/world-info';
export async function importRawCharacter(name: string, content: Blob): Promise<Response> {
name = name.replace(/\.(?:png|json)$/, '');
const avatar = name + '.png';
const old_worldbook_name = RawCharacter.find({ name })?.data?.extensions?.world;
const file = new File([content], name, { type: 'image/png' });
const form_data = new FormData();
form_data.append('avatar', file);
form_data.append('file_type', 'png');
form_data.append('preserved_name', file.name);
const headers = getRequestHeaders();
_.unset(headers, 'Content-Type');
return fetch('/api/characters/import', {
method: 'POST',
headers: headers,
body: form_data,
cache: 'no-cache',
}).then(async result => {
$('#character_search_bar').val('').trigger('input');
const store = useCharacterSettingsStore();
const is_current = store.name === name;
await getCharacters();
await getOneCharacter(avatar);
await render_character(name, await getCharacter(name), is_current);
if (old_worldbook_name) {
const worldbook = (characters as any[]).find(character => character.avatar === avatar)?.data?.character_book;
if (world_names.includes(old_worldbook_name) && worldbook) {
await saveWorldInfo(worldbook.name, convertCharacterBook(worldbook), true);
reloadEditor(worldbook.name);
}
}
if (is_current) {
store.forceReload();
}
return result;
});
}
export async function importRawChat(name: string, content: string): Promise<Response> {
if (this_chid === undefined) {
throw Error('导入聊天文件失败, 请先选择一张角色卡');
}
const form_data = new FormData();
form_data.append('avatar', new File([content], name + '.jsonl', { type: 'application/json' }));
form_data.append('file_type', 'jsonl');
form_data.append('avatar_url', characters[this_chid as unknown as number].avatar);
form_data.append('character_name', characters[this_chid as unknown as number].name);
form_data.append('user_name', name1);
const headers = getRequestHeaders();
_.unset(headers, 'Content-Type');
return fetch(`/api/chats/import`, {
method: 'POST',
headers: headers,
body: form_data,
cache: 'no-cache',
});
}
export async function importRawPreset(name: string, content: string): Promise<boolean> {
try {
await preset_manager.savePreset(name, JSON.parse(content));
return true;
} catch (error) {
return false;
}
}
export async function importRawWorldbook(name: string, content: string): Promise<boolean> {
try {
await saveWorldInfo(name, _.pick(JSON.parse(content), 'entries'), true);
} catch (error) {
return false;
}
reloadEditor(name);
return true;
}
export function importRawTavernRegex(name: string, content: string): boolean {
const json = JSON.parse(content);
if (!_.has(json, 'findRegex')) {
return false;
}
_.set(json, 'id', uuidv4());
_.set(json, 'scriptName', name);
extension_settings.regex.push(json);
render_tavern_regexes_debounced();
return true;
}

View File

@@ -0,0 +1,428 @@
import {
appendAudioList,
getAudioList,
getAudioSettings,
pauseAudio,
playAudio,
replaceAudioList,
setAudioSettings,
} from '@/function/audio';
import { builtin } from '@/function/builtin';
import {
createCharacter,
createOrReplaceCharacter,
deleteCharacter,
getCharacter,
getCharacterNames,
getCurrentCharacterName,
replaceCharacter,
updateCharacterWith,
} from '@/function/character';
import {
createChatMessages,
deleteChatMessages,
getChatMessages,
rotateChatMessages,
setChatMessage,
setChatMessages,
} from '@/function/chat_message';
import { formatAsDisplayedMessage, refreshOneMessage, retrieveDisplayedMessage } from '@/function/displayed_message';
import {
_eventClearAll,
_eventClearEvent,
_eventClearListener,
_eventEmit,
_eventEmitAndWait,
_eventMakeFirst,
_eventMakeLast,
_eventOn,
_eventOnButton,
_eventOnce,
_eventRemoveListener,
iframe_events,
tavern_events,
} from '@/function/event';
import {
getExtensionInstallationInfo,
getExtensionType,
getTavernHelperExtensionId,
installExtension,
isAdmin,
isInstalledExtension,
reinstallExtension,
uninstallExtension,
updateExtension,
} from '@/function/extension';
import { generate, generateRaw, stopAllGeneration, stopGenerationById } from '@/function/generate';
import { builtin_prompt_default_order } from '@/function/generate/types';
import { _initializeGlobal, _waitGlobalInitialized, initializeGlobal, waitGlobalInitialized } from '@/function/global';
import {
importRawCharacter,
importRawChat,
importRawPreset,
importRawTavernRegex,
importRawWorldbook,
} from '@/function/import_raw';
import { injectPrompts, uninjectPrompts } from '@/function/inject';
import {
createLorebook,
deleteLorebook,
getCharLorebooks,
getChatLorebook,
getCurrentCharPrimaryLorebook,
getLorebooks,
getLorebookSettings,
getOrCreateChatLorebook,
setChatLorebook,
setCurrentCharLorebooks,
setLorebookSettings,
} from '@/function/lorebook';
import {
createLorebookEntries,
createLorebookEntry,
deleteLorebookEntries,
deleteLorebookEntry,
getLorebookEntries,
replaceLorebookEntries,
setLorebookEntries,
updateLorebookEntriesWith,
} from '@/function/lorebook_entry';
import { _registerMacroLike, registerMacroLike, unregisterMacroLike } from '@/function/macro_like';
import {
createOrReplacePreset,
createPreset,
default_preset,
deletePreset,
getLoadedPresetName,
getPreset,
getPresetNames,
isPresetNormalPrompt,
isPresetPlaceholderPrompt,
isPresetSystemPrompt,
loadPreset,
renamePreset,
replacePreset,
setPreset,
updatePresetWith,
} from '@/function/preset';
import {
getCharAvatarPath,
getCharData,
getChatHistoryBrief,
getChatHistoryDetail,
RawCharacter,
} from '@/function/raw_character';
import {
_appendInexistentScriptButtons,
_getButtonEvent,
_getScriptButtons,
_getScriptInfo,
_replaceScriptButtons,
_replaceScriptInfo,
getAllEnabledScriptButtons,
} from '@/function/script';
import { triggerSlash } from '@/function/slash';
import {
formatAsTavernRegexedString,
getTavernRegexes,
isCharacterTavernRegexesEnabled,
replaceTavernRegexes,
updateTavernRegexesWith,
} from '@/function/tavern_regex';
import {
_errorCatched,
_getCurrentMessageId,
_getIframeName,
_getScriptId,
_reloadIframe,
errorCatched,
getLastMessageId,
getMessageId,
substitudeMacros,
} from '@/function/util';
import {
_deleteVariable,
_getAllVariables,
_getVariables,
_insertOrAssignVariables,
_insertVariables,
_replaceVariables,
_updateVariablesWith,
deleteVariable,
getVariables,
insertOrAssignVariables,
insertVariables,
registerVariableSchema,
replaceVariables,
updateVariablesWith,
} from '@/function/variables';
import { getTavernHelperVersion, getTavernVersion, updateTavernHelper } from '@/function/version';
import {
createOrReplaceWorldbook,
createWorldbook,
createWorldbookEntries,
deleteWorldbook,
deleteWorldbookEntries,
getCharWorldbookNames,
getChatWorldbookName,
getGlobalWorldbookNames,
getOrCreateChatWorldbook,
getWorldbook,
getWorldbookNames,
rebindCharWorldbooks,
rebindChatWorldbook,
rebindGlobalWorldbooks,
replaceWorldbook,
updateWorldbookWith,
} from '@/function/worldbook';
import { audioEnable, audioImport, audioMode, audioPlay, audioSelect } from '@/slash_command/audio';
import { useIframeLogsStore } from '@/store/iframe_logs';
import { writeExtensionField } from '@/util/tavern';
function getTavernHelper() {
return {
_th_impl: {
_init: useIframeLogsStore().init,
_log: useIframeLogsStore().log,
_clearLog: useIframeLogsStore().clear,
writeExtensionField,
},
_bind: {
// event
_eventOn,
_eventOnButton,
_eventMakeLast,
_eventMakeFirst,
_eventOnce,
_eventEmit,
_eventEmitAndWait,
_eventRemoveListener,
_eventClearEvent,
_eventClearListener,
_eventClearAll,
// global
_initializeGlobal,
_waitGlobalInitialized,
// macro_like
_registerMacroLike,
// script
_getButtonEvent,
_getScriptButtons,
_replaceScriptButtons,
_appendInexistentScriptButtons,
_getScriptInfo,
_replaceScriptInfo,
// variables
_getVariables,
_getAllVariables,
_replaceVariables,
_updateVariablesWith,
_insertOrAssignVariables,
_insertVariables,
_deleteVariable,
// util
_reloadIframe,
_errorCatched,
_getIframeName,
_getScriptId,
_getCurrentMessageId,
},
// audio
audioEnable,
audioImport,
audioMode,
audioPlay,
audioSelect,
playAudio,
pauseAudio,
getAudioList,
replaceAudioList,
appendAudioList,
getAudioSettings,
setAudioSettings,
// builtin
builtin,
// character
getCharacterNames,
getCurrentCharacterName,
createCharacter,
createOrReplaceCharacter,
deleteCharacter,
getCharacter,
replaceCharacter,
updateCharacterWith,
// chat_message
getChatMessages,
setChatMessages,
setChatMessage,
createChatMessages,
deleteChatMessages,
rotateChatMessages,
// displayed_message
formatAsDisplayedMessage,
retrieveDisplayedMessage,
refreshOneMessage,
// event
tavern_events,
iframe_events,
// extension
isAdmin,
getTavernHelperExtensionId,
getExtensionType,
getExtensionStatus: getExtensionInstallationInfo,
isInstalledExtension,
installExtension,
uninstallExtension,
reinstallExtension,
updateExtension,
// import_raw
importRawCharacter,
importRawPreset,
importRawChat,
importRawWorldbook,
importRawTavernRegex,
// inject
injectPrompts,
uninjectPrompts,
// generate
builtin_prompt_default_order,
generate,
generateRaw,
stopGenerationById,
stopAllGeneration,
// global
initializeGlobal,
waitGlobalInitialized,
// lorebook_entry
getLorebookEntries,
replaceLorebookEntries,
updateLorebookEntriesWith,
setLorebookEntries,
createLorebookEntries,
createLorebookEntry,
deleteLorebookEntries,
deleteLorebookEntry,
// lorebook
getLorebookSettings,
setLorebookSettings,
getCharLorebooks,
setCurrentCharLorebooks,
getLorebooks,
deleteLorebook,
createLorebook,
getCurrentCharPrimaryLorebook,
getChatLorebook,
setChatLorebook,
getOrCreateChatLorebook,
// preset
isPresetNormalPrompt,
isPresetSystemPrompt,
isPresetPlaceholderPrompt,
default_preset,
getPresetNames,
getLoadedPresetName,
loadPreset,
createPreset,
createOrReplacePreset,
deletePreset,
renamePreset,
getPreset,
replacePreset,
updatePresetWith,
setPreset,
// raw_character
RawCharacter,
getCharData,
getCharAvatarPath,
getChatHistoryBrief,
getChatHistoryDetail,
// macro_like
registerMacroLike,
unregisterMacroLike,
// script
getAllEnabledScriptButtons,
// slash
triggerSlash,
triggerSlashWithResult: triggerSlash,
// tavern_regex
formatAsTavernRegexedString,
isCharacterTavernRegexesEnabled,
getTavernRegexes,
replaceTavernRegexes,
updateTavernRegexesWith,
// util
substitudeMacros,
getLastMessageId,
errorCatched,
getMessageId,
// variables
registerVariableSchema,
getVariables,
replaceVariables,
updateVariablesWith,
insertOrAssignVariables,
deleteVariable,
insertVariables,
// version
getTavernHelperVersion,
getFrontendVersion: getTavernHelperVersion,
updateTavernHelper,
updateFrontendVersion: updateTavernHelper,
getTavernVersion,
// worldbook
getWorldbookNames,
getGlobalWorldbookNames,
rebindGlobalWorldbooks,
getCharWorldbookNames,
rebindCharWorldbooks,
getChatWorldbookName,
rebindChatWorldbook,
getOrCreateChatWorldbook,
createWorldbook,
createOrReplaceWorldbook,
deleteWorldbook,
getWorldbook,
replaceWorldbook,
updateWorldbookWith,
createWorldbookEntries,
deleteWorldbookEntries,
};
}
// eslint-disable-next-line @typescript-eslint/no-namespace
declare namespace globalThis {
let TavernHelper: ReturnType<typeof getTavernHelper>;
}
export function initTavernHelperObject() {
globalThis.TavernHelper = getTavernHelper();
}

View File

@@ -0,0 +1,59 @@
import { iframe_events, tavern_events } from '@/function/event';
import { eventSource, extension_prompts, setExtensionPrompt } from '@sillytavern/script';
import { uuidv4 } from '@sillytavern/scripts/utils';
export type InjectionPrompt = {
id: string;
position: 'in_chat' | 'none';
depth: number;
role: 'system' | 'assistant' | 'user';
content: string;
filter?: (() => boolean) | (() => Promise<boolean>);
should_scan?: boolean;
};
type injectPromptsOptions = {
once?: boolean;
};
export function injectPrompts(prompts: InjectionPrompt[], { once = false }: injectPromptsOptions = {}) {
prompts.forEach(prompt =>
setExtensionPrompt(
prompt.id ?? uuidv4(),
prompt.content ?? '',
prompt.position === 'none' ? -1 : 1,
prompt.depth ?? 0,
prompt.should_scan ?? false,
{ system: 0, user: 1, assistant: 2 }[prompt.role ?? 'system'],
// @ts-expect-error `null` 按照实际接口是可行的
prompt.filter ?? null,
),
);
let deleted = false;
const uninject = () => {
if (deleted) {
return;
}
uninjectPrompts(prompts.map(p => p.id));
deleted = true;
};
if (once) {
eventSource.once(iframe_events.GENERATION_ENDED, uninject);
eventSource.once(tavern_events.GENERATION_ENDED, uninject);
eventSource.once(tavern_events.GENERATION_STOPPED, uninject);
}
return {
uninject,
};
}
export function uninjectPrompts(ids: string[]) {
ids.forEach(k => {
_.unset(extension_prompts, k);
});
}

View File

@@ -0,0 +1,360 @@
import { RawCharacter } from '@/function/raw_character';
import {
characters,
chat_metadata,
getCurrentChatId,
getOneCharacter,
getRequestHeaders,
saveCharacterDebounced,
saveMetadata,
saveSettings,
saveSettingsDebounced,
this_chid,
} from '@sillytavern/script';
import { ensureImageFormatSupported, getCharaFilename } from '@sillytavern/scripts/utils';
import {
createNewWorldInfo,
deleteWorldInfo,
getWorldInfoSettings,
METADATA_KEY,
selected_world_info,
setWorldInfoButtonClass,
world_info,
world_names,
} from '@sillytavern/scripts/world-info';
type LorebookSettings = {
selected_global_lorebooks: string[];
scan_depth: number;
context_percentage: number;
budget_cap: number; // 0 表示禁用
min_activations: number;
max_depth: number; // 0 表示无限制
max_recursion_steps: number;
insertion_strategy: 'evenly' | 'character_first' | 'global_first';
include_names: boolean;
recursive: boolean;
case_sensitive: boolean;
match_whole_words: boolean;
use_group_scoring: boolean;
overflow_alert: boolean;
};
async function editCurrentCharacter(): Promise<boolean> {
$('#rm_info_avatar').html('');
const form_data = new FormData(($('#form_create') as JQuery<HTMLFormElement>).get(0));
const raw_file = form_data.get('avatar');
if (raw_file instanceof File) {
const converted_file = await ensureImageFormatSupported(raw_file);
form_data.set('avatar', converted_file);
}
const headers = getRequestHeaders();
_.unset(headers, 'Content-Type');
// TODO: 这里的代码可以用来修改第一条消息!
form_data.delete('alternate_greetings');
const chid = $('.open_alternate_greetings').data('chid');
if (chid && Array.isArray(characters[chid]?.data?.alternate_greetings)) {
for (const value of characters[chid].data.alternate_greetings) {
form_data.append('alternate_greetings', value);
}
}
const response = await fetch('/api/characters/edit', {
method: 'POST',
headers: headers,
body: form_data,
cache: 'no-cache',
});
if (!response.ok) {
return false;
}
await getOneCharacter(form_data.get('avatar_url'));
$('#add_avatar_button').replaceWith($('#add_avatar_button').val('').clone(true));
$('#create_button').attr('value', 'Save');
return true;
}
function toLorebookSettings(world_info_settings: ReturnType<typeof getWorldInfoSettings>): LorebookSettings {
return {
selected_global_lorebooks: (world_info_settings.world_info as { globalSelect: string[] }).globalSelect,
scan_depth: world_info_settings.world_info_depth,
context_percentage: world_info_settings.world_info_budget,
budget_cap: world_info_settings.world_info_budget_cap,
min_activations: world_info_settings.world_info_min_activations,
max_depth: world_info_settings.world_info_min_activations_depth_max,
max_recursion_steps: world_info_settings.world_info_max_recursion_steps,
insertion_strategy: { 0: 'evenly', 1: 'character_first', 2: 'global_first' }[
world_info_settings.world_info_character_strategy
] as 'evenly' | 'character_first' | 'global_first',
include_names: world_info_settings.world_info_include_names,
recursive: world_info_settings.world_info_recursive,
case_sensitive: world_info_settings.world_info_case_sensitive,
match_whole_words: world_info_settings.world_info_match_whole_words,
use_group_scoring: world_info_settings.world_info_use_group_scoring,
overflow_alert: world_info_settings.world_info_overflow_alert,
};
}
function assignPartialLorebookSettings(settings: Partial<LorebookSettings>): void {
let $inputs = $();
let $changes = $();
const for_eachs = {
selected_global_lorebooks: (value: LorebookSettings['selected_global_lorebooks']) => {
$('#world_info').find('option[value!=""]').remove();
world_names.forEach((item, i) =>
$('#world_info').append(`<option value='${i}'${value.includes(item) ? ' selected' : ''}>${item}</option>`),
);
selected_world_info.length = 0;
selected_world_info.push(...value);
saveSettings();
},
scan_depth: (value: LorebookSettings['scan_depth']) => {
$inputs = $inputs.add($('#world_info_depth').val(value));
},
context_percentage: (value: LorebookSettings['context_percentage']) => {
$inputs = $inputs.add($('#world_info_budget').val(value));
},
budget_cap: (value: LorebookSettings['budget_cap']) => {
$inputs = $inputs.add($('#world_info_budget_cap').val(value));
},
min_activations: (value: LorebookSettings['min_activations']) => {
$inputs = $inputs.add($('#world_info_min_activations').val(value));
},
max_depth: (value: LorebookSettings['max_depth']) => {
$inputs = $inputs.add($('#world_info_min_activations_depth_max').val(value));
},
max_recursion_steps: (value: LorebookSettings['max_recursion_steps']) => {
$inputs = $inputs.add($('#world_info_max_recursion_steps').val(value));
},
insertion_strategy: (value: LorebookSettings['insertion_strategy']) => {
const converted_value = { evenly: 0, character_first: 1, global_first: 2 }[value];
$(`#world_info_character_strategy option[value='${converted_value}']`).prop('selected', true);
$changes = $changes.add($('#world_info_character_strategy').val(converted_value));
},
include_names: (value: LorebookSettings['include_names']) => {
$inputs = $inputs.add($('#world_info_include_names').prop('checked', value));
},
recursive: (value: LorebookSettings['recursive']) => {
$inputs = $inputs.add($('#world_info_recursive').prop('checked', value));
},
case_sensitive: (value: LorebookSettings['case_sensitive']) => {
$inputs = $inputs.add($('#world_info_case_sensitive').prop('checked', value));
},
match_whole_words: (value: LorebookSettings['match_whole_words']) => {
$inputs = $inputs.add($('#world_info_match_whole_words').prop('checked', value));
},
use_group_scoring: (value: LorebookSettings['use_group_scoring']) => {
$changes = $changes.add($('#world_info_use_group_scoring').prop('checked', value));
},
overflow_alert: (value: LorebookSettings['overflow_alert']) => {
$changes = $changes.add($('#world_info_overflow_alert').prop('checked', value));
},
} as const;
Object.entries(settings)
.filter(([_, value]) => value !== undefined)
.forEach(([field, value]) => {
// @ts-expect-error 未知类型报错
for_eachs[field]?.(value);
});
$inputs.trigger('input');
$changes.trigger('change');
}
type GetCharLorebooksOption = {
name?: string;
type?: 'all' | 'primary' | 'additional';
};
export function getLorebookSettings(): LorebookSettings {
return klona(toLorebookSettings(getWorldInfoSettings()));
}
export function setLorebookSettings(settings: Partial<LorebookSettings>): void {
if (settings.selected_global_lorebooks) {
const inexisting_lorebooks = settings.selected_global_lorebooks.filter(lorebook => !world_names.includes(lorebook));
if (inexisting_lorebooks.length > 0) {
throw Error(`尝试修改要全局启用的世界书, 但未找到以下世界书: ${JSON.stringify(inexisting_lorebooks)}`);
}
}
const original_settings = getLorebookSettings();
settings = _.omitBy(settings, (value, key) => value === original_settings[key as keyof LorebookSettings]);
assignPartialLorebookSettings(settings);
}
export function getLorebooks(): string[] {
return klona(world_names);
}
export async function deleteLorebook(lorebook: string): Promise<boolean> {
return deleteWorldInfo(lorebook);
}
export async function createLorebook(lorebook: string): Promise<boolean> {
return createNewWorldInfo(lorebook, { interactive: false });
}
type CharLorebooks = {
primary: string | null;
additional: string[];
};
export function getCharLorebooks({ name = 'current' }: GetCharLorebooksOption = {}): CharLorebooks {
const character = RawCharacter.find({ name: name });
if (!character) {
throw Error(`未找到${name === 'current' ? '当前打开' : `名为 '${name}' `}的角色卡`);
}
const books: CharLorebooks = { primary: null, additional: [] };
if (character.data?.extensions?.world) {
books.primary = character.data?.extensions?.world || null;
}
// TODO: 提取成函数
const filename = character.avatar.replace(/\.[^/.]+$/, '');
const extra_charlore = (world_info as { charLore: { name: string; extraBooks: string[] }[] }).charLore?.find(
e => e.name === filename,
);
if (extra_charlore && Array.isArray(extra_charlore.extraBooks)) {
books.additional = extra_charlore.extraBooks;
}
return klona(books);
}
export function getCurrentCharPrimaryLorebook(): string | null {
return getCharLorebooks().primary;
}
export async function setCurrentCharLorebooks(lorebooks: Partial<CharLorebooks>): Promise<void> {
const filename = getCharaFilename(this_chid);
if (!filename) {
throw Error(`未打开任何角色卡`);
}
const inexisting_lorebooks = _(_.concat(lorebooks.primary ? [lorebooks.primary] : [], lorebooks.additional))
.reject(_.isNull)
.reject(lorebook_name => getLorebooks().some(value => value === lorebook_name))
.value();
if (inexisting_lorebooks.length > 0) {
throw Error(`尝试修改 '${filename}' 绑定的世界书, 但未找到以下世界书: ${inexisting_lorebooks}`);
}
if (lorebooks.primary !== undefined) {
const previous_primary = String($('#character_world').val());
$('#character_world').val(lorebooks.primary || '');
$('.character_world_info_selector')
.find('option:selected')
.val(lorebooks.primary ? world_names.indexOf(lorebooks.primary) : '');
if (previous_primary && !lorebooks.primary) {
const data = JSON.parse(String($('#character_json_data').val()));
if (data?.data?.character_book) {
data.data.character_book = undefined;
}
$('#character_json_data').val(JSON.stringify(data));
}
if (!(await editCurrentCharacter())) {
throw Error(`尝试为 '${filename}' 绑定主要世界书, 但在访问酒馆后端时出错`);
}
// @ts-expect-error 类型是正确的
setWorldInfoButtonClass(undefined, !!lorebooks.primary);
}
if (lorebooks.additional !== undefined) {
type CharLoreEntry = {
name: string;
extraBooks: string[];
};
const char_lore = (world_info as { charLore: CharLoreEntry[] }).charLore ?? [];
const existing_char_index = char_lore.findIndex(entry => entry.name === filename);
if (existing_char_index === -1) {
char_lore.push({ name: filename, extraBooks: lorebooks.additional });
} else if (lorebooks.additional.length === 0) {
char_lore.splice(existing_char_index, 1);
} else {
char_lore[existing_char_index].extraBooks = lorebooks.additional;
}
Object.assign(world_info, { charLore: char_lore });
}
saveCharacterDebounced();
saveSettingsDebounced();
}
export function getChatLorebook(): string | null {
const chat_id = getCurrentChatId();
if (!chat_id) {
throw Error(`未打开任何聊天, 不可获取聊天世界书`);
}
const existing_lorebook = _.get(chat_metadata, METADATA_KEY, '') as string;
if (world_names.includes(existing_lorebook)) {
return existing_lorebook;
}
_.unset(chat_metadata, METADATA_KEY);
return null;
}
export async function setChatLorebook(lorebook: string | null): Promise<void> {
if (lorebook === null) {
_.unset(chat_metadata, METADATA_KEY);
$('.chat_lorebook_button').removeClass('world_set');
} else {
if (!world_names.includes(lorebook)) {
throw new Error(`尝试为角色卡绑定聊天世界书, 但该世界书 '${lorebook}' 不存在`);
}
_.set(chat_metadata, METADATA_KEY, lorebook);
$('.chat_lorebook_button').addClass('world_set');
}
await saveMetadata();
}
export async function getOrCreateChatLorebook(lorebook?: string): Promise<string> {
const existing_lorebook = await getChatLorebook();
if (existing_lorebook !== null) {
return existing_lorebook;
}
const new_lorebook = (() => {
if (lorebook) {
if (world_names.includes(lorebook)) {
throw new Error(`尝试创建聊天世界书, 但该名称 '${lorebook}' 已存在`);
}
return lorebook;
}
return `Chat Book ${getCurrentChatId()}`
.replace(/[^a-z0-9]/gi, '_')
.replace(/_{2,}/g, '_')
.substring(0, 64);
})();
await createNewWorldInfo(new_lorebook);
await setChatLorebook(new_lorebook);
return new_lorebook;
}

View File

@@ -0,0 +1,430 @@
import { reloadEditorDebounced } from '@/util/compatibility';
import { loadWorldInfo, saveWorldInfo, world_names } from '@sillytavern/scripts/world-info';
type LorebookEntry = {
uid: number;
display_index: number;
comment: string;
enabled: boolean;
type: 'constant' | 'selective' | 'vectorized';
position:
| 'before_character_definition' // 角色定义之前
| 'after_character_definition' // 角色定义之后
| 'before_example_messages' // 示例消息之前
| 'after_example_messages' // 示例消息之后
| 'before_author_note' // 作者注释之前
| 'after_author_note' // 作者注释之后
| 'at_depth_as_system' // @D⚙
| 'at_depth_as_assistant' // @D👤
| 'at_depth_as_user'; // @D🤖
depth: number | null;
order: number;
probability: number;
/** @deprecated 请使用 `keys` 代替 */
key: string[];
keys: string[];
logic: 'and_any' | 'and_all' | 'not_all' | 'not_any';
/** @deprecated 请使用 `filters` 代替 */
filter: string[];
filters: string[];
scan_depth: 'same_as_global' | number;
case_sensitive: 'same_as_global' | boolean;
match_whole_words: 'same_as_global' | boolean;
use_group_scoring: 'same_as_global' | boolean;
automation_id: string | null;
exclude_recursion: boolean;
prevent_recursion: boolean;
delay_until_recursion: boolean | number;
content: string;
group: string;
group_prioritized: boolean;
group_weight: number;
sticky: number | null;
cooldown: number | null;
delay: number | null;
};
type _OriginalLorebookEntry = {
uid: number;
key: string[];
keysecondary: string[];
comment: string;
content: string;
constant: boolean;
vectorized: boolean;
selective: boolean;
selectiveLogic: 0 | 1 | 2 | 3; // 0: and_any, 1: not_all, 2: not_any, 3: and_all
addMemo: boolean;
order: number;
position: number;
disable: boolean;
excludeRecursion: boolean;
preventRecursion: boolean;
matchPersonaDescription: boolean;
matchCharacterDescription: boolean;
matchCharacterPersonality: boolean;
matchCharacterDepthPrompt: boolean;
matchScenario: boolean;
matchCreatorNotes: boolean;
delayUntilRecursion: number;
probability: number;
useProbability: boolean;
depth: number;
group: string;
groupOverride: boolean;
groupWeight: number;
scanDepth: number | null;
caseSensitive: boolean | null;
matchWholeWords: boolean | null;
useGroupScoring: boolean | null;
automationId: string;
role: 0 | 1 | 2; // 0: system, 1: user, 2: assistant
sticky: number | null;
cooldown: number | null;
delay: number | null;
displayIndex: number;
};
const default_original_lorebook_entry: Omit<_OriginalLorebookEntry, 'uid' | 'displayIndex'> = {
key: [],
keysecondary: [],
comment: '',
content: '',
constant: false,
vectorized: false,
selective: true,
selectiveLogic: 0,
addMemo: true,
order: 100,
position: 0,
disable: false,
excludeRecursion: false,
preventRecursion: false,
matchPersonaDescription: false,
matchCharacterDescription: false,
matchCharacterPersonality: false,
matchCharacterDepthPrompt: false,
matchScenario: false,
matchCreatorNotes: false,
delayUntilRecursion: 0,
probability: 100,
useProbability: true,
depth: 4,
group: '',
groupOverride: false,
groupWeight: 100,
scanDepth: null,
caseSensitive: null,
matchWholeWords: null,
useGroupScoring: null,
automationId: '',
role: 0,
sticky: null,
cooldown: null,
delay: null,
};
function toLorebookEntry(entry: _OriginalLorebookEntry): LorebookEntry {
return {
uid: entry.uid,
display_index: entry.displayIndex,
comment: entry.comment,
enabled: !entry.disable,
type: entry.constant ? 'constant' : entry.vectorized ? 'vectorized' : 'selective',
position:
(
{
0: 'before_character_definition',
1: 'after_character_definition',
5: 'before_example_messages',
6: 'after_example_messages',
2: 'before_author_note',
3: 'after_author_note',
} as const
)[entry.position] ??
(entry.role === 1 ? 'at_depth_as_user' : entry.role === 2 ? 'at_depth_as_assistant' : 'at_depth_as_system'),
depth: entry.position === 4 ? entry.depth : null,
order: entry.order,
probability: entry.probability,
key: entry.key,
keys: entry.key,
logic: (
{
0: 'and_any',
1: 'not_all',
2: 'not_any',
3: 'and_all',
} as const
)[entry.selectiveLogic],
filter: entry.keysecondary,
filters: entry.keysecondary,
scan_depth: entry.scanDepth ?? 'same_as_global',
case_sensitive: entry.caseSensitive ?? 'same_as_global',
match_whole_words: entry.matchWholeWords ?? 'same_as_global',
use_group_scoring: entry.useGroupScoring ?? 'same_as_global',
automation_id: entry.automationId || null,
exclude_recursion: entry.excludeRecursion,
prevent_recursion: entry.preventRecursion,
delay_until_recursion: entry.delayUntilRecursion,
content: entry.content,
group: entry.group,
group_prioritized: entry.groupOverride,
group_weight: entry.groupWeight,
sticky: entry.sticky || null,
cooldown: entry.cooldown || null,
delay: entry.delay || null,
};
}
interface GetLorebookEntriesOption {
filter?: 'none' | Partial<LorebookEntry>;
}
export async function getLorebookEntries(
lorebook: string,
{ filter = 'none' }: GetLorebookEntriesOption = {},
): Promise<LorebookEntry[]> {
if (!world_names.includes(lorebook)) {
throw Error(`未能找到世界书 '${lorebook}'`);
}
const data = (await loadWorldInfo(lorebook)) as { entries: { [uid: number]: _OriginalLorebookEntry } };
let entries: LorebookEntry[] = _(data.entries).values().map(toLorebookEntry).value();
if (filter !== 'none') {
entries = entries.filter(entry =>
Object.entries(filter).every(([field, expected_value]) => {
const entry_value = entry[field as keyof LorebookEntry];
if (Array.isArray(entry_value)) {
return (expected_value as string[]).every(value => entry_value.includes(value));
}
if (typeof entry_value === 'string') {
return entry_value.includes(expected_value as string);
}
return entry_value === expected_value;
}),
);
}
return klona(entries);
}
function fromPartialLorebookEntry(
entry: Pick<LorebookEntry, 'uid' | 'display_index'> & Partial<LorebookEntry>,
): Pick<_OriginalLorebookEntry, 'uid' | 'displayIndex'> & Partial<_OriginalLorebookEntry> {
const transformers = {
uid: (value: LorebookEntry['uid']) => ({ uid: value }),
display_index: (value: LorebookEntry['display_index']) => ({ displayIndex: value }),
comment: (value: LorebookEntry['comment']) => ({ comment: value }),
enabled: (value: LorebookEntry['enabled']) => ({ disable: !value }),
type: (value: LorebookEntry['type']) => ({
constant: value === 'constant',
vectorized: value === 'vectorized',
}),
position: (value: LorebookEntry['position']) => ({
position: {
before_character_definition: 0,
after_character_definition: 1,
before_example_messages: 5,
after_example_messages: 6,
before_author_note: 2,
after_author_note: 3,
at_depth_as_system: 4,
at_depth_as_user: 4,
at_depth_as_assistant: 4,
}[value],
role: _.get(
{
at_depth_as_system: 0,
at_depth_as_user: 1,
at_depth_as_assistant: 2,
},
value,
null,
),
}),
depth: (value: LorebookEntry['depth']) => ({ depth: value === null ? 4 : value }),
order: (value: LorebookEntry['order']) => ({ order: value }),
probability: (value: LorebookEntry['probability']) => ({ probability: value }),
keys: (value: LorebookEntry['keys']) => ({ key: value }),
logic: (value: LorebookEntry['logic']) => ({
selectiveLogic: {
and_any: 0,
not_all: 1,
not_any: 2,
and_all: 3,
}[value],
}),
filters: (value: LorebookEntry['filter']) => ({ keysecondary: value }),
scan_depth: (value: LorebookEntry['scan_depth']) => ({ scanDepth: value === 'same_as_global' ? null : value }),
case_sensitive: (value: LorebookEntry['case_sensitive']) => ({
caseSensitive: value === 'same_as_global' ? null : value,
}),
match_whole_words: (value: LorebookEntry['match_whole_words']) => ({
matchWholeWords: value === 'same_as_global' ? null : value,
}),
use_group_scoring: (value: LorebookEntry['use_group_scoring']) => ({
useGroupScoring: value === 'same_as_global' ? null : value,
}),
automation_id: (value: LorebookEntry['automation_id']) => ({ automationId: value === null ? '' : value }),
exclude_recursion: (value: LorebookEntry['exclude_recursion']) => ({ excludeRecursion: value }),
prevent_recursion: (value: LorebookEntry['prevent_recursion']) => ({ preventRecursion: value }),
delay_until_recursion: (value: LorebookEntry['delay_until_recursion']) => ({ delayUntilRecursion: value }),
content: (value: LorebookEntry['content']) => ({ content: value }),
group: (value: LorebookEntry['group']) => ({ group: value }),
group_prioritized: (value: LorebookEntry['group_prioritized']) => ({ groupOverride: value }),
group_weight: (value: LorebookEntry['group_weight']) => ({ groupWeight: value }),
sticky: (value: LorebookEntry['sticky']) => ({ sticky: value === null ? 0 : value }),
cooldown: (value: LorebookEntry['cooldown']) => ({ cooldown: value === null ? 0 : value }),
delay: (value: LorebookEntry['delay']) => ({ delay: value === null ? 0 : value }),
} as const;
return _.merge(
{},
default_original_lorebook_entry,
...Object.entries(entry)
.filter(([_, value]) => value !== undefined)
// @ts-expect-error 未知类型报错
.map(([key, value]) => transformers[key]?.(value)),
);
}
const MAX_UID = 1_000_000;
function handleLorebookEntriesCollision(
entries: Partial<LorebookEntry>[],
): Array<Pick<LorebookEntry, 'uid' | 'display_index'> & Partial<LorebookEntry>> {
const uid_set = new Set<number>();
const handle_uid_collision = (index: number | undefined) => {
if (index === undefined) {
index = _.random(0, MAX_UID - 1);
}
let i = 1;
while (true) {
if (!uid_set.has(index)) {
uid_set.add(index);
return index;
}
index = (index + i * i) % MAX_UID;
++i;
}
};
let max_display_index = _.max(entries.map(entry => entry.display_index ?? -1)) ?? -1;
return entries.map(entry => ({
...entry,
uid: handle_uid_collision(entry.uid),
display_index: entry.display_index ?? ++max_display_index,
}));
}
export async function replaceLorebookEntries(lorebook: string, entries: Partial<LorebookEntry>[]): Promise<void> {
if (!world_names.includes(lorebook)) {
throw Error(`未能找到世界书 '${lorebook}'`);
}
const data = {
entries: _.merge(
{},
...handleLorebookEntriesCollision(entries)
.map(fromPartialLorebookEntry)
.map(entry => ({ [entry.uid]: entry })),
),
};
await saveWorldInfo(lorebook, data);
reloadEditorDebounced(lorebook);
}
type LorebookEntriesUpdater =
| ((entries: LorebookEntry[]) => Partial<LorebookEntry>[])
| ((entries: LorebookEntry[]) => Promise<Partial<LorebookEntry>[]>);
export async function updateLorebookEntriesWith(
lorebook: string,
updater: LorebookEntriesUpdater,
): Promise<LorebookEntry[]> {
await replaceLorebookEntries(lorebook, await updater(await getLorebookEntries(lorebook)));
return getLorebookEntries(lorebook);
}
export async function setLorebookEntries(
lorebook: string,
entries: Array<Pick<LorebookEntry, 'uid'> & Partial<LorebookEntry>>,
): Promise<LorebookEntry[]> {
return await updateLorebookEntriesWith(lorebook, data => {
for (const entry_to_set of entries) {
const data_entry = data.find(entry => entry.uid === entry_to_set.uid);
if (data_entry) {
_.merge(data_entry, entry_to_set);
}
}
return data;
});
}
export async function createLorebookEntries(
lorebook: string,
entries: Partial<LorebookEntry>[],
): Promise<{ entries: LorebookEntry[]; new_uids: number[] }> {
const new_uids: number[] = [];
const updated_entries = await updateLorebookEntriesWith(lorebook, data => {
const uid_set = new Set(data.map(entry => entry.uid));
const get_free_uid = () => {
for (let i = 0; i < MAX_UID; ++i) {
if (!uid_set.has(i)) {
uid_set.add(i);
new_uids.push(i);
return i;
}
}
throw Error(`无法找到可用的世界书条目 uid`);
};
entries.forEach(entry => (entry.uid = get_free_uid()));
return [...data, ...entries];
});
return { entries: updated_entries, new_uids: new_uids };
}
export async function deleteLorebookEntries(
lorebook: string,
uids: number[],
): Promise<{ entries: LorebookEntry[]; delete_occurred: boolean }> {
let deleted: boolean = false;
const updated_entires = await updateLorebookEntriesWith(lorebook, data => {
const removed_data = _.remove(data, entry => uids.includes(entry.uid));
deleted = removed_data.length > 0;
return data;
});
return { entries: updated_entires, delete_occurred: deleted };
}
//----------------------------------------------------------------------------------------------------------------------
/** @deprecated 请使用 `createLorebookEntries` 代替 */
export async function createLorebookEntry(lorebook: string, field_values: Partial<LorebookEntry>): Promise<number> {
return (await createLorebookEntries(lorebook, [field_values])).new_uids[0];
}
/** @deprecated 请使用 `deleteLorebookEntries` 代替 */
export async function deleteLorebookEntry(lorebook: string, uid: number): Promise<boolean> {
return (await deleteLorebookEntries(lorebook, [uid])).delete_occurred;
}

View File

@@ -0,0 +1,104 @@
import { get_variables_without_clone } from '@/function/variables';
import { chat } from '@sillytavern/script';
import { omitDeepBy } from 'lodash-omitdeep';
import YAML from 'yaml';
export interface MacroLike {
regex: RegExp;
replace: (context: MacroLikeContext, substring: string, ...args: any[]) => string;
}
export interface MacroLikeContext {
message_id?: number;
role?: 'user' | 'assistant' | 'system';
}
function getVariableOption(context: MacroLikeContext, type: 'message' | 'chat' | 'character' | 'preset' | 'global') {
return type !== 'message'
? { type }
: {
type,
message_id:
context.message_id ?? chat.findLastIndex(message => _.isObject(message.variables?.[message.swipe_id ?? 0])),
};
}
function getWithout$(variables: Record<string, any>, path: string) {
return omitDeepBy(_.get(variables, _.unescape(path), null), (_, key) => key.startsWith('$'));
}
const format_variable_regex = /^(.*)\{\{format_(message|chat|character|preset|global)_variable::(.*?)\}\}/im;
function applyFormatVariable(
context: MacroLikeContext,
_substring: string,
prefix: string,
type: 'message' | 'chat' | 'character' | 'preset' | 'global',
path: string,
) {
const match = prefix.match(format_variable_regex);
if (match) {
prefix =
applyFormatVariable(
context,
'',
match[1],
match[2] as 'message' | 'chat' | 'character' | 'preset' | 'global',
match[3],
) + prefix.slice(match[0].length);
}
const variables = get_variables_without_clone(getVariableOption(context, type));
const value = getWithout$(variables, path);
return (
prefix +
(typeof value === 'string' ? value : YAML.stringify(value, { blockQuote: 'literal' }).trimEnd()).replaceAll(
'\n',
'\n' + ' '.repeat(prefix.length),
)
);
}
export const macros: MacroLike[] = [
{
regex: /\{\{get_(message|chat|character|preset|global)_variable::(.*?)\}\}/gi,
replace: (
context: MacroLikeContext,
_substring: string,
type: 'message' | 'chat' | 'character' | 'preset' | 'global',
path: string,
) => {
const variables = get_variables_without_clone(getVariableOption(context, type));
const value = getWithout$(variables, path);
return typeof value === 'string' ? value : JSON.stringify(value);
},
},
{
regex: /^(.*)\{\{format_(message|chat|character|preset|global)_variable::(.*?)\}\}/gim,
replace: applyFormatVariable,
},
];
export function registerMacroLike(
regex: RegExp,
replace: (context: MacroLikeContext, substring: string, ...args: any[]) => string,
): { unregister: () => void } {
if (!macros.some(macro => macro.regex.source === regex.source)) {
macros.push({ regex, replace });
}
return { unregister: () => unregisterMacroLike(regex) };
}
export function _registerMacroLike(
this: Window,
regex: RegExp,
replace: (context: MacroLikeContext, substring: string, ...args: any[]) => string,
): { unregister: () => void } {
const { unregister } = registerMacroLike(regex, replace);
$(this).on('pagehide', unregister);
return { unregister };
}
export function unregisterMacroLike(regex: RegExp) {
const index = macros.findIndex(macro => macro.regex.source === regex.source);
if (index !== -1) {
macros.splice(index, 1);
}
}

View File

@@ -0,0 +1,751 @@
import { from_tavern_regex, TavernRegex, to_tavern_regex } from '@/function/tavern_regex';
import { settingsToUpdate } from '@/util/compatibility';
import { getCompletionPresetByName } from '@/util/tavern';
import { saveSettingsDebounced } from '@sillytavern/script';
import { oai_settings, promptManager } from '@sillytavern/scripts/openai';
import { getPresetManager } from '@sillytavern/scripts/preset-manager';
import { uuidv4 } from '@sillytavern/scripts/utils';
import { LiteralUnion, PartialDeep, SetRequired } from 'type-fest';
type Preset = {
settings: {
max_context: number;
max_completion_tokens: number;
reply_count: number;
should_stream: boolean;
temperature: number;
frequency_penalty: number;
presence_penalty: number;
repetition_penalty: number;
top_p: number;
min_p: number;
top_k: number;
top_a: number;
seed: number;
squash_system_messages: boolean;
reasoning_effort: 'auto' | 'min' | 'low' | 'medium' | 'high' | 'max';
request_thoughts: boolean;
request_images: boolean;
enable_function_calling: boolean;
enable_web_search: boolean;
allow_sending_images: 'disabled' | 'auto' | 'low' | 'high';
allow_sending_videos: boolean;
character_name_prefix: 'none' | 'default' | 'content' | 'completion';
wrap_user_messages_in_quotes: boolean;
};
prompts: PresetPrompt[];
prompts_unused: PresetPrompt[];
extensions: {
regex_scripts?: TavernRegex[];
tavern_helper: {
scripts: Record<string, any>[];
variables: Record<string, any>;
};
[other: string]: any;
};
};
type PresetPrompt = {
id: LiteralUnion<
| 'main'
| 'nsfw'
| 'jailbreak'
| 'enhanceDefinitions'
| 'worldInfoBefore'
| 'personaDescription'
| 'charDescription'
| 'charPersonality'
| 'scenario'
| 'worldInfoAfter'
| 'dialogueExamples'
| 'chatHistory',
string
>;
name: string;
enabled: boolean;
position:
| {
type: 'relative';
depth?: never;
order?: never;
}
| { type: 'in_chat'; depth: number; order: number };
role: 'system' | 'user' | 'assistant';
content?: string;
extra?: Record<string, any>;
};
type PresetNormalPrompt = SetRequired<{ id: string } & Omit<PresetPrompt, 'id'>, 'position' | 'content'>;
type PresetSystemPrompt = SetRequired<
{ id: 'main' | 'nsfw' | 'jailbreak' | 'enhanceDefinitions' } & Omit<PresetPrompt, 'id'>,
'content'
>;
type PresetPlaceholderPrompt = SetRequired<
{
id:
| 'worldInfoBefore'
| 'personaDescription'
| 'charDescription'
| 'charPersonality'
| 'scenario'
| 'worldInfoAfter'
| 'dialogueExamples'
| 'chatHistory';
} & Omit<PresetPrompt, 'id'>,
'position'
>;
export function isPresetNormalPrompt(prompt: PresetPrompt): prompt is PresetNormalPrompt {
return !isPresetSystemPrompt(prompt) && !isPresetPlaceholderPrompt(prompt);
}
export function isPresetSystemPrompt(prompt: PresetPrompt): prompt is PresetSystemPrompt {
return ['main', 'nsfw', 'jailbreak', 'enhanceDefinitions'].includes(prompt.id);
}
export function isPresetPlaceholderPrompt(prompt: PresetPrompt): prompt is PresetPlaceholderPrompt {
return [
'worldInfoBefore',
'personaDescription',
'charDescription',
'charPersonality',
'scenario',
'worldInfoAfter',
'dialogueExamples',
'chatHistory',
].includes(prompt.id);
}
export const default_preset: Preset = {
settings: {
max_context: 2000000,
max_completion_tokens: 300,
reply_count: 1,
should_stream: false,
temperature: 1,
frequency_penalty: 0,
presence_penalty: 0,
repetition_penalty: 1,
top_p: 1,
min_p: 0,
top_k: 0,
top_a: 0,
seed: -1,
squash_system_messages: false,
reasoning_effort: 'auto',
request_thoughts: false,
request_images: false,
enable_function_calling: false,
enable_web_search: false,
allow_sending_images: 'disabled',
allow_sending_videos: false,
character_name_prefix: 'none',
wrap_user_messages_in_quotes: false,
},
prompts: [
{
id: 'worldInfoBefore',
name: 'World Info (before) - 角色定义之前',
enabled: true,
position: { type: 'relative' },
role: 'system',
},
{
id: 'personaDescription',
name: 'Persona Description - 玩家描述',
enabled: true,
position: { type: 'relative' },
role: 'system',
},
{
id: 'charDescription',
name: 'Char Description - 角色描述',
enabled: true,
position: { type: 'relative' },
role: 'system',
},
{
id: 'charPersonality',
name: 'Char Personality - 角色性格',
enabled: true,
position: { type: 'relative' },
role: 'system',
},
{ id: 'scenario', name: 'Scenario - 情景', enabled: true, position: { type: 'relative' }, role: 'system' },
{
id: 'worldInfoAfter',
name: 'World Info (after) - 角色定义之后',
enabled: true,
position: { type: 'relative' },
role: 'system',
},
{
id: 'dialogueExamples',
name: 'Chat Examples - 对话示例',
enabled: true,
position: { type: 'relative' },
role: 'system',
},
{
id: 'chatHistory',
name: 'Chat History - 聊天记录',
enabled: true,
position: { type: 'relative' },
role: 'system',
},
],
prompts_unused: [],
extensions: {
tavern_helper: {
scripts: [],
variables: {},
},
},
} as const;
const in_use_map = {
temp_openai: 'temperature',
freq_pen_openai: 'frequency_penalty',
pres_pen_openai: 'presence_penalty',
top_p_openai: 'top_p',
repetition_penalty_openai: 'repetition_penalty',
min_p_openai: 'min_p',
top_k_openai: 'top_k',
top_a_openai: 'top_a',
} as const;
type _OriginalPreset = {
max_context_unlocked: boolean;
openai_max_context: number;
openai_max_tokens: number;
n: number;
stream_openai: boolean;
// vv in use vv
temp_openai: number;
freq_pen_openai: number;
pres_pen_openai: number;
top_p_openai: number;
repetition_penalty_openai: number;
min_p_openai: number;
top_k_openai: number;
top_a_openai: number;
// vv in file vv
temperature: number;
frequency_penalty: number;
presence_penalty: number;
top_p: number;
repetition_penalty: number;
min_p: number;
top_k: number;
top_a: number;
seed: number;
squash_system_messages: boolean;
reasoning_effort: 'auto' | 'min' | 'low' | 'medium' | 'high' | 'max';
show_thoughts: boolean;
request_images: boolean;
function_calling: boolean;
enable_web_search: boolean;
image_inlining: boolean;
inline_image_quality: 'auto' | 'low' | 'high';
video_inlining: boolean;
names_behavior: -1 | 0 | 2 | 1;
wrap_in_quotes: boolean;
prompts: _OriginalPrompt[];
prompt_order: Array<{
character_id: 100001;
order: _OriginalPromptOrder[];
}>;
extensions: Record<string, any>;
};
type _OriginalPromptOrder = {
identifier: string;
enabled: boolean;
};
type _OriginalPrompt = _OriginalNormalPrompt | _OriginalSystemPrompt | _OriginalPlaceholderPrompt;
type _OriginalNormalPrompt = {
identifier: string;
name: string;
enabled?: boolean;
injection_position: 0 | 1;
injection_depth: number;
injection_order: number;
role: 'system' | 'user' | 'assistant';
content: string;
system_prompt: false;
marker?: false;
extra?: Record<string, any>;
forbid_overrides: false;
};
type _OriginalSystemPrompt = {
identifier: 'main' | 'nsfw' | 'jailbreak' | 'enhanceDefinitions';
name: string;
enabled?: boolean;
role: 'system' | 'user' | 'assistant';
content: string;
system_prompt: true;
marker?: false;
extra?: Record<string, any>;
forbid_overrides: false;
};
type _OriginalPlaceholderPrompt = {
identifier:
| 'worldInfoBefore'
| 'personaDescription'
| 'charDescription'
| 'charPersonality'
| 'scenario'
| 'worldInfoAfter'
| 'dialogueExamples'
| 'chatHistory';
name: string;
enabled?: boolean;
injection_position: 0 | 1;
injection_depth: number;
injection_order: number;
role: 'system' | 'user' | 'assistant';
content?: never;
system_prompt: true;
marker?: true;
extra?: Record<string, any>;
};
function toPresetPrompt(prompt: _OriginalPrompt, prompt_order: _OriginalPromptOrder[]): PresetPrompt {
const is_normal_prompt = prompt.system_prompt === false && (prompt.marker === undefined || prompt.marker === false);
const is_system_prompt = prompt.system_prompt === true && (prompt.marker === undefined || prompt.marker === false);
const is_placeholder_prompt = prompt.marker === true;
let result = _({})
.set('id', prompt.identifier ?? uuidv4())
.set('name', prompt.name ?? 'unnamed')
.set(
'enabled',
prompt_order.find(order => order.identifier === prompt.identifier)?.enabled ?? prompt.enabled ?? true,
);
if (is_normal_prompt || is_placeholder_prompt) {
result = result.set('position.type', { 0: 'relative', 1: 'in_chat' }[prompt.injection_position ?? 0]);
if (prompt.injection_position === 1) {
result = result.set('position.depth', prompt.injection_depth ?? 4);
result = result.set('position.order', prompt.injection_order ?? 100);
}
}
result = result.set('role', prompt.role ?? 'system');
if (is_normal_prompt || is_system_prompt) {
result = result.set('content', prompt.content ?? '');
}
if (prompt.extra) {
result = result.set('extra', prompt.extra);
}
return result.value() as PresetPrompt;
}
function fromPresetPrompt(prompt: PresetPrompt): _OriginalPrompt {
const is_normal_prompt = isPresetNormalPrompt(prompt);
const is_system_prompt = isPresetSystemPrompt(prompt);
const is_placeholder_prompt = isPresetPlaceholderPrompt(prompt);
let result = _({}).set('identifier', prompt.id).set('name', prompt.name).set('enabled', prompt.enabled);
if ((is_normal_prompt || is_placeholder_prompt) && !['dialogueExamples', 'chatHistory'].includes(prompt.id)) {
result = result
.set('injection_position', (prompt.position?.type ?? 'relative') === 'relative' ? 0 : 1)
.set('injection_depth', prompt.position?.depth ?? 4)
.set('injection_order', prompt.position?.order ?? 100);
}
result = result.set('role', prompt.role);
if (is_normal_prompt || is_system_prompt) {
result = result.set('content', prompt.content);
}
result = result.set('system_prompt', is_system_prompt || is_placeholder_prompt).set('marker', is_placeholder_prompt);
if (prompt.extra) {
result = result.set('extra', prompt.extra);
}
result = result.set('forbid_overrides', false);
return result.value() as _OriginalPrompt;
}
function toPreset(preset: _OriginalPreset, { in_use }: { in_use: boolean }): Preset {
const prompt_order = preset.prompt_order.find(order => order.character_id === 100001)?.order ?? [];
const prompts_all = preset.prompts.map(prompt => toPresetPrompt(prompt, prompt_order));
const prompt_order_identifiers = prompt_order.map(order => order.identifier);
const [prompts_used, prompts_unused] = _.partition(prompts_all, prompt =>
prompt_order_identifiers.includes(prompt.id),
);
const prompts = prompt_order_identifiers.map(identifier => prompts_used.find(prompt => prompt.id === identifier)!);
const extensions = klona(preset.extensions);
_.set(extensions, 'regex_scripts', (extensions?.regex_scripts ?? []).map(to_tavern_regex));
return {
settings: {
max_context: Number(preset.openai_max_context),
max_completion_tokens: Number(preset.openai_max_tokens),
reply_count: Number(preset.n),
should_stream: Boolean(preset.stream_openai),
temperature: Number(in_use ? preset.temp_openai : preset.temperature),
frequency_penalty: Number(in_use ? preset.freq_pen_openai : preset.frequency_penalty),
presence_penalty: Number(in_use ? preset.pres_pen_openai : preset.presence_penalty),
top_p: Number(in_use ? preset.top_p_openai : preset.top_p),
repetition_penalty: Number(in_use ? preset.repetition_penalty_openai : preset.repetition_penalty),
min_p: Number(in_use ? preset.min_p_openai : preset.min_p),
top_k: Number(in_use ? preset.top_k_openai : preset.top_k),
top_a: Number(in_use ? preset.top_a_openai : preset.top_a),
seed: Number(preset.seed),
squash_system_messages: Boolean(preset.squash_system_messages),
reasoning_effort: String(preset.reasoning_effort) as 'auto' | 'min' | 'low' | 'medium' | 'high' | 'max',
request_thoughts: Boolean(preset.show_thoughts),
request_images: Boolean(preset.request_images),
enable_function_calling: Boolean(preset.function_calling),
enable_web_search: Boolean(preset.enable_web_search),
allow_sending_images:
Boolean(preset.image_inlining) === false
? 'disabled'
: (String(preset.inline_image_quality) as 'auto' | 'low' | 'high'),
allow_sending_videos: Boolean(preset.video_inlining),
character_name_prefix: (
{
[-1]: 'none',
[0]: 'default',
[2]: 'content',
[1]: 'completion',
} as const
)[Number(preset.names_behavior) as -1 | 0 | 2 | 1],
wrap_user_messages_in_quotes: Boolean(preset.wrap_in_quotes),
},
prompts,
prompts_unused,
// @ts-expect-error 类型是正确的, extensions 里必然有 tavern_helper
extensions,
};
}
function fromPreset(preset: Preset): _OriginalPreset {
const id_set = new Set<string>();
const handle_id_collision = (id: string, is_normal_prompt: boolean): string => {
if (!id_set.has(id)) {
id_set.add(id);
return id;
}
if (!is_normal_prompt) {
throw Error(`修改的预设中存在重复的系统/占位提示词 '${id}'`);
}
const new_id = uuidv4();
id_set.add(new_id);
return new_id;
};
const make_uncollision_prompts = (prompts: PresetPrompt[]) => {
return prompts.map(prompt => {
const new_id = handle_id_collision(prompt.id, isPresetNormalPrompt(prompt));
return {
...prompt,
id: new_id,
};
});
};
preset.prompts = make_uncollision_prompts(preset.prompts);
preset.prompts_unused = make_uncollision_prompts(preset.prompts_unused);
const prompt_used = preset.prompts.map(prompt => fromPresetPrompt(prompt));
const prompt_unused = preset.prompts_unused.map(prompt => fromPresetPrompt(prompt));
const extensions = klona(preset.extensions);
if (_.has(extensions, 'regex_scripts[0].source')) {
// @ts-expect-error 类型是正确的, 就是需要转换回酒馆格式
extensions.regex_scripts = extensions.regex_scripts.map(from_tavern_regex);
}
return {
max_context_unlocked: true,
openai_max_context: preset.settings.max_context,
openai_max_tokens: preset.settings.max_completion_tokens,
n: preset.settings.reply_count,
stream_openai: preset.settings.should_stream,
// vv in use vv
temp_openai: preset.settings.temperature,
freq_pen_openai: preset.settings.frequency_penalty,
pres_pen_openai: preset.settings.presence_penalty,
top_p_openai: preset.settings.top_p,
repetition_penalty_openai: preset.settings.repetition_penalty,
min_p_openai: preset.settings.min_p,
top_k_openai: preset.settings.top_k,
top_a_openai: preset.settings.top_a,
// vv in file vv
temperature: preset.settings.temperature,
frequency_penalty: preset.settings.frequency_penalty,
presence_penalty: preset.settings.presence_penalty,
top_p: preset.settings.top_p,
repetition_penalty: preset.settings.repetition_penalty,
min_p: preset.settings.min_p,
top_k: preset.settings.top_k,
top_a: preset.settings.top_a,
seed: preset.settings.seed,
squash_system_messages: preset.settings.squash_system_messages,
reasoning_effort: preset.settings.reasoning_effort,
show_thoughts: preset.settings.request_thoughts,
request_images: preset.settings.request_images,
function_calling: preset.settings.enable_function_calling,
enable_web_search: preset.settings.enable_web_search,
image_inlining: preset.settings.allow_sending_images !== 'disabled',
inline_image_quality:
preset.settings.allow_sending_images === 'disabled' ? 'auto' : preset.settings.allow_sending_images,
video_inlining: preset.settings.allow_sending_videos,
names_behavior: (
{
none: -1,
default: 0,
content: 2,
completion: 1,
} as const
)[preset.settings.character_name_prefix],
wrap_in_quotes: preset.settings.wrap_user_messages_in_quotes,
prompts: [...prompt_used, ...prompt_unused],
prompt_order: [
{
character_id: 100001,
order: prompt_used.map(prompt => ({ identifier: prompt.identifier, enabled: prompt.enabled ?? true })),
},
],
extensions,
};
}
const preset_manager = getPresetManager('openai');
export function getPresetNames(): string[] {
return klona(['in_use', ...preset_manager.getAllPresets()]);
}
export function getLoadedPresetName(): string {
return preset_manager.getSelectedPresetName();
}
export function loadPreset(preset_name: Exclude<string, 'in_use'>): boolean {
const preset_value = preset_manager.findPreset(preset_name);
if (!preset_value) {
return false;
}
preset_manager.selectPreset(preset_value);
return true;
}
export async function createPreset(
preset_name: Exclude<string, 'in_use'>,
preset: Preset = default_preset,
): Promise<boolean> {
if (getPresetNames().includes(preset_name)) {
return false;
}
await createOrReplacePreset(preset_name, preset);
return true;
}
function updateOriginalPresetData(
data: Record<string, any>,
updates: _OriginalPreset,
{ in_use, render }: { in_use: boolean; render?: 'immediate' | 'debounced' | 'none' },
): void {
let lodash_data = _(data);
Object.entries(settingsToUpdate).forEach(([key, { oai_setting }]) => {
lodash_data = lodash_data.set(
in_use ? oai_setting : _.get(in_use_map, oai_setting, oai_setting),
updates[key as keyof _OriginalPreset],
);
});
lodash_data.value();
if (!in_use) {
return;
}
saveSettingsDebounced();
if (render === 'none') {
return;
}
const checkboxes = $();
const inputs = $();
Object.entries(settingsToUpdate).forEach(([key, { selector, type }]) => {
switch (type) {
case 'checkbox':
$(selector).prop('checked', updates[key as keyof _OriginalPreset]);
checkboxes.add(selector);
break;
case 'input':
$(selector).val(updates[key as keyof _OriginalPreset] as number | string);
inputs.add(selector);
break;
}
});
$(checkboxes).trigger('input', { source: 'preset' });
$(inputs).trigger('input', { source: 'preset' });
if (render === 'debounced') {
promptManager.renderDebounced();
} else {
promptManager.render(false);
}
}
type ReplacePresetOptions = {
render?: 'debounced' | 'immediate' | 'none';
};
export async function createOrReplacePreset(
preset_name: LiteralUnion<'in_use', string>,
preset: Preset = default_preset,
{ render = 'debounced' }: ReplacePresetOptions = {},
): Promise<boolean> {
const original_preset = fromPreset(preset);
const is_existing = getPresetNames().includes(preset_name);
if (!is_existing) {
const { presets, preset_names } = preset_manager.getPresetList();
presets.push(original_preset);
(preset_names as Record<string, number>)[preset_name] = presets.length - 1;
preset_manager.select.append(
$('<option></option>', { value: presets.length - 1, text: preset_name, selected: false }),
);
} else {
updateOriginalPresetData(
preset_name === 'in_use' ? oai_settings : getCompletionPresetByName(preset_name),
original_preset,
{
in_use: preset_name === 'in_use',
render,
},
);
}
if (preset_name !== 'in_use') {
await preset_manager.savePreset(preset_name, getCompletionPresetByName(preset_name), {
skipUpdate: true,
});
}
return !is_existing;
}
export async function deletePreset(preset_name: Exclude<string, 'in_use'>): Promise<boolean> {
return Boolean(await preset_manager.deletePreset(preset_name));
}
export async function renamePreset(preset_name: Exclude<string, 'in_use'>, new_name: string): Promise<boolean> {
if (!getPresetNames().includes(preset_name)) {
return false;
}
await createPreset(new_name, getPreset(preset_name)!);
await deletePreset(preset_name);
return true;
}
export function getPreset(preset_name: LiteralUnion<'in_use', string>): Preset {
const original_preset = preset_name === 'in_use' ? oai_settings : getCompletionPresetByName(preset_name);
if (!original_preset) {
throw Error(`预设 '${preset_name}' 不存在`);
}
return klona(toPreset(original_preset, { in_use: preset_name === 'in_use' }));
}
export async function replacePreset(
preset_name: LiteralUnion<'in_use', string>,
preset: Preset,
options: ReplacePresetOptions = {},
): Promise<void> {
if (!getPresetNames().includes(preset_name)) {
throw Error(`预设 '${preset_name}' 不存在`);
}
await createOrReplacePreset(preset_name, preset, options);
}
type PresetUpdater = ((preset: Preset) => Preset) | ((preset: Preset) => Promise<Preset>);
export async function updatePresetWith(
preset_name: LiteralUnion<'in_use', string>,
updater: PresetUpdater,
options: ReplacePresetOptions = {},
): Promise<Preset> {
if (!getPresetNames().includes(preset_name)) {
throw Error(`预设 '${preset_name}' 不存在`);
}
const preset = await updater(getPreset(preset_name)!);
await replacePreset(preset_name, preset, options);
return preset;
}
export async function setPreset(
preset_name: LiteralUnion<'in_use', string>,
preset: PartialDeep<Preset>,
options: ReplacePresetOptions = {},
): Promise<Preset> {
return await updatePresetWith(
preset_name,
old_preset => {
return {
settings: _.defaultsDeep(preset.settings, old_preset.settings),
prompts: preset.prompts ?? old_preset.prompts,
prompts_unused: preset.prompts_unused ?? old_preset.prompts_unused,
extensions: _.defaultsDeep(preset.extensions, old_preset.extensions),
};
},
options,
);
}

View File

@@ -0,0 +1,196 @@
// TODO: 重新设计这里的接口, set 部分直接访问后端
import { characters, getPastCharacterChats, getRequestHeaders, getThumbnailUrl, this_chid } from '@sillytavern/script';
import { v1CharData } from '@sillytavern/scripts/char-data';
import { LiteralUnion } from 'type-fest';
export class RawCharacter {
private character_data: v1CharData;
constructor(character_data: v1CharData) {
this.character_data = character_data;
}
static find({ name }: { name: LiteralUnion<'current', string> }): v1CharData | null {
const index = this.findIndex(name);
if (index !== -1) {
return characters[index];
}
return null;
}
static findIndex(name: LiteralUnion<'current', string>): number {
if (name === 'current') {
return this_chid === undefined ? -1 : Number(this_chid);
}
const lowered_name = name.toLowerCase();
return characters.findIndex(
character => character.name.toLowerCase() === lowered_name || character.avatar.toLowerCase() === lowered_name,
);
}
static async getChatsFromFiles(data: any[], isGroupChat: boolean): Promise<Record<string, any>> {
const chat_dict: Record<string, any> = {};
const chat_list = Object.values(data)
.sort((a, b) => a['file_name'].localeCompare(b['file_name']))
.reverse();
const chat_promise = chat_list.map(async ({ file_name }) => {
// 从文件名中提取角色名称(破折号前的部分)
const ch_name = isGroupChat ? '' : file_name.split(' - ')[0];
// 使用Character.find方法查找角色获取头像
let characterData = null;
let avatar_url = '';
if (!isGroupChat && ch_name) {
characterData = RawCharacter.find({ name: ch_name });
if (characterData) {
avatar_url = characterData.avatar;
}
}
const endpoint = isGroupChat ? '/api/chats/group/get' : '/api/chats/get';
const requestBody = isGroupChat
? JSON.stringify({ id: file_name })
: JSON.stringify({
ch_name: ch_name,
file_name: file_name.replace('.jsonl', ''),
avatar_url: avatar_url,
});
const chatResponse = await fetch(endpoint, {
method: 'POST',
headers: getRequestHeaders(),
body: requestBody,
cache: 'no-cache',
});
if (!chatResponse.ok) {
return;
}
const currentChat = await chatResponse.json();
if (!isGroupChat) {
// remove the first message, which is metadata, only for individual chats
currentChat.shift();
}
chat_dict[file_name] = currentChat;
});
await Promise.all(chat_promise);
return chat_dict;
}
getCardData(): v1CharData {
return this.character_data;
}
getAvatarId(): string {
return this.character_data.avatar || '';
}
getRegexScripts(): Array<{
id: string;
scriptName: string;
findRegex: string;
replaceString: string;
trimStrings: string[];
placement: number[];
disabled: boolean;
markdownOnly: boolean;
promptOnly: boolean;
runOnEdit: boolean;
substituteRegex: number | boolean;
minDepth: number;
maxDepth: number;
}> {
return this.character_data.data?.extensions?.regex_scripts || [];
}
getCharacterBook(): {
name: string;
entries: Array<{
keys: string[];
secondary_keys?: string[];
comment: string;
content: string;
constant: boolean;
selective: boolean;
insertion_order: number;
enabled: boolean;
position: string;
extensions: any;
id: number;
}>;
} | null {
return this.character_data.data?.character_book || null;
}
getWorldName(): string {
return this.character_data.data?.extensions?.world || '';
}
}
export function getCharData(name: LiteralUnion<'current', string>): v1CharData | null {
try {
// backward compatibility
name = !name ? 'current' : name;
const characterData = RawCharacter.find({ name });
if (!characterData) return null;
const character = new RawCharacter(characterData);
return character.getCardData();
} catch (err) {
const error = err as Error;
throw Error(`获取${name ? ` '${name}' ` : '未知'}角色卡数据失败: ${error.message}`);
}
}
export function getCharAvatarPath(name: LiteralUnion<'current', string>): string | null {
// backward compatibility
name = !name ? 'current' : name;
const characterData = RawCharacter.find({ name });
if (!characterData) {
return null;
}
const character = new RawCharacter(characterData);
const avatarId = character.getAvatarId();
// 使用 getThumbnailUrl 获取缩略图URL然后提取实际文件名
const thumbnailPath = getThumbnailUrl('avatar', avatarId);
const targetAvatarImg = thumbnailPath.substring(thumbnailPath.lastIndexOf('=') + 1);
return '/characters/' + targetAvatarImg;
}
export async function getChatHistoryBrief(name: LiteralUnion<'current', string>): Promise<any[] | null> {
// backward compatibility
name = !name ? 'current' : name;
const character_data = RawCharacter.find({ name });
if (!character_data) {
return null;
}
const character = new RawCharacter(character_data);
const index = RawCharacter.findIndex(character.getAvatarId());
if (index === -1) {
return null;
}
const chats = await getPastCharacterChats(index);
return chats;
}
export async function getChatHistoryDetail(
data: any[],
isGroupChat: boolean = false,
): Promise<Record<string, any> | null> {
const result = await RawCharacter.getChatsFromFiles(data, isGroupChat);
return result;
}

View File

@@ -0,0 +1,69 @@
import { _getScriptId } from '@/function/util';
import { useScriptIframeRuntimesStore } from '@/store/iframe_runtimes';
import { getButtonId } from '@/store/iframe_runtimes/script';
type ScriptButton = {
name: string;
visible: boolean;
};
export function _getButtonEvent(this: Window, button_name: string): string {
return getButtonId(String(_getScriptId.call(this)), button_name);
}
export function _getScriptButtons(this: Window): ScriptButton[] {
const script = useScriptIframeRuntimesStore().get(_getScriptId.call(this));
// TODO: 对于预设脚本、角色脚本, $(window).on('pagehide') 时已经切换了角色卡, get 会失败
if (!script) {
return [];
}
return klona(script.button.buttons);
}
export function getAllEnabledScriptButtons(): { [script_id: string]: { button_id: string; button_name: string }[] } {
return klona(useScriptIframeRuntimesStore().button_map);
}
export function _replaceScriptButtons(this: Window, script_id: string, buttons: ScriptButton[]): void;
export function _replaceScriptButtons(this: Window, buttons: ScriptButton[]): void;
export function _replaceScriptButtons(this: Window, param1: string | ScriptButton[], param2?: ScriptButton[]): void {
const script = useScriptIframeRuntimesStore().get(_getScriptId.call(this))!;
// TODO: 对于预设脚本、角色脚本, $(window).on('pagehide') 时已经切换了角色卡, get 会失败
if (!script) {
return;
}
script.button.buttons = typeof param1 === 'string' ? param2! : param1;
}
export function _appendInexistentScriptButtons(this: Window, script_id: string, buttons: ScriptButton[]): void;
export function _appendInexistentScriptButtons(this: Window, buttons: ScriptButton[]): void;
export function _appendInexistentScriptButtons(
this: Window,
param1: string | ScriptButton[],
param2?: ScriptButton[],
): void {
const buttons = typeof param1 === 'string' ? param2! : param1;
const script_buttons = _getScriptButtons.call(this);
const inexistent_buttons = buttons.filter(button => !script_buttons.some(b => b.name === button.name));
if (inexistent_buttons.length === 0) {
return;
}
_replaceScriptButtons.call(this, [...script_buttons, ...inexistent_buttons]);
}
export function _getScriptInfo(this: Window): string {
// TODO: 对于预设脚本、角色脚本, $(window).on('pagehide') 时已经切换了角色卡, get 会失败
const script = useScriptIframeRuntimesStore().get(_getScriptId.call(this));
if (!script) {
return '';
}
return script.info;
}
export function _replaceScriptInfo(this: Window, info: string): void {
const script = useScriptIframeRuntimesStore().get(_getScriptId.call(this))!;
if (!script) {
return;
}
script.info = info;
}

View File

@@ -0,0 +1,9 @@
import { executeSlashCommandsWithOptions } from '@sillytavern/scripts/slash-commands';
export async function triggerSlash(command: string): Promise<string> {
const result = await executeSlashCommandsWithOptions(command);
if (result.isError) {
throw Error(`运行 Slash 命令 '${command}' 时出错: ${result.errorMessage}`);
}
return result.pipe;
}

View File

@@ -0,0 +1,282 @@
// TODO: 重制酒馆正则函数
import { refreshOneMessage } from '@/function/displayed_message';
import { macros } from '@/function/macro_like';
import { RawCharacter } from '@/function/raw_character';
import {
characters,
chat,
event_types,
eventSource,
getCurrentChatId,
saveSettings,
substituteParams,
this_chid,
} from '@sillytavern/script';
import { RegexScriptData } from '@sillytavern/scripts/char-data';
import { extension_settings, writeExtensionField } from '@sillytavern/scripts/extensions';
import { getRegexedString, regex_placement } from '@sillytavern/scripts/extensions/regex/engine';
type FormatAsTavernRegexedStringOption = {
depth?: number;
character_name?: string;
};
export function formatAsTavernRegexedString(
text: string,
source: 'user_input' | 'ai_output' | 'slash_command' | 'world_info' | 'reasoning',
destination: 'display' | 'prompt',
{ depth, character_name }: FormatAsTavernRegexedStringOption = {},
) {
let result = getRegexedString(
text,
(
{
user_input: regex_placement.USER_INPUT,
ai_output: regex_placement.AI_OUTPUT,
slash_command: regex_placement.SLASH_COMMAND,
world_info: regex_placement.WORLD_INFO,
reasoning: regex_placement.REASONING,
} as const
)[source],
{
characterOverride: character_name,
isMarkdown: destination === 'display',
isPrompt: destination === 'prompt',
depth,
},
);
result = substituteParams(result, undefined, character_name, undefined, undefined);
macros.forEach(macro => {
result = result.replace(macro.regex, (substring, ...args) =>
macro.replace(
{
role: (
{
user_input: 'user',
ai_output: 'assistant',
slash_command: 'system',
world_info: 'system',
reasoning: 'system',
} as const
)[source],
message_id: depth !== undefined ? chat.length - depth - 1 : undefined,
},
substring,
...args,
),
);
});
return result;
}
export type TavernRegex = {
id: string;
script_name: string;
enabled: boolean;
find_regex: string;
replace_string: string;
source: {
user_input: boolean;
ai_output: boolean;
slash_command: boolean;
world_info: boolean;
};
destination: {
display: boolean;
prompt: boolean;
};
run_on_edit: boolean;
min_depth: number | null;
max_depth: number | null;
};
type TavernRegexOptionGlobal = {
type: 'global';
};
type TavernRegexOptionCharacter = {
type: 'character';
name?: string | 'current';
};
type TavernRegexOptionPreset = {
type: 'preset';
name?: string | 'current';
};
type TavernRegexOption = TavernRegexOptionGlobal | TavernRegexOptionCharacter | TavernRegexOptionPreset;
export function get_tavern_regexes_without_clone(option: TavernRegexOption): RegexScriptData[] {
switch (option.type) {
case 'global':
return extension_settings.regex ?? [];
case 'character': {
const id = RawCharacter.findIndex(option.name ?? 'current');
return characters.at(id)?.data?.extensions?.regex_scripts ?? [];
}
case 'preset':
throw Error('暂不支持获取预设的酒馆正则');
}
}
export function to_tavern_regex(regex_script_data: RegexScriptData): TavernRegex {
return {
id: regex_script_data.id,
script_name: regex_script_data.scriptName,
enabled: !regex_script_data.disabled,
find_regex: regex_script_data.findRegex,
replace_string: regex_script_data.replaceString,
source: {
user_input: regex_script_data.placement.includes(regex_placement.USER_INPUT),
ai_output: regex_script_data.placement.includes(regex_placement.AI_OUTPUT),
slash_command: regex_script_data.placement.includes(regex_placement.SLASH_COMMAND),
world_info: regex_script_data.placement.includes(regex_placement.WORLD_INFO),
},
destination: {
display: regex_script_data.markdownOnly,
prompt: regex_script_data.promptOnly,
},
run_on_edit: regex_script_data.runOnEdit,
min_depth: typeof regex_script_data.minDepth === 'number' ? regex_script_data.minDepth : null,
max_depth: typeof regex_script_data.maxDepth === 'number' ? regex_script_data.maxDepth : null,
};
}
export function from_tavern_regex(tavern_regex: TavernRegex): RegexScriptData {
return {
id: tavern_regex.id,
scriptName: tavern_regex.script_name,
disabled: !tavern_regex.enabled,
runOnEdit: tavern_regex.run_on_edit,
findRegex: tavern_regex.find_regex,
replaceString: tavern_regex.replace_string,
trimStrings: [], // TODO: handle this?
placement: [
...(tavern_regex.source.user_input ? [regex_placement.USER_INPUT] : []),
...(tavern_regex.source.ai_output ? [regex_placement.AI_OUTPUT] : []),
...(tavern_regex.source.slash_command ? [regex_placement.SLASH_COMMAND] : []),
...(tavern_regex.source.world_info ? [regex_placement.WORLD_INFO] : []),
],
substituteRegex: 0, // TODO: handle this?
// @ts-expect-error 类型是正确的
minDepth: tavern_regex.min_depth,
// @ts-expect-error 类型是正确的
maxDepth: tavern_regex.max_depth,
markdownOnly: tavern_regex.destination.display,
promptOnly: tavern_regex.destination.prompt,
};
}
export function isCharacterTavernRegexesEnabled(): boolean {
return (extension_settings?.character_allowed_regex as string[])?.includes(
characters.at(Number(this_chid))?.avatar ?? '',
);
}
type GetTavernRegexesOption = {
scope?: 'all' | 'global' | 'character'; // 按所在区域筛选正则
enable_state?: 'all' | 'enabled' | 'disabled'; // 按是否被开启筛选正则
};
export function getTavernRegexes({ scope = 'all', enable_state = 'all' }: GetTavernRegexesOption = {}): TavernRegex[] {
if (!['all', 'enabled', 'disabled'].includes(enable_state)) {
throw Error(`提供的 enable_state 无效, 请提供 'all', 'enabled' 或 'disabled', 你提供的是: ${enable_state}`);
}
if (!['all', 'global', 'character'].includes(scope)) {
throw Error(`提供的 scope 无效, 请提供 'all', 'global' 或 'character', 你提供的是: ${scope}`);
}
let regexes: TavernRegex[] = [];
if (scope === 'all' || scope === 'global') {
regexes = [
...regexes,
...get_tavern_regexes_without_clone({ type: 'global' })
.map(to_tavern_regex)
.map(regex => ({ ...regex, scope: 'global' })),
];
}
if (scope === 'all' || scope === 'character') {
regexes = [
...regexes,
...get_tavern_regexes_without_clone({ type: 'character' })
.map(to_tavern_regex)
.map(regex => ({ ...regex, scope: 'character' })),
];
}
if (enable_state !== 'all') {
regexes = regexes.filter(regex => regex.enabled === (enable_state === 'enabled'));
}
return klona(regexes);
}
type ReplaceTavernRegexesOption = {
scope?: 'all' | 'global' | 'character'; // 要替换的酒馆正则部分
};
export async function render_tavern_regexes() {
await saveSettings();
await Promise.all(
$('#chat > .mes').map((_index, element) => {
return refreshOneMessage(Number($(element).attr('mesid')));
}),
);
await eventSource.emit(event_types.CHAT_CHANGED, getCurrentChatId());
}
export const render_tavern_regexes_debounced = _.debounce(render_tavern_regexes, 1000);
export async function replaceTavernRegexes(
regexes: TavernRegex[],
{ scope = 'all' }: ReplaceTavernRegexesOption,
): Promise<void> {
if (!['all', 'global', 'character'].includes(scope)) {
throw Error(`提供的 scope 无效, 请提供 'all', 'global' 或 'character', 你提供的是: ${scope}`);
}
// TODO: `trimStrings` and `substituteRegex` are not considered
regexes
.filter(regex => regex.script_name == '')
.forEach(regex => {
regex.script_name = `未命名-${regex.id}`;
});
// @ts-expect-error 确实有 `scope` 字段, 之后想办法弃用整个函数
const [global_regexes, character_regexes] = _.partition(regexes, regex => regex.scope === 'global').map(paritioned =>
paritioned.map(from_tavern_regex),
);
const character = characters.at(this_chid as unknown as number);
if (scope === 'all' || scope === 'global') {
extension_settings.regex = global_regexes;
}
if (scope === 'all' || scope === 'character') {
if (character) {
character.data.extensions.regex_scripts = character_regexes;
await writeExtensionField(this_chid as unknown as string, 'regex_scripts', character_regexes);
}
}
render_tavern_regexes_debounced();
}
type TavernRegexUpdater =
| ((regexes: TavernRegex[]) => TavernRegex[])
| ((regexes: TavernRegex[]) => Promise<TavernRegex[]>);
export async function updateTavernRegexesWith(
updater: TavernRegexUpdater,
{ scope = 'all' }: ReplaceTavernRegexesOption = {},
): Promise<TavernRegex[]> {
let regexes = getTavernRegexes({ scope });
regexes = await updater(regexes);
await replaceTavernRegexes(regexes, { scope });
return regexes;
}

View File

@@ -0,0 +1,115 @@
import { substituteParamsExtended } from '@sillytavern/script';
import isPromise from 'is-promise';
import { ZodError } from 'zod';
export function _reloadIframe(this: Window): void {
this.location.reload();
}
export function substitudeMacros(text: string): string {
return substituteParamsExtended(text);
}
export function getLastMessageId(): number {
return Number(substitudeMacros('{{lastMessageId}}'));
}
export function errorCatched<T extends any[], U>(fn: (...args: T) => U): (...args: T) => U {
const onError = (error: Error) => {
toastr.error(
`<pre style="white-space: pre-wrap">${error.stack ? (error instanceof ZodError ? [error.message, error.stack].join('\n') : error.stack) : error.message}</pre>`,
error.name,
{
escapeHtml: false,
toastClass: 'toastr w-fit! min-w-[300px]',
},
);
throw error;
};
return (...args: T): U => {
try {
const result = fn(...args);
if (isPromise(result)) {
return result.then(undefined, error => {
onError(error);
}) as U;
}
return result;
} catch (error) {
return onError(error as Error);
}
};
}
export function _errorCatched<T extends any[], U>(this: Window, fn: (...args: T) => U): (...args: T) => U {
const onError = (error: Error) => {
const iframe_name = _getIframeName.call(this);
const message = error.stack
? error instanceof ZodError
? [error.message, error.stack].join('\n')
: error.stack
: error.message;
toastr.error(`<pre style="white-space: pre-wrap">${message}</pre>`, `[${iframe_name}] ${error.name}`, {
escapeHtml: false,
toastClass: 'toastr w-fit! min-w-[300px]',
});
// @ts-expect-error _th_impl 是存在的
this._th_impl._log(iframe_name, 'error', message);
throw error;
};
return (...args: T): U => {
try {
const result = fn(...args);
if (isPromise(result)) {
return result.then(undefined, error => {
onError(error);
}) as U;
}
return result;
} catch (error) {
return onError(error as Error);
}
};
}
export function _getIframeName(this: Window): string {
const frameElement = this.frameElement as Element | null;
const cachedId = (this as typeof window & { __TH_IFRAME_ID?: string }).__TH_IFRAME_ID || this.name;
if (frameElement?.id) {
// Persist id so we can still resolve it after the iframe is removed (Firefox srcdoc teardown).
(this as typeof window & { __TH_IFRAME_ID?: string }).__TH_IFRAME_ID = frameElement.id;
if (!this.name) {
this.name = frameElement.id;
}
return frameElement.id;
}
if (cachedId) {
if (!this.name) {
this.name = cachedId;
}
return cachedId;
}
throw new TypeError('frameElement is null while resolving iframe id');
}
export function _getScriptId(this: Window): string {
const iframe_name = _getIframeName.call(this);
if (!iframe_name.startsWith('TH-script--')) {
throw new Error('你只能在脚本 iframe 内获取 getScriptId!');
}
return iframe_name.replace(/TH-script--.+--/, '');
}
export function _getCurrentMessageId(this: Window): number {
return getMessageId(_getIframeName.call(this));
}
export function getMessageId(iframe_name: string): number {
const match = iframe_name.match(/^TH-message--(\d+)--\d+(_\d+)?$/);
if (!match) {
throw Error(`获取 ${iframe_name} 所在楼层 id 时出错: 不要对全局脚本 iframe 调用 getMessageId!`);
}
return parseInt(match[1].toString());
}

View File

@@ -0,0 +1,302 @@
import { _getCurrentMessageId, _getIframeName, _getScriptId } from '@/function/util';
import { useScriptIframeRuntimesStore } from '@/store/iframe_runtimes';
import { useCharacterSettingsStore, usePresetSettingsStore } from '@/store/settings';
import { useVariableSchemasStore } from '@/store/variable_schemas';
import { saveChatConditionalDebounced } from '@/util/tavern';
import { chat, chat_metadata, saveSettingsDebounced } from '@sillytavern/script';
import { extension_settings, saveMetadataDebounced } from '@sillytavern/scripts/extensions';
import isPromise from 'is-promise';
export function registerVariableSchema(
schema: z.ZodType<any>,
{ type }: { type: 'global' | 'preset' | 'character' | 'chat' | 'message' },
) {
const store = useVariableSchemasStore();
switch (type) {
case 'global': {
store.global = schema;
break;
}
case 'preset': {
store.preset = schema;
break;
}
case 'character': {
store.character = schema;
break;
}
case 'chat': {
store.chat = schema;
break;
}
case 'message': {
store.message = schema;
break;
}
}
}
type VariableOptionNormal = {
type: 'chat' | 'character' | 'preset' | 'global';
};
type VariableOptionMessage = {
type: 'message';
message_id?: number | 'latest';
};
type VariableOptionScript = {
type: 'script';
script_id?: string;
};
type VariableOptionExtension = {
type: 'extension';
extension_id: string;
};
type VariableOption = VariableOptionNormal | VariableOptionMessage | VariableOptionScript | VariableOptionExtension;
export function get_variables_without_clone(option: VariableOption): Record<string, any> {
switch (option.type) {
case 'message': {
const normalized_message_id =
option.message_id === undefined || option.message_id === 'latest' ? -1 : option.message_id;
if (!_.inRange(normalized_message_id, -chat.length, chat.length)) {
throw Error(`提供的消息楼层号 '${option.message_id}' 超出了范围 [${-chat.length}, ${chat.length})`);
}
let chat_message;
if (option.message_id === undefined || option.message_id === 'latest') {
chat_message = chat.filter(chat_message => !chat_message.is_system).at(normalized_message_id);
} else {
chat_message = chat.at(normalized_message_id);
}
return chat_message?.variables?.[chat_message?.swipe_id ?? 0] ?? {};
}
case 'chat': {
return _.get(chat_metadata, 'variables', {});
}
case 'character': {
return useCharacterSettingsStore().settings.variables;
}
case 'preset': {
return usePresetSettingsStore().settings.variables;
}
case 'global': {
return extension_settings.variables.global;
}
case 'script': {
if (!option.script_id) {
throw Error('获取变量失败, 未指定 script_id');
}
return useScriptIframeRuntimesStore().get(option.script_id)?.data ?? {};
}
case 'extension': {
return _.get(extension_settings, option.extension_id, {});
}
}
}
export function getVariables(option: VariableOption = { type: 'chat' }): Record<string, any> {
return klona(get_variables_without_clone(option));
}
export function _getVariables(this: Window, option: VariableOption = { type: 'chat' }): Record<string, any> {
return option.type === 'script'
? getVariables({ type: 'script', script_id: _getScriptId.call(this) })
: getVariables(option);
}
export function _getAllVariables(this: Window): Record<string, any> {
const is_message_iframe = _getIframeName.call(this).startsWith('TH-message');
let result = _({});
result = result.assign(
get_variables_without_clone({ type: 'global' }),
get_variables_without_clone({ type: 'character' }),
);
if (!is_message_iframe) {
result = result.assign(get_variables_without_clone({ type: 'script', script_id: _getScriptId.call(this) }));
}
result = result.assign(get_variables_without_clone({ type: 'chat' }));
if (is_message_iframe) {
result = result.assign(
...chat
.slice(0, _getCurrentMessageId.call(this) + 1)
.map((chat_message: any) => chat_message?.variables?.[chat_message?.swipe_id ?? 0]),
);
}
return klona(result.value());
}
export function replaceVariables(variables: Record<string, any>, option: VariableOption = { type: 'chat' }): void {
switch (option.type) {
case 'message': {
option.message_id = option.message_id === undefined || option.message_id === 'latest' ? -1 : option.message_id;
if (!_.inRange(option.message_id, -chat.length, chat.length)) {
throw Error(`提供的消息楼层号 '${option.message_id}' 超出了范围 (${-chat.length}, ${chat.length})`);
}
const chat_message = chat.at(option.message_id) as Record<string, any>;
if (!_.has(chat_message, 'variables')) {
_.set(chat_message, 'variables', _.times(chat_message.swipes?.length ?? 1, _.constant({})));
}
// 与提示词模板的兼容性
if (_.isPlainObject(_.get(chat_message, 'variables'))) {
_.set(
chat_message,
'variables',
_.range(0, chat_message.swipes?.length ?? 1).map(i => chat_message.variables[i] ?? {}),
);
}
_.set(chat_message, ['variables', _.get(chat_message, 'swipe_id', 0)], variables);
saveChatConditionalDebounced();
break;
}
case 'chat': {
_.set(chat_metadata, 'variables', variables);
saveMetadataDebounced();
break;
}
case 'character': {
const store = useCharacterSettingsStore();
if (store.name === undefined) {
throw new Error('当前没有打开角色卡,保存角色卡变量失败');
}
toRef(store.settings, 'variables').value = variables;
break;
}
case 'preset': {
const store = usePresetSettingsStore();
toRef(store.settings, 'variables').value = variables;
break;
}
case 'global': {
_.set(extension_settings.variables, 'global', variables);
saveSettingsDebounced();
break;
}
case 'script': {
if (!option.script_id) {
throw Error('保存变量失败, 未指定 script_id');
}
const script = useScriptIframeRuntimesStore().get(option.script_id);
if (!script) {
return;
}
script.data = variables;
break;
}
case 'extension': {
_.set(extension_settings, option.extension_id, variables);
saveSettingsDebounced();
break;
}
}
}
export function _replaceVariables(
this: Window,
variables: Record<string, any>,
option: VariableOption = { type: 'chat' },
): void {
return option.type === 'script'
? replaceVariables(variables, { type: 'script', script_id: _getScriptId.call(this) })
: replaceVariables(variables, option);
}
export function updateVariablesWith(
updater: (variables: Record<string, any>) => Record<string, any>,
option: VariableOption,
): Record<string, any>;
export function updateVariablesWith(
updater: (variables: Record<string, any>) => Promise<Record<string, any>>,
option: VariableOption,
): Promise<Record<string, any>>;
export function updateVariablesWith(
updater:
| ((variables: Record<string, any>) => Record<string, any>)
| ((variables: Record<string, any>) => Promise<Record<string, any>>),
option: VariableOption = { type: 'chat' },
): Record<string, any> | Promise<Record<string, any>> {
const variables = getVariables(option);
let result = updater(variables);
if (isPromise(result)) {
result = result.then((result: Record<string, any>) => {
replaceVariables(result, option);
return result;
});
} else {
replaceVariables(result, option);
}
return result;
}
export function _updateVariablesWith(
this: Window,
updater:
| ((variables: Record<string, any>) => Record<string, any>)
| ((variables: Record<string, any>) => Promise<Record<string, any>>),
option: VariableOption = { type: 'chat' },
): Record<string, any> | Promise<Record<string, any>> {
return option.type === 'script'
? updateVariablesWith(updater, { type: 'script', script_id: _getScriptId.call(this) })
: updateVariablesWith(updater, option);
}
export function insertOrAssignVariables(
variables: Record<string, any>,
option: VariableOption = { type: 'chat' },
): Record<string, any> {
return updateVariablesWith(
old_variables => _.mergeWith(old_variables, variables, (_lhs, rhs) => (_.isArray(rhs) ? rhs : undefined)),
option,
);
}
export function _insertOrAssignVariables(
this: Window,
variables: Record<string, any>,
option: VariableOption = { type: 'chat' },
): Record<string, any> {
return option.type === 'script'
? insertOrAssignVariables(variables, { type: 'script', script_id: _getScriptId.call(this) })
: insertOrAssignVariables(variables, option);
}
export function insertVariables(
variables: Record<string, any>,
option: VariableOption = { type: 'chat' },
): Record<string, any> {
return updateVariablesWith(
old_variables => _.mergeWith({}, variables, old_variables, (_lhs, rhs) => (_.isArray(rhs) ? rhs : undefined)),
option,
);
}
export function _insertVariables(
this: Window,
variables: Record<string, any>,
option: VariableOption = { type: 'chat' },
): Record<string, any> {
return option.type === 'script'
? insertVariables(variables, { type: 'script', script_id: _getScriptId.call(this) })
: insertVariables(variables, option);
}
export function deleteVariable(
variable_path: string,
option: VariableOption = { type: 'chat' },
): { variables: Record<string, any>; delete_occurred: boolean } {
let delete_occurred: boolean = false;
const variables = updateVariablesWith(old_variables => {
delete_occurred = _.unset(old_variables, variable_path);
return old_variables;
}, option);
return { variables, delete_occurred };
}
export function _deleteVariable(
this: Window,
variable_path: string,
option: VariableOption = { type: 'chat' },
): { variables: Record<string, any>; delete_occurred: boolean } {
return option.type === 'script'
? deleteVariable(variable_path, { type: 'script', script_id: _getScriptId.call(this) })
: deleteVariable(variable_path, option);
}

View File

@@ -0,0 +1,15 @@
import manifest from '@/../manifest.json';
import { getTavernHelperExtensionId, updateExtension } from '@/function/extension';
import { version } from '@/util/tavern';
export function getTavernHelperVersion(): string {
return manifest.version;
}
export async function updateTavernHelper(): Promise<boolean> {
return updateExtension(getTavernHelperExtensionId()).then(res => res.ok);
}
export function getTavernVersion(): string {
return version;
}

View File

@@ -0,0 +1,469 @@
import {
getCharLorebooks,
getChatLorebook,
getOrCreateChatLorebook,
setChatLorebook,
setCurrentCharLorebooks,
} from '@/function/lorebook';
import { RawCharacter } from '@/function/raw_character';
import { reloadEditor, reloadEditorDebounced } from '@/util/compatibility';
import { saveSettingsDebounced } from '@sillytavern/script';
import {
createNewWorldInfo,
deleteWorldInfo,
getWorldInfoSettings,
loadWorldInfo,
parseRegexFromString,
saveWorldInfo,
selected_world_info,
world_names,
} from '@sillytavern/scripts/world-info';
import { LiteralUnion, PartialDeep } from 'type-fest';
export function getWorldbookNames(): string[] {
return klona(world_names);
}
export function getGlobalWorldbookNames(): string[] {
return klona((getWorldInfoSettings().world_info as { globalSelect: string[] }).globalSelect);
}
export async function rebindGlobalWorldbooks(worldbook_names: string[]): Promise<void> {
const $world_info = $('#world_info');
$world_info.find('option[value!=""]').remove();
$world_info.append(
world_names.map((item, i) => {
const should_select = worldbook_names.includes(item);
return new Option(item, String(i), should_select, should_select);
}),
);
selected_world_info.length = 0;
selected_world_info.push(...worldbook_names);
_.set(getWorldInfoSettings().world_info, 'globalSelect', selected_world_info);
saveSettingsDebounced();
}
type CharWorldbooks = {
primary: string | null;
additional: string[];
};
export function getCharWorldbookNames(character_name: LiteralUnion<'current', string>): CharWorldbooks {
return getCharLorebooks({ name: character_name });
}
export async function rebindCharWorldbooks(character_name: 'current', char_worldbooks: CharWorldbooks): Promise<void> {
if (character_name !== 'current') {
throw Error(`目前不支持对非当前角色卡调用 bindCharWorldbooks`);
}
const character = RawCharacter.find({ name: character_name });
if (!character) {
throw Error(`角色卡 '${character_name}' 不存在`);
}
// TODO: 重做 characters.ts, 然后直接访问后端来修改这里
return setCurrentCharLorebooks(char_worldbooks);
}
export function getChatWorldbookName(chat_name: 'current'): string | null {
if (chat_name !== 'current') {
throw Error(`目前不支持对非当前聊天调用 getChatWorldbookName`);
}
return getChatLorebook();
}
export async function rebindChatWorldbook(chat_name: 'current', worldbook_name: string): Promise<void> {
if (chat_name !== 'current') {
throw Error(`目前不支持对非当前聊天调用 getChatWorldbookName`);
}
await setChatLorebook(worldbook_name);
}
export async function getOrCreateChatWorldbook(chat_name: 'current', worldbook_name?: string): Promise<string> {
if (chat_name !== 'current') {
throw Error(`目前不支持对非当前聊天调用 getChatWorldbookName`);
}
return await getOrCreateChatLorebook(worldbook_name);
}
type WorldbookEntry = {
uid: number;
name: string;
enabled: boolean;
strategy: {
type: 'constant' | 'selective' | 'vectorized';
keys: (string | RegExp)[];
keys_secondary: { logic: 'and_any' | 'and_all' | 'not_all' | 'not_any'; keys: (string | RegExp)[] };
scan_depth: 'same_as_global' | number;
};
position: {
type:
| 'before_character_definition'
| 'after_character_definition'
| 'before_example_messages'
| 'after_example_messages'
| 'before_author_note'
| 'after_author_note'
| 'at_depth';
role: 'system' | 'assistant' | 'user';
depth: number;
order: number;
};
content: string;
probability: number;
recursion: {
prevent_incoming: boolean;
prevent_outgoing: boolean;
delay_until: null | number;
};
effect: {
sticky: null | number;
cooldown: null | number;
delay: null | number;
};
extra?: Record<string, any>;
};
const _default_implicit_keys: _ImplicitKeys = {
addMemo: true,
matchPersonaDescription: false,
matchCharacterDescription: false,
matchCharacterPersonality: false,
matchCharacterDepthPrompt: false,
matchScenario: false,
matchCreatorNotes: false,
group: '',
groupOverride: false,
groupWeight: 100,
caseSensitive: null,
matchWholeWords: null,
useGroupScoring: null,
automationId: '',
ignoreBudget: false,
outletName: '',
triggers: [],
characterFilter: {
isExclude: false,
names: [],
tags: [],
},
} as const;
type _ImplicitKeys = {
addMemo: true;
matchPersonaDescription: false;
matchCharacterDescription: false;
matchCharacterPersonality: false;
matchCharacterDepthPrompt: false;
matchScenario: false;
matchCreatorNotes: false;
group: '';
groupOverride: false;
groupWeight: 100;
caseSensitive: null;
matchWholeWords: null;
useGroupScoring: null;
automationId: '';
ignoreBudget: false,
outletName: '',
triggers: [],
characterFilter: {
isExclude: false,
names: [],
tags: [],
},
};
type _OriginalWorldbookEntry = {
uid: number;
displayIndex: number;
comment: string;
disable: boolean;
constant: boolean;
selective: boolean;
key: string[];
selectiveLogic: 0 | 1 | 2 | 3; // 0: and_any, 1: not_all, 2: not_any, 3: and_all
keysecondary: string[];
scanDepth: number | null;
vectorized: boolean;
position: 0 | 1 | 2 | 3 | 4 | 5 | 6;
role: 0 | 1 | 2 | null; // 0: system, 1: user, 2: assistant
depth: number;
order: number;
content: string;
useProbability: boolean;
probability: number;
excludeRecursion: boolean;
preventRecursion: boolean;
delayUntilRecursion: boolean | number;
sticky: number | null;
cooldown: number | null;
delay: number | null;
extra?: Record<string, any>;
};
function toWorldbookEntry(entry: _OriginalWorldbookEntry & _ImplicitKeys): WorldbookEntry & Partial<_ImplicitKeys> {
let result = _({})
.set('uid', entry.uid)
.set('name', entry.comment)
.set('enabled', !entry.disable)
.set('strategy.type', entry.constant ? 'constant' : entry.vectorized ? 'vectorized' : 'selective')
.set(
'strategy.keys',
entry.key.map(value => parseRegexFromString(value) ?? value),
)
.set('strategy.keys_secondary', {
logic: ({ 0: 'and_any', 1: 'not_all', 2: 'not_any', 3: 'and_all' } as const)[entry.selectiveLogic],
keys: entry.keysecondary.map(value => parseRegexFromString(value) ?? value),
})
.set('strategy.scan_depth', entry.scanDepth ?? 'same_as_global')
.set(
'position.type',
{
0: 'before_character_definition',
1: 'after_character_definition',
5: 'before_example_messages',
6: 'after_example_messages',
2: 'before_author_note',
3: 'after_author_note',
4: 'at_depth',
}[entry.position],
)
.set('position.role', ({ 0: 'system', 1: 'user', 2: 'assistant' } as const)[entry.role ?? 0])
.set('position.depth', entry.depth)
.set('position.order', entry.order)
.set('content', entry.content)
.set('probability', entry.useProbability ? entry.probability : 100)
.set('recursion.prevent_incoming', entry.excludeRecursion)
.set('recursion.prevent_outgoing', entry.preventRecursion)
.set(
'recursion.delay_until',
typeof entry.delayUntilRecursion === 'number' && entry.delayUntilRecursion > 0 ? entry.delayUntilRecursion : null,
)
.set('effect.sticky', typeof entry.sticky === 'number' && entry.sticky > 0 ? entry.sticky : null)
.set('effect.cooldown', typeof entry.cooldown === 'number' && entry.cooldown > 0 ? entry.cooldown : null)
.set('effect.delay', typeof entry.delay === 'number' && entry.delay > 0 ? entry.delay : null);
if (entry.extra) {
result = result.set('extra', entry.extra);
}
result = result.merge(_.pick(entry, Object.keys(_default_implicit_keys)));
return result.value() as WorldbookEntry & _ImplicitKeys;
}
function fromWorldbookEntry(
entry: Pick<WorldbookEntry, 'uid'> & PartialDeep<WorldbookEntry & _ImplicitKeys>,
display_index: number,
): _OriginalWorldbookEntry & _ImplicitKeys {
let result = _({})
.set('uid', entry.uid)
.set('displayIndex', display_index)
.set('comment', entry.name ?? '')
.set('disable', !(entry.enabled ?? true))
.set('constant', entry?.strategy?.type ? entry?.strategy?.type === 'constant' : true)
.set('selective', entry?.strategy?.type === 'selective')
.set('key', entry?.strategy?.keys?.map(_.toString) ?? [])
.set(
'selectiveLogic',
(
{
and_any: 0,
not_all: 1,
not_any: 2,
and_all: 3,
} as const
)[entry?.strategy?.keys_secondary?.logic ?? 'and_any'],
)
.set('keysecondary', entry?.strategy?.keys_secondary?.keys?.map(_.toString) ?? [])
.set('scanDepth', entry?.strategy?.scan_depth === 'same_as_global' ? null : (entry?.strategy?.scan_depth ?? null))
.set('vectorized', entry?.strategy?.type === 'vectorized')
.set(
'position',
{
before_character_definition: 0,
after_character_definition: 1,
before_example_messages: 5,
after_example_messages: 6,
before_author_note: 2,
after_author_note: 3,
at_depth: 4,
}[entry?.position?.type ?? 'at_depth'],
)
.set('role', ({ system: 0, user: 1, assistant: 2 } as const)[entry?.position?.role ?? 'system'])
.set('depth', entry?.position?.depth ?? 4)
.set('order', entry?.position?.order ?? 100)
.set('content', entry.content ?? '')
.set('useProbability', true)
.set('probability', entry.probability ?? 100)
.set('excludeRecursion', entry.recursion?.prevent_incoming ?? false)
.set('preventRecursion', entry.recursion?.prevent_outgoing ?? false)
.set('delayUntilRecursion', entry.recursion?.delay_until ?? false)
.set('sticky', entry.effect?.sticky ?? null)
.set('cooldown', entry.effect?.cooldown ?? null)
.set('delay', entry.effect?.delay ?? null);
if (entry.extra) {
result = result.set('extra', entry.extra);
}
result = result.merge(_default_implicit_keys as object).merge(_.pick(entry, Object.keys(_default_implicit_keys)));
return result.value() as _OriginalWorldbookEntry & _ImplicitKeys;
}
function handleWorldbookEntriesCollision(
entries: PartialDeep<WorldbookEntry>[],
): Array<Pick<WorldbookEntry, 'uid'> & PartialDeep<WorldbookEntry & _ImplicitKeys>> {
const MAX_UID = 1_000_000 as const;
const uid_set = new Set<number>();
const handle_uid_collision = (index: number | undefined) => {
if (index === undefined) {
index = _.random(0, MAX_UID - 1);
}
let i = 1;
while (true) {
if (!uid_set.has(index)) {
uid_set.add(index);
return index;
}
index = (index + i * i) % MAX_UID;
++i;
}
};
return entries.map(entry => ({
...entry,
uid: handle_uid_collision(entry.uid),
}));
}
export async function createWorldbook(worldbook_name: string, worldbook: WorldbookEntry[] = []): Promise<boolean> {
if (getWorldbookNames().includes(worldbook_name)) {
return false;
}
return await createOrReplaceWorldbook(worldbook_name, worldbook);
}
interface ReplaceWorldbookOptions {
render?: 'debounced' | 'immediate';
}
export async function createOrReplaceWorldbook(
worldbook_name: string,
worldbook: PartialDeep<WorldbookEntry>[] = [],
{ render = 'debounced' }: ReplaceWorldbookOptions = {},
): Promise<boolean> {
const is_existing = getWorldbookNames().includes(worldbook_name);
if (!getWorldbookNames().includes(worldbook_name)) {
const success = await createNewWorldInfo(worldbook_name, { interactive: false });
if (!success) {
return false;
}
}
if (is_existing || worldbook.length > 0) {
await saveWorldInfo(worldbook_name, {
entries: _.merge(
{},
..._(handleWorldbookEntriesCollision(worldbook))
.map(fromWorldbookEntry)
.map(entry => ({ [entry.uid]: entry }))
.value(),
),
});
switch (render) {
case 'debounced':
reloadEditorDebounced(worldbook_name);
break;
case 'immediate':
reloadEditor(worldbook_name);
break;
}
}
return !is_existing;
}
export async function deleteWorldbook(worldbook_name: string): Promise<boolean> {
return await deleteWorldInfo(worldbook_name);
}
// TODO: rename 需要处理世界书绑定
// export function renameWorldbook(old_name: string, new_name: string): boolean;
export async function getWorldbook(worldbook_name: string): Promise<WorldbookEntry[]> {
if (!getWorldbookNames().includes(worldbook_name)) {
throw Error(`未能找到世界书 '${worldbook_name}'`);
}
const original_worldbook_entries = await loadWorldInfo(worldbook_name).then(
data => (data! as { entries: { [uid: number]: _OriginalWorldbookEntry & _ImplicitKeys } }) ?? {},
);
return klona(_(original_worldbook_entries.entries).values().sortBy('displayIndex').map(toWorldbookEntry).value());
}
export async function replaceWorldbook(
worldbook_name: string,
worldbook: PartialDeep<WorldbookEntry>[],
options?: ReplaceWorldbookOptions,
): Promise<void> {
if (!getWorldbookNames().includes(worldbook_name)) {
throw Error(`未能找到世界书 '${worldbook_name}'`);
}
await createOrReplaceWorldbook(worldbook_name, worldbook, options);
}
type WorldbookUpdater =
| ((worldbook: WorldbookEntry[]) => PartialDeep<WorldbookEntry>[])
| ((worldbook: WorldbookEntry[]) => Promise<PartialDeep<WorldbookEntry>[]>);
export async function updateWorldbookWith(
worldbook_name: string,
updater: WorldbookUpdater,
options?: ReplaceWorldbookOptions,
): Promise<WorldbookEntry[]> {
await replaceWorldbook(worldbook_name, await updater(await getWorldbook(worldbook_name)), options);
return await getWorldbook(worldbook_name);
}
export async function createWorldbookEntries(
worldbook_name: string,
new_entries: PartialDeep<WorldbookEntry>[],
options?: ReplaceWorldbookOptions,
): Promise<{ worldbook: WorldbookEntry[]; new_entries: WorldbookEntry[] }> {
let slice_start;
const worldbook = await updateWorldbookWith(
worldbook_name,
data => {
slice_start = data.length;
return [...data, ...new_entries];
},
options,
);
return { worldbook, new_entries: worldbook.slice(slice_start) };
}
export async function deleteWorldbookEntries(
worldbook_name: string,
predicate: (entry: WorldbookEntry) => boolean,
options?: ReplaceWorldbookOptions,
): Promise<{ worldbook: WorldbookEntry[]; deleted_entries: WorldbookEntry[] }> {
let deleted_entries: WorldbookEntry[] = [];
const worldbook = await updateWorldbookWith(
worldbook_name,
data => {
deleted_entries = _.remove(data, predicate);
return data;
},
options,
);
return { worldbook, deleted_entries };
}

View File

@@ -0,0 +1,54 @@
@layer theme, base, components, utilities;
@import 'tailwindcss/theme.css' layer(theme);
@import 'tailwindcss/utilities.css' layer(utilities);
/* 避免 tailwindcss 扫描 dist 中的打包结果, 导致打包变慢 */
@source not "../dist";
@theme {
--spacing: var(--th-spacing, 0.25rem);
}
:where(.TH-custom-tailwind) {
--spacing: var(--mainFontSize);
}
@utility th-text-base {
font-size: var(--mainFontSize);
}
@utility th-text-xs {
font-size: calc(var(--mainFontSize) * 0.75);
}
@utility th-text-sm {
font-size: calc(var(--mainFontSize) * 0.875);
}
@utility th-text-md {
font-size: calc(var(--mainFontSize) * 1.15);
}
@utility th-text-lg {
font-size: calc(var(--mainFontSize) * 1.5);
}
@utility th-text-xl {
font-size: calc(var(--mainFontSize) * 2);
}
@utility th-text-2xl {
font-size: calc(var(--mainFontSize) * 2.5);
}
/* 模拟overlay: 使用 CSS 伪元素自动创建全局唯一的 overlay */
body:has(.TH-popup)::before {
content: '';
position: fixed;
top: 0;
left: 0;
width: 100dvw;
height: 100dvh;
background-color: rgba(0, 0, 0, 0.5);
z-index: 9999;
pointer-events: auto;
}

View File

@@ -0,0 +1,57 @@
(function () {
let scheduled = false;
function measureAndPost() {
scheduled = false;
try {
const doc = window.document;
const body = doc.body;
const html = doc.documentElement;
if (!body || !html) {
return;
}
let height = 0;
height = body.scrollHeight;
if (!Number.isFinite(height) || height <= 0) {
return;
}
frameElement.style.height = `${height}px`;
} catch {
//
}
}
const throttledMeasureAndPost = _.throttle(measureAndPost, 500);
function postIframeHeight() {
if (scheduled) {
return;
}
scheduled = true;
if (typeof window.requestAnimationFrame === 'function') {
window.requestAnimationFrame(measureAndPost);
} else {
throttledMeasureAndPost();
}
}
function observeHeightChange() {
const body = document.body;
if (!body) {
return;
}
const resize_observer = new ResizeObserver(entries => {
postIframeHeight();
});
resize_observer.observe(body);
}
$(() => {
postIframeHeight();
observeHeightChange();
});
})();

View File

@@ -0,0 +1,6 @@
$('html').css('--TH-viewport-height', `${window.parent.innerHeight}px`);
window.addEventListener('message', function (event) {
if (event.data?.type === 'TH_UPDATE_VIEWPORT_HEIGHT') {
$('html').css('--TH-viewport-height', `${window.parent.innerHeight}px`);
}
});

View File

@@ -0,0 +1,2 @@
window.$ = window.parent.$;
window.jQuery = window.parent.jQuery;

View File

@@ -0,0 +1,43 @@
window._ = window.parent._;
const iframeId = window.frameElement?.id || window.name;
if (iframeId) {
// Cache the iframe id in case frameElement disappears (e.g., Firefox removing srcdoc iframes on navigation)
// and also put it on window.name so it survives DOM removal.
window.__TH_IFRAME_ID = iframeId;
if (!window.name) {
window.name = iframeId;
}
}
let result = _(window);
result = result.merge(_.pick(window.parent, ['EjsTemplate', 'TavernHelper', 'YAML', 'showdown', 'toastr', 'z']));
result = result.merge(_.omit(_.get(window.parent, 'TavernHelper'), '_bind'));
result = result.merge(
...Object.entries(_.get(window.parent, 'TavernHelper')._bind).map(([key, value]) => ({
[key.replace('_', '')]: value.bind(window),
})),
);
result.value();
Object.defineProperty(window, 'SillyTavern', {
get: () => {
const SillyTavern = _.get(window.parent, 'SillyTavern');
const getContext = () => {
return { ...SillyTavern.getContext(), writeExtensionField: _th_impl.writeExtensionField };
};
return { ...getContext(), getContext };
},
});
// 其实应该用 waitGlobalInitialized 来等待 Mvu 初始化完毕, 这里设置 window.Mvu 只是为了兼容性
if (_.has(window.parent, 'Mvu')) {
Object.defineProperty(window, 'Mvu', {
get: () => _.get(window.parent, 'Mvu'),
// Mvu 脚本自己还会 `_.set()` 自己的变量, 所以这里设置一个空 set
set: () => {},
configurable: true,
});
}
$(window).on('pagehide', () => {
eventClearAll();
});

View File

@@ -0,0 +1,14 @@
import adjust_viewport from '@/iframe/adjust_viewport?raw';
import adjust_iframe_height from '@/iframe/adjust_iframe_height?raw';
import parent_jquery from '@/iframe/parent_jquery?raw';
import predefine from '@/iframe/predefine?raw';
function createObjectURLFromScript(code: string): string {
return URL.createObjectURL(new Blob([code], { type: 'application/javascript' }));
}
// 反正酒馆助手不会 unmount, 无需考虑 revoke
export const adjust_iframe_height_url = createObjectURLFromScript(adjust_iframe_height);
export const adjust_viewport_url = createObjectURLFromScript(adjust_viewport);
export const parent_jquery_url = createObjectURLFromScript(parent_jquery);
export const predefine_url = createObjectURLFromScript(predefine);

View File

@@ -0,0 +1,8 @@
<link rel="stylesheet" href="https://testingcf.jsdelivr.net/npm/@fortawesome/fontawesome-free/css/all.min.css" />
<script src="/scripts/extensions/third-party/JS-Slash-Runner/lib/tailwindcss.min.js"></script>
<script src="https://testingcf.jsdelivr.net/npm/jquery/dist/jquery.min.js"></script>
<script src="https://testingcf.jsdelivr.net/npm/jquery-ui/dist/jquery-ui.min.js"></script>
<link rel="stylesheet" href="https://testingcf.jsdelivr.net/npm/jquery-ui/themes/base/theme.min.css" />
<script src="https://testingcf.jsdelivr.net/npm/jquery-ui-touch-punch"></script>
<script src="https://testingcf.jsdelivr.net/npm/vue/dist/vue.runtime.global.prod.min.js"></script>
<script src="https://testingcf.jsdelivr.net/npm/vue-router/dist/vue-router.global.prod.min.js"></script>

View File

@@ -0,0 +1,2 @@
<script src="https://testingcf.jsdelivr.net/npm/vue/dist/vue.runtime.global.prod.min.js"></script>
<script src="https://testingcf.jsdelivr.net/npm/vue-router/dist/vue-router.global.prod.min.js"></script>

View File

@@ -0,0 +1,51 @@
import { initTavernHelperObject } from '@/function/index';
import '@/global.css';
import { registerMacros } from '@/macro';
import Panel from '@/Panel.vue';
import { initSlashCommands } from '@/slash_command/index';
import { useGlobalSettingsStore } from '@/store/settings';
import { registerSwipeEvent } from '@/swipe';
import { initThirdPartyObject } from '@/third_party_object';
import { getCurrentLocale } from '@sillytavern/scripts/i18n';
import { App } from 'vue';
import { createVfm } from 'vue-final-modal';
import 'vue-final-modal/style.css';
import VueTippy from 'vue-tippy';
const app = createApp(Panel);
const pinia = createPinia();
app.use(pinia);
const vfm = createVfm();
app.use(vfm);
app.use(VueTippy);
declare module 'vue' {
interface ComponentCustomProperties {
t: typeof t;
}
}
const i18n = {
install: (app: App) => {
app.config.globalProperties.t = t;
},
};
app.use(i18n);
$(() => {
z.config(getCurrentLocale().includes('zh') ? z.locales.zhCN() : z.locales.en());
registerMacros();
registerSwipeEvent();
initTavernHelperObject();
initThirdPartyObject();
initSlashCommands();
const $app = $('<div id="tavern_helper">').appendTo('#extensions_settings');
app.mount($app[0]);
});
$(window).on('pagehide', () => {
app.unmount();
});

View File

@@ -0,0 +1,13 @@
import { getCharAvatarPath, getUserAvatarPath } from '@/util/tavern';
import { MacrosParser } from '@sillytavern/scripts/macros';
const macros = {
userAvatarPath: getUserAvatarPath,
charAvatarPath: getCharAvatarPath,
};
export function registerMacros() {
for (const [key, value] of Object.entries(macros)) {
MacrosParser.registerMacro(key, value);
}
}

View File

@@ -0,0 +1,13 @@
<template>
<div class="flex flex-col gap-0.5">
<MacroLike />
<Listener />
<Reference />
</div>
</template>
<script setup lang="ts">
import Listener from '@/panel/developer/Listener.vue';
import MacroLike from '@/panel/developer/MacroLike.vue';
import Reference from '@/panel/developer/Reference.vue';
</script>

View File

@@ -0,0 +1,67 @@
<template>
<Popup width="fit" :buttons="[{ name: t`关闭` }]">
<div class="flex flex-col gap-0.75 p-1">
<div class="flex flex-col items-center justify-center gap-0.25">
<span class="inline-flex gap-0.5 th-text-lg"><span class="font-bold">Tavern</span> Helper</span>
<span>{{ t`Ver ${current_version}` }}</span>
<Button class="w-auto! whitespace-nowrap" @click="openUpdateModal">{{ button_text }}</Button>
</div>
<div class="flex flex-1 flex-col items-center gap-0.5">
<!-- prettier-ignore-attribute -->
<div class="flex flex-col text-center th-text-xs opacity-70">
<div class="mb-0.25 th-text-sm">{{ t`作者KAKAA青空莉想做舞台少女的狗` }}</div>
<div>{{ t`本扩展免费使用,禁止任何形式的商业用途` }}</div>
<div>{{ t`脚本可能存在风险,请确保安全后再运行` }}</div>
</div>
</div>
<div class="flex justify-center gap-0.75">
<a
href="https://n0vi028.github.io/JS-Slash-Runner-Doc"
target="_blank"
>
<i class="fa-solid fa-book"></i>
</a>
<a href="https://github.com/N0VI028/JS-Slash-Runner" target="_blank">
<i class="fa-brands fa-github"></i>
</a>
<Tippy trigger="click" placement="top" :interactive="true">
<i class="fa-brands fa-discord cursor-pointer text-(--SmartThemeQuoteColor)"></i>
<template #content>
<!-- prettier-ignore-attribute -->
<div class="flex cursor-pointer flex-col gap-0.25 rounded-sm bg-(--SmartThemeBlurTintColor) p-0.5">
<a href="https://discord.com/channels/1134557553011998840/1296494001406345318" target="_blank"
><span class="text-(--SmartThemeBodyColor)">{{ t`类脑` }}</span></a
>
<div class="h-[0.5px] w-full bg-(--SmartThemeEmColor)" />
<a href="https://discord.com/channels/1291925535324110879/1374297592854216774" target="_blank"
><span class="text-(--SmartThemeBodyColor)">{{ t`旅程` }}</span></a
>
</div>
</template>
</Tippy>
</div>
</div>
</Popup>
</template>
<script setup lang="ts">
import { getTavernHelperVersion } from '@/function/version';
import Button from '@/panel/component/Button.vue';
import Popup from '@/panel/component/Popup.vue';
import { getLatestVersion, hasUpdate } from '@/panel/info/update';
import Update from '@/panel/info/Update.vue';
import { Tippy } from 'vue-tippy';
const current_version = getTavernHelperVersion();
const button_text = ref(t`查看日志`);
onMounted(async () => {
if (await hasUpdate()) {
button_text.value = t`最新: ${await getLatestVersion()}`;
}
});
const { open: openUpdateModal } = useModal({
component: Update,
});
</script>

Some files were not shown because too many files have changed in this diff Show More