refactor: modularize api server assets and handlers
This commit is contained in:
529
server-rs/crates/api-server/src/puzzle/mappers.rs
Normal file
529
server-rs/crates/api-server/src/puzzle/mappers.rs
Normal file
@@ -0,0 +1,529 @@
|
||||
use super::*;
|
||||
|
||||
pub(super) fn map_puzzle_agent_session_response(
|
||||
session: PuzzleAgentSessionRecord,
|
||||
) -> PuzzleAgentSessionSnapshotResponse {
|
||||
PuzzleAgentSessionSnapshotResponse {
|
||||
session_id: session.session_id,
|
||||
seed_text: session.seed_text,
|
||||
current_turn: session.current_turn,
|
||||
progress_percent: session.progress_percent,
|
||||
stage: session.stage,
|
||||
anchor_pack: map_puzzle_anchor_pack_response(session.anchor_pack),
|
||||
draft: session.draft.map(map_puzzle_result_draft_response),
|
||||
messages: session
|
||||
.messages
|
||||
.into_iter()
|
||||
.map(map_puzzle_agent_message_response)
|
||||
.collect(),
|
||||
last_assistant_reply: session.last_assistant_reply,
|
||||
published_profile_id: session.published_profile_id,
|
||||
suggested_actions: session
|
||||
.suggested_actions
|
||||
.into_iter()
|
||||
.map(map_puzzle_suggested_action_response)
|
||||
.collect(),
|
||||
result_preview: session
|
||||
.result_preview
|
||||
.map(map_puzzle_result_preview_response),
|
||||
updated_at: session.updated_at,
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn map_puzzle_anchor_pack_response(
|
||||
anchor_pack: PuzzleAnchorPackRecord,
|
||||
) -> PuzzleAnchorPackResponse {
|
||||
PuzzleAnchorPackResponse {
|
||||
theme_promise: map_puzzle_anchor_item_response(anchor_pack.theme_promise),
|
||||
visual_subject: map_puzzle_anchor_item_response(anchor_pack.visual_subject),
|
||||
visual_mood: map_puzzle_anchor_item_response(anchor_pack.visual_mood),
|
||||
composition_hooks: map_puzzle_anchor_item_response(anchor_pack.composition_hooks),
|
||||
tags_and_forbidden: map_puzzle_anchor_item_response(anchor_pack.tags_and_forbidden),
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn map_puzzle_anchor_item_response(anchor: PuzzleAnchorItemRecord) -> PuzzleAnchorItemResponse {
|
||||
PuzzleAnchorItemResponse {
|
||||
key: anchor.key,
|
||||
label: anchor.label,
|
||||
value: anchor.value,
|
||||
status: anchor.status,
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn map_puzzle_result_draft_response(draft: PuzzleResultDraftRecord) -> PuzzleResultDraftResponse {
|
||||
PuzzleResultDraftResponse {
|
||||
work_title: draft.work_title,
|
||||
work_description: draft.work_description,
|
||||
level_name: draft.level_name,
|
||||
summary: draft.summary,
|
||||
theme_tags: draft.theme_tags,
|
||||
forbidden_directives: draft.forbidden_directives,
|
||||
creator_intent: draft.creator_intent.map(map_puzzle_creator_intent_response),
|
||||
anchor_pack: map_puzzle_anchor_pack_response(draft.anchor_pack),
|
||||
candidates: draft
|
||||
.candidates
|
||||
.into_iter()
|
||||
.map(map_puzzle_generated_image_candidate_response)
|
||||
.collect(),
|
||||
selected_candidate_id: draft.selected_candidate_id,
|
||||
cover_image_src: draft.cover_image_src,
|
||||
cover_asset_id: draft.cover_asset_id,
|
||||
generation_status: draft.generation_status,
|
||||
levels: draft
|
||||
.levels
|
||||
.into_iter()
|
||||
.map(map_puzzle_draft_level_response)
|
||||
.collect(),
|
||||
form_draft: draft.form_draft.map(map_puzzle_form_draft_response),
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn map_puzzle_form_draft_response(draft: PuzzleFormDraftRecord) -> PuzzleFormDraftResponse {
|
||||
PuzzleFormDraftResponse {
|
||||
work_title: draft.work_title,
|
||||
work_description: draft.work_description,
|
||||
picture_description: draft.picture_description,
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn map_puzzle_draft_level_response(level: PuzzleDraftLevelRecord) -> PuzzleDraftLevelResponse {
|
||||
PuzzleDraftLevelResponse {
|
||||
level_id: level.level_id,
|
||||
level_name: level.level_name,
|
||||
picture_description: level.picture_description,
|
||||
picture_reference: level.picture_reference,
|
||||
ui_background_prompt: level.ui_background_prompt,
|
||||
ui_background_image_src: level.ui_background_image_src,
|
||||
ui_background_image_object_key: level.ui_background_image_object_key,
|
||||
background_music: level
|
||||
.background_music
|
||||
.map(map_puzzle_audio_asset_record_response),
|
||||
candidates: level
|
||||
.candidates
|
||||
.into_iter()
|
||||
.map(map_puzzle_generated_image_candidate_response)
|
||||
.collect(),
|
||||
selected_candidate_id: level.selected_candidate_id,
|
||||
cover_image_src: level.cover_image_src,
|
||||
cover_asset_id: level.cover_asset_id,
|
||||
generation_status: level.generation_status,
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn map_puzzle_audio_asset_record_response(asset: PuzzleAudioAssetRecord) -> CreationAudioAsset {
|
||||
CreationAudioAsset {
|
||||
task_id: asset.task_id,
|
||||
provider: asset.provider,
|
||||
asset_object_id: asset.asset_object_id,
|
||||
asset_kind: asset.asset_kind,
|
||||
audio_src: asset.audio_src,
|
||||
prompt: asset.prompt,
|
||||
title: asset.title,
|
||||
updated_at: asset.updated_at,
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn map_puzzle_audio_asset_domain_record(
|
||||
asset: module_puzzle::PuzzleAudioAsset,
|
||||
) -> PuzzleAudioAssetRecord {
|
||||
PuzzleAudioAssetRecord {
|
||||
task_id: asset.task_id,
|
||||
provider: asset.provider,
|
||||
asset_object_id: asset.asset_object_id,
|
||||
asset_kind: asset.asset_kind,
|
||||
audio_src: asset.audio_src,
|
||||
prompt: asset.prompt,
|
||||
title: asset.title,
|
||||
updated_at: asset.updated_at,
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn puzzle_audio_asset_response_module_json(asset: &Option<CreationAudioAsset>) -> Value {
|
||||
asset
|
||||
.as_ref()
|
||||
.map(|asset| {
|
||||
json!({
|
||||
"task_id": asset.task_id,
|
||||
"provider": asset.provider,
|
||||
"asset_object_id": asset.asset_object_id,
|
||||
"asset_kind": asset.asset_kind,
|
||||
"audio_src": asset.audio_src,
|
||||
"prompt": asset.prompt,
|
||||
"title": asset.title,
|
||||
"updated_at": asset.updated_at,
|
||||
})
|
||||
})
|
||||
.unwrap_or(Value::Null)
|
||||
}
|
||||
|
||||
pub(super) fn puzzle_audio_asset_record_module_json(asset: &Option<PuzzleAudioAssetRecord>) -> Value {
|
||||
asset
|
||||
.as_ref()
|
||||
.map(|asset| {
|
||||
json!({
|
||||
"task_id": asset.task_id,
|
||||
"provider": asset.provider,
|
||||
"asset_object_id": asset.asset_object_id,
|
||||
"asset_kind": asset.asset_kind,
|
||||
"audio_src": asset.audio_src,
|
||||
"prompt": asset.prompt,
|
||||
"title": asset.title,
|
||||
"updated_at": asset.updated_at,
|
||||
})
|
||||
})
|
||||
.unwrap_or(Value::Null)
|
||||
}
|
||||
|
||||
pub(super) fn map_puzzle_creator_intent_response(
|
||||
intent: PuzzleCreatorIntentRecord,
|
||||
) -> PuzzleCreatorIntentResponse {
|
||||
PuzzleCreatorIntentResponse {
|
||||
source_mode: intent.source_mode,
|
||||
raw_messages_summary: intent.raw_messages_summary,
|
||||
theme_promise: intent.theme_promise,
|
||||
visual_subject: intent.visual_subject,
|
||||
visual_mood: intent.visual_mood,
|
||||
composition_hooks: intent.composition_hooks,
|
||||
theme_tags: intent.theme_tags,
|
||||
forbidden_directives: intent.forbidden_directives,
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn map_puzzle_generated_image_candidate_response(
|
||||
candidate: PuzzleGeneratedImageCandidateRecord,
|
||||
) -> PuzzleGeneratedImageCandidateResponse {
|
||||
PuzzleGeneratedImageCandidateResponse {
|
||||
candidate_id: candidate.candidate_id,
|
||||
image_src: candidate.image_src,
|
||||
asset_id: candidate.asset_id,
|
||||
prompt: candidate.prompt,
|
||||
actual_prompt: candidate.actual_prompt,
|
||||
source_type: candidate.source_type,
|
||||
selected: candidate.selected,
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn map_puzzle_agent_message_response(
|
||||
message: PuzzleAgentMessageRecord,
|
||||
) -> PuzzleAgentMessageResponse {
|
||||
PuzzleAgentMessageResponse {
|
||||
id: message.message_id,
|
||||
role: message.role,
|
||||
kind: message.kind,
|
||||
text: message.text,
|
||||
created_at: message.created_at,
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn map_puzzle_suggested_action_response(
|
||||
action: PuzzleAgentSuggestedActionRecord,
|
||||
) -> PuzzleAgentSuggestedActionResponse {
|
||||
PuzzleAgentSuggestedActionResponse {
|
||||
id: action.action_id,
|
||||
action_type: action.action_type,
|
||||
label: action.label,
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn map_puzzle_result_preview_response(
|
||||
preview: PuzzleResultPreviewRecord,
|
||||
) -> PuzzleResultPreviewEnvelopeResponse {
|
||||
PuzzleResultPreviewEnvelopeResponse {
|
||||
draft: map_puzzle_result_draft_response(preview.draft),
|
||||
blockers: preview
|
||||
.blockers
|
||||
.into_iter()
|
||||
.map(map_puzzle_result_preview_blocker_response)
|
||||
.collect(),
|
||||
quality_findings: preview
|
||||
.quality_findings
|
||||
.into_iter()
|
||||
.map(map_puzzle_result_preview_finding_response)
|
||||
.collect(),
|
||||
publish_ready: preview.publish_ready,
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn map_puzzle_result_preview_blocker_response(
|
||||
blocker: PuzzleResultPreviewBlockerRecord,
|
||||
) -> PuzzleResultPreviewBlockerResponse {
|
||||
PuzzleResultPreviewBlockerResponse {
|
||||
id: blocker.blocker_id,
|
||||
code: blocker.code,
|
||||
message: blocker.message,
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn map_puzzle_result_preview_finding_response(
|
||||
finding: PuzzleResultPreviewFindingRecord,
|
||||
) -> PuzzleResultPreviewFindingResponse {
|
||||
PuzzleResultPreviewFindingResponse {
|
||||
id: finding.finding_id,
|
||||
severity: finding.severity,
|
||||
code: finding.code,
|
||||
message: finding.message,
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn map_puzzle_work_summary_response(
|
||||
state: &AppState,
|
||||
item: PuzzleWorkProfileRecord,
|
||||
) -> PuzzleWorkSummaryResponse {
|
||||
let author = resolve_work_author_by_user_id(
|
||||
state,
|
||||
&item.owner_user_id,
|
||||
Some(&item.author_display_name),
|
||||
None,
|
||||
);
|
||||
PuzzleWorkSummaryResponse {
|
||||
work_id: item.work_id,
|
||||
profile_id: item.profile_id,
|
||||
owner_user_id: item.owner_user_id,
|
||||
source_session_id: item.source_session_id,
|
||||
author_display_name: author.display_name,
|
||||
work_title: item.work_title,
|
||||
work_description: item.work_description,
|
||||
level_name: item.level_name,
|
||||
summary: item.summary,
|
||||
theme_tags: item.theme_tags,
|
||||
cover_image_src: item.cover_image_src,
|
||||
cover_asset_id: item.cover_asset_id,
|
||||
publication_status: item.publication_status,
|
||||
updated_at: item.updated_at,
|
||||
published_at: item.published_at,
|
||||
play_count: item.play_count,
|
||||
remix_count: item.remix_count,
|
||||
like_count: item.like_count,
|
||||
recent_play_count_7d: item.recent_play_count_7d,
|
||||
point_incentive_total_half_points: item.point_incentive_total_half_points,
|
||||
point_incentive_claimed_points: item.point_incentive_claimed_points,
|
||||
point_incentive_total_points: item.point_incentive_total_half_points as f64 / 2.0,
|
||||
point_incentive_claimable_points: item
|
||||
.point_incentive_total_half_points
|
||||
.saturating_div(2)
|
||||
.saturating_sub(item.point_incentive_claimed_points),
|
||||
publish_ready: item.publish_ready,
|
||||
levels: Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn map_puzzle_work_profile_response(
|
||||
state: &AppState,
|
||||
item: PuzzleWorkProfileRecord,
|
||||
) -> PuzzleWorkProfileResponse {
|
||||
let mut summary = map_puzzle_work_summary_response(state, item.clone());
|
||||
summary.levels = item
|
||||
.levels
|
||||
.into_iter()
|
||||
.map(map_puzzle_draft_level_response)
|
||||
.collect();
|
||||
|
||||
PuzzleWorkProfileResponse {
|
||||
summary,
|
||||
anchor_pack: map_puzzle_anchor_pack_response(item.anchor_pack),
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn map_puzzle_run_response(run: PuzzleRunRecord) -> PuzzleRunSnapshotResponse {
|
||||
PuzzleRunSnapshotResponse {
|
||||
run_id: run.run_id,
|
||||
entry_profile_id: run.entry_profile_id,
|
||||
cleared_level_count: run.cleared_level_count,
|
||||
current_level_index: run.current_level_index,
|
||||
current_grid_size: run.current_grid_size,
|
||||
played_profile_ids: run.played_profile_ids,
|
||||
previous_level_tags: run.previous_level_tags,
|
||||
current_level: run.current_level.map(map_puzzle_runtime_level_response),
|
||||
recommended_next_profile_id: run.recommended_next_profile_id,
|
||||
next_level_mode: run.next_level_mode,
|
||||
next_level_profile_id: run.next_level_profile_id,
|
||||
next_level_id: run.next_level_id,
|
||||
recommended_next_works: run
|
||||
.recommended_next_works
|
||||
.into_iter()
|
||||
.map(map_puzzle_recommended_next_work_response)
|
||||
.collect(),
|
||||
leaderboard_entries: run
|
||||
.leaderboard_entries
|
||||
.into_iter()
|
||||
.map(map_puzzle_leaderboard_entry_response)
|
||||
.collect(),
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn map_puzzle_recommended_next_work_response(
|
||||
item: PuzzleRecommendedNextWorkRecord,
|
||||
) -> PuzzleRecommendedNextWorkResponse {
|
||||
PuzzleRecommendedNextWorkResponse {
|
||||
profile_id: item.profile_id,
|
||||
level_name: item.level_name,
|
||||
author_display_name: item.author_display_name,
|
||||
theme_tags: item.theme_tags,
|
||||
cover_image_src: item.cover_image_src,
|
||||
similarity_score: item.similarity_score,
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) async fn enrich_puzzle_run_author_name(
|
||||
state: &AppState,
|
||||
mut run: PuzzleRunRecord,
|
||||
) -> PuzzleRunRecord {
|
||||
if let Some(level) = run.current_level.as_mut() {
|
||||
if let Ok(profile) = state
|
||||
.spacetime_client()
|
||||
.get_puzzle_gallery_detail(level.profile_id.clone())
|
||||
.await
|
||||
{
|
||||
level.author_display_name = resolve_work_author_by_user_id(
|
||||
state,
|
||||
&profile.owner_user_id,
|
||||
Some(&profile.author_display_name),
|
||||
None,
|
||||
)
|
||||
.display_name;
|
||||
}
|
||||
}
|
||||
|
||||
run
|
||||
}
|
||||
|
||||
pub(super) fn map_puzzle_runtime_level_response(
|
||||
level: spacetime_client::PuzzleRuntimeLevelRecord,
|
||||
) -> PuzzleRuntimeLevelSnapshotResponse {
|
||||
let timer_defaults =
|
||||
build_puzzle_runtime_timer_response_defaults(level.level_index, level.grid_size);
|
||||
let time_limit_ms = if level.time_limit_ms == 0 {
|
||||
timer_defaults.time_limit_ms
|
||||
} else {
|
||||
level.time_limit_ms
|
||||
};
|
||||
let remaining_ms =
|
||||
if level.remaining_ms == 0 && level.status == PuzzleRuntimeLevelStatus::Playing.as_str() {
|
||||
time_limit_ms
|
||||
} else {
|
||||
level.remaining_ms.min(time_limit_ms)
|
||||
};
|
||||
PuzzleRuntimeLevelSnapshotResponse {
|
||||
run_id: level.run_id,
|
||||
level_index: level.level_index,
|
||||
level_id: level.level_id,
|
||||
grid_size: level.grid_size,
|
||||
profile_id: level.profile_id,
|
||||
level_name: level.level_name,
|
||||
author_display_name: level.author_display_name,
|
||||
theme_tags: level.theme_tags,
|
||||
cover_image_src: level.cover_image_src,
|
||||
ui_background_image_src: level.ui_background_image_src,
|
||||
background_music: level
|
||||
.background_music
|
||||
.map(map_puzzle_audio_asset_record_response),
|
||||
board: map_puzzle_board_response(level.board),
|
||||
status: level.status,
|
||||
started_at_ms: level.started_at_ms,
|
||||
cleared_at_ms: level.cleared_at_ms,
|
||||
elapsed_ms: level.elapsed_ms,
|
||||
time_limit_ms,
|
||||
remaining_ms,
|
||||
paused_accumulated_ms: level.paused_accumulated_ms,
|
||||
pause_started_at_ms: level.pause_started_at_ms,
|
||||
freeze_accumulated_ms: level.freeze_accumulated_ms,
|
||||
freeze_started_at_ms: level.freeze_started_at_ms,
|
||||
freeze_until_ms: level.freeze_until_ms,
|
||||
leaderboard_entries: level
|
||||
.leaderboard_entries
|
||||
.into_iter()
|
||||
.map(map_puzzle_leaderboard_entry_response)
|
||||
.collect(),
|
||||
}
|
||||
}
|
||||
|
||||
struct PuzzleRuntimeTimerResponseDefaults {
|
||||
time_limit_ms: u64,
|
||||
}
|
||||
|
||||
fn build_puzzle_runtime_timer_response_defaults(
|
||||
level_index: u32,
|
||||
grid_size: u32,
|
||||
) -> PuzzleRuntimeTimerResponseDefaults {
|
||||
let time_limit_ms = if level_index > 0 {
|
||||
module_puzzle::resolve_puzzle_level_time_limit_ms_by_index(level_index)
|
||||
} else {
|
||||
module_puzzle::resolve_puzzle_level_time_limit_ms(grid_size)
|
||||
};
|
||||
PuzzleRuntimeTimerResponseDefaults { time_limit_ms }
|
||||
}
|
||||
|
||||
pub(super) fn map_puzzle_leaderboard_entry_response(
|
||||
entry: PuzzleLeaderboardEntryRecord,
|
||||
) -> PuzzleLeaderboardEntryResponse {
|
||||
PuzzleLeaderboardEntryResponse {
|
||||
rank: entry.rank,
|
||||
nickname: entry.nickname,
|
||||
elapsed_ms: entry.elapsed_ms,
|
||||
visible_tags: entry.visible_tags,
|
||||
is_current_player: entry.is_current_player,
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn map_puzzle_board_response(
|
||||
board: spacetime_client::PuzzleBoardRecord,
|
||||
) -> PuzzleBoardSnapshotResponse {
|
||||
PuzzleBoardSnapshotResponse {
|
||||
rows: board.rows,
|
||||
cols: board.cols,
|
||||
pieces: board
|
||||
.pieces
|
||||
.into_iter()
|
||||
.map(|piece| PuzzlePieceStateResponse {
|
||||
piece_id: piece.piece_id,
|
||||
correct_row: piece.correct_row,
|
||||
correct_col: piece.correct_col,
|
||||
current_row: piece.current_row,
|
||||
current_col: piece.current_col,
|
||||
merged_group_id: piece.merged_group_id,
|
||||
})
|
||||
.collect(),
|
||||
merged_groups: board
|
||||
.merged_groups
|
||||
.into_iter()
|
||||
.map(|group| PuzzleMergedGroupStateResponse {
|
||||
group_id: group.group_id,
|
||||
piece_ids: group.piece_ids,
|
||||
occupied_cells: group
|
||||
.occupied_cells
|
||||
.into_iter()
|
||||
.map(|cell| PuzzleCellPositionResponse {
|
||||
row: cell.row,
|
||||
col: cell.col,
|
||||
})
|
||||
.collect(),
|
||||
})
|
||||
.collect(),
|
||||
selected_piece_id: board.selected_piece_id,
|
||||
all_tiles_resolved: board.all_tiles_resolved,
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn resolve_author_display_name(
|
||||
state: &AppState,
|
||||
authenticated: &AuthenticatedAccessToken,
|
||||
) -> String {
|
||||
state
|
||||
.auth_user_service()
|
||||
.get_user_by_id(authenticated.claims().user_id())
|
||||
.ok()
|
||||
.flatten()
|
||||
.map(|user| user.display_name)
|
||||
.filter(|value| !value.trim().is_empty())
|
||||
.unwrap_or_else(|| "玩家".to_string())
|
||||
}
|
||||
|
||||
pub(super) fn build_puzzle_welcome_text(seed_text: &str) -> String {
|
||||
if seed_text.trim().is_empty() {
|
||||
return "拼图创作信息已准备好。".to_string();
|
||||
}
|
||||
|
||||
"拼图创作信息已准备好。".to_string()
|
||||
}
|
||||
|
||||
518
server-rs/crates/api-server/src/puzzle/tags.rs
Normal file
518
server-rs/crates/api-server/src/puzzle/tags.rs
Normal file
@@ -0,0 +1,518 @@
|
||||
use super::*;
|
||||
|
||||
pub(super) async fn generate_puzzle_work_tags(
|
||||
state: &AppState,
|
||||
work_title: &str,
|
||||
work_description: &str,
|
||||
) -> Vec<String> {
|
||||
if let Some(llm_client) = state.llm_client() {
|
||||
let user_prompt = build_puzzle_tag_generation_user_prompt(work_title, work_description);
|
||||
let response = llm_client
|
||||
.request_text(
|
||||
LlmTextRequest::new(vec![
|
||||
LlmMessage::system(PUZZLE_TAG_GENERATION_SYSTEM_PROMPT),
|
||||
LlmMessage::user(user_prompt),
|
||||
])
|
||||
.with_model(CREATION_TEMPLATE_LLM_MODEL)
|
||||
.with_responses_api(),
|
||||
)
|
||||
.await;
|
||||
match response {
|
||||
Ok(response) => {
|
||||
let tags = normalize_puzzle_tag_candidates(parse_puzzle_tags_from_text(
|
||||
response.content.as_str(),
|
||||
));
|
||||
if tags.len() == module_puzzle::PUZZLE_MAX_TAG_COUNT {
|
||||
return tags;
|
||||
}
|
||||
tracing::warn!(
|
||||
provider = PUZZLE_AGENT_API_BASE_PROVIDER,
|
||||
work_title,
|
||||
"拼图 AI 标签数量不足,降级使用关键词补齐"
|
||||
);
|
||||
}
|
||||
Err(error) => {
|
||||
tracing::warn!(
|
||||
provider = PUZZLE_AGENT_API_BASE_PROVIDER,
|
||||
work_title,
|
||||
error = %error,
|
||||
"拼图 AI 标签生成失败,降级使用关键词标签"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
normalize_puzzle_tag_candidates(build_fallback_puzzle_tags(work_title, work_description))
|
||||
}
|
||||
|
||||
pub(super) fn parse_puzzle_tags_from_text(text: &str) -> Vec<String> {
|
||||
let trimmed = text.trim();
|
||||
let json_text = if let Some(start) = trimmed.find('{')
|
||||
&& let Some(end) = trimmed.rfind('}')
|
||||
&& end > start
|
||||
{
|
||||
&trimmed[start..=end]
|
||||
} else {
|
||||
trimmed
|
||||
};
|
||||
let Ok(value) = serde_json::from_str::<Value>(json_text) else {
|
||||
return normalize_puzzle_tag_candidates(trimmed.split([',', ',', '、', '\n']));
|
||||
};
|
||||
let Some(tags) = value.get("tags").and_then(Value::as_array) else {
|
||||
return Vec::new();
|
||||
};
|
||||
normalize_puzzle_tag_candidates(tags.iter().filter_map(Value::as_str))
|
||||
}
|
||||
|
||||
pub(super) fn normalize_puzzle_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_puzzle_tag(candidate.as_ref());
|
||||
if normalized.is_empty() || tags.iter().any(|tag| tag == &normalized) {
|
||||
continue;
|
||||
}
|
||||
tags.push(normalized);
|
||||
if tags.len() >= module_puzzle::PUZZLE_MAX_TAG_COUNT {
|
||||
break;
|
||||
}
|
||||
}
|
||||
for fallback in ["拼图", "插画", "清晰构图", "奇幻", "场景", "氛围"] {
|
||||
if tags.len() >= module_puzzle::PUZZLE_MAX_TAG_COUNT {
|
||||
break;
|
||||
}
|
||||
if !tags.iter().any(|tag| tag == fallback) {
|
||||
tags.push(fallback.to_string());
|
||||
}
|
||||
}
|
||||
tags
|
||||
}
|
||||
|
||||
pub(super) fn normalize_puzzle_tag(value: &str) -> String {
|
||||
value
|
||||
.trim()
|
||||
.trim_matches(|ch: char| {
|
||||
ch.is_ascii_punctuation()
|
||||
|| matches!(
|
||||
ch,
|
||||
',' | '。' | '、' | ';' | ':' | '!' | '?' | '“' | '”' | '《' | '》'
|
||||
)
|
||||
})
|
||||
.trim_start_matches(|ch: char| ch.is_ascii_digit() || matches!(ch, '.' | '、' | ')' | ')'))
|
||||
.trim()
|
||||
.chars()
|
||||
.filter(|ch| !matches!(ch, '#' | '"' | '\'' | '`'))
|
||||
.take(6)
|
||||
.collect::<String>()
|
||||
}
|
||||
|
||||
pub(super) fn build_fallback_puzzle_tags(work_title: &str, work_description: &str) -> Vec<&'static str> {
|
||||
let source = format!("{work_title} {work_description}");
|
||||
let mut tags = Vec::new();
|
||||
for (keyword, tag) in [
|
||||
("猫", "猫咪"),
|
||||
("狗", "小狗"),
|
||||
("神庙", "神庙遗迹"),
|
||||
("遗迹", "神庙遗迹"),
|
||||
("森林", "童话森林"),
|
||||
("雨", "雨夜"),
|
||||
("夜", "夜景"),
|
||||
("城市", "城市奇景"),
|
||||
("蒸汽", "蒸汽城市"),
|
||||
("机械", "机械幻想"),
|
||||
("海", "海岸"),
|
||||
("花", "花园"),
|
||||
("雪", "雪景"),
|
||||
("龙", "幻想生物"),
|
||||
("灯", "暖灯"),
|
||||
("塔", "高塔"),
|
||||
] {
|
||||
if source.contains(keyword) && !tags.contains(&tag) {
|
||||
tags.push(tag);
|
||||
}
|
||||
}
|
||||
tags.extend(["拼图", "插画", "清晰构图", "奇幻", "场景", "氛围"]);
|
||||
tags
|
||||
}
|
||||
|
||||
pub(super) async fn save_generated_puzzle_tags_to_session(
|
||||
state: &AppState,
|
||||
session_id: &str,
|
||||
owner_user_id: &str,
|
||||
payload: &ExecutePuzzleAgentActionRequest,
|
||||
generated_tags: Vec<String>,
|
||||
levels_json: Option<String>,
|
||||
now: i64,
|
||||
) -> Result<PuzzleAgentSessionRecord, AppError> {
|
||||
let session = state
|
||||
.spacetime_client()
|
||||
.get_puzzle_agent_session(session_id.to_string(), owner_user_id.to_string())
|
||||
.await
|
||||
.map_err(map_puzzle_client_error)?;
|
||||
let draft = session.draft.clone().ok_or_else(|| {
|
||||
AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({
|
||||
"provider": PUZZLE_AGENT_API_BASE_PROVIDER,
|
||||
"message": "拼图结果页草稿尚未生成",
|
||||
}))
|
||||
})?;
|
||||
let mut levels = if let Some(levels_json) = levels_json.as_deref() {
|
||||
parse_puzzle_level_records_from_module_json(levels_json)?
|
||||
} else {
|
||||
draft.levels.clone()
|
||||
};
|
||||
if levels.is_empty() {
|
||||
levels = draft.levels.clone();
|
||||
}
|
||||
let first_level = levels.first().cloned().ok_or_else(|| {
|
||||
AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({
|
||||
"provider": PUZZLE_AGENT_API_BASE_PROVIDER,
|
||||
"message": "拼图草稿缺少可编辑关卡",
|
||||
}))
|
||||
})?;
|
||||
let work_title = payload
|
||||
.work_title
|
||||
.as_deref()
|
||||
.map(str::trim)
|
||||
.filter(|value| !value.is_empty())
|
||||
.unwrap_or(draft.work_title.as_str())
|
||||
.to_string();
|
||||
let work_description = payload
|
||||
.work_description
|
||||
.as_deref()
|
||||
.map(str::trim)
|
||||
.filter(|value| !value.is_empty())
|
||||
.unwrap_or(draft.work_description.as_str())
|
||||
.to_string();
|
||||
let levels_json = Some(serialize_puzzle_level_records_for_module(&levels)?);
|
||||
let (_, profile_id) = build_stable_puzzle_work_ids(session_id);
|
||||
state
|
||||
.spacetime_client()
|
||||
.update_puzzle_work(PuzzleWorkUpsertRecordInput {
|
||||
profile_id,
|
||||
owner_user_id: owner_user_id.to_string(),
|
||||
work_title: work_title.clone(),
|
||||
work_description: work_description.clone(),
|
||||
level_name: first_level.level_name.clone(),
|
||||
summary: work_description.clone(),
|
||||
theme_tags: generated_tags.clone(),
|
||||
cover_image_src: first_level.cover_image_src.clone(),
|
||||
cover_asset_id: first_level.cover_asset_id.clone(),
|
||||
levels_json,
|
||||
updated_at_micros: now,
|
||||
})
|
||||
.await
|
||||
.map_err(map_puzzle_client_error)?;
|
||||
|
||||
Ok(apply_generated_puzzle_tags_to_session_snapshot(
|
||||
session,
|
||||
generated_tags,
|
||||
work_title,
|
||||
work_description,
|
||||
levels,
|
||||
now,
|
||||
))
|
||||
}
|
||||
|
||||
pub(super) fn apply_generated_puzzle_tags_to_session_snapshot(
|
||||
mut session: PuzzleAgentSessionRecord,
|
||||
generated_tags: Vec<String>,
|
||||
work_title: String,
|
||||
work_description: String,
|
||||
levels: Vec<PuzzleDraftLevelRecord>,
|
||||
updated_at_micros: i64,
|
||||
) -> PuzzleAgentSessionRecord {
|
||||
let Some(draft) = session.draft.as_mut() else {
|
||||
return session;
|
||||
};
|
||||
draft.work_title = work_title;
|
||||
draft.work_description = work_description.clone();
|
||||
draft.summary = work_description;
|
||||
draft.theme_tags = generated_tags;
|
||||
draft.levels = levels;
|
||||
sync_puzzle_primary_draft_fields_from_level(draft);
|
||||
session.progress_percent = session.progress_percent.max(96);
|
||||
session.stage = if is_puzzle_session_snapshot_publish_ready(draft) {
|
||||
"ready_to_publish".to_string()
|
||||
} else {
|
||||
"image_refining".to_string()
|
||||
};
|
||||
session.last_assistant_reply = Some("作品标签已生成。".to_string());
|
||||
session.updated_at = format_timestamp_micros(updated_at_micros);
|
||||
session
|
||||
}
|
||||
|
||||
pub(super) fn is_puzzle_session_snapshot_publish_ready(draft: &PuzzleResultDraftRecord) -> bool {
|
||||
!draft.work_title.trim().is_empty()
|
||||
&& !draft.work_description.trim().is_empty()
|
||||
&& draft.theme_tags.len() >= module_puzzle::PUZZLE_MIN_TAG_COUNT
|
||||
&& draft.theme_tags.len() <= module_puzzle::PUZZLE_MAX_TAG_COUNT
|
||||
&& !draft.levels.is_empty()
|
||||
&& draft.levels.iter().all(|level| {
|
||||
!level.level_name.trim().is_empty()
|
||||
&& level
|
||||
.cover_image_src
|
||||
.as_deref()
|
||||
.map(str::trim)
|
||||
.is_some_and(|value| !value.is_empty())
|
||||
})
|
||||
}
|
||||
|
||||
pub(super) fn serialize_puzzle_level_records_for_module(
|
||||
levels: &[PuzzleDraftLevelRecord],
|
||||
) -> Result<String, AppError> {
|
||||
let payload = levels
|
||||
.iter()
|
||||
.map(|level| {
|
||||
json!({
|
||||
"level_id": level.level_id,
|
||||
"level_name": level.level_name,
|
||||
"picture_description": level.picture_description,
|
||||
"picture_reference": level.picture_reference,
|
||||
"ui_background_prompt": level.ui_background_prompt,
|
||||
"ui_background_image_src": level.ui_background_image_src,
|
||||
"ui_background_image_object_key": level.ui_background_image_object_key,
|
||||
"background_music": puzzle_audio_asset_record_module_json(&level.background_music),
|
||||
"candidates": level
|
||||
.candidates
|
||||
.iter()
|
||||
.map(|candidate| {
|
||||
json!({
|
||||
"candidate_id": candidate.candidate_id,
|
||||
"image_src": candidate.image_src,
|
||||
"asset_id": candidate.asset_id,
|
||||
"prompt": candidate.prompt,
|
||||
"actual_prompt": candidate.actual_prompt,
|
||||
"source_type": candidate.source_type,
|
||||
"selected": candidate.selected,
|
||||
})
|
||||
})
|
||||
.collect::<Vec<_>>(),
|
||||
"selected_candidate_id": level.selected_candidate_id,
|
||||
"cover_image_src": level.cover_image_src,
|
||||
"cover_asset_id": level.cover_asset_id,
|
||||
"generation_status": level.generation_status,
|
||||
})
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
serde_json::to_string(&payload).map_err(|error| {
|
||||
AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({
|
||||
"provider": PUZZLE_AGENT_API_BASE_PROVIDER,
|
||||
"message": format!("拼图关卡列表序列化失败:{error}"),
|
||||
}))
|
||||
})
|
||||
}
|
||||
|
||||
pub(super) fn is_spacetimedb_connectivity_app_error(error: &AppError) -> bool {
|
||||
matches!(
|
||||
error.status_code(),
|
||||
StatusCode::SERVICE_UNAVAILABLE | StatusCode::GATEWAY_TIMEOUT
|
||||
)
|
||||
}
|
||||
|
||||
pub(super) fn ensure_non_empty(
|
||||
request_context: &RequestContext,
|
||||
provider: &str,
|
||||
value: &str,
|
||||
field_name: &str,
|
||||
) -> Result<(), Response> {
|
||||
if value.trim().is_empty() {
|
||||
return Err(puzzle_error_response(
|
||||
request_context,
|
||||
provider,
|
||||
AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({
|
||||
"provider": provider,
|
||||
"message": format!("{field_name} is required"),
|
||||
})),
|
||||
));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub(super) fn puzzle_bad_request(request_context: &RequestContext, provider: &str, message: &str) -> Response {
|
||||
puzzle_error_response(
|
||||
request_context,
|
||||
provider,
|
||||
AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({
|
||||
"provider": provider,
|
||||
"message": message,
|
||||
})),
|
||||
)
|
||||
}
|
||||
|
||||
pub(super) fn map_puzzle_client_error(error: SpacetimeClientError) -> AppError {
|
||||
let status = match &error {
|
||||
SpacetimeClientError::ConnectDropped => StatusCode::SERVICE_UNAVAILABLE,
|
||||
SpacetimeClientError::Timeout => StatusCode::GATEWAY_TIMEOUT,
|
||||
error if should_skip_asset_operation_billing_for_connectivity(error) => {
|
||||
StatusCode::SERVICE_UNAVAILABLE
|
||||
}
|
||||
SpacetimeClientError::Runtime(_) => StatusCode::BAD_REQUEST,
|
||||
SpacetimeClientError::Procedure(message)
|
||||
if message.contains("不存在")
|
||||
|| message.contains("not found")
|
||||
|| message.contains("does not exist") =>
|
||||
{
|
||||
StatusCode::NOT_FOUND
|
||||
}
|
||||
SpacetimeClientError::Procedure(message)
|
||||
if message.contains("当前模型不可用")
|
||||
|| message.contains("生成失败")
|
||||
|| message.contains("解析失败")
|
||||
|| message.contains("缺少有效回复") =>
|
||||
{
|
||||
StatusCode::BAD_GATEWAY
|
||||
}
|
||||
_ => StatusCode::BAD_GATEWAY,
|
||||
};
|
||||
|
||||
AppError::from_status(status).with_details(json!({
|
||||
"provider": "spacetimedb",
|
||||
"message": error.to_string(),
|
||||
}))
|
||||
}
|
||||
|
||||
pub(super) fn should_sync_puzzle_freeze_boundary(error: &AppError, is_freeze_time: bool) -> bool {
|
||||
is_freeze_time && error.body_text().contains("操作不合法")
|
||||
}
|
||||
|
||||
pub(super) fn is_missing_puzzle_form_draft_procedure_error(error: &SpacetimeClientError) -> bool {
|
||||
matches!(error, SpacetimeClientError::Procedure(message) if
|
||||
message.contains("save_puzzle_form_draft")
|
||||
&& (message.contains("No such procedure")
|
||||
|| message.contains("不存在")
|
||||
|| message.contains("does not exist")
|
||||
|| message.contains("not found")))
|
||||
}
|
||||
|
||||
pub(super) fn map_puzzle_compile_error(error: SpacetimeClientError) -> AppError {
|
||||
let message = error.to_string();
|
||||
// 中文注释:历史运行态或旧 SpacetimeDB 错误快照可能仍带 APIMart 图片网关文案;当前 GPT-image-2 已统一迁移到 VectorEngine,返回给前端前先归一,避免误导排障。
|
||||
let is_legacy_apimart_image_error =
|
||||
message.contains("APIMart") || message.contains("apimart") || message.contains("APIMART");
|
||||
let provider = if message.contains("VectorEngine")
|
||||
|| message.contains("vector-engine")
|
||||
|| message.contains("VECTOR_ENGINE")
|
||||
|| is_legacy_apimart_image_error
|
||||
{
|
||||
VECTOR_ENGINE_PROVIDER
|
||||
} else if message.contains("OSS") || message.contains("oss") || message.contains("参考图") {
|
||||
"puzzle-assets"
|
||||
} else {
|
||||
"spacetimedb"
|
||||
};
|
||||
let status = if provider == VECTOR_ENGINE_PROVIDER
|
||||
&& (message.contains("VECTOR_ENGINE_API_KEY")
|
||||
|| message.contains("VECTOR_ENGINE_BASE_URL")
|
||||
|| message.contains("APIMART_API_KEY")
|
||||
|| message.contains("APIMART_BASE_URL")
|
||||
|| message.contains("未配置"))
|
||||
{
|
||||
StatusCode::SERVICE_UNAVAILABLE
|
||||
} else if matches!(
|
||||
error,
|
||||
SpacetimeClientError::ConnectDropped | SpacetimeClientError::Timeout
|
||||
) || should_skip_asset_operation_billing_for_connectivity(&error)
|
||||
{
|
||||
StatusCode::SERVICE_UNAVAILABLE
|
||||
} else if matches!(error, SpacetimeClientError::Runtime(_))
|
||||
&& (message.contains("生成")
|
||||
|| message.contains("上游")
|
||||
|| message.contains("VectorEngine")
|
||||
|| message.contains("vector-engine")
|
||||
|| message.contains("VECTOR_ENGINE")
|
||||
|| is_legacy_apimart_image_error
|
||||
|| message.contains("参考图")
|
||||
|| message.contains("图片")
|
||||
|| message.contains("OSS")
|
||||
|| message.contains("oss"))
|
||||
{
|
||||
StatusCode::BAD_GATEWAY
|
||||
} else {
|
||||
match &error {
|
||||
SpacetimeClientError::Procedure(message)
|
||||
if message.contains("不存在")
|
||||
|| message.contains("not found")
|
||||
|| message.contains("does not exist") =>
|
||||
{
|
||||
StatusCode::NOT_FOUND
|
||||
}
|
||||
SpacetimeClientError::Procedure(message)
|
||||
if message.contains("当前模型不可用")
|
||||
|| message.contains("生成失败")
|
||||
|| message.contains("解析失败")
|
||||
|| message.contains("缺少有效回复") =>
|
||||
{
|
||||
StatusCode::BAD_GATEWAY
|
||||
}
|
||||
SpacetimeClientError::ConnectDropped => StatusCode::SERVICE_UNAVAILABLE,
|
||||
SpacetimeClientError::Timeout => StatusCode::GATEWAY_TIMEOUT,
|
||||
SpacetimeClientError::Runtime(_) => StatusCode::BAD_REQUEST,
|
||||
_ => StatusCode::BAD_GATEWAY,
|
||||
}
|
||||
};
|
||||
let user_message = normalize_legacy_puzzle_image_error_message(message.as_str());
|
||||
|
||||
AppError::from_status(status).with_details(json!({
|
||||
"provider": provider,
|
||||
"message": user_message,
|
||||
}))
|
||||
}
|
||||
|
||||
pub(super) fn normalize_legacy_puzzle_image_error_message(message: &str) -> String {
|
||||
message
|
||||
.replace(
|
||||
"APIMart 图片生成密钥未配置",
|
||||
"VectorEngine 图片生成密钥未配置",
|
||||
)
|
||||
.replace(
|
||||
"APIMart 图片生成地址未配置",
|
||||
"VectorEngine 图片生成地址未配置",
|
||||
)
|
||||
.replace("APIMART_API_KEY", "VECTOR_ENGINE_API_KEY")
|
||||
.replace("APIMART_BASE_URL", "VECTOR_ENGINE_BASE_URL")
|
||||
}
|
||||
|
||||
pub(super) fn puzzle_error_response(
|
||||
request_context: &RequestContext,
|
||||
provider: &str,
|
||||
error: AppError,
|
||||
) -> Response {
|
||||
let mut response = error.into_response_with_context(Some(request_context));
|
||||
response.headers_mut().insert(
|
||||
HeaderName::from_static("x-genarrative-provider"),
|
||||
header::HeaderValue::from_str(provider)
|
||||
.unwrap_or_else(|_| header::HeaderValue::from_static("puzzle")),
|
||||
);
|
||||
response
|
||||
}
|
||||
|
||||
pub(super) fn puzzle_sse_json_event(event_name: &str, payload: Value) -> Result<Event, AppError> {
|
||||
Event::default()
|
||||
.event(event_name)
|
||||
.json_data(payload)
|
||||
.map_err(|error| {
|
||||
AppError::from_status(StatusCode::INTERNAL_SERVER_ERROR).with_details(json!({
|
||||
"provider": "sse",
|
||||
"message": format!("SSE payload 序列化失败:{error}"),
|
||||
}))
|
||||
})
|
||||
}
|
||||
|
||||
pub(super) fn puzzle_sse_json_event_or_error(event_name: &str, payload: Value) -> Event {
|
||||
match puzzle_sse_json_event(event_name, payload) {
|
||||
Ok(event) => event,
|
||||
Err(_) => puzzle_sse_error_event_message("SSE payload 序列化失败".to_string()),
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn puzzle_sse_error_event_message(message: String) -> Event {
|
||||
let payload = format!(
|
||||
"{{\"message\":{}}}",
|
||||
serde_json::to_string(&message)
|
||||
.unwrap_or_else(|_| "\"SSE 错误事件序列化失败\"".to_string())
|
||||
);
|
||||
Event::default().event("error").data(payload)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user