预设装配注入人设与身份宏,并支持编辑采样参数。
让游玩提示词能吃到当前用户角色,同时在设置里可调 generation。 Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -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,
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
@@ -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";
|
||||
|
||||
@@ -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
@@ -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: "",
|
||||
};
|
||||
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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"],
|
||||
]);
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user