diff --git a/CHANGELOG.md b/CHANGELOG.md index e73a0e3..ceb4899 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,9 @@ +# 2026-06-22 +## 体验改进 + - 现在支持关闭内置破限的随机头部(使用Gemini系模型时,需要打开),以获得更好的缓存效果。 + - 现在支持使用 白名单/黑名单 筛选参与额外解析的世界书。 + - (请注意,这个功能主要面向玩家,角色卡作者请使用 `[mvu_update]` `[mvu_plot]` 达到相同的效果。 + # 2026-06-20 ## 体验改进 - 现在支持调整在额外模型解析过程中,提供的消息楼层数。默认为 2。 diff --git a/src/function/request/entry_comment_regex.ts b/src/function/request/entry_comment_regex.ts new file mode 100644 index 0000000..d7d6937 --- /dev/null +++ b/src/function/request/entry_comment_regex.ts @@ -0,0 +1,68 @@ +export type EntryCommentRegexCompileResult = { + regex?: RegExp; + error?: string; +}; + +export type EntryCommentFilterResult = { + lore: 'globalLore' | 'characterLore' | 'chatLore' | 'personaLore'; + world: string; + comment: string; + reason: '白名单' | '黑名单'; +}; + +export const ENTRY_COMMENT_FILTER_LOG_TITLE = '[MVU]世界书条目黑/白名单筛选结果'; + +export function logEntryCommentFilterResult(result: EntryCommentFilterResult[]) { + console.log( + ENTRY_COMMENT_FILTER_LOG_TITLE, + result.map(entry => ({ ...entry })) + ); +} + +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('JS 风格正则需要以 /pattern/flags 形式书写'); + } + 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/request/filter_entries.ts b/src/function/request/filter_entries.ts index d978952..7d97ef4 100644 --- a/src/function/request/filter_entries.ts +++ b/src/function/request/filter_entries.ts @@ -1,5 +1,11 @@ import { isExtraModelSupported } from '@/function/is_extra_model_supported'; import { isFunctionCallingSupported } from '@/function/is_function_calling_supported'; +import { + compileEntryCommentRegex, + EntryCommentFilterResult, + logEntryCommentFilterResult, + testEntryCommentRegex, +} from '@/function/request/entry_comment_regex'; import { useDataStore } from '@/store'; import { PLOT_REGEX, UPDATE_REGEX } from '@/variable_def'; @@ -11,6 +17,9 @@ export async function filterEntries(lores: { }) { const store = useDataStore(); store.runtimes.unsupported_warnings = ''; + if (store.runtimes.is_during_extra_analysis) { + store.runtimes.上次世界书条目过滤结果 = []; + } //在这个回调中,会将所有lore的条目传入,此处可以去除所有 [mvu_update] 相关的条目,避免在非更新的轮次中输出相关内容。 if (store.settings.更新方式 === '随AI输出') { @@ -75,4 +84,77 @@ export async function filterEntries(lores: { .value(); store.runtimes.unsupported_warnings = Array.from(removed_worlds).join(', '); + + if (!store.runtimes.is_during_extra_analysis) { + return; + } + + const compile_filter_regex = (label: string, value: string) => { + const result = compileEntryCommentRegex(value); + if (result.error) { + toastr.warning( + `${label}无效,已在本轮世界书条目过滤中忽略:${result.error}`, + '[MVU]世界书条目过滤正则无效', + { timeOut: 5000 } + ); + } + return result.regex; + }; + + const white_regex = compile_filter_regex( + '白名单正则', + store.settings.额外模型解析配置.世界书条目白名单正则 + ); + const black_regex = compile_filter_regex( + '黑名单正则', + store.settings.额外模型解析配置.世界书条目黑名单正则 + ); + + if (!white_regex && !black_regex) { + return; + } + + const filtered_entries: EntryCommentFilterResult[] = []; + + const get_comment_filter_reason = (entry: Record) => { + const comment = String(entry.comment ?? ''); + if (UPDATE_REGEX.test(comment)) { + return undefined; + } + if (white_regex && !testEntryCommentRegex(white_regex, comment)) { + return '白名单'; + } + if (black_regex && testEntryCommentRegex(black_regex, comment)) { + return '黑名单'; + } + return undefined; + }; + + const apply_comment_regex_filter = ( + lore_name: EntryCommentFilterResult['lore'], + lore: Record[] + ) => { + _.remove(lore, entry => { + const reason = get_comment_filter_reason(entry); + if (!reason) { + return false; + } + filtered_entries.push({ + lore: lore_name, + world: String(entry.world ?? ''), + comment: String(entry.comment ?? ''), + reason, + }); + return true; + }); + }; + apply_comment_regex_filter('characterLore', lores.characterLore); + apply_comment_regex_filter('globalLore', lores.globalLore); + apply_comment_regex_filter('chatLore', lores.chatLore); + apply_comment_regex_filter('personaLore', lores.personaLore); + + store.runtimes.上次世界书条目过滤结果 = filtered_entries; + if (filtered_entries.length > 0) { + logEntryCommentFilterResult(filtered_entries); + } } diff --git a/src/panel/update/Prompt.vue b/src/panel/update/Prompt.vue index 6b52f01..5f19679 100644 --- a/src/panel/update/Prompt.vue +++ b/src/panel/update/Prompt.vue @@ -51,12 +51,56 @@ 启用 + + + + +
+ {{ whitelist_regex_error }} +
+
+ + + + +
+ {{ blacklist_regex_error }} +
+
+ +
+ +
+ + diff --git a/src/panel/update/Request.vue b/src/panel/update/Request.vue index 358ece8..952d486 100644 --- a/src/panel/update/Request.vue +++ b/src/panel/update/Request.vue @@ -57,7 +57,10 @@ const store = useDataStore(); watch( () => store.settings.额外模型解析配置.请求方式, value => { - if (value !== '依次请求,失败后重试' && compare(store.versions.tavernhelper, '4.4.3', '<')) { + if ( + value !== '依次请求,失败后重试' && + compare(store.versions.tavernhelper, '4.4.3', '<') + ) { toastr.warning( '请升级酒馆助手到 4.4.3 或更高版本,否则批量请求功能可能让预设的「流式传输」设置失效', '[MVU]批量请求可能有问题', diff --git a/src/store.ts b/src/store.ts index e7881cc..c556930 100644 --- a/src/store.ts +++ b/src/store.ts @@ -109,6 +109,8 @@ const NewSettings = z ]) .default('依次请求,失败后重试'), 请求次数: z.number().default(3), + 世界书条目白名单正则: z.string().default(''), + 世界书条目黑名单正则: z.string().default(''), 模型来源: z.enum(['与插头相同', '自定义']).default('与插头相同'), api地址: z.string().default('http://localhost:1234/v1'), @@ -191,6 +193,16 @@ const Runtimes = z unsupported_warnings: z.string().default(''), is_during_extra_analysis: z.boolean().default(false), is_function_call_enabled: z.boolean().default(false), + 上次世界书条目过滤结果: z + .array( + z.object({ + lore: z.enum(['globalLore', 'characterLore', 'chatLore', 'personaLore']), + world: z.string(), + comment: z.string(), + reason: z.enum(['白名单', '黑名单']), + }) + ) + .default([]), debug: z .object({ 首次额外请求必失败: z.boolean().default(false), diff --git a/tests/prompt_filter.test.ts b/tests/prompt_filter.test.ts index 9736f51..5137f32 100644 --- a/tests/prompt_filter.test.ts +++ b/tests/prompt_filter.test.ts @@ -1,3 +1,4 @@ +import { ENTRY_COMMENT_FILTER_LOG_TITLE } from '@/function/request/entry_comment_regex'; import { filterEntries } from '@/function/request/filter_entries'; import { useDataStore } from '@/store'; @@ -7,15 +8,20 @@ const cloneEntries = (entries: Array<{ world: string; comment: string }>) => let mockGetCurrentCharPrimaryLorebook: jest.MockedFunction<() => string | undefined>; let mockGetLorebookEntries: jest.MockedFunction<(name: string) => Promise>; +let consoleLogSpy: jest.SpyInstance; describe('filterEntries', () => { beforeEach(() => { const store = useDataStore(); store.settings.更新方式 = '额外模型解析'; store.settings.额外模型解析配置.应答格式 = '聊天消息'; + store.settings.额外模型解析配置.世界书条目白名单正则 = ''; + store.settings.额外模型解析配置.世界书条目黑名单正则 = ''; store.runtimes.unsupported_warnings = ''; store.runtimes.is_during_extra_analysis = false; + store.runtimes.上次世界书条目过滤结果 = []; + consoleLogSpy = jest.spyOn(console, 'log').mockImplementation(() => undefined); (globalThis as any).toastr = { warning: jest.fn(), @@ -40,6 +46,7 @@ describe('filterEntries', () => { afterEach(() => { useDataStore().runtimes.is_during_extra_analysis = false; + consoleLogSpy.mockRestore(); }); // 场景: 更新方式为随AI输出时,不进行任何过滤处理 @@ -203,4 +210,252 @@ describe('filterEntries', () => { ]); expect(store.runtimes.unsupported_warnings).toBe('UntaggedWorld'); }); + + test('applies whitelist regex to entry comments during extra analysis', async () => { + const store = useDataStore(); + + store.runtimes.is_during_extra_analysis = true; + store.settings.额外模型解析配置.世界书条目白名单正则 = '角色A'; + + const lores = { + globalLore: [ + makeEntry('PlotWorld', '[mvu_plot]'), + makeEntry('PlotWorld', '角色A设定'), + makeEntry('PlotWorld', '地点B设定'), + ], + characterLore: [makeEntry('WorldA', '[mvu_update]')], + chatLore: [], + personaLore: [], + }; + + mockGetLorebookEntries.mockResolvedValue(cloneEntries(lores.characterLore)); + + await filterEntries(lores); + + expect(lores.globalLore).toEqual([makeEntry('PlotWorld', '角色A设定')]); + expect(store.runtimes.上次世界书条目过滤结果).toEqual([ + { + lore: 'globalLore', + world: 'PlotWorld', + comment: '地点B设定', + reason: '白名单', + }, + ]); + expect(consoleLogSpy).toHaveBeenCalledWith(ENTRY_COMMENT_FILTER_LOG_TITLE, [ + { + lore: 'globalLore', + world: 'PlotWorld', + comment: '地点B设定', + reason: '白名单', + }, + ]); + }); + + test('applies blacklist regex to entry comments during extra analysis', async () => { + const store = useDataStore(); + + store.runtimes.is_during_extra_analysis = true; + store.settings.额外模型解析配置.世界书条目黑名单正则 = '禁用|临时'; + + const lores = { + globalLore: [ + makeEntry('PlotWorld', '[mvu_plot]'), + makeEntry('PlotWorld', '常驻设定'), + makeEntry('PlotWorld', '临时设定'), + ], + characterLore: [makeEntry('WorldA', '[mvu_update]')], + chatLore: [], + personaLore: [], + }; + + mockGetLorebookEntries.mockResolvedValue(cloneEntries(lores.characterLore)); + + await filterEntries(lores); + + expect(lores.globalLore).toEqual([makeEntry('PlotWorld', '常驻设定')]); + }); + + test('requires whitelist match and blacklist miss when both regexes are configured', async () => { + const store = useDataStore(); + + store.runtimes.is_during_extra_analysis = true; + store.settings.额外模型解析配置.世界书条目白名单正则 = '角色|地点'; + store.settings.额外模型解析配置.世界书条目黑名单正则 = '地点'; + + const lores = { + globalLore: [ + makeEntry('PlotWorld', '[mvu_plot]'), + makeEntry('PlotWorld', '角色设定'), + makeEntry('PlotWorld', '地点设定'), + makeEntry('PlotWorld', '物品设定'), + ], + characterLore: [makeEntry('WorldA', '[mvu_update]')], + chatLore: [], + personaLore: [], + }; + + mockGetLorebookEntries.mockResolvedValue(cloneEntries(lores.characterLore)); + + await filterEntries(lores); + + expect(lores.globalLore).toEqual([makeEntry('PlotWorld', '角色设定')]); + expect(store.runtimes.上次世界书条目过滤结果).toEqual([ + { + lore: 'globalLore', + world: 'PlotWorld', + comment: '地点设定', + reason: '黑名单', + }, + { + lore: 'globalLore', + world: 'PlotWorld', + comment: '物品设定', + reason: '白名单', + }, + ]); + }); + + test('keeps empty last filter result and does not log when no entry is removed', async () => { + const store = useDataStore(); + + store.runtimes.is_during_extra_analysis = true; + store.runtimes.上次世界书条目过滤结果 = [ + { + lore: 'globalLore', + world: 'OldWorld', + comment: 'old', + reason: '黑名单', + }, + ]; + store.settings.额外模型解析配置.世界书条目白名单正则 = '角色'; + + const lores = { + globalLore: [makeEntry('PlotWorld', '[mvu_plot]'), makeEntry('PlotWorld', '角色设定')], + characterLore: [makeEntry('WorldA', '[mvu_update]')], + chatLore: [], + personaLore: [], + }; + + mockGetLorebookEntries.mockResolvedValue(cloneEntries(lores.characterLore)); + + await filterEntries(lores); + + expect(lores.globalLore).toEqual([makeEntry('PlotWorld', '角色设定')]); + expect(store.runtimes.上次世界书条目过滤结果).toEqual([]); + expect(consoleLogSpy).not.toHaveBeenCalledWith( + ENTRY_COMMENT_FILTER_LOG_TITLE, + expect.anything() + ); + }); + + test('lets update entries bypass comment whitelist and blacklist filters', async () => { + const store = useDataStore(); + + store.runtimes.is_during_extra_analysis = true; + store.settings.额外模型解析配置.世界书条目白名单正则 = '角色A'; + store.settings.额外模型解析配置.世界书条目黑名单正则 = 'mvu_update|禁用'; + + const lores = { + globalLore: [ + makeEntry('PlotWorld', '[mvu_plot]'), + makeEntry('PlotWorld', '[mvu_update] 禁用'), + makeEntry('PlotWorld', '禁用设定'), + ], + characterLore: [makeEntry('WorldA', '[mvu_update]')], + chatLore: [], + personaLore: [], + }; + + mockGetLorebookEntries.mockResolvedValue(cloneEntries(lores.characterLore)); + + await filterEntries(lores); + + expect(lores.globalLore).toEqual([makeEntry('PlotWorld', '[mvu_update] 禁用')]); + expect(lores.characterLore).toEqual([makeEntry('WorldA', '[mvu_update]')]); + }); + + test('does not apply comment whitelist and blacklist filters outside extra analysis', async () => { + const store = useDataStore(); + + store.runtimes.is_during_extra_analysis = false; + store.settings.额外模型解析配置.世界书条目白名单正则 = '保留'; + store.settings.额外模型解析配置.世界书条目黑名单正则 = '排除'; + + const lores = { + globalLore: [ + makeEntry('PlotWorld', '[mvu_plot]'), + makeEntry('PlotWorld', '排除设定'), + makeEntry('PlotWorld', '其他设定'), + ], + characterLore: [makeEntry('WorldA', '[mvu_plot]')], + chatLore: [], + personaLore: [], + }; + + mockGetLorebookEntries.mockResolvedValue(cloneEntries(lores.characterLore)); + + await filterEntries(lores); + + expect(lores.globalLore).toEqual([ + makeEntry('PlotWorld', '[mvu_plot]'), + makeEntry('PlotWorld', '排除设定'), + makeEntry('PlotWorld', '其他设定'), + ]); + }); + + test('supports JS-style slash regex with flags', async () => { + const store = useDataStore(); + + store.runtimes.is_during_extra_analysis = true; + store.settings.额外模型解析配置.世界书条目白名单正则 = '/ALLOWED/i'; + store.settings.额外模型解析配置.世界书条目黑名单正则 = '/drop/i'; + + const lores = { + globalLore: [ + makeEntry('PlotWorld', '[mvu_plot]'), + makeEntry('PlotWorld', 'allowed entry'), + makeEntry('PlotWorld', 'ALLOWED drop entry'), + makeEntry('PlotWorld', 'other entry'), + ], + characterLore: [makeEntry('WorldA', '[mvu_update]')], + chatLore: [], + personaLore: [], + }; + + mockGetLorebookEntries.mockResolvedValue(cloneEntries(lores.characterLore)); + + await filterEntries(lores); + + expect(lores.globalLore).toEqual([makeEntry('PlotWorld', 'allowed entry')]); + }); + + test('ignores invalid regexes without interrupting valid filters', async () => { + const store = useDataStore(); + + store.runtimes.is_during_extra_analysis = true; + store.settings.额外模型解析配置.世界书条目白名单正则 = '['; + store.settings.额外模型解析配置.世界书条目黑名单正则 = 'drop'; + + const lores = { + globalLore: [ + makeEntry('PlotWorld', '[mvu_plot]'), + makeEntry('PlotWorld', 'keep entry'), + makeEntry('PlotWorld', 'drop entry'), + ], + characterLore: [makeEntry('WorldA', '[mvu_update]')], + chatLore: [], + personaLore: [], + }; + + mockGetLorebookEntries.mockResolvedValue(cloneEntries(lores.characterLore)); + + await expect(filterEntries(lores)).resolves.toBeUndefined(); + + expect(lores.globalLore).toEqual([makeEntry('PlotWorld', 'keep entry')]); + expect((globalThis as any).toastr.warning).toHaveBeenCalledWith( + expect.stringContaining('白名单正则无效'), + '[MVU]世界书条目过滤正则无效', + { timeOut: 5000 } + ); + }); }); diff --git a/tests/store_response_format.test.ts b/tests/store_response_format.test.ts index a0995f5..6aa8c51 100644 --- a/tests/store_response_format.test.ts +++ b/tests/store_response_format.test.ts @@ -64,6 +64,29 @@ describe('extra model response format settings', () => { expect(store.settings.额外模型解析配置.max_chat_history).toBe(2); }); + test('defaults worldbook entry comment filters to disabled empty regexes', () => { + const store = useDataStore(); + + expect(store.settings.额外模型解析配置.世界书条目白名单正则).toBe(''); + expect(store.settings.额外模型解析配置.世界书条目黑名单正则).toBe(''); + }); + + test('keeps configured worldbook entry comment filters when loading settings', () => { + (globalThis as any).SillyTavern.extensionSettings = { + mvu_settings: { + 额外模型解析配置: { + 世界书条目白名单正则: '角色|地点', + 世界书条目黑名单正则: '/临时/i', + }, + }, + }; + + const store = useDataStore(); + + expect(store.settings.额外模型解析配置.世界书条目白名单正则).toBe('角色|地点'); + expect(store.settings.额外模型解析配置.世界书条目黑名单正则).toBe('/临时/i'); + }); + test('defaults v4 compatible thinking override to disabled state off', () => { const store = useDataStore();