refactor: split large modules and normalize rust layout
This commit is contained in:
File diff suppressed because it is too large
Load Diff
881
server-rs/crates/api-server/src/match3d/draft.rs
Normal file
881
server-rs/crates/api-server/src/match3d/draft.rs
Normal file
@@ -0,0 +1,881 @@
|
||||
use super::*;
|
||||
|
||||
pub(super) async fn submit_and_finalize_match3d_message(
|
||||
state: &AppState,
|
||||
request_context: &RequestContext,
|
||||
owner_user_id: &str,
|
||||
session_id: String,
|
||||
payload: SendMatch3DAgentMessageRequest,
|
||||
) -> Result<Match3DAgentSessionRecord, Response> {
|
||||
ensure_non_empty(
|
||||
request_context,
|
||||
MATCH3D_AGENT_PROVIDER,
|
||||
&session_id,
|
||||
"sessionId",
|
||||
)?;
|
||||
ensure_non_empty(
|
||||
request_context,
|
||||
MATCH3D_AGENT_PROVIDER,
|
||||
&payload.client_message_id,
|
||||
"clientMessageId",
|
||||
)?;
|
||||
ensure_non_empty(
|
||||
request_context,
|
||||
MATCH3D_AGENT_PROVIDER,
|
||||
&payload.text,
|
||||
"text",
|
||||
)?;
|
||||
|
||||
let submitted = state
|
||||
.spacetime_client()
|
||||
.submit_match3d_agent_message(Match3DAgentMessageSubmitRecordInput {
|
||||
session_id: session_id.clone(),
|
||||
owner_user_id: owner_user_id.to_string(),
|
||||
user_message_id: payload.client_message_id.clone(),
|
||||
user_message_text: payload.text.clone(),
|
||||
submitted_at_micros: current_utc_micros(),
|
||||
})
|
||||
.await
|
||||
.map_err(|error| {
|
||||
match3d_error_response(
|
||||
request_context,
|
||||
MATCH3D_AGENT_PROVIDER,
|
||||
map_match3d_client_error(error),
|
||||
)
|
||||
})?;
|
||||
let next_turn = submitted.current_turn.saturating_add(1);
|
||||
let next_config = build_config_from_message(&submitted, &payload);
|
||||
let assistant_reply = build_match3d_assistant_reply_for_turn(&next_config, next_turn);
|
||||
let progress_percent = resolve_progress_percent_for_turn(next_turn);
|
||||
let stage = if progress_percent >= 100 {
|
||||
"ReadyToCompile"
|
||||
} else {
|
||||
"Collecting"
|
||||
}
|
||||
.to_string();
|
||||
|
||||
state
|
||||
.spacetime_client()
|
||||
.finalize_match3d_agent_message(Match3DAgentMessageFinalizeRecordInput {
|
||||
session_id,
|
||||
owner_user_id: owner_user_id.to_string(),
|
||||
assistant_message_id: Some(build_prefixed_uuid_id(MATCH3D_MESSAGE_ID_PREFIX)),
|
||||
assistant_reply_text: Some(assistant_reply),
|
||||
config_json: serialize_match3d_config(&next_config),
|
||||
progress_percent,
|
||||
stage,
|
||||
updated_at_micros: current_utc_micros(),
|
||||
error_message: None,
|
||||
})
|
||||
.await
|
||||
.map_err(|error| {
|
||||
match3d_error_response(
|
||||
request_context,
|
||||
MATCH3D_AGENT_PROVIDER,
|
||||
map_match3d_client_error(error),
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
pub(super) async fn load_match3d_agent_session_response_with_persisted_assets(
|
||||
state: &AppState,
|
||||
owner_user_id: &str,
|
||||
session: Match3DAgentSessionRecord,
|
||||
) -> Match3DAgentSessionSnapshotResponse {
|
||||
let Some(profile_id) = resolve_match3d_session_existing_profile_id(&session) else {
|
||||
return map_match3d_agent_session_response(session);
|
||||
};
|
||||
let assets =
|
||||
get_match3d_existing_generated_item_assets(state, owner_user_id, profile_id.as_str()).await;
|
||||
map_match3d_agent_session_response_with_assets(session, &assets)
|
||||
}
|
||||
|
||||
fn resolve_match3d_session_existing_profile_id(
|
||||
session: &Match3DAgentSessionRecord,
|
||||
) -> Option<String> {
|
||||
session
|
||||
.draft
|
||||
.as_ref()
|
||||
.map(|draft| draft.profile_id.trim())
|
||||
.filter(|profile_id| !profile_id.is_empty())
|
||||
.or_else(|| {
|
||||
session
|
||||
.published_profile_id
|
||||
.as_deref()
|
||||
.map(str::trim)
|
||||
.filter(|profile_id| !profile_id.is_empty())
|
||||
})
|
||||
.map(str::to_string)
|
||||
}
|
||||
|
||||
pub(super) async fn compile_match3d_draft_for_session(
|
||||
state: &AppState,
|
||||
request_context: &RequestContext,
|
||||
authenticated: &AuthenticatedAccessToken,
|
||||
session_id: String,
|
||||
game_name: Option<String>,
|
||||
summary: Option<String>,
|
||||
tags: Option<Vec<String>>,
|
||||
cover_image_src: Option<String>,
|
||||
generate_click_sound: Option<bool>,
|
||||
) -> Result<(Match3DAgentSessionRecord, Vec<Match3DGeneratedItemAsset>), Response> {
|
||||
let owner_user_id = authenticated.claims().user_id().to_string();
|
||||
let initial_session = state
|
||||
.spacetime_client()
|
||||
.get_match3d_agent_session(session_id.clone(), owner_user_id.clone())
|
||||
.await
|
||||
.map_err(|error| {
|
||||
match3d_error_response(
|
||||
request_context,
|
||||
MATCH3D_AGENT_PROVIDER,
|
||||
map_match3d_client_error(error),
|
||||
)
|
||||
})?;
|
||||
let mut config = resolve_config_or_default(initial_session.config.as_ref());
|
||||
if let Some(generate_click_sound) = generate_click_sound {
|
||||
config.generate_click_sound = generate_click_sound;
|
||||
}
|
||||
// 中文注释:抓大鹅入口已支持表单直创;完整表单创建的 session
|
||||
// 不需要再伪造三轮聊天,只要配置字段完整即可进入草稿编译。
|
||||
let has_complete_form_config = !config.theme_text.trim().is_empty()
|
||||
&& config.clear_count > 0
|
||||
&& (1..=10).contains(&config.difficulty);
|
||||
if !has_complete_form_config
|
||||
&& (initial_session.current_turn < 3 || initial_session.progress_percent < 100)
|
||||
{
|
||||
return Err(match3d_bad_request(
|
||||
request_context,
|
||||
MATCH3D_AGENT_PROVIDER,
|
||||
"match3d 创作配置尚未确认完成",
|
||||
));
|
||||
}
|
||||
|
||||
let requested_game_name = normalize_optional_match3d_text(game_name);
|
||||
let requested_summary = normalize_optional_match3d_text(summary);
|
||||
let requested_tags = tags.map(normalize_tags).filter(|items| !items.is_empty());
|
||||
let requested_cover_image_src = normalize_optional_match3d_text(cover_image_src);
|
||||
let fallback_work_metadata = fallback_match3d_work_metadata(config.theme_text.as_str());
|
||||
let profile_id = resolve_match3d_draft_profile_id(&initial_session);
|
||||
let initial_game_name = requested_game_name
|
||||
.clone()
|
||||
.unwrap_or_else(|| fallback_work_metadata.game_name.clone());
|
||||
let initial_tags = requested_tags
|
||||
.clone()
|
||||
.unwrap_or_else(|| fallback_work_metadata.tags.clone());
|
||||
let billing_asset_id = format!("{}:{}:{}", session_id, profile_id, current_utc_micros());
|
||||
execute_billable_match3d_draft_generation(
|
||||
state,
|
||||
request_context,
|
||||
owner_user_id.as_str(),
|
||||
billing_asset_id.as_str(),
|
||||
async {
|
||||
let mut session = upsert_match3d_draft_snapshot(
|
||||
state,
|
||||
request_context,
|
||||
authenticated,
|
||||
session_id.clone(),
|
||||
owner_user_id.clone(),
|
||||
profile_id.clone(),
|
||||
Some(initial_game_name),
|
||||
requested_summary.clone().or_else(|| Some(String::new())),
|
||||
Some(serde_json::to_string(&initial_tags).unwrap_or_default()),
|
||||
requested_cover_image_src.clone(),
|
||||
None,
|
||||
None,
|
||||
)
|
||||
.await?;
|
||||
|
||||
if session.draft.is_none() {
|
||||
return Err(match3d_error_response(
|
||||
request_context,
|
||||
MATCH3D_AGENT_PROVIDER,
|
||||
match3d_bad_gateway("抓大鹅草稿创建失败,请稍后重试"),
|
||||
));
|
||||
}
|
||||
|
||||
let mut generated_work_metadata = generate_match3d_draft_plan(state, &config).await;
|
||||
let resolved_game_name = requested_game_name
|
||||
.unwrap_or_else(|| generated_work_metadata.metadata.game_name.clone());
|
||||
let resolved_summary = requested_summary
|
||||
.clone()
|
||||
.unwrap_or_else(|| generated_work_metadata.metadata.summary.clone());
|
||||
let resolved_tags = match requested_tags {
|
||||
Some(tags) => tags,
|
||||
None => {
|
||||
generate_match3d_work_tags_for_plan(
|
||||
state,
|
||||
resolved_game_name.as_str(),
|
||||
config.theme_text.as_str(),
|
||||
resolved_summary.as_str(),
|
||||
&generated_work_metadata.metadata.tags,
|
||||
)
|
||||
.await
|
||||
}
|
||||
};
|
||||
generated_work_metadata.metadata.tags = resolved_tags.clone();
|
||||
session = upsert_match3d_draft_snapshot(
|
||||
state,
|
||||
request_context,
|
||||
authenticated,
|
||||
session_id,
|
||||
owner_user_id.clone(),
|
||||
profile_id.clone(),
|
||||
Some(resolved_game_name),
|
||||
Some(resolved_summary),
|
||||
Some(serde_json::to_string(&resolved_tags).unwrap_or_default()),
|
||||
requested_cover_image_src.clone(),
|
||||
None,
|
||||
None,
|
||||
)
|
||||
.await?;
|
||||
|
||||
let existing_assets = get_match3d_existing_generated_item_assets(
|
||||
state,
|
||||
owner_user_id.as_str(),
|
||||
profile_id.as_str(),
|
||||
)
|
||||
.await;
|
||||
let generated_item_assets = generate_match3d_item_assets(
|
||||
state,
|
||||
request_context,
|
||||
authenticated,
|
||||
owner_user_id.as_str(),
|
||||
session.session_id.as_str(),
|
||||
profile_id.as_str(),
|
||||
&config,
|
||||
generated_work_metadata.items,
|
||||
existing_assets,
|
||||
)
|
||||
.await?;
|
||||
let generated_item_assets = ensure_match3d_background_asset(
|
||||
state,
|
||||
request_context,
|
||||
authenticated,
|
||||
owner_user_id.as_str(),
|
||||
session.session_id.as_str(),
|
||||
profile_id.as_str(),
|
||||
&config,
|
||||
generated_work_metadata.background_prompt.as_str(),
|
||||
generated_item_assets,
|
||||
)
|
||||
.await?;
|
||||
let existing_cover_image_src = get_match3d_existing_cover_image_src(
|
||||
state,
|
||||
owner_user_id.as_str(),
|
||||
profile_id.as_str(),
|
||||
)
|
||||
.await;
|
||||
let default_cover_image_src = requested_cover_image_src
|
||||
.clone()
|
||||
.or(existing_cover_image_src)
|
||||
.or_else(|| resolve_match3d_default_cover_image_src(&generated_item_assets));
|
||||
let next_session = upsert_match3d_draft_snapshot(
|
||||
state,
|
||||
request_context,
|
||||
authenticated,
|
||||
session.session_id.clone(),
|
||||
owner_user_id.clone(),
|
||||
profile_id,
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
default_cover_image_src,
|
||||
None,
|
||||
serialize_match3d_generated_item_assets(&generated_item_assets),
|
||||
)
|
||||
.await?;
|
||||
|
||||
Ok((next_session, generated_item_assets))
|
||||
},
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
/// 中文注释:抓大鹅草稿生成是一次完整外部生成动作,按 session/profile 幂等预扣 10 泥点。
|
||||
async fn execute_billable_match3d_draft_generation<T, Fut>(
|
||||
state: &AppState,
|
||||
request_context: &RequestContext,
|
||||
owner_user_id: &str,
|
||||
billing_asset_id: &str,
|
||||
operation: Fut,
|
||||
) -> Result<T, Response>
|
||||
where
|
||||
Fut: Future<Output = Result<T, Response>>,
|
||||
{
|
||||
let points_consumed = consume_match3d_draft_generation_points(
|
||||
state,
|
||||
request_context,
|
||||
owner_user_id,
|
||||
billing_asset_id,
|
||||
)
|
||||
.await?;
|
||||
|
||||
match operation.await {
|
||||
Ok(value) => Ok(value),
|
||||
Err(response) => {
|
||||
if points_consumed {
|
||||
refund_match3d_draft_generation_points(state, owner_user_id, billing_asset_id)
|
||||
.await;
|
||||
}
|
||||
Err(response)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async fn consume_match3d_draft_generation_points(
|
||||
state: &AppState,
|
||||
request_context: &RequestContext,
|
||||
owner_user_id: &str,
|
||||
billing_asset_id: &str,
|
||||
) -> Result<bool, Response> {
|
||||
let ledger_id = format!(
|
||||
"asset_operation_consume:{}:match3d_draft_generation:{}",
|
||||
owner_user_id, billing_asset_id
|
||||
);
|
||||
match state
|
||||
.spacetime_client()
|
||||
.consume_profile_wallet_points(
|
||||
owner_user_id.to_string(),
|
||||
MATCH3D_DRAFT_GENERATION_POINTS_COST,
|
||||
ledger_id,
|
||||
current_utc_micros(),
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(_) => Ok(true),
|
||||
Err(error) if should_skip_asset_operation_billing_for_connectivity(&error) => {
|
||||
tracing::warn!(
|
||||
owner_user_id,
|
||||
billing_asset_id,
|
||||
error = %error,
|
||||
"抓大鹅草稿泥点预扣因 SpacetimeDB 连接不可用而降级跳过"
|
||||
);
|
||||
Ok(false)
|
||||
}
|
||||
Err(error) => Err(match3d_error_response(
|
||||
request_context,
|
||||
MATCH3D_AGENT_PROVIDER,
|
||||
map_asset_operation_wallet_error(error),
|
||||
)),
|
||||
}
|
||||
}
|
||||
|
||||
async fn refund_match3d_draft_generation_points(
|
||||
state: &AppState,
|
||||
owner_user_id: &str,
|
||||
billing_asset_id: &str,
|
||||
) {
|
||||
let ledger_id = format!(
|
||||
"asset_operation_refund:{}:match3d_draft_generation:{}",
|
||||
owner_user_id, billing_asset_id
|
||||
);
|
||||
if let Err(error) = state
|
||||
.spacetime_client()
|
||||
.refund_profile_wallet_points(
|
||||
owner_user_id.to_string(),
|
||||
MATCH3D_DRAFT_GENERATION_POINTS_COST,
|
||||
ledger_id,
|
||||
current_utc_micros(),
|
||||
)
|
||||
.await
|
||||
{
|
||||
tracing::error!(
|
||||
owner_user_id,
|
||||
billing_asset_id,
|
||||
error = %error,
|
||||
"抓大鹅草稿生成失败后的泥点退款失败"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
fn resolve_match3d_draft_profile_id(session: &Match3DAgentSessionRecord) -> String {
|
||||
session
|
||||
.draft
|
||||
.as_ref()
|
||||
.map(|draft| draft.profile_id.trim())
|
||||
.filter(|profile_id| !profile_id.is_empty())
|
||||
.or_else(|| {
|
||||
session
|
||||
.published_profile_id
|
||||
.as_deref()
|
||||
.map(str::trim)
|
||||
.filter(|profile_id| !profile_id.is_empty())
|
||||
})
|
||||
.map(str::to_string)
|
||||
.unwrap_or_else(|| build_prefixed_uuid_id(MATCH3D_PROFILE_ID_PREFIX))
|
||||
}
|
||||
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub(super) async fn upsert_match3d_draft_snapshot(
|
||||
state: &AppState,
|
||||
request_context: &RequestContext,
|
||||
authenticated: &AuthenticatedAccessToken,
|
||||
session_id: String,
|
||||
owner_user_id: String,
|
||||
profile_id: String,
|
||||
game_name: Option<String>,
|
||||
summary_text: Option<String>,
|
||||
tags_json: Option<String>,
|
||||
cover_image_src: Option<String>,
|
||||
cover_asset_id: Option<String>,
|
||||
generated_item_assets_json: Option<String>,
|
||||
) -> Result<Match3DAgentSessionRecord, Response> {
|
||||
state
|
||||
.spacetime_client()
|
||||
.compile_match3d_draft(Match3DCompileDraftRecordInput {
|
||||
session_id,
|
||||
owner_user_id,
|
||||
profile_id,
|
||||
author_display_name: resolve_author_display_name(state, authenticated),
|
||||
game_name,
|
||||
summary_text,
|
||||
tags_json,
|
||||
cover_image_src,
|
||||
cover_asset_id,
|
||||
compiled_at_micros: current_utc_micros(),
|
||||
generated_item_assets_json,
|
||||
})
|
||||
.await
|
||||
.map_err(|error| {
|
||||
match3d_error_response(
|
||||
request_context,
|
||||
MATCH3D_AGENT_PROVIDER,
|
||||
map_match3d_client_error(error),
|
||||
)
|
||||
})
|
||||
}
|
||||
pub(super) fn build_config_from_create_request(
|
||||
payload: &CreateMatch3DAgentSessionRequest,
|
||||
) -> Match3DConfigJson {
|
||||
Match3DConfigJson {
|
||||
theme_text: payload
|
||||
.theme_text
|
||||
.as_deref()
|
||||
.or(payload.seed_text.as_deref())
|
||||
.map(str::trim)
|
||||
.filter(|value| !value.is_empty())
|
||||
.unwrap_or(MATCH3D_DEFAULT_THEME)
|
||||
.to_string(),
|
||||
reference_image_src: payload.reference_image_src.clone(),
|
||||
clear_count: payload.clear_count.unwrap_or(MATCH3D_DEFAULT_CLEAR_COUNT),
|
||||
difficulty: payload
|
||||
.difficulty
|
||||
.unwrap_or(MATCH3D_DEFAULT_DIFFICULTY)
|
||||
.clamp(1, 10),
|
||||
asset_style_id: normalize_optional_text(payload.asset_style_id.as_deref()),
|
||||
asset_style_label: normalize_optional_text(payload.asset_style_label.as_deref()),
|
||||
asset_style_prompt: normalize_optional_text(payload.asset_style_prompt.as_deref()),
|
||||
generate_click_sound: payload.generate_click_sound.unwrap_or(false),
|
||||
}
|
||||
}
|
||||
|
||||
fn build_config_from_message(
|
||||
session: &Match3DAgentSessionRecord,
|
||||
payload: &SendMatch3DAgentMessageRequest,
|
||||
) -> Match3DConfigJson {
|
||||
let current = resolve_config_or_default(session.config.as_ref());
|
||||
let text = payload.text.trim();
|
||||
let reference_image_src = payload
|
||||
.reference_image_src
|
||||
.as_deref()
|
||||
.map(str::trim)
|
||||
.filter(|value| !value.is_empty())
|
||||
.map(str::to_string)
|
||||
.or(current.reference_image_src);
|
||||
let quick_fill_requested =
|
||||
payload.quick_fill_requested.unwrap_or(false) || text.contains("自动配置");
|
||||
|
||||
let mut theme_text = current.theme_text;
|
||||
let mut clear_count = current.clear_count.max(1);
|
||||
let mut difficulty = current.difficulty.clamp(1, 10);
|
||||
let asset_style_id = current.asset_style_id;
|
||||
let asset_style_label = current.asset_style_label;
|
||||
let asset_style_prompt = current.asset_style_prompt;
|
||||
let generate_click_sound = current.generate_click_sound;
|
||||
|
||||
match session.current_turn {
|
||||
0 => {
|
||||
theme_text = if quick_fill_requested {
|
||||
MATCH3D_DEFAULT_THEME.to_string()
|
||||
} else {
|
||||
parse_theme_answer(text).unwrap_or(theme_text)
|
||||
};
|
||||
}
|
||||
1 => {
|
||||
clear_count = if quick_fill_requested {
|
||||
clear_count
|
||||
} else {
|
||||
parse_number_after_keywords(text, &["消除", "次数", "clearCount"])
|
||||
.unwrap_or(clear_count)
|
||||
}
|
||||
.max(1);
|
||||
}
|
||||
_ => {
|
||||
difficulty = if quick_fill_requested {
|
||||
difficulty
|
||||
} else {
|
||||
parse_number_after_keywords(text, &["难度", "difficulty"]).unwrap_or(difficulty)
|
||||
}
|
||||
.clamp(1, 10);
|
||||
}
|
||||
}
|
||||
|
||||
Match3DConfigJson {
|
||||
theme_text,
|
||||
reference_image_src,
|
||||
clear_count,
|
||||
difficulty,
|
||||
asset_style_id,
|
||||
asset_style_label,
|
||||
asset_style_prompt,
|
||||
generate_click_sound,
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn resolve_config_or_default(config: Option<&Match3DCreatorConfigRecord>) -> Match3DConfigJson {
|
||||
config
|
||||
.map(|config| Match3DConfigJson {
|
||||
theme_text: config.theme_text.clone(),
|
||||
reference_image_src: config.reference_image_src.clone(),
|
||||
clear_count: config.clear_count.max(1),
|
||||
difficulty: config.difficulty.clamp(1, 10),
|
||||
asset_style_id: config.asset_style_id.clone(),
|
||||
asset_style_label: config.asset_style_label.clone(),
|
||||
asset_style_prompt: config.asset_style_prompt.clone(),
|
||||
generate_click_sound: config.generate_click_sound,
|
||||
})
|
||||
.unwrap_or_else(|| Match3DConfigJson {
|
||||
theme_text: MATCH3D_DEFAULT_THEME.to_string(),
|
||||
reference_image_src: None,
|
||||
clear_count: MATCH3D_DEFAULT_CLEAR_COUNT,
|
||||
difficulty: MATCH3D_DEFAULT_DIFFICULTY,
|
||||
asset_style_id: None,
|
||||
asset_style_label: None,
|
||||
asset_style_prompt: None,
|
||||
generate_click_sound: false,
|
||||
})
|
||||
}
|
||||
|
||||
pub(super) fn normalize_optional_text(value: Option<&str>) -> Option<String> {
|
||||
value
|
||||
.map(str::trim)
|
||||
.filter(|value| !value.is_empty())
|
||||
.map(str::to_string)
|
||||
}
|
||||
|
||||
pub(super) fn serialize_match3d_config(config: &Match3DConfigJson) -> Option<String> {
|
||||
serde_json::to_string(config).ok()
|
||||
}
|
||||
|
||||
pub(super) fn build_seed_text(
|
||||
payload: &CreateMatch3DAgentSessionRequest,
|
||||
config: &Match3DConfigJson,
|
||||
) -> String {
|
||||
payload
|
||||
.seed_text
|
||||
.as_deref()
|
||||
.map(str::trim)
|
||||
.filter(|value| !value.is_empty())
|
||||
.map(str::to_string)
|
||||
.unwrap_or_else(|| {
|
||||
format!(
|
||||
"{}题材,消除{}次,难度{}",
|
||||
config.theme_text, config.clear_count, config.difficulty
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
fn build_match3d_assistant_reply(config: &Match3DConfigJson) -> String {
|
||||
format!(
|
||||
"已确认:{}题材,需要消除 {} 次,共 {} 件物品,难度 {}。",
|
||||
config.theme_text,
|
||||
config.clear_count,
|
||||
config.clear_count.saturating_mul(3),
|
||||
config.difficulty
|
||||
)
|
||||
}
|
||||
|
||||
pub(super) fn build_match3d_assistant_reply_for_turn(config: &Match3DConfigJson, current_turn: u32) -> String {
|
||||
match current_turn {
|
||||
0 => MATCH3D_QUESTION_THEME.to_string(),
|
||||
1 => MATCH3D_QUESTION_CLEAR_COUNT.to_string(),
|
||||
2 => MATCH3D_QUESTION_DIFFICULTY.to_string(),
|
||||
_ => build_match3d_assistant_reply(config),
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn resolve_progress_percent_for_turn(current_turn: u32) -> u32 {
|
||||
match current_turn {
|
||||
0 => 0,
|
||||
1 => 33,
|
||||
2 => 66,
|
||||
_ => 100,
|
||||
}
|
||||
}
|
||||
|
||||
fn parse_theme_answer(text: &str) -> Option<String> {
|
||||
for marker in ["题材", "主题"] {
|
||||
if let Some((_, value)) = text.split_once(marker) {
|
||||
let normalized = value
|
||||
.trim_matches(|ch: char| ch == ':' || ch == ':' || ch.is_whitespace())
|
||||
.split_whitespace()
|
||||
.next()
|
||||
.unwrap_or_default()
|
||||
.trim_matches(['。', ',', ',', ';', ';'])
|
||||
.to_string();
|
||||
if !normalized.is_empty() {
|
||||
return Some(normalized);
|
||||
}
|
||||
}
|
||||
}
|
||||
let trimmed = text.trim();
|
||||
if (2..=24).contains(&trimmed.chars().count()) && !trimmed.chars().any(|ch| ch.is_ascii_digit())
|
||||
{
|
||||
return Some(trimmed.to_string());
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
fn parse_number_after_keywords(text: &str, keywords: &[&str]) -> Option<u32> {
|
||||
for keyword in keywords {
|
||||
if let Some(index) = text.find(keyword) {
|
||||
let suffix = &text[index + keyword.len()..];
|
||||
if let Some(value) = first_positive_integer(suffix) {
|
||||
return Some(value);
|
||||
}
|
||||
}
|
||||
}
|
||||
first_positive_integer(text)
|
||||
}
|
||||
|
||||
fn first_positive_integer(text: &str) -> Option<u32> {
|
||||
let mut digits = String::new();
|
||||
for ch in text.chars() {
|
||||
if ch.is_ascii_digit() {
|
||||
digits.push(ch);
|
||||
} else if !digits.is_empty() {
|
||||
break;
|
||||
}
|
||||
}
|
||||
digits.parse::<u32>().ok().filter(|value| *value > 0)
|
||||
}
|
||||
|
||||
pub(super) fn normalize_tags(tags: Vec<String>) -> Vec<String> {
|
||||
let mut result: Vec<String> = Vec::new();
|
||||
for tag in tags {
|
||||
let trimmed = normalize_match3d_tag(tag.as_str());
|
||||
if !trimmed.is_empty() && !result.iter().any(|value| value == &trimmed) {
|
||||
result.push(trimmed);
|
||||
}
|
||||
if result.len() >= 6 {
|
||||
break;
|
||||
}
|
||||
}
|
||||
result
|
||||
}
|
||||
|
||||
fn normalize_optional_match3d_text(value: Option<String>) -> Option<String> {
|
||||
value
|
||||
.map(|value| value.trim().to_string())
|
||||
.filter(|value| !value.is_empty())
|
||||
}
|
||||
async fn generate_match3d_draft_plan(
|
||||
state: &AppState,
|
||||
config: &Match3DConfigJson,
|
||||
) -> Match3DGeneratedDraftPlan {
|
||||
let Some(llm_client) = state
|
||||
.creative_agent_gpt5_client()
|
||||
.or_else(|| state.llm_client())
|
||||
else {
|
||||
return fallback_match3d_draft_plan(config);
|
||||
};
|
||||
let system_prompt = "你是抓大鹅游戏的草稿生成编辑,只返回 JSON。";
|
||||
let gameplay_item_count = resolve_match3d_gameplay_item_count(config);
|
||||
let generated_item_count = resolve_match3d_generated_item_count(config);
|
||||
let user_prompt = format!(
|
||||
"题材设定:{}\n请生成抓大鹅游戏草稿生成计划。要求:只返回 JSON 对象,字段为 gameName、summary、tags、backgroundPrompt、items。gameName 为 4 到 12 个中文字符,不要包含“作品”“游戏”;summary 为 18 到 48 个中文字符的作品描述,说明题材氛围和核心体验,不要写规则说明;tags 为 3 到 6 个中文短标签,每个 2 到 6 个汉字,后续会用同一作品信息再次生成作品标签;backgroundPrompt 是用于生成局内纯背景图的中文提示词,只描述竖屏移动端抓大鹅题材氛围、色彩和环境,不得描述锅、圆盘、托盘、拼图槽、物品槽、HUD、UI、文字、按钮、倒计时、分数或物品;当前玩法需要 {} 种物品,但素材图固定每 5 个物品一批,因此 items 必须向上补齐并正好返回 {} 项,每项包含 name、itemSize 和 soundPrompt。name 为 2 到 6 个汉字;itemSize 只能是“大”“中”“小”之一,按物品真实相对尺寸判断,例如西瓜/大箱子偏大,苹果/杯子偏中,糖果/钥匙偏小;soundPrompt 只作为历史字段保留,可返回空字符串。",
|
||||
config.theme_text, gameplay_item_count, generated_item_count
|
||||
);
|
||||
let response = llm_client
|
||||
.request_text(
|
||||
LlmTextRequest::new(vec![
|
||||
LlmMessage::system(system_prompt),
|
||||
LlmMessage::user(user_prompt),
|
||||
])
|
||||
.with_model(MATCH3D_WORK_METADATA_LLM_MODEL)
|
||||
.with_responses_api(),
|
||||
)
|
||||
.await;
|
||||
|
||||
match response {
|
||||
Ok(response) => parse_match3d_draft_plan(response.content.as_str(), config)
|
||||
.unwrap_or_else(|| fallback_match3d_draft_plan(config)),
|
||||
Err(error) => {
|
||||
tracing::warn!(
|
||||
provider = MATCH3D_AGENT_PROVIDER,
|
||||
theme_text = config.theme_text.as_str(),
|
||||
error = %error,
|
||||
"抓大鹅草稿生成计划失败,降级使用本地生成计划"
|
||||
);
|
||||
fallback_match3d_draft_plan(config)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn parse_match3d_draft_plan(
|
||||
raw: &str,
|
||||
config: &Match3DConfigJson,
|
||||
) -> Option<Match3DGeneratedDraftPlan> {
|
||||
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 value = serde_json::from_str::<Value>(json_text).ok()?;
|
||||
let game_name = normalize_match3d_game_name(value.get("gameName")?.as_str()?);
|
||||
if game_name.is_empty() {
|
||||
return None;
|
||||
}
|
||||
let tags = value
|
||||
.get("tags")
|
||||
.and_then(Value::as_array)
|
||||
.map(|items| normalize_match3d_tag_candidates(items.iter().filter_map(Value::as_str)))
|
||||
.unwrap_or_default();
|
||||
let fallback = fallback_match3d_draft_plan(config);
|
||||
let summary = value
|
||||
.get("summary")
|
||||
.or_else(|| value.get("description"))
|
||||
.or_else(|| value.get("workSummary"))
|
||||
.or_else(|| value.get("work_summary"))
|
||||
.and_then(Value::as_str)
|
||||
.map(normalize_match3d_work_summary)
|
||||
.filter(|value| !value.is_empty())
|
||||
.unwrap_or(fallback.metadata.summary);
|
||||
let items = value
|
||||
.get("items")
|
||||
.and_then(Value::as_array)
|
||||
.map(|items| {
|
||||
items
|
||||
.iter()
|
||||
.filter_map(|item| {
|
||||
let name =
|
||||
normalize_match3d_item_name(item.get("name").and_then(Value::as_str)?);
|
||||
if name.is_empty() {
|
||||
return None;
|
||||
}
|
||||
let item_size = item
|
||||
.get("itemSize")
|
||||
.or_else(|| item.get("item_size"))
|
||||
.or_else(|| item.get("size"))
|
||||
.and_then(Value::as_str)
|
||||
.map(normalize_match3d_item_size)
|
||||
.filter(|value| !value.is_empty())
|
||||
.unwrap_or_else(|| infer_match3d_item_size(&name));
|
||||
let sound_prompt = item
|
||||
.get("soundPrompt")
|
||||
.or_else(|| item.get("sound_prompt"))
|
||||
.and_then(Value::as_str)
|
||||
.map(normalize_match3d_audio_prompt)
|
||||
.filter(|value| !value.is_empty())
|
||||
.unwrap_or_else(|| build_fallback_match3d_item_sound_prompt(config, &name));
|
||||
Some(Match3DGeneratedItemPlan {
|
||||
name,
|
||||
item_size,
|
||||
sound_prompt,
|
||||
})
|
||||
})
|
||||
.collect::<Vec<_>>()
|
||||
})
|
||||
.unwrap_or_default();
|
||||
let background_prompt = value
|
||||
.get("backgroundPrompt")
|
||||
.or_else(|| value.get("background_prompt"))
|
||||
.and_then(Value::as_str)
|
||||
.map(normalize_match3d_background_prompt)
|
||||
.filter(|value| !value.is_empty())
|
||||
.unwrap_or(fallback.background_prompt);
|
||||
|
||||
Some(Match3DGeneratedDraftPlan {
|
||||
metadata: Match3DGeneratedWorkMetadata {
|
||||
game_name,
|
||||
summary,
|
||||
tags: normalize_match3d_tag_candidates(tags),
|
||||
},
|
||||
items: normalize_match3d_item_plan(config, items),
|
||||
background_prompt,
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
pub(super) fn parse_match3d_work_metadata(raw: &str) -> Option<Match3DGeneratedWorkMetadata> {
|
||||
let config = Match3DConfigJson {
|
||||
theme_text: MATCH3D_DEFAULT_THEME.to_string(),
|
||||
reference_image_src: None,
|
||||
clear_count: MATCH3D_DEFAULT_CLEAR_COUNT,
|
||||
difficulty: MATCH3D_DEFAULT_DIFFICULTY,
|
||||
asset_style_id: None,
|
||||
asset_style_label: None,
|
||||
asset_style_prompt: None,
|
||||
generate_click_sound: false,
|
||||
};
|
||||
parse_match3d_draft_plan(raw, &config).map(|plan| plan.metadata)
|
||||
}
|
||||
|
||||
fn normalize_match3d_game_name(raw: &str) -> String {
|
||||
raw.trim()
|
||||
.trim_matches(['"', '\'', '“', '”', '。', ',', ',', '、'])
|
||||
.chars()
|
||||
.filter(|character| !character.is_control())
|
||||
.take(16)
|
||||
.collect::<String>()
|
||||
.trim()
|
||||
.to_string()
|
||||
}
|
||||
|
||||
fn normalize_match3d_work_summary(raw: &str) -> String {
|
||||
raw.trim()
|
||||
.trim_matches(['"', '\'', '“', '”'])
|
||||
.split_whitespace()
|
||||
.collect::<Vec<_>>()
|
||||
.join("")
|
||||
.chars()
|
||||
.filter(|character| !character.is_control())
|
||||
.take(80)
|
||||
.collect::<String>()
|
||||
.trim()
|
||||
.to_string()
|
||||
}
|
||||
|
||||
pub(super) fn fallback_match3d_work_metadata(theme_text: &str) -> Match3DGeneratedWorkMetadata {
|
||||
let theme = theme_text.trim();
|
||||
let normalized_theme = if theme.is_empty() { "主题" } else { theme };
|
||||
Match3DGeneratedWorkMetadata {
|
||||
game_name: format!("{normalized_theme}抓大鹅"),
|
||||
summary: normalize_match3d_work_summary(
|
||||
format!("{normalized_theme}主题的轻量抓取消除作品,适合快速体验。").as_str(),
|
||||
),
|
||||
tags: normalize_match3d_tag_candidates([normalized_theme, "抓大鹅", "经典消除", "2D素材"]),
|
||||
}
|
||||
}
|
||||
|
||||
fn fallback_match3d_draft_plan(config: &Match3DConfigJson) -> Match3DGeneratedDraftPlan {
|
||||
let metadata = fallback_match3d_work_metadata(config.theme_text.as_str());
|
||||
let items = fallback_match3d_item_names(config.theme_text.as_str())
|
||||
.into_iter()
|
||||
.take(resolve_match3d_generated_item_count(config))
|
||||
.map(|name| Match3DGeneratedItemPlan {
|
||||
item_size: infer_match3d_item_size(&name),
|
||||
sound_prompt: build_fallback_match3d_item_sound_prompt(config, &name),
|
||||
name,
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
Match3DGeneratedDraftPlan {
|
||||
background_prompt: build_fallback_match3d_background_prompt(config),
|
||||
metadata,
|
||||
items,
|
||||
}
|
||||
}
|
||||
1406
server-rs/crates/api-server/src/match3d/handlers.rs
Normal file
1406
server-rs/crates/api-server/src/match3d/handlers.rs
Normal file
File diff suppressed because it is too large
Load Diff
2631
server-rs/crates/api-server/src/match3d/item_assets.rs
Normal file
2631
server-rs/crates/api-server/src/match3d/item_assets.rs
Normal file
File diff suppressed because it is too large
Load Diff
37
server-rs/crates/api-server/src/match3d/runtime.rs
Normal file
37
server-rs/crates/api-server/src/match3d/runtime.rs
Normal file
@@ -0,0 +1,37 @@
|
||||
pub(super) fn normalize_match3d_run_status(value: &str) -> &str {
|
||||
match value {
|
||||
"Running" => "running",
|
||||
"Won" => "won",
|
||||
"Failed" => "failed",
|
||||
"Stopped" => "stopped",
|
||||
_ => value,
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn normalize_match3d_item_state(value: &str) -> &str {
|
||||
match value {
|
||||
"InBoard" => "in_board",
|
||||
"InTray" => "in_tray",
|
||||
"Cleared" => "cleared",
|
||||
_ => value,
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn normalize_match3d_failure_reason(value: &str) -> &str {
|
||||
match value {
|
||||
"TimeUp" => "time_up",
|
||||
"TrayFull" => "tray_full",
|
||||
_ => value,
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn normalize_match3d_click_reject_reason(value: &str) -> &str {
|
||||
match value {
|
||||
"RejectedNotClickable" => "item_not_clickable",
|
||||
"RejectedAlreadyMoved" => "item_not_in_board",
|
||||
"RejectedTrayFull" => "tray_full",
|
||||
"VersionConflict" => "snapshot_version_mismatch",
|
||||
"RunFinished" => "run_not_active",
|
||||
_ => value,
|
||||
}
|
||||
}
|
||||
1875
server-rs/crates/api-server/src/match3d/tests.rs
Normal file
1875
server-rs/crates/api-server/src/match3d/tests.rs
Normal file
File diff suppressed because it is too large
Load Diff
483
server-rs/crates/api-server/src/match3d/vector_engine_gemini.rs
Normal file
483
server-rs/crates/api-server/src/match3d/vector_engine_gemini.rs
Normal file
@@ -0,0 +1,483 @@
|
||||
use super::*;
|
||||
|
||||
pub(super) async fn generate_match3d_material_sheet(
|
||||
state: &AppState,
|
||||
config: &Match3DConfigJson,
|
||||
item_names: &[String],
|
||||
) -> Result<Match3DMaterialSheet, AppError> {
|
||||
let settings = require_match3d_vector_engine_gemini_image_settings(state)?;
|
||||
let http_client = build_match3d_vector_engine_gemini_image_http_client(&settings)?;
|
||||
let prompt = build_match3d_material_sheet_prompt(config, item_names);
|
||||
let negative_prompt = build_match3d_material_sheet_negative_prompt(config);
|
||||
let generated = create_match3d_vector_engine_gemini_image_generation(
|
||||
&http_client,
|
||||
&settings,
|
||||
prompt.as_str(),
|
||||
negative_prompt.as_str(),
|
||||
"抓大鹅素材图生成失败",
|
||||
)
|
||||
.await?;
|
||||
let image = generated.images.into_iter().next().ok_or_else(|| {
|
||||
AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({
|
||||
"provider": "vector-engine-gemini",
|
||||
"message": "抓大鹅素材图生成失败:未返回图片",
|
||||
}))
|
||||
})?;
|
||||
|
||||
Ok(Match3DMaterialSheet {
|
||||
task_id: generated.task_id,
|
||||
image,
|
||||
})
|
||||
}
|
||||
|
||||
fn require_match3d_vector_engine_gemini_image_settings(
|
||||
state: &AppState,
|
||||
) -> Result<Match3DVectorEngineGeminiImageSettings, AppError> {
|
||||
let base_url = state
|
||||
.config
|
||||
.vector_engine_base_url
|
||||
.trim()
|
||||
.trim_end_matches('/');
|
||||
if base_url.is_empty() {
|
||||
return Err(
|
||||
AppError::from_status(StatusCode::SERVICE_UNAVAILABLE).with_details(json!({
|
||||
"provider": "vector-engine-gemini",
|
||||
"reason": "VECTOR_ENGINE_BASE_URL 未配置",
|
||||
})),
|
||||
);
|
||||
}
|
||||
|
||||
let api_key = state
|
||||
.config
|
||||
.vector_engine_api_key
|
||||
.as_deref()
|
||||
.map(str::trim)
|
||||
.filter(|value| !value.is_empty())
|
||||
.ok_or_else(|| {
|
||||
AppError::from_status(StatusCode::SERVICE_UNAVAILABLE).with_details(json!({
|
||||
"provider": "vector-engine-gemini",
|
||||
"reason": "VECTOR_ENGINE_API_KEY 未配置",
|
||||
}))
|
||||
})?;
|
||||
|
||||
Ok(Match3DVectorEngineGeminiImageSettings {
|
||||
base_url: base_url.to_string(),
|
||||
api_key: api_key.to_string(),
|
||||
request_timeout_ms: state.config.vector_engine_image_request_timeout_ms.max(1),
|
||||
})
|
||||
}
|
||||
|
||||
fn build_match3d_vector_engine_gemini_image_http_client(
|
||||
settings: &Match3DVectorEngineGeminiImageSettings,
|
||||
) -> Result<reqwest::Client, AppError> {
|
||||
reqwest::Client::builder()
|
||||
.timeout(Duration::from_millis(settings.request_timeout_ms))
|
||||
.build()
|
||||
.map_err(|error| {
|
||||
AppError::from_status(StatusCode::INTERNAL_SERVER_ERROR).with_details(json!({
|
||||
"provider": "vector-engine-gemini",
|
||||
"message": format!("构造抓大鹅 VectorEngine Gemini 图片生成 HTTP 客户端失败:{error}"),
|
||||
}))
|
||||
})
|
||||
}
|
||||
|
||||
async fn create_match3d_vector_engine_gemini_image_generation(
|
||||
http_client: &reqwest::Client,
|
||||
settings: &Match3DVectorEngineGeminiImageSettings,
|
||||
prompt: &str,
|
||||
negative_prompt: &str,
|
||||
failure_context: &str,
|
||||
) -> Result<OpenAiGeneratedImages, AppError> {
|
||||
let request_body = build_match3d_vector_engine_gemini_image_request_body(
|
||||
prompt,
|
||||
negative_prompt,
|
||||
MATCH3D_MATERIAL_VECTOR_ENGINE_GEMINI_ASPECT_RATIO,
|
||||
);
|
||||
let response = http_client
|
||||
.post(build_match3d_vector_engine_gemini_generate_content_url(
|
||||
settings,
|
||||
))
|
||||
.query(&[("key", settings.api_key.as_str())])
|
||||
.header(header::ACCEPT, "application/json")
|
||||
.header(header::CONTENT_TYPE, "application/json")
|
||||
.json(&request_body)
|
||||
.send()
|
||||
.await
|
||||
.map_err(|error| {
|
||||
map_match3d_vector_engine_gemini_image_request_error(format!(
|
||||
"{failure_context}:调用 VectorEngine Gemini 图片生成失败:{error}"
|
||||
))
|
||||
})?;
|
||||
let status = response.status();
|
||||
let response_text = response.text().await.map_err(|error| {
|
||||
map_match3d_vector_engine_gemini_image_request_error(format!(
|
||||
"{failure_context}:读取 VectorEngine Gemini 图片生成响应失败:{error}"
|
||||
))
|
||||
})?;
|
||||
if !status.is_success() {
|
||||
return Err(map_match3d_vector_engine_gemini_image_upstream_error(
|
||||
status,
|
||||
response_text.as_str(),
|
||||
failure_context,
|
||||
));
|
||||
}
|
||||
|
||||
let payload = parse_match3d_json_payload(
|
||||
response_text.as_str(),
|
||||
"解析抓大鹅 VectorEngine Gemini 图片生成响应失败",
|
||||
"vector-engine-gemini",
|
||||
)?;
|
||||
let image_urls = extract_match3d_image_urls(&payload);
|
||||
if !image_urls.is_empty() {
|
||||
return download_match3d_images_from_urls(
|
||||
http_client,
|
||||
format!("vector-engine-gemini-{}", current_utc_micros()),
|
||||
image_urls,
|
||||
1,
|
||||
"vector-engine-gemini",
|
||||
)
|
||||
.await;
|
||||
}
|
||||
|
||||
let b64_images = extract_match3d_b64_images(&payload);
|
||||
if !b64_images.is_empty() {
|
||||
return Ok(match3d_images_from_base64(
|
||||
format!("vector-engine-gemini-{}", current_utc_micros()),
|
||||
b64_images,
|
||||
1,
|
||||
));
|
||||
}
|
||||
|
||||
Err(
|
||||
AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({
|
||||
"provider": "vector-engine-gemini",
|
||||
"message": "抓大鹅 VectorEngine Gemini 图片生成未返回图片",
|
||||
"rawExcerpt": trim_match3d_upstream_excerpt(response_text.as_str(), 800),
|
||||
})),
|
||||
)
|
||||
}
|
||||
|
||||
pub(super) fn build_match3d_vector_engine_gemini_image_request_body(
|
||||
prompt: &str,
|
||||
negative_prompt: &str,
|
||||
aspect_ratio: &str,
|
||||
) -> Value {
|
||||
json!({
|
||||
"contents": [{
|
||||
"role": "user",
|
||||
"parts": [{
|
||||
"text": build_match3d_vector_engine_gemini_prompt(prompt, negative_prompt),
|
||||
}],
|
||||
}],
|
||||
"generationConfig": {
|
||||
"responseModalities": ["TEXT", "IMAGE"],
|
||||
"imageConfig": {
|
||||
"aspectRatio": aspect_ratio,
|
||||
},
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
pub(super) fn build_match3d_vector_engine_gemini_generate_content_url(
|
||||
settings: &Match3DVectorEngineGeminiImageSettings,
|
||||
) -> String {
|
||||
let base_url = settings.base_url.trim_end_matches("/v1");
|
||||
format!(
|
||||
"{}/v1beta/models/{}:generateContent",
|
||||
base_url, MATCH3D_MATERIAL_VECTOR_ENGINE_GEMINI_MODEL
|
||||
)
|
||||
}
|
||||
|
||||
fn build_match3d_vector_engine_gemini_prompt(prompt: &str, negative_prompt: &str) -> String {
|
||||
let prompt = prompt.trim();
|
||||
let negative_prompt = negative_prompt.trim();
|
||||
if negative_prompt.is_empty() {
|
||||
return prompt.to_string();
|
||||
}
|
||||
|
||||
format!("{prompt}\n避免:{negative_prompt}")
|
||||
}
|
||||
|
||||
async fn download_match3d_images_from_urls(
|
||||
http_client: &reqwest::Client,
|
||||
task_id: String,
|
||||
image_urls: Vec<String>,
|
||||
candidate_count: u32,
|
||||
provider: &str,
|
||||
) -> Result<OpenAiGeneratedImages, AppError> {
|
||||
let mut images = Vec::with_capacity(candidate_count.clamp(1, 4) as usize);
|
||||
for image_url in image_urls
|
||||
.into_iter()
|
||||
.take(candidate_count.clamp(1, 4) as usize)
|
||||
{
|
||||
images
|
||||
.push(download_match3d_remote_image(http_client, image_url.as_str(), provider).await?);
|
||||
}
|
||||
Ok(OpenAiGeneratedImages {
|
||||
task_id,
|
||||
actual_prompt: None,
|
||||
images,
|
||||
})
|
||||
}
|
||||
|
||||
async fn download_match3d_remote_image(
|
||||
http_client: &reqwest::Client,
|
||||
image_url: &str,
|
||||
provider: &str,
|
||||
) -> Result<DownloadedOpenAiImage, AppError> {
|
||||
let response = http_client.get(image_url).send().await.map_err(|error| {
|
||||
AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({
|
||||
"provider": provider,
|
||||
"message": format!("下载抓大鹅生成图片失败:{error}"),
|
||||
}))
|
||||
})?;
|
||||
let status = response.status();
|
||||
let content_type = response
|
||||
.headers()
|
||||
.get(header::CONTENT_TYPE)
|
||||
.and_then(|value| value.to_str().ok())
|
||||
.unwrap_or("image/png")
|
||||
.to_string();
|
||||
let body = response.bytes().await.map_err(|error| {
|
||||
AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({
|
||||
"provider": provider,
|
||||
"message": format!("读取抓大鹅生成图片内容失败:{error}"),
|
||||
}))
|
||||
})?;
|
||||
if !status.is_success() {
|
||||
return Err(
|
||||
AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({
|
||||
"provider": provider,
|
||||
"message": "下载抓大鹅生成图片失败",
|
||||
"status": status.as_u16(),
|
||||
})),
|
||||
);
|
||||
}
|
||||
|
||||
let mime_type = normalize_match3d_downloaded_image_mime_type(content_type.as_str());
|
||||
Ok(DownloadedOpenAiImage {
|
||||
extension: match3d_mime_to_extension(mime_type.as_str()).to_string(),
|
||||
mime_type,
|
||||
bytes: body.to_vec(),
|
||||
})
|
||||
}
|
||||
|
||||
fn match3d_images_from_base64(
|
||||
task_id: String,
|
||||
b64_images: Vec<String>,
|
||||
candidate_count: u32,
|
||||
) -> OpenAiGeneratedImages {
|
||||
let images = b64_images
|
||||
.into_iter()
|
||||
.take(candidate_count.clamp(1, 4) as usize)
|
||||
.filter_map(|raw| decode_match3d_base64_image(raw.as_str()))
|
||||
.collect();
|
||||
OpenAiGeneratedImages {
|
||||
task_id,
|
||||
actual_prompt: None,
|
||||
images,
|
||||
}
|
||||
}
|
||||
|
||||
fn decode_match3d_base64_image(raw: &str) -> Option<DownloadedOpenAiImage> {
|
||||
let bytes = BASE64_STANDARD.decode(raw.trim()).ok()?;
|
||||
let mime_type = infer_match3d_image_mime_type(bytes.as_slice()).to_string();
|
||||
Some(DownloadedOpenAiImage {
|
||||
extension: match3d_mime_to_extension(mime_type.as_str()).to_string(),
|
||||
mime_type,
|
||||
bytes,
|
||||
})
|
||||
}
|
||||
|
||||
fn parse_match3d_json_payload(
|
||||
raw_text: &str,
|
||||
failure_context: &str,
|
||||
provider: &str,
|
||||
) -> Result<Value, AppError> {
|
||||
serde_json::from_str::<Value>(raw_text).map_err(|error| {
|
||||
AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({
|
||||
"provider": provider,
|
||||
"message": format!("{failure_context}:{error}"),
|
||||
"rawExcerpt": trim_match3d_upstream_excerpt(raw_text, 800),
|
||||
}))
|
||||
})
|
||||
}
|
||||
|
||||
fn extract_match3d_image_urls(payload: &Value) -> Vec<String> {
|
||||
let mut urls = Vec::new();
|
||||
collect_match3d_strings_by_key(payload, "url", &mut urls);
|
||||
collect_match3d_strings_by_key(payload, "image", &mut urls);
|
||||
collect_match3d_strings_by_key(payload, "image_url", &mut urls);
|
||||
let mut deduped = Vec::new();
|
||||
for url in urls {
|
||||
if (url.starts_with("http://") || url.starts_with("https://")) && !deduped.contains(&url) {
|
||||
deduped.push(url);
|
||||
}
|
||||
}
|
||||
deduped
|
||||
}
|
||||
|
||||
pub(super) fn extract_match3d_b64_images(payload: &Value) -> Vec<String> {
|
||||
let mut values = Vec::new();
|
||||
collect_match3d_strings_by_key(payload, "b64_json", &mut values);
|
||||
collect_match3d_inline_image_data(payload, &mut values);
|
||||
values
|
||||
}
|
||||
|
||||
fn collect_match3d_inline_image_data(payload: &Value, results: &mut Vec<String>) {
|
||||
match payload {
|
||||
Value::Array(entries) => {
|
||||
for entry in entries {
|
||||
collect_match3d_inline_image_data(entry, results);
|
||||
}
|
||||
}
|
||||
Value::Object(object) => {
|
||||
for key in ["inlineData", "inline_data"] {
|
||||
if let Some(Value::Object(inline_data)) = object.get(key) {
|
||||
let mime_type = inline_data
|
||||
.get("mimeType")
|
||||
.or_else(|| inline_data.get("mime_type"))
|
||||
.and_then(Value::as_str)
|
||||
.map(str::trim)
|
||||
.unwrap_or("image/png")
|
||||
.to_ascii_lowercase();
|
||||
if !mime_type.is_empty() && !mime_type.starts_with("image/") {
|
||||
continue;
|
||||
}
|
||||
if let Some(data) = inline_data
|
||||
.get("data")
|
||||
.and_then(Value::as_str)
|
||||
.map(str::trim)
|
||||
.filter(|value| !value.is_empty())
|
||||
{
|
||||
results.push(data.to_string());
|
||||
}
|
||||
}
|
||||
}
|
||||
for nested_value in object.values() {
|
||||
collect_match3d_inline_image_data(nested_value, results);
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
fn find_first_match3d_string_by_key(payload: &Value, target_key: &str) -> Option<String> {
|
||||
let mut results = Vec::new();
|
||||
collect_match3d_strings_by_key(payload, target_key, &mut results);
|
||||
results.into_iter().next()
|
||||
}
|
||||
|
||||
fn collect_match3d_strings_by_key(payload: &Value, target_key: &str, results: &mut Vec<String>) {
|
||||
match payload {
|
||||
Value::Array(entries) => {
|
||||
for entry in entries {
|
||||
collect_match3d_strings_by_key(entry, target_key, results);
|
||||
}
|
||||
}
|
||||
Value::Object(object) => {
|
||||
for (key, nested_value) in object {
|
||||
if key == target_key {
|
||||
match nested_value {
|
||||
Value::String(text) => {
|
||||
let text = text.trim();
|
||||
if !text.is_empty() {
|
||||
results.push(text.to_string());
|
||||
}
|
||||
}
|
||||
Value::Array(entries) => {
|
||||
for entry in entries {
|
||||
if let Some(text) = entry
|
||||
.as_str()
|
||||
.map(str::trim)
|
||||
.filter(|value| !value.is_empty())
|
||||
{
|
||||
results.push(text.to_string());
|
||||
}
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
collect_match3d_strings_by_key(nested_value, target_key, results);
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
fn map_match3d_vector_engine_gemini_image_request_error(message: String) -> AppError {
|
||||
AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({
|
||||
"provider": "vector-engine-gemini",
|
||||
"message": message,
|
||||
}))
|
||||
}
|
||||
|
||||
fn map_match3d_vector_engine_gemini_image_upstream_error(
|
||||
upstream_status: reqwest::StatusCode,
|
||||
raw_text: &str,
|
||||
fallback_message: &str,
|
||||
) -> AppError {
|
||||
let message = parse_match3d_api_error_message(raw_text, fallback_message);
|
||||
let raw_excerpt = trim_match3d_upstream_excerpt(raw_text, 800);
|
||||
tracing::warn!(
|
||||
provider = "vector-engine-gemini",
|
||||
upstream_status = upstream_status.as_u16(),
|
||||
message = %message,
|
||||
raw_excerpt = %raw_excerpt,
|
||||
"抓大鹅 VectorEngine Gemini 图片生成上游请求失败"
|
||||
);
|
||||
AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({
|
||||
"provider": "vector-engine-gemini",
|
||||
"upstreamStatus": upstream_status.as_u16(),
|
||||
"message": message,
|
||||
"rawExcerpt": raw_excerpt,
|
||||
}))
|
||||
}
|
||||
|
||||
fn parse_match3d_api_error_message(raw_text: &str, fallback_message: &str) -> String {
|
||||
let trimmed = raw_text.trim();
|
||||
if trimmed.is_empty() {
|
||||
return fallback_message.to_string();
|
||||
}
|
||||
if let Ok(payload) = serde_json::from_str::<Value>(trimmed) {
|
||||
for key in ["message", "code"] {
|
||||
if let Some(value) = find_first_match3d_string_by_key(&payload, key) {
|
||||
return if key == "message" {
|
||||
value
|
||||
} else {
|
||||
format!("{fallback_message}({value})")
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
trimmed.to_string()
|
||||
}
|
||||
|
||||
fn trim_match3d_upstream_excerpt(raw_text: &str, max_chars: usize) -> String {
|
||||
raw_text.chars().take(max_chars).collect()
|
||||
}
|
||||
|
||||
fn normalize_match3d_downloaded_image_mime_type(content_type: &str) -> String {
|
||||
let mime_type = content_type
|
||||
.split(';')
|
||||
.next()
|
||||
.map(str::trim)
|
||||
.unwrap_or("image/png");
|
||||
match mime_type {
|
||||
"image/png" | "image/webp" | "image/jpeg" | "image/jpg" | "image/gif" => {
|
||||
mime_type.to_string()
|
||||
}
|
||||
_ => "image/png".to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn match3d_mime_to_extension(mime_type: &str) -> &str {
|
||||
match mime_type {
|
||||
"image/png" => "png",
|
||||
"image/webp" => "webp",
|
||||
"image/gif" => "gif",
|
||||
"image/jpeg" | "image/jpg" => "jpg",
|
||||
_ => "png",
|
||||
}
|
||||
}
|
||||
1254
server-rs/crates/api-server/src/match3d/works.rs
Normal file
1254
server-rs/crates/api-server/src/match3d/works.rs
Normal file
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user