Files
Genarrative/server-rs/crates/api-server/src/prompt/rpg/role_asset_studio.rs
2026-04-28 19:36:39 +08:00

349 lines
13 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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");
}
}