预设兼容:按真实抓包对齐装配与包装层,补上声明式工具与预设正则。
- 装配只认 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:
+125
-8
@@ -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[],
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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,
|
||||
|
||||
@@ -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),
|
||||
);
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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),
|
||||
};
|
||||
});
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user