feat: hide assistant card names in extraction transcript, split sectioned recentMessages into 2 system messages

This commit is contained in:
Youzini-afk
2026-04-12 13:33:39 +08:00
parent 433e62e084
commit f40b03c306
8 changed files with 232 additions and 46 deletions

View File

@@ -289,6 +289,32 @@ function resolveSpeakerName(message = {}, role = "assistant", names = {}) {
return role || "assistant";
}
function shouldHideSpeakerLabel(message = {}, role = "assistant", names = {}) {
if (message?.hideSpeakerLabel === true) {
return true;
}
if (message?.hideSpeakerLabel === false) {
return false;
}
if (role !== "assistant") {
return false;
}
if (String(message?.source || "").trim() === "worldInfo-atDepth") {
return false;
}
const explicitSpeaker = String(
message?.speaker ?? message?.name ?? message?.displayName ?? "",
).trim();
if (!explicitSpeaker) {
return true;
}
const activeCharName = String(names?.charName || "").trim();
if (!activeCharName) {
return false;
}
return explicitSpeaker === activeCharName;
}
function normalizeExtractionMessage(message = {}, index = 0, names = {}) {
const role = normalizeRole(
message?.role ?? (message?.is_user === true ? "user" : "assistant"),
@@ -296,6 +322,7 @@ function normalizeExtractionMessage(message = {}, index = 0, names = {}) {
const content = String(resolveMessageContent(message) || "").trim();
const rawContent = String(resolveMessageRawContent(message) || content).trim();
const speaker = resolveSpeakerName(message, role, names);
const hideSpeakerLabel = shouldHideSpeakerLabel(message, role, names);
const seq = Number.isFinite(Number(message?.seq)) ? Number(message.seq) : null;
return {
@@ -304,6 +331,7 @@ function normalizeExtractionMessage(message = {}, index = 0, names = {}) {
role,
speaker,
name: speaker,
hideSpeakerLabel,
content,
rawContent,
sourceType: role === "user" ? "user_input" : "ai_output",
@@ -347,7 +375,8 @@ export function formatExtractionTranscript(messages = []) {
: `#${index + 1}`;
const role = normalizeRole(message?.role || "assistant");
const speaker = String(message?.speaker || message?.name || "").trim();
const speakerLabel = speaker ? `|${speaker}` : "";
const speakerLabel =
message?.hideSpeakerLabel === true || !speaker ? "" : `|${speaker}`;
const line = `${seqLabel} [${role}${speakerLabel}]: ${String(message?.content || "")}`;
if (String(line || "").trim()) {
lines.push(line);

View File

@@ -873,6 +873,7 @@ export async function extractMemories({
content: message?.content,
speaker: message?.speaker,
name: message?.name,
hideSpeakerLabel: message?.hideSpeakerLabel === true,
isContextOnly: message?.isContextOnly === true,
}))
: [];

View File

@@ -296,6 +296,7 @@ function getPromptMessageLikeDescriptor(value) {
role: role === "user" ? "user" : "assistant",
seq: getOptionalFiniteNumber(value.seq),
speaker,
hideSpeakerLabel: value?.hideSpeakerLabel === true,
isContextOnly:
typeof value.isContextOnly === "boolean" ? value.isContextOnly : null,
};
@@ -310,6 +311,7 @@ function getPromptMessageLikeDescriptor(value) {
role: value.is_user === true ? "user" : "assistant",
seq: getOptionalFiniteNumber(value.seq),
speaker,
hideSpeakerLabel: value?.hideSpeakerLabel === true,
isContextOnly:
typeof value.isContextOnly === "boolean" ? value.isContextOnly : null,
};
@@ -326,6 +328,11 @@ function isPromptMessageArray(value) {
);
}
const EXTRACTION_CONTEXT_REVIEW_HEADER =
"--- 以下是上下文回顾(已提取过),仅供理解剧情 ---";
const EXTRACTION_TARGET_CONTENT_HEADER =
"--- 以下是本次需要提取记忆的新对话内容 ---";
function getPromptMessageContextGroup(value) {
const descriptor = getPromptMessageLikeDescriptor(value);
if (!descriptor || typeof descriptor.isContextOnly !== "boolean") {
@@ -334,6 +341,16 @@ function getPromptMessageContextGroup(value) {
return descriptor.isContextOnly ? "context" : "target";
}
function getPromptMessageContextHeader(group = "") {
if (group === "context") {
return EXTRACTION_CONTEXT_REVIEW_HEADER;
}
if (group === "target") {
return EXTRACTION_TARGET_CONTENT_HEADER;
}
return "";
}
function formatPromptMessageTranscript(value) {
const entries = Array.isArray(value) ? value : [value];
const hasContextMessages = entries.some(
@@ -353,16 +370,12 @@ function formatPromptMessageTranscript(value) {
}
const group = getPromptMessageContextGroup(entry);
if (hasContextMessages && hasTargetMessages && group && group !== activeGroup) {
lines.push(
group === "context"
? "--- 以下是上下文回顾(已提取过),仅供理解剧情 ---"
: "--- 以下是本次需要提取记忆的新对话内容 ---",
);
lines.push(getPromptMessageContextHeader(group));
activeGroup = group;
}
const seqLabel =
descriptor.seq != null ? `#${descriptor.seq}` : `#${index + 1}`;
const speakerLabel = descriptor.speaker
const speakerLabel = !descriptor.hideSpeakerLabel && descriptor.speaker
? `|${descriptor.speaker}`
: "";
lines.push(`${seqLabel} [${descriptor.role}${speakerLabel}]: ${descriptor.content}`);
@@ -1910,6 +1923,87 @@ function clonePayloadMessage(message = {}) {
});
}
function splitSectionedTranscriptPayloadMessage(message = {}) {
const normalizedRole = normalizeRole(message?.role);
const sourceKey = String(message?.sourceKey || "").trim();
const content = String(message?.content || "").trim();
if (
normalizedRole !== "system" ||
!["recentMessages", "dialogueText"].includes(sourceKey) ||
!content.includes(EXTRACTION_CONTEXT_REVIEW_HEADER) ||
!content.includes(EXTRACTION_TARGET_CONTENT_HEADER)
) {
return [message];
}
const headerMatches = [];
let searchIndex = 0;
while (searchIndex < content.length) {
const contextIndex = content.indexOf(
EXTRACTION_CONTEXT_REVIEW_HEADER,
searchIndex,
);
const targetIndex = content.indexOf(
EXTRACTION_TARGET_CONTENT_HEADER,
searchIndex,
);
let nextIndex = -1;
let nextHeader = "";
if (contextIndex >= 0 && (targetIndex < 0 || contextIndex <= targetIndex)) {
nextIndex = contextIndex;
nextHeader = EXTRACTION_CONTEXT_REVIEW_HEADER;
} else if (targetIndex >= 0) {
nextIndex = targetIndex;
nextHeader = EXTRACTION_TARGET_CONTENT_HEADER;
}
if (nextIndex < 0 || !nextHeader) {
break;
}
headerMatches.push({
index: nextIndex,
header: nextHeader,
});
searchIndex = nextIndex + nextHeader.length;
}
if (headerMatches.length < 2 || headerMatches[0].index !== 0) {
return [message];
}
const { role: _role, content: _content, ...sharedMeta } = message;
const splitMessages = [];
for (let index = 0; index < headerMatches.length; index += 1) {
const current = headerMatches[index];
const next = headerMatches[index + 1];
const sectionBody = content
.slice(current.index + current.header.length, next ? next.index : content.length)
.trim();
const transcriptSection =
current.header === EXTRACTION_CONTEXT_REVIEW_HEADER ? "context" : "target";
splitMessages.push(
createExecutionMessage(
"system",
sectionBody ? `${current.header}\n\n${sectionBody}` : current.header,
{
...sharedMeta,
sourceKey,
transcriptSection,
transcriptSectionPart: "section",
},
),
);
}
return splitMessages.filter(Boolean);
}
function expandSectionedTranscriptPayloadMessages(messages = []) {
return (Array.isArray(messages) ? messages : []).flatMap((message) =>
splitSectionedTranscriptPayloadMessage(message),
);
}
function collectPayloadUserMessageTexts(messages = []) {
return (Array.isArray(messages) ? messages : [])
.filter((message) => String(message?.role || "").trim().toLowerCase() === "user")
@@ -2008,8 +2102,11 @@ export function buildTaskLlmPayload(promptBuild = null, fallbackUserPrompt = "")
!(isCustomFilter && messageUsesWorldInfoContent(message)),
},
);
const expandedExecutionMessages = expandSectionedTranscriptPayloadMessages(
executionMessages,
);
const hasUserMessage = executionMessages.some(
const hasUserMessage = expandedExecutionMessages.some(
(message) => message.role === "user",
);
if (!hasUserMessage && rawExecutionMessages.length > 0) {
@@ -2028,7 +2125,7 @@ export function buildTaskLlmPayload(promptBuild = null, fallbackUserPrompt = "")
`after recreate=${userBlocksAfterRaw.length}, ` +
`after sanitize=${userBlocksAfterSanitize.length}, ` +
`blockedContents count=${blockedContents.length}, ` +
`total executionMessages=${executionMessages.length}`,
`total executionMessages=${expandedExecutionMessages.length}`,
);
if (userBlocksBefore.length > 0) {
for (const block of userBlocksBefore) {
@@ -2046,17 +2143,19 @@ export function buildTaskLlmPayload(promptBuild = null, fallbackUserPrompt = "")
}
}
const additionalMessages =
executionMessages.length > 0
expandedExecutionMessages.length > 0
? []
: sanitizePromptMessages(
settings,
taskType,
rawPrivateTaskMessages,
{
blockedContents,
applySanitizer: (message) =>
!(isCustomFilter && messageUsesWorldInfoContent(message)),
},
: expandSectionedTranscriptPayloadMessages(
sanitizePromptMessages(
settings,
taskType,
rawPrivateTaskMessages,
{
blockedContents,
applySanitizer: (message) =>
!(isCustomFilter && messageUsesWorldInfoContent(message)),
},
),
);
const hasAdditionalUserMessage = additionalMessages.some(
(message) => message.role === "user",
@@ -2078,9 +2177,11 @@ export function buildTaskLlmPayload(promptBuild = null, fallbackUserPrompt = "")
return {
systemPrompt:
executionMessages.length > 0 ? "" : String(promptBuild?.systemPrompt || ""),
expandedExecutionMessages.length > 0
? ""
: String(promptBuild?.systemPrompt || ""),
userPrompt: fallbackUserPromptResult.text,
promptMessages: executionMessages,
promptMessages: expandedExecutionMessages,
additionalMessages,
fallbackUserPromptSource: fallbackUserPromptResult.source,
fallbackUserPromptApplied: Boolean(fallbackUserPromptResult.text),

View File

@@ -76,9 +76,23 @@ const chat = [
{
const mixed = [
{ seq: 1, role: "user", content: "context user", speaker: "A", isContextOnly: true },
{ seq: 2, role: "assistant", content: "context ai", speaker: "B", isContextOnly: true },
{
seq: 2,
role: "assistant",
content: "context ai",
speaker: "B",
hideSpeakerLabel: true,
isContextOnly: true,
},
{ seq: 3, role: "user", content: "target user", speaker: "A", isContextOnly: false },
{ seq: 4, role: "assistant", content: "target ai", speaker: "B", isContextOnly: false },
{
seq: 4,
role: "assistant",
content: "target ai",
speaker: "B",
hideSpeakerLabel: true,
isContextOnly: false,
},
];
const transcript = formatExtractionTranscript(mixed);
assert.match(transcript, /已提取过/, "transcript should contain context review header");
@@ -89,6 +103,8 @@ const chat = [
);
assert.match(transcript, /#1.*context user/, "context message should appear");
assert.match(transcript, /#3.*target user/, "target message should appear");
assert.match(transcript, /#2 \[assistant\]: context ai/, "assistant card name should be hidden");
assert.doesNotMatch(transcript, /#2 \[assistant\|B\]:/, "assistant card name should not be rendered");
console.log(" ✓ formatExtractionTranscript: section dividers for mixed context/target");
}
@@ -132,8 +148,20 @@ const chat = [
const targetFiltered = result.filteredMessages.filter((m) => !m.isContextOnly);
assert.equal(contextFiltered.length, 2, "context messages propagated through filtering");
assert.equal(targetFiltered.length, 2, "target messages propagated through filtering");
assert.equal(
result.filteredMessages.find((m) => m.seq === 2)?.hideSpeakerLabel,
true,
"active character assistant label should be hidden",
);
assert.equal(
result.filteredMessages.find((m) => m.seq === 1)?.hideSpeakerLabel,
false,
"user label should remain visible",
);
assert.match(result.filteredTranscript, /已提取过/, "transcript includes context header");
assert.match(result.filteredTranscript, /本次需要提取/, "transcript includes target header");
assert.match(result.filteredTranscript, /#2 \[assistant\]: old answer/, "assistant transcript should hide character name");
assert.doesNotMatch(result.filteredTranscript, /#2 \[assistant\|B\]:/, "assistant transcript should not show character name");
console.log(" ✓ buildExtractionInputContext: isContextOnly propagated to filteredMessages and transcript");
}

View File

@@ -141,8 +141,9 @@ try {
(message) => message.sourceKey === "recentMessages",
);
assert.ok(recentBlock);
assert.match(String(recentBlock?.content || ""), /#10 \[assistant\|艾琳\]: 继续说明/);
assert.match(String(recentBlock?.content || ""), /#10 \[assistant\]: 继续说明/);
assert.match(String(recentBlock?.content || ""), /#11 \[user\|玩家\]: 用户输入/);
assert.doesNotMatch(String(recentBlock?.content || ""), /#10 \[assistant\|艾琳\]:/);
assert.doesNotMatch(String(recentBlock?.content || ""), /隐式思维|<think>/);
} finally {
restore();

View File

@@ -224,16 +224,25 @@ function collectAllPromptContent(captured) {
assert.equal(result.success, true);
assert.ok(captured);
const recentBlock = (Array.isArray(captured.promptMessages) ? captured.promptMessages : []).find(
const recentMessages = (Array.isArray(captured.promptMessages)
? captured.promptMessages
: []
).filter(
(m) => m.sourceKey === "recentMessages",
);
assert.ok(recentBlock, "recentMessages block should exist");
const recentContent = String(recentBlock?.content || "");
assert.match(recentContent, /以下是上下文回顾(已提取过),仅供理解剧情/);
assert.match(recentContent, /以下是本次需要提取记忆的新对话内容/);
assert.equal(recentMessages.length, 2, "recentMessages should split into 2 section system messages");
assert.equal(recentMessages[0]?.role, "system");
assert.equal(recentMessages[0]?.transcriptSection, "context");
assert.match(String(recentMessages[0]?.content || ""), /^--- 以下是上下文回顾(已提取过),仅供理解剧情 ---/);
assert.match(String(recentMessages[0]?.content || ""), /#10 \[user\|玩家\]: 第一轮消息/);
assert.equal(recentMessages[1]?.role, "system");
assert.equal(recentMessages[1]?.transcriptSection, "target");
assert.match(String(recentMessages[1]?.content || ""), /^--- 以下是本次需要提取记忆的新对话内容 ---/);
assert.match(String(recentMessages[1]?.content || ""), /#12 \[user\|玩家\]: 第二轮消息/);
assert.ok(
recentContent.indexOf("已提取过") < recentContent.indexOf("本次需要提取"),
"context review should appear before extraction target section",
recentMessages[0].content.includes("提取") &&
recentMessages[1].content.includes("本次需要提取"),
"context and target sections should each be emitted as a single system message",
);
} finally {
restore();

View File

@@ -286,7 +286,7 @@ try {
).find((message) => message.sourceKey === "recentMessages");
assert.ok(recentBlock, "recentMessages block should exist");
const recentContent = String(recentBlock?.content || "");
assert.match(recentContent, /#30 \[assistant\|艾琳\]: 艾琳说:去调查蓝钥匙。/);
assert.match(recentContent, /#30 \[assistant\]: 艾琳说:去调查蓝钥匙。/);
assert.match(
recentContent,
/#31 \[assistant\|旁白\]: 旁白补充:<status mood='tense'>雨夜<\/status>巷子很安静。/,
@@ -351,7 +351,7 @@ try {
: []
).find((message) => message.sourceKey === "recentMessages");
assert.ok(recentBlock, "recentMessages block should still exist when worldbook is disabled");
assert.match(String(recentBlock?.content || ""), /#30 \[assistant\|艾琳\]: 艾琳说:去调查蓝钥匙。/);
assert.match(String(recentBlock?.content || ""), /#30 \[assistant\]: 艾琳说:去调查蓝钥匙。/);
} finally {
restore();
}

View File

@@ -117,6 +117,7 @@ const promptBuild = await buildTaskPrompt(settings, "extract", {
content: "继续说明",
name: "艾琳",
speaker: "艾琳",
hideSpeakerLabel: true,
isContextOnly: true,
},
{
@@ -133,25 +134,41 @@ const promptBuild = await buildTaskPrompt(settings, "extract", {
currentRange: "41 ~ 42",
});
const payload = buildTaskLlmPayload(promptBuild, "fallback-user");
const recentBlock = payload.promptMessages.find(
const recentMessages = payload.promptMessages.filter(
(message) => message.sourceKey === "recentMessages",
);
assert.match(
String(recentBlock?.content || ""),
/以下是上下文回顾(已提取过),仅供理解剧情/,
assert.deepEqual(
recentMessages.map((message) => ({
role: message.role,
sourceKey: message.sourceKey,
transcriptSection: message.transcriptSection,
transcriptSectionPart: message.transcriptSectionPart,
})),
[
{
role: "system",
sourceKey: "recentMessages",
transcriptSection: "context",
transcriptSectionPart: "section",
},
{
role: "system",
sourceKey: "recentMessages",
transcriptSection: "target",
transcriptSectionPart: "section",
},
],
);
assert.match(
String(recentBlock?.content || ""),
/以下是本次需要提取记忆的新对话内容/,
);
assert.match(String(recentBlock?.content || ""), /#41 \[assistant\|艾琳\]: 助手已净化/);
assert.match(String(recentBlock?.content || ""), /#42 \[user\|玩家\]: 用户已净化/);
assert.match(String(recentMessages[0]?.content || ""), /^--- 以下是上下文回顾(已提取过),仅供理解剧情 ---/);
assert.match(String(recentMessages[0]?.content || ""), /#41 \[assistant\]: 助手已净化/);
assert.match(String(recentMessages[1]?.content || ""), /^--- 以下是本次需要提取记忆的新对话内容 ---/);
assert.match(String(recentMessages[1]?.content || ""), /#42 \[user\|玩家\]: 用户已净化/);
assert.doesNotMatch(
String(recentBlock?.content || ""),
/#41 \[assistant\|艾琳\]: 用户已净化/,
String(recentMessages[0]?.content || ""),
/#41 \[assistant\|艾琳\]:/,
);
assert.doesNotMatch(
String(recentBlock?.content || ""),
String(recentMessages[1]?.content || ""),
/#42 \[user\|玩家\]: 助手已净化/,
);