1
This commit is contained in:
@@ -38,9 +38,10 @@ use shared_contracts::{
|
||||
puzzle_runtime::{
|
||||
AdvanceLocalPuzzleNextLevelRequest, PuzzleBoardSnapshotResponse,
|
||||
PuzzleCellPositionResponse, PuzzleLeaderboardEntryResponse, PuzzleMergedGroupStateResponse,
|
||||
PuzzlePieceStateResponse, PuzzleRunResponse, PuzzleRunSnapshotResponse,
|
||||
PuzzleRuntimeLevelSnapshotResponse, StartPuzzleRunRequest, SubmitPuzzleLeaderboardRequest,
|
||||
SwapPuzzlePiecesRequest, UpdatePuzzleRuntimePauseRequest, UsePuzzleRuntimePropRequest,
|
||||
PuzzlePieceStateResponse, PuzzleRecommendedNextWorkResponse, PuzzleRunResponse,
|
||||
PuzzleRunSnapshotResponse, PuzzleRuntimeLevelSnapshotResponse, StartPuzzleRunRequest,
|
||||
SubmitPuzzleLeaderboardRequest, SwapPuzzlePiecesRequest, UpdatePuzzleRuntimePauseRequest,
|
||||
UsePuzzleRuntimePropRequest,
|
||||
},
|
||||
puzzle_works::{
|
||||
PutPuzzleWorkRequest, PuzzleWorkDetailResponse, PuzzleWorkMutationResponse,
|
||||
@@ -56,12 +57,12 @@ use spacetime_client::{
|
||||
PuzzleFormDraftRecord, PuzzleFormDraftSaveRecordInput, PuzzleGeneratedImageCandidateRecord,
|
||||
PuzzleGeneratedImagesSaveRecordInput, PuzzleLeaderboardEntryRecord,
|
||||
PuzzleLeaderboardSubmitRecordInput, PuzzleMergedGroupRecord, PuzzlePieceStateRecord,
|
||||
PuzzlePublishRecordInput, PuzzleResultDraftRecord, PuzzleResultPreviewBlockerRecord,
|
||||
PuzzleResultPreviewFindingRecord, PuzzleResultPreviewRecord, PuzzleRunPauseRecordInput,
|
||||
PuzzleRunPropRecordInput, PuzzleRunRecord, PuzzleRunStartRecordInput, PuzzleRunSwapRecordInput,
|
||||
PuzzleRuntimeLevelRecord, PuzzleSelectCoverImageRecordInput, PuzzleWorkLikeReportRecordInput,
|
||||
PuzzleWorkProfileRecord, PuzzleWorkRemixRecordInput, PuzzleWorkUpsertRecordInput,
|
||||
SpacetimeClientError,
|
||||
PuzzlePublishRecordInput, PuzzleRecommendedNextWorkRecord, PuzzleResultDraftRecord,
|
||||
PuzzleResultPreviewBlockerRecord, PuzzleResultPreviewFindingRecord, PuzzleResultPreviewRecord,
|
||||
PuzzleRunPauseRecordInput, PuzzleRunPropRecordInput, PuzzleRunRecord,
|
||||
PuzzleRunStartRecordInput, PuzzleRunSwapRecordInput, PuzzleRuntimeLevelRecord,
|
||||
PuzzleSelectCoverImageRecordInput, PuzzleWorkLikeReportRecordInput, PuzzleWorkProfileRecord,
|
||||
PuzzleWorkRemixRecordInput, PuzzleWorkUpsertRecordInput, SpacetimeClientError,
|
||||
};
|
||||
use std::convert::Infallible;
|
||||
use tokio::time::sleep;
|
||||
@@ -72,7 +73,13 @@ use crate::{
|
||||
asset_billing::execute_billable_asset_operation,
|
||||
auth::AuthenticatedAccessToken,
|
||||
http_error::AppError,
|
||||
prompt::puzzle::image::{PUZZLE_DEFAULT_NEGATIVE_PROMPT, build_puzzle_image_prompt},
|
||||
prompt::puzzle::{
|
||||
draft::{
|
||||
PuzzleFormSeedPromptParts, build_puzzle_form_seed_prompt,
|
||||
resolve_puzzle_draft_cover_prompt, resolve_puzzle_level_image_prompt,
|
||||
},
|
||||
image::{PUZZLE_DEFAULT_NEGATIVE_PROMPT, build_puzzle_image_prompt},
|
||||
},
|
||||
puzzle_agent_turn::{
|
||||
PuzzleAgentTurnRequest, build_failed_finalize_record_input, build_finalize_record_input,
|
||||
run_puzzle_agent_turn,
|
||||
@@ -472,7 +479,7 @@ pub async fn execute_puzzle_agent_action(
|
||||
.map(str::trim)
|
||||
.filter(|value| !value.is_empty())
|
||||
.or_else(|| payload.prompt_text.as_deref());
|
||||
if let Err(response) = save_puzzle_form_payload_before_compile(
|
||||
let compile_session_id = match save_puzzle_form_payload_before_compile(
|
||||
&state,
|
||||
&request_context,
|
||||
&session_id,
|
||||
@@ -482,8 +489,9 @@ pub async fn execute_puzzle_agent_action(
|
||||
)
|
||||
.await
|
||||
{
|
||||
return Err(response);
|
||||
}
|
||||
Ok(next_session_id) => next_session_id,
|
||||
Err(response) => return Err(response),
|
||||
};
|
||||
let session = execute_billable_asset_operation(
|
||||
&state,
|
||||
&owner_user_id,
|
||||
@@ -492,7 +500,7 @@ pub async fn execute_puzzle_agent_action(
|
||||
async {
|
||||
compile_puzzle_draft_with_initial_cover(
|
||||
&state,
|
||||
session_id.clone(),
|
||||
compile_session_id.clone(),
|
||||
owner_user_id.clone(),
|
||||
prompt_text,
|
||||
payload.reference_image_src.as_deref(),
|
||||
@@ -522,7 +530,7 @@ pub async fn execute_puzzle_agent_action(
|
||||
.as_deref()
|
||||
.or(payload.prompt_text.as_deref()),
|
||||
);
|
||||
let session = state
|
||||
let save_result = state
|
||||
.spacetime_client()
|
||||
.save_puzzle_form_draft(PuzzleFormDraftSaveRecordInput {
|
||||
session_id: session_id.clone(),
|
||||
@@ -530,14 +538,36 @@ pub async fn execute_puzzle_agent_action(
|
||||
seed_text,
|
||||
saved_at_micros: now,
|
||||
})
|
||||
.await
|
||||
.map_err(|error| {
|
||||
puzzle_error_response(
|
||||
&request_context,
|
||||
PUZZLE_AGENT_API_BASE_PROVIDER,
|
||||
map_puzzle_client_error(error),
|
||||
)
|
||||
});
|
||||
.await;
|
||||
let session = match save_result {
|
||||
Ok(session) => Ok(session),
|
||||
Err(error) if is_missing_puzzle_form_draft_procedure_error(&error) => {
|
||||
// 中文注释:Maincloud 旧 wasm 缺少该自动保存 procedure 时,返回当前 session,避免填表页被非关键错误打断。
|
||||
tracing::warn!(
|
||||
provider = PUZZLE_AGENT_API_BASE_PROVIDER,
|
||||
session_id = %session_id,
|
||||
owner_user_id = %owner_user_id,
|
||||
error = %error,
|
||||
"拼图表单自动保存 procedure 缺失,降级返回当前会话"
|
||||
);
|
||||
state
|
||||
.spacetime_client()
|
||||
.get_puzzle_agent_session(session_id.clone(), owner_user_id.clone())
|
||||
.await
|
||||
.map_err(|fallback_error| {
|
||||
puzzle_error_response(
|
||||
&request_context,
|
||||
PUZZLE_AGENT_API_BASE_PROVIDER,
|
||||
map_puzzle_client_error(fallback_error),
|
||||
)
|
||||
})
|
||||
}
|
||||
Err(error) => Err(puzzle_error_response(
|
||||
&request_context,
|
||||
PUZZLE_AGENT_API_BASE_PROVIDER,
|
||||
map_puzzle_client_error(error),
|
||||
)),
|
||||
};
|
||||
(
|
||||
"save_puzzle_form_draft",
|
||||
"表单草稿保存",
|
||||
@@ -547,30 +577,42 @@ pub async fn execute_puzzle_agent_action(
|
||||
}
|
||||
"generate_puzzle_images" => {
|
||||
let target_level_id = payload.level_id.clone();
|
||||
let levels_json = normalize_puzzle_levels_json_for_module(
|
||||
payload.levels_json.as_deref(),
|
||||
)
|
||||
.map_err(|message| {
|
||||
AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({
|
||||
"provider": PUZZLE_AGENT_API_BASE_PROVIDER,
|
||||
"message": message,
|
||||
}))
|
||||
});
|
||||
let session = execute_billable_asset_operation(
|
||||
&state,
|
||||
&owner_user_id,
|
||||
"puzzle_generated_image",
|
||||
&billing_asset_id,
|
||||
async {
|
||||
let levels_json = levels_json?;
|
||||
let session = state
|
||||
.spacetime_client()
|
||||
.get_puzzle_agent_session(session_id.clone(), owner_user_id.clone())
|
||||
.await
|
||||
.map_err(map_puzzle_client_error)?;
|
||||
let draft = session.draft.clone().ok_or_else(|| {
|
||||
let mut draft = session.draft.clone().ok_or_else(|| {
|
||||
AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({
|
||||
"provider": PUZZLE_AGENT_API_BASE_PROVIDER,
|
||||
"message": "拼图结果页草稿尚未生成",
|
||||
}))
|
||||
})?;
|
||||
if let Some(levels_json) = levels_json.as_ref() {
|
||||
draft.levels = parse_puzzle_level_records_from_module_json(levels_json)?;
|
||||
}
|
||||
let target_level =
|
||||
select_puzzle_level_for_api(&draft, target_level_id.as_deref())?;
|
||||
let prompt = payload
|
||||
.prompt_text
|
||||
.clone()
|
||||
.filter(|value| !value.trim().is_empty())
|
||||
.unwrap_or_else(|| target_level.picture_description.clone());
|
||||
let prompt = resolve_puzzle_level_image_prompt(
|
||||
payload.prompt_text.as_deref(),
|
||||
&target_level.picture_description,
|
||||
);
|
||||
// 拼图结果页从多候选抽卡收口为单图替换,前端传入的旧 candidateCount 只做兼容忽略。
|
||||
let candidate_count = 1;
|
||||
let candidate_start_index = target_level.candidates.len();
|
||||
@@ -609,6 +651,7 @@ pub async fn execute_puzzle_agent_action(
|
||||
session_id: session.session_id,
|
||||
owner_user_id: owner_user_id.clone(),
|
||||
level_id: Some(target_level.level_id),
|
||||
levels_json,
|
||||
candidates_json,
|
||||
saved_at_micros: now,
|
||||
})
|
||||
@@ -977,7 +1020,7 @@ pub async fn get_puzzle_gallery_detail(
|
||||
Ok(json_success_body(
|
||||
Some(&request_context),
|
||||
PuzzleGalleryDetailResponse {
|
||||
item: map_puzzle_work_summary_response(&state, item),
|
||||
item: map_puzzle_work_profile_response(&state, item),
|
||||
},
|
||||
))
|
||||
}
|
||||
@@ -1014,7 +1057,7 @@ pub async fn record_puzzle_gallery_like(
|
||||
Ok(json_success_body(
|
||||
Some(&request_context),
|
||||
PuzzleGalleryDetailResponse {
|
||||
item: map_puzzle_work_summary_response(&state, item),
|
||||
item: map_puzzle_work_profile_response(&state, item),
|
||||
},
|
||||
))
|
||||
}
|
||||
@@ -1303,6 +1346,7 @@ pub async fn use_puzzle_runtime_prop(
|
||||
"hint" => "puzzle_prop_hint",
|
||||
"reference" => "puzzle_prop_preview",
|
||||
"freezeTime" | "freeze_time" => "puzzle_prop_freeze_time",
|
||||
"extendTime" | "extend_time" => "puzzle_prop_extend_time",
|
||||
_ => {
|
||||
return Err(puzzle_bad_request(
|
||||
&request_context,
|
||||
@@ -1646,6 +1690,7 @@ fn map_puzzle_work_summary_response(
|
||||
like_count: item.like_count,
|
||||
recent_play_count_7d: item.recent_play_count_7d,
|
||||
publish_ready: item.publish_ready,
|
||||
levels: Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1653,14 +1698,16 @@ 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: map_puzzle_work_summary_response(state, item.clone()),
|
||||
summary,
|
||||
anchor_pack: map_puzzle_anchor_pack_response(item.anchor_pack),
|
||||
levels: item
|
||||
.levels
|
||||
.into_iter()
|
||||
.map(map_puzzle_draft_level_response)
|
||||
.collect(),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1675,6 +1722,14 @@ fn map_puzzle_run_response(run: PuzzleRunRecord) -> PuzzleRunSnapshotResponse {
|
||||
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()
|
||||
@@ -1683,6 +1738,19 @@ fn map_puzzle_run_response(run: PuzzleRunRecord) -> PuzzleRunSnapshotResponse {
|
||||
}
|
||||
}
|
||||
|
||||
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,
|
||||
}
|
||||
}
|
||||
|
||||
async fn enrich_puzzle_run_author_name(
|
||||
state: &AppState,
|
||||
mut run: PuzzleRunRecord,
|
||||
@@ -1717,6 +1785,14 @@ fn map_puzzle_run_request_record(run: PuzzleRunSnapshotResponse) -> PuzzleRunRec
|
||||
previous_level_tags: run.previous_level_tags,
|
||||
current_level: run.current_level.map(map_puzzle_level_request_record),
|
||||
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_request_record)
|
||||
.collect(),
|
||||
leaderboard_entries: run
|
||||
.leaderboard_entries
|
||||
.into_iter()
|
||||
@@ -1725,12 +1801,26 @@ fn map_puzzle_run_request_record(run: PuzzleRunSnapshotResponse) -> PuzzleRunRec
|
||||
}
|
||||
}
|
||||
|
||||
fn map_puzzle_recommended_next_work_request_record(
|
||||
item: PuzzleRecommendedNextWorkResponse,
|
||||
) -> PuzzleRecommendedNextWorkRecord {
|
||||
PuzzleRecommendedNextWorkRecord {
|
||||
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,
|
||||
}
|
||||
}
|
||||
|
||||
fn map_puzzle_level_request_record(
|
||||
level: PuzzleRuntimeLevelSnapshotResponse,
|
||||
) -> PuzzleRuntimeLevelRecord {
|
||||
PuzzleRuntimeLevelRecord {
|
||||
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,
|
||||
@@ -1823,6 +1913,7 @@ fn map_puzzle_runtime_level_response(
|
||||
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,
|
||||
@@ -1933,14 +2024,14 @@ fn build_puzzle_welcome_text(seed_text: &str) -> String {
|
||||
}
|
||||
|
||||
fn build_puzzle_form_seed_text(payload: &CreatePuzzleAgentSessionRequest) -> String {
|
||||
build_puzzle_form_seed_text_from_parts(
|
||||
payload
|
||||
build_puzzle_form_seed_prompt(PuzzleFormSeedPromptParts {
|
||||
title: payload
|
||||
.work_title
|
||||
.as_deref()
|
||||
.or(payload.seed_text.as_deref()),
|
||||
payload.work_description.as_deref(),
|
||||
payload.picture_description.as_deref(),
|
||||
)
|
||||
work_description: payload.work_description.as_deref(),
|
||||
picture_description: payload.picture_description.as_deref(),
|
||||
})
|
||||
}
|
||||
|
||||
fn build_puzzle_form_seed_text_from_parts(
|
||||
@@ -1948,20 +2039,11 @@ fn build_puzzle_form_seed_text_from_parts(
|
||||
work_description: Option<&str>,
|
||||
picture_description: Option<&str>,
|
||||
) -> String {
|
||||
let title = title.unwrap_or_default().trim();
|
||||
let work_description = work_description.unwrap_or_default().trim();
|
||||
let picture_description = picture_description.unwrap_or_default().trim();
|
||||
|
||||
[
|
||||
("作品名称", title),
|
||||
("作品描述", work_description),
|
||||
("画面描述", picture_description),
|
||||
]
|
||||
.into_iter()
|
||||
.filter(|(_, value)| !value.is_empty())
|
||||
.map(|(label, value)| format!("{label}:{value}"))
|
||||
.collect::<Vec<_>>()
|
||||
.join("\n")
|
||||
build_puzzle_form_seed_prompt(PuzzleFormSeedPromptParts {
|
||||
title,
|
||||
work_description,
|
||||
picture_description,
|
||||
})
|
||||
}
|
||||
|
||||
async fn save_puzzle_form_payload_before_compile(
|
||||
@@ -1971,7 +2053,7 @@ async fn save_puzzle_form_payload_before_compile(
|
||||
owner_user_id: &str,
|
||||
payload: &ExecutePuzzleAgentActionRequest,
|
||||
now: i64,
|
||||
) -> Result<(), Response> {
|
||||
) -> Result<String, Response> {
|
||||
let seed_text = build_puzzle_form_seed_text_from_parts(
|
||||
payload.work_title.as_deref(),
|
||||
payload.work_description.as_deref(),
|
||||
@@ -1981,26 +2063,101 @@ async fn save_puzzle_form_payload_before_compile(
|
||||
.or(payload.prompt_text.as_deref()),
|
||||
);
|
||||
if seed_text.trim().is_empty() {
|
||||
return Ok(());
|
||||
return Ok(session_id.to_string());
|
||||
}
|
||||
|
||||
state
|
||||
let save_result = state
|
||||
.spacetime_client()
|
||||
.save_puzzle_form_draft(PuzzleFormDraftSaveRecordInput {
|
||||
session_id: session_id.to_string(),
|
||||
owner_user_id: owner_user_id.to_string(),
|
||||
seed_text,
|
||||
seed_text: seed_text.clone(),
|
||||
saved_at_micros: now,
|
||||
})
|
||||
.await
|
||||
.map(|_| ())
|
||||
.map(|_| ());
|
||||
match save_result {
|
||||
Ok(()) => Ok(session_id.to_string()),
|
||||
Err(error) if is_missing_puzzle_form_draft_procedure_error(&error) => {
|
||||
create_seeded_puzzle_session_when_form_save_missing(
|
||||
state,
|
||||
request_context,
|
||||
session_id,
|
||||
owner_user_id,
|
||||
seed_text,
|
||||
now,
|
||||
&error,
|
||||
)
|
||||
.await
|
||||
}
|
||||
Err(error) => Err(puzzle_error_response(
|
||||
request_context,
|
||||
PUZZLE_AGENT_API_BASE_PROVIDER,
|
||||
map_puzzle_client_error(error),
|
||||
)),
|
||||
}
|
||||
}
|
||||
|
||||
async fn create_seeded_puzzle_session_when_form_save_missing(
|
||||
state: &AppState,
|
||||
request_context: &RequestContext,
|
||||
session_id: &str,
|
||||
owner_user_id: &str,
|
||||
seed_text: String,
|
||||
now: i64,
|
||||
original_error: &SpacetimeClientError,
|
||||
) -> Result<String, Response> {
|
||||
let current_session = state
|
||||
.spacetime_client()
|
||||
.get_puzzle_agent_session(session_id.to_string(), owner_user_id.to_string())
|
||||
.await
|
||||
.map_err(|error| {
|
||||
puzzle_error_response(
|
||||
request_context,
|
||||
PUZZLE_AGENT_API_BASE_PROVIDER,
|
||||
map_puzzle_client_error(error),
|
||||
)
|
||||
})?;
|
||||
if !current_session.seed_text.trim().is_empty() {
|
||||
tracing::warn!(
|
||||
provider = PUZZLE_AGENT_API_BASE_PROVIDER,
|
||||
session_id,
|
||||
owner_user_id,
|
||||
error = %original_error,
|
||||
"拼图表单草稿保存 procedure 缺失,沿用已有 seed_text 编译"
|
||||
);
|
||||
return Ok(session_id.to_string());
|
||||
}
|
||||
|
||||
// 中文注释:旧 Maincloud 缺自动保存 procedure 时,空 session 无法被编译;这里重建带表单 seed 的 session 保证生成主链可继续。
|
||||
let replacement_session_id = build_prefixed_uuid_id("puzzle-session-");
|
||||
let replacement = state
|
||||
.spacetime_client()
|
||||
.create_puzzle_agent_session(PuzzleAgentSessionCreateRecordInput {
|
||||
session_id: replacement_session_id.clone(),
|
||||
owner_user_id: owner_user_id.to_string(),
|
||||
seed_text: seed_text.clone(),
|
||||
welcome_message_id: build_prefixed_uuid_id("puzzle-message-"),
|
||||
welcome_message_text: build_puzzle_welcome_text(&seed_text),
|
||||
created_at_micros: now,
|
||||
})
|
||||
.await
|
||||
.map_err(|error| {
|
||||
puzzle_error_response(
|
||||
request_context,
|
||||
PUZZLE_AGENT_API_BASE_PROVIDER,
|
||||
map_puzzle_client_error(error),
|
||||
)
|
||||
})?;
|
||||
tracing::warn!(
|
||||
provider = PUZZLE_AGENT_API_BASE_PROVIDER,
|
||||
old_session_id = %session_id,
|
||||
new_session_id = %replacement.session_id,
|
||||
owner_user_id,
|
||||
error = %original_error,
|
||||
"拼图表单草稿保存 procedure 缺失,已创建带表单 seed 的替代 session"
|
||||
);
|
||||
Ok(replacement.session_id)
|
||||
}
|
||||
|
||||
fn select_puzzle_level_for_api(
|
||||
@@ -2008,15 +2165,20 @@ fn select_puzzle_level_for_api(
|
||||
level_id: Option<&str>,
|
||||
) -> Result<PuzzleDraftLevelRecord, AppError> {
|
||||
let normalized_level_id = level_id.map(str::trim).filter(|value| !value.is_empty());
|
||||
let level = normalized_level_id
|
||||
.and_then(|target_id| {
|
||||
draft
|
||||
.levels
|
||||
.iter()
|
||||
.find(|level| level.level_id == target_id)
|
||||
.cloned()
|
||||
})
|
||||
.or_else(|| draft.levels.first().cloned());
|
||||
if let Some(target_id) = normalized_level_id {
|
||||
return draft
|
||||
.levels
|
||||
.iter()
|
||||
.find(|level| level.level_id == target_id)
|
||||
.cloned()
|
||||
.ok_or_else(|| {
|
||||
AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({
|
||||
"provider": PUZZLE_AGENT_API_BASE_PROVIDER,
|
||||
"message": format!("拼图关卡不存在:{target_id}"),
|
||||
}))
|
||||
});
|
||||
}
|
||||
let level = draft.levels.first().cloned();
|
||||
level.ok_or_else(|| {
|
||||
AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({
|
||||
"provider": PUZZLE_AGENT_API_BASE_PROVIDER,
|
||||
@@ -2025,6 +2187,43 @@ fn select_puzzle_level_for_api(
|
||||
})
|
||||
}
|
||||
|
||||
fn parse_puzzle_level_records_from_module_json(
|
||||
value: &str,
|
||||
) -> Result<Vec<PuzzleDraftLevelRecord>, AppError> {
|
||||
let levels: Vec<module_puzzle::PuzzleDraftLevel> =
|
||||
serde_json::from_str(value).map_err(|error| {
|
||||
AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({
|
||||
"provider": PUZZLE_AGENT_API_BASE_PROVIDER,
|
||||
"message": format!("拼图关卡列表 JSON 非法:{error}"),
|
||||
}))
|
||||
})?;
|
||||
Ok(levels
|
||||
.into_iter()
|
||||
.map(|level| PuzzleDraftLevelRecord {
|
||||
level_id: level.level_id,
|
||||
level_name: level.level_name,
|
||||
picture_description: level.picture_description,
|
||||
candidates: level
|
||||
.candidates
|
||||
.into_iter()
|
||||
.map(|candidate| PuzzleGeneratedImageCandidateRecord {
|
||||
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(),
|
||||
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())
|
||||
}
|
||||
|
||||
fn serialize_puzzle_levels_response(
|
||||
request_context: &RequestContext,
|
||||
levels: &[PuzzleDraftLevelResponse],
|
||||
@@ -2138,22 +2337,18 @@ async fn compile_puzzle_draft_with_initial_cover(
|
||||
.ok_or_else(|| SpacetimeClientError::Runtime("拼图结果页草稿尚未生成".to_string()))?;
|
||||
let target_level = select_puzzle_level_for_api(&draft, None)
|
||||
.map_err(|error| SpacetimeClientError::Runtime(error.message().to_string()))?;
|
||||
let image_prompt = prompt_text
|
||||
.map(str::trim)
|
||||
.filter(|value| !value.is_empty())
|
||||
.or_else(|| {
|
||||
Some(target_level.picture_description.as_str())
|
||||
.map(str::trim)
|
||||
.filter(|value| !value.is_empty())
|
||||
})
|
||||
.unwrap_or(draft.summary.as_str());
|
||||
let image_prompt = resolve_puzzle_draft_cover_prompt(
|
||||
prompt_text,
|
||||
&target_level.picture_description,
|
||||
&draft.summary,
|
||||
);
|
||||
// 点击生成草稿时一次性完成首图生成与正式图选定,前端只展示进度,不再承担业务编排。
|
||||
let candidates = generate_puzzle_image_candidates(
|
||||
state,
|
||||
owner_user_id.as_str(),
|
||||
&compiled_session.session_id,
|
||||
&target_level.level_name,
|
||||
image_prompt,
|
||||
&image_prompt,
|
||||
reference_image_src,
|
||||
1,
|
||||
target_level.candidates.len(),
|
||||
@@ -2179,6 +2374,7 @@ async fn compile_puzzle_draft_with_initial_cover(
|
||||
session_id: compiled_session.session_id.clone(),
|
||||
owner_user_id: owner_user_id.clone(),
|
||||
level_id: Some(target_level.level_id.clone()),
|
||||
levels_json: None,
|
||||
candidates_json,
|
||||
saved_at_micros: current_utc_micros(),
|
||||
})
|
||||
@@ -2252,6 +2448,15 @@ fn map_puzzle_client_error(error: SpacetimeClientError) -> AppError {
|
||||
}))
|
||||
}
|
||||
|
||||
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")))
|
||||
}
|
||||
|
||||
fn map_puzzle_compile_error(error: SpacetimeClientError) -> AppError {
|
||||
let message = error.to_string();
|
||||
let provider = if message.contains("DashScope") || message.contains("dashscope") {
|
||||
@@ -2484,11 +2689,18 @@ async fn build_local_next_puzzle_run(
|
||||
);
|
||||
}
|
||||
|
||||
let source_session_id = payload.source_session_id.unwrap_or_default();
|
||||
if let Some(next_run) =
|
||||
build_same_work_local_next_puzzle_run(state, &run, &source_session_id, owner_user_id)
|
||||
.await?
|
||||
{
|
||||
return Ok(next_run);
|
||||
}
|
||||
|
||||
if let Some(gallery_item) = resolve_gallery_next_puzzle_work(state, &run).await? {
|
||||
return Ok(build_next_run_from_puzzle_work(state, run, gallery_item));
|
||||
}
|
||||
|
||||
let source_session_id = payload.source_session_id.unwrap_or_default();
|
||||
if source_session_id.trim().is_empty() {
|
||||
return Err(
|
||||
AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({
|
||||
@@ -2551,6 +2763,7 @@ async fn build_local_next_puzzle_run(
|
||||
session_id: session.session_id,
|
||||
owner_user_id: owner_user_id.to_string(),
|
||||
level_id: None,
|
||||
levels_json: None,
|
||||
candidates_json,
|
||||
saved_at_micros: current_utc_micros(),
|
||||
})
|
||||
@@ -2578,6 +2791,101 @@ async fn build_local_next_puzzle_run(
|
||||
))
|
||||
}
|
||||
|
||||
async fn build_same_work_local_next_puzzle_run(
|
||||
state: &AppState,
|
||||
run: &PuzzleRunRecord,
|
||||
source_session_id: &str,
|
||||
owner_user_id: &str,
|
||||
) -> Result<Option<PuzzleRunRecord>, AppError> {
|
||||
if !should_use_same_work_next_level(run) {
|
||||
return Ok(None);
|
||||
}
|
||||
|
||||
if let Some(work) = fetch_local_current_work_detail(state, run).await? {
|
||||
if let Some(level) = select_local_next_level(&work.levels, run) {
|
||||
let next_after_level =
|
||||
select_next_level_after_level_id(&work.levels, level.level_id.as_str())
|
||||
.map(|item| item.level_id.clone());
|
||||
return Ok(Some(build_next_run_from_draft_level(
|
||||
run.clone(),
|
||||
level,
|
||||
Some(work.profile_id),
|
||||
work.author_display_name,
|
||||
work.theme_tags,
|
||||
next_after_level,
|
||||
)));
|
||||
}
|
||||
}
|
||||
|
||||
let normalized_session_id = source_session_id.trim();
|
||||
if normalized_session_id.is_empty() {
|
||||
return Ok(None);
|
||||
}
|
||||
let session = state
|
||||
.spacetime_client()
|
||||
.get_puzzle_agent_session(normalized_session_id.to_string(), owner_user_id.to_string())
|
||||
.await
|
||||
.map_err(map_puzzle_client_error)?;
|
||||
let Some(draft) = session.draft.as_ref() else {
|
||||
return Ok(None);
|
||||
};
|
||||
if let Some(level) = select_local_next_level(&draft.levels, run) {
|
||||
let next_after_level =
|
||||
select_next_level_after_level_id(&draft.levels, level.level_id.as_str())
|
||||
.map(|item| item.level_id.clone());
|
||||
return Ok(Some(build_next_run_from_draft_level(
|
||||
run.clone(),
|
||||
level,
|
||||
Some(run.entry_profile_id.clone()),
|
||||
"当前草稿".to_string(),
|
||||
draft.theme_tags.clone(),
|
||||
next_after_level,
|
||||
)));
|
||||
}
|
||||
|
||||
Ok(None)
|
||||
}
|
||||
|
||||
fn should_use_same_work_next_level(run: &PuzzleRunRecord) -> bool {
|
||||
run.next_level_mode == module_puzzle::PUZZLE_NEXT_LEVEL_MODE_SAME_WORK
|
||||
|| run
|
||||
.next_level_id
|
||||
.as_ref()
|
||||
.is_some_and(|value| !value.trim().is_empty())
|
||||
}
|
||||
|
||||
async fn fetch_local_current_work_detail(
|
||||
state: &AppState,
|
||||
run: &PuzzleRunRecord,
|
||||
) -> Result<Option<PuzzleWorkProfileRecord>, AppError> {
|
||||
let profile_id = run
|
||||
.next_level_profile_id
|
||||
.as_deref()
|
||||
.filter(|value| !value.trim().is_empty())
|
||||
.or_else(|| {
|
||||
run.current_level
|
||||
.as_ref()
|
||||
.map(|level| level.profile_id.as_str())
|
||||
.filter(|value| !value.trim().is_empty())
|
||||
})
|
||||
.unwrap_or(run.entry_profile_id.as_str());
|
||||
match state
|
||||
.spacetime_client()
|
||||
.get_puzzle_gallery_detail(profile_id.to_string())
|
||||
.await
|
||||
{
|
||||
Ok(work) => Ok(Some(work)),
|
||||
Err(SpacetimeClientError::Procedure(message))
|
||||
if message.contains("不存在")
|
||||
|| message.contains("not found")
|
||||
|| message.contains("does not exist") =>
|
||||
{
|
||||
Ok(None)
|
||||
}
|
||||
Err(error) => Err(map_puzzle_client_error(error)),
|
||||
}
|
||||
}
|
||||
|
||||
async fn resolve_gallery_next_puzzle_work(
|
||||
state: &AppState,
|
||||
run: &PuzzleRunRecord,
|
||||
@@ -2609,6 +2917,76 @@ fn pick_unused_puzzle_candidate<'a>(
|
||||
})
|
||||
}
|
||||
|
||||
fn select_local_next_level<'a>(
|
||||
levels: &'a [PuzzleDraftLevelRecord],
|
||||
run: &PuzzleRunRecord,
|
||||
) -> Option<&'a PuzzleDraftLevelRecord> {
|
||||
if levels.is_empty() {
|
||||
return None;
|
||||
}
|
||||
if let Some(next_level_id) = run
|
||||
.next_level_id
|
||||
.as_deref()
|
||||
.map(str::trim)
|
||||
.filter(|value| !value.is_empty())
|
||||
{
|
||||
if let Some(level) = levels.iter().find(|level| level.level_id == next_level_id) {
|
||||
return Some(level);
|
||||
}
|
||||
}
|
||||
|
||||
let current_level = run.current_level.as_ref()?;
|
||||
let matched_index = levels
|
||||
.iter()
|
||||
.position(|level| {
|
||||
level.cover_image_src == current_level.cover_image_src
|
||||
&& level.level_name == current_level.level_name
|
||||
})
|
||||
.or_else(|| {
|
||||
current_level
|
||||
.level_index
|
||||
.checked_sub(1)
|
||||
.and_then(|index| ((index as usize) < levels.len()).then_some(index as usize))
|
||||
})?;
|
||||
levels.get(matched_index + 1)
|
||||
}
|
||||
|
||||
fn select_next_level_after_level_id<'a>(
|
||||
levels: &'a [PuzzleDraftLevelRecord],
|
||||
level_id: &str,
|
||||
) -> Option<&'a PuzzleDraftLevelRecord> {
|
||||
let matched_index = levels.iter().position(|level| level.level_id == level_id)?;
|
||||
levels.get(matched_index + 1)
|
||||
}
|
||||
|
||||
fn resolve_level_cover_image_src(level: &PuzzleDraftLevelRecord) -> Option<String> {
|
||||
level
|
||||
.cover_image_src
|
||||
.as_ref()
|
||||
.filter(|value| !value.trim().is_empty())
|
||||
.cloned()
|
||||
.or_else(|| {
|
||||
level
|
||||
.selected_candidate_id
|
||||
.as_ref()
|
||||
.and_then(|candidate_id| {
|
||||
level
|
||||
.candidates
|
||||
.iter()
|
||||
.find(|candidate| candidate.candidate_id == *candidate_id)
|
||||
})
|
||||
.map(|candidate| candidate.image_src.clone())
|
||||
.filter(|value| !value.trim().is_empty())
|
||||
})
|
||||
.or_else(|| {
|
||||
level
|
||||
.candidates
|
||||
.iter()
|
||||
.find(|candidate| !candidate.image_src.trim().is_empty())
|
||||
.map(|candidate| candidate.image_src.clone())
|
||||
})
|
||||
}
|
||||
|
||||
fn build_next_run_from_puzzle_work(
|
||||
state: &AppState,
|
||||
run: PuzzleRunRecord,
|
||||
@@ -2654,6 +3032,34 @@ fn build_next_run_from_candidate(
|
||||
)
|
||||
}
|
||||
|
||||
fn build_next_run_from_draft_level(
|
||||
mut run: PuzzleRunRecord,
|
||||
level: &PuzzleDraftLevelRecord,
|
||||
profile_id: Option<String>,
|
||||
author_display_name: String,
|
||||
theme_tags: Vec<String>,
|
||||
next_after_level_id: Option<String>,
|
||||
) -> PuzzleRunRecord {
|
||||
// 中文注释:当前关卡 id 必须取本次选中的目标 level,避免旧 run 的空值或脏值影响后续同作品接续。
|
||||
run.next_level_id = Some(level.level_id.clone());
|
||||
let fallback_profile_id = run
|
||||
.current_level
|
||||
.as_ref()
|
||||
.map(|level| level.profile_id.clone())
|
||||
.unwrap_or_else(|| level.level_id.clone());
|
||||
build_next_run_from_parts_with_handoff(
|
||||
run,
|
||||
profile_id
|
||||
.filter(|value| !value.trim().is_empty())
|
||||
.unwrap_or(fallback_profile_id),
|
||||
level.level_name.clone(),
|
||||
author_display_name,
|
||||
theme_tags,
|
||||
resolve_level_cover_image_src(level),
|
||||
next_after_level_id,
|
||||
)
|
||||
}
|
||||
|
||||
fn build_next_run_from_parts(
|
||||
run: PuzzleRunRecord,
|
||||
profile_id: String,
|
||||
@@ -2661,11 +3067,32 @@ fn build_next_run_from_parts(
|
||||
author_display_name: String,
|
||||
theme_tags: Vec<String>,
|
||||
cover_image_src: Option<String>,
|
||||
) -> PuzzleRunRecord {
|
||||
build_next_run_from_parts_with_handoff(
|
||||
run,
|
||||
profile_id,
|
||||
level_name,
|
||||
author_display_name,
|
||||
theme_tags,
|
||||
cover_image_src,
|
||||
None,
|
||||
)
|
||||
}
|
||||
|
||||
fn build_next_run_from_parts_with_handoff(
|
||||
run: PuzzleRunRecord,
|
||||
profile_id: String,
|
||||
level_name: String,
|
||||
author_display_name: String,
|
||||
theme_tags: Vec<String>,
|
||||
cover_image_src: Option<String>,
|
||||
next_after_level_id: Option<String>,
|
||||
) -> PuzzleRunRecord {
|
||||
let next_level_index = run.current_level_index + 1;
|
||||
let grid_size = if run.cleared_level_count >= 3 { 4 } else { 3 };
|
||||
let time_limit_ms = module_puzzle::resolve_puzzle_level_time_limit_ms(grid_size);
|
||||
let mut played_profile_ids = run.played_profile_ids.clone();
|
||||
let current_level_id = run.next_level_id.clone();
|
||||
if !played_profile_ids.contains(&profile_id) {
|
||||
played_profile_ids.push(profile_id.clone());
|
||||
}
|
||||
@@ -2681,8 +3108,9 @@ fn build_next_run_from_parts(
|
||||
current_level: Some(PuzzleRuntimeLevelRecord {
|
||||
run_id: run.run_id,
|
||||
level_index: next_level_index,
|
||||
level_id: current_level_id,
|
||||
grid_size,
|
||||
profile_id,
|
||||
profile_id: profile_id.clone(),
|
||||
level_name,
|
||||
author_display_name,
|
||||
theme_tags,
|
||||
@@ -2702,6 +3130,13 @@ fn build_next_run_from_parts(
|
||||
leaderboard_entries: Vec::new(),
|
||||
}),
|
||||
recommended_next_profile_id: None,
|
||||
next_level_mode: next_after_level_id
|
||||
.as_ref()
|
||||
.map(|_| module_puzzle::PUZZLE_NEXT_LEVEL_MODE_SAME_WORK.to_string())
|
||||
.unwrap_or_else(|| module_puzzle::PUZZLE_NEXT_LEVEL_MODE_NONE.to_string()),
|
||||
next_level_profile_id: next_after_level_id.as_ref().map(|_| profile_id),
|
||||
next_level_id: next_after_level_id,
|
||||
recommended_next_works: Vec::new(),
|
||||
leaderboard_entries: Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user