349 lines
13 KiB
Rust
349 lines
13 KiB
Rust
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");
|
||
}
|
||
}
|