Files
Genarrative/server-node/src/modules/ai/chatPromptBuilders.ts
高物 8c3fbd9bcf
Some checks failed
CI / verify (push) Has been cancelled
1
2026-04-18 19:40:33 +08:00

456 lines
16 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import type {
CharacterChatReplyRequest,
CharacterChatSuggestionsRequest,
CharacterChatSummaryRequest,
NpcChatDialogueRequest,
NpcChatTurnRequest,
NpcRecruitDialogueRequest,
} from '../../../../packages/shared/src/contracts/story.js';
type JsonRecord = Record<string, unknown>;
export const CHARACTER_PANEL_CHAT_SYSTEM_PROMPT = `你是像素动作 RPG 里的同行角色。
只回复这名角色此刻会对玩家说的话。
不要输出角色名、引号、旁白、动作提示、Markdown、JSON 或解释。
保持人设,结合最近剧情和关系变化,回复简洁自然。`;
export const CHARACTER_PANEL_CHAT_SUGGESTION_SYSTEM_PROMPT = `生成恰好 3 条玩家回复建议。
只输出纯文本,共 3 行,每行一条。
不要加编号、项目符号、Markdown 或额外说明。
三条建议语气要有区分:关心、追问、轻松或拉近关系。`;
export const CHARACTER_PANEL_CHAT_SUMMARY_SYSTEM_PROMPT = `总结玩家与这名角色之间不断变化的关系。
只输出一段简洁文字。
包含当前关系气氛、态度变化,以及最近聊天里最重要的新信息、承诺、担忧或线索。`;
export const NPC_CHAT_DIALOGUE_STRICT_SYSTEM_PROMPT = `你是角色扮演 RPG 的角色对话编剧。
你只能输出纯中文对话正文不能输出解释、代码、markdown、JSON 或额外说明。
硬性规则:
- 每一行都必须严格以“你:”或“角色名字:”开头。
- 第一行必须是“你:”开头。
- 总行数控制在 4 到 6 行。
- 玩家和对方至少各说 2 次。
- 这段内容只是聊天,不是做决定。
- 禁止在聊天里主动引导、建议、安排或预告交易、招募、切磋、战斗、送礼、求助、离开、继续前进、切换场景等其他 function。
- 禁止把情报直接写成对玩家的指令。
- 结束时要让玩家感觉到气氛、情报或关系发生了变化,但变化仍停留在聊天层面。`;
export const NPC_RECRUIT_DIALOGUE_SYSTEM_PROMPT = `你是角色扮演 RPG 的招募剧情对话编剧。
你只能输出纯中文对话正文不能输出解释、代码、markdown、JSON 或额外说明。
硬性规则:
- 每一行都必须严格以“你:”或“角色名字:”开头。
- 第一行必须是“你:”开头。
- 总行数控制在 4 到 6 行。
- 玩家和对方至少各说 2 次。
- 这段对话的目标是把“邀请对方入队”自然谈成。
- 不允许出现拒绝入队、继续观望、以后再说、条件未满足等结果。
- 不允许出现“我不能答应”“我还没想好”“再让我考虑”“暂时不行”“以后再说”这类拒绝或拖延表述。
- 最后一行必须由对方明确答应加入队伍。`;
export const NPC_CHAT_TURN_REPLY_SYSTEM_PROMPT = `你是角色扮演 RPG 里的当前 NPC。
你只输出这名 NPC 此刻会对玩家说的一轮回复。
只输出纯中文口语回复正文不要输出角色名、引号、旁白、动作描写、Markdown、JSON 或解释。
回复长度控制在 1 到 3 句,必须紧接玩家刚说的话,自然推进气氛、情报或关系。`;
export const NPC_CHAT_TURN_SUGGESTION_SYSTEM_PROMPT = `你要为玩家生成下一轮可直接点击的 3 条聊天续写候选。
只输出纯文本,共 3 行,每行 1 条。
不要加编号、项目符号、Markdown、JSON 或额外说明。
三条候选必须明显不同,分别体现继续追问、表达态度、轻微拉近关系这三种不同方向。`;
function asRecord(value: unknown): JsonRecord | null {
return value && typeof value === 'object' && !Array.isArray(value)
? (value as JsonRecord)
: null;
}
function readString(value: unknown) {
return typeof value === 'string' && value.trim() ? value.trim() : null;
}
function readNumber(value: unknown, fallback = 0) {
return typeof value === 'number' && Number.isFinite(value) ? value : fallback;
}
function readStringArray(value: unknown) {
return Array.isArray(value)
? value
.map((item) => readString(item))
.filter((item): item is string => Boolean(item))
: [];
}
function describeWorld(worldType: string) {
switch (worldType) {
case 'WUXIA':
return '边城模板';
case 'XIANXIA':
return '灵潮模板';
case 'CUSTOM':
return '自定义世界';
default:
return worldType || '未知世界';
}
}
function describeStats(label: string, record: JsonRecord | null) {
const hp = readNumber(record?.hp);
const maxHp = Math.max(1, readNumber(record?.maxHp, hp));
const mana = readNumber(record?.mana);
const maxMana = Math.max(1, readNumber(record?.maxMana, mana));
return `${label}生命 ${hp}/${maxHp},灵力 ${mana}/${maxMana}`;
}
function describeCharacter(label: string, value: unknown) {
const record = asRecord(value);
const name = readString(record?.name) ?? '未知角色';
const title = readString(record?.title) ?? '未知称号';
const description = readString(record?.description) ?? '暂无额外描述';
const personality = readString(record?.personality) ?? '性格信息未显式提供';
return [
`${label}姓名:${name}`,
`${label}称号:${title}`,
`${label}描述:${description}`,
`${label}性格:${personality}`,
].join('\n');
}
function describeStoryHistory(history: unknown) {
if (!Array.isArray(history) || history.length === 0) {
return '近期剧情:暂无。';
}
const lines = history
.slice(-4)
.map((item) => readString(asRecord(item)?.text))
.filter((item): item is string => Boolean(item));
return lines.length > 0
? ['近期剧情:', ...lines.map((line) => `- ${line}`)].join('\n')
: '近期剧情:暂无。';
}
function describeConversationHistory(history: unknown) {
if (!Array.isArray(history) || history.length === 0) {
return '聊天记录:暂无。';
}
const lines = history
.slice(-12)
.map((item) => {
const record = asRecord(item);
const speaker = readString(record?.speaker) === 'player' ? '玩家' : '角色';
const text = readString(record?.text);
return text ? `- ${speaker}${text}` : null;
})
.filter((item): item is string => Boolean(item));
return lines.length > 0
? ['聊天记录:', ...lines].join('\n')
: '聊天记录:暂无。';
}
function describeNpcConversationHistory(history: unknown, npcName: string) {
if (!Array.isArray(history) || history.length === 0) {
return '当前聊天记录:暂无。';
}
const lines = history
.slice(-10)
.map((item) => {
const record = asRecord(item);
const speaker = readString(record?.speaker);
const speakerName = readString(record?.speakerName);
const text = readString(record?.text);
if (!text) return null;
if (speaker === 'player') {
return `- 玩家:${text}`;
}
if (speaker === 'npc') {
return `- ${speakerName ?? npcName}${text}`;
}
if (speaker === 'system') {
return `- 系统提示:${text}`;
}
return `- ${speakerName ?? '同伴'}${text}`;
})
.filter((item): item is string => Boolean(item));
return lines.length > 0
? ['当前聊天记录:', ...lines].join('\n')
: '当前聊天记录:暂无。';
}
function describeSceneContext(context: unknown) {
const record = asRecord(context);
const sceneName = readString(record?.sceneName) ?? '当前区域';
const sceneDescription =
readString(record?.sceneDescription) ?? '周围气氛仍未完全安定。';
const inBattle = record?.inBattle === true ? '战斗中' : '非战斗';
const customWorldProfile = asRecord(record?.customWorldProfile);
const customWorldName = readString(customWorldProfile?.name);
const customWorldSummary = readString(customWorldProfile?.summary);
return [
`世界补充:${customWorldName ?? '无'}`,
customWorldSummary ? `世界摘要:${customWorldSummary}` : null,
`场景:${sceneName}`,
`场景描述:${sceneDescription}`,
`当前状态:${inBattle}`,
describeStats('玩家', record),
]
.filter(Boolean)
.join('\n');
}
function describeTargetStatus(status: unknown) {
const record = asRecord(status);
const roleLabel = readString(record?.roleLabel) ?? '同行角色';
const affinity = record?.affinity;
return [
`对方身份:${roleLabel}`,
describeStats('对方', record),
typeof affinity === 'number' ? `当前好感:${affinity}` : null,
]
.filter(Boolean)
.join('\n');
}
function describeEncounter(encounter: unknown) {
const record = asRecord(encounter);
const npcName = readString(record?.npcName) ?? '眼前角色';
const contextText =
readString(record?.context) ??
readString(record?.npcDescription) ??
'你们正在当前遭遇里继续对话。';
return {
npcName,
block: [`当前对象:${npcName}`, `对象背景:${contextText}`].join('\n'),
};
}
function describeMonsters(monsters: unknown) {
if (!Array.isArray(monsters) || monsters.length === 0) {
return '当前敌对目标:无。';
}
const lines = monsters
.slice(0, 4)
.map((item) => {
const record = asRecord(item);
const name =
readString(record?.name) ??
readString(record?.npcName) ??
readString(record?.id);
const hp = readNumber(record?.hp);
const maxHp = Math.max(1, readNumber(record?.maxHp, hp));
return name ? `- ${name}(生命 ${hp}/${maxHp}` : null;
})
.filter((item): item is string => Boolean(item));
return lines.length > 0
? ['当前敌对目标:', ...lines].join('\n')
: '当前敌对目标:无。';
}
function describeTargetCharacterName(payload: {
targetCharacter?: unknown;
encounter?: unknown;
}) {
return (
readString(asRecord(payload.targetCharacter)?.name) ??
readString(asRecord(payload.encounter)?.npcName) ??
'对方'
);
}
export function buildCharacterPanelChatPrompt(
payload: CharacterChatReplyRequest,
) {
const targetName = describeTargetCharacterName(payload);
return [
`世界:${describeWorld(payload.worldType)}`,
describeSceneContext(payload.context),
describeCharacter('玩家 / ', payload.playerCharacter),
describeCharacter('对方 / ', payload.targetCharacter),
describeTargetStatus(payload.targetStatus),
describeStoryHistory(payload.storyHistory),
payload.conversationSummary
? `之前聊天摘要:${payload.conversationSummary}`
: '之前聊天摘要:暂无。',
describeConversationHistory(payload.conversationHistory),
`玩家刚刚对 ${targetName} 说:${payload.playerMessage}`,
`现在请以 ${targetName} 的身份,直接回复玩家。`,
]
.filter(Boolean)
.join('\n\n');
}
export function buildCharacterPanelChatSuggestionPrompt(
payload: CharacterChatSuggestionsRequest,
) {
const targetName = describeTargetCharacterName(payload);
const latestCharacterReply = Array.isArray(payload.conversationHistory)
? [...payload.conversationHistory]
.reverse()
.map((item) => asRecord(item))
.find((record) => readString(record?.speaker) === 'character')
: null;
const latestReplyText = readString(latestCharacterReply?.text);
return [
`世界:${describeWorld(payload.worldType)}`,
describeSceneContext(payload.context),
describeCharacter('玩家 / ', payload.playerCharacter),
describeCharacter('对方 / ', payload.targetCharacter),
describeTargetStatus(payload.targetStatus),
describeStoryHistory(payload.storyHistory),
payload.conversationSummary
? `之前聊天摘要:${payload.conversationSummary}`
: '之前聊天摘要:暂无。',
describeConversationHistory(payload.conversationHistory),
latestReplyText
? `角色刚刚的回复:${latestReplyText}`
: `玩家正准备与 ${targetName} 开始一段新的私聊。`,
`请围绕当前气氛,为玩家生成 3 条可以直接发送给 ${targetName} 的简短回复候选。`,
]
.filter(Boolean)
.join('\n\n');
}
export function buildCharacterPanelChatSummaryPrompt(
payload: CharacterChatSummaryRequest,
) {
const targetName = describeTargetCharacterName(payload);
return [
`世界:${describeWorld(payload.worldType)}`,
describeSceneContext(payload.context),
describeCharacter('玩家 / ', payload.playerCharacter),
describeCharacter('对方 / ', payload.targetCharacter),
describeTargetStatus(payload.targetStatus),
describeStoryHistory(payload.storyHistory),
payload.previousSummary
? `旧摘要:${payload.previousSummary}`
: '旧摘要:暂无。',
describeConversationHistory(payload.conversationHistory),
`请把玩家与 ${targetName} 的旧摘要和最新聊天整理成一段更新后的关系摘要。`,
]
.filter(Boolean)
.join('\n\n');
}
function buildNpcDialoguePromptBase(
payload: NpcChatDialogueRequest | NpcChatTurnRequest | NpcRecruitDialogueRequest,
) {
const encounter = describeEncounter(payload.encounter);
return [
`世界:${describeWorld(payload.worldType)}`,
describeSceneContext(payload.context),
describeCharacter('玩家 / ', payload.character),
encounter.block,
describeMonsters(payload.monsters),
describeStoryHistory(payload.history),
]
.filter(Boolean)
.join('\n\n');
}
export function buildStrictNpcChatDialoguePrompt(
payload: NpcChatDialogueRequest,
) {
const encounter = describeEncounter(payload.encounter);
const context = asRecord(payload.context);
const openingCampBackground = readString(context?.openingCampBackground);
const openingCampDialogue = readString(context?.openingCampDialogue);
const allowedTopics = readStringArray(context?.encounterAllowedTopics);
const blockedTopics = readStringArray(context?.encounterBlockedTopics);
return [
buildNpcDialoguePromptBase(payload),
openingCampBackground ? `营地开场背景:${openingCampBackground}` : null,
openingCampDialogue ? `刚刚发生的第一段对话:${openingCampDialogue}` : null,
allowedTopics.length > 0
? `当前更适合谈的内容:${allowedTopics.join('、')}`
: null,
blockedTopics.length > 0
? `当前避免直接说破:${blockedTopics.join('、')}`
: null,
`当前聊天主题:${payload.topic}`,
payload.resultSummary
? `这段聊天希望带来的变化:${payload.resultSummary}`
: '这段聊天要让气氛、情报或关系出现一层新的变化。',
`请围绕“${payload.topic}”写一段刚刚发生的对话。必须只输出对白正文,每一行都必须以“你:”或“${encounter.npcName}:”开头。`,
]
.filter(Boolean)
.join('\n\n');
}
export function buildNpcRecruitDialoguePrompt(
payload: NpcRecruitDialogueRequest,
) {
const encounter = describeEncounter(payload.encounter);
return [
buildNpcDialoguePromptBase(payload),
`玩家邀请:${payload.invitationText}`,
payload.recruitSummary
? `招募补充条件:${payload.recruitSummary}`
: '这轮对话已经具备自然邀请对方入队的条件。',
'这是一段“邀请对方入队”的对话。请让几轮交流逐步导向成功加入队伍,不要写出拒绝、观望或延期答复。',
`最后一行必须由 ${encounter.npcName} 明确答应加入队伍。`,
]
.filter(Boolean)
.join('\n\n');
}
export function buildNpcChatTurnReplyPrompt(
payload: NpcChatTurnRequest,
) {
const encounter = describeEncounter(payload.encounter);
const npcState = asRecord(payload.npcState);
const affinity = readNumber(npcState?.affinity, 0);
const chattedCount = readNumber(npcState?.chattedCount, 0);
return [
buildNpcDialoguePromptBase(payload),
describeNpcConversationHistory(payload.conversationHistory, encounter.npcName),
`当前关系值:${affinity}`,
`已聊天轮次:${chattedCount}`,
`玩家刚刚说:${payload.playerMessage}`,
`现在请只写 ${encounter.npcName} 这一轮会回复玩家的话。`,
]
.filter(Boolean)
.join('\n\n');
}
export function buildNpcChatTurnSuggestionPrompt(
payload: NpcChatTurnRequest,
npcReply: string,
) {
const encounter = describeEncounter(payload.encounter);
return [
buildNpcDialoguePromptBase(payload),
describeNpcConversationHistory(payload.conversationHistory, encounter.npcName),
`玩家刚刚说:${payload.playerMessage}`,
`NPC 刚刚回复:${npcReply}`,
`请围绕刚刚这轮对话,为玩家生成 3 条可以继续和 ${encounter.npcName} 聊下去的中文短句候选。`,
]
.filter(Boolean)
.join('\n\n');
}