514 lines
31 KiB
Rust
514 lines
31 KiB
Rust
use serde_json::Value as JsonValue;
|
||
|
||
const CUSTOM_WORLD_BACKSTORY_CHAPTER_AFFINITIES: [i64; 4] = [15, 30, 60, 90];
|
||
|
||
pub(crate) fn build_custom_world_framework_prompt(setting_text: &str) -> String {
|
||
[
|
||
"请先根据下面的玩家设定创建一份“世界核心骨架”,后续我会分步骤生成角色名单、场景名单和详细档案。".to_string(),
|
||
"你必须只输出一个能被 JSON.parse 直接解析的 JSON 对象,不要输出 Markdown、代码块、注释或解释。".to_string(),
|
||
"这一步只保留世界顶层信息与一个开局归处占位,不要输出 playableNpcs、storyNpcs、landmarks,也不要展开人物、地图细节或多幕场景内容。".to_string(),
|
||
"玩家设定:".to_string(),
|
||
setting_text.trim().to_string(),
|
||
"".to_string(),
|
||
"输出 JSON 模板:".to_string(),
|
||
"{".to_string(),
|
||
" \"name\": \"世界名称\",".to_string(),
|
||
" \"subtitle\": \"世界副标题\",".to_string(),
|
||
" \"summary\": \"世界概述\",".to_string(),
|
||
" \"tone\": \"世界基调\",".to_string(),
|
||
" \"playerGoal\": \"玩家核心目标\",".to_string(),
|
||
" \"templateWorldType\": \"WUXIA|XIANXIA\",".to_string(),
|
||
" \"majorFactions\": [\"势力甲\", \"势力乙\"],".to_string(),
|
||
" \"coreConflicts\": [\"冲突甲\", \"冲突乙\"],".to_string(),
|
||
" \"attributeSchema\": {".to_string(),
|
||
" \"schemaName\": \"本世界六维名称\",".to_string(),
|
||
" \"slots\": [".to_string(),
|
||
" { \"slotId\": \"axis_a\", \"name\": \"维度名\", \"definition\": \"维度定义\", \"positiveSignals\": [\"正向表现\"], \"negativeSignals\": [\"负向表现\"], \"combatUseText\": \"战斗用途\", \"socialUseText\": \"社交用途\", \"explorationUseText\": \"探索用途\" },".to_string(),
|
||
" { \"slotId\": \"axis_b\", \"name\": \"维度名\", \"definition\": \"维度定义\", \"positiveSignals\": [\"正向表现\"], \"negativeSignals\": [\"负向表现\"], \"combatUseText\": \"战斗用途\", \"socialUseText\": \"社交用途\", \"explorationUseText\": \"探索用途\" },".to_string(),
|
||
" { \"slotId\": \"axis_c\", \"name\": \"维度名\", \"definition\": \"维度定义\", \"positiveSignals\": [\"正向表现\"], \"negativeSignals\": [\"负向表现\"], \"combatUseText\": \"战斗用途\", \"socialUseText\": \"社交用途\", \"explorationUseText\": \"探索用途\" },".to_string(),
|
||
" { \"slotId\": \"axis_d\", \"name\": \"维度名\", \"definition\": \"维度定义\", \"positiveSignals\": [\"正向表现\"], \"negativeSignals\": [\"负向表现\"], \"combatUseText\": \"战斗用途\", \"socialUseText\": \"社交用途\", \"explorationUseText\": \"探索用途\" },".to_string(),
|
||
" { \"slotId\": \"axis_e\", \"name\": \"维度名\", \"definition\": \"维度定义\", \"positiveSignals\": [\"正向表现\"], \"negativeSignals\": [\"负向表现\"], \"combatUseText\": \"战斗用途\", \"socialUseText\": \"社交用途\", \"explorationUseText\": \"探索用途\" },".to_string(),
|
||
" { \"slotId\": \"axis_f\", \"name\": \"维度名\", \"definition\": \"维度定义\", \"positiveSignals\": [\"正向表现\"], \"negativeSignals\": [\"负向表现\"], \"combatUseText\": \"战斗用途\", \"socialUseText\": \"社交用途\", \"explorationUseText\": \"探索用途\" }".to_string(),
|
||
" ]".to_string(),
|
||
" },".to_string(),
|
||
" \"camp\": {".to_string(),
|
||
" \"name\": \"开局归处名称\",".to_string(),
|
||
" \"description\": \"这是玩家进入世界后的第一处落脚点描述\"".to_string(),
|
||
" }".to_string(),
|
||
"}".to_string(),
|
||
"".to_string(),
|
||
"要求:".to_string(),
|
||
"- 所有生成文本都必须使用中文。".to_string(),
|
||
"- 这一步只输出顶层 10 个字段:name、subtitle、summary、tone、playerGoal、templateWorldType、majorFactions、coreConflicts、attributeSchema、camp。".to_string(),
|
||
"- 这是一个完全独立的自定义世界;不要在任何正文里直接写出“武侠世界”“仙侠世界”等现成世界名。".to_string(),
|
||
"- templateWorldType 只是系统兼容字段,不代表正文应当引用的世界名称。".to_string(),
|
||
"- camp 只表示玩家开局时的落脚处占位,更接近归舍、住处、栖居、前哨居所这类“家/归处”的概念;不要在这一步生成开局场景任务、三幕事件或三幕背景。".to_string(),
|
||
"- 不要输出 playableNpcs、storyNpcs、landmarks、items,也不要输出任何角色和地图细节。".to_string(),
|
||
"- majorFactions 保持 2 到 3 个,coreConflicts 保持 2 到 3 个。".to_string(),
|
||
"- attributeSchema 必须是本世界专属的角色六维属性体系,slots 必须恰好 6 个,slotId 固定为 axis_a 到 axis_f,维度名必须是 2 到 4 个汉字且互不重复。".to_string(),
|
||
"- attributeSchema.slots 的 name 禁止使用:生命、法力、护甲、攻击、防御、力量、敏捷、智力、精神;不要写通用 DND 或传统四维属性。".to_string(),
|
||
"- 每个属性维度definition都要像RPG游戏属性名,同时能服务战斗、社交、探索三种场景,definition、combatUseText、socialUseText、explorationUseText 必须贴合本世界主题。".to_string(),
|
||
"- 世界设定必须直接源自玩家输入,不要脱离主题乱扩写。".to_string(),
|
||
"- 每个字符串尽量简洁:subtitle 控制在 8 到 18 个汉字内,summary 控制在 16 到 32 个汉字内,tone 控制在 6 到 16 个汉字内,playerGoal 控制在 16 到 32 个汉字内,camp.description 控制在 18 到 40 个汉字内。".to_string(),
|
||
"- 返回前自检:必须是一个能被 JSON.parse 直接解析的单个 JSON 对象。".to_string(),
|
||
].join("\n")
|
||
}
|
||
|
||
pub(crate) fn build_custom_world_framework_json_repair_prompt(response_text: &str) -> String {
|
||
[
|
||
"下面这段文本本应是自定义世界核心骨架的单个 JSON 对象,但当前不能被 JSON.parse 直接解析。",
|
||
"请只输出修复后的 JSON 对象。",
|
||
"顶层必须只包含:name、subtitle、summary、tone、playerGoal、templateWorldType、majorFactions、coreConflicts、attributeSchema、camp。",
|
||
"不要输出 playableNpcs、storyNpcs、landmarks、items 或任何其他字段。",
|
||
"majorFactions 与 coreConflicts 必须是字符串数组。",
|
||
"attributeSchema 必须是对象,且包含 schemaName 与 slots;slots 必须恰好 6 个,slotId 固定为 axis_a 到 axis_f。",
|
||
"camp 必须是对象,且只包含:name、description。",
|
||
"原始文本:",
|
||
response_text.trim(),
|
||
].join("\n")
|
||
}
|
||
|
||
pub(crate) fn build_custom_world_role_outline_batch_prompt(
|
||
framework: &JsonValue,
|
||
role_type: &str,
|
||
batch_count: usize,
|
||
forbidden_names: &[String],
|
||
) -> String {
|
||
let key = role_key(role_type);
|
||
let label = if role_type == "playable" {
|
||
"可扮演角色"
|
||
} else {
|
||
"场景角色"
|
||
};
|
||
[
|
||
format!("请根据下面的世界核心信息,生成一批{label}框架名单。"),
|
||
"后续我会继续补全人物档案,所以这一步每个角色只保留身份骨架与资产默认描述字段。".to_string(),
|
||
"你必须只输出一个能被 JSON.parse 直接解析的 JSON 对象,不要输出 Markdown、代码块、注释或解释。".to_string(),
|
||
"世界核心信息:".to_string(),
|
||
build_framework_summary_text(framework, 0),
|
||
if forbidden_names.is_empty() { "".to_string() } else { format!("这些名字已经生成,禁止重复:{}", forbidden_names.join("、")) },
|
||
"".to_string(),
|
||
"输出 JSON 模板:".to_string(),
|
||
"{".to_string(),
|
||
format!(" \"{key}\": ["),
|
||
" {".to_string(),
|
||
" \"name\": \"角色名称\",".to_string(),
|
||
" \"title\": \"称号\",".to_string(),
|
||
" \"role\": \"身份\",".to_string(),
|
||
" \"description\": \"极简定位描述\",".to_string(),
|
||
" \"visualDescription\": \"默认角色形象描述\",".to_string(),
|
||
" \"actionDescription\": \"默认角色动作描述\",".to_string(),
|
||
" \"sceneVisualDescription\": \"默认出现场景描述\",".to_string(),
|
||
" \"initialAffinity\": 18,".to_string(),
|
||
" \"relationshipHooks\": [\"一个关系切入口\"],".to_string(),
|
||
" \"tags\": [\"标签1\", \"标签2\"]".to_string(),
|
||
" }".to_string(),
|
||
" ]".to_string(),
|
||
"}".to_string(),
|
||
"".to_string(),
|
||
"要求:".to_string(),
|
||
format!("- 必须生成恰好 {batch_count} 个{label}。"),
|
||
"- 这是一个完全独立的自定义世界;不要把角色写成来自“武侠世界”“仙侠世界”等现成世界。".to_string(),
|
||
"- 名称必须具体且互不重复,不要使用 角色1、NPC1、场景角色1 之类的占位名。".to_string(),
|
||
"- 只保留:name、title、role、description、visualDescription、actionDescription、sceneVisualDescription、initialAffinity、relationshipHooks、tags。".to_string(),
|
||
"- visualDescription 是打开角色形象图像生成面板时默认填入的角色形象描述,必须具体到体型、服装、轮廓与识别点,控制在 24 到 60 个汉字内。".to_string(),
|
||
"- actionDescription 是打开每个角色动作视频生成面板时默认填入的动作描述,必须体现该角色默认动作节奏、武器或施法方式,控制在 18 到 48 个汉字内。".to_string(),
|
||
"- sceneVisualDescription 是该角色常出现或关联的场景画面描述,会作为场景生图描述框的默认候选,控制在 24 到 60 个汉字内。".to_string(),
|
||
"- relationshipHooks 最多 1 条;tags 保持 1 到 2 个。".to_string(),
|
||
"- description 控制在 8 到 18 个汉字内,title 和 role 也尽量短。".to_string(),
|
||
"- initialAffinity 必须是 -40 到 90 的整数。".to_string(),
|
||
if role_type == "playable" { "- 可扮演角色的定位必须明显不同,通常使用 18 到 40 的初始好感。".to_string() } else { "- 场景角色要覆盖势力成员、居民、异类或怪物,不要全是同一种身份;敌对或怪物型角色可以使用负好感。".to_string() },
|
||
"- 所有生成文本都必须使用中文。".to_string(),
|
||
"- 返回前自检:必须是一个能被 JSON.parse 直接解析的单个 JSON 对象。".to_string(),
|
||
].into_iter().filter(|value| !value.is_empty()).collect::<Vec<_>>().join("\n")
|
||
}
|
||
|
||
pub(crate) fn build_custom_world_role_outline_batch_json_repair_prompt(
|
||
response_text: &str,
|
||
role_type: &str,
|
||
expected_count: usize,
|
||
forbidden_names: &[String],
|
||
) -> String {
|
||
let key = role_key(role_type);
|
||
[
|
||
format!("下面这段文本本应是自定义世界{}框架名单批次的单个 JSON 对象,但当前不能被 JSON.parse 直接解析。", if role_type == "playable" { "可扮演角色" } else { "场景角色" }),
|
||
"请只输出修复后的 JSON 对象。".to_string(),
|
||
format!("顶层必须只包含一个 {key} 数组。"),
|
||
format!("必须保留恰好 {expected_count} 个角色对象。"),
|
||
if forbidden_names.is_empty() { "".to_string() } else { format!("禁止使用这些重复名:{}。", forbidden_names.join("、")) },
|
||
"每个角色只包含:name、title、role、description、visualDescription、actionDescription、sceneVisualDescription、initialAffinity、relationshipHooks、tags。".to_string(),
|
||
"如果缺少字段:字符串补空字符串,relationshipHooks 和 tags 补空数组,initialAffinity 补默认整数。".to_string(),
|
||
"不要输出 backstory、skills、landmarks 或任何其他字段。".to_string(),
|
||
"原始文本:".to_string(),
|
||
response_text.trim().to_string(),
|
||
].into_iter().filter(|value| !value.is_empty()).collect::<Vec<_>>().join("\n")
|
||
}
|
||
|
||
pub(crate) fn build_custom_world_landmark_seed_batch_prompt(
|
||
framework: &JsonValue,
|
||
batch_count: usize,
|
||
forbidden_names: &[String],
|
||
is_opening_batch: bool,
|
||
) -> String {
|
||
let story_npc_names = names_from_entries(&array_field(framework, "storyNpcs"));
|
||
[
|
||
"请根据下面的世界核心信息,批量生成场景框架名单。".to_string(),
|
||
if is_opening_batch {
|
||
"这一步必须一次性生成开局场景和普通关键场景的场景骨架、默认生图描述、逐幕背景描述、幕 NPC 分配和相连场景信息。".to_string()
|
||
} else {
|
||
"这一步必须一次性生成普通关键场景的场景骨架、默认生图描述、逐幕背景描述、幕 NPC 分配和相连场景信息。".to_string()
|
||
},
|
||
"你必须只输出一个能被 JSON.parse 直接解析的 JSON 对象,不要输出 Markdown、代码块、注释或解释。".to_string(),
|
||
"世界核心信息:".to_string(),
|
||
build_framework_summary_text(framework, 0),
|
||
if story_npc_names.is_empty() { "".to_string() } else { format!("可用场景角色名单:{}", story_npc_names.join("、")) },
|
||
if is_opening_batch {
|
||
"第一条场景必须是玩家进入世界时所在的开局场景,后续条目才是普通关键场景。".to_string()
|
||
} else {
|
||
"本批只生成普通关键场景,不要再生成开局场景。".to_string()
|
||
},
|
||
if forbidden_names.is_empty() { "".to_string() } else { format!("这些场景已经生成,禁止重复:{}", forbidden_names.join("、")) },
|
||
"".to_string(),
|
||
"输出 JSON 模板:".to_string(),
|
||
"{".to_string(),
|
||
" \"landmarks\": [".to_string(),
|
||
" {".to_string(),
|
||
" \"name\": \"场景名称\",".to_string(),
|
||
" \"description\": \"场景极简描述\",".to_string(),
|
||
" \"visualDescription\": \"默认场景生图描述\",".to_string(),
|
||
" \"sceneTaskDescription\": \"首次进入该场景时要生成的章节任务核心上下文\",".to_string(),
|
||
" \"actBackgroundPromptTexts\": [\"第一幕背景画面描述\", \"第二幕背景画面描述\", \"第三幕背景画面描述\"],".to_string(),
|
||
" \"actEventDescriptions\": [\"第一幕事件描述\", \"第二幕事件描述\", \"第三幕事件描述\"],".to_string(),
|
||
" \"actNPCNames\": [\"第一幕主场景角色名\", \"第二幕主场景角色名\", \"第三幕主场景角色名\"],".to_string(),
|
||
" \"connectedLandmarkNames\": [\"相邻或可通往的地点名\"],".to_string(),
|
||
" \"entryHook\": \"玩家进入这里时首先遇到的钩子\"".to_string(),
|
||
" }".to_string(),
|
||
" ]".to_string(),
|
||
"}".to_string(),
|
||
"".to_string(),
|
||
"要求:".to_string(),
|
||
if is_opening_batch {
|
||
format!("- 必须生成恰好 {batch_count} 个场景,第 1 个必须是开局场景。")
|
||
} else {
|
||
format!("- 必须生成恰好 {batch_count} 个普通关键场景,不能包含开局场景。")
|
||
},
|
||
if is_opening_batch { "- 开局场景也必须按普通场景同级规则生成完整字段,不能只给 camp 简介。".to_string() } else { "".to_string() },
|
||
"- 这是一个完全独立的自定义世界;场景名称必须直接服务玩家输入主题。".to_string(),
|
||
"- 名称必须具体且互不重复,不要使用 地点1、场景1、开局场景 之类的占位名。".to_string(),
|
||
"- 每个场景只保留:name、description、visualDescription、sceneTaskDescription、actBackgroundPromptTexts、actEventDescriptions、actNPCNames、connectedLandmarkNames、entryHook。".to_string(),
|
||
"- sceneTaskDescription 必须描述玩家首次进入该场景时要完成的核心任务,会作为游戏章节任务生成上下文,控制在 24 到 56 个汉字内。".to_string(),
|
||
"- visualDescription 是打开场景背景图像生成面板时默认填入的场景描述,必须具体到画面主体、远近景层次、地面可站立区域和氛围识别点,控制在 32 到 80 个汉字内。".to_string(),
|
||
"- actNPCNames 只能引用上方可用场景角色名单中的名字,表示第 1/2/3 幕各自的主场景角色;如果名单为空,输出空数组。".to_string(),
|
||
"- 可用场景角色名单非空时,actNPCNames 必须恰好 3 个;可以重复使用同一角色,但每一项都必须服务对应幕事件。".to_string(),
|
||
"- actNPCNames[n] 会成为第 n+1 幕对面主角色;三幕事件和幕背景必须围绕对应角色的行动、阻碍、试探或求助展开。".to_string(),
|
||
"- connectedLandmarkNames 优先引用本批或已知场景名称,每个场景 1 到 3 个;只有 1 个场景时可以输出空数组。".to_string(),
|
||
"- entryHook 控制在 16 到 36 个汉字内。".to_string(),
|
||
"- actEventDescriptions 必须恰好 3 条,分别描述每一幕发生的事件;第 1 幕负责铺垫,第 2 幕必须让冲突升级,第 3 幕必须形成高潮或关键抉择;事件必须和当前幕对面的角色强相关,控制在 24 到 56 个汉字内。".to_string(),
|
||
"- actBackgroundPromptTexts 必须恰好 3 条,分别对应这个场景章节的第 1/2/3 幕背景图画面内容描述;每条都必须基于同序号 actEventDescriptions、当前地点和可出场角色直接写出画面主体、站位空间、冲突痕迹与氛围,控制在 40 到 90 个汉字内。".to_string(),
|
||
"- actBackgroundPromptTexts 禁止使用“某某第1幕背景;玩家会在……”这类标题、摘要、规则句拼接格式;必须像可直接交给生图模型的自然画面描述。".to_string(),
|
||
"- description 控制在 12 到 24 个汉字内。".to_string(),
|
||
"- 所有生成文本都必须使用中文。".to_string(),
|
||
"- 返回前自检:必须是一个能被 JSON.parse 直接解析的单个 JSON 对象。".to_string(),
|
||
].into_iter().filter(|value| !value.is_empty()).collect::<Vec<_>>().join("\n")
|
||
}
|
||
|
||
pub(crate) fn build_custom_world_landmark_seed_batch_json_repair_prompt(
|
||
response_text: &str,
|
||
expected_count: usize,
|
||
forbidden_names: &[String],
|
||
is_opening_batch: bool,
|
||
) -> String {
|
||
[
|
||
"下面这段文本本应是自定义世界场景框架名单批次的单个 JSON 对象,但当前不能被 JSON.parse 直接解析。".to_string(),
|
||
"请只输出修复后的 JSON 对象。".to_string(),
|
||
"顶层必须只包含一个 landmarks 数组。".to_string(),
|
||
format!("必须保留恰好 {expected_count} 个场景对象。"),
|
||
if is_opening_batch { "第一项必须是开局场景,且字段粒度与普通场景一致。".to_string() } else { "本批只保留普通关键场景,不要包含开局场景。".to_string() },
|
||
if forbidden_names.is_empty() { "".to_string() } else { format!("禁止使用这些重复名:{}。", forbidden_names.join("、")) },
|
||
"每个场景只包含:name、description、visualDescription、sceneTaskDescription、actBackgroundPromptTexts、actEventDescriptions、actNPCNames、connectedLandmarkNames、entryHook。".to_string(),
|
||
"如果缺少字段:字符串补空字符串,actBackgroundPromptTexts、actEventDescriptions、actNPCNames 和 connectedLandmarkNames 补空数组。".to_string(),
|
||
"不要输出 items 或任何其他字段。".to_string(),
|
||
"原始文本:".to_string(),
|
||
response_text.trim().to_string(),
|
||
].into_iter().filter(|value| !value.is_empty()).collect::<Vec<_>>().join("\n")
|
||
}
|
||
|
||
pub(crate) fn build_custom_world_role_batch_prompt(
|
||
framework: &JsonValue,
|
||
role_type: &str,
|
||
role_batch: &[JsonValue],
|
||
stage: &str,
|
||
) -> String {
|
||
let key = role_key(role_type);
|
||
let label = if role_type == "playable" {
|
||
"可扮演角色"
|
||
} else {
|
||
"场景角色"
|
||
};
|
||
let stage_label = if stage == "narrative" {
|
||
"叙事档案"
|
||
} else {
|
||
"养成档案"
|
||
};
|
||
let required_fields = if stage == "narrative" {
|
||
"name、backstory、personality、motivation、combatStyle"
|
||
} else {
|
||
"name、backstoryReveal、skills、initialItems"
|
||
};
|
||
let template_extra = if stage == "narrative" {
|
||
[
|
||
" \"backstory\": \"公开背景\",",
|
||
" \"personality\": \"性格关键词\",",
|
||
" \"motivation\": \"当前动机\",",
|
||
" \"combatStyle\": \"行动或战斗风格\"",
|
||
]
|
||
.join("\n")
|
||
} else {
|
||
[
|
||
" \"backstoryReveal\": { \"publicSummary\": \"公开摘要\", \"chapters\": [{ \"affinityRequired\": 15, \"title\": \"羁绊章节\", \"summary\": \"章节摘要\" }] },",
|
||
" \"skills\": [{ \"name\": \"技能名\", \"summary\": \"技能摘要\", \"style\": \"风格\" }],",
|
||
" \"initialItems\": [{ \"name\": \"物品名\", \"category\": \"道具\", \"quantity\": 1, \"rarity\": \"common\", \"description\": \"描述\", \"tags\": [\"标签\"] }]",
|
||
].join("\n")
|
||
};
|
||
[
|
||
format!("请为下面这一批{label}补全{stage_label}。"),
|
||
"你必须只输出一个能被 JSON.parse 直接解析的 JSON 对象,不要输出 Markdown、代码块、注释或解释。".to_string(),
|
||
"世界核心信息:".to_string(),
|
||
build_framework_summary_text(framework, 10),
|
||
"本批角色:".to_string(),
|
||
build_role_outline_prompt_text(role_batch, framework, role_type),
|
||
"".to_string(),
|
||
"输出 JSON 模板:".to_string(),
|
||
"{".to_string(),
|
||
format!(" \"{key}\": ["),
|
||
" {".to_string(),
|
||
" \"name\": \"角色名称\",".to_string(),
|
||
template_extra,
|
||
" }".to_string(),
|
||
" ]".to_string(),
|
||
"}".to_string(),
|
||
"".to_string(),
|
||
"要求:".to_string(),
|
||
"- 必须只补全本批角色,name 必须与本批角色完全一致,不得增删改名。".to_string(),
|
||
format!("- 每个角色必须包含:{required_fields}。"),
|
||
if stage == "narrative" { "- backstory 控制在 32 到 80 个汉字内;personality、motivation、combatStyle 都要短而具体。".to_string() } else { format!("- backstoryReveal 必须包含 publicSummary 和 4 个 chapters,chapters.affinityRequired 固定为 {}。", CUSTOM_WORLD_BACKSTORY_CHAPTER_AFFINITIES.iter().map(i64::to_string).collect::<Vec<_>>().join("、")) },
|
||
if stage == "narrative" { "- 不要输出 backstoryReveal、skills、initialItems。".to_string() } else { "- skills 默认 3 个;initialItems 默认 3 个;不要输出 backstory、personality、motivation、combatStyle。".to_string() },
|
||
"- 所有生成文本都必须使用中文。".to_string(),
|
||
"- 返回前自检:必须是一个能被 JSON.parse 直接解析的单个 JSON 对象。".to_string(),
|
||
].into_iter().filter(|value| !value.is_empty()).collect::<Vec<_>>().join("\n")
|
||
}
|
||
|
||
pub(crate) fn build_custom_world_role_batch_json_repair_prompt(
|
||
response_text: &str,
|
||
role_type: &str,
|
||
stage: &str,
|
||
expected_names: &[String],
|
||
) -> String {
|
||
let key = role_key(role_type);
|
||
if stage == "narrative" {
|
||
return [
|
||
format!("下面这段文本本应是自定义世界{}叙事档案补全批次的单个 JSON 对象,但当前不能被 JSON.parse 直接解析。", if role_type == "playable" { "可扮演角色" } else { "场景角色" }),
|
||
"请只输出修复后的 JSON 对象。".to_string(),
|
||
format!("顶层必须只包含一个 {key} 数组。"),
|
||
format!("这个数组里只能保留这些角色名:{}。", expected_names.join("、")),
|
||
"名称必须与名单完全一致,不得增删改名;如果原文遗漏,可按名单顺序补齐占位对象。".to_string(),
|
||
"每个角色都必须包含:name、backstory、personality、motivation、combatStyle。".to_string(),
|
||
"如果缺少字段:字符串补空字符串。".to_string(),
|
||
"不要输出 backstoryReveal、skills、initialItems,也不要新增名单外的角色。".to_string(),
|
||
"原始文本:".to_string(),
|
||
response_text.trim().to_string(),
|
||
].join("\n");
|
||
}
|
||
[
|
||
format!("下面这段文本本应是自定义世界{}档案补全批次的单个 JSON 对象,但当前不能被 JSON.parse 直接解析。", if role_type == "playable" { "可扮演角色" } else { "场景角色" }),
|
||
"请只输出修复后的 JSON 对象。".to_string(),
|
||
format!("顶层必须只包含一个 {key} 数组。"),
|
||
format!("这个数组里只能保留这些角色名:{}。", expected_names.join("、")),
|
||
"名称必须与名单完全一致,不得增删改名;如果原文遗漏,可按名单顺序补齐占位对象。".to_string(),
|
||
"每个角色都必须包含:name、backstoryReveal、skills、initialItems。".to_string(),
|
||
format!("backstoryReveal 必须包含 publicSummary 和 4 个 chapters,chapters.affinityRequired 固定为 {}。", CUSTOM_WORLD_BACKSTORY_CHAPTER_AFFINITIES.iter().map(i64::to_string).collect::<Vec<_>>().join("、")),
|
||
"skills 默认补成 3 个对象,每个对象包含 name、summary、style;initialItems 默认补成 3 个对象,每个对象包含 name、category、quantity、rarity、description、tags。".to_string(),
|
||
"不要输出 backstory、personality、motivation、combatStyle、landmarks,也不要新增名单外的角色。".to_string(),
|
||
"原始文本:".to_string(),
|
||
response_text.trim().to_string(),
|
||
].join("\n")
|
||
}
|
||
|
||
fn build_framework_summary_text(framework: &JsonValue, max_landmarks: usize) -> String {
|
||
let landmark_text = array_field(framework, "landmarks")
|
||
.into_iter()
|
||
.take(max_landmarks)
|
||
.map(|landmark| {
|
||
format!(
|
||
"{}({})",
|
||
json_text(&landmark, "name").unwrap_or_default(),
|
||
json_text(&landmark, "description").unwrap_or_default()
|
||
)
|
||
})
|
||
.filter(|value| !value.trim().is_empty())
|
||
.collect::<Vec<_>>()
|
||
.join("、");
|
||
[
|
||
format!("世界:{}", json_text(framework, "name").unwrap_or_default()),
|
||
format!(
|
||
"副标题:{}",
|
||
json_text(framework, "subtitle").unwrap_or_default()
|
||
),
|
||
format!(
|
||
"世界概述:{}",
|
||
json_text(framework, "summary").unwrap_or_default()
|
||
),
|
||
format!(
|
||
"世界基调:{}",
|
||
json_text(framework, "tone").unwrap_or_default()
|
||
),
|
||
format!(
|
||
"玩家核心目标:{}",
|
||
json_text(framework, "playerGoal").unwrap_or_default()
|
||
),
|
||
json_string_array(framework, "majorFactions")
|
||
.map(|items| format!("主要势力:{}", items.join("、")))
|
||
.unwrap_or_default(),
|
||
json_string_array(framework, "coreConflicts")
|
||
.map(|items| format!("核心冲突:{}", items.join("、")))
|
||
.unwrap_or_default(),
|
||
format!(
|
||
"开局归处:{}({})",
|
||
json_path_text(framework, &["camp", "name"]).unwrap_or_default(),
|
||
json_path_text(framework, &["camp", "description"]).unwrap_or_default()
|
||
),
|
||
if landmark_text.is_empty() {
|
||
String::new()
|
||
} else {
|
||
format!("关键场景:{landmark_text}")
|
||
},
|
||
]
|
||
.into_iter()
|
||
.filter(|value| !value.is_empty())
|
||
.collect::<Vec<_>>()
|
||
.join("\n")
|
||
}
|
||
|
||
fn build_role_outline_prompt_text(
|
||
role_batch: &[JsonValue],
|
||
framework: &JsonValue,
|
||
role_type: &str,
|
||
) -> String {
|
||
role_batch
|
||
.iter()
|
||
.map(|role| {
|
||
let appearance_text = if role_type == "story" {
|
||
landmark_names_for_role(
|
||
framework,
|
||
json_text(role, "name").unwrap_or_default().as_str(),
|
||
)
|
||
.join("、")
|
||
} else {
|
||
String::new()
|
||
};
|
||
[
|
||
format!(
|
||
"- {} / {}",
|
||
json_text(role, "name").unwrap_or_default(),
|
||
json_text(role, "title").unwrap_or_default()
|
||
),
|
||
format!("身份:{}", json_text(role, "role").unwrap_or_default()),
|
||
format!(
|
||
"框架描述:{}",
|
||
json_text(role, "description").unwrap_or_default()
|
||
),
|
||
format!(
|
||
"预设好感:{}",
|
||
role.get("initialAffinity")
|
||
.and_then(JsonValue::as_i64)
|
||
.unwrap_or(0)
|
||
),
|
||
json_string_array(role, "relationshipHooks")
|
||
.map(|items| format!("关系切入口:{}", items.join("、")))
|
||
.unwrap_or_default(),
|
||
json_string_array(role, "tags")
|
||
.map(|items| format!("标签:{}", items.join("、")))
|
||
.unwrap_or_default(),
|
||
if appearance_text.is_empty() {
|
||
String::new()
|
||
} else {
|
||
format!("出现场景:{appearance_text}")
|
||
},
|
||
]
|
||
.into_iter()
|
||
.filter(|value| !value.is_empty())
|
||
.collect::<Vec<_>>()
|
||
.join("\n")
|
||
})
|
||
.collect::<Vec<_>>()
|
||
.join("\n")
|
||
}
|
||
|
||
fn landmark_names_for_role(framework: &JsonValue, role_name: &str) -> Vec<String> {
|
||
array_field(framework, "landmarks")
|
||
.into_iter()
|
||
.filter_map(|landmark| {
|
||
let names = json_string_array(&landmark, "actNPCNames")
|
||
.or_else(|| json_string_array(&landmark, "sceneNpcNames"))
|
||
.unwrap_or_default();
|
||
if names.iter().any(|name| name == role_name) {
|
||
json_text(&landmark, "name")
|
||
} else {
|
||
None
|
||
}
|
||
})
|
||
.collect()
|
||
}
|
||
|
||
fn role_key(role_type: &str) -> &'static str {
|
||
if role_type == "playable" {
|
||
"playableNpcs"
|
||
} else {
|
||
"storyNpcs"
|
||
}
|
||
}
|
||
|
||
fn array_field(value: &JsonValue, key: &str) -> Vec<JsonValue> {
|
||
value
|
||
.get(key)
|
||
.and_then(JsonValue::as_array)
|
||
.cloned()
|
||
.unwrap_or_default()
|
||
}
|
||
|
||
fn names_from_entries(entries: &[JsonValue]) -> Vec<String> {
|
||
entries
|
||
.iter()
|
||
.filter_map(|entry| json_text(entry, "name"))
|
||
.filter(|value| !value.is_empty())
|
||
.collect()
|
||
}
|
||
|
||
fn json_text(value: &JsonValue, key: &str) -> Option<String> {
|
||
json_path_text(value, &[key])
|
||
}
|
||
|
||
fn json_path_text(value: &JsonValue, path: &[&str]) -> Option<String> {
|
||
let mut current = value;
|
||
for segment in path {
|
||
current = current.get(*segment)?;
|
||
}
|
||
current
|
||
.as_str()
|
||
.map(str::trim)
|
||
.filter(|value| !value.is_empty())
|
||
.map(ToOwned::to_owned)
|
||
}
|
||
|
||
fn json_string_array(value: &JsonValue, key: &str) -> Option<Vec<String>> {
|
||
let items = value
|
||
.get(key)?
|
||
.as_array()?
|
||
.iter()
|
||
.filter_map(|entry| entry.as_str().map(str::trim))
|
||
.filter(|entry| !entry.is_empty())
|
||
.map(ToOwned::to_owned)
|
||
.collect::<Vec<_>>();
|
||
if items.is_empty() { None } else { Some(items) }
|
||
}
|