1
Some checks failed
CI / verify (push) Has been cancelled

This commit is contained in:
2026-04-20 15:45:14 +08:00
parent 8a7bd90458
commit 1c72066bab
73 changed files with 7814 additions and 1018 deletions

View File

@@ -4,6 +4,25 @@ import {
getActionTemplateById,
} from '../../../packages/shared/src/prompts/qwenSprite.js';
/**
* 角色资产正式 prompt 主源。
*
* 这份脚本同时承担两层职责:
* 1. 角色卡 -> 默认资产描述文本
* - 产出 visualPromptText / animationPromptText / scenePromptText
* - 这层本质上是在“编译默认描述文本”,不是最终直接发给图像模型的完整 prompt
* 2. 默认描述文本 -> 正式模型 prompt
* - buildNpcVisualPrompt / buildNpcAnimationPrompt / buildArkCharacterAnimationPrompt
* - 这层才是正式发给图像 / 动作模型的 prompt 组装入口
*
* 当前仓库状态需要特别区分:
* - 当前自定义世界角色资产工坊默认输入框,实际直接使用前端
* src/prompts/customWorldRolePromptDefaults.ts
* - 本文件里的 CHARACTER_PROMPT_BUNDLE_SYSTEM_PROMPT 及其生成接口
* /api/assets/character-prompts/generate 目前仍保留、可用、且有测试覆盖,
* 但不是当前资产工坊初始默认值的主链来源
* - 当前正式角色主图与动作生成,仍然走本文件里的正式 prompt builder
*/
function clampPromptSeedText(value: unknown, maxLength: number) {
if (typeof value !== 'string') {
return '';
@@ -39,6 +58,17 @@ export type CharacterPromptBundle = {
model: string | null;
};
/**
* 当默认描述文本编译接口不可用,或当前环境不走 LLM 编译时,
* 用角色卡字段本地拼出一份可直接使用的默认文本 bundle。
*
* 这份返回值属于“默认描述文本层”:
* - visualPromptText: 给角色主图用的默认描述
* - animationPromptText: 给动作试片用的默认描述
* - scenePromptText: 给角色关联场景用的默认描述
*
* 它不是最终发给正式图像 / 动作模型的完整 prompt。
*/
export function buildFallbackCharacterPromptBundle(params: {
characterName: string;
roleKind: string;
@@ -108,6 +138,12 @@ function sanitizePromptBundleValue(
return normalized || fallback;
}
/**
* 将 LLM 返回的默认文本 bundle 规整成稳定结构。
*
* 这里只负责兜底、限长和字段补齐,不负责把 bundle 进一步编译成
* 正式图像 / 动作生成 prompt。
*/
export function sanitizeCharacterPromptBundle(
value: unknown,
fallback: CharacterPromptBundle,
@@ -161,6 +197,13 @@ function buildCompactAnimationCharacterBrief(value: string) {
.join('');
}
/**
* 默认文本 bundle 的 user prompt。
*
* 这段文本只用于让 LLM 从角色卡摘要里提炼出
* visualPromptText / animationPromptText / scenePromptText 三段默认描述,
* 不是正式图像模型或动作模型的 system prompt。
*/
export function buildCharacterPromptBundleUserPrompt(params: {
roleKind: string;
characterBriefText: string;
@@ -197,6 +240,17 @@ export function buildCharacterPromptBundleUserPrompt(params: {
.join('\n');
}
/**
* 正式角色主图 prompt 编译入口。
*
* 输入的 promptText 通常是资产工坊输入框里的“形象描述”文本;
* 这里会把它和角色摘要合并,再交给共享层 buildMasterPrompt
* 产出真正发给图像模型的正式 prompt。
*
* 因此:
* - promptText = 默认描述文本层
* - buildNpcVisualPrompt 返回值 = 正式图像生成 prompt 层
*/
export function buildNpcVisualPrompt(promptText: string, characterBriefText = '') {
const mergedBrief = [characterBriefText.trim(), promptText.trim()]
.filter(Boolean)
@@ -207,6 +261,11 @@ export function buildNpcVisualPrompt(promptText: string, characterBriefText = ''
);
}
/**
* 正式角色主图生成的负向提示词。
*
* 只服务于图像生成请求,不参与默认描述文本生成。
*/
export function buildNpcVisualNegativePrompt() {
return [
'正面视角',
@@ -239,6 +298,12 @@ export function buildNpcVisualNegativePrompt() {
].join('');
}
/**
* 连续序列帧方案的正式动作 prompt。
*
* 这是“图像序列帧”动作生成链路使用的正式 prompt
* 不属于默认描述文本层。
*/
export function buildImageSequencePrompt(
animation: string,
promptText: string,
@@ -258,6 +323,15 @@ export function buildImageSequencePrompt(
.join(' ');
}
/**
* 通用动作视频方案的正式动作 prompt。
*
* 输入的 promptText 是动作描述文本;
* 输出的是可以直接提交给动作模型的视频 prompt。
*
* 当前仓库里它主要服务于非 Ark 的动作视频链路,
* 以及某些保留的动作生成策略。
*/
export function buildNpcAnimationPrompt(options: {
animation: string;
promptText: string;
@@ -309,6 +383,13 @@ export function buildNpcAnimationPrompt(options: {
.join(' ');
}
/**
* Ark 图生视频动作链路的正式动作 prompt。
*
* 当前自定义世界角色资产工坊的主动作生成流程,
* 最终会走到这个 builder。它会在共享模板的基础上
* 叠加 Ark 所需的首帧 / 尾帧约束与动作英文名约束。
*/
export function buildArkCharacterAnimationPrompt(options: {
animation: string;
promptText: string;
@@ -359,6 +440,11 @@ export function buildArkCharacterAnimationPrompt(options: {
.join(' ');
}
/**
* 当正式动作 prompt 触发审核或兼容问题时的保守兜底动作 prompt。
*
* 这条链路是正式动作生成的降级方案,不参与默认描述文本生成。
*/
export function buildFallbackModerationSafeAnimationPrompt(options: {
animation: string;
loop: boolean;

View File

@@ -28,10 +28,12 @@ export const NPC_CHAT_DIALOGUE_STRICT_SYSTEM_PROMPT = `你是角色扮演 RPG
硬性规则:
- 每一行都必须严格以“你:”或“角色名字:”开头。
- 第一行必须是“你:”开头。
- 总行数控制在 4 到 6 行。
- 玩家和对方至少各说 2 次。
- 这段内容只是聊天,不是做决定。
- 如果当前要求是“由 NPC 主动开口”,第一行必须是“角色名字:”开头,且第一句先是自然招呼或开场判断。
- 如果当前不是“由 NPC 主动开口”,第一行必须是“你:”开头。
- 如果这是双方第一次真正接触,对方第一次开口必须先是自然招呼或开场判断,不能写成第三人称占位旁白。
- 禁止在聊天里主动引导、建议、安排或预告交易、招募、切磋、战斗、送礼、求助、离开、继续前进、切换场景等其他 function。
- 禁止把情报直接写成对玩家的指令。
- 结束时要让玩家感觉到气氛、情报或关系发生了变化,但变化仍停留在聊天层面。`;
@@ -52,6 +54,7 @@ export const NPC_RECRUIT_DIALOGUE_SYSTEM_PROMPT = `你是角色扮演 RPG 的招
export const NPC_CHAT_TURN_REPLY_SYSTEM_PROMPT = `你是角色扮演 RPG 里的当前 NPC。
你只输出这名 NPC 此刻会对玩家说的一轮回复。
只输出纯中文口语回复正文不要输出角色名、引号、旁白、动作描写、Markdown、JSON 或解释。
- 如果这是第一次真正接触中的首轮回复,第一句必须先用自然招呼或开场判断起手,不能写成第三人称占位旁白。
回复长度控制在 1 到 3 句,必须紧接玩家刚说的话,自然推进气氛、情报或关系。`;
export const NPC_CHAT_TURN_SUGGESTION_SYSTEM_PROMPT = `你要为玩家生成下一轮可直接点击的 3 条聊天续写候选。
@@ -73,6 +76,10 @@ function readNumber(value: unknown, fallback = 0) {
return typeof value === 'number' && Number.isFinite(value) ? value : fallback;
}
function readBoolean(value: unknown, fallback = false) {
return typeof value === 'boolean' ? value : fallback;
}
function readStringArray(value: unknown) {
return Array.isArray(value)
? value
@@ -81,6 +88,22 @@ function readStringArray(value: unknown) {
: [];
}
function describeFirstContactRelationStance(value: unknown) {
const stance = readString(value);
switch (stance) {
case 'guarded':
return '戒备试探';
case 'neutral':
return '正常交流但仍不熟';
case 'cooperative':
return '已有善意,先确认合作节奏';
case 'bonded':
return '明显信任,但仍是第一次正式对上人';
default:
return '第一次真正接触';
}
}
function describeWorld(worldType: string) {
switch (worldType) {
case 'WUXIA':
@@ -384,11 +407,31 @@ export function buildStrictNpcChatDialoguePrompt(
const openingCampDialogue = readString(context?.openingCampDialogue);
const allowedTopics = readStringArray(context?.encounterAllowedTopics);
const blockedTopics = readStringArray(context?.encounterBlockedTopics);
const isFirstMeaningfulContact = readBoolean(
context?.isFirstMeaningfulContact,
false,
);
const npcInitiatesConversation = readBoolean(
payload.npcInitiatesConversation,
false,
);
const firstContactRelationStance = describeFirstContactRelationStance(
context?.firstContactRelationStance,
);
return [
buildNpcDialoguePromptBase(payload),
openingCampBackground ? `营地开场背景:${openingCampBackground}` : null,
openingCampDialogue ? `刚刚发生的第一段对话:${openingCampDialogue}` : null,
isFirstMeaningfulContact
? `当前接触阶段:第一次真正接触(${firstContactRelationStance})。对方第一次开口必须先给一句自然招呼或开场判断,再进入眼前话题。`
: null,
isFirstMeaningfulContact
? '禁止写成“某人看着你,像是在等你把话接下去”这类第三人称占位旁白,也不要用系统说明代替对白。'
: null,
npcInitiatesConversation
? `当前要求:由 ${encounter.npcName} 主动开口。第一行必须是“${encounter.npcName}:”,不要先替玩家说话。`
: '当前要求:玩家先挑起这段话,第一行必须是“你:”。',
allowedTopics.length > 0
? `当前更适合谈的内容:${allowedTopics.join('、')}`
: null,
@@ -427,21 +470,96 @@ export function buildNpcChatTurnReplyPrompt(
payload: NpcChatTurnRequest,
) {
const encounter = describeEncounter(payload.encounter);
const context = asRecord(payload.context);
const npcState = asRecord(payload.npcState);
const chatDirective = asRecord(payload.chatDirective);
const conversationHistory =
Array.isArray(payload.conversationHistory) && payload.conversationHistory.length > 0
? payload.conversationHistory
: payload.dialogue ?? payload.conversationHistory ?? [];
const openingCampBackground = readString(context?.openingCampBackground);
const openingCampDialogue = readString(context?.openingCampDialogue);
const allowedTopics = readStringArray(context?.encounterAllowedTopics);
const blockedTopics = readStringArray(context?.encounterBlockedTopics);
const isFirstMeaningfulContact = readBoolean(
context?.isFirstMeaningfulContact,
false,
);
const affinity = readNumber(npcState?.affinity, 0);
const chattedCount = readNumber(npcState?.chattedCount, 0);
const limitReason = readString(chatDirective?.limitReason);
const turnLimit = Math.max(0, readNumber(chatDirective?.turnLimit, 0));
const remainingTurns = Math.max(0, readNumber(chatDirective?.remainingTurns, 0));
const closingMode = readString(chatDirective?.closingMode);
const isLimitedNegativeAffinityChat =
limitReason === 'negative_affinity' && turnLimit > 0;
const isForeshadowCloseTurn =
closingMode === 'foreshadow_close' ||
readBoolean(chatDirective?.forceExitAfterTurn, false);
const hasNpcReplyInHistory = conversationHistory.some((item) => {
const turn = asRecord(item);
return readString(turn?.speaker) === 'npc';
});
const npcInitiatesConversation = readBoolean(
payload.npcInitiatesConversation,
false,
);
const isFirstNpcSpokenTurn =
isFirstMeaningfulContact && !hasNpcReplyInHistory && chattedCount <= 0;
const firstContactRelationStance = describeFirstContactRelationStance(
context?.firstContactRelationStance,
);
const playerMessage = payload.playerMessage.trim();
return [
buildNpcDialoguePromptBase(payload),
describeNpcConversationHistory(conversationHistory, encounter.npcName),
openingCampBackground ? `营地开场背景:${openingCampBackground}` : null,
openingCampDialogue ? `刚刚发生的第一段对话:${openingCampDialogue}` : null,
`当前关系值:${affinity}`,
`已聊天轮次:${chattedCount}`,
`玩家刚刚说:${payload.playerMessage}`,
`现在请只写 ${encounter.npcName} 这一轮会回复玩家的话`,
isFirstNpcSpokenTurn
? `当前接触阶段:第一次真正接触(${firstContactRelationStance})。这是这次聊天里 ${encounter.npcName} 第一次真正对玩家开口`
: null,
isFirstNpcSpokenTurn
? '第一句必须先用一句自然招呼或开场判断起手,再顺着玩家刚刚的话往下接。'
: null,
isFirstNpcSpokenTurn
? '不要写成“某人看着你,像是在等你把话接下去”这类第三人称占位旁白,也不要把整轮写成设定说明。'
: null,
npcInitiatesConversation
? `当前要求:这是 ${encounter.npcName} 主动开口的第一句,不要假装玩家已经先说过话。`
: null,
allowedTopics.length > 0
? `当前更适合先谈:${allowedTopics.join('、')}`
: null,
blockedTopics.length > 0
? `当前避免直接说破:${blockedTopics.join('、')}`
: null,
isLimitedNegativeAffinityChat
? `当前相遇属于负好感主角色有限聊天,本次总上限 ${turnLimit} 轮。`
: null,
isLimitedNegativeAffinityChat
? `在你回复完这一轮之后,还剩 ${remainingTurns} 轮可以继续聊。`
: null,
isLimitedNegativeAffinityChat && !isForeshadowCloseTurn
? '语气可以戒备、冷淡、带刺,但不要立刻转成开战,也不要把对话硬掐死。'
: null,
isForeshadowCloseTurn
? '这是最后一轮回复。必须带有收束感,但不能只用“别问了”“滚开”之类的话把聊天粗暴截断。'
: null,
isForeshadowCloseTurn
? '最后一轮必须抛出能推动后续剧情的明确铺垫,例如威胁、线索、条件、去处、人物、未说完的真相或下一步悬念。'
: null,
isForeshadowCloseTurn
? '回复后这轮聊天会结束,所以不要邀请继续闲聊,也不要直接宣布已经开战。'
: null,
npcInitiatesConversation
? '玩家此刻还没有先说话,请直接写 NPC 主动开口时会说的第一轮回复。'
: `玩家刚刚说:${playerMessage}`,
npcInitiatesConversation
? `现在请只写 ${encounter.npcName} 主动开口时会说的话。`
: `现在请只写 ${encounter.npcName} 这一轮会回复玩家的话。`,
]
.filter(Boolean)
.join('\n\n');