引入游玩回合与开场收口,抽出可替换 agent 司机并支持运行中止;完善预设洞位、世界书拼装与试跑,并打磨会话运行时与 Web 交互。
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -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
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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];
|
||||
}
|
||||
@@ -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),
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user