From 16f66ffd5e4dce3d9577eb722dd06e3a69fc2837 Mon Sep 17 00:00:00 2001 From: moranzhi Date: Mon, 14 Sep 2026 01:14:32 +0800 Subject: [PATCH] =?UTF-8?q?=E9=A2=84=E8=AE=BE=E8=A3=85=E9=85=8D=E6=B3=A8?= =?UTF-8?q?=E5=85=A5=E4=BA=BA=E8=AE=BE=E4=B8=8E=E8=BA=AB=E4=BB=BD=E5=AE=8F?= =?UTF-8?q?=EF=BC=8C=E5=B9=B6=E6=94=AF=E6=8C=81=E7=BC=96=E8=BE=91=E9=87=87?= =?UTF-8?q?=E6=A0=B7=E5=8F=82=E6=95=B0=E3=80=82?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 让游玩提示词能吃到当前用户角色,同时在设置里可调 generation。 Co-authored-by: Cursor --- src/preset/assembler.ts | 5 ++ src/preset/generation.ts | 90 ++++++++++++++++++++++++++++++++++++ src/preset/markers.ts | 5 +- src/preset/play-frame.ts | 33 +++++++++++-- src/preset/probe.ts | 8 ++-- src/runtime/phase-runtime.ts | 3 ++ src/skills/worker-llm.ts | 12 ++++- tests/play-frame.test.ts | 51 ++++++++++++++++++++ tests/preset-probe.test.ts | 1 - tests/preset.test.ts | 36 +++++++++++++++ 10 files changed, 233 insertions(+), 11 deletions(-) create mode 100644 src/preset/generation.ts diff --git a/src/preset/assembler.ts b/src/preset/assembler.ts index 0b75d9f..608d2dd 100644 --- a/src/preset/assembler.ts +++ b/src/preset/assembler.ts @@ -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, diff --git a/src/preset/generation.ts b/src/preset/generation.ts new file mode 100644 index 0000000..0e90ff0 --- /dev/null +++ b/src/preset/generation.ts @@ -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; + 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)); +} diff --git a/src/preset/markers.ts b/src/preset/markers.ts index b06d4be..17e835e 100644 --- a/src/preset/markers.ts +++ b/src/preset/markers.ts @@ -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"; diff --git a/src/preset/play-frame.ts b/src/preset/play-frame.ts index 02db33c..1843132 100644 --- a/src/preset/play-frame.ts +++ b/src/preset/play-frame.ts @@ -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; diff --git a/src/preset/probe.ts b/src/preset/probe.ts index 669b881..dae81bb 100644 --- a/src/preset/probe.ts +++ b/src/preset/probe.ts @@ -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: "", }; diff --git a/src/runtime/phase-runtime.ts b/src/runtime/phase-runtime.ts index 227205a..444bcea 100644 --- a/src/runtime/phase-runtime.ts +++ b/src/runtime/phase-runtime.ts @@ -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( diff --git a/src/skills/worker-llm.ts b/src/skills/worker-llm.ts index 30a323b..178c49e 100644 --- a/src/skills/worker-llm.ts +++ b/src/skills/worker-llm.ts @@ -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; 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, ); } diff --git a/tests/play-frame.test.ts b/tests/play-frame.test.ts index 27314c6..49c946f 100644 --- a/tests/play-frame.test.ts +++ b/tests/play-frame.test.ts @@ -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); + }); }); diff --git a/tests/preset-probe.test.ts b/tests/preset-probe.test.ts index 8c31c21..f46f6b9 100644 --- a/tests/preset-probe.test.ts +++ b/tests/preset-probe.test.ts @@ -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"], ]); diff --git a/tests/preset.test.ts b/tests/preset.test.ts index 250f5f7..a673d8d 100644 --- a/tests/preset.test.ts +++ b/tests/preset.test.ts @@ -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); + }); +});