预设兼容:按真实抓包对齐装配与包装层,补上声明式工具与预设正则。

- 装配只认 prompt_order.enabled;squash_system_messages 只合并相邻 system(新增 system 档,连接符按酒馆用单换行)
- 包装层:new_chat_prompt、示例对话(new_example_chat_prompt + <START> 切段 + name)、角色卡三块与 personality_format/scenario_format、{{user}}/{{char}} 取值、任务契约并进预设条目
- 工具协议:按 ToolBindings / 伪工具协议数据声明真 tools(presetToolPolicy=preset),工具描述用到的条目不再重复作为消息发出
- 正则:promptOnly 作用于对话历史(含 depth 过滤)、写入时脚本改写回复;{{//}} 注释按配平扫描
- tools/st-request-diff:真假上游抓包对比工具、三份预设的机制结论与实测报告
This commit is contained in:
2026-10-23 00:13:08 +08:00
parent ed09e5c831
commit 9dc927282f
94 changed files with 11558 additions and 413 deletions
+125 -8
View File
@@ -2,12 +2,76 @@ import type { ChatMessage } from "../llm/client.js";
import type { PresetPackage } from "../types/preset.js";
import { isStUnfilledMarker } from "./markers.js";
export type MarkerResolver = (identifier: string) => string | null;
/**
* 打在「历史区」message 上的记号:真多轮的那一段。
* Symbol 属性不会进请求体,也不会进存档;`splitPresetHistory` 用它切段,
* 免得消息整形(squash)把历史轮次揉回一段。
*/
export const PRESET_HISTORY_MARK = Symbol.for("writingAgent.presetHistory");
/** 填洞结果:一段文本,或一段 message 序列(历史)。 */
export type MarkerFill = string | ChatMessage[] | null;
export type MarkerResolver = (identifier: string) => MarkerFill;
export type ContentExpander = (content: string) => string;
const defaultMarkerResolver: MarkerResolver = () => null;
const defaultContentExpander: ContentExpander = (c) => c;
function isMessageSequence(fill: MarkerFill): fill is ChatMessage[] {
return Array.isArray(fill);
}
type DepthInjection = {
depth: number;
order: number;
seq: number;
message: ChatMessage;
};
/** 历史区在 messages 里的下标范围(mark 过的连续段)。 */
function historyRange(messages: ChatMessage[]): { start: number; end: number } {
const hit = (m: ChatMessage) =>
Boolean((m as unknown as Record<symbol, unknown>)[PRESET_HISTORY_MARK]);
const first = messages.findIndex(hit);
if (first < 0) return { start: messages.length, end: messages.length };
let last = first;
for (let i = messages.length - 1; i > first; i--) {
if (hit(messages[i]!)) {
last = i;
break;
}
}
return { start: first, end: last + 1 };
}
/**
* `injection_position = 1`:按 `injection_depth` 从历史底部数往上插。
* depth 0 = 历史最后一条之后(紧贴 currentTurn 之前);depth 1 = 倒数第一条之前,以此类推。
* 同深度按 `injection_order` 升序排(大的更靠后,同酒馆)。
* 无历史区时(单包请求)直接按收集顺序附在末尾。
*/
function insertDepthInjections(
messages: ChatMessage[],
items: DepthInjection[],
): void {
if (items.length === 0) return;
const { start, end } = historyRange(messages);
const placed = items
.map((item) => ({
...item,
at: Math.max(start, end - Math.max(0, item.depth)),
}))
.sort((a, b) => a.at - b.at || a.order - b.order || a.seq - b.seq);
let inserted = 0;
for (const item of placed) {
const next = { ...item.message } as ChatMessage;
(next as unknown as Record<symbol, unknown>)[PRESET_HISTORY_MARK] = true;
messages.splice(item.at + inserted, 0, next);
inserted += 1;
}
}
/**
* 按 prompt_order 装配 preset 消息。
* 酒馆角色卡 / WI marker 不走 resolver(空洞跳过);自有洞由 resolver 填。
@@ -17,9 +81,12 @@ export function assemblePresetMessages(
preset: PresetPackage,
resolveMarker: MarkerResolver = defaultMarkerResolver,
contentExpander: ContentExpander = defaultContentExpander,
excludeEntryIds?: ReadonlySet<string>,
): ChatMessage[] {
const promptById = new Map(preset.prompts.map((p) => [p.id, p]));
const messages: ChatMessage[] = [];
const depthItems: DepthInjection[] = [];
let seq = 0;
const ordered = [...preset.promptOrder].sort(
(a, b) => a.orderIndex - b.orderIndex,
@@ -27,29 +94,79 @@ export function assemblePresetMessages(
for (const orderItem of ordered) {
if (!orderItem.enabled) continue;
if (excludeEntryIds?.has(orderItem.promptId)) continue;
const entry = promptById.get(orderItem.promptId);
if (!entry || !entry.enabled) continue;
// 酒馆只认 prompt_order.enabled;prompts[].enabled 是历史遗留字段(openai.js 从不读它)。
if (!entry) continue;
let content = entry.content.trim();
const markerId = entry.sourceIdentifier || entry.id;
const skipFill = isStUnfilledMarker(markerId);
let fill: MarkerFill = content;
if (!content && entry.marker && !skipFill) {
const resolved = resolveMarker(markerId);
if (resolved?.trim()) content = resolved.trim();
fill = resolveMarker(markerId);
}
if (!content) continue;
content = contentExpander(content);
if (isMessageSequence(fill)) {
// 历史洞:一条条目展开成多轮 message,各自保留角色。
for (const message of fill) {
const body = contentExpander(message.content ?? "").trim();
if (!body) continue;
const next = { ...message, content: body } as ChatMessage;
// 只保护真实对话轮(user / assistant):
// 无结构的历史会降级成一条 system,它仍然允许被 merge 整形。
if (next.role !== "system") {
(next as unknown as Record<symbol, unknown>)[PRESET_HISTORY_MARK] = true;
}
messages.push(next);
}
continue;
}
messages.push({
content = (fill ?? "").trim();
if (!content) continue;
// 展开后可能变空(只剩注释 / 只有 setvar):空条目不入 messages。
content = contentExpander(content).trim();
if (!content) continue;
const message: ChatMessage = {
role: entry.role,
content,
});
};
// 酒馆的「按深度插入」:不进线性位置,等历史区就绪后插进历史里。
if (entry.injection?.position === 1) {
depthItems.push({
depth: entry.injection.depth ?? 0,
order: entry.injection.order ?? 0,
seq: seq++,
message,
});
continue;
}
messages.push(message);
}
insertDepthInjections(messages, depthItems);
return messages;
}
/** 把历史区从装配结果里切出来(squash 只整形前后两段)。 */
export function splitPresetHistory(messages: ChatMessage[]): {
head: ChatMessage[];
history: ChatMessage[];
tail: ChatMessage[];
} {
const { start, end } = historyRange(messages);
if (start === end) return { head: messages, history: [], tail: [] };
return {
head: messages.slice(0, start),
history: messages.slice(start, end),
tail: messages.slice(end),
};
}
export function mergeMessages(
presetMessages: ChatMessage[],
taskMessages: ChatMessage[],
+546
View File
@@ -0,0 +1,546 @@
/**
* 导入诊断:把「这份预设在我们这儿能不能跑、哪一块会散」算出来给用户看。
* 纯文本扫描,不调用模型。
*/
import {
collectMacroUsages,
isRandomMacroName,
} from "../directives/macros.js";
import type {
PresetDiagnostics,
PresetPackage,
PresetPromptEntry,
} from "../types/preset.js";
import { CHAT_HISTORY_MARKER } from "./markers.js";
/** 伪工具协议的指纹:命中任意一条即认为这条目在“假装有一个工具”。 */
const TOOL_PROTOCOL_MARKERS = [
"%TOOL_NAME%",
"FUNCTION_CALL_ONLY",
"INVOKE_NOW",
"display_stream",
"tool_call",
"function_call",
"output_channel",
"target_sink",
];
const PLACEHOLDER_RE = /%[A-Z][A-Z0-9_]*%|dispatch_stream_payload_[0-9a-f]+/g;
/** 标签:`<tag ...>` / `</tag>`;支持中文标签名(`<对话历史>`)。自闭合与 `->` `=>` 不算。 */
const TAG_RE =
/<(\/?)([A-Za-z\u4e00-\u9fff][\w.:\-\u4e00-\u9fff]*)([^<>]*?)(\/?)>/g;
export function orderedEnabledEntries(
preset: PresetPackage,
): PresetPromptEntry[] {
const byId = new Map(preset.prompts.map((p) => [p.id, p]));
const out: PresetPromptEntry[] = [];
for (const item of [...preset.promptOrder].sort(
(a, b) => a.orderIndex - b.orderIndex,
)) {
if (!item.enabled) continue;
const entry = byId.get(item.promptId);
if (!entry) continue;
out.push(entry);
}
return out;
}
function entryLabel(entry: PresetPromptEntry): string {
return entry.name.trim() || entry.sourceIdentifier || entry.id;
}
/** marker 洞的正文要运行时才填,导入期看到的是空串,跳过它们。 */
function contentEntries(preset: PresetPackage): PresetPromptEntry[] {
return orderedEnabledEntries(preset).filter((e) => e.content.trim());
}
function buildMacroUsage(preset: PresetPackage): PresetDiagnostics["macros"] {
const byName = new Map<
string,
{ name: string; known: boolean; count: number; entries: string[] }
>();
for (const entry of contentEntries(preset)) {
for (const usage of collectMacroUsages(entry.content)) {
const existing = byName.get(usage.name);
if (existing) {
existing.count += usage.count;
existing.entries.push(entryLabel(entry));
} else {
byName.set(usage.name, {
name: usage.name,
known: usage.known,
count: usage.count,
entries: [entryLabel(entry)],
});
}
}
}
return [...byName.values()].sort((a, b) => b.count - a.count);
}
type ParsedToolBinding = {
entryId: string;
toolName: string;
params: string[];
description: string;
};
/** 从 `SPreset.ToolBindings[pick].form` 读:酒馆已经把工具定义结构化存了一份。 */
function parseToolBindings(raw: Record<string, unknown> | null): ParsedToolBinding[] {
const extensions = raw?.extensions as Record<string, unknown> | undefined;
const spreset = extensions?.SPreset as Record<string, unknown> | undefined;
const bindings = spreset?.ToolBindings;
if (!bindings || typeof bindings !== "object") return [];
const out: ParsedToolBinding[] = [];
for (const [entryId, value] of Object.entries(
bindings as Record<string, unknown>,
)) {
if (!value || typeof value !== "object") continue;
const binding = value as Record<string, unknown>;
const form = binding.form as Record<string, unknown> | undefined;
const toolName =
(typeof form?.name === "string" && form.name.trim()) ||
(typeof binding.resolvedName === "string" && binding.resolvedName.trim()) ||
"";
if (!toolName) continue;
const params: string[] = [];
const formParams = form?.parameters;
if (Array.isArray(formParams)) {
for (const p of formParams) {
if (p && typeof p === "object") {
const name = (p as Record<string, unknown>).name;
if (typeof name === "string" && name.trim()) params.push(name.trim());
}
}
}
const description =
typeof form?.description === "string" ? form.description.trim() : "";
out.push({ entryId, toolName, params, description });
}
return out;
}
function buildToolProtocol(
preset: PresetPackage,
raw: Record<string, unknown> | null,
): PresetDiagnostics["toolProtocol"] {
const entries: string[] = [];
const placeholders = new Set<string>();
const entryLabels = new Map<string, string>();
for (const entry of contentEntries(preset)) {
entryLabels.set(entry.id, entryLabel(entry));
const content = entry.content;
const hit = TOOL_PROTOCOL_MARKERS.some((m) => content.includes(m));
if (!hit) continue;
entries.push(entryLabel(entry));
for (const match of content.matchAll(PLACEHOLDER_RE)) {
placeholders.add(match[0]);
}
}
const declarations: string[] = [];
const extensions = raw?.extensions as
| Record<string, unknown>
| undefined;
const spreset = extensions?.SPreset as Record<string, unknown> | undefined;
const bindings = parseToolBindings(raw).map((b) => {
const entry = entryLabels.get(b.entryId) ?? "(未启用的条目)";
// 绑定的条目本身就是在要求“调这个工具”,算伪工具协议条目
if (entryLabels.has(b.entryId) && !entries.includes(entry)) {
entries.push(entry);
}
return {
entry,
toolName: b.toolName,
params: b.params,
description: b.description,
};
});
if (spreset) {
if (bindings.length > 0) {
declarations.push(
`SPreset.ToolBindings(${bindings.map((b) => b.toolName).join("、")})`,
);
}
const preprocessing = spreset.OutputPreprocessing as
| Record<string, unknown>
| undefined;
if (preprocessing?.enabled === true) {
declarations.push("SPreset.OutputPreprocessing(改写工具调用)");
}
const formatter = preprocessing?.toolCallFormatter as
| Record<string, unknown>
| undefined;
if (formatter?.enabled === true) {
declarations.push("SPreset.OutputPreprocessing.toolCallFormatter");
}
}
if (raw?.function_calling === true) {
declarations.push("function_calling = true");
}
return {
entries,
placeholders: [...placeholders],
declarations,
bindings,
};
}
type OpenTag = { tag: string; entry: string };
function buildTagPairing(preset: PresetPackage): PresetDiagnostics["tags"] {
const crossEntry: PresetDiagnostics["tags"]["crossEntry"] = [];
const orphanClose: PresetDiagnostics["tags"]["orphanClose"] = [];
const stack: OpenTag[] = [];
for (const entry of contentEntries(preset)) {
const label = entryLabel(entry);
for (const match of entry.content.matchAll(TAG_RE)) {
const closing = match[1] === "/";
const tag = match[2]!.toLowerCase();
const selfClosing = match[4] === "/";
if (selfClosing) continue;
if (!closing) {
stack.push({ tag, entry: label });
continue;
}
let found = -1;
for (let i = stack.length - 1; i >= 0; i--) {
if (stack[i]!.tag === tag) {
found = i;
break;
}
}
if (found < 0) {
orphanClose.push({ tag, closeIn: label });
continue;
}
const open = stack.splice(found, 1)[0]!;
if (open.entry !== label) {
crossEntry.push({ tag, openIn: open.entry, closeIn: label });
}
}
}
return {
crossEntry,
unclosed: stack.map((o) => ({ tag: o.tag, openIn: o.entry })),
orphanClose,
};
}
function buildRandomMacros(
preset: PresetPackage,
): PresetDiagnostics["randomMacros"] {
const out: PresetDiagnostics["randomMacros"] = [];
const historyOrder = preset.promptOrder.find(
(o) => o.promptId === CHAT_HISTORY_MARKER,
)?.orderIndex;
for (const entry of contentEntries(preset)) {
const usages = collectMacroUsages(entry.content).filter((u) =>
isRandomMacroName(u.name),
);
if (usages.length === 0) continue;
const orderIndex = preset.promptOrder.find(
(o) => o.promptId === entry.id,
)?.orderIndex;
// 噪声排在 chatHistory 之后就不额外破坏前缀缓存。
const afterHistory =
historyOrder !== undefined &&
orderIndex !== undefined &&
orderIndex > historyOrder;
for (const usage of usages) {
out.push({
entry: entryLabel(entry),
macro: usage.name,
count: usage.count,
afterHistory,
});
}
}
return out;
}
type RawRegex = {
id?: string;
scriptName?: string;
placement?: number[];
promptOnly?: boolean;
markdownOnly?: boolean;
disabled?: boolean;
};
function collectRawRegexes(raw: Record<string, unknown> | null): RawRegex[] {
if (!raw) return [];
const out: RawRegex[] = [];
const extensions = raw.extensions as Record<string, unknown> | undefined;
const push = (value: unknown): void => {
if (!Array.isArray(value)) return;
for (const item of value) {
if (item && typeof item === "object") out.push(item as RawRegex);
}
};
push(raw.regex_scripts);
push(extensions?.regex_scripts);
const spreset = extensions?.SPreset as Record<string, unknown> | undefined;
const binding = spreset?.RegexBinding as Record<string, unknown> | undefined;
push(binding?.regexes);
// 去重:SPreset 常把同一批正则存两份
const seen = new Set<string>();
return out.filter((r) => {
const key = r.id ?? `${r.scriptName ?? ""}`;
if (seen.has(key)) return false;
seen.add(key);
return true;
});
}
/** 脚本里能认出来的能力,以及本版本是否已有替代。 */
const SCRIPT_FEATURES: Array<{
label: string;
test: RegExp;
replaced: boolean;
}> = [
{
label: "注册酒馆前端工具",
test: /registerFunctionTool|ToolManager|SillyTavern\./,
replaced: true, // toolPolicy=adapt 能把工具协议接到已有工具上
},
{
label: "注入合成工具定义",
test: /function_call|functionCall|tools\s*:/,
replaced: true,
},
{
label: "改写请求 / 拦截 fetch",
test: /fetch\s*\(|rewindow|XMLHttpRequest/,
replaced: false,
},
{
label: "流式解包 / 防截断",
test: /event-stream|SSE|ReadableStream|getReader\s*\(/,
replaced: false,
},
{
label: "监听酒馆事件",
test: /eventOn\s*\(|eventOnce\s*\(|tavern_events/,
replaced: false,
},
];
function detectScriptFeatures(content: string): string[] {
const out: string[] = [];
for (const feature of SCRIPT_FEATURES) {
if (feature.test.test(content)) out.push(feature.label);
}
return out;
}
const DOM_CALL_RE = /document\.\w+|querySelector(?:All)?|innerHTML|createElement|addEventListener/g;
/** 大量 DOM 操作的多半是界面面板(如 NVWA 的 1.8MB 面板),不该报“没替代的能力”。 */
function looksLikeUiPanel(content: string): boolean {
return (content.match(DOM_CALL_RE) ?? []).length > 50;
}
function unportedFeatures(features: string[]): string[] {
return features.filter(
(label) => !SCRIPT_FEATURES.find((f) => f.label === label)?.replaced,
);
}
function buildScripts(
raw: Record<string, unknown> | null,
): PresetDiagnostics["scripts"] {
const extensions = raw?.extensions as Record<string, unknown> | undefined;
const helper = extensions?.tavern_helper as
| { scripts?: unknown }
| undefined;
const scripts = helper?.scripts;
if (!Array.isArray(scripts)) return [];
const out: PresetDiagnostics["scripts"] = [];
for (const item of scripts) {
if (!item || typeof item !== "object") continue;
const s = item as Record<string, unknown>;
const content = typeof s.content === "string" ? s.content : "";
out.push({
name: typeof s.name === "string" ? s.name : "(未命名脚本)",
kind: typeof s.type === "string" ? s.type : "script",
bytes: Buffer.byteLength(content, "utf8"),
features: detectScriptFeatures(content),
isUiPanel: looksLikeUiPanel(content),
});
}
return out;
}
/** `injection_position=1` 的启用条目:现在会按 `injection_depth` 插进历史区。 */
function buildDeepInjection(
preset: PresetPackage,
raw: Record<string, unknown> | null,
): string[] {
if (!raw) return [];
const prompts = raw.prompts;
if (!Array.isArray(prompts)) return [];
const sent = new Set(
contentEntries(preset).map((e) => e.sourceIdentifier || e.id),
);
const out: string[] = [];
for (const item of prompts) {
if (!item || typeof item !== "object") continue;
const p = item as Record<string, unknown>;
if (p.injection_position !== 1) continue;
// prompts[].enabled 不作数(酒馆只看 prompt_order),不再拿它过滤。
const identifier = typeof p.identifier === "string" ? p.identifier : "";
if (identifier && !sent.has(identifier)) continue;
const name =
typeof p.name === "string" && p.name.trim()
? p.name.trim()
: identifier || "(未命名条目)";
out.push(name);
}
return out;
}
export function buildPresetDiagnostics(
preset: PresetPackage,
rawInput?: unknown,
): PresetDiagnostics {
const raw =
rawInput && typeof rawInput === "object"
? (rawInput as Record<string, unknown>)
: null;
const regexes = collectRawRegexes(raw).map((r) => ({
name: r.scriptName ?? r.id ?? "(未命名正则)",
placement: Array.isArray(r.placement) ? r.placement : [],
promptOnly: r.promptOnly === true,
markdownOnly: r.markdownOnly === true,
disabled: r.disabled === true,
}));
const scripts = buildScripts(raw);
return {
macros: buildMacroUsage(preset),
toolProtocol: buildToolProtocol(preset, raw),
tags: buildTagPairing(preset),
randomMacros: buildRandomMacros(preset),
regexes,
scripts,
silentGaps: {
deepInjection: buildDeepInjection(preset, raw),
unportedScriptFeatures: scripts
.filter((s) => !s.isUiPanel)
.map((s) => ({ script: s.name, features: unportedFeatures(s.features) }))
.filter((s) => s.features.length > 0),
},
};
}
/** 把诊断压成几句人能读的告警。 */
export function diagnosticsWarnings(d: PresetDiagnostics): string[] {
const out: string[] = [];
const unknown = d.macros.filter((m) => !m.known);
if (unknown.length > 0) {
out.push(
`未识别宏 ${unknown.length} 个将原样发出:${unknown
.map((m) => `${m.name}×${m.count}`)
.join("、")}`,
);
}
if (d.toolProtocol.entries.length > 0) {
const ph = d.toolProtocol.placeholders.length
? `;待映射占位符 ${d.toolProtocol.placeholders.join("、")}`
: "";
const bound = d.toolProtocol.bindings.length
? `;已声明工具 ${d.toolProtocol.bindings
.map((b) => `${b.toolName}(${b.params.join("/")})`)
.join("、")}`
: "";
out.push(
`伪工具协议 ${d.toolProtocol.entries.length} 条(${d.toolProtocol.entries
.slice(0, 6)
.join("、")})${ph}${bound}`,
);
}
if (d.tags.crossEntry.length > 0) {
const sample = d.tags.crossEntry
.slice(0, 4)
.map((t) => `<${t.tag}>`)
.join("、");
out.push(`跨条目标签配对 ${d.tags.crossEntry.length} 处(${sample}),合并消息会打散`);
}
if (d.tags.unclosed.length > 0) {
out.push(
`装配结束后仍未闭合的标签:${d.tags.unclosed
.slice(0, 6)
.map((t) => `<${t.tag}>`)
.join("、")}`,
);
}
const randomBefore = d.randomMacros.filter((r) => !r.afterHistory);
if (randomBefore.length > 0) {
const total = randomBefore.reduce((n, r) => n + r.count, 0);
out.push(
`随机宏 ${total} 处位于对话历史之前,会让之后的提示缓存每轮失效`,
);
}
if (d.regexes.length > 0) {
const displayOnly = d.regexes.filter(
(r) => r.markdownOnly && !r.disabled,
).length;
out.push(
`正则 ${d.regexes.length} 条已记录但不会执行(当前版本不实现 ST 正则引擎)${
displayOnly > 0
? `;其中 ${displayOnly} 条是显示层规则,正文里会直接露出它们本来要隐藏的标签`
: ""
}`,
);
}
if (d.scripts.length > 0) {
out.push(
`脚本 ${d.scripts.length} 个不会执行:${d.scripts
.map(
(s) =>
`${s.name}(${Math.round(s.bytes / 1024)}KB${s.isUiPanel ? ",界面面板" : ""})`,
)
.join("、")}`,
);
}
if (d.silentGaps.deepInjection.length > 0) {
out.push(
`深度注入 ${d.silentGaps.deepInjection.length} 条(${d.silentGaps.deepInjection
.slice(0, 4)
.join("、")}):按 injection_depth 插进历史区(depth 0 = 历史末尾,紧贴本轮输入之前)`,
);
}
for (const item of d.silentGaps.unportedScriptFeatures) {
out.push(
`脚本「${item.script}」需要${item.features.join("、")}的能力,本版本没有替代`,
);
}
return out;
}
+2 -1
View File
@@ -33,7 +33,8 @@ function toView(
orderEnabled: boolean,
): PresetEntryView {
const content = entry.content ?? "";
const enabled = orderEnabled && entry.enabled;
// 跟酒馆一致:只看 prompt_order 的开关;prompts[].enabled 不参与。
const enabled = orderEnabled;
const trimmed = content.trim();
return {
orderIndex,
+69
View File
@@ -0,0 +1,69 @@
import type { ChatMessage } from "../llm/client.js";
/** 酒馆给示例对话消息打的 name(不是角色名)。 */
export const EXAMPLE_USER_NAME = "example_user";
export const EXAMPLE_ASSISTANT_NAME = "example_assistant";
/**
* 把酒馆 `mes_example` 切成示例消息(对应 `openai.js` 的 `setOpenAIMessageExamples` +
* `parseExampleIntoIndividual`):
* - 按 `<START>` 分块;
* - 块内以 `用户名:` / `角色名:` 开头的行切换说话人,其余行算续行;
* - 每条消息 role 都是 system,name 是 example_user / example_assistant,
* 正文去掉 `名字:` 前缀。
* 传进来的 text 应该已经展开过宏(`{{user}}` / `{{char}}`),否则名字对不上。
*/
export function parseExampleDialogue(params: {
text: string;
userName: string;
charName: string;
}): ChatMessage[][] {
const text = (params.text ?? "").replace(/\r/g, "");
if (!text.trim()) return [];
const userName = params.userName.trim();
const charName = params.charName.trim();
const blocks = text
.split(/<START>/i)
.map((block) => block.trim())
.filter(Boolean);
const source = blocks.length > 0 ? blocks : [text.trim()];
const out: ChatMessage[][] = [];
for (const block of source) {
const messages: ChatMessage[] = [];
let speaker: "user" | "assistant" | null = null;
let lines: string[] = [];
const flush = () => {
const content = lines.join("\n").trim();
if (speaker && content) {
messages.push({
role: "system",
content,
name: speaker === "user" ? EXAMPLE_USER_NAME : EXAMPLE_ASSISTANT_NAME,
});
}
lines = [];
};
for (const rawLine of block.split("\n")) {
const line = rawLine.replace(/\r$/, "");
if (userName && line.startsWith(`${userName}:`)) {
flush();
speaker = "user";
lines.push(line.slice(userName.length + 1));
continue;
}
if (charName && line.startsWith(`${charName}:`)) {
flush();
speaker = "assistant";
lines.push(line.slice(charName.length + 1));
continue;
}
lines.push(line);
}
flush();
if (messages.length > 0) out.push(messages);
}
return out;
}
+128 -12
View File
@@ -1,6 +1,9 @@
import { randomUUID } from "node:crypto";
import type {
GenerationParameters,
MessageSquashMode,
PresetChatPrompts,
PresetFormats,
PresetImportReport,
PresetPackage,
PresetPromptEntry,
@@ -8,6 +11,7 @@ import type {
UnsupportedPresetSection,
} from "../types/preset.js";
import { applyExtendedMarkers } from "./markers.js";
import { buildPresetDiagnostics, diagnosticsWarnings } from "./diagnostics.js";
type StPrompt = {
identifier?: string;
@@ -45,11 +49,67 @@ type SillyTavernPreset = {
n?: number;
prompts?: StPrompt[];
prompt_order?: StPromptOrderBlock[];
squash_system_messages?: unknown;
regex_scripts?: unknown;
extensions?: unknown;
[key: string]: unknown;
};
const SQUASH_MODES: MessageSquashMode[] = [
"none",
"merge",
"semi",
"strict",
"single-user-turn",
"system",
];
/**
* `squash_system_messages`:旧版是 bool,新版是枚举/序号。
* 字段缺失或不可识别时返回 undefined —— 「跟预设走」就是原样发(none)。
*/
export function readSquashMode(value: unknown): MessageSquashMode | undefined {
if (typeof value === "boolean") return value ? "system" : "none";
if (typeof value === "number") return SQUASH_MODES[value];
if (typeof value === "string") {
const text = value.trim().toLowerCase();
const hit = SQUASH_MODES.find((m) => m === text);
if (hit) return hit;
if (text === "true") return "system";
if (text === "false") return "none";
}
return undefined;
}
/** new_chat_prompt / new_example_chat_prompt 这几条:酒馆按位置插进历史与示例位。 */
function readChatPrompts(raw: SillyTavernPreset): PresetChatPrompts | undefined {
const pick = (key: string): string | undefined => {
const value = raw[key];
return typeof value === "string" && value.trim() ? value.trim() : undefined;
};
const prompts: PresetChatPrompts = {
newChat: pick("new_chat_prompt"),
newGroupChat: pick("new_group_chat_prompt"),
newExampleChat: pick("new_example_chat_prompt"),
continueNudge: pick("continue_nudge_prompt"),
};
return Object.values(prompts).some(Boolean) ? prompts : undefined;
}
/** `personality_format` / `scenario_format` / `wi_format`:酒馆用来包裹角色卡字段的模板。 */
function readFormats(raw: SillyTavernPreset): PresetFormats | undefined {
const pick = (key: string): string | undefined => {
const value = raw[key];
return typeof value === "string" && value.trim() ? value : undefined;
};
const formats: PresetFormats = {
personality: pick("personality_format"),
scenario: pick("scenario_format"),
worldInfo: pick("wi_format"),
};
return Object.values(formats).some(Boolean) ? formats : undefined;
}
function slugify(name: string): string {
return name
.toLowerCase()
@@ -83,6 +143,35 @@ function readGeneration(raw: SillyTavernPreset): GenerationParameters {
};
}
/** 酒馆里「通用于所有角色」的那一套 prompt_order。 */
export const ST_SHARED_ORDER_CHARACTER_ID = 100001;
/** 一套装配顺序跑得起来的前提:这三个位置得在。 */
export const CORE_ORDER_IDENTIFIERS = ["main", "chatHistory", "jailbreak"] as const;
function scoreOrderBlock(
block: StPromptOrderBlock,
promptMap: Map<string, StPrompt>,
): number {
let score = 0;
for (const item of block.order ?? []) {
if (!item.enabled) continue;
const prompt = promptMap.get(item.identifier);
if (!prompt) continue;
// prompt_order 里没开的不进上下文,这里也不该给它加分。
// 酒馆不读 prompts[].enabled,别拿它给顺序打分。
if (prompt.content?.trim()) score += 3;
else if (prompt.marker) score += 1;
else score += 1;
}
return score;
}
/**
* 选一套 prompt_order。
* 酒馆存在多套(按 character_id 分);100001 是通用那套,优先用它,
* 只有它不存在时才按「启用内容最多」打分。
*/
function selectPromptOrder(
raw: SillyTavernPreset,
promptMap: Map<string, StPrompt>,
@@ -91,18 +180,15 @@ function selectPromptOrder(
if (orders.length === 0) return { order: [] };
if (orders.length === 1) return orders[0];
const shared = orders.find(
(b) => b.character_id === ST_SHARED_ORDER_CHARACTER_ID,
);
if (shared) return shared;
let best = orders[0];
let bestScore = -1;
for (const block of orders) {
let score = 0;
for (const item of block.order ?? []) {
if (!item.enabled) continue;
const prompt = promptMap.get(item.identifier);
if (!prompt) continue;
if (prompt.content?.trim()) score += 3;
else if (prompt.marker) score += 0;
else score += 1;
}
const score = scoreOrderBlock(block, promptMap);
if (score > bestScore) {
bestScore = score;
best = block;
@@ -111,6 +197,17 @@ function selectPromptOrder(
return best;
}
/** 选中的那套顺序里缺了哪些核心位置。 */
export function missingCoreOrderIdentifiers(
block: StPromptOrderBlock,
): string[] {
const present = new Set<string>();
for (const item of block.order ?? []) {
if (item.enabled) present.add(item.identifier);
}
return CORE_ORDER_IDENTIFIERS.filter((id) => !present.has(id));
}
function collectUnsupported(raw: SillyTavernPreset): UnsupportedPresetSection[] {
const unsupported: UnsupportedPresetSection[] = [];
if (raw.regex_scripts) {
@@ -148,7 +245,13 @@ export function importSillyTavernPreset(
const selectedOrder = selectPromptOrder(raw, promptMap);
if ((raw.prompt_order?.length ?? 0) > 1) {
warnings.push(
`检测到 ${raw.prompt_order!.length} 套 prompt_order,已自动选用启用内容最多的一套 (character_id=${selectedOrder.character_id ?? "?"})`,
`检测到 ${raw.prompt_order!.length} 套 prompt_order,已选用 character_id=${selectedOrder.character_id ?? "?"} 的一套`,
);
}
const missingCore = missingCoreOrderIdentifiers(selectedOrder);
if (missingCore.length > 0) {
warnings.push(
`选用的 prompt_order 缺少 ${missingCore.join(" / ")},预设的框架可能不完整`,
);
}
@@ -157,7 +260,7 @@ export function importSillyTavernPreset(
return {
id: sourceId,
name: p.name ?? sourceId,
enabled: p.enabled !== false,
enabled: true, // 酒馆装配只看 prompt_order.enabled;prompts[].enabled 是历史遗留字段
role: normalizeRole(p.role),
content: p.content ?? "",
marker: Boolean(p.marker),
@@ -200,6 +303,9 @@ export function importSillyTavernPreset(
? (rawInput as { name: string }).name
: "导入的预设");
const squashMode = readSquashMode(raw.squash_system_messages);
const chatPrompts = readChatPrompts(raw);
const formats = readFormats(raw);
const preset: PresetPackage = applyExtendedMarkers({
id: options.id ?? `${slugify(presetName) || "preset"}-${randomUUID().slice(0, 8)}`,
name: presetName,
@@ -207,6 +313,9 @@ export function importSillyTavernPreset(
prompts,
promptOrder,
generation,
...(chatPrompts ? { chatPrompts } : {}),
...(formats ? { formats } : {}),
...(squashMode ? { messagePolicy: { squash: squashMode } } : {}),
unsupported: collectUnsupported(raw),
importedAt: new Date().toISOString(),
raw: rawInput,
@@ -219,9 +328,12 @@ export function importSillyTavernPreset(
const framedEnabled = preset.promptOrder.filter((o) => {
if (!o.enabled) return false;
const entry = preset.prompts.find((p) => p.id === o.promptId);
return entry?.enabled !== false;
return Boolean(entry);
}).length;
const diagnostics = buildPresetDiagnostics(preset, rawInput);
warnings.push(...diagnosticsWarnings(diagnostics));
return {
preset,
promptCount: preset.prompts.length,
@@ -230,5 +342,9 @@ export function importSillyTavernPreset(
missingIdentifiers,
generationFields,
warnings,
orderCharacterId: selectedOrder.character_id ?? null,
missingCoreIdentifiers: missingCore,
diagnostics,
};
}
+28 -13
View File
@@ -6,17 +6,22 @@ import type {
} from "../types/preset.js";
/** 酒馆角色卡 / WI marker:导入后保留位置;personaDescription 由当前用户角色填。 */
export const ST_UNFILLED_MARKERS = new Set([
"worldInfoBefore",
"charDescription",
"charPersonality",
"scenario",
"worldInfoAfter",
"dialogueExamples",
]);
/** 酒馆的 worldInfoBefore / worldInfoAfter 现在由 pack 填,集合留空(保留判定入口)。 */
export const ST_UNFILLED_MARKERS = new Set<string>([]);
export const WORLD_INFO_BEFORE_MARKER = "worldInfoBefore";
export const WORLD_INFO_AFTER_MARKER = "worldInfoAfter";
export const PERSONA_DESCRIPTION_MARKER = "personaDescription";
/** 酒馆示例对话洞:new_example_chat_prompt + mes_example 切出来的多段。 */
export const DIALOGUE_EXAMPLES_MARKER = "dialogueExamples";
/** 角色卡三块:由直聊槽位填,不再当"酒馆未填洞"跳过。 */
export const CHAR_DESCRIPTION_MARKER = "charDescription";
export const CHAR_PERSONALITY_MARKER = "charPersonality";
export const SCENARIO_MARKER = "scenario";
export const CHAT_HISTORY_MARKER = "chatHistory";
export const WORLD_BOOK_BEFORE = "worldBookBefore";
export const WORLD_BOOK_AFTER = "worldBookAfter";
@@ -89,20 +94,30 @@ export function applyExtendedMarkers(preset: PresetPackage): PresetPackage {
const histIndex = rawOrder.findIndex(
(item) => item.promptId === CHAT_HISTORY_MARKER,
);
// 预设自带酒馆的 worldInfoBefore / worldInfoAfter(并启用)时,不插自有的世界书洞:
// 内容直接填进预设那两条,位置上才对得上酒馆。
const hasStBefore = rawOrder.some(
(item) => item.promptId === WORLD_INFO_BEFORE_MARKER && item.enabled,
);
const hasStAfter = rawOrder.some(
(item) => item.promptId === WORLD_INFO_AFTER_MARKER && item.enabled,
);
const inserted = (id: string): PresetPromptOrderItem => ({
promptId: id,
enabled: true,
orderIndex: 0,
});
const beforeMarkers = hasStBefore ? [] : [inserted(WORLD_BOOK_BEFORE)];
const afterMarkers = hasStAfter ? [] : [inserted(WORLD_BOOK_AFTER)];
let next: PresetPromptOrderItem[];
if (histIndex < 0) {
next = reindex([
...rawOrder,
inserted(WORLD_BOOK_BEFORE),
...beforeMarkers,
inserted(CHAT_HISTORY_MARKER),
inserted(WORLD_BOOK_AFTER),
...afterMarkers,
inserted(CURRENT_TURN),
inserted(POST_TURN),
]);
@@ -112,9 +127,9 @@ export function applyExtendedMarkers(preset: PresetPackage): PresetPackage {
const after = rawOrder.slice(histIndex + 1);
next = reindex([
...before,
inserted(WORLD_BOOK_BEFORE),
...beforeMarkers,
history,
inserted(WORLD_BOOK_AFTER),
...afterMarkers,
inserted(CURRENT_TURN),
...after,
inserted(POST_TURN),
+228 -36
View File
@@ -1,43 +1,90 @@
import { expandIdentityDirectives } from "../directives/expand.js";
import type { ChatMessage } from "../llm/client.js";
import type { ChatMessage } from "../llm/client.js";
import {
markSquashExempt,
mergeAdjacentSameRole,
squashMessages,
} from "../llm/message-squash.js";
import type { MessageSquashMode } from "../types/preset.js";
import { expandPresetDirectives } from "../directives/expand.js";
import { createMacroVars } from "../directives/macros.js";
import {
getActivePersona,
type PersonaDirective,
} from "../persona/store.js";
import type { PresetPackage } from "../types/preset.js";
import { assemblePresetMessages } from "./assembler.js";
import { assemblePresetMessages, splitPresetHistory } from "./assembler.js";
import {
applyExtendedMarkers,
CHAR_DESCRIPTION_MARKER,
CHAR_PERSONALITY_MARKER,
CHAT_HISTORY_MARKER,
DIALOGUE_EXAMPLES_MARKER,
PERSONA_DESCRIPTION_MARKER,
SCENARIO_MARKER,
} from "./markers.js";
import { parseExampleDialogue } from "./examples.js";
import {
applyRegexScripts,
readRegexScripts,
REGEX_PLACEMENT,
type PresetRegexScript,
} from "./regex-scripts.js";
import {
resolveWorldInfoMarker,
type WorldInfoEntry,
type WorldInfoPack,
} from "./world-info-pack.js";
/** `{{char}}` 没给值时的兜底;`{{user}}` 兜底与酒馆默认用户名一致。 */
const PRESET_CHAR_FALLBACK = "作品";
const DEFAULT_USER_NAME = "User";
/** 酒馆预设里系统提示那一条的 identifier。 */
const MAIN_PROMPT_ID = "main";
const TASK_SYSTEM_ENTRY_ID = "task-system";
const TASK_SYSTEM_ENTRY_NAME = "任务契约";
function withTaskSystem(
/**
* 任务契约(上下文壳里的系统提示)不再单独发一条消息,按酒馆的条目顺序找地方放:
* 1. 预设启用了 `main`(酒馆的系统提示条目)且已有正文 → 追加到它末尾;
* 2. 否则并进世界书(预设带 `worldInfoBefore` 时正好落在那一 条里);
* 3. 都没有才退回自有世界书洞(老行为)。
*/
function attachTaskSystem(
preset: PresetPackage,
pack: WorldInfoPack,
systemPrompt: string,
): WorldInfoPack {
): { preset: PresetPackage; pack: WorldInfoPack } {
const text = systemPrompt.trim();
if (!text) return pack;
if (!text) return { preset, pack };
const mainEnabled = preset.promptOrder.some(
(item) => item.promptId === MAIN_PROMPT_ID && item.enabled,
);
const mainEntry = preset.prompts.find((p) => p.id === MAIN_PROMPT_ID);
if (mainEnabled && mainEntry && mainEntry.content.trim()) {
const prompts = preset.prompts.map((p) =>
p.id === MAIN_PROMPT_ID
? { ...p, content: `${p.content.trim()}\n\n${text}` }
: p,
);
return { preset: { ...preset, prompts }, pack };
}
const entry: WorldInfoEntry = {
id: TASK_SYSTEM_ENTRY_ID,
name: TASK_SYSTEM_ENTRY_NAME,
content: text,
};
return {
...pack,
worldBookBefore: [entry, ...pack.worldBookBefore],
preset,
pack: { ...pack, worldBookBefore: [entry, ...pack.worldBookBefore] },
};
}
/**
* 严格按扩展 prompt_order 填洞。任务契约进 worldBookBefore,不在预设外另挂 system。
* 「对话.历史」进 chatHistory 洞时展开成真多轮 message,不拍平成一段文本。
*/
export function assemblePlayWorkerMessages(params: {
systemPrompt: string;
@@ -45,29 +92,152 @@ export function assemblePlayWorkerMessages(params: {
pack: WorldInfoPack;
/** 缺省用当前选用(试跑 / 未绑会话)。创作期应传入创作默认人设。 */
persona?: PersonaDirective | null;
/** `{{char}}` 的取值(书名 / 主角名);缺省回落「作品」。 */
charName?: string | null;
/** 装配时排除的条目(`toolPolicy = app` 时用来剔掉伪工具协议)。 */
excludeEntryIds?: ReadonlySet<string>;
}): ChatMessage[] {
const framed = applyExtendedMarkers(params.preset);
const pack = withTaskSystem(params.pack, params.systemPrompt);
const attached = attachTaskSystem(
params.preset,
params.pack,
params.systemPrompt,
);
const framed = applyExtendedMarkers(attached.preset);
const pack = attached.pack;
const persona =
params.persona !== undefined ? params.persona : getActivePersona();
const dirCtx = persona
? { persona: { name: persona.name, description: persona.description } }
: {};
// 一份变量表跨本次装配全部条目:`{{setvar}}` 写在前面,`{{getvar}}` 在后面就能读到。
const vars = createMacroVars();
const dirCtx = {
persona: persona
? { name: persona.name, description: persona.description }
: null,
charName: params.charName ?? null,
lastUserMessage: pack.turn,
vars,
};
const charNameForFill = params.charName?.trim() || PRESET_CHAR_FALLBACK;
// 示例对话的宏要在切段前展开:`{{user}}:` 这种前缀得先变成真名字才认得出来。
const exampleUserName = persona?.name?.trim() || DEFAULT_USER_NAME;
const exampleCtx = {
...dirCtx,
charName: charNameForFill,
persona: { name: exampleUserName, description: persona?.description ?? "" },
};
return assemblePresetMessages(
framed,
(id) => {
// 酒馆:人设描述为空就不发这条洞(不塞「姓名:X」占位)。
if (id === PERSONA_DESCRIPTION_MARKER) {
const desc = persona?.description?.trim();
if (desc) return desc;
const name = persona?.name?.trim();
return name ? `姓名:${name}` : null;
return persona?.description?.trim() || null;
}
if (id === CHAT_HISTORY_MARKER) {
// 酒馆把 new_chat_prompt 放在历史最前面;没有历史时就是它单独一条。
const lead = params.preset.chatPrompts?.newChat?.trim();
const head: ChatMessage[] = lead
? [
markSquashExempt({
role: "system",
content: expandPresetDirectives(lead, dirCtx).text,
}),
]
: [];
const scripts = readRegexScripts(params.preset.raw);
const history = regexHistoryMessages(pack.history, scripts, (text) =>
expandPresetDirectives(text, dirCtx).text,
);
const all = head.length > 0 ? [...head, ...history] : history;
return all.length > 0 ? all : null;
}
// 角色卡三块:性格与场景按预设的 format 包一层(酒馆的 personality_format / scenario_format)。
if (id === CHAR_DESCRIPTION_MARKER) {
return pack.card?.description?.trim() || null;
}
if (id === CHAR_PERSONALITY_MARKER) {
const text = pack.card?.personality?.trim();
if (!text) return null;
return wrapWithFormat(params.preset.formats?.personality, "personality", text);
}
if (id === SCENARIO_MARKER) {
const text = pack.card?.scenario?.trim();
if (!text) return null;
return wrapWithFormat(params.preset.formats?.scenario, "scenario", text);
}
if (id === DIALOGUE_EXAMPLES_MARKER) {
const rawExamples = (pack.exampleDialogue ?? "").trim();
if (!rawExamples) return null;
const expanded = expandPresetDirectives(rawExamples, exampleCtx).text;
const blocks = parseExampleDialogue({
text: expanded,
userName: exampleUserName,
charName: charNameForFill,
});
if (blocks.length === 0) return null;
// 酒馆每块示例前面都插一条 new_example_chat_prompt。
const lead = params.preset.chatPrompts?.newExampleChat?.trim();
const leadText = lead ? expandPresetDirectives(lead, dirCtx).text.trim() : "";
const out: ChatMessage[] = [];
for (const block of blocks) {
if (leadText) {
out.push(markSquashExempt({ role: "system", content: leadText }));
}
out.push(...block);
}
return out;
}
return resolveWorldInfoMarker(pack, id);
},
(content) => expandIdentityDirectives(content, dirCtx).text,
// marker 填进来的是我们的正文,也要过一遍宏(世界书条目里可能有 {{user}})。
(content) => expandPresetDirectives(content, dirCtx).text,
params.excludeEntryIds,
);
}
/**
* 酒馆的"发送前正则"只作用于**对话历史**:user→placement 1、assistant→placement 2、
* 思维链→6,depth 是离历史末尾的距离。这里只处理 user / assistant。
*/
function regexHistoryMessages(
history: ChatMessage[],
scripts: PresetRegexScript[],
expand: (text: string) => string,
): ChatMessage[] {
if (scripts.length === 0 || history.length === 0) return history;
return history.map((message, index) => {
const text = typeof message.content === "string" ? message.content : "";
if (!text) return message;
const placement =
message.role === "user"
? REGEX_PLACEMENT.USER_INPUT
: message.role === "assistant"
? REGEX_PLACEMENT.AI_OUTPUT
: null;
if (placement === null) return message;
const depth = history.length - index - 1;
const next = applyRegexScripts(text, scripts, {
placement,
direction: "prompt",
depth,
expand,
});
return next === text ? message : { ...message, content: next };
});
}
/** 酒馆的 `personality_format` 之类:模板里用 `{{personality}}` 占位,没有占位就原样发。 */
function wrapWithFormat(
format: string | undefined,
key: string,
value: string,
): string {
const text = format?.trim();
if (!text) return value;
const token = `{{${key}}}`;
if (!text.includes(token)) return value;
return text.split(token).join(value);
}
function isToolLoopBoundary(m: ChatMessage): boolean {
if (m.role === "tool") return true;
return Boolean(
@@ -75,29 +245,17 @@ function isToolLoopBoundary(m: ChatMessage): boolean {
);
}
function formatHistoryMessages(msgs: ChatMessage[]): string {
const parts: string[] = [];
for (const m of msgs) {
if (m.role === "user" && m.content.trim()) {
parts.push(`【用户】\n${m.content.trim()}`);
} else if (m.role === "assistant") {
const text = (m.content ?? "").trim();
if (text) parts.push(`【助手】\n${text}`);
} else if (m.role === "system" && m.content.trim()) {
parts.push(`【系统】\n${m.content.trim()}`);
}
}
return parts.join("\n\n");
}
/**
* 任务 messages → 只填四袋 + chatHistory;发出序列的首尾由预设 prompt_order 决定。
* 末条之前的正文按原角色进历史区(真多轮),末条 user 进 currentTurn。
* tool 循环尾接在夹心后。
*/
export function frameMessagesWithPreset(
preset: PresetPackage,
messages: ChatMessage[],
persona?: PersonaDirective | null,
charName?: string | null,
excludeEntryIds?: ReadonlySet<string>,
): ChatMessage[] {
let splitAt = messages.length;
for (let i = 0; i < messages.length; i++) {
@@ -117,14 +275,14 @@ export function frameMessagesWithPreset(
}
let turn = "";
let history = "";
let history: ChatMessage[] = [];
if (body.length > 0) {
const last = body[body.length - 1]!;
if (last.role === "user") {
turn = last.content.trim();
history = formatHistoryMessages(body.slice(0, -1));
history = body.slice(0, -1);
} else {
history = formatHistoryMessages(body);
history = body;
}
}
@@ -139,7 +297,41 @@ export function frameMessagesWithPreset(
postTurn: [],
},
persona,
charName,
excludeEntryIds,
});
return continuation.length > 0 ? [...framed, ...continuation] : framed;
}
/** 历史区切片:消息整形只作用于前后两段,历史轮次原样保留。 */
export function splitFramedHistory(messages: ChatMessage[]) {
return splitPresetHistory(messages);
}
/**
* 消息整形(squash)入口。
*
* 历史区(真多轮)也参与整形,但只用“合并相邻同角色”这一档:
* - 正常情况下历史本来就是 user/assistant 交替,不动;
* - 用户连发两条(上一轮助手没出正文)时合并成一条,不破结构;
* - `injection_depth` 插进历史的 system 条目也不会被折掉。
* 史前 / 史后两段才用完整模式(`semi` / `strict` 会把 system 并进相邻消息);
* `single-user-turn` 不能堆历史,只能堆前后两段;`system` 整条序列只做 system 合并。
*/
export function shapeFramedMessages(
messages: ChatMessage[],
mode: MessageSquashMode,
): ChatMessage[] {
if (mode === "none") return messages;
const { head, history, tail } = splitPresetHistory(messages);
if (history.length === 0) return squashMessages(messages, mode);
if (mode === "merge" || mode === "system") return squashMessages(messages, mode);
const keepHistory =
mode === "single-user-turn" ? history : mergeAdjacentSameRole(history);
return [
...squashMessages(head, mode),
...keepHistory,
...squashMessages(tail, mode),
];
}
+62 -8
View File
@@ -1,6 +1,9 @@
import type { ChatMessage } from "../llm/client.js";
import type { PresetPackage } from "../types/preset.js";
import { assemblePlayWorkerMessages } from "./play-frame.js";
import { parseHistoryMessages } from "../skills/dialogue-history.js";
import type { MessageSquashMode, PresetPackage } from "../types/preset.js";
import { DEFAULT_PRESET_SQUASH } from "../types/preset.js";
import { assemblePlayWorkerMessages, shapeFramedMessages } from "./play-frame.js";
import { resolveToolPlan } from "./tool-policy.js";
import type { WorldInfoPack } from "./world-info-pack.js";
/** 试跑窗口用的短契约,不是 play worker SKILL。 */
@@ -34,6 +37,21 @@ export type PresetProbeResult = {
completed: boolean;
skipReason?: string;
error?: string;
/** 本次实际采用的消息整形模式 */
squashMode?: MessageSquashMode;
/** 预设要求的工具协议(用于确认破限是否真能接上) */
toolPlan?: {
present: boolean;
targetName: string | null;
payloadFields: string[];
};
};
export type PresetProbeOptions = {
/** `{{char}}` 的取值 */
charName?: string | null;
/** 消息整形模式;缺省用预设自己的 */
squash?: MessageSquashMode;
};
function optionalEntry(
@@ -49,13 +67,19 @@ export function worldInfoPackFromProbeInput(
input: PresetProbeInput,
): WorldInfoPack {
const loreBefore = input.loreBefore ?? DEFAULT_PROBE_CONTEXT.loreBefore;
const history = input.history ?? DEFAULT_PROBE_CONTEXT.history;
const rawHistory = input.history ?? DEFAULT_PROBE_CONTEXT.history;
const loreAfter = input.loreAfter ?? DEFAULT_PROBE_CONTEXT.loreAfter;
const postTurn = input.postTurn ?? DEFAULT_PROBE_CONTEXT.postTurn;
const history = parseHistoryMessages(rawHistory);
return {
worldBookBefore: optionalEntry("probe-before", "试跑设定", loreBefore),
history: history.trim(),
history:
history.length > 0
? history
: rawHistory.trim()
? [{ role: "system", content: rawHistory.trim() }]
: [],
worldBookAfter: optionalEntry("probe-after", "本轮状态", loreAfter),
turn: input.message.trim(),
postTurn: optionalEntry("probe-post", "尾注", postTurn),
@@ -66,12 +90,17 @@ export function worldInfoPackFromProbeInput(
export function assemblePresetProbe(
preset: PresetPackage,
input: PresetProbeInput,
options: PresetProbeOptions = {},
): ChatMessage[] {
return assemblePlayWorkerMessages({
const messages = assemblePlayWorkerMessages({
systemPrompt: PROBE_SYSTEM_PROMPT,
preset,
pack: worldInfoPackFromProbeInput(input),
charName: options.charName ?? null,
});
const mode =
options.squash ?? preset.messagePolicy?.squash ?? DEFAULT_PRESET_SQUASH;
return shapeFramedMessages(messages, mode);
}
/** 交给 wrapLlmForSession 的任务消息(未夹心)。 */
@@ -83,7 +112,10 @@ export function buildPresetProbeTaskMessages(
for (const e of pack.worldBookBefore) {
parts.push(`### ${e.name}\n\n${e.content}`);
}
if (pack.history.trim()) parts.push(pack.history.trim());
for (const m of pack.history) {
const text = typeof m.content === "string" ? m.content.trim() : "";
if (text) parts.push(text);
}
for (const e of pack.worldBookAfter) {
parts.push(`### ${e.name}\n\n${e.content}`);
}
@@ -112,8 +144,10 @@ export function serializeProbeMessages(
export async function runPresetProbe(params: {
preset: PresetPackage;
input: PresetProbeInput;
options?: PresetProbeOptions;
complete?: (
messages: ChatMessage[],
pack: WorldInfoPack,
) => Promise<{ content: string; reasoning?: string }>;
}): Promise<PresetProbeResult> {
const message = params.input.message.trim();
@@ -122,13 +156,28 @@ export async function runPresetProbe(params: {
}
const input = { ...params.input, message };
const options = params.options ?? {};
const plan = resolveToolPlan(params.preset);
const meta = {
squashMode:
options.squash ??
params.preset.messagePolicy?.squash ??
DEFAULT_PRESET_SQUASH,
toolPlan: {
present: plan.present,
targetName: plan.targetName,
payloadFields: plan.payloadFields,
},
} satisfies Partial<PresetProbeResult>;
// UI 仍展示「理想分袋夹心」;发模型只交任务消息,由统一包装层夹心
const serialized = serializeProbeMessages(
assemblePresetProbe(params.preset, input),
assemblePresetProbe(params.preset, input, options),
);
if (!params.complete) {
return {
...meta,
messages: serialized,
reply: null,
reasoning: null,
@@ -138,8 +187,12 @@ export async function runPresetProbe(params: {
}
try {
const result = await params.complete(buildPresetProbeTaskMessages(input));
const result = await params.complete(
buildPresetProbeTaskMessages(input),
worldInfoPackFromProbeInput(input),
);
return {
...meta,
messages: serialized,
reply: result.content,
reasoning: result.reasoning ?? null,
@@ -147,6 +200,7 @@ export async function runPresetProbe(params: {
};
} catch (err) {
return {
...meta,
messages: serialized,
reply: null,
reasoning: null,
+260
View File
@@ -0,0 +1,260 @@
/**
* 预设自带的正则脚本(酒馆 `regex_scripts`)。
*
* 纯数据驱动:`findRegex` / `replaceString` / `placement` / `promptOnly` / `markdownOnly`
* 都写在预设里,这里只做等价执行,不跑预设里的 JS。
*
* 酒馆的三类(见 `extensions/regex/engine.js`):
* - 发送前(`isPrompt`):只跑 `promptOnly` 的脚本,作用于**对话历史**(user=1、assistant=2、思维链=6),带 depth 过滤;
* - 展示(`isMarkdown`):只跑 `markdownOnly` 的脚本,只影响界面;
* - 写入时(两个 flag 都不是 true):在消息进 chat 的那一刻改写文本本身。
*/
/** `placement`:作用于哪一类文本(与酒馆一致)。 */
export const REGEX_PLACEMENT = {
USER_INPUT: 1,
AI_OUTPUT: 2,
WORLD_INFO: 5,
REASONING: 6,
} as const;
/** `substituteRegex`:findRegex 里的宏怎么替换。 */
export const SUBSTITUTE_FIND_REGEX = {
NONE: 0,
RAW: 1,
ESCAPED: 2,
} as const;
export type PresetRegexScript = {
name: string;
findRegex: string;
replaceString: string;
trimStrings: string[];
placement: number[];
promptOnly: boolean;
markdownOnly: boolean;
disabled: boolean;
runOnEdit: boolean;
substituteRegex: number;
minDepth: number | null;
maxDepth: number | null;
/** `substituteRegex = 2`(转义替换)暂按 RAW 处理,记下来给诊断提示 */
approximateSubstitute: boolean;
};
export type RegexDirection = "prompt" | "display" | "raw";
export type RegexApplyOptions = {
placement: number;
direction: RegexDirection;
/** 距离历史末尾的深度(酒馆 depth 语义);缺省不做 depth 过滤 */
depth?: number;
/** 宏展开(replaceString / trimStrings / substituteRegex=RAW 时用) */
expand?: (text: string) => string;
};
function asRecord(value: unknown): Record<string, unknown> | null {
return value && typeof value === "object"
? (value as Record<string, unknown>)
: null;
}
function readString(value: unknown): string {
return typeof value === "string" ? value : "";
}
function readNumberOrNull(value: unknown): number | null {
return typeof value === "number" && Number.isFinite(value) ? value : null;
}
function readStringList(value: unknown): string[] {
if (!Array.isArray(value)) return [];
return value.filter((item): item is string => typeof item === "string");
}
function readPlacement(value: unknown): number[] {
if (Array.isArray(value)) {
return value.filter((item): item is number => typeof item === "number");
}
if (typeof value === "number") return [value];
return [];
}
function collectRawScripts(raw: Record<string, unknown> | null): unknown[] {
if (!raw) return [];
const extensions = asRecord(raw.extensions);
const spreset = asRecord(extensions?.SPreset);
const binding = asRecord(spreset?.RegexBinding);
const buckets = [raw.regex_scripts, extensions?.regex_scripts, binding?.regexes];
const out: unknown[] = [];
for (const bucket of buckets) {
if (!Array.isArray(bucket)) continue;
for (const item of bucket) {
if (item && typeof item === "object") out.push(item);
}
}
return out;
}
/** 从预设 raw 里读出全部正则脚本(三处来源去重;SPreset 常把同一批存两份)。 */
export function readRegexScripts(raw: unknown): PresetRegexScript[] {
const record = asRecord(raw);
const scripts: PresetRegexScript[] = [];
const seen = new Set<string>();
for (const item of collectRawScripts(record)) {
const entry = asRecord(item);
if (!entry) continue;
const findRegex = readString(entry.findRegex);
if (!findRegex.trim()) continue;
const id = readString(entry.id);
const name = readString(entry.scriptName) || id || "(未命名正则)";
const key = id || `${name}@@${findRegex}`;
if (seen.has(key)) continue;
seen.add(key);
const substituteRegex =
typeof entry.substituteRegex === "number" ? entry.substituteRegex : 0;
scripts.push({
name,
findRegex,
replaceString: readString(entry.replaceString),
trimStrings: readStringList(entry.trimStrings),
placement: readPlacement(entry.placement),
promptOnly: entry.promptOnly === true,
markdownOnly: entry.markdownOnly === true,
disabled: entry.disabled === true,
runOnEdit: entry.runOnEdit === true,
substituteRegex,
minDepth: readNumberOrNull(entry.minDepth),
maxDepth: readNumberOrNull(entry.maxDepth),
approximateSubstitute: substituteRegex === SUBSTITUTE_FIND_REGEX.ESCAPED,
});
}
return scripts;
}
/** `/pattern/flags` → RegExp;不合法返回 null。 */
function parseFindRegex(source: string): RegExp | null {
const text = source.trim();
const wrapped = /^\/([\s\S]*)\/([a-z]*)$/.exec(text);
try {
if (wrapped) {
const flags = wrapped[2]!.includes("g") ? wrapped[2]! : `${wrapped[2]}g`;
return new RegExp(wrapped[1]!, flags);
}
return new RegExp(text, "g");
} catch {
return null;
}
}
function buildFindRegex(
script: PresetRegexScript,
expand?: (text: string) => string,
): RegExp | null {
const source =
script.substituteRegex === SUBSTITUTE_FIND_REGEX.RAW && expand
? expand(script.findRegex)
: script.findRegex;
return parseFindRegex(source);
}
function trimMatched(
text: string,
trimStrings: string[],
expand?: (text: string) => string,
): string {
if (trimStrings.length === 0) return text;
let out = text;
for (const trim of trimStrings) {
const needle = expand ? expand(trim) : trim;
if (!needle) continue;
out = out.split(needle).join("");
}
return out;
}
/** 单条脚本执行(对齐酒馆 `runRegexScript`)。 */
export function runRegexScript(
text: string,
script: PresetRegexScript,
options: { expand?: (text: string) => string } = {},
): string {
const regex = buildFindRegex(script, options.expand);
if (!regex) return text;
const template = script.replaceString.replace(/{{match}}/gi, "$0");
return text.replace(regex, (...args: unknown[]) => {
const groups =
args.length > 0 && typeof args[args.length - 1] === "object"
? (args[args.length - 1] as Record<string, string> | null)
: null;
const replaced = template.replaceAll(
/\$(\d+)|\$<([^>]+)>/g,
(_raw: string, num?: string, groupName?: string) => {
const matched =
num !== undefined
? (args[Number(num)] as string | undefined)
: groups && groupName
? groups[groupName]
: undefined;
if (matched === undefined || matched === null) return "";
return trimMatched(
String(matched),
script.trimStrings,
options.expand,
);
},
);
return options.expand ? options.expand(replaced) : replaced;
});
}
function directionAllows(
script: PresetRegexScript,
direction: RegexDirection,
): boolean {
if (script.disabled) return false;
if (direction === "prompt") return script.promptOnly;
if (direction === "display") return script.markdownOnly;
return !script.promptOnly && !script.markdownOnly;
}
function depthAllows(script: PresetRegexScript, depth: number | undefined): boolean {
if (typeof depth !== "number") return true;
if (script.minDepth !== null && script.minDepth >= -1 && depth < script.minDepth) {
return false;
}
if (script.maxDepth !== null && script.maxDepth >= 0 && depth > script.maxDepth) {
return false;
}
return true;
}
/** 按方向 / placement / depth 依次跑脚本(顺序与预设里一致)。 */
export function applyRegexScripts(
text: string,
scripts: PresetRegexScript[],
options: RegexApplyOptions,
): string {
if (!text || scripts.length === 0) return text;
let out = text;
for (const script of scripts) {
if (!script.placement.includes(options.placement)) continue;
if (!directionAllows(script, options.direction)) continue;
if (!depthAllows(script, options.depth)) continue;
out = runRegexScript(out, script, { expand: options.expand });
}
return out;
}
/** 有没有某个方向会真的跑到的脚本(装配时用来提前短路)。 */
export function hasRegexFor(
scripts: PresetRegexScript[],
direction: RegexDirection,
placement: number,
): boolean {
return scripts.some(
(script) =>
script.placement.includes(placement) &&
directionAllows(script, direction),
);
}
+4
View File
@@ -3,6 +3,7 @@ import path from "node:path";
import type { PresetPackage } from "../types/preset.js";
import { ensureUserDataDirs, getPresetsDir } from "../config/user-data-dir.js";
import { importSillyTavernPreset } from "./importer.js";
import { clearToolPlanCache } from "./tool-policy.js";
/** 旧预设里的思考强度不再使用;读到就从文件里删掉。 */
function withoutReasoningEffort(preset: PresetPackage, filePath: string): PresetPackage {
@@ -53,10 +54,13 @@ export function getPreset(id: string): PresetPackage | null {
export function savePreset(preset: PresetPackage): PresetPackage {
ensureUserDataDirs();
writeFileSync(presetPath(preset.id), JSON.stringify(preset, null, 2), "utf8");
// 条目/内容被改过,导入时算好的工具映射要重建
clearToolPlanCache(preset.id);
return preset;
}
export function deletePreset(id: string): void {
clearToolPlanCache(id);
try {
unlinkSync(presetPath(id));
} catch {
+238
View File
@@ -0,0 +1,238 @@
import type { ToolDefinition } from "../llm/client.js";
import { expandPresetDirectives } from "../directives/expand.js";
import type { PresetPackage } from "../types/preset.js";
import { buildPresetDiagnostics, orderedEnabledEntries } from "./diagnostics.js";
import { resolveToolPlan } from "./tool-policy.js";
/**
* 把预设里「工具协议」的**数据**翻译成真正的 `tools` 声明(对应酒馆里脚本干活的那一层)。
*
* 只读数据、不执行脚本,所以不会为某个预设特判:
* 1. `extensions.SPreset.ToolBindings[].form`:作者直接写的 name / description / parameters;
* 2. 只有伪工具协议条目(`%TOOL_NAME%` + `display_stream` 这种占位符)时,
* 从条目正文里取工具名、说明与字段说明,拼出等价的 schema。
*/
export type PresetToolDeclaration = {
source: "bindings" | "protocol";
tools: ToolDefinition[];
toolChoice: "auto";
/** 正文会落在这些参数上,调用完要把它们折回正文 */
payloadFields: string[];
/** 已经并进 tool 描述/参数描述的条目 id:装配时要剔掉,别重复发 */
consumedEntryIds: string[];
};
type BindingFormParam = {
name?: unknown;
type?: unknown;
description?: unknown;
required?: unknown;
};
type BindingForm = {
name?: unknown;
description?: unknown;
parameters?: unknown;
};
type BindingEntry = {
enabled?: unknown;
resolvedName?: unknown;
form?: unknown;
};
function asRecord(value: unknown): Record<string, unknown> | null {
return value && typeof value === "object" ? (value as Record<string, unknown>) : null;
}
function bindingMaps(preset: PresetPackage): Record<string, unknown>[] {
const extensions = asRecord(asRecord(preset.raw)?.extensions);
if (!extensions) return [];
const maps: Record<string, unknown>[] = [];
const fromPreset = asRecord(asRecord(extensions.SPreset)?.ToolBindings);
if (fromPreset) maps.push(fromPreset);
const direct = asRecord(extensions.ToolBindings);
if (direct) maps.push(direct);
return maps;
}
function schemaFromForm(form: BindingForm): {
schema: Record<string, unknown>;
fields: string[];
} {
const properties: Record<string, unknown> = {};
const required: string[] = [];
const fields: string[] = [];
const params = Array.isArray(form.parameters) ? (form.parameters as BindingFormParam[]) : [];
for (const param of params) {
const name = typeof param.name === "string" ? param.name.trim() : "";
if (!name) continue;
const type = typeof param.type === "string" && param.type.trim() ? param.type.trim() : "string";
const description =
typeof param.description === "string" ? param.description.trim() : "";
properties[name] = description ? { type, description } : { type };
fields.push(name);
if (param.required !== false) required.push(name);
}
const schema: Record<string, unknown> = { type: "object", properties };
if (required.length > 0) schema.required = required;
return { schema, fields };
}
function fromBindings(preset: PresetPackage): PresetToolDeclaration | null {
const tools: ToolDefinition[] = [];
const payloadFields = new Set<string>();
for (const map of bindingMaps(preset)) {
for (const value of Object.values(map)) {
const entry = asRecord(value) as BindingEntry | null;
if (!entry) continue;
if (entry.enabled === false) continue;
const form = asRecord(entry.form) as BindingForm | null;
if (!form) continue;
const name =
(typeof form.name === "string" && form.name.trim()) ||
(typeof entry.resolvedName === "string" && entry.resolvedName.trim()) ||
"";
if (!name) continue;
const description = typeof form.description === "string" ? form.description.trim() : "";
const { schema, fields } = schemaFromForm(form);
tools.push({
type: "function",
function: { name, description, parameters: schema },
});
for (const field of fields) payloadFields.add(field);
}
}
if (tools.length === 0) return null;
return {
source: "bindings",
tools,
toolChoice: "auto",
payloadFields: [...payloadFields],
consumedEntryIds: [],
};
}
/** 伪工具协议条目里那个"工具名":占位符 → 正文里写死的 xxx_payload_xxx → targetName → 兜底。 */
function protocolToolName(preset: PresetPackage): string {
const diagnostics = buildPresetDiagnostics(preset, preset.raw);
const named = diagnostics.toolProtocol.placeholders.find(
(item) => !/^%[A-Z0-9_]+%$/.test(item),
);
if (named) return named;
for (const text of protocolEntryTexts(preset)) {
const hit = text.match(/[a-z][a-z0-9]*(?:_[a-z0-9]+)*payload[a-z0-9_]*/i);
if (hit) return hit[0];
}
const target = resolveToolPlan(preset).targetName;
return target ?? "preset_payload";
}
/** 伪工具协议各条目(按 prompt_order 顺序)。 */
function protocolEntries(
preset: PresetPackage,
): Array<{ id: string; name: string; content: string }> {
const diagnostics = buildPresetDiagnostics(preset, preset.raw);
const ids = new Set(diagnostics.toolProtocol.entries);
const entries = orderedEnabledEntries(preset);
const picked = entries
.filter((entry) => ids.has(entry.name.trim() || entry.id) || ids.has(entry.id))
.map((entry) => ({ id: entry.id, name: entry.name.trim() || entry.id, content: entry.content }));
if (picked.length > 0) return picked;
// 诊断没认出来(命名不固定)时,按正文特征兜底
return entries
.map((entry) => ({ id: entry.id, name: entry.name.trim() || entry.id, content: entry.content }))
.filter((entry) => /%[A-Z0-9_]+%|payload|工具/i.test(entry.content));
}
function protocolEntryTexts(preset: PresetPackage): string[] {
return protocolEntries(preset).map((entry) => entry.content);
}
/**
* 从协议条目里挑正文:先看条目名像不像(工具说明 / 字段说明),再看正文是否提到关键词。
*/
function pickProtocolEntry(
preset: PresetPackage,
keyword: string,
nameHints: RegExp[] = [],
): { id: string; content: string } | null {
const entries = protocolEntries(preset);
for (const hint of nameHints) {
const byName = entries.find((entry) => hint.test(entry.name));
if (byName) return byName;
}
const hit = entries.find((entry) => entry.content.includes(keyword));
return hit ?? entries[0] ?? null;
}
function fromProtocol(preset: PresetPackage): PresetToolDeclaration | null {
const plan = resolveToolPlan(preset);
if (!plan.present) return null;
const name = protocolToolName(preset);
const fields = plan.payloadFields.length > 0 ? plan.payloadFields : ["content"];
const properties: Record<string, unknown> = {};
const consumed = new Set<string>();
for (const field of fields) {
const fieldEntry = pickProtocolEntry(preset, field, [
/字段说明|field.?desc/i,
/字段|参数|field|param/i,
]);
const description = expandPresetDirectives(fieldEntry?.content ?? "", {}).text.trim();
if (fieldEntry) consumed.add(fieldEntry.id);
properties[field] = description ? { type: "string", description } : { type: "string" };
}
const toolEntry = pickProtocolEntry(preset, name, [
/工具说明|tool.?desc/i,
/工具|tool/i,
]);
const description =
expandPresetDirectives(toolEntry?.content ?? "", {}).text.trim() || plan.description;
if (toolEntry) consumed.add(toolEntry.id);
return {
source: "protocol",
tools: [
{
type: "function",
function: {
name,
description,
parameters: { type: "object", properties, required: fields },
},
},
],
toolChoice: "auto",
payloadFields: fields,
consumedEntryIds: [...consumed],
};
}
export function declarePresetTools(
preset: PresetPackage,
): PresetToolDeclaration | null {
return fromBindings(preset) ?? fromProtocol(preset);
}
/** 工具调用结果里把正文抽回来(按声明的字段优先级)。 */
export function extractDeclaredPayload(
args: string,
payloadFields: string[],
): string | null {
let parsed: unknown;
try {
parsed = JSON.parse(args);
} catch {
return null;
}
const record = asRecord(parsed);
if (!record) return null;
const order = payloadFields.length > 0 ? payloadFields : ["content"];
for (const field of order) {
const value = record[field];
if (typeof value === "string" && value.trim()) return value;
}
for (const value of Object.values(record)) {
if (typeof value === "string" && value.trim()) return value;
}
return null;
}
+410
View File
@@ -0,0 +1,410 @@
/**
* 预设的工具协议适配(toolPolicy)。
*
* 两种真实形态:
* 1. `SPreset.ToolBindings` 明确声明工具(如 Reborn 的 `game_content(content)`);
* 2. 只在条目正文里写占位符(如 linux 的 `%TOOL_NAME%` + `display_stream`)。
*
* 目标:不新增业务工具,让预设要求的「调一次工具、正文放参数里」在已有工具上成立。
*/
import type { ToolDefinition } from "../llm/client.js";
import type { ParsedToolCall } from "../llm/client.js";
import type { PresetPackage } from "../types/preset.js";
import { buildPresetDiagnostics, orderedEnabledEntries } from "./diagnostics.js";
export type ToolPolicyMode = "adapt" | "app" | "preset";
/** 正文参数的常见别名:预设用哪个,我们都能收。 */
const COMMON_PAYLOAD_FIELDS = new Set([
"content",
"text",
"output",
"payload",
"reply",
"message",
"display_stream",
]);
/**
* 只有「被赋值」才有资格当字段名:`stream_target = display_stream`。
* 光凭正文里出现过 `content` 不算(那是 `<content>` 标签)。
*/
const ASSIGNED_FIELD_RE =
/\b(?:stream_target|payload_slot|output_channel|output_parameter|output_sink|parameter|target_sink)\s*=\s*"?([A-Za-z_][A-Za-z0-9_]*)"?/g;
/** `dispatch_stream_payload_34d9…` 这类是执行宿标识,不是参数名。 */
function looksLikeFieldName(value: string): boolean {
if (!/^[a-z][a-z0-9_]{1,40}$/.test(value)) return false;
if (/_[0-9a-f]{8,}$/i.test(value)) return false;
return true;
}
/** 看起来像「提交产物」的工具。 */
const HOST_TOOL_HINT = /submit|write|reply|present|deliver|output/i;
export type PresetToolPlan = {
/** 预设明确要求的工具名(`%TOOL_NAME%` 无法解析时为 null) */
targetName: string | null;
/** 正文可能落在哪些参数名上 */
payloadFields: string[];
/** 预设自己写的工具说明 + 字段说明,作为染色用的描述文本 */
description: string;
/** 有没有工具协议(没有就不动 tools) */
present: boolean;
/** 伪工具协议条目的 id(`toolPolicy = app` 时用来排除) */
protocolEntryIds: string[];
};
const planCache = new Map<string, PresetToolPlan>();
function planKey(preset: PresetPackage): string {
return `${preset.id}@${preset.importedAt}`;
}
/**
* 预设被改写(改条目 / 改生成参数 / 删)后必须重建 plan,
* 否则导入时算好的工具映射会一直生效。
*/
export function clearToolPlanCache(id?: string): void {
if (!id) {
planCache.clear();
return;
}
for (const key of [...planCache.keys()]) {
if (key.startsWith(`${id}@`)) planCache.delete(key);
}
}
export function resolveToolPlan(preset: PresetPackage): PresetToolPlan {
const key = planKey(preset);
const cached = planCache.get(key);
if (cached) return cached;
const diagnostics = buildPresetDiagnostics(preset, preset.raw);
const bindings = diagnostics.toolProtocol.bindings;
const placeholders = diagnostics.toolProtocol.placeholders;
const protocolEntries = new Set(diagnostics.toolProtocol.entries);
const protocolText = orderedEnabledEntries(preset)
.filter((e) => protocolEntries.has(e.name.trim() || e.id))
.map((e) => e.content)
.join("\n");
const payloadFields = new Set<string>();
for (const binding of bindings) {
for (const param of binding.params) payloadFields.add(param);
}
for (const placeholder of placeholders) {
// `%TOOL_NAME%` 是工具名占位符,其余长得像字段名的才算
if (/^%[A-Z_]+%$/.test(placeholder)) continue;
if (!looksLikeFieldName(placeholder)) continue;
if (COMMON_PAYLOAD_FIELDS.has(placeholder)) payloadFields.add(placeholder);
}
// 常见别名只在预设里真的被当成参数赋值过时才加,避免给工具塞一堆无用参数
for (const match of protocolText.matchAll(ASSIGNED_FIELD_RE)) {
const field = match[1]!;
if (looksLikeFieldName(field) && COMMON_PAYLOAD_FIELDS.has(field)) {
payloadFields.add(field);
}
}
if (payloadFields.size === 0 && diagnostics.toolProtocol.entries.length > 0) {
payloadFields.add("content");
}
// 描述只取 ToolBindings 里作者写的 short description。
// 不把条目正文整个抄进工具描述——那会把 prompt 体积翻倍(预设正文已经在 messages 里)。
const declared = bindings
.map((b) => b.description)
.filter((d) => d.trim().length > 0);
const description =
declared.length > 0
? declared.join("\n\n")
: payloadFields.size > 0
? `把完整正文放进 ${[...payloadFields].join(" / ")} 参数。`
: "";
const plan: PresetToolPlan = {
targetName: bindings[0]?.toolName ?? null,
payloadFields: [...payloadFields],
description,
present:
bindings.length > 0 ||
diagnostics.toolProtocol.entries.length > 0 ||
placeholders.length > 0,
protocolEntryIds: collectProtocolEntryIds(preset, diagnostics),
};
planCache.set(key, plan);
return plan;
}
function collectProtocolEntryIds(
preset: PresetPackage,
diagnostics: ReturnType<typeof buildPresetDiagnostics>,
): string[] {
const names = new Set<string>([
...diagnostics.toolProtocol.entries,
...diagnostics.toolProtocol.bindings.map((b) => b.entry),
]);
if (names.size === 0) return [];
const out: string[] = [];
for (const entry of preset.prompts) {
const label = entry.name.trim() || entry.sourceIdentifier || entry.id;
if (names.has(label)) out.push(entry.id);
}
return out;
}
/** 伪工具协议条目的 id 集合,供 `app` 模式排除。走 plan 缓存,不重复扫正文。 */
export function toolProtocolEntryIds(preset: PresetPackage): Set<string> {
return new Set(resolveToolPlan(preset).protocolEntryIds);
}
export type ToolAlias = {
/** 预设要求的工具名(模型可能真的这么调用) */
from: string;
/** 落到我们哪个工具上 */
to: string;
/** 正文可能落在哪些参数上 */
payloadFields: string[];
};
/** 宿主工具的字段别名:模型填了兼容字段也要能收。 */
export type PayloadAlias = {
tool: string;
primary: string;
fields: string[];
};
export type ToolDecoration = {
tools: ToolDefinition[];
aliases: ToolAlias[];
payloadAliases: PayloadAlias[];
notes: string[];
};
function firstRequiredStringField(tool: ToolDefinition): string | null {
const params = tool.function.parameters as
| { properties?: Record<string, unknown>; required?: unknown }
| undefined;
const required = Array.isArray(params?.required) ? params!.required : [];
const properties = (params?.properties ?? {}) as Record<string, unknown>;
for (const name of required) {
if (typeof name !== "string") continue;
const prop = properties[name] as { type?: unknown } | undefined;
if (prop?.type === "string") return name;
}
for (const [name, prop] of Object.entries(properties)) {
if ((prop as { type?: unknown } | undefined)?.type === "string") return name;
}
return null;
}
function pickHostTool(tools: ToolDefinition[]): ToolDefinition | null {
if (tools.length === 0) return null;
return tools.find((t) => HOST_TOOL_HINT.test(t.function.name)) ?? tools[0]!;
}
/** 把预设要求的字段名补进宿主工具的 schema(只加可选字段,不改 required)。 */
function withAliasFields(
tool: ToolDefinition,
payloadFields: string[],
): ToolDefinition {
const params = (tool.function.parameters ?? {}) as {
type?: string;
properties?: Record<string, unknown>;
required?: string[];
additionalProperties?: boolean;
};
const properties = { ...(params.properties ?? {}) };
let changed = false;
for (const field of payloadFields) {
if (properties[field]) continue;
properties[field] = {
type: "string",
description: "正文载荷(预设要求的兼容字段名,与主字段同义)",
};
changed = true;
}
if (!changed) return tool;
return {
...tool,
function: {
...tool.function,
parameters: { ...params, type: params.type ?? "object", properties },
},
};
}
function payloadTool(
name: string,
description: string,
payloadFields: string[],
): ToolDefinition {
const properties: Record<string, unknown> = {};
for (const field of payloadFields.length > 0 ? payloadFields : ["content"]) {
properties[field] = { type: "string" };
}
return {
type: "function",
function: {
name,
description,
parameters: {
type: "object",
properties,
additionalProperties: true,
},
},
};
}
/**
* 按预设要求调整本次请求的工具表。
* - app:原样(预设的伪工具协议条目应在装配时排除)
* - adapt:优先染色宿主工具;宿主不存在时补一个载荷工具
* - preset:只发预设声明的那个工具
*/
export function decorateTools(params: {
mode: ToolPolicyMode;
tools: ToolDefinition[];
plan: PresetToolPlan;
}): ToolDecoration {
const { mode, tools, plan } = params;
const notes: string[] = [];
const empty = { tools, aliases: [], payloadAliases: [], notes };
if (!plan.present || mode === "app") {
return empty;
}
if (mode === "preset") {
const name = plan.targetName ?? "preset_payload";
notes.push(`preset 模式:只发预设工具 ${name}`);
return {
tools: [payloadTool(name, plan.description, plan.payloadFields)],
aliases: [],
payloadAliases: [],
notes,
};
}
const host = pickHostTool(tools);
const aliases: ToolAlias[] = [];
const payloadAliases: PayloadAlias[] = [];
if (host) {
const primary = firstRequiredStringField(host);
const decorated = withAliasFields(host, plan.payloadFields);
const withDescription: ToolDefinition = plan.description
? {
...decorated,
function: {
...decorated.function,
description: `${decorated.function.description}\n\n${plan.description}`,
},
}
: decorated;
if (primary) {
payloadAliases.push({
tool: host.function.name,
primary,
fields: plan.payloadFields.filter((f) => f !== primary),
});
}
const next = tools.map((t) => (t === host ? withDescription : t));
const aliasName = plan.targetName;
if (aliasName && aliasName !== host.function.name) {
// 预设写的工具名对不上我们的:挂一个同名同参的载荷工具,
// 收到调用时再折算到宿主工具上。
next.push(payloadTool(aliasName, plan.description, plan.payloadFields));
aliases.push({
from: aliasName,
to: host.function.name,
payloadFields: plan.payloadFields,
});
notes.push(
`adapt:${aliasName} → ${host.function.name}(正文取 ${plan.payloadFields.join("/")})`,
);
} else {
notes.push(
`adapt:染色 ${host.function.name}(正文可填 ${plan.payloadFields.join("/")})`,
);
}
return { tools: next, aliases, payloadAliases, notes };
}
const name = plan.targetName ?? "preset_payload";
notes.push(`adapt:没有宿主工具,单独挂 ${name}`);
return {
tools: [payloadTool(name, plan.description, plan.payloadFields)],
aliases: [],
payloadAliases: [],
notes,
};
}
/**
* 把模型对「预设工具名」的调用折算到宿主工具上:
* 别名换成宿主名,正文参数换成宿主工具的主字段,其余字段保留。
*/
export function normalizeToolCalls(
calls: ParsedToolCall[],
decoration: ToolDecoration,
tools: ToolDefinition[],
): ParsedToolCall[] {
if (decoration.aliases.length === 0 && decoration.payloadAliases.length === 0) {
return calls;
}
return calls.map((call) => {
const alias = decoration.aliases.find(
(a) => a.from.toLowerCase() === call.name.toLowerCase(),
);
const targetName = alias?.to ?? call.name;
const host =
tools.find((t) => t.function.name === targetName) ??
decoration.tools.find((t) => t.function.name === targetName);
const payloadAlias = decoration.payloadAliases.find(
(a) => a.tool === targetName,
);
if (!host || !payloadAlias) {
return alias ? { ...call, name: targetName } : call;
}
let parsed: Record<string, unknown>;
try {
parsed = JSON.parse(call.arguments || "{}") as Record<string, unknown>;
} catch {
return alias ? { ...call, name: targetName } : call;
}
const current = parsed[payloadAlias.primary];
const hasPrimary =
typeof current === "string" && current.trim().length > 0;
const aliasField = payloadAlias.fields.find((field) => {
const value = parsed[field];
return typeof value === "string" && value.trim().length > 0;
});
if (!aliasField) {
return alias
? { ...call, name: targetName, arguments: JSON.stringify(parsed) }
: call;
}
const next: Record<string, unknown> = { ...parsed };
const value = next[aliasField];
delete next[aliasField];
if (!hasPrimary && typeof value === "string") {
next[payloadAlias.primary] = value;
}
return {
...call,
name: targetName,
arguments: JSON.stringify(next),
};
});
}
+38 -20
View File
@@ -1,7 +1,11 @@
import type { Blackboard } from "../blackboard/blackboard.js";
import type { BlackboardInputMerge } from "../types/blackboard.js";
import type { ChatMessage } from "../llm/client.js";
import {
CURRENT_TURN_TAG,
isCurrentTurnRef,
isDialogueHistoryRef,
parseHistoryMessages,
DIALOGUE_HISTORY_TAG,
} from "../skills/dialogue-history.js";
import {
@@ -11,24 +15,16 @@ import {
import {
CHAT_HISTORY_MARKER,
CURRENT_TURN,
WORLD_INFO_AFTER_MARKER,
WORLD_INFO_BEFORE_MARKER,
POST_TURN,
WORLD_BOOK_AFTER,
WORLD_BOOK_BEFORE,
isStUnfilledMarker,
} from "./markers.js";
export const CURRENT_TURN_TAG = "用户.最新输入";
const CURRENT_TURN_ALIASES = new Set([
CURRENT_TURN_TAG,
"currentTurn",
"user.latest",
"用户最新输入",
]);
export function isCurrentTurnRef(ref: string): boolean {
return CURRENT_TURN_ALIASES.has(ref.trim());
}
/** 兼容旧的导入点:历史相关标签判定归 `skills/dialogue-history.ts`。 */
export { CURRENT_TURN_TAG, isCurrentTurnRef };
export type WorldInfoEntry = {
id: string;
@@ -38,8 +34,17 @@ export type WorldInfoEntry = {
export type WorldInfoPack = {
worldBookBefore: WorldInfoEntry[];
history: string;
/** 「对话.历史」按块解析成真多轮 messages(非文本块);装配时直接进 chatHistory 洞。 */
history: ChatMessage[];
worldBookAfter: WorldInfoEntry[];
/** 酒馆 mes_example 同格式的示例对话(用 <START> 分块),装配时进 dialogueExamples 洞。 */
exampleDialogue?: string;
/** 角色卡三块(酒馆 charDescription / charPersonality / scenario 洞)。 */
card?: {
description?: string;
personality?: string;
scenario?: string;
};
turn: string;
postTurn: WorldInfoEntry[];
};
@@ -47,7 +52,7 @@ export type WorldInfoPack = {
function emptyPack(): WorldInfoPack {
return {
worldBookBefore: [],
history: "",
history: [],
worldBookAfter: [],
turn: "",
postTurn: [],
@@ -103,7 +108,11 @@ export function worldInfoPackFromSegments(params: {
).trim();
if (isHist) {
pack.history = body;
pack.history = parseHistoryMessages(body);
if (pack.history.length === 0 && body.trim()) {
// 没有 `## 用户/## 助手` 块头:当作一段上下文,别默默丢掉。
pack.history = [{ role: "system", content: body.trim() }];
}
phase = "after";
continue;
}
@@ -119,8 +128,14 @@ export function worldInfoPackFromSegments(params: {
else pack.postTurn.push(entry);
}
if (!pack.history.trim() && params.inputs[DIALOGUE_HISTORY_TAG]?.trim()) {
pack.history = params.inputs[DIALOGUE_HISTORY_TAG]!.trim();
if (pack.history.length === 0) {
const fallback = params.inputs[DIALOGUE_HISTORY_TAG]?.trim();
if (fallback) {
pack.history = parseHistoryMessages(fallback);
if (pack.history.length === 0) {
pack.history = [{ role: "system", content: fallback }];
}
}
}
if (!pack.turn.trim() && params.inputs[CURRENT_TURN_TAG]?.trim()) {
pack.turn = params.inputs[CURRENT_TURN_TAG]!.trim();
@@ -135,13 +150,16 @@ export function resolveWorldInfoMarker(
): string | null {
if (isStUnfilledMarker(identifier)) return null;
switch (identifier) {
case WORLD_BOOK_BEFORE: {
case WORLD_BOOK_BEFORE:
case WORLD_INFO_BEFORE_MARKER: {
const text = formatWorldInfoEntries(pack.worldBookBefore);
return text || null;
}
case CHAT_HISTORY_MARKER:
return pack.history.trim() || null;
case WORLD_BOOK_AFTER: {
// 历史是 message 序列,由 assembler 按块 splice,不走字符串填洞。
return null;
case WORLD_BOOK_AFTER:
case WORLD_INFO_AFTER_MARKER: {
const text = formatWorldInfoEntries(pack.worldBookAfter);
return text || null;
}