引入游玩回合与开场收口,抽出可替换 agent 司机并支持运行中止;完善预设洞位、世界书拼装与试跑,并打磨会话运行时与 Web 交互。

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-08-24 01:13:48 +08:00
co-authored by Cursor
parent ec474cb946
commit b82da4c5a5
95 changed files with 6781 additions and 1344 deletions
+7 -3
View File
@@ -1,12 +1,14 @@
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;
const defaultMarkerResolver: MarkerResolver = () => null;
/**
* 按 prompt_order 装配 preset 消息,插入在所有业务 prompt 之前。
* 按 prompt_order 装配 preset 消息。
* 酒馆角色卡 / WI marker 不走 resolver(空洞跳过);自有洞由 resolver 填。
*/
export function assemblePresetMessages(
preset: PresetPackage,
@@ -25,8 +27,10 @@ export function assemblePresetMessages(
if (!entry || !entry.enabled) continue;
let content = entry.content.trim();
if (!content && entry.marker) {
const resolved = resolveMarker(entry.sourceIdentifier);
const markerId = entry.sourceIdentifier || entry.id;
const skipFill = isStUnfilledMarker(markerId);
if (!content && entry.marker && !skipFill) {
const resolved = resolveMarker(markerId);
if (resolved?.trim()) content = resolved.trim();
}
+16 -16
View File
@@ -7,6 +7,7 @@ import type {
PresetPromptOrderItem,
UnsupportedPresetSection,
} from "../types/preset.js";
import { applyExtendedMarkers } from "./markers.js";
type StPrompt = {
identifier?: string;
@@ -186,17 +187,6 @@ export function importSillyTavernPreset(
});
});
const referenced = new Set(promptOrder.map((o) => o.promptId));
const unreferencedCount = prompts.filter(
(p) => !referenced.has(p.id),
).length;
const enabledCount = promptOrder.filter((o) => {
if (!o.enabled) return false;
const entry = prompts.find((p) => p.id === o.promptId);
return entry?.enabled !== false;
}).length;
const generation = readGeneration(raw);
const generationFields = Object.entries(generation)
.filter(([, v]) => v !== undefined)
@@ -211,7 +201,7 @@ export function importSillyTavernPreset(
? (rawInput as { name: string }).name
: "导入的预设");
const preset: PresetPackage = {
const preset: PresetPackage = applyExtendedMarkers({
id: options.id ?? `${slugify(presetName) || "preset"}-${randomUUID().slice(0, 8)}`,
name: presetName,
source: "sillytavern",
@@ -221,13 +211,23 @@ export function importSillyTavernPreset(
unsupported: collectUnsupported(raw),
importedAt: new Date().toISOString(),
raw: rawInput,
};
});
const framedReferenced = new Set(preset.promptOrder.map((o) => o.promptId));
const framedUnreferenced = preset.prompts.filter(
(p) => !framedReferenced.has(p.id),
).length;
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;
}).length;
return {
preset,
promptCount: prompts.length,
enabledCount,
unreferencedCount,
promptCount: preset.prompts.length,
enabledCount: framedEnabled,
unreferencedCount: framedUnreferenced,
missingIdentifiers,
generationFields,
warnings,
+132
View File
@@ -0,0 +1,132 @@
import type {
PresetPackage,
PresetPromptEntry,
PresetPromptOrderItem,
PresetPromptRole,
} from "../types/preset.js";
/** 酒馆角色卡 / WI marker:导入后保留位置,运行时不填我们的正文。 */
export const ST_UNFILLED_MARKERS = new Set([
"worldInfoBefore",
"personaDescription",
"charDescription",
"charPersonality",
"scenario",
"worldInfoAfter",
"dialogueExamples",
]);
export const CHAT_HISTORY_MARKER = "chatHistory";
export const WORLD_BOOK_BEFORE = "worldBookBefore";
export const WORLD_BOOK_AFTER = "worldBookAfter";
export const CURRENT_TURN = "currentTurn";
export const POST_TURN = "postTurn";
/** 导入时插入的自有洞(不含共用的 chatHistory)。 */
export const APP_INSERTED_MARKERS = [
WORLD_BOOK_BEFORE,
WORLD_BOOK_AFTER,
CURRENT_TURN,
POST_TURN,
] as const;
const APP_INSERTED_SET = new Set<string>(APP_INSERTED_MARKERS);
type MarkerDef = {
id: string;
name: string;
role: PresetPromptRole;
};
const MARKER_DEFS: MarkerDef[] = [
{ id: WORLD_BOOK_BEFORE, name: "世界书(历史前)", role: "system" },
{ id: CHAT_HISTORY_MARKER, name: "对话历史", role: "system" },
{ id: WORLD_BOOK_AFTER, name: "世界书(历史后)", role: "system" },
{ id: CURRENT_TURN, name: "本轮对话", role: "user" },
{ id: POST_TURN, name: "本轮之后", role: "system" },
];
function upsertMarkerEntry(
prompts: PresetPromptEntry[],
def: MarkerDef,
): PresetPromptEntry[] {
if (prompts.some((p) => p.id === def.id)) return prompts;
return [
...prompts,
{
id: def.id,
name: def.name,
enabled: true,
role: def.role,
content: "",
marker: true,
sourceIdentifier: def.id,
},
];
}
function reindex(
order: Omit<PresetPromptOrderItem, "orderIndex">[],
): PresetPromptOrderItem[] {
return order.map((item, orderIndex) => ({ ...item, orderIndex }));
}
/**
* 以 chatHistory 为锚点插入四个自有 marker。
* 可重复调用:先摘掉已插入的自有洞,再按规则放回。
*/
export function applyExtendedMarkers(preset: PresetPackage): PresetPackage {
let prompts = [...preset.prompts];
for (const def of MARKER_DEFS) {
prompts = upsertMarkerEntry(prompts, def);
}
const rawOrder = [...preset.promptOrder]
.sort((a, b) => a.orderIndex - b.orderIndex)
.filter((item) => !APP_INSERTED_SET.has(item.promptId));
const histIndex = rawOrder.findIndex(
(item) => item.promptId === CHAT_HISTORY_MARKER,
);
const inserted = (id: string): PresetPromptOrderItem => ({
promptId: id,
enabled: true,
orderIndex: 0,
});
let next: PresetPromptOrderItem[];
if (histIndex < 0) {
next = reindex([
...rawOrder,
inserted(WORLD_BOOK_BEFORE),
inserted(CHAT_HISTORY_MARKER),
inserted(WORLD_BOOK_AFTER),
inserted(CURRENT_TURN),
inserted(POST_TURN),
]);
} else {
const before = rawOrder.slice(0, histIndex);
const history = rawOrder[histIndex]!;
const after = rawOrder.slice(histIndex + 1);
next = reindex([
...before,
inserted(WORLD_BOOK_BEFORE),
history,
inserted(WORLD_BOOK_AFTER),
inserted(CURRENT_TURN),
...after,
inserted(POST_TURN),
]);
}
return {
...preset,
prompts,
promptOrder: next,
};
}
export function isStUnfilledMarker(identifier: string): boolean {
return ST_UNFILLED_MARKERS.has(identifier);
}
+24
View File
@@ -0,0 +1,24 @@
import type { ChatMessage } from "../llm/client.js";
import type { PresetPackage } from "../types/preset.js";
import { assemblePresetMessages } from "./assembler.js";
import { applyExtendedMarkers } from "./markers.js";
import {
resolveWorldInfoMarker,
type WorldInfoPack,
} from "./world-info-pack.js";
/**
* Play worker:system 契约 + 按扩展 prompt_order 填洞。
* 不要再把同一份 context-order 正文叠成第二包 user。
*/
export function assemblePlayWorkerMessages(params: {
systemPrompt: string;
preset: PresetPackage;
pack: WorldInfoPack;
}): ChatMessage[] {
const framed = applyExtendedMarkers(params.preset);
const filled = assemblePresetMessages(framed, (id) =>
resolveWorldInfoMarker(params.pack, id),
);
return [{ role: "system", content: params.systemPrompt }, ...filled];
}
+130
View File
@@ -0,0 +1,130 @@
import type { ChatMessage } from "../llm/client.js";
import type { PresetPackage } from "../types/preset.js";
import { assemblePlayWorkerMessages } from "./play-frame.js";
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: "时间:深夜。地点:旅馆大厅。",
postTurn: "",
};
export type PresetProbeInput = {
message: string;
loreBefore?: string;
history?: string;
loreAfter?: string;
postTurn?: string;
};
export type PresetProbeMessage = {
role: string;
content: string;
};
export type PresetProbeResult = {
messages: PresetProbeMessage[];
reply: string | null;
reasoning: string | null;
completed: boolean;
skipReason?: string;
error?: string;
};
function optionalEntry(
id: string,
name: string,
content: string,
): WorldInfoPack["worldBookBefore"] {
const text = content.trim();
return text ? [{ id, name, content: text }] : [];
}
export function worldInfoPackFromProbeInput(
input: PresetProbeInput,
): WorldInfoPack {
const loreBefore = input.loreBefore ?? DEFAULT_PROBE_CONTEXT.loreBefore;
const history = input.history ?? DEFAULT_PROBE_CONTEXT.history;
const loreAfter = input.loreAfter ?? DEFAULT_PROBE_CONTEXT.loreAfter;
const postTurn = input.postTurn ?? DEFAULT_PROBE_CONTEXT.postTurn;
return {
worldBookBefore: optionalEntry("probe-before", "试跑设定", loreBefore),
history: history.trim(),
worldBookAfter: optionalEntry("probe-after", "本轮状态", loreAfter),
turn: input.message.trim(),
postTurn: optionalEntry("probe-post", "尾注", postTurn),
};
}
export function assemblePresetProbe(
preset: PresetPackage,
input: PresetProbeInput,
): ChatMessage[] {
return assemblePlayWorkerMessages({
systemPrompt: PROBE_SYSTEM_PROMPT,
preset,
pack: worldInfoPackFromProbeInput(input),
});
}
export function serializeProbeMessages(
messages: ChatMessage[],
): PresetProbeMessage[] {
return messages.map((m) => ({
role: m.role,
content: typeof m.content === "string" ? m.content : String(m.content ?? ""),
}));
}
export async function runPresetProbe(params: {
preset: PresetPackage;
input: PresetProbeInput;
complete?: (
messages: ChatMessage[],
) => Promise<{ content: string; reasoning?: string }>;
}): Promise<PresetProbeResult> {
const message = params.input.message.trim();
if (!message) {
throw new Error("缺少本轮输入");
}
const messages = assemblePresetProbe(params.preset, {
...params.input,
message,
});
const serialized = serializeProbeMessages(messages);
if (!params.complete) {
return {
messages: serialized,
reply: null,
reasoning: null,
completed: false,
skipReason: "未请求生成",
};
}
try {
const result = await params.complete(messages);
return {
messages: serialized,
reply: result.content,
reasoning: result.reasoning ?? null,
completed: true,
};
} catch (err) {
return {
messages: serialized,
reply: null,
reasoning: null,
completed: false,
error: err instanceof Error ? err.message : String(err),
};
}
}
+157
View File
@@ -0,0 +1,157 @@
import type { Blackboard } from "../blackboard/blackboard.js";
import type { BlackboardInputMerge } from "../types/blackboard.js";
import {
isDialogueHistoryRef,
DIALOGUE_HISTORY_TAG,
} from "../skills/dialogue-history.js";
import {
renderContextSegmentBody,
type ContextSegmentDef,
} from "../skills/context-segments.js";
import {
CHAT_HISTORY_MARKER,
CURRENT_TURN,
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());
}
export type WorldInfoEntry = {
id: string;
name: string;
content: string;
};
export type WorldInfoPack = {
worldBookBefore: WorldInfoEntry[];
history: string;
worldBookAfter: WorldInfoEntry[];
turn: string;
postTurn: WorldInfoEntry[];
};
function emptyPack(): WorldInfoPack {
return {
worldBookBefore: [],
history: "",
worldBookAfter: [],
turn: "",
postTurn: [],
};
}
function entryName(segment: ContextSegmentDef): string {
const raw = (segment.label ?? "").replace(/^#+\s*/, "").trim();
return raw || segment.id;
}
function toEntry(segment: ContextSegmentDef, content: string): WorldInfoEntry {
return {
id: segment.id,
name: entryName(segment),
content: content.trim(),
};
}
export function formatWorldInfoEntries(entries: WorldInfoEntry[]): string {
return entries
.filter((e) => e.content.trim())
.map((e) => {
const title = e.name.trim();
const body = e.content.trim();
return title ? `### ${title}\n\n${body}` : body;
})
.join("\n\n");
}
/**
* 按 contextSegments 相对「对话.历史」「用户.最新输入」切成四袋。
*/
export function worldInfoPackFromSegments(params: {
segments: ContextSegmentDef[];
inputs: Record<string, string>;
blackboard: Blackboard;
inputMerge?: BlackboardInputMerge;
}): WorldInfoPack {
const inputMerge = params.inputMerge ?? "latest";
const pack = emptyPack();
let phase: "before" | "after" | "post" = "before";
for (const segment of params.segments) {
const tags = segment.tags ?? [];
const isHist = tags.some((t) => isDialogueHistoryRef(t));
const isTurn = tags.some((t) => isCurrentTurnRef(t));
const body = renderContextSegmentBody(
segment,
params.inputs,
params.blackboard,
inputMerge,
).trim();
if (isHist) {
pack.history = body;
phase = "after";
continue;
}
if (isTurn) {
pack.turn = body;
phase = "post";
continue;
}
if (!body) continue;
const entry = toEntry(segment, body);
if (phase === "before") pack.worldBookBefore.push(entry);
else if (phase === "after") pack.worldBookAfter.push(entry);
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.turn.trim() && params.inputs[CURRENT_TURN_TAG]?.trim()) {
pack.turn = params.inputs[CURRENT_TURN_TAG]!.trim();
}
return pack;
}
export function resolveWorldInfoMarker(
pack: WorldInfoPack,
identifier: string,
): string | null {
if (isStUnfilledMarker(identifier)) return null;
switch (identifier) {
case WORLD_BOOK_BEFORE: {
const text = formatWorldInfoEntries(pack.worldBookBefore);
return text || null;
}
case CHAT_HISTORY_MARKER:
return pack.history.trim() || null;
case WORLD_BOOK_AFTER: {
const text = formatWorldInfoEntries(pack.worldBookAfter);
return text || null;
}
case CURRENT_TURN:
return pack.turn.trim() || null;
case POST_TURN: {
const text = formatWorldInfoEntries(pack.postTurn);
return text || null;
}
default:
return null;
}
}