refactor: modularize api server assets and handlers
This commit is contained in:
@@ -2137,493 +2137,9 @@ async fn persist_match3d_generated_item_assets_snapshot(
|
||||
.map(|_| ())
|
||||
}
|
||||
|
||||
fn map_match3d_agent_session_response(
|
||||
session: Match3DAgentSessionRecord,
|
||||
) -> Match3DAgentSessionSnapshotResponse {
|
||||
Match3DAgentSessionSnapshotResponse {
|
||||
session_id: session.session_id,
|
||||
current_turn: session.current_turn,
|
||||
progress_percent: session.progress_percent,
|
||||
stage: session.stage.clone(),
|
||||
anchor_pack: map_match3d_anchor_pack_response_for_turn(
|
||||
session.anchor_pack,
|
||||
session.current_turn,
|
||||
session.stage.as_str(),
|
||||
),
|
||||
config: session.config.map(map_match3d_config_response),
|
||||
draft: session.draft.map(map_match3d_draft_response),
|
||||
messages: session
|
||||
.messages
|
||||
.into_iter()
|
||||
.map(map_match3d_message_response)
|
||||
.collect(),
|
||||
last_assistant_reply: session.last_assistant_reply,
|
||||
published_profile_id: session.published_profile_id,
|
||||
updated_at: session.updated_at,
|
||||
}
|
||||
}
|
||||
mod mappers;
|
||||
|
||||
fn map_match3d_agent_session_response_with_assets(
|
||||
session: Match3DAgentSessionRecord,
|
||||
generated_item_assets: &[Match3DGeneratedItemAsset],
|
||||
) -> Match3DAgentSessionSnapshotResponse {
|
||||
let mut response = map_match3d_agent_session_response(session);
|
||||
if let Some(draft) = response.draft.as_mut() {
|
||||
draft.generated_item_assets = generated_item_assets
|
||||
.iter()
|
||||
.cloned()
|
||||
.map(map_match3d_generated_item_asset_for_agent)
|
||||
.collect();
|
||||
if draft
|
||||
.cover_image_src
|
||||
.as_deref()
|
||||
.map(str::trim)
|
||||
.unwrap_or_default()
|
||||
.is_empty()
|
||||
{
|
||||
draft.cover_image_src = resolve_match3d_default_cover_image_src(generated_item_assets);
|
||||
}
|
||||
let background_asset = find_match3d_generated_background_asset(generated_item_assets);
|
||||
apply_match3d_background_asset_to_agent_draft(draft, background_asset);
|
||||
}
|
||||
response
|
||||
}
|
||||
|
||||
fn map_match3d_anchor_pack_response_for_turn(
|
||||
anchor: Match3DAnchorPackRecord,
|
||||
current_turn: u32,
|
||||
stage: &str,
|
||||
) -> Match3DAnchorPackResponse {
|
||||
let is_ready = matches!(
|
||||
stage,
|
||||
"ReadyToCompile"
|
||||
| "ready_to_compile"
|
||||
| "DraftCompiled"
|
||||
| "draft_compiled"
|
||||
| "draft_ready"
|
||||
| "ReadyToPublish"
|
||||
| "ready_to_publish"
|
||||
| "Published"
|
||||
| "published"
|
||||
);
|
||||
let collected_count = if is_ready { 3 } else { current_turn.min(3) };
|
||||
|
||||
Match3DAnchorPackResponse {
|
||||
theme: map_match3d_anchor_item_response_for_collected(anchor.theme, collected_count >= 1),
|
||||
clear_count: map_match3d_anchor_item_response_for_collected(
|
||||
anchor.clear_count,
|
||||
collected_count >= 2,
|
||||
),
|
||||
difficulty: map_match3d_anchor_item_response_for_collected(
|
||||
anchor.difficulty,
|
||||
collected_count >= 3,
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
fn map_match3d_anchor_item_response(anchor: Match3DAnchorItemRecord) -> Match3DAnchorItemResponse {
|
||||
Match3DAnchorItemResponse {
|
||||
key: anchor.key,
|
||||
label: anchor.label,
|
||||
value: anchor.value,
|
||||
status: anchor.status,
|
||||
}
|
||||
}
|
||||
|
||||
fn map_match3d_anchor_item_response_for_collected(
|
||||
anchor: Match3DAnchorItemRecord,
|
||||
collected: bool,
|
||||
) -> Match3DAnchorItemResponse {
|
||||
if collected {
|
||||
return map_match3d_anchor_item_response(anchor);
|
||||
}
|
||||
|
||||
Match3DAnchorItemResponse {
|
||||
key: anchor.key,
|
||||
label: anchor.label,
|
||||
value: String::new(),
|
||||
status: "missing".to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
fn map_match3d_config_response(config: Match3DCreatorConfigRecord) -> Match3DCreatorConfigResponse {
|
||||
Match3DCreatorConfigResponse {
|
||||
theme_text: config.theme_text,
|
||||
reference_image_src: config.reference_image_src,
|
||||
clear_count: config.clear_count,
|
||||
difficulty: config.difficulty,
|
||||
asset_style_id: config.asset_style_id,
|
||||
asset_style_label: config.asset_style_label,
|
||||
asset_style_prompt: config.asset_style_prompt,
|
||||
generate_click_sound: config.generate_click_sound,
|
||||
}
|
||||
}
|
||||
|
||||
fn map_match3d_draft_response(draft: Match3DResultDraftRecord) -> Match3DResultDraftResponse {
|
||||
Match3DResultDraftResponse {
|
||||
profile_id: draft.profile_id,
|
||||
game_name: draft.game_name,
|
||||
theme_text: draft.theme_text,
|
||||
summary_text: Some(draft.summary_text.clone()),
|
||||
summary: draft.summary_text,
|
||||
tags: draft.tags,
|
||||
cover_image_src: draft.cover_image_src,
|
||||
reference_image_src: draft.reference_image_src,
|
||||
clear_count: draft.clear_count,
|
||||
difficulty: draft.difficulty,
|
||||
total_item_count: draft.total_item_count,
|
||||
publish_ready: draft.publish_ready,
|
||||
blockers: draft.blockers,
|
||||
background_prompt: None,
|
||||
background_image_src: None,
|
||||
background_image_object_key: None,
|
||||
generated_background_asset: None,
|
||||
generated_item_assets: Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
fn map_match3d_generated_item_asset_for_agent(
|
||||
asset: Match3DGeneratedItemAsset,
|
||||
) -> Match3DAgentGeneratedItemAssetResponse {
|
||||
Match3DAgentGeneratedItemAssetResponse {
|
||||
item_id: asset.item_id,
|
||||
item_name: asset.item_name,
|
||||
image_src: asset.image_src,
|
||||
image_object_key: asset.image_object_key,
|
||||
image_views: asset
|
||||
.image_views
|
||||
.into_iter()
|
||||
.map(map_match3d_image_view_for_agent)
|
||||
.collect(),
|
||||
model_src: asset.model_src,
|
||||
model_object_key: asset.model_object_key,
|
||||
model_file_name: asset.model_file_name,
|
||||
task_uuid: asset.task_uuid,
|
||||
subscription_key: asset.subscription_key,
|
||||
sound_prompt: asset.sound_prompt,
|
||||
background_music_title: asset.background_music_title,
|
||||
background_music_style: asset.background_music_style,
|
||||
background_music_prompt: asset.background_music_prompt,
|
||||
background_music: asset.background_music,
|
||||
click_sound: asset.click_sound,
|
||||
background_asset: asset
|
||||
.background_asset
|
||||
.map(map_match3d_background_asset_for_agent),
|
||||
status: asset.status,
|
||||
error: asset.error,
|
||||
}
|
||||
}
|
||||
|
||||
fn map_match3d_generated_item_asset_for_work(
|
||||
asset: Match3DGeneratedItemAssetJson,
|
||||
) -> shared_contracts::match3d_works::Match3DGeneratedItemAssetResponse {
|
||||
shared_contracts::match3d_works::Match3DGeneratedItemAssetResponse {
|
||||
item_id: asset.item_id,
|
||||
item_name: asset.item_name,
|
||||
image_src: asset.image_src,
|
||||
image_object_key: asset.image_object_key,
|
||||
image_views: asset
|
||||
.image_views
|
||||
.into_iter()
|
||||
.map(map_match3d_image_view_for_work)
|
||||
.collect(),
|
||||
model_src: asset.model_src,
|
||||
model_object_key: asset.model_object_key,
|
||||
model_file_name: asset.model_file_name,
|
||||
task_uuid: asset.task_uuid,
|
||||
subscription_key: asset.subscription_key,
|
||||
sound_prompt: asset.sound_prompt,
|
||||
background_music_title: asset.background_music_title,
|
||||
background_music_style: asset.background_music_style,
|
||||
background_music_prompt: asset.background_music_prompt,
|
||||
background_music: asset.background_music,
|
||||
click_sound: asset.click_sound,
|
||||
background_asset: asset
|
||||
.background_asset
|
||||
.map(map_match3d_background_asset_for_work),
|
||||
status: asset.status,
|
||||
error: asset.error,
|
||||
}
|
||||
}
|
||||
|
||||
fn map_match3d_image_view_for_agent(
|
||||
view: Match3DGeneratedItemImageView,
|
||||
) -> shared_contracts::match3d_agent::Match3DGeneratedItemImageViewResponse {
|
||||
shared_contracts::match3d_agent::Match3DGeneratedItemImageViewResponse {
|
||||
view_id: view.view_id,
|
||||
view_index: view.view_index,
|
||||
image_src: view.image_src,
|
||||
image_object_key: view.image_object_key,
|
||||
}
|
||||
}
|
||||
|
||||
fn map_match3d_image_view_for_work(
|
||||
view: Match3DGeneratedItemImageView,
|
||||
) -> shared_contracts::match3d_works::Match3DGeneratedItemImageViewResponse {
|
||||
shared_contracts::match3d_works::Match3DGeneratedItemImageViewResponse {
|
||||
view_id: view.view_id,
|
||||
view_index: view.view_index,
|
||||
image_src: view.image_src,
|
||||
image_object_key: view.image_object_key,
|
||||
}
|
||||
}
|
||||
|
||||
fn map_match3d_image_view_from_work(
|
||||
view: shared_contracts::match3d_works::Match3DGeneratedItemImageViewResponse,
|
||||
) -> Match3DGeneratedItemImageView {
|
||||
Match3DGeneratedItemImageView {
|
||||
view_id: view.view_id,
|
||||
view_index: view.view_index,
|
||||
image_src: view.image_src,
|
||||
image_object_key: view.image_object_key,
|
||||
}
|
||||
}
|
||||
|
||||
fn map_match3d_background_asset_for_agent(
|
||||
asset: Match3DGeneratedBackgroundAsset,
|
||||
) -> shared_contracts::match3d_agent::Match3DGeneratedBackgroundAssetResponse {
|
||||
shared_contracts::match3d_agent::Match3DGeneratedBackgroundAssetResponse {
|
||||
prompt: asset.prompt,
|
||||
image_src: asset.image_src,
|
||||
image_object_key: asset.image_object_key,
|
||||
container_prompt: asset.container_prompt,
|
||||
container_image_src: asset.container_image_src,
|
||||
container_image_object_key: asset.container_image_object_key,
|
||||
status: asset.status,
|
||||
error: asset.error,
|
||||
}
|
||||
}
|
||||
|
||||
fn map_match3d_background_asset_for_work(
|
||||
asset: Match3DGeneratedBackgroundAsset,
|
||||
) -> shared_contracts::match3d_works::Match3DGeneratedBackgroundAssetResponse {
|
||||
shared_contracts::match3d_works::Match3DGeneratedBackgroundAssetResponse {
|
||||
prompt: asset.prompt,
|
||||
image_src: asset.image_src,
|
||||
image_object_key: asset.image_object_key,
|
||||
container_prompt: asset.container_prompt,
|
||||
container_image_src: asset.container_image_src,
|
||||
container_image_object_key: asset.container_image_object_key,
|
||||
status: asset.status,
|
||||
error: asset.error,
|
||||
}
|
||||
}
|
||||
|
||||
fn find_match3d_generated_background_asset(
|
||||
assets: &[Match3DGeneratedItemAsset],
|
||||
) -> Option<Match3DGeneratedBackgroundAsset> {
|
||||
assets
|
||||
.iter()
|
||||
.find_map(|asset| asset.background_asset.clone())
|
||||
}
|
||||
|
||||
fn resolve_match3d_default_cover_image_src(assets: &[Match3DGeneratedItemAsset]) -> Option<String> {
|
||||
find_match3d_generated_background_asset(assets).and_then(|asset| {
|
||||
asset
|
||||
.container_image_src
|
||||
.as_deref()
|
||||
.map(str::trim)
|
||||
.filter(|value| !value.is_empty())
|
||||
.map(str::to_string)
|
||||
.or_else(|| {
|
||||
asset
|
||||
.container_image_object_key
|
||||
.as_deref()
|
||||
.map(str::trim)
|
||||
.filter(|value| !value.is_empty())
|
||||
.map(str::to_string)
|
||||
})
|
||||
.or_else(|| {
|
||||
asset
|
||||
.image_src
|
||||
.as_deref()
|
||||
.map(str::trim)
|
||||
.filter(|value| !value.is_empty())
|
||||
.map(str::to_string)
|
||||
})
|
||||
.or_else(|| {
|
||||
asset
|
||||
.image_object_key
|
||||
.as_deref()
|
||||
.map(str::trim)
|
||||
.filter(|value| !value.is_empty())
|
||||
.map(str::to_string)
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
fn find_match3d_generated_background_asset_json(
|
||||
assets: &[Match3DGeneratedItemAssetJson],
|
||||
) -> Option<Match3DGeneratedBackgroundAsset> {
|
||||
assets
|
||||
.iter()
|
||||
.find_map(|asset| asset.background_asset.clone())
|
||||
}
|
||||
|
||||
fn apply_match3d_background_asset_to_agent_draft(
|
||||
draft: &mut Match3DResultDraftResponse,
|
||||
background_asset: Option<Match3DGeneratedBackgroundAsset>,
|
||||
) {
|
||||
if let Some(asset) = background_asset {
|
||||
draft.background_prompt = Some(asset.prompt.clone());
|
||||
draft.background_image_src = asset.image_src.clone();
|
||||
draft.background_image_object_key = asset.image_object_key.clone();
|
||||
draft.generated_background_asset = Some(map_match3d_background_asset_for_agent(asset));
|
||||
}
|
||||
}
|
||||
|
||||
fn map_match3d_message_response(message: Match3DAgentMessageRecord) -> Match3DAgentMessageResponse {
|
||||
Match3DAgentMessageResponse {
|
||||
id: message.message_id,
|
||||
role: message.role,
|
||||
kind: message.kind,
|
||||
text: message.text,
|
||||
created_at: message.created_at,
|
||||
}
|
||||
}
|
||||
|
||||
fn map_match3d_work_summary_response(item: Match3DWorkProfileRecord) -> Match3DWorkSummaryResponse {
|
||||
let generated_item_asset_json =
|
||||
parse_match3d_generated_item_assets(item.generated_item_assets_json.as_deref());
|
||||
let background_asset = find_match3d_generated_background_asset_json(&generated_item_asset_json);
|
||||
let generated_background_asset = background_asset
|
||||
.clone()
|
||||
.map(map_match3d_background_asset_for_work);
|
||||
let generated_item_assets = generated_item_asset_json
|
||||
.into_iter()
|
||||
.map(map_match3d_generated_item_asset_for_work)
|
||||
.collect();
|
||||
Match3DWorkSummaryResponse {
|
||||
work_id: item.work_id,
|
||||
profile_id: item.profile_id,
|
||||
owner_user_id: item.owner_user_id,
|
||||
source_session_id: item.source_session_id,
|
||||
game_name: item.game_name,
|
||||
theme_text: item.theme_text,
|
||||
summary: item.summary,
|
||||
tags: item.tags,
|
||||
cover_image_src: item.cover_image_src,
|
||||
reference_image_src: item.reference_image_src,
|
||||
clear_count: item.clear_count,
|
||||
difficulty: item.difficulty,
|
||||
publication_status: item.publication_status,
|
||||
play_count: item.play_count,
|
||||
updated_at: item.updated_at,
|
||||
published_at: item.published_at,
|
||||
publish_ready: item.publish_ready,
|
||||
background_prompt: background_asset.as_ref().map(|asset| asset.prompt.clone()),
|
||||
background_image_src: background_asset
|
||||
.as_ref()
|
||||
.and_then(|asset| asset.image_src.clone()),
|
||||
background_image_object_key: background_asset
|
||||
.as_ref()
|
||||
.and_then(|asset| asset.image_object_key.clone()),
|
||||
generated_background_asset,
|
||||
generated_item_assets,
|
||||
}
|
||||
}
|
||||
|
||||
fn match3d_bad_gateway(message: impl Into<String>) -> AppError {
|
||||
AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({
|
||||
"provider": MATCH3D_AGENT_PROVIDER,
|
||||
"message": message.into(),
|
||||
}))
|
||||
}
|
||||
|
||||
fn match3d_background_music_missing_error(message: impl Into<String>) -> AppError {
|
||||
AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({
|
||||
"provider": MATCH3D_AGENT_PROVIDER,
|
||||
"message": message.into(),
|
||||
"missingAssets": ["背景音乐"],
|
||||
}))
|
||||
}
|
||||
|
||||
fn require_match3d_background_music_title(
|
||||
plan: &Match3DGeneratedBackgroundMusicPlan,
|
||||
) -> Result<String, AppError> {
|
||||
let title = normalize_match3d_audio_title(plan.title.as_str());
|
||||
if title.is_empty() {
|
||||
return Err(match3d_background_music_missing_error(
|
||||
"抓大鹅草稿背景音乐名称为空,无法完成背景音乐生成",
|
||||
));
|
||||
}
|
||||
Ok(title)
|
||||
}
|
||||
|
||||
fn map_match3d_work_profile_response(item: Match3DWorkProfileRecord) -> Match3DWorkProfileResponse {
|
||||
Match3DWorkProfileResponse {
|
||||
summary: map_match3d_work_summary_response(item),
|
||||
}
|
||||
}
|
||||
|
||||
fn map_match3d_run_response(run: Match3DRunRecord) -> Match3DRunSnapshotResponse {
|
||||
Match3DRunSnapshotResponse {
|
||||
run_id: run.run_id,
|
||||
profile_id: run.profile_id,
|
||||
owner_user_id: run.owner_user_id,
|
||||
status: normalize_match3d_run_status(run.status.as_str()).to_string(),
|
||||
snapshot_version: run.snapshot_version,
|
||||
started_at_ms: run.started_at_ms,
|
||||
duration_limit_ms: run.duration_limit_ms,
|
||||
server_now_ms: run.server_now_ms,
|
||||
remaining_ms: run.remaining_ms,
|
||||
clear_count: run.clear_count,
|
||||
total_item_count: run.total_item_count,
|
||||
cleared_item_count: run.cleared_item_count,
|
||||
items: run
|
||||
.items
|
||||
.into_iter()
|
||||
.map(map_match3d_item_response)
|
||||
.collect(),
|
||||
tray_slots: run
|
||||
.tray_slots
|
||||
.into_iter()
|
||||
.map(map_match3d_tray_slot_response)
|
||||
.collect(),
|
||||
failure_reason: run
|
||||
.failure_reason
|
||||
.map(|reason| normalize_match3d_failure_reason(reason.as_str()).to_string()),
|
||||
last_confirmed_action_id: run.last_confirmed_action_id,
|
||||
}
|
||||
}
|
||||
|
||||
fn map_match3d_item_response(item: Match3DItemSnapshotRecord) -> Match3DItemSnapshotResponse {
|
||||
Match3DItemSnapshotResponse {
|
||||
item_instance_id: item.item_instance_id,
|
||||
item_type_id: item.item_type_id,
|
||||
visual_key: item.visual_key,
|
||||
x: item.x,
|
||||
y: item.y,
|
||||
radius: item.radius,
|
||||
layer: item.layer,
|
||||
state: normalize_match3d_item_state(item.state.as_str()).to_string(),
|
||||
clickable: item.clickable,
|
||||
tray_slot_index: item.tray_slot_index,
|
||||
}
|
||||
}
|
||||
|
||||
fn map_match3d_tray_slot_response(slot: Match3DTraySlotRecord) -> Match3DTraySlotResponse {
|
||||
Match3DTraySlotResponse {
|
||||
slot_index: slot.slot_index,
|
||||
item_instance_id: slot.item_instance_id,
|
||||
item_type_id: slot.item_type_id,
|
||||
visual_key: slot.visual_key,
|
||||
}
|
||||
}
|
||||
|
||||
fn map_match3d_click_confirmation_response(
|
||||
confirmation: Match3DClickConfirmationRecord,
|
||||
) -> Match3DClickConfirmationResponse {
|
||||
Match3DClickConfirmationResponse {
|
||||
accepted: confirmation.accepted,
|
||||
reject_reason: confirmation
|
||||
.reject_reason
|
||||
.map(|reason| normalize_match3d_click_reject_reason(reason.as_str()).to_string()),
|
||||
entered_slot_index: confirmation.entered_slot_index,
|
||||
cleared_item_instance_ids: confirmation.cleared_item_instance_ids,
|
||||
run: map_match3d_run_response(confirmation.run),
|
||||
}
|
||||
}
|
||||
use mappers::*;
|
||||
|
||||
fn build_config_from_create_request(
|
||||
payload: &CreateMatch3DAgentSessionRequest,
|
||||
@@ -2861,175 +2377,9 @@ fn normalize_optional_match3d_text(value: Option<String>) -> Option<String> {
|
||||
.filter(|value| !value.is_empty())
|
||||
}
|
||||
|
||||
fn normalize_match3d_tag(value: &str) -> String {
|
||||
let trimmed = value.trim();
|
||||
let without_number_prefix = trimmed
|
||||
.char_indices()
|
||||
.find_map(|(index, ch)| {
|
||||
if index == 0 || !matches!(ch, '.' | '、' | ')' | ')') {
|
||||
return None;
|
||||
}
|
||||
let prefix = &trimmed[..index];
|
||||
if prefix.chars().all(|candidate| candidate.is_ascii_digit()) {
|
||||
Some(trimmed[index + ch.len_utf8()..].trim_start())
|
||||
} else {
|
||||
None
|
||||
}
|
||||
})
|
||||
.unwrap_or(trimmed);
|
||||
mod tags;
|
||||
|
||||
without_number_prefix
|
||||
.trim_matches(|ch: char| {
|
||||
ch.is_ascii_punctuation()
|
||||
|| matches!(
|
||||
ch,
|
||||
',' | '。' | '、' | ';' | ':' | '!' | '?' | '“' | '”' | '《' | '》'
|
||||
)
|
||||
})
|
||||
.trim()
|
||||
.chars()
|
||||
.filter(|ch| !matches!(ch, '#' | '"' | '\'' | '`'))
|
||||
.collect::<String>()
|
||||
.chars()
|
||||
.take(6)
|
||||
.collect::<String>()
|
||||
}
|
||||
|
||||
fn normalize_match3d_tag_candidates<S>(candidates: impl IntoIterator<Item = S>) -> Vec<String>
|
||||
where
|
||||
S: AsRef<str>,
|
||||
{
|
||||
let mut tags = Vec::new();
|
||||
for candidate in candidates {
|
||||
let normalized = normalize_match3d_tag(candidate.as_ref());
|
||||
if normalized.is_empty() || tags.iter().any(|tag| tag == &normalized) {
|
||||
continue;
|
||||
}
|
||||
tags.push(normalized);
|
||||
if tags.len() >= 6 {
|
||||
break;
|
||||
}
|
||||
}
|
||||
for fallback in ["抓大鹅", "经典消除", "2D素材", "轻量休闲", "收集", "挑战"] {
|
||||
if tags.len() >= 6 {
|
||||
break;
|
||||
}
|
||||
if !tags.iter().any(|tag| tag == fallback) {
|
||||
tags.push(fallback.to_string());
|
||||
}
|
||||
}
|
||||
tags
|
||||
}
|
||||
|
||||
async fn generate_match3d_work_tags_for_profile(
|
||||
state: &AppState,
|
||||
game_name: &str,
|
||||
theme_text: &str,
|
||||
summary: Option<&str>,
|
||||
) -> Vec<String> {
|
||||
request_match3d_work_tags_with_llm(state, game_name, theme_text, summary)
|
||||
.await
|
||||
.unwrap_or_else(|| fallback_match3d_work_tags(game_name, theme_text))
|
||||
}
|
||||
|
||||
async fn request_match3d_work_tags_with_llm(
|
||||
state: &AppState,
|
||||
game_name: &str,
|
||||
theme_text: &str,
|
||||
summary: Option<&str>,
|
||||
) -> Option<Vec<String>> {
|
||||
let Some(llm_client) = state
|
||||
.creative_agent_gpt5_client()
|
||||
.or_else(|| state.llm_client())
|
||||
else {
|
||||
return None;
|
||||
};
|
||||
let user_prompt = format!(
|
||||
"题材设定:{}\n作品名称:{}\n作品描述:{}\n请生成 3 到 6 个适合抓大鹅作品发布的中文短标签。要求:只返回 JSON 数组,每项 2 到 6 个汉字,不要解释。",
|
||||
theme_text,
|
||||
game_name,
|
||||
summary.unwrap_or_default()
|
||||
);
|
||||
let response = llm_client
|
||||
.request_text(
|
||||
LlmTextRequest::new(vec![
|
||||
LlmMessage::system("你是抓大鹅作品标签编辑,只返回 JSON 字符串数组。"),
|
||||
LlmMessage::user(user_prompt),
|
||||
])
|
||||
.with_model(MATCH3D_WORK_METADATA_LLM_MODEL)
|
||||
.with_responses_api(),
|
||||
)
|
||||
.await;
|
||||
|
||||
match response {
|
||||
Ok(response) => {
|
||||
let tags = parse_match3d_tags_from_text(response.content.as_str());
|
||||
if tags.len() >= MATCH3D_MIN_GENERATED_TAG_COUNT {
|
||||
return Some(tags);
|
||||
}
|
||||
tracing::warn!(
|
||||
provider = MATCH3D_WORKS_PROVIDER,
|
||||
game_name,
|
||||
"抓大鹅 AI 标签生成数量不足,降级使用本地标签"
|
||||
);
|
||||
None
|
||||
}
|
||||
Err(error) => {
|
||||
tracing::warn!(
|
||||
provider = MATCH3D_WORKS_PROVIDER,
|
||||
game_name,
|
||||
error = %error,
|
||||
"抓大鹅 AI 标签生成失败,降级使用本地标签"
|
||||
);
|
||||
None
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async fn generate_match3d_work_tags_for_plan(
|
||||
state: &AppState,
|
||||
game_name: &str,
|
||||
theme_text: &str,
|
||||
summary: &str,
|
||||
plan_tags: &[String],
|
||||
) -> Vec<String> {
|
||||
if let Some(tags) =
|
||||
request_match3d_work_tags_with_llm(state, game_name, theme_text, Some(summary)).await
|
||||
{
|
||||
return tags;
|
||||
}
|
||||
merge_match3d_plan_tags_with_fallback(game_name, theme_text, plan_tags)
|
||||
}
|
||||
|
||||
fn merge_match3d_plan_tags_with_fallback(
|
||||
game_name: &str,
|
||||
theme_text: &str,
|
||||
plan_tags: &[String],
|
||||
) -> Vec<String> {
|
||||
let mut candidates = plan_tags.to_vec();
|
||||
candidates.extend(fallback_match3d_work_tags(game_name, theme_text));
|
||||
normalize_match3d_tag_candidates(candidates)
|
||||
}
|
||||
|
||||
const MATCH3D_MIN_GENERATED_TAG_COUNT: usize = 3;
|
||||
|
||||
fn parse_match3d_tags_from_text(raw: &str) -> Vec<String> {
|
||||
let raw = raw.trim();
|
||||
let json_text = if let Some(start) = raw.find('[')
|
||||
&& let Some(end) = raw.rfind(']')
|
||||
&& end > start
|
||||
{
|
||||
&raw[start..=end]
|
||||
} else {
|
||||
raw
|
||||
};
|
||||
let parsed = serde_json::from_str::<Vec<String>>(json_text).unwrap_or_default();
|
||||
normalize_match3d_tag_candidates(parsed)
|
||||
}
|
||||
|
||||
fn fallback_match3d_work_tags(game_name: &str, theme_text: &str) -> Vec<String> {
|
||||
normalize_match3d_tag_candidates([theme_text, game_name, "抓大鹅", "经典消除", "2D素材"])
|
||||
}
|
||||
use tags::*;
|
||||
|
||||
fn serialize_match3d_generated_item_assets(assets: &[Match3DGeneratedItemAsset]) -> Option<String> {
|
||||
if assets.is_empty() {
|
||||
@@ -3614,9 +2964,8 @@ async fn ensure_match3d_background_music_asset(
|
||||
));
|
||||
};
|
||||
|
||||
let title = require_match3d_background_music_title(plan).map_err(|error| {
|
||||
match3d_error_response(request_context, MATCH3D_AGENT_PROVIDER, error)
|
||||
})?;
|
||||
let title = require_match3d_background_music_title(plan)
|
||||
.map_err(|error| match3d_error_response(request_context, MATCH3D_AGENT_PROVIDER, error))?;
|
||||
let style = normalize_match3d_audio_style(plan.style.as_str());
|
||||
match generate_match3d_background_music_asset(state, owner_user_id, profile_id, &title, &style)
|
||||
.await
|
||||
@@ -6556,12 +5905,13 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn match3d_background_music_title_is_required_for_auto_draft() {
|
||||
let missing = require_match3d_background_music_title(&Match3DGeneratedBackgroundMusicPlan {
|
||||
title: " ,。 ".to_string(),
|
||||
style: "轻快, 休闲".to_string(),
|
||||
prompt: String::new(),
|
||||
})
|
||||
.expect_err("自动草稿背景音乐必须有可提交给 Suno 的曲名");
|
||||
let missing =
|
||||
require_match3d_background_music_title(&Match3DGeneratedBackgroundMusicPlan {
|
||||
title: " ,。 ".to_string(),
|
||||
style: "轻快, 休闲".to_string(),
|
||||
prompt: String::new(),
|
||||
})
|
||||
.expect_err("自动草稿背景音乐必须有可提交给 Suno 的曲名");
|
||||
|
||||
assert!(missing.body_text().contains("背景音乐"));
|
||||
|
||||
|
||||
Reference in New Issue
Block a user