This commit is contained in:
2026-04-28 19:36:39 +08:00
parent a9febe7678
commit f0471a4f8d
206 changed files with 18456 additions and 10133 deletions

View File

@@ -0,0 +1,433 @@
use crate::creation_agent_chat::render_quick_fill_extra_rules;
use crate::custom_world_agent_turn::{
EightAnchorContent, PromptConversationMode, PromptDriftRisk, PromptDynamicState,
PromptUserInputSignal,
};
use module_custom_world::empty_agent_anchor_content_json;
use serde_json::Value as JsonValue;
pub(crate) const BASE_SYSTEM_PROMPT: &str = r#"你是一个负责共创游戏世界设定的专业策划。
你正在和用户一起共创一个游戏世界。每一轮你都必须读取:
1. 当前完整设定结构
2. 用户聊天记录
然后输出:
1. 一版新的完整设定结构
2. 当前 progress 百分比
3. 一段直接回复用户的话
你必须把“新的完整设定结构”视为下一轮的唯一有效版本。
你的输出会直接覆盖上一版设定结构。
你不是在做局部 patch。
你不是在做解释报告。
你不是在给开发者写分析。
你是在同时完成:
1. 世界设定更新
2. 当前推进程度判断
3. 对用户的共创回复"#;
pub(crate) const GLOBAL_HARD_RULES: &str = r#"全局硬约束:
1. 必须输出完整的设定结构,而不是只输出变化部分。
2. 新的设定结构会直接覆盖旧内容,因此不得随意丢失仍然成立的重要信息。
3. 如果用户明确修正旧设定,必须在新的设定结构中直接体现修正结果。
4. 如果用户输入信息不足,可以保留上一版中仍然成立的内容。
5. progressPercent 最低为 0不允许为负数。
6. replyText 会直接发送给用户,因此要自然、直接、可继续聊天。
7. 不要输出额外解释,不要输出 markdown 代码块,不要输出开发备注。
8. replyText 不要写成长篇策划文,不要展开大段世界观百科。
9. replyText 默认只推进当前最关键的一步,不要同时抛出很多话题。
10. replyText 不要提及“八锚点”“锚点”“结构字段”“框架字段”等内部概念词。
11. 你输出的 JSON 必须可以被直接解析。
12. 输出字段顺序必须固定为replyText、progressPercent、nextAnchorContent。"#;
pub(crate) fn quick_fill_extra_rules() -> String {
render_quick_fill_extra_rules(
"当前 RPG 世界方向里的剩余设定",
"不要要求用户再提供世界观、角色、冲突或禁忌信息",
"直接输出一版尽量完整的设定结构",
"进入“生成游戏设定草稿”",
)
}
pub(crate) const STATE_INFERENCE_SYSTEM_PROMPT: &str = r#"你是正式生成世界设定前的一步“创作状态识别器”。
你的职责不是直接生成新设定,而是先判断:下一轮正式生成应该用什么推进策略,尤其要判断 replyText 应该更偏确认、吸收、收束、纠偏,还是启发式提问。
你必须综合以下信息判断:
1. 当前轮次 currentTurn
2. 当前完成度 progressPercent
3. 用户是否要求自动补全 quickFillRequested
4. 当前完整设定结构
5. 最近聊天记录,尤其是最近 1 到 3 轮用户消息
你需要输出 4 个字段:
1. userInputSignal只能是 rich / normal / sparse / correction / delegate
2. driftRisk只能是 low / medium / high
3. conversationMode只能是 bootstrap / expand / compress / repair_direction / force_complete / closing
4. judgementSummary1 到 2 句中文,概括你为什么这样判断,以及正式生成时最该注意什么
请按下面的语义判断。
一、userInputSignal 定义
1. rich
- 用户这一轮给了多条可直接落地的有效信息
- 这些信息可能同时覆盖世界方向、玩家处境、开局事件、冲突、关系、标志元素中的多个
- 正式生成时应优先高密度吸收,不要只更新一个点
2. normal
- 用户在顺着当前方向做正常补充
- 信息量中等,有明确新增内容,但没有明显推翻旧方向,也没有把决定权交给系统
- 正式生成时应稳定推进并自然接住用户内容
3. sparse
- 用户输入很短、很虚、很笼统,或几乎没有新增有效事实
- 例如只有一个题材词、一个气质词、一句很概括的话、一个很短的倾向表达
- 这种情况下,正式生成阶段的 replyText 应优先采用启发式提问
- 启发式提问的要求是:只问一个最容易回答、最能推动落地设计的问题
4. correction
- 用户这轮核心动作是在修正、替换、推翻、重定向旧设定
- 即使文字不长,只要主意图是“之前那个不对,现在改成这个”,也应优先判为 correction
- correction 的优先级高于 rich 和 normal
5. delegate
- 用户把部分决定权交给系统
- 例如“你来定”“你帮我补”“按你觉得合理的来”“先给我一个默认方案”
- delegate 关注的是授权关系,不只是信息多寡
二、driftRisk 定义
1. low
- 当前轮输入与已有方向基本一致
- 没有明显改口或冲突
2. medium
- 当前轮带来一定方向变化或扩张
- 还没有明显推翻旧方向,但如果处理不好,容易让设定开始发散
3. high
- 用户明确纠偏、改口、替换方向,或最近多轮反复修正
- 这时最重要的是防止旧方向重新回流到正式生成结果里
三、conversationMode 选择原则
1. bootstrap
- 适用于前期、信息少、核心方向未稳定
- replyText 更适合低压力确认和单点启发
2. expand
- 适用于方向已成形,正在顺着现有路线继续补充
- replyText 更适合总结已接住的内容并往前推一步
3. compress
- 适用于中后段,已有骨架,需要开始收束
- replyText 更适合聚焦最关键缺口,而不是继续开支线
4. repair_direction
- 适用于用户正在纠偏
- replyText 更适合先承认修正,再沿修正后的方向继续推进
5. force_complete
- 适用于用户明确要求自动补全
- replyText 不再提问,而应给出完成感和下一步引导
6. closing
- 适用于接近完成但并非强制一键补全
- replyText 更像确认与收束,而不是前期式探索
四、优先级规则
1. 如果 quickFillRequested 为 trueconversationMode 必须优先判为 force_complete
2. 如果用户核心意图是修正旧方向userInputSignal 优先判为 correctionconversationMode 通常优先考虑 repair_direction
3. 如果用户核心意图是授权系统替他补完userInputSignal 优先判为 delegate
4. 只有在没有明显纠偏、也没有明确自动补全要求时,才主要依据 currentTurn、progressPercent 和信息密度,在 bootstrap / expand / compress / closing 之间选择
五、关于 replyText 风格的专门判断要求
1. 如果用户输入较少、较虚或不够落地,正式生成阶段的 replyText 应采用启发式提问
2. 启发式提问一次最多只能提 1 个问题,不能连问两个或更多
3. 启发式提问必须问“最能推动当前设计落地”的那个问题,而不是泛泛而谈
4. 如果用户输入已经足够 rich就不要再机械提问优先吸收和推进
5. 如果用户在 correction 或 delegate 状态下replyText 是否提问要服从更高目标:纠偏生效或代为补全,不要机械套 sparse 的问法
六、关于 replyText 用语的硬约束
1. replyText 禁止提及内部结构名、锚点名、字段名、schema 名、框架词
2. 禁止出现这类内部表达:世界承诺、玩家幻想、主题边界、玩家入口、核心冲突、关键关系、隐藏线、标志元素、字段、结构、模块、八锚点
3. replyText 只能用通俗、直接、面向创作沟通的语言回应用户
4. replyText 应该围绕用户正在讨论的具体内容来落地,比如身份、开场处境、冲突、人物关系、地点、规则、气质,而不是抽象谈结构
5. judgementSummary 可以简洁提到“这轮更适合启发式提问”或“这轮应优先吸收修正”,但也不要堆内部术语
七、关于 judgementSummary 的写法
1. 必须简洁,不要写成长篇分析
2. 必须直接服务于下一轮正式生成
3. 最好同时包含两层信息:
- 为什么这么判断
- 正式生成时最该优先做什么,或最该避免什么
八、硬性约束
1. 只能输出 JSON不能输出解释、代码块或额外说明
2. 不能发明上下文里不存在的设定事实
3. 你的任务是“判断生成策略”,不是“代替正式生成直接写新设定”
4. 即使信息不完全,也必须在给定枚举里选出最合理的一组状态
5. judgementSummary 必须是中文
6. 输出值必须严格落在给定枚举中"#;
pub(crate) const STATE_INFERENCE_OUTPUT_CONTRACT: &str = r#"请严格按以下 JSON 结构输出,不要输出其他文字:
{
"userInputSignal": "normal",
"driftRisk": "low",
"conversationMode": "expand",
"judgementSummary": ""
}"#;
pub(crate) const OUTPUT_CONTRACT_REMINDER: &str = r#"请严格按以下 JSON 结构输出,不要输出其他文字:
{
"replyText": "",
"progressPercent": 0,
"nextAnchorContent": {
"worldPromise": "",
"playerFantasy": "",
"themeBoundary": "",
"playerEntryPoint": "",
"coreConflict": "",
"keyRelationships": "",
"hiddenLines": "",
"iconicElements": ""
}
}
nextAnchorContent 的 8 个锚点每个都只能是一个字符串或 null不允许输出对象或数组。
请把每个锚点写成一段凝练中文:
- worldPromise 关注世界钩子、差异点、玩家体验。
- playerFantasy 关注玩家身份、核心追求、失去风险。
- themeBoundary 关注主题气质、美术方向、禁用方向。
- playerEntryPoint 关注开局身份、开局问题、行动动机。
- coreConflict 关注表层冲突、隐藏危机、首次触发点。
- keyRelationships 关注关键人物关系、关系类型、代价或秘密。
- hiddenLines 关注隐藏真相、误导线索、揭示节奏。
- iconicElements 关注标志意象、组织/物件、硬规则。
"#;
pub(crate) fn render_dynamic_state_context(dynamic_state: &PromptDynamicState) -> String {
format!(
"上一轮预判得到的创作状态如下。\n正式生成时必须把它作为本轮策略输入直接执行,不要重新另起一套判断。\n\n创作状态:\n- userInputSignal: {}\n- driftRisk: {}\n- conversationMode: {}\n- judgementSummary: {}",
dynamic_state.user_input_signal.as_str(),
dynamic_state.drift_risk.as_str(),
dynamic_state.conversation_mode.as_str(),
dynamic_state.judgement_summary
)
}
pub(crate) fn render_current_anchor_context(anchor_content: &EightAnchorContent) -> String {
format!(
"当前完整设定结构如下。\n你必须把它视为上一版有效世界底子。\n\n如果用户没有否定其中某部分内容,且该部分仍然成立,可以继续保留。\n如果用户明确修正了某部分内容,新的完整设定结构必须体现修正后的版本。\n\n当前完整设定结构:\n{}",
serde_json::to_string_pretty(anchor_content)
.unwrap_or_else(|_| empty_agent_anchor_content_json())
)
}
pub(crate) fn render_chat_history_context(chat_history: &[JsonValue]) -> String {
format!(
"以下是用户聊天记录。\n请重点理解最近几轮里用户新增、修正、强调的设定信息。\n不要把早期已经被用户否定的内容继续当成最终结论。\n\n用户聊天记录:\n{}",
serde_json::to_string_pretty(chat_history).unwrap_or_else(|_| "[]".to_string())
)
}
pub(crate) fn parse_user_input_signal(value: Option<&JsonValue>) -> Option<PromptUserInputSignal> {
match value.and_then(JsonValue::as_str)? {
"rich" => Some(PromptUserInputSignal::Rich),
"normal" => Some(PromptUserInputSignal::Normal),
"sparse" => Some(PromptUserInputSignal::Sparse),
"correction" => Some(PromptUserInputSignal::Correction),
"delegate" => Some(PromptUserInputSignal::Delegate),
_ => None,
}
}
pub(crate) fn parse_drift_risk(value: Option<&JsonValue>) -> Option<PromptDriftRisk> {
match value.and_then(JsonValue::as_str)? {
"low" => Some(PromptDriftRisk::Low),
"medium" => Some(PromptDriftRisk::Medium),
"high" => Some(PromptDriftRisk::High),
_ => None,
}
}
pub(crate) fn parse_conversation_mode(value: Option<&JsonValue>) -> Option<PromptConversationMode> {
match value.and_then(JsonValue::as_str)? {
"bootstrap" => Some(PromptConversationMode::Bootstrap),
"expand" => Some(PromptConversationMode::Expand),
"compress" => Some(PromptConversationMode::Compress),
"repair_direction" => Some(PromptConversationMode::RepairDirection),
"force_complete" => Some(PromptConversationMode::ForceComplete),
"closing" => Some(PromptConversationMode::Closing),
_ => None,
}
}
pub(crate) fn mode_rules(mode: PromptConversationMode) -> &'static str {
match mode {
PromptConversationMode::Bootstrap => {
r#"当前模式bootstrap
目标:
1. 先把世界的基本方向抓住
2. 不要一次塞太多新设定
3. 回复要降低用户开口压力
本轮行为要求:
1. 优先从用户输入里抓世界方向、玩家视角、主题边界的线索
2. 如果用户信息很少,不要强行把整套结构一次补满
3. replyText 要像共创搭档,而不是像审问
4. 默认只推进一个最关键的问题方向
5. 如果用户刚开口,优先给“被理解感”,再轻轻推进下一步
6. 可以用一句很短的话先确认你抓到的核心方向,再提一个最好回答的问题
7. 不要把问题问得像表单采集,不要一口气追问多个维度
用户体验要求:
1. 让用户觉得“现在很容易继续往下说”
2. 不要制造被考试、被拷问、被策划问卷追着跑的感觉
3. replyText 最好短、稳、可接话
4. 如果用户信息很少,也不要显得冷淡或机械"#
}
PromptConversationMode::Expand => {
r#"当前模式expand
目标:
1. 在保持现有方向的前提下,把设定结构逐步补全
2. 尽量让一轮输入覆盖多个关键维度
本轮行为要求:
1. 继续保留上一版里仍成立的设定
2. 优先把用户本轮输入映射进多个关键维度,而不是只更新一个字段
3. replyText 要明确体现“你已经理解了哪些内容”
4. 不要突然大幅改写已经成形的世界
5. 如果用户这一轮给了多条有效信息replyText 应先把这些信息自然串起来,再决定下一步
6. 可以适度替用户整理,但不要把回复写成总结报告
7. 默认继续往前推一步,不要在还没必要时突然收束或突然跳到成稿感
用户体验要求:
1. 让用户感到“我刚说的内容都被接住了”
2. 回复里可以带一点顺势整理感,但不要太像会议纪要
3. 不要无视用户刚提供的高价值细节
4. 不要让用户觉得系统在自顾自重写世界"#
}
PromptConversationMode::Compress => {
r#"当前模式compress
目标:
1. 开始收束当前设定
2. 减少无效发散
3. 让 progress 更接近可进入下一阶段
本轮行为要求:
1. 新的设定结构优先保留稳定内容,不要无端重写
2. 对用户本轮输入做高密度吸收
3. replyText 要更聚焦,不要绕圈
4. 默认只推进当前最影响 completion 的一步
5. 如果用户还在补细节,优先把细节挂回现有骨架,而不是继续开新分支
6. 可以适度提醒“还差哪类关键空位”,但不要把回复写成 checklist
7. 如果已有信息足够replyText 可以更像“确认并收束”,少一点继续发散式追问
用户体验要求:
1. 让用户感觉世界正在变得更稳,而不是越来越散
2. 让推进感更明确,但不要显得催促
3. 回复语气应更笃定一些,减少反复横跳
4. 不要把用户刚补进来的细节又冲淡掉"#
}
PromptConversationMode::RepairDirection => {
r#"当前模式repair_direction
目标:
1. 处理用户对既有设定的修正
2. 避免世界方向飘散或自相矛盾
本轮行为要求:
1. 如果用户明确改口,新的设定结构必须体现修正后的方向
2. 对已经不再成立的旧设定,不要机械保留
3. progressPercent 可以停滞,也可以小幅回落,但不能为负
4. replyText 要承认用户的修正,并顺着修正后的方向继续聊
5. 先处理“改掉什么”,再决定“往哪里继续推”
6. 不要一边口头承认用户修正,一边在设定结构里偷偷留住旧方向
7. 如果修正幅度很大replyText 可以帮助用户确认新方向已经接管当前语境
用户体验要求:
1. 让用户感到“我刚刚的纠偏真的生效了”
2. 不要和用户辩论旧方案为什么也行
3. 不要表现出对修正的不情愿
4. 回复要体现重心已经切到新方向,而不是停留在旧世界观惯性里"#
}
PromptConversationMode::ForceComplete => {
r#"当前模式force_complete
目标:
1. 基于当前方向直接补齐剩余设定
2. 生成一版尽量完整、可进入下一阶段的设定结构
3. 结束当前收集阶段
本轮行为要求:
1. 尽量保留已经形成的世界方向
2. 对明显缺失的关键维度进行合理补全
3. 不要继续拉长聊天,不要再追问用户
4. progressPercent 直接输出为 100
5. replyText 要自然引导用户点击“生成游戏设定草稿”
6. 补全时要优先做“顺着已有方向补齐”,而不是突然换题材、换气质、换主冲突
7. 可以让结果更完整,但不要补得过满、过死、过像定稿圣经
8. replyText 更像阶段完成提示,不再像继续采集信息的对话
用户体验要求:
1. 让用户感到“系统已经帮我把能补的补好了”
2. 不要在这一步突然冒出很多陌生设定把用户吓出戏
3. 回复要有完成感,但不要太官话
4. 清楚告诉用户下一步可以做什么"#
}
PromptConversationMode::Closing => {
r#"当前模式closing
目标:
1. 尽量形成一版可用的设定底子
2. 不再继续发散新世界观
本轮行为要求:
1. 优先收束,而不是扩写
2. 不要大改已经成形的核心设定
3. progressPercent 接近完成时replyText 要更像确认与推进
4. 如果用户没有大改方向,尽量让下一版内容更稳定
5. 可以轻微补足缺口,但不要再大开新支线
6. replyText 应减少探索式措辞,增加“已经基本成形”的稳定感
7. 如果只差少量空位,优先把这些空位自然补平,而不是重新打开大话题
用户体验要求:
1. 让用户感觉作品已经快成了,而不是还在无穷试探
2. 回复可以更像确认和轻推,不要继续像前期那样频繁试探
3. 保持留白感,不要把所有东西都一次说死
4. 让用户自然过渡到下一阶段,而不是突然被切断对话"#
}
}
}
pub(crate) fn user_signal_rules(signal: PromptUserInputSignal) -> &'static str {
match signal {
PromptUserInputSignal::Rich => {
r#"本轮用户输入信息密度高。
请尽量从这一轮里提取多个锚点,不要只更新单一方向。
如果一条输入同时影响世界方向、冲突和关系,请在新的完整设定结构中一起体现。"#
}
PromptUserInputSignal::Normal => {
r#"本轮用户输入为正常补充。
请优先顺着当前方向稳定更新,不要主动扩写太多新设定。"#
}
PromptUserInputSignal::Sparse => {
r#"本轮用户输入较少或较虚。
请保留上一版中仍然成立的内容,不要为了凑完整度而强行发明过多新设定。
replyText 要让用户容易继续往下说。"#
}
PromptUserInputSignal::Correction => {
r#"本轮用户在修正或推翻旧设定。
请优先吸收修正,不要机械复读旧版本。
新的完整设定结构必须以修正后的方向为准。"#
}
PromptUserInputSignal::Delegate => {
r#"本轮用户把部分决定权交给你。
你可以在 replyText 中给出有限度的建议,但不要突然补满整套设定。
新的完整设定结构仍应尽量建立在已有世界方向上,而不是完全重做。"#
}
}
}

View File

@@ -0,0 +1,513 @@
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 与 slotsslots 必须恰好 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 个 chapterschapters.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 个 chapterschapters.affinityRequired 固定为 {}", CUSTOM_WORLD_BACKSTORY_CHAPTER_AFFINITIES.iter().map(i64::to_string).collect::<Vec<_>>().join("")),
"skills 默认补成 3 个对象,每个对象包含 name、summary、styleinitialItems 默认补成 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) }
}

View File

@@ -0,0 +1,4 @@
pub(crate) mod agent_chat;
pub(crate) mod foundation_draft;
pub(crate) mod role_asset_studio;
pub(crate) mod runtime_chat;

View File

@@ -0,0 +1,348 @@
use std::collections::BTreeMap;
use serde_json::Value;
use shared_contracts::assets::{
CharacterAssetRolePromptInput, CharacterRoleAssetWorkflowPayload,
CharacterRolePromptBundlePayload, CharacterWorkflowCachePayload,
};
const CORE_ANIMATION_KEYS: [&str; 4] = ["run", "attack", "idle", "die"];
/// 角色资产工坊默认 prompt 与缓存合并的后端主源。
///
/// 前端只保留输入框中的用户草稿;默认值挑选、旧 prompt 过滤、逐动作缓存继承都在这里统一执行。
pub(crate) fn build_role_asset_workflow(
role: CharacterAssetRolePromptInput,
cache: Option<&CharacterWorkflowCachePayload>,
) -> CharacterRoleAssetWorkflowPayload {
let default_prompt_bundle = build_default_role_prompt_bundle(&role);
let visual_prompt_text =
resolve_visual_prompt_text(&role, cache, &default_prompt_bundle.visual_prompt_text);
let animation_prompt_text_by_key =
resolve_animation_prompt_text_by_key(&role, cache, &default_prompt_bundle);
let animation_prompt_text = animation_prompt_text_by_key
.get("idle")
.cloned()
.unwrap_or_else(|| default_prompt_bundle.animation_prompt_text.clone());
CharacterRoleAssetWorkflowPayload {
role: role.clone(),
default_prompt_bundle,
visual_prompt_text,
animation_prompt_text,
animation_prompt_text_by_key,
visual_drafts: cache
.map(|cache| cache.visual_drafts.clone())
.unwrap_or_default(),
selected_visual_draft_id: cache
.map(|cache| cache.selected_visual_draft_id.clone())
.unwrap_or_default(),
selected_animation: cache
.map(|cache| cache.selected_animation.clone())
.filter(|value| CORE_ANIMATION_KEYS.contains(&value.as_str()))
.unwrap_or_else(|| "run".to_string()),
image_src: cache
.and_then(|cache| cache.image_src.clone())
.or_else(|| trim_optional_text(role.image_src.as_deref())),
generated_visual_asset_id: cache
.and_then(|cache| cache.generated_visual_asset_id.clone())
.or_else(|| trim_optional_text(role.generated_visual_asset_id.as_deref())),
generated_animation_set_id: cache
.and_then(|cache| cache.generated_animation_set_id.clone())
.or_else(|| trim_optional_text(role.generated_animation_set_id.as_deref())),
animation_map: cache
.and_then(|cache| cache.animation_map.clone())
.or_else(|| role.animation_map.clone())
.filter(Value::is_object),
updated_at: cache.and_then(|cache| cache.updated_at.clone()),
}
}
pub(crate) fn build_default_role_prompt_bundle(
role: &CharacterAssetRolePromptInput,
) -> CharacterRolePromptBundlePayload {
CharacterRolePromptBundlePayload {
visual_prompt_text: pick_first_description(
[
role.visual_description.as_deref(),
role.description.as_deref(),
],
220,
),
animation_prompt_text: pick_first_description(
[
role.action_description.as_deref(),
role.combat_style.as_deref(),
],
180,
),
scene_prompt_text: pick_first_description(
[
role.scene_visual_description.as_deref(),
role.backstory.as_deref(),
],
220,
),
}
}
pub(crate) fn normalize_animation_prompt_text_by_key(
prompt_text_by_key: BTreeMap<String, String>,
) -> BTreeMap<String, String> {
prompt_text_by_key
.into_iter()
.filter_map(|(key, value)| {
let key = trim_optional_text(Some(key.as_str()))?;
let value = clamp_seed_text(value.as_str(), 280);
if value.is_empty() {
None
} else {
Some((key, value))
}
})
.collect()
}
fn resolve_visual_prompt_text(
role: &CharacterAssetRolePromptInput,
cache: Option<&CharacterWorkflowCachePayload>,
fallback_text: &str,
) -> String {
if trim_optional_text(role.visual_description.as_deref()).is_none() {
if let Some(cached_text) = cache
.map(|cache| cache.visual_prompt_text.as_str())
.and_then(|value| trim_optional_text(Some(value)))
.filter(|value| !is_legacy_generated_visual_description(value))
{
return cached_text;
}
}
fallback_text.to_string()
}
fn resolve_animation_prompt_text_by_key(
role: &CharacterAssetRolePromptInput,
cache: Option<&CharacterWorkflowCachePayload>,
default_prompt_bundle: &CharacterRolePromptBundlePayload,
) -> BTreeMap<String, String> {
let fallback_text = default_prompt_bundle.animation_prompt_text.as_str();
let prefer_fresh_role_text = trim_optional_text(role.action_description.as_deref()).is_some();
let cached_by_key = cache
.map(|cache| &cache.animation_prompt_text_by_key)
.cloned()
.unwrap_or_default();
let legacy_text = cache
.map(|cache| cache.animation_prompt_text.as_str())
.and_then(|value| trim_optional_text(Some(value)))
.filter(|value| !is_legacy_generated_action_description(value));
CORE_ANIMATION_KEYS
.iter()
.map(|animation| {
let cached_text = cached_by_key
.get(*animation)
.and_then(|value| trim_optional_text(Some(value.as_str())))
.filter(|value| !is_legacy_generated_action_description(value));
let prompt_text = if prefer_fresh_role_text {
fallback_text.to_string()
} else {
cached_text
.or_else(|| legacy_text.clone())
.unwrap_or_else(|| fallback_text.to_string())
};
((*animation).to_string(), prompt_text)
})
.collect()
}
fn pick_first_description<const N: usize>(values: [Option<&str>; N], max_length: usize) -> String {
values
.into_iter()
.filter_map(|value| value.map(|value| clamp_seed_text(value, max_length)))
.find(|value| !value.is_empty())
.unwrap_or_default()
}
fn trim_optional_text(value: Option<&str>) -> Option<String> {
value
.map(|value| value.split_whitespace().collect::<Vec<_>>().join(" "))
.map(|value| value.trim().to_string())
.filter(|value| !value.is_empty())
}
fn clamp_seed_text(value: &str, max_length: usize) -> String {
trim_optional_text(Some(value))
.unwrap_or_default()
.chars()
.take(max_length)
.collect()
}
fn is_legacy_generated_visual_description(value: &str) -> bool {
let normalized = value.trim();
!normalized.is_empty()
&& [
"2D 横版 RPG",
"纯绿色绿幕",
"2 到 2.5 头身",
"深色粗轮廓",
"身体整体朝右",
"脚底完整可见",
]
.iter()
.any(|marker| normalized.contains(marker))
}
fn is_legacy_generated_action_description(value: &str) -> bool {
let normalized = value.trim();
!normalized.is_empty()
&& [
"动作气质参考:",
"发力起手明确",
"收招利落",
"动作表现偏向",
"起手克制",
]
.iter()
.any(|marker| normalized.contains(marker))
}
#[cfg(test)]
mod tests {
use super::*;
fn role_input() -> CharacterAssetRolePromptInput {
CharacterAssetRolePromptInput {
id: "hero".to_string(),
name: "沈砺".to_string(),
title: "灰炬向导".to_string(),
role: "边路同行者".to_string(),
visual_description: Some("灰黑短斗篷压着风痕。".to_string()),
action_description: Some("起手先观察风向,再用短弓牵制。".to_string()),
scene_visual_description: Some("边路哨点铺着潮湿石板。".to_string()),
description: Some("熟悉裂潮边路的向导。".to_string()),
backstory: Some("他把旧案痕迹留在边路。".to_string()),
personality: None,
motivation: None,
combat_style: Some("短弓牵制后贴近补刀。".to_string()),
tags: Vec::new(),
image_src: None,
generated_visual_asset_id: None,
generated_animation_set_id: None,
animation_map: None,
}
}
#[test]
fn default_prompt_bundle_keeps_existing_mapping_rules() {
let bundle = build_default_role_prompt_bundle(&role_input());
assert_eq!(bundle.visual_prompt_text, "灰黑短斗篷压着风痕。");
assert_eq!(
bundle.animation_prompt_text,
"起手先观察风向,再用短弓牵制。"
);
assert_eq!(bundle.scene_prompt_text, "边路哨点铺着潮湿石板。");
}
#[test]
fn workflow_prefers_fresh_role_prompt_over_cache() {
let cache = CharacterWorkflowCachePayload {
character_id: "hero".to_string(),
cache_scope_id: None,
visual_prompt_text: "缓存视觉".to_string(),
animation_prompt_text: "缓存动作".to_string(),
animation_prompt_text_by_key: BTreeMap::from([(
"run".to_string(),
"缓存奔跑".to_string(),
)]),
visual_drafts: Vec::new(),
selected_visual_draft_id: String::new(),
selected_animation: "idle".to_string(),
image_src: None,
generated_visual_asset_id: None,
generated_animation_set_id: None,
animation_map: None,
updated_at: None,
};
let workflow = build_role_asset_workflow(role_input(), Some(&cache));
assert_eq!(workflow.visual_prompt_text, "灰黑短斗篷压着风痕。");
assert_eq!(
workflow.animation_prompt_text_by_key["run"],
"起手先观察风向,再用短弓牵制。"
);
}
#[test]
fn workflow_uses_non_legacy_cache_when_role_has_no_fresh_text() {
let mut role = role_input();
role.visual_description = None;
role.action_description = None;
let cache = CharacterWorkflowCachePayload {
character_id: "hero".to_string(),
cache_scope_id: None,
visual_prompt_text: "缓存视觉".to_string(),
animation_prompt_text: "缓存旧动作".to_string(),
animation_prompt_text_by_key: BTreeMap::from([(
"attack".to_string(),
"缓存攻击动作".to_string(),
)]),
visual_drafts: Vec::new(),
selected_visual_draft_id: String::new(),
selected_animation: "attack".to_string(),
image_src: None,
generated_visual_asset_id: None,
generated_animation_set_id: None,
animation_map: None,
updated_at: None,
};
let workflow = build_role_asset_workflow(role, Some(&cache));
assert_eq!(workflow.visual_prompt_text, "缓存视觉");
assert_eq!(
workflow.animation_prompt_text_by_key["attack"],
"缓存攻击动作"
);
assert_eq!(workflow.animation_prompt_text_by_key["run"], "缓存旧动作");
assert_eq!(workflow.selected_animation, "attack");
}
#[test]
fn workflow_filters_legacy_cache_prompts() {
let mut role = role_input();
role.visual_description = None;
role.action_description = None;
let cache = CharacterWorkflowCachePayload {
character_id: "hero".to_string(),
cache_scope_id: None,
visual_prompt_text: "2D 横版 RPG纯绿色绿幕。".to_string(),
animation_prompt_text: "动作气质参考:发力起手明确。".to_string(),
animation_prompt_text_by_key: BTreeMap::from([(
"run".to_string(),
"收招利落,动作表现偏向快速。".to_string(),
)]),
visual_drafts: Vec::new(),
selected_visual_draft_id: String::new(),
selected_animation: "unknown".to_string(),
image_src: None,
generated_visual_asset_id: None,
generated_animation_set_id: None,
animation_map: None,
updated_at: None,
};
let workflow = build_role_asset_workflow(role, Some(&cache));
assert_eq!(workflow.visual_prompt_text, "熟悉裂潮边路的向导。");
assert_eq!(
workflow.animation_prompt_text_by_key["run"],
"短弓牵制后贴近补刀。"
);
assert_eq!(workflow.selected_animation, "run");
}
}

File diff suppressed because it is too large Load Diff