Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
49 changes: 2 additions & 47 deletions src/function/request/entry_comment_regex.ts
Original file line number Diff line number Diff line change
@@ -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 = '用户全局配置' | '角色卡配置';

Expand All @@ -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);
Expand Down
5 changes: 4 additions & 1 deletion src/function/update/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 });
Comment thread
MagicalAstrogy marked this conversation as resolved.
Resolved
const stop_list: Array<() => void> = [];
stop_list.push(
controlledStoppableEventOn(tavern_events.MESSAGE_SENT, handleVariablesInMessage)
Expand Down
29 changes: 26 additions & 3 deletions src/function/update/on_message_received.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down Expand Up @@ -63,9 +64,31 @@ 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'));
toastr.info(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'));
toastr.info(tr('runtime.extraModel.triggerTagMissingLog'));
return;
}
}

if (!isCurrentMessage()) {
Expand Down
22 changes: 22 additions & 0 deletions src/i18n/messages/panel.ts
Original file line number Diff line number Diff line change
Expand Up @@ -431,6 +431,28 @@ export const panelMessages = defineMessages({
'关闭后,AI 回复完成时不会自动触发额外模型解析;你需要主动点击“重试额外模型解析”按钮,才会执行解析并添加状态栏占位符 &lt;StatusPlaceHolderImpl/&gt;。',
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 &lt;StatusPlaceHolderImpl/&gt;.',
},
'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 回复匹配此正则表达式才自动请求额外模型解析。可直接填写 &lt;/content&gt;,或用 &lt;/(content&#124;正文)&gt; 匹配多个标签;也支持 /pattern/flags 格式,例如 /content/i 忽略大小写。默认区分大小写,特殊字符需用反斜杠转义。忽略配置首尾空白,留空表示不限制;正则无效时跳过自动请求。若同时设置最小请求字数,两个条件都须满足。手动重试不受限制。',
en: 'Automatically request extra-model parsing only when the AI reply matches this regular expression. Enter &lt;/content&gt; directly, or use &lt;/(content&#124;正文)&gt; 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 或更高版本,否则批量请求功能可能让预设的“流式传输”设置失效',
Expand Down
12 changes: 12 additions & 0 deletions src/i18n/messages/runtime.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.',
Expand Down
53 changes: 53 additions & 0 deletions src/panel/update/Request.vue
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,40 @@
<Checkbox v-model="store.settings.额外模型解析配置.启用自动请求">
<span>{{ t('common.enabled') }}</span>
</Checkbox>
<div
v-if="store.effective_settings.额外模型解析配置.启用自动请求"
class="mvu-auto-request-conditions flex-container flexFlowColumn"
>
<Field :label="t('panel.request.minLength')">
<template #label-suffix>
<HelpIcon :help="t('panel.request.minLengthHelp')" />
</template>
<input
v-model.number="min_request_length"
class="text_pole"
type="number"
min="0"
step="1"
:aria-label="t('panel.request.minLength')"
/>
</Field>
<Field :label="t('panel.request.triggerTag')">
<template #label-suffix>
<HelpIcon :help="t('panel.request.triggerTagHelp')" />
</template>
<input
v-model="store.settings.额外模型解析配置.触发标签"
class="text_pole"
type="text"
placeholder="</content>"
:aria-label="t('panel.request.triggerTag')"
:aria-invalid="trigger_tag_error !== undefined"
/>
<small v-if="trigger_tag_error !== undefined" role="alert">
{{ t('panel.request.triggerTagInvalid', { error: trigger_tag_error }) }}
</small>
</Field>
</div>
</Field>
</Detail>
</template>
Expand All @@ -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
);
Expand Down Expand Up @@ -84,3 +131,9 @@ watch(
}
);
</script>

<style scoped>
.mvu-auto-request-conditions {
margin-left: 1rem;
}
</style>
6 changes: 6 additions & 0 deletions src/store.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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([
'依次请求,失败后重试',
Expand Down
49 changes: 49 additions & 0 deletions src/util/regex.ts
Original file line number Diff line number Diff line change
@@ -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) };
}
}
8 changes: 8 additions & 0 deletions tests/i18n.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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('&lt;/(content&#124;正文)&gt;');
});

// Pi 界面文案:组合来源标签与协议名称都有对应翻译。
test('translates provider labels and protocols used in combined source choices', () => {
locale_ref.value = 'zh-CN';
Expand Down
Loading
Loading