预设装配注入人设与身份宏,并支持编辑采样参数。

让游玩提示词能吃到当前用户角色,同时在设置里可调 generation。

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-09-14 01:14:32 +08:00
co-authored by Cursor
parent 8a22cdc37c
commit 16f66ffd5e
10 changed files with 233 additions and 11 deletions
+5
View File
@@ -3,16 +3,20 @@ import type { PresetPackage } from "../types/preset.js";
import { isStUnfilledMarker } from "./markers.js";
export type MarkerResolver = (identifier: string) => string | null;
export type ContentExpander = (content: string) => string;
const defaultMarkerResolver: MarkerResolver = () => null;
const defaultContentExpander: ContentExpander = (c) => c;
/**
* 按 prompt_order 装配 preset 消息。
* 酒馆角色卡 / WI marker 不走 resolver(空洞跳过);自有洞由 resolver 填。
* contentExpander:对最终正文做身份宏展开(@玩家 / {{user}})。
*/
export function assemblePresetMessages(
preset: PresetPackage,
resolveMarker: MarkerResolver = defaultMarkerResolver,
contentExpander: ContentExpander = defaultContentExpander,
): ChatMessage[] {
const promptById = new Map(preset.prompts.map((p) => [p.id, p]));
const messages: ChatMessage[] = [];
@@ -35,6 +39,7 @@ export function assemblePresetMessages(
}
if (!content) continue;
content = contentExpander(content);
messages.push({
role: entry.role,
+90
View File
@@ -0,0 +1,90 @@
import type { GenerationParameters, PresetPackage } from "../types/preset.js";
import { savePreset } from "./store.js";
function finiteNumber(value: unknown): number | undefined {
if (value === null || value === undefined || value === "") return undefined;
const n = typeof value === "number" ? value : Number(value);
return Number.isFinite(n) ? n : undefined;
}
function optionalString(value: unknown): string | undefined {
if (typeof value !== "string") return undefined;
const trimmed = value.trim();
return trimmed ? trimmed : undefined;
}
/**
* 从表单 / API 体归一化 generation:空字段省略(等于清除)。
* 整对象替换,不做与旧值的字段级合并。
*/
export function normalizeGeneration(
input: unknown,
): GenerationParameters {
if (!input || typeof input !== "object" || Array.isArray(input)) {
return {};
}
const raw = input as Record<string, unknown>;
const out: GenerationParameters = {};
const temperature = finiteNumber(raw.temperature);
if (temperature !== undefined) out.temperature = temperature;
const topP = finiteNumber(raw.topP);
if (topP !== undefined) out.topP = topP;
const topK = finiteNumber(raw.topK);
if (topK !== undefined) out.topK = topK;
const minP = finiteNumber(raw.minP);
if (minP !== undefined) out.minP = minP;
const frequencyPenalty = finiteNumber(raw.frequencyPenalty);
if (frequencyPenalty !== undefined) out.frequencyPenalty = frequencyPenalty;
const presencePenalty = finiteNumber(raw.presencePenalty);
if (presencePenalty !== undefined) out.presencePenalty = presencePenalty;
const repetitionPenalty = finiteNumber(raw.repetitionPenalty);
if (repetitionPenalty !== undefined) {
out.repetitionPenalty = repetitionPenalty;
}
const maxContextTokens = finiteNumber(raw.maxContextTokens);
if (maxContextTokens !== undefined) out.maxContextTokens = maxContextTokens;
const maxOutputTokens = finiteNumber(raw.maxOutputTokens);
if (maxOutputTokens !== undefined) out.maxOutputTokens = maxOutputTokens;
if (typeof raw.stream === "boolean") out.stream = raw.stream;
const reasoningEffort = optionalString(raw.reasoningEffort);
if (reasoningEffort !== undefined) out.reasoningEffort = reasoningEffort;
const verbosity = optionalString(raw.verbosity);
if (verbosity !== undefined) out.verbosity = verbosity;
const seed = finiteNumber(raw.seed);
if (seed !== undefined) out.seed = seed;
const variants = finiteNumber(raw.variants);
if (variants !== undefined) out.variants = variants;
return out;
}
export function applyPresetGeneration(
preset: PresetPackage,
generation: unknown,
): PresetPackage {
return {
...preset,
generation: normalizeGeneration(generation),
};
}
export function patchPresetGeneration(
preset: PresetPackage,
generation: unknown,
): PresetPackage {
return savePreset(applyPresetGeneration(preset, generation));
}
+3 -2
View File
@@ -5,10 +5,9 @@ import type {
PresetPromptRole,
} from "../types/preset.js";
/** 酒馆角色卡 / WI marker:导入后保留位置,运行时不填我们的正文。 */
/** 酒馆角色卡 / WI marker:导入后保留位置;personaDescription 由当前用户角色填。 */
export const ST_UNFILLED_MARKERS = new Set([
"worldInfoBefore",
"personaDescription",
"charDescription",
"charPersonality",
"scenario",
@@ -16,6 +15,8 @@ export const ST_UNFILLED_MARKERS = new Set([
"dialogueExamples",
]);
export const PERSONA_DESCRIPTION_MARKER = "personaDescription";
export const CHAT_HISTORY_MARKER = "chatHistory";
export const WORLD_BOOK_BEFORE = "worldBookBefore";
export const WORLD_BOOK_AFTER = "worldBookAfter";
+30 -3
View File
@@ -1,7 +1,15 @@
import { expandIdentityDirectives } from "../directives/expand.js";
import type { ChatMessage } from "../llm/client.js";
import {
getActivePersona,
type PersonaDirective,
} from "../persona/store.js";
import type { PresetPackage } from "../types/preset.js";
import { assemblePresetMessages } from "./assembler.js";
import { applyExtendedMarkers } from "./markers.js";
import {
applyExtendedMarkers,
PERSONA_DESCRIPTION_MARKER,
} from "./markers.js";
import {
resolveWorldInfoMarker,
type WorldInfoEntry,
@@ -35,11 +43,28 @@ export function assemblePlayWorkerMessages(params: {
systemPrompt: string;
preset: PresetPackage;
pack: WorldInfoPack;
/** 缺省用当前选用(试跑 / 未绑会话)。创作期应传入创作默认人设。 */
persona?: PersonaDirective | null;
}): ChatMessage[] {
const framed = applyExtendedMarkers(params.preset);
const pack = withTaskSystem(params.pack, params.systemPrompt);
return assemblePresetMessages(framed, (id) =>
resolveWorldInfoMarker(pack, id),
const persona =
params.persona !== undefined ? params.persona : getActivePersona();
const dirCtx = persona
? { persona: { name: persona.name, description: persona.description } }
: {};
return assemblePresetMessages(
framed,
(id) => {
if (id === PERSONA_DESCRIPTION_MARKER) {
const desc = persona?.description?.trim();
if (desc) return desc;
const name = persona?.name?.trim();
return name ? `姓名:${name}` : null;
}
return resolveWorldInfoMarker(pack, id);
},
(content) => expandIdentityDirectives(content, dirCtx).text,
);
}
@@ -72,6 +97,7 @@ function formatHistoryMessages(msgs: ChatMessage[]): string {
export function frameMessagesWithPreset(
preset: PresetPackage,
messages: ChatMessage[],
persona?: PersonaDirective | null,
): ChatMessage[] {
let splitAt = messages.length;
for (let i = 0; i < messages.length; i++) {
@@ -112,6 +138,7 @@ export function frameMessagesWithPreset(
turn,
postTurn: [],
},
persona,
});
return continuation.length > 0 ? [...framed, ...continuation] : framed;
+4 -4
View File
@@ -5,12 +5,12 @@ import type { WorldInfoPack } from "./world-info-pack.js";
/** 试跑窗口用的短契约,不是 play worker SKILL。 */
export const PROBE_SYSTEM_PROMPT =
"试跑本条预设:根据已拼好的上下文写一轮短回复。产物是对白或叙述,对应本轮用户输入。";
"试跑本条预设:对本轮输入做忠实扩写。不增删情节,不另起冲突。";
export const DEFAULT_PROBE_CONTEXT = {
loreBefore: "一座雨夜的港口旅馆。柜台点着油灯。",
history: "店员:今晚只剩阁楼那间。\n你:好。",
loreAfter: "时间:深夜。地点:旅馆大厅。",
loreBefore: "写作要求:对本轮输入做忠实扩写;只展开已给出的动作与信息,不另起情节。",
history: "扩写范围仅限本轮输入,勿补前因后果或无关对话。",
loreAfter: "",
postTurn: "",
};
+3
View File
@@ -36,6 +36,7 @@ import { toActiveSkillSnapshot } from "../skills/snapshot.js";
import { runWorkerSkill } from "../worker/executor.js";
import { resolveWorkerId } from "../worker/resolve-id.js";
import { resolveWorkerLlmProvider } from "../skills/worker-llm.js";
import { personaForLifecycle } from "../persona/store.js";
import {
DIALOGUE_HISTORY_TAG,
appendDialogueHistoryTurn,
@@ -2272,6 +2273,8 @@ export class PhaseRuntime {
bindings: (await loadSkill(activeSkill.name)).workerLlmBindings,
slots,
fallbackLlm: this.llm,
getPersona: () =>
personaForLifecycle(inferLifecycleStage(this.session)),
});
this.onMessage(
+11 -1
View File
@@ -7,6 +7,7 @@ import {
wrapLlmForSession,
type LlmTrackingRef,
} from "../llm/preset-wrapper.js";
import type { PersonaDirective } from "../persona/store.js";
import type { ParsedWorkerSkill, SkillWorkerLlmBindings } from "./types.js";
export type { LlmTrackingContext } from "../llm/token-tracker.js";
@@ -16,6 +17,7 @@ export function createLlmForProfileId(
profileId: string,
fallback: LlmProvider,
trackingRef?: LlmTrackingRef,
getPersona?: () => PersonaDirective | null,
): LlmProvider {
const profile = getApiProfile(profileId);
if (!profile?.apiKey?.trim()) {
@@ -25,6 +27,8 @@ export function createLlmForProfileId(
return wrapLlmForSession(
new OpenAiCompatibleProvider(profileToLlmConfig(profile)),
trackingRef,
undefined,
getPersona,
);
}
@@ -43,14 +47,17 @@ export function resolveWorkerLlmProvider(options: {
slots: Record<string, unknown>;
fallbackLlm: LlmProvider;
trackingRef?: LlmTrackingRef;
getPersona?: () => PersonaDirective | null;
}): LlmProvider {
const { worker, bindings, slots, fallbackLlm, trackingRef } = options;
const { worker, bindings, slots, fallbackLlm, trackingRef, getPersona } =
options;
if (worker.llmProfileId?.trim()) {
return createLlmForProfileId(
worker.llmProfileId.trim(),
fallbackLlm,
trackingRef,
getPersona,
);
}
@@ -62,6 +69,7 @@ export function resolveWorkerLlmProvider(options: {
workerBinding.byRole[roleId].trim(),
fallbackLlm,
trackingRef,
getPersona,
);
}
}
@@ -71,6 +79,7 @@ export function resolveWorkerLlmProvider(options: {
workerBinding.profileId.trim(),
fallbackLlm,
trackingRef,
getPersona,
);
}
@@ -79,6 +88,7 @@ export function resolveWorkerLlmProvider(options: {
bindings.defaultProfileId.trim(),
fallbackLlm,
trackingRef,
getPersona,
);
}
+51
View File
@@ -48,6 +48,7 @@ describe("assemblePlayWorkerMessages", () => {
systemPrompt: "SKILL",
preset: report.preset,
pack,
persona: null,
});
expect(messages.map((m) => [m.role, m.content])).toEqual([
@@ -107,6 +108,7 @@ describe("assemblePlayWorkerMessages", () => {
turn: "本轮",
postTurn: [],
},
persona: null,
});
expect(messages[0]).toEqual({ role: "system", content: "<世界书>" });
expect(messages.some((m) => m.content === "创作步骤契约")).toBe(false);
@@ -148,4 +150,53 @@ describe("assemblePlayWorkerMessages", () => {
});
expect(messages.map((m) => m.content)).toEqual(["HIST"]);
});
it("fills personaDescription and expands @玩家 with the given persona", () => {
const report = importSillyTavernPreset({
prompts: [
{
identifier: "personaDescription",
name: "Persona",
role: "system",
marker: true,
},
{
identifier: "main",
name: "Main",
role: "system",
content: "对 @玩家 说话",
},
{
identifier: "chatHistory",
name: "History",
role: "system",
marker: true,
},
],
prompt_order: [
{
character_id: 1,
order: [
{ identifier: "personaDescription", enabled: true },
{ identifier: "main", enabled: true },
{ identifier: "chatHistory", enabled: true },
],
},
],
});
const messages = assemblePlayWorkerMessages({
systemPrompt: "SKILL",
preset: report.preset,
pack: {
worldBookBefore: [],
history: "昨日",
worldBookAfter: [],
turn: "本轮",
postTurn: [],
},
persona: { name: "@玩家", description: "占位人设" },
});
expect(messages.some((m) => m.content === "占位人设")).toBe(true);
expect(messages.some((m) => m.content === "对 @玩家 说话")).toBe(true);
});
});
-1
View File
@@ -52,7 +52,6 @@ describe("assemblePresetProbe", () => {
`### 任务契约\n\n${PROBE_SYSTEM_PROMPT}\n\n### 试跑设定\n\n${DEFAULT_PROBE_CONTEXT.loreBefore}`,
],
["system", DEFAULT_PROBE_CONTEXT.history],
["system", `### 本轮状态\n\n${DEFAULT_PROBE_CONTEXT.loreAfter}`],
["user", "我推开门。"],
["system", "PHI"],
]);
+36
View File
@@ -6,6 +6,10 @@ import {
listAllPresetEntries,
listEnabledPresetEntries,
} from "../src/preset/entries.js";
import {
applyPresetGeneration,
normalizeGeneration,
} from "../src/preset/generation.js";
import { importSillyTavernPreset } from "../src/preset/importer.js";
const samplePreset = {
@@ -170,3 +174,35 @@ describe("applyPresetEntryPatches", () => {
).toBe(true);
});
});
describe("normalizeGeneration / applyPresetGeneration", () => {
it("keeps finite numbers and clears empty fields on replace", () => {
expect(
normalizeGeneration({
temperature: "0.9",
topP: "",
maxOutputTokens: 4096,
reasoningEffort: " high ",
verbosity: " ",
stream: true,
seed: "not-a-number",
}),
).toEqual({
temperature: 0.9,
maxOutputTokens: 4096,
reasoningEffort: "high",
stream: true,
});
});
it("replaces preset generation wholesale", () => {
const report = importSillyTavernPreset(samplePreset);
expect(report.preset.generation.temperature).toBe(1);
const next = applyPresetGeneration(report.preset, {
topP: 0.8,
maxOutputTokens: 2048,
});
expect(next.generation).toEqual({ topP: 0.8, maxOutputTokens: 2048 });
expect(next.prompts).toBe(report.preset.prompts);
});
});