From a91c911ac28dcf29c8f5ce4f40def7e6f983121e Mon Sep 17 00:00:00 2001 From: magicalastrogy Date: Wed, 14 Oct 2026 11:44:37 +0800 Subject: [PATCH 1/3] feat: add conditional automatic extra-model requests --- src/function/request/entry_comment_regex.ts | 49 +----- src/function/update/on_message_received.ts | 27 +++- src/i18n/messages/panel.ts | 22 +++ src/i18n/messages/runtime.ts | 12 ++ src/panel/update/Request.vue | 53 +++++++ src/store.ts | 6 + src/util/regex.ts | 49 ++++++ tests/i18n.test.ts | 8 + tests/on_message_received_conditions.test.ts | 156 +++++++++++++++++++ tests/store_response_format.test.ts | 33 ++++ 10 files changed, 365 insertions(+), 50 deletions(-) create mode 100644 src/util/regex.ts create mode 100644 tests/on_message_received_conditions.test.ts diff --git a/src/function/request/entry_comment_regex.ts b/src/function/request/entry_comment_regex.ts index 2a416b5..86d4edc 100644 --- a/src/function/request/entry_comment_regex.ts +++ b/src/function/request/entry_comment_regex.ts @@ -1,9 +1,7 @@ import { tr } from '@/i18n'; -export type EntryCommentRegexCompileResult = { - regex?: RegExp; - error?: string; -}; +export { compileRegex as compileEntryCommentRegex } from '@/util/regex'; +export type { RegexCompileResult as EntryCommentRegexCompileResult } from '@/util/regex'; export type EntryCommentFilterSource = '用户全局配置' | '角色卡配置'; @@ -24,49 +22,6 @@ export function logEntryCommentFilterResult(result: EntryCommentFilterResult[]) ); } -function isEscaped(value: string, index: number): boolean { - let backslash_count = 0; - for (let i = index - 1; i >= 0 && value[i] === '\\'; i--) { - backslash_count++; - } - return backslash_count % 2 === 1; -} - -function findClosingSlash(value: string): number { - for (let i = value.length - 1; i > 0; i--) { - if (value[i] === '/' && !isEscaped(value, i)) { - return i; - } - } - return -1; -} - -export function compileEntryCommentRegex(value: string): EntryCommentRegexCompileResult { - const trimmed = value.trim(); - if (trimmed === '') { - return {}; - } - - try { - if (trimmed.startsWith('/')) { - const closing_slash = findClosingSlash(trimmed); - if (closing_slash <= 0) { - throw new Error(tr('runtime.regex.jsStyleSyntax')); - } - return { - regex: new RegExp( - trimmed.slice(1, closing_slash), - trimmed.slice(closing_slash + 1) - ), - }; - } - - return { regex: new RegExp(trimmed) }; - } catch (error) { - return { error: error instanceof Error ? error.message : String(error) }; - } -} - export function testEntryCommentRegex(regex: RegExp, comment: string): boolean { regex.lastIndex = 0; return regex.test(comment); diff --git a/src/function/update/on_message_received.ts b/src/function/update/on_message_received.ts index 004ab7e..3ab4d8d 100644 --- a/src/function/update/on_message_received.ts +++ b/src/function/update/on_message_received.ts @@ -6,6 +6,7 @@ import { isPiMultiproviderEnabled } from '@/function/update/pi/feature_flag'; import { handleVariablesInMessage } from '@/function/update_variables'; import { tr } from '@/i18n'; import { useDataStore } from '@/store'; +import { compileRegex } from '@/util/regex'; /** 接收时启动解析;非工具调用的自动解析在本条消息渲染后等待结果并写回。 */ export async function onMessageReceived( @@ -63,9 +64,29 @@ export async function onMessageReceived( return; } - if (!force && store.effective_settings.额外模型解析配置.启用自动请求 === false) { - console.log(tr('runtime.extraModel.autoRequestDisabledLog')); - return; + if (!force) { + const settings = store.effective_settings.额外模型解析配置; + if (!settings.启用自动请求) { + console.log(tr('runtime.extraModel.autoRequestDisabledLog')); + return; + } + // 按 Unicode 字符统计整条回复(去除首尾空白),不依赖模型的 tokenizer。 + if ( + settings.最小请求字数 > 0 && + Array.from(message_content.trim()).length < settings.最小请求字数 + ) { + console.log(tr('runtime.extraModel.belowMinLengthLog')); + return; + } + const trigger_tag = compileRegex(settings.触发标签); + if (trigger_tag.error !== undefined) { + console.warn(tr('runtime.extraModel.triggerTagInvalidLog'), trigger_tag.error); + return; + } + if (trigger_tag.regex && !trigger_tag.regex.test(message_content)) { + console.log(tr('runtime.extraModel.triggerTagMissingLog')); + return; + } } if (!isCurrentMessage()) { diff --git a/src/i18n/messages/panel.ts b/src/i18n/messages/panel.ts index 1ca8911..95aafc7 100644 --- a/src/i18n/messages/panel.ts +++ b/src/i18n/messages/panel.ts @@ -431,6 +431,28 @@ export const panelMessages = defineMessages({ '关闭后,AI 回复完成时不会自动触发额外模型解析;你需要主动点击“重试额外模型解析”按钮,才会执行解析并添加状态栏占位符 <StatusPlaceHolderImpl/>。', en: 'When disabled, extra-model parsing is not triggered automatically after the AI finishes replying. Click “Retry extra-model parsing” to run it and add the status-bar placeholder <StatusPlaceHolderImpl/>.', }, + 'panel.request.minLength': { + 'zh-CN': '最小请求字数', + en: 'Minimum reply length', + }, + 'panel.request.minLengthHelp': { + 'zh-CN': + 'AI 回复达到此字数才自动请求额外模型解析。按整条回复去除首尾空白后的字符数计算(包含标签,不是 token 数);0 表示不限制。若同时设置触发标签,两个条件都须满足。手动重试不受限制。', + en: 'Automatically request extra-model parsing only when the AI reply reaches this many characters. Counts the entire reply, including tags, after trimming leading and trailing whitespace (not tokens). Set to 0 for no minimum. If a trigger tag is also set, both conditions must be met. Manual retries bypass these conditions.', + }, + 'panel.request.triggerTag': { + 'zh-CN': '触发标签', + en: 'Trigger tag', + }, + 'panel.request.triggerTagHelp': { + 'zh-CN': + 'AI 回复匹配此正则表达式才自动请求额外模型解析。可直接填写 </content>,或用 </(content|正文)> 匹配多个标签;也支持 /pattern/flags 格式,例如 /content/i 忽略大小写。默认区分大小写,特殊字符需用反斜杠转义。忽略配置首尾空白,留空表示不限制;正则无效时跳过自动请求。若同时设置最小请求字数,两个条件都须满足。手动重试不受限制。', + en: 'Automatically request extra-model parsing only when the AI reply matches this regular expression. Enter </content> directly, or use </(content|正文)> to match either tag. The /pattern/flags format is also supported, such as /content/i for case-insensitive matching. Matching is case-sensitive by default; escape special characters with a backslash. Leading and trailing whitespace in this setting is ignored; leave empty for no restriction. Invalid regexes skip automatic requests. If a minimum reply length is also set, both conditions must be met. Manual retries bypass these conditions.', + }, + 'panel.request.triggerTagInvalid': { + 'zh-CN': '触发标签正则无效,将跳过自动请求:{error}', + en: 'Invalid trigger-tag regex; automatic requests will be skipped: {error}', + }, 'panel.request.batchWarning': { 'zh-CN': '请升级酒馆助手到 4.4.3 或更高版本,否则批量请求功能可能让预设的“流式传输”设置失效', diff --git a/src/i18n/messages/runtime.ts b/src/i18n/messages/runtime.ts index 33f8c37..6a103e1 100644 --- a/src/i18n/messages/runtime.ts +++ b/src/i18n/messages/runtime.ts @@ -598,6 +598,18 @@ export const runtimeMessages = defineMessages({ 'zh-CN': '[MVU]不自动触发额外模型解析', en: '[MVU] Automatic extra-model parsing is disabled', }, + 'runtime.extraModel.belowMinLengthLog': { + 'zh-CN': '[MVU]回复未达到最小请求字数,跳过自动额外模型解析', + en: '[MVU] Reply is below the minimum length; skipping automatic extra-model parsing', + }, + 'runtime.extraModel.triggerTagMissingLog': { + 'zh-CN': '[MVU]回复未匹配触发标签正则,跳过自动额外模型解析', + en: '[MVU] Reply does not match the trigger-tag regex; skipping automatic extra-model parsing', + }, + 'runtime.extraModel.triggerTagInvalidLog': { + 'zh-CN': '[MVU]触发标签正则无效,跳过自动额外模型解析', + en: '[MVU] Invalid trigger-tag regex; skipping automatic extra-model parsing', + }, 'runtime.extraModel.updateFailed': { 'zh-CN': '建议调整变量更新方式;可在“输入框左下角魔棒 → 日志查看器”查看具体情况', en: 'Try adjusting the variable update method. Open Magic Wand → Log Viewer at the lower-left of the input box for details.', diff --git a/src/panel/update/Request.vue b/src/panel/update/Request.vue index 863c369..91d42fd 100644 --- a/src/panel/update/Request.vue +++ b/src/panel/update/Request.vue @@ -34,6 +34,40 @@ {{ t('common.enabled') }} +
+ + + + + + + + + {{ t('panel.request.triggerTagInvalid', { error: trigger_tag_error }) }} + + +
@@ -50,11 +84,24 @@ import Select from '@/panel/component/Select.vue'; import request_method_help_en from '@/panel/update/request_method.en.md'; import request_method_help_zh_cn from '@/panel/update/request_method.zh-CN.md'; import { useDataStore } from '@/store'; +import { compileRegex } from '@/util/regex'; import { compare } from 'compare-versions'; import { computed, watch } from 'vue'; const store = useDataStore(); const { locale, t } = useMvuI18n(); +const trigger_tag_error = computed( + () => compileRegex(store.settings.额外模型解析配置.触发标签).error +); +const min_request_length = computed({ + get: () => store.settings.额外模型解析配置.最小请求字数, + set: value => { + const length = Number(value); + store.settings.额外模型解析配置.最小请求字数 = Number.isFinite(length) + ? Math.max(0, Math.floor(length)) + : 0; + }, +}); const request_method_help = computed(() => locale.value === 'zh-CN' ? request_method_help_zh_cn : request_method_help_en ); @@ -84,3 +131,9 @@ watch( } ); + + diff --git a/src/store.ts b/src/store.ts index 7d05fde..5ded485 100644 --- a/src/store.ts +++ b/src/store.ts @@ -242,6 +242,12 @@ const NewSettings = z 随机头部: z.boolean().default(true), 启用自动请求: z.boolean().default(true), + 最小请求字数: z.coerce + .number() + .catch(0) + .default(0) + .transform(value => Math.max(0, Math.floor(value))), + 触发标签: z.string().default(''), 请求方式: z .enum([ '依次请求,失败后重试', diff --git a/src/util/regex.ts b/src/util/regex.ts new file mode 100644 index 0000000..af162a1 --- /dev/null +++ b/src/util/regex.ts @@ -0,0 +1,49 @@ +import { tr } from '@/i18n'; + +export type RegexCompileResult = { + regex?: RegExp; + error?: string; +}; + +function isEscaped(value: string, index: number): boolean { + let backslash_count = 0; + for (let i = index - 1; i >= 0 && value[i] === '\\'; i--) { + backslash_count++; + } + return backslash_count % 2 === 1; +} + +function findClosingSlash(value: string): number { + for (let i = value.length - 1; i > 0; i--) { + if (value[i] === '/' && !isEscaped(value, i)) { + return i; + } + } + return -1; +} + +export function compileRegex(value: string): RegexCompileResult { + const trimmed = value.trim(); + if (trimmed === '') { + return {}; + } + + try { + if (trimmed.startsWith('/')) { + const closing_slash = findClosingSlash(trimmed); + if (closing_slash <= 0) { + throw new Error(tr('runtime.regex.jsStyleSyntax')); + } + return { + regex: new RegExp( + trimmed.slice(1, closing_slash), + trimmed.slice(closing_slash + 1) + ), + }; + } + + return { regex: new RegExp(trimmed) }; + } catch (error) { + return { error: error instanceof Error ? error.message : String(error) }; + } +} diff --git a/tests/i18n.test.ts b/tests/i18n.test.ts index 8096c60..944707f 100644 --- a/tests/i18n.test.ts +++ b/tests/i18n.test.ts @@ -85,6 +85,14 @@ describe('MVU localization', () => { ); }); + test.each(APP_LOCALES)('keeps the complete trigger regex help in %s', locale => { + locale_ref.value = locale; + + const help = tr('panel.request.triggerTagHelp'); + expect(help).toBe(panelMessages['panel.request.triggerTagHelp'][locale]); + expect(help).toContain('</(content|正文)>'); + }); + // Pi 界面文案:组合来源标签与协议名称都有对应翻译。 test('translates provider labels and protocols used in combined source choices', () => { locale_ref.value = 'zh-CN'; diff --git a/tests/on_message_received_conditions.test.ts b/tests/on_message_received_conditions.test.ts new file mode 100644 index 0000000..3eb737e --- /dev/null +++ b/tests/on_message_received_conditions.test.ts @@ -0,0 +1,156 @@ +/** 验证自动请求条件在消息入口阻止调用,手动重试与随 AI 输出解析保持原行为。 */ +import { isExtraModelSupported } from '@/function/is_extra_model_supported'; +import { invokeExtraModelWithStrategy } from '@/function/update/invoke_extra_model'; +import { onMessageReceived } from '@/function/update/on_message_received'; +import { handleVariablesInMessage } from '@/function/update_variables'; +import { useDataStore } from '@/store'; + +jest.mock('@/function/is_extra_model_supported', () => ({ + isExtraModelSupported: jest.fn(), +})); +jest.mock('@/function/update/invoke_extra_model', () => ({ + invokeExtraModelWithStrategy: jest.fn(), +})); +jest.mock('@/function/update_variables', () => ({ + handleVariablesInMessage: jest.fn(), +})); + +const mockInvoke = jest.mocked(invokeExtraModelWithStrategy); +const mockHandleVariables = jest.mocked(handleVariablesInMessage); + +describe('自动请求条件', () => { + beforeEach(() => { + jest.clearAllMocks(); + Object.assign(SillyTavern, { + extensionSettings: {}, + name2: 'Assistant', + chat: [{}, {}], + getCurrentChatId: jest.fn().mockReturnValue('conditions-chat'), + }); + jest.mocked(getChatMessages).mockReturnValue([ + { name: 'Assistant', message: '这是一条完整回复' } as ChatMessage, + ]); + jest.mocked(isExtraModelSupported).mockResolvedValue(true); + mockInvoke.mockResolvedValue('_.set("health", 80);'); + mockHandleVariables.mockResolvedValue(undefined); + + const store = useDataStore(); + store.settings.更新方式 = '额外模型解析'; + store.settings.兼容性.额外模型解析非阻塞 = false; + Object.assign(store.settings.额外模型解析配置, { + 启用自动请求: true, + 模型来源: '自定义', + 应答格式: '聊天消息', + }); + }); + + test.each([ + ['默认条件', '这是一条普通回复', 0, '', true], + ['低于最小字数', '这是一条普通回复', 9, '', false], + ['恰好达到最小字数', '这是一条普通回复', 8, '', true], + ['超过最小字数', '这是一条普通回复', 7, '', true], + ['忽略首尾空白', ' 这是一条普通回复\n', 9, '', false], + ['Unicode 字符不重复计数', '正文加上😀', 6, '', false], + ['Unicode 字符达到边界', '正文加上😀', 5, '', true], + ['缺少标签', '这是一条普通回复', 0, '', false], + ['包含标签', '这是一条普通回复', 0, '', true], + ['支持正则通配', '这是一条普通回复', 0, '', true], + ['支持多个标签', '这是一条普通回复', 0, '', true], + ['正则未匹配', '这是一条普通回复', 0, '', false], + ['支持忽略大小写标志', '这是一条普通回复', 0, '/<\\/content>/i', true], + ['支持多行匹配标志', '这是一条普通回复\n', 0, '/^<\\/content>$/m', true], + ['支持转义特殊字符', '这是一条普通回复[end]', 0, '\\[end\\]$', true], + ['无效正则', '这是一条普通回复', 0, '[', false], + ['无效正则标志', '这是一条普通回复', 0, '/content/invalid', false], + ['缺少正则结束分隔符', '这是一条普通回复', 0, '/content', false], + ['标签区分大小写', '这是一条普通回复', 0, '', false], + ['忽略标签设置首尾空白', '这是一条普通回复', 0, ' \n', true], + ['空白标签不限制', '这是一条普通回复', 0, ' \n ', true], + ['仅字数满足', '这是一条普通回复', 8, '', false], + ['仅标签满足', '这是一条普通回复', 500, '', false], + ['两个条件均满足', '这是一条普通回复', 8, '', true], + ])('%s', async (_name, message, min_length, trigger_tag, should_request) => { + Object.assign(useDataStore().settings.额外模型解析配置, { + 最小请求字数: min_length, + 触发标签: trigger_tag, + }); + jest.mocked(getChatMessages).mockReturnValue([ + { name: 'Assistant', message } as ChatMessage, + ]); + + await onMessageReceived(1); + + expect(mockInvoke).toHaveBeenCalledTimes(should_request ? 1 : 0); + expect(setChatMessages).toHaveBeenCalledTimes(should_request ? 1 : 0); + expect(mockHandleVariables).toHaveBeenCalledTimes(should_request ? 1 : 0); + expect(eventOn).not.toHaveBeenCalledWith( + tavern_events.CHARACTER_MESSAGE_RENDERED, + expect.any(Function) + ); + }); + + test('关闭自动请求时即使满足条件也不会请求', async () => { + useDataStore().settings.额外模型解析配置.启用自动请求 = false; + + await onMessageReceived(1); + + expect(mockInvoke).not.toHaveBeenCalled(); + }); + + test('带全局标志的正则在多次请求间不保留匹配位置', async () => { + useDataStore().settings.额外模型解析配置.触发标签 = '/<\\/content>/g'; + + await onMessageReceived(1); + await onMessageReceived(1); + + expect(mockInvoke).toHaveBeenCalledTimes(2); + }); + + test('手动重试绕过无效正则', async () => { + useDataStore().settings.额外模型解析配置.触发标签 = '['; + + await onMessageReceived(1, { force: true }); + + expect(mockInvoke).toHaveBeenCalledTimes(1); + }); + + test.each([true, false])('手动重试绕过条件(自动请求开关:%s)', async enabled => { + Object.assign(useDataStore().settings.额外模型解析配置, { + 启用自动请求: enabled, + 最小请求字数: 500, + 触发标签: '', + }); + + await onMessageReceived(1, { force: true }); + + expect(mockInvoke).toHaveBeenCalledTimes(1); + expect(mockHandleVariables).toHaveBeenCalledWith(1); + }); + + test('随 AI 输出模式仍解析回复中的变量', async () => { + const store = useDataStore(); + store.settings.更新方式 = '随AI输出'; + store.settings.额外模型解析配置.最小请求字数 = 500; + store.settings.额外模型解析配置.触发标签 = ''; + + await onMessageReceived(1); + + expect(mockInvoke).not.toHaveBeenCalled(); + expect(mockHandleVariables).toHaveBeenCalledWith(1); + }); + + test('非阻塞模式跳过时不注册渲染监听', async () => { + const store = useDataStore(); + store.settings.兼容性.额外模型解析非阻塞 = true; + store.settings.额外模型解析配置.最小请求字数 = 500; + + await onMessageReceived(1); + + expect(mockInvoke).not.toHaveBeenCalled(); + expect(eventOn).not.toHaveBeenCalledWith( + tavern_events.CHARACTER_MESSAGE_RENDERED, + expect.any(Function) + ); + expect(setChatMessages).not.toHaveBeenCalled(); + }); +}); diff --git a/tests/store_response_format.test.ts b/tests/store_response_format.test.ts index 627cf81..92953ad 100644 --- a/tests/store_response_format.test.ts +++ b/tests/store_response_format.test.ts @@ -20,6 +20,39 @@ describe('extra model response format settings', () => { expect(store.settings.额外模型解析配置.兼容假流式).toBe(false); }); + test('defaults automatic request conditions to unrestricted', () => { + const settings = useDataStore().settings.额外模型解析配置; + + expect(settings.最小请求字数).toBe(0); + expect(settings.触发标签).toBe(''); + }); + + test('preserves automatic request conditions when loading and reloading', () => { + (globalThis as any).SillyTavern.extensionSettings = { + mvu_settings: { + 额外模型解析配置: { 最小请求字数: 500, 触发标签: '' }, + }, + }; + const store = useDataStore(); + store._reload_settings(); + + expect(store.settings.额外模型解析配置.最小请求字数).toBe(500); + expect(store.settings.额外模型解析配置.触发标签).toBe(''); + }); + + test.each([ + [-10, 0], + [12.8, 12], + ['500', 500], + ['invalid', 0], + ])('normalizes minimum reply length %s to %s', (value, expected) => { + (globalThis as any).SillyTavern.extensionSettings = { + mvu_settings: { 额外模型解析配置: { 最小请求字数: value } }, + }; + + expect(useDataStore().settings.额外模型解析配置.最小请求字数).toBe(expected); + }); + test('migrates legacy function calling flag to tool calling response format', () => { (globalThis as any).SillyTavern.extensionSettings = { mvu_settings: { From 83f28c2e85ea200e4da9d63987baaa538af2ef21 Mon Sep 17 00:00:00 2001 From: magicalastrogy Date: Wed, 14 Oct 2026 16:41:38 +0800 Subject: [PATCH 2/3] fix: disable trailing message receive calls --- src/function/update/index.ts | 5 +- tests/update_receive_throttle.test.ts | 79 +++++++++++++++++++++++++++ 2 files changed, 83 insertions(+), 1 deletion(-) create mode 100644 tests/update_receive_throttle.test.ts diff --git a/src/function/update/index.ts b/src/function/update/index.ts index c08e283..03de1b5 100644 --- a/src/function/update/index.ts +++ b/src/function/update/index.ts @@ -12,7 +12,10 @@ export function initResponse() { const controller = new AbortController(); const receive = (message_id: number) => onMessageReceived(message_id, { signal: controller.signal }); - const throttled_receive = _.throttle(receive, 3000); + // receive 必须在当次 MESSAGE_RECEIVED 的异步调用链中启动,并返回 Promise 供宿主等待。 + // 窗口内的重复事件直接丢弃,禁止定时器在尾部补调,避免脱离原事件调用链后 + // 再读取已经变化的聊天或配置状态。 + const throttled_receive = _.throttle(receive, 3000, { trailing: false }); const stop_list: Array<() => void> = []; stop_list.push( controlledStoppableEventOn(tavern_events.MESSAGE_SENT, handleVariablesInMessage) diff --git a/tests/update_receive_throttle.test.ts b/tests/update_receive_throttle.test.ts new file mode 100644 index 0000000..c8818d9 --- /dev/null +++ b/tests/update_receive_throttle.test.ts @@ -0,0 +1,79 @@ +/** 覆盖生产环境的节流路径:当次事件立即启动处理,窗口内重复事件不在尾部补调。 */ +import { initResponse } from '@/function/update'; +import { onMessageReceived } from '@/function/update/on_message_received'; + +jest.mock('@/jest', () => ({ is_jest_environment: false })); +jest.mock('@/function/update/on_message_received', () => ({ + onMessageReceived: jest.fn(), +})); +jest.mock('@/function/update/pi/controller_registry', () => ({ + clearPiRequestControllers: jest.fn(), +})); +jest.mock('@/function/update_variables', () => ({ + handleVariablesInMessage: jest.fn(), +})); +jest.mock('@/util', () => ({ + controlledStoppableEventOn: (event: EventType, listener: (...args: any[]) => any) => { + eventOn(event, listener); + return () => eventRemoveListener(event, listener); + }, +})); + +describe('MESSAGE_RECEIVED 生产环境节流', () => { + const mockReceive = jest.mocked(onMessageReceived); + let stop: () => void; + + beforeEach(() => { + jest.useFakeTimers(); + jest.clearAllMocks(); + mockReceive.mockReset().mockResolvedValue(undefined); + stop = initResponse(); + }); + + afterEach(() => { + stop(); + jest.useRealTimers(); + }); + + test('立即调用并丢弃窗口内的事件,到期不补调,后续事件可立即执行', async () => { + const first = eventEmit(tavern_events.MESSAGE_RECEIVED, 1, 'normal'); + // 不推进计时器、不让出当前调用栈,处理应当已经开始。 + expect(mockReceive).toHaveBeenCalledWith(1, { signal: expect.any(AbortSignal) }); + await first; + + await jest.advanceTimersByTimeAsync(1000); + await eventEmit(tavern_events.MESSAGE_RECEIVED, 2, 'normal'); + await jest.advanceTimersByTimeAsync(1000); + await eventEmit(tavern_events.MESSAGE_RECEIVED, 3, 'normal'); + await jest.advanceTimersByTimeAsync(1000); + expect(mockReceive).toHaveBeenCalledTimes(1); + + const next = eventEmit(tavern_events.MESSAGE_RECEIVED, 4, 'normal'); + expect(mockReceive).toHaveBeenCalledTimes(2); + expect(mockReceive).toHaveBeenLastCalledWith(4, { signal: expect.any(AbortSignal) }); + await next; + await jest.advanceTimersByTimeAsync(3000); + expect(mockReceive).toHaveBeenCalledTimes(2); + }); + + test('宿主事件等待当次处理的 Promise,并收到其异常', async () => { + const pending = Promise.withResolvers(); + mockReceive.mockReturnValueOnce(pending.promise); + let settled = false; + const emitted = eventEmit(tavern_events.MESSAGE_RECEIVED, 1, 'normal').finally(() => { + settled = true; + }); + const error = new Error('receive failed'); + const assertion = expect(emitted).rejects.toBe(error); + + await Promise.resolve(); + expect(mockReceive).toHaveBeenCalledTimes(1); + expect(settled).toBe(false); + + pending.reject(error); + await assertion; + expect(settled).toBe(true); + await jest.advanceTimersByTimeAsync(3000); + expect(mockReceive).toHaveBeenCalledTimes(1); + }); +}); From fc4071ada86d8873d9507cb149bb25b1216cf2f8 Mon Sep 17 00:00:00 2001 From: magicalastrogy Date: Mon, 19 Oct 2026 05:40:43 +0800 Subject: [PATCH 3/3] fix: show toast when automatic update conditions are not met --- src/function/update/on_message_received.ts | 2 ++ tests/on_message_received_conditions.test.ts | 1 + 2 files changed, 3 insertions(+) diff --git a/src/function/update/on_message_received.ts b/src/function/update/on_message_received.ts index 3ab4d8d..1d6369d 100644 --- a/src/function/update/on_message_received.ts +++ b/src/function/update/on_message_received.ts @@ -76,6 +76,7 @@ export async function onMessageReceived( Array.from(message_content.trim()).length < settings.最小请求字数 ) { console.log(tr('runtime.extraModel.belowMinLengthLog')); + toastr.info(tr('runtime.extraModel.belowMinLengthLog')); return; } const trigger_tag = compileRegex(settings.触发标签); @@ -85,6 +86,7 @@ export async function onMessageReceived( } if (trigger_tag.regex && !trigger_tag.regex.test(message_content)) { console.log(tr('runtime.extraModel.triggerTagMissingLog')); + toastr.info(tr('runtime.extraModel.triggerTagMissingLog')); return; } } diff --git a/tests/on_message_received_conditions.test.ts b/tests/on_message_received_conditions.test.ts index 3eb737e..c62166e 100644 --- a/tests/on_message_received_conditions.test.ts +++ b/tests/on_message_received_conditions.test.ts @@ -21,6 +21,7 @@ const mockHandleVariables = jest.mocked(handleVariablesInMessage); describe('自动请求条件', () => { beforeEach(() => { jest.clearAllMocks(); + (globalThis as any).toastr = { info: jest.fn() }; Object.assign(SillyTavern, { extensionSettings: {}, name2: 'Assistant',