1
This commit is contained in:
348
server-rs/crates/api-server/src/prompt/rpg/role_asset_studio.rs
Normal file
348
server-rs/crates/api-server/src/prompt/rpg/role_asset_studio.rs
Normal 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");
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user