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
5 changes: 5 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,3 +1,8 @@
# 2026-06-20
## 体验改进
- 现在支持调整在额外模型解析过程中,提供的消息楼层数。默认为 2。
- 增加了应答格式 `格式化输出(v4兼容)`, 用于 官deepseek-v4 等渠道。(注:如果同时使用其他包含额外解析的插件,可能会有兼容性问题)
-
# 2026-04-25
## 体验改进
- 现在额外模型解析支持指定当前环境下的已有预设,代表使用那个预设的提示词结构,api/预设脚本/正则 等依然使用当前预设/手动指定的。
Expand Down
182 changes: 172 additions & 10 deletions src/function/update/invoke_extra_model.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,15 +21,143 @@ import { useDataStore } from '@/store';
import { normalizeBaseURL } from '@/util';
import { literalYamlify, uuidv4 } from '@util/common';
import { compare } from 'compare-versions';
import YAML from 'yaml';

//测试用,为了使首次请求必失败
let debug_extra_request_counter = 0;

const V4_COMPATIBLE_FORMATTED_OUTPUT = '格式化输出(v4兼容)';
const JSON_OBJECT_CUSTOM_INCLUDE_BODY = Object.freeze({
response_format: {
type: 'json_object',
},
});
const DISABLED_THINKING_CUSTOM_INCLUDE_BODY = Object.freeze({
thinking: {
type: 'disabled',
},
});

function generateRandomHeader(): string {
return _.times(4, () => uuidv4().slice(0, 8)).join('\n');
}

function setExtraAnalysisStates() {
function isV4CompatibleFormattedOutput(): boolean {
return useDataStore().settings.额外模型解析配置.应答格式 === V4_COMPATIBLE_FORMATTED_OUTPUT;
}

function assertV4CompatibleFormattedOutputUsable() {
const store = useDataStore();
if (
store.settings.额外模型解析配置.应答格式 === V4_COMPATIBLE_FORMATTED_OUTPUT &&
store.settings.额外模型解析配置.模型来源 === '与插头相同'
) {
throw new Error(
'[MVU额外模型解析]格式化输出(v4兼容)需要额外模型来源为自定义,不能与插头相同。'
);
}
}

function isPlainObject(value: unknown): value is Record<string, unknown> {
return typeof value === 'object' && value !== null && !Array.isArray(value);
}

function parseCustomIncludeBody(body: unknown): Record<string, unknown> {
if (typeof body !== 'string' || body.trim() === '') {
return {};
}

const parsed = YAML.parse(body);
if (isPlainObject(parsed)) {
return parsed;
}
if (Array.isArray(parsed)) {
return Object.assign({}, ...parsed.filter(isPlainObject));
}
throw new Error('[MVU额外模型解析]custom_include_body 不是 YAML object,无法合并配置。');
}

function buildJsonObjectCustomIncludeBody(original_body: unknown): string {
const store = useDataStore();
return YAML.stringify({
...parseCustomIncludeBody(original_body),
...JSON_OBJECT_CUSTOM_INCLUDE_BODY,
...(store.settings.额外模型解析配置.关闭thinking
? DISABLED_THINKING_CUSTOM_INCLUDE_BODY
: {}),
}).trimEnd();
}

async function saveSillyTavernSettings() {
const save_settings =
typeof builtin === 'undefined' ? undefined : builtin.saveSettings.bind(builtin);
if (typeof save_settings !== 'function') {
throw new Error('[MVU额外模型解析]无法获取 SillyTavern saveSettings,不能临时更新配置。');
}
await save_settings();
}

let temporary_json_object_response_format_state: {
had_original_body: boolean;
original_body: unknown;
} | null = null;

async function setTemporaryJsonObjectResponseFormat() {
if (!isV4CompatibleFormattedOutput()) {
temporary_json_object_response_format_state = null;
return;
}

assertV4CompatibleFormattedOutputUsable();
const oai_settings = SillyTavern.chatCompletionSettings;
if (!isPlainObject(oai_settings)) {
throw new Error('[MVU额外模型解析]无法获取 SillyTavern OpenAI 设置。');
}

const had_original_body = Object.prototype.hasOwnProperty.call(
oai_settings,
'custom_include_body'
);
const original_body = oai_settings.custom_include_body;
oai_settings.custom_include_body = buildJsonObjectCustomIncludeBody(original_body);
try {
await saveSillyTavernSettings();
Comment on lines +122 to +124

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Avoid applying json_object to unrelated generations

When v4-compatible extra analysis overlaps with a normal Custom/OpenAI-compatible generation, this assignment writes response_format: json_object into global SillyTavern.chatCompletionSettings and persists it before the extra request completes. Any unrelated request started before restoreTemporaryJsonObjectResponseFormat() runs will inherit the JSON-only response format and can return JSON/error instead of the user-facing prose; keep this override scoped to the extra-model request rather than global settings.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

这没办法。

temporary_json_object_response_format_state = {
had_original_body,
original_body,
};
} catch (error) {
if (had_original_body) {
oai_settings.custom_include_body = original_body;
} else {
delete oai_settings.custom_include_body;
}
temporary_json_object_response_format_state = null;
throw error;
}
}

async function restoreTemporaryJsonObjectResponseFormat() {
if (!temporary_json_object_response_format_state) {
return;
}

const oai_settings = SillyTavern.chatCompletionSettings;
if (!isPlainObject(oai_settings)) {
throw new Error('[MVU额外模型解析]无法获取 SillyTavern OpenAI 设置,不能恢复配置。');
}

const { had_original_body, original_body } = temporary_json_object_response_format_state;
temporary_json_object_response_format_state = null;

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Keep restore state until settings save succeeds

If saveSettings() fails while restoring after a successful temporary save, this line has already dropped the only copy of the original custom_include_body, so the persisted settings can remain stuck with response_format: json_object and later reloads keep affecting normal generations. Keep the state until the restore save succeeds (or restore it in a catch) so a failure can be retried.

Useful? React with 👍 / 👎.

if (had_original_body) {
oai_settings.custom_include_body = original_body;
} else {
delete oai_settings.custom_include_body;
}
await saveSillyTavernSettings();
}

async function setExtraAnalysisStates() {
const store = useDataStore();

if (store.runtimes.is_during_extra_analysis === true) {
Expand All @@ -41,15 +169,25 @@ function setExtraAnalysisStates() {
//因为这个操作是幂等的,所以无所谓。

store.runtimes.is_during_extra_analysis = true;
try {
await setTemporaryJsonObjectResponseFormat();
} catch (error) {
store.runtimes.is_during_extra_analysis = false;
throw error;
}
}

function unsetExtraAnalysisStates() {
async function unsetExtraAnalysisStates() {
const store = useDataStore();

SillyTavern.unregisterMacro('lastUserMessage');
clearExtraModelRequestOverrides();
store.runtimes.is_during_extra_analysis = false;
store.runtimes.is_function_call_enabled = false;
try {
await restoreTemporaryJsonObjectResponseFormat();
} finally {
store.runtimes.is_during_extra_analysis = false;
}
}

let is_analysis_in_progress = false;
Expand Down Expand Up @@ -78,28 +216,36 @@ export async function invokeExtraModelWithStrategy(): Promise<string | null> {
is_manual_canceled: boolean;
}> => {
let is_manual_canceled = false;
let did_set_extra_analysis_states = false;
try {
setExtraAnalysisStates();
await setExtraAnalysisStates();
did_set_extra_analysis_states = true;
return { result: await recordedInvoke(), is_manual_canceled: false };
} catch (e) {
/** 已经记录, 忽略 */
if (e === 'Clicked stop button') is_manual_canceled = true;
} finally {
unsetExtraAnalysisStates();
if (did_set_extra_analysis_states) {
await unsetExtraAnalysisStates();
}
}
return { result: null, is_manual_canceled: is_manual_canceled };
};
const concurrentInvoke = async (times: number) => {
const uuids = _.times(times, uuidv4);
let did_set_extra_analysis_states = false;
try {
setExtraAnalysisStates();
await setExtraAnalysisStates();
did_set_extra_analysis_states = true;
//在函数调用的模式下,允许接受 **任意** 有效的函数结果,因此被允许被覆盖。
return await Promise.any(uuids.map(recordedInvoke));
} catch (e) {
/** 已经记录, 忽略 */
} finally {
uuids.forEach(stopGenerationById);
unsetExtraAnalysisStates();
if (did_set_extra_analysis_states) {
await unsetExtraAnalysisStates();
}
}
return null;
};
Expand Down Expand Up @@ -152,6 +298,8 @@ export async function invokeExtraModelWithStrategy(): Promise<string | null> {
);
}
return concurrentInvoke(store.settings.额外模型解析配置.请求次数 - 1);
default:
return null;
}
} finally {
is_analysis_in_progress = false;
Expand All @@ -162,11 +310,15 @@ export async function invokeExtraModelWithStrategy(): Promise<string | null> {
* @brief 调用额外模型解析,可能会抛出异常。
*/
export async function generateExtraModel(): Promise<string | null> {
let did_set_extra_analysis_states = false;
try {
setExtraAnalysisStates();
await setExtraAnalysisStates();
did_set_extra_analysis_states = true;
return await invokeExtraModel();
} finally {
unsetExtraAnalysisStates();
if (did_set_extra_analysis_states) {
await unsetExtraAnalysisStates();
}
}
}

Expand Down Expand Up @@ -245,7 +397,7 @@ function normalizeGenerateResultByResponseFormat(
result: string | GenerateToolCallResult,
response_format: string
): string {
if (response_format === '格式化输出') {
if (response_format === '格式化输出' || response_format === V4_COMPATIBLE_FORMATTED_OUTPUT) {
const formatted = extractFromFormattedOutput(result);
if (formatted) {
return formatted;
Expand All @@ -257,6 +409,9 @@ function normalizeGenerateResultByResponseFormat(
async function requestReply(generation_id?: string, batch_id?: string): Promise<string> {
const store = useDataStore();
const response_format = store.settings.额外模型解析配置.应答格式;
const is_v4_compatible_formatted_output = response_format === V4_COMPATIBLE_FORMATTED_OUTPUT;

assertV4CompatibleFormattedOutputUsable();

const config: GenerateRawConfig = {
user_input: '遵循<must>指令',
Expand All @@ -280,6 +435,9 @@ async function requestReply(generation_id?: string, batch_id?: string): Promise<
top_p: unset_if_equal(store.settings.额外模型解析配置.top_p, 1),
top_k: unset_if_equal(store.settings.额外模型解析配置.top_k, 0),
};
if (is_v4_compatible_formatted_output) {
config.custom_api.source = 'custom';
}
}

let task = decoded_extra_model_task;
Expand All @@ -292,6 +450,10 @@ async function requestReply(generation_id?: string, batch_id?: string): Promise<
task +=
'\n You are in formatted-output mode. Do not output <UpdateVariable> tags, markdown, or prose. Return only a JSON object matching the provided json_schema: {"analysis":"...","json_patch":[...]}. Put MVU JsonPatch dialect operations in `json_patch`.';
config.json_schema = MVU_JSON_PATCH_RESPONSE_SCHEMA;
} else if (is_v4_compatible_formatted_output) {
task +=
'\n You are in formatted-output mode. Do not output <UpdateVariable> tags, markdown, or prose. Return only a JSON object: {"analysis":"...","json_patch":[...]}. Put MVU JsonPatch dialect operations in `json_patch`. Return exactly one JSON object that conforms to this schema:' +
JSON.stringify(MVU_JSON_PATCH_RESPONSE_SCHEMA.value);
}

//因为部分预设会用到 {{lastUserMessage}},因此进行修正。
Expand Down
30 changes: 28 additions & 2 deletions src/panel/update/Prompt.vue
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,18 @@
/>
</Field>

<Field
v-if="store.settings.额外模型解析配置.应答格式 === '格式化输出(v4兼容)'"
label="关闭thinking"
>
<template #label-suffix>
<HelpIcon help="关闭后会避免一部分空回状况。" />
</template>
<Checkbox v-model="store.settings.额外模型解析配置.关闭thinking">
<span>关闭</span>
</Checkbox>
</Field>

<Field label="兼容假流式">
<template #label-suffix>
<HelpIcon
Expand Down Expand Up @@ -82,8 +94,12 @@ watch(
);

watch(
() => store.settings.额外模型解析配置.应答格式,
value => {
() =>
[
store.settings.额外模型解析配置.应答格式,
store.settings.额外模型解析配置.模型来源,
] as const,
([value, model_source]) => {
if (value === '工具调用') {
const version_message = getFunctionCallingApiVersionUnsupportedMessage();
if (version_message) {
Expand All @@ -105,6 +121,16 @@ watch(
return;
}
}
if (value === '格式化输出(v4兼容)' && model_source === '与插头相同') {
toastr.error(
'格式化输出(v4兼容)需要额外模型来源为自定义,不能与插头相同',
"[MVU]无法使用'格式化输出(v4兼容)'",
{
timeOut: 5000,
}
);
store.settings.额外模型解析配置.应答格式 = '聊天消息';
}
}
);
</script>
4 changes: 3 additions & 1 deletion src/panel/update/prompt_toolcall.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,8 @@
calling。通常能减少正文干扰, 但不支持工具调用的模型或反代会报错或退化。
- **格式化输出**: 要求提供商支持 OpenAI 兼容的
`response_format.json_schema`。通常最适合 JsonPatch 变量更新, 因为返回会被约束为结构化 JSON。
- **格式化输出(v4兼容)**: 用于只支持 `response_format.type = json_object` 的渠道,如 dsv4f 等。
这个模式仅在额外模型来源为 **自定义** 时可用。

如果你的渠道明确支持 `response_format.json_schema`, 优先尝试 **格式化输出**。如果不支持, 改用
**聊天消息**;如果渠道支持 tools/function calling, 也可以尝试 **工具调用**。
**格式化输出(v4兼容)** 或 **聊天消息**;如果渠道支持 tools/function calling, 也可以尝试 **工具调用**。
8 changes: 7 additions & 1 deletion src/store.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,12 @@ import { defineStore } from 'pinia';
import { ref, toRaw, watch } from 'vue';
import * as z from 'zod';

export const EXTRA_MODEL_RESPONSE_FORMATS = ['聊天消息', '工具调用', '格式化输出'] as const;
export const EXTRA_MODEL_RESPONSE_FORMATS = [
'聊天消息',
'工具调用',
'格式化输出',
'格式化输出(v4兼容)',
] as const;

const ExtraModelResponseFormat = z.enum(EXTRA_MODEL_RESPONSE_FORMATS);

Expand Down Expand Up @@ -92,6 +97,7 @@ const NewSettings = z
其他预设名称: z.string().default(''),
使用函数调用: z.boolean().optional(),
应答格式: ExtraModelResponseFormat.optional(),
关闭thinking: z.boolean().default(false),
兼容假流式: z.boolean().default(false),

启用自动请求: z.boolean().default(true),
Expand Down
Loading
Loading