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, ) -> BTreeMap { 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 { 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(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 { value .map(|value| value.split_whitespace().collect::>().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"); } }