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::>().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::>().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::>().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::>().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::>().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::>().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::>().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::>() .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::>() .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::>() .join("\n") }) .collect::>() .join("\n") } fn landmark_names_for_role(framework: &JsonValue, role_name: &str) -> Vec { 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 { value .get(key) .and_then(JsonValue::as_array) .cloned() .unwrap_or_default() } fn names_from_entries(entries: &[JsonValue]) -> Vec { entries .iter() .filter_map(|entry| json_text(entry, "name")) .filter(|value| !value.is_empty()) .collect() } fn json_text(value: &JsonValue, key: &str) -> Option { json_path_text(value, &[key]) } fn json_path_text(value: &JsonValue, path: &[&str]) -> Option { 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> { 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::>(); if items.is_empty() { None } else { Some(items) } }