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,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");
}
}