This commit is contained in:
@@ -124,8 +124,9 @@ use crate::{
|
||||
drop_square_hole_shape, execute_square_hole_agent_action, finish_square_hole_time_up,
|
||||
get_square_hole_agent_session, get_square_hole_run, get_square_hole_work_detail,
|
||||
get_square_hole_works, list_square_hole_gallery, publish_square_hole_work,
|
||||
put_square_hole_work, restart_square_hole_run, start_square_hole_run, stop_square_hole_run,
|
||||
stream_square_hole_agent_message, submit_square_hole_agent_message,
|
||||
put_square_hole_work, regenerate_square_hole_work_image, restart_square_hole_run,
|
||||
start_square_hole_run, stop_square_hole_run, stream_square_hole_agent_message,
|
||||
submit_square_hole_agent_message,
|
||||
},
|
||||
state::AppState,
|
||||
story_battles::{
|
||||
@@ -904,6 +905,13 @@ pub fn build_router(state: AppState) -> Router {
|
||||
require_bearer_auth,
|
||||
)),
|
||||
)
|
||||
.route(
|
||||
"/api/creation/square-hole/works/{profile_id}/images/regenerate",
|
||||
post(regenerate_square_hole_work_image).route_layer(middleware::from_fn_with_state(
|
||||
state.clone(),
|
||||
require_bearer_auth,
|
||||
)),
|
||||
)
|
||||
.route(
|
||||
"/api/runtime/square-hole/gallery",
|
||||
get(list_square_hole_gallery),
|
||||
|
||||
@@ -28,8 +28,15 @@ use crate::{
|
||||
};
|
||||
|
||||
// 历史素材类型需要与 SpacetimeDB 侧白名单保持同一口径,避免新增素材类型时 HTTP 门面漏同步。
|
||||
const SUPPORTED_ASSET_HISTORY_KINDS: [&str; 3] =
|
||||
["character_visual", "scene_image", "puzzle_cover_image"];
|
||||
const SUPPORTED_ASSET_HISTORY_KINDS: [&str; 7] = [
|
||||
"character_visual",
|
||||
"scene_image",
|
||||
"puzzle_cover_image",
|
||||
"square_hole_cover_image",
|
||||
"square_hole_background_image",
|
||||
"square_hole_shape_image",
|
||||
"square_hole_hole_image",
|
||||
];
|
||||
|
||||
pub async fn create_direct_upload_ticket(
|
||||
State(state): State<AppState>,
|
||||
@@ -492,6 +499,18 @@ mod tests {
|
||||
assert!(super::is_supported_asset_history_kind("character_visual"));
|
||||
assert!(super::is_supported_asset_history_kind("scene_image"));
|
||||
assert!(super::is_supported_asset_history_kind("puzzle_cover_image"));
|
||||
assert!(super::is_supported_asset_history_kind(
|
||||
"square_hole_cover_image"
|
||||
));
|
||||
assert!(super::is_supported_asset_history_kind(
|
||||
"square_hole_background_image"
|
||||
));
|
||||
assert!(super::is_supported_asset_history_kind(
|
||||
"square_hole_shape_image"
|
||||
));
|
||||
assert!(super::is_supported_asset_history_kind(
|
||||
"square_hole_hole_image"
|
||||
));
|
||||
assert!(!super::is_supported_asset_history_kind(
|
||||
"puzzle_preview_image"
|
||||
));
|
||||
@@ -501,7 +520,7 @@ mod tests {
|
||||
fn asset_history_kind_message_lists_all_supported_kinds() {
|
||||
assert_eq!(
|
||||
super::supported_asset_history_kind_message(),
|
||||
"历史素材类型只支持 character_visual、scene_image、puzzle_cover_image"
|
||||
"历史素材类型只支持 character_visual、scene_image、puzzle_cover_image、square_hole_cover_image、square_hole_background_image、square_hole_shape_image、square_hole_hole_image"
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -24,8 +24,9 @@ pub(crate) const SQUARE_HOLE_AGENT_SYSTEM_PROMPT: &str = r#"你是一个负责
|
||||
6. 默认核心反差优先使用“方洞万能”或“方洞优先”,但可以根据用户题材包装成更有记忆点的规则
|
||||
7. progressPercent 范围只能是 0 到 100
|
||||
8. shapeCount 只能是 6 到 24 的整数,difficulty 只能是 1 到 10 的整数
|
||||
9. shapeOptions 至少给 6 个,holeOptions 给 3 到 6 个,且至少一个 holeOptions.bonus 为 true
|
||||
10. imagePrompt 和 backgroundPrompt 必须适合直接生成图片,不要包含 UI、文字、水印或解释
|
||||
9. shapeOptions 至少给 6 个,每个 shapeOptions.targetHoleId 必须指向某个 holeOptions.holeId
|
||||
10. holeOptions 给 3 到 6 个,每个洞口都要有 imagePrompt
|
||||
11. imagePrompt 和 backgroundPrompt 必须适合直接生成图片,不要包含 UI、文字、水印或解释
|
||||
"#;
|
||||
|
||||
const SQUARE_HOLE_AGENT_OUTPUT_CONTRACT: &str = r#"请严格按以下 JSON 输出,不要输出其他文字:
|
||||
@@ -42,15 +43,16 @@ const SQUARE_HOLE_AGENT_OUTPUT_CONTRACT: &str = r#"请严格按以下 JSON 输
|
||||
"optionId": "square-block",
|
||||
"shapeKind": "square",
|
||||
"label": "方块",
|
||||
"targetHoleId": "hole-1",
|
||||
"imagePrompt": "玩具纸箱主题的方块贴纸图,透明背景,明亮可爱,游戏资产"
|
||||
}
|
||||
],
|
||||
"holeOptions": [
|
||||
{
|
||||
"holeId": "square-hole",
|
||||
"holeKind": "square",
|
||||
"label": "方洞",
|
||||
"bonus": true
|
||||
"holeId": "hole-1",
|
||||
"holeKind": "hole-1",
|
||||
"label": "洞口 1",
|
||||
"imagePrompt": "玩具纸箱主题的洞口 1 贴纸图,透明背景,明亮可爱,游戏资产"
|
||||
}
|
||||
],
|
||||
"backgroundPrompt": "玩具桌面上的纸箱洞板背景,中央留出操作空间"
|
||||
@@ -80,7 +82,7 @@ pub(crate) fn build_square_hole_agent_prompt(
|
||||
String::new()
|
||||
};
|
||||
format!(
|
||||
"模板目标:收束成可试玩、可发布的方洞挑战玩法草稿。{quick_fill_rules}\n\n当前是第 {turn} 轮,当前进度 {progress}% 。\n\n是否要求自动配置:{quick_fill_requested_text}\n\n当前配置:\n{current_config}\n\n最近聊天记录:\n{chat_history}\n\n收束要求:\n1. themeText 描述本局的玩具、道具或场景题材,保持短句。\n2. twistRule 描述真实判定规则,优先体现方洞优先或类似反直觉逻辑。\n3. shapeCount 决定单局形状数量,移动端短局建议 8 到 16。\n4. difficulty 决定误导强度和节奏,建议 3 到 7。\n5. shapeOptions 必须给出至少 6 个可生成贴图的形状候选,每个 imagePrompt 都围绕主题生成。\n6. holeOptions 必须给出 3 到 6 个洞口,创作者可在结果页继续改;至少一个 bonus=true。\n7. backgroundPrompt 用于生成运行态背景,必须描述画面,不要写规则说明。\n8. 用户给出明确方向时优先吸收并推进,不要机械问完所有字段。\n\n{contract}",
|
||||
"模板目标:收束成可试玩、可发布的方洞挑战玩法草稿。{quick_fill_rules}\n\n当前是第 {turn} 轮,当前进度 {progress}% 。\n\n是否要求自动配置:{quick_fill_requested_text}\n\n当前配置:\n{current_config}\n\n最近聊天记录:\n{chat_history}\n\n收束要求:\n1. themeText 描述本局的玩具、道具或场景题材,保持短句。\n2. twistRule 描述真实判定规则,强调每轮当前选项需要拖进指定洞口形成反直觉效果。\n3. shapeCount 决定单局形状数量,移动端短局建议 8 到 16。\n4. difficulty 决定误导强度和节奏,建议 3 到 7。\n5. shapeOptions 必须给出至少 6 个可生成贴图的候选,每个 imagePrompt 都围绕主题生成,每个 targetHoleId 指向一个洞口 holeId。\n6. holeOptions 必须给出 3 到 6 个洞口,holeId 使用 hole-1、hole-2 这类稳定 ID,holeKind 保持同 ID;每个洞口都要有 imagePrompt。\n7. backgroundPrompt 用于生成运行态背景,必须描述画面,不要写规则说明。\n8. 用户给出明确方向时优先吸收并推进,不要机械问完所有字段。\n\n{contract}",
|
||||
quick_fill_rules = quick_fill_rules,
|
||||
turn = session.current_turn.saturating_add(1),
|
||||
progress = session.progress_percent,
|
||||
@@ -103,6 +105,7 @@ fn serialize_square_hole_session_config(session: &SquareHoleAgentSessionRecord)
|
||||
"optionId": option.option_id,
|
||||
"shapeKind": option.shape_kind,
|
||||
"label": option.label,
|
||||
"targetHoleId": option.target_hole_id,
|
||||
"imagePrompt": option.image_prompt,
|
||||
"imageSrc": option.image_src,
|
||||
})
|
||||
@@ -117,7 +120,8 @@ fn serialize_square_hole_session_config(session: &SquareHoleAgentSessionRecord)
|
||||
"holeId": option.hole_id,
|
||||
"holeKind": option.hole_kind,
|
||||
"label": option.label,
|
||||
"bonus": option.bonus,
|
||||
"imagePrompt": option.image_prompt,
|
||||
"imageSrc": option.image_src,
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
|
||||
@@ -14,13 +14,14 @@ use module_runtime::{
|
||||
RuntimeProfileTaskItemRecord, RuntimeProfileTaskStatus, RuntimeProfileWalletLedgerSourceType,
|
||||
RuntimeReferralInviteCenterRecord, RuntimeTrackingScopeKind,
|
||||
};
|
||||
use serde_json::{Value, json};
|
||||
use serde::Deserialize;
|
||||
use serde_json::{Value, json};
|
||||
use shared_contracts::runtime::{
|
||||
AdminDisableProfileRedeemCodeRequest, AdminDisableProfileTaskConfigRequest,
|
||||
AdminUpsertProfileInviteCodeRequest, AdminUpsertProfileRedeemCodeRequest,
|
||||
AdminUpsertProfileTaskConfigRequest, AnalyticsBucketMetricResponse,
|
||||
AnalyticsMetricQueryResponse, ClaimProfileTaskRewardResponse,
|
||||
ANALYTICS_GRANULARITY_DAY, ANALYTICS_GRANULARITY_MONTH, ANALYTICS_GRANULARITY_QUARTER,
|
||||
ANALYTICS_GRANULARITY_WEEK, ANALYTICS_GRANULARITY_YEAR, AdminDisableProfileRedeemCodeRequest,
|
||||
AdminDisableProfileTaskConfigRequest, AdminUpsertProfileInviteCodeRequest,
|
||||
AdminUpsertProfileRedeemCodeRequest, AdminUpsertProfileTaskConfigRequest,
|
||||
AnalyticsBucketMetricResponse, AnalyticsMetricQueryResponse, ClaimProfileTaskRewardResponse,
|
||||
CreateProfileRechargeOrderRequest, CreateProfileRechargeOrderResponse,
|
||||
PROFILE_TASK_CYCLE_DAILY, PROFILE_TASK_STATUS_CLAIMABLE, PROFILE_TASK_STATUS_CLAIMED,
|
||||
PROFILE_TASK_STATUS_DISABLED, PROFILE_TASK_STATUS_INCOMPLETE,
|
||||
@@ -33,8 +34,6 @@ use shared_contracts::runtime::{
|
||||
PROFILE_WALLET_LEDGER_SOURCE_TYPE_POINTS_RECHARGE,
|
||||
PROFILE_WALLET_LEDGER_SOURCE_TYPE_PUZZLE_AUTHOR_INCENTIVE_CLAIM,
|
||||
PROFILE_WALLET_LEDGER_SOURCE_TYPE_REDEEM_CODE_REWARD,
|
||||
ANALYTICS_GRANULARITY_DAY, ANALYTICS_GRANULARITY_MONTH, ANALYTICS_GRANULARITY_QUARTER,
|
||||
ANALYTICS_GRANULARITY_WEEK, ANALYTICS_GRANULARITY_YEAR,
|
||||
PROFILE_WALLET_LEDGER_SOURCE_TYPE_SNAPSHOT_SYNC, ProfileDashboardSummaryResponse,
|
||||
ProfileInviteCodeAdminListResponse, ProfileInviteCodeAdminResponse,
|
||||
ProfileMembershipBenefitResponse, ProfileMembershipResponse, ProfilePlayStatsResponse,
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
use std::{
|
||||
collections::BTreeMap,
|
||||
convert::Infallible,
|
||||
time::{SystemTime, UNIX_EPOCH},
|
||||
};
|
||||
@@ -14,11 +15,16 @@ use axum::{
|
||||
},
|
||||
};
|
||||
use base64::{Engine as _, engine::general_purpose::STANDARD as BASE64_STANDARD};
|
||||
use module_assets::{
|
||||
AssetObjectAccessPolicy, AssetObjectFieldError, build_asset_entity_binding_input,
|
||||
build_asset_object_upsert_input, generate_asset_binding_id, generate_asset_object_id,
|
||||
};
|
||||
use module_square_hole::{
|
||||
SQUARE_HOLE_MESSAGE_ID_PREFIX, SQUARE_HOLE_PROFILE_ID_PREFIX, SQUARE_HOLE_RUN_ID_PREFIX,
|
||||
SQUARE_HOLE_SESSION_ID_PREFIX, default_background_prompt, normalize_hole_options,
|
||||
normalize_shape_options,
|
||||
};
|
||||
use platform_oss::{LegacyAssetPrefix, OssHeadObjectRequest, OssObjectAccess, OssPutObjectRequest};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::{Value, json};
|
||||
use shared_contracts::{
|
||||
@@ -36,10 +42,11 @@ use shared_contracts::{
|
||||
SquareHoleShapeSnapshotResponse, StartSquareHoleRunRequest, StopSquareHoleRunRequest,
|
||||
},
|
||||
square_hole_works::{
|
||||
PutSquareHoleWorkRequest, SquareHoleHoleOptionResponse as SquareHoleWorkHoleOptionResponse,
|
||||
PutSquareHoleWorkRequest, RegenerateSquareHoleWorkImageRequest,
|
||||
SquareHoleHoleOptionResponse as SquareHoleWorkHoleOptionResponse,
|
||||
SquareHoleShapeOptionResponse as SquareHoleWorkShapeOptionResponse,
|
||||
SquareHoleWorkDetailResponse, SquareHoleWorkMutationResponse,
|
||||
SquareHoleWorkProfileResponse, SquareHoleWorkSummaryResponse, SquareHoleWorksResponse,
|
||||
SquareHoleWorkDetailResponse, SquareHoleWorkMutationResponse, SquareHoleWorkProfileResponse,
|
||||
SquareHoleWorkSummaryResponse, SquareHoleWorksResponse,
|
||||
},
|
||||
};
|
||||
use shared_kernel::build_prefixed_uuid_id;
|
||||
@@ -60,9 +67,10 @@ use crate::{
|
||||
auth::AuthenticatedAccessToken,
|
||||
http_error::AppError,
|
||||
openai_image_generation::{
|
||||
build_openai_image_http_client, create_openai_image_generation,
|
||||
DownloadedOpenAiImage, build_openai_image_http_client, create_openai_image_generation,
|
||||
require_openai_image_settings,
|
||||
},
|
||||
platform_errors::map_oss_error,
|
||||
request_context::RequestContext,
|
||||
square_hole_agent_turn::{
|
||||
SquareHoleAgentTurnRequest, build_finalize_record_input, run_square_hole_agent_turn,
|
||||
@@ -78,6 +86,11 @@ const SQUARE_HOLE_DEFAULT_TWIST_RULE: &str = "方洞万能";
|
||||
const SQUARE_HOLE_DEFAULT_SHAPE_COUNT: u32 = 12;
|
||||
const SQUARE_HOLE_DEFAULT_DIFFICULTY: u32 = 4;
|
||||
const SQUARE_HOLE_QUESTION_THEME: &str = "你想做什么题材";
|
||||
const SQUARE_HOLE_ENTITY_KIND: &str = "square_hole_work";
|
||||
const SQUARE_HOLE_COVER_IMAGE_KIND: &str = "square_hole_cover_image";
|
||||
const SQUARE_HOLE_BACKGROUND_IMAGE_KIND: &str = "square_hole_background_image";
|
||||
const SQUARE_HOLE_SHAPE_IMAGE_KIND: &str = "square_hole_shape_image";
|
||||
const SQUARE_HOLE_HOLE_IMAGE_KIND: &str = "square_hole_hole_image";
|
||||
|
||||
#[derive(Clone, Debug, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
@@ -104,6 +117,8 @@ struct SquareHoleConfigShapeOptionJson {
|
||||
option_id: String,
|
||||
shape_kind: String,
|
||||
label: String,
|
||||
#[serde(default)]
|
||||
target_hole_id: String,
|
||||
image_prompt: String,
|
||||
#[serde(default, deserialize_with = "deserialize_optional_string_as_default")]
|
||||
image_src: String,
|
||||
@@ -116,7 +131,9 @@ struct SquareHoleConfigHoleOptionJson {
|
||||
hole_kind: String,
|
||||
label: String,
|
||||
#[serde(default)]
|
||||
bonus: bool,
|
||||
image_prompt: String,
|
||||
#[serde(default, deserialize_with = "deserialize_optional_string_as_default")]
|
||||
image_src: String,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Deserialize)]
|
||||
@@ -359,6 +376,9 @@ pub async fn execute_square_hole_agent_action(
|
||||
&request_context,
|
||||
&authenticated,
|
||||
session_id,
|
||||
payload.regenerate_visual_assets.unwrap_or(false),
|
||||
payload.visual_asset_slot,
|
||||
payload.visual_asset_option_id,
|
||||
)
|
||||
.await?
|
||||
}
|
||||
@@ -543,18 +563,18 @@ pub async fn put_square_hole_work(
|
||||
.clone()
|
||||
.filter(|value| !value.trim().is_empty())
|
||||
.unwrap_or(existing.theme_text);
|
||||
let shape_options_json = payload
|
||||
.shape_options
|
||||
.clone()
|
||||
.map(square_hole_work_shape_options_to_records)
|
||||
.unwrap_or_else(|| existing.shape_options.clone());
|
||||
let shape_options_json = serialize_square_hole_shape_option_records(&shape_options_json);
|
||||
let hole_options_json = payload
|
||||
let hole_options = payload
|
||||
.hole_options
|
||||
.clone()
|
||||
.map(square_hole_work_hole_options_to_records)
|
||||
.unwrap_or_else(|| existing.hole_options.clone());
|
||||
let hole_options_json = serialize_square_hole_hole_option_records(&hole_options_json);
|
||||
let hole_options_json = serialize_square_hole_hole_option_records(&hole_options);
|
||||
let shape_options = payload
|
||||
.shape_options
|
||||
.clone()
|
||||
.map(|options| square_hole_work_shape_options_to_records(options, hole_options.as_slice()))
|
||||
.unwrap_or_else(|| existing.shape_options.clone());
|
||||
let shape_options_json = serialize_square_hole_shape_option_records(&shape_options);
|
||||
let item = state
|
||||
.spacetime_client()
|
||||
.update_square_hole_work(SquareHoleWorkUpdateRecordInput {
|
||||
@@ -633,6 +653,40 @@ pub async fn publish_square_hole_work(
|
||||
))
|
||||
}
|
||||
|
||||
pub async fn regenerate_square_hole_work_image(
|
||||
State(state): State<AppState>,
|
||||
Path(profile_id): Path<String>,
|
||||
Extension(request_context): Extension<RequestContext>,
|
||||
Extension(authenticated): Extension<AuthenticatedAccessToken>,
|
||||
payload: Result<Json<RegenerateSquareHoleWorkImageRequest>, JsonRejection>,
|
||||
) -> Result<Json<Value>, Response> {
|
||||
let Json(payload) = square_hole_json(payload, &request_context, SQUARE_HOLE_WORKS_PROVIDER)?;
|
||||
ensure_non_empty(
|
||||
&request_context,
|
||||
SQUARE_HOLE_WORKS_PROVIDER,
|
||||
&profile_id,
|
||||
"profileId",
|
||||
)?;
|
||||
|
||||
let owner_user_id = authenticated.claims().user_id().to_string();
|
||||
let item = regenerate_square_hole_visual_asset_for_work(
|
||||
&state,
|
||||
&request_context,
|
||||
owner_user_id,
|
||||
profile_id,
|
||||
payload.visual_asset_slot,
|
||||
payload.visual_asset_option_id,
|
||||
)
|
||||
.await?;
|
||||
|
||||
Ok(json_success_body(
|
||||
Some(&request_context),
|
||||
SquareHoleWorkMutationResponse {
|
||||
item: map_square_hole_work_profile_response(item),
|
||||
},
|
||||
))
|
||||
}
|
||||
|
||||
pub async fn delete_square_hole_work(
|
||||
State(state): State<AppState>,
|
||||
Path(profile_id): Path<String>,
|
||||
@@ -1064,6 +1118,9 @@ async fn generate_square_hole_visual_assets_for_session(
|
||||
request_context: &RequestContext,
|
||||
authenticated: &AuthenticatedAccessToken,
|
||||
session_id: String,
|
||||
regenerate_visual_assets: bool,
|
||||
visual_asset_slot: Option<String>,
|
||||
visual_asset_option_id: Option<String>,
|
||||
) -> Result<SquareHoleAgentSessionRecord, Response> {
|
||||
let owner_user_id = authenticated.claims().user_id().to_string();
|
||||
let session = state
|
||||
@@ -1100,11 +1157,29 @@ async fn generate_square_hole_visual_assets_for_session(
|
||||
)
|
||||
})?;
|
||||
|
||||
let requested_slot = normalize_square_hole_visual_asset_slot(
|
||||
visual_asset_slot.as_deref(),
|
||||
visual_asset_option_id.as_deref(),
|
||||
);
|
||||
|
||||
let cover_image_src = match work.cover_image_src.clone() {
|
||||
Some(value) if !value.trim().is_empty() => Some(value),
|
||||
Some(value)
|
||||
if !should_generate_square_hole_cover_image(
|
||||
requested_slot.as_ref(),
|
||||
regenerate_visual_assets,
|
||||
value.as_str(),
|
||||
) =>
|
||||
{
|
||||
Some(value)
|
||||
}
|
||||
_ => Some(
|
||||
generate_square_hole_image_data_url(
|
||||
state,
|
||||
&owner_user_id,
|
||||
&session_id,
|
||||
profile_id.as_str(),
|
||||
"cover",
|
||||
SQUARE_HOLE_COVER_IMAGE_KIND,
|
||||
build_square_hole_cover_prompt(&work).as_str(),
|
||||
"16:9",
|
||||
"生成方洞挑战封面图失败",
|
||||
@@ -1116,10 +1191,23 @@ async fn generate_square_hole_visual_assets_for_session(
|
||||
),
|
||||
};
|
||||
let background_image_src = match work.background_image_src.clone() {
|
||||
Some(value) if !value.trim().is_empty() => Some(value),
|
||||
Some(value)
|
||||
if !should_generate_square_hole_background_image(
|
||||
requested_slot.as_ref(),
|
||||
regenerate_visual_assets,
|
||||
value.as_str(),
|
||||
) =>
|
||||
{
|
||||
Some(value)
|
||||
}
|
||||
_ => Some(
|
||||
generate_square_hole_image_data_url(
|
||||
state,
|
||||
&owner_user_id,
|
||||
&session_id,
|
||||
profile_id.as_str(),
|
||||
"background",
|
||||
SQUARE_HOLE_BACKGROUND_IMAGE_KIND,
|
||||
build_square_hole_background_prompt(&work).as_str(),
|
||||
"16:9",
|
||||
"生成方洞挑战背景图失败",
|
||||
@@ -1133,18 +1221,21 @@ async fn generate_square_hole_visual_assets_for_session(
|
||||
let mut shape_options = work.shape_options.clone();
|
||||
let prompt_work = work.clone();
|
||||
for option in shape_options.iter_mut() {
|
||||
if option
|
||||
.image_src
|
||||
.as_deref()
|
||||
.map(str::trim)
|
||||
.filter(|value| !value.is_empty())
|
||||
.is_some()
|
||||
{
|
||||
if !should_generate_square_hole_shape_image(
|
||||
requested_slot.as_ref(),
|
||||
regenerate_visual_assets,
|
||||
option,
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
option.image_src = Some(
|
||||
generate_square_hole_image_data_url(
|
||||
state,
|
||||
&owner_user_id,
|
||||
&session_id,
|
||||
profile_id.as_str(),
|
||||
option.option_id.as_str(),
|
||||
SQUARE_HOLE_SHAPE_IMAGE_KIND,
|
||||
build_square_hole_shape_prompt(&prompt_work, option).as_str(),
|
||||
"1:1",
|
||||
"生成方洞挑战形状贴图失败",
|
||||
@@ -1155,6 +1246,33 @@ async fn generate_square_hole_visual_assets_for_session(
|
||||
})?,
|
||||
);
|
||||
}
|
||||
let mut hole_options = work.hole_options.clone();
|
||||
for option in hole_options.iter_mut() {
|
||||
if !should_generate_square_hole_hole_image(
|
||||
requested_slot.as_ref(),
|
||||
regenerate_visual_assets,
|
||||
option,
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
option.image_src = Some(
|
||||
generate_square_hole_image_data_url(
|
||||
state,
|
||||
&owner_user_id,
|
||||
&session_id,
|
||||
profile_id.as_str(),
|
||||
option.hole_id.as_str(),
|
||||
SQUARE_HOLE_HOLE_IMAGE_KIND,
|
||||
build_square_hole_hole_prompt(&prompt_work, option).as_str(),
|
||||
"1:1",
|
||||
"生成方洞挑战洞口贴图失败",
|
||||
)
|
||||
.await
|
||||
.map_err(|error| {
|
||||
square_hole_error_response(request_context, SQUARE_HOLE_AGENT_PROVIDER, error)
|
||||
})?,
|
||||
);
|
||||
}
|
||||
|
||||
work = state
|
||||
.spacetime_client()
|
||||
@@ -1171,7 +1289,7 @@ async fn generate_square_hole_visual_assets_for_session(
|
||||
background_prompt: work.background_prompt.clone(),
|
||||
background_image_src: background_image_src.clone().unwrap_or_default(),
|
||||
shape_options_json: serialize_square_hole_shape_option_records(&shape_options),
|
||||
hole_options_json: serialize_square_hole_hole_option_records(&work.hole_options),
|
||||
hole_options_json: serialize_square_hole_hole_option_records(&hole_options),
|
||||
shape_count: work.shape_count,
|
||||
difficulty: work.difficulty,
|
||||
updated_at_micros: current_utc_micros(),
|
||||
@@ -1206,8 +1324,180 @@ async fn generate_square_hole_visual_assets_for_session(
|
||||
Ok(next_session)
|
||||
}
|
||||
|
||||
async fn regenerate_square_hole_visual_asset_for_work(
|
||||
state: &AppState,
|
||||
request_context: &RequestContext,
|
||||
owner_user_id: String,
|
||||
profile_id: String,
|
||||
visual_asset_slot: String,
|
||||
visual_asset_option_id: Option<String>,
|
||||
) -> Result<SquareHoleWorkProfileRecord, Response> {
|
||||
let mut work = state
|
||||
.spacetime_client()
|
||||
.get_square_hole_work_detail(profile_id.clone(), owner_user_id.clone())
|
||||
.await
|
||||
.map_err(|error| {
|
||||
square_hole_error_response(
|
||||
request_context,
|
||||
SQUARE_HOLE_WORKS_PROVIDER,
|
||||
map_square_hole_client_error(error),
|
||||
)
|
||||
})?;
|
||||
let requested_slot = normalize_square_hole_visual_asset_slot(
|
||||
Some(visual_asset_slot.as_str()),
|
||||
visual_asset_option_id.as_deref(),
|
||||
)
|
||||
.ok_or_else(|| {
|
||||
square_hole_bad_request(
|
||||
request_context,
|
||||
SQUARE_HOLE_WORKS_PROVIDER,
|
||||
"图片槽位不存在",
|
||||
)
|
||||
})?;
|
||||
let synthetic_session_id = work
|
||||
.source_session_id
|
||||
.clone()
|
||||
.filter(|value| !value.trim().is_empty())
|
||||
.unwrap_or_else(|| profile_id.clone());
|
||||
let prompt_work = work.clone();
|
||||
match &requested_slot {
|
||||
SquareHoleVisualAssetSlotRequest::Cover => {
|
||||
work.cover_image_src = Some(
|
||||
generate_square_hole_image_data_url(
|
||||
state,
|
||||
owner_user_id.as_str(),
|
||||
synthetic_session_id.as_str(),
|
||||
profile_id.as_str(),
|
||||
"cover",
|
||||
SQUARE_HOLE_COVER_IMAGE_KIND,
|
||||
build_square_hole_cover_prompt(&prompt_work).as_str(),
|
||||
"16:9",
|
||||
"生成方洞挑战封面图失败",
|
||||
)
|
||||
.await
|
||||
.map_err(|error| {
|
||||
square_hole_error_response(request_context, SQUARE_HOLE_WORKS_PROVIDER, error)
|
||||
})?,
|
||||
);
|
||||
}
|
||||
SquareHoleVisualAssetSlotRequest::Background => {
|
||||
work.background_image_src = Some(
|
||||
generate_square_hole_image_data_url(
|
||||
state,
|
||||
owner_user_id.as_str(),
|
||||
synthetic_session_id.as_str(),
|
||||
profile_id.as_str(),
|
||||
"background",
|
||||
SQUARE_HOLE_BACKGROUND_IMAGE_KIND,
|
||||
build_square_hole_background_prompt(&prompt_work).as_str(),
|
||||
"16:9",
|
||||
"生成方洞挑战背景图失败",
|
||||
)
|
||||
.await
|
||||
.map_err(|error| {
|
||||
square_hole_error_response(request_context, SQUARE_HOLE_WORKS_PROVIDER, error)
|
||||
})?,
|
||||
);
|
||||
}
|
||||
SquareHoleVisualAssetSlotRequest::Shape(option_id) => {
|
||||
let Some(option) = work
|
||||
.shape_options
|
||||
.iter_mut()
|
||||
.find(|option| option.option_id == *option_id)
|
||||
else {
|
||||
return Err(square_hole_bad_request(
|
||||
request_context,
|
||||
SQUARE_HOLE_WORKS_PROVIDER,
|
||||
"形状图片槽位不存在",
|
||||
));
|
||||
};
|
||||
option.image_src = Some(
|
||||
generate_square_hole_image_data_url(
|
||||
state,
|
||||
owner_user_id.as_str(),
|
||||
synthetic_session_id.as_str(),
|
||||
profile_id.as_str(),
|
||||
option.option_id.as_str(),
|
||||
SQUARE_HOLE_SHAPE_IMAGE_KIND,
|
||||
build_square_hole_shape_prompt(&prompt_work, option).as_str(),
|
||||
"1:1",
|
||||
"生成方洞挑战形状贴图失败",
|
||||
)
|
||||
.await
|
||||
.map_err(|error| {
|
||||
square_hole_error_response(request_context, SQUARE_HOLE_WORKS_PROVIDER, error)
|
||||
})?,
|
||||
);
|
||||
}
|
||||
SquareHoleVisualAssetSlotRequest::Hole(hole_id) => {
|
||||
let Some(option) = work
|
||||
.hole_options
|
||||
.iter_mut()
|
||||
.find(|option| option.hole_id == *hole_id)
|
||||
else {
|
||||
return Err(square_hole_bad_request(
|
||||
request_context,
|
||||
SQUARE_HOLE_WORKS_PROVIDER,
|
||||
"洞口图片槽位不存在",
|
||||
));
|
||||
};
|
||||
option.image_src = Some(
|
||||
generate_square_hole_image_data_url(
|
||||
state,
|
||||
owner_user_id.as_str(),
|
||||
synthetic_session_id.as_str(),
|
||||
profile_id.as_str(),
|
||||
option.hole_id.as_str(),
|
||||
SQUARE_HOLE_HOLE_IMAGE_KIND,
|
||||
build_square_hole_hole_prompt(&prompt_work, option).as_str(),
|
||||
"1:1",
|
||||
"生成方洞挑战洞口贴图失败",
|
||||
)
|
||||
.await
|
||||
.map_err(|error| {
|
||||
square_hole_error_response(request_context, SQUARE_HOLE_WORKS_PROVIDER, error)
|
||||
})?,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
state
|
||||
.spacetime_client()
|
||||
.update_square_hole_work(SquareHoleWorkUpdateRecordInput {
|
||||
profile_id,
|
||||
owner_user_id,
|
||||
game_name: work.game_name.clone(),
|
||||
theme_text: work.theme_text.clone(),
|
||||
twist_rule: work.twist_rule.clone(),
|
||||
summary_text: work.summary.clone(),
|
||||
tags_json: serde_json::to_string(&normalize_tags(work.tags.clone()))
|
||||
.unwrap_or_default(),
|
||||
cover_image_src: work.cover_image_src.clone().unwrap_or_default(),
|
||||
background_prompt: work.background_prompt.clone(),
|
||||
background_image_src: work.background_image_src.clone().unwrap_or_default(),
|
||||
shape_options_json: serialize_square_hole_shape_option_records(&work.shape_options),
|
||||
hole_options_json: serialize_square_hole_hole_option_records(&work.hole_options),
|
||||
shape_count: work.shape_count,
|
||||
difficulty: work.difficulty,
|
||||
updated_at_micros: current_utc_micros(),
|
||||
})
|
||||
.await
|
||||
.map_err(|error| {
|
||||
square_hole_error_response(
|
||||
request_context,
|
||||
SQUARE_HOLE_WORKS_PROVIDER,
|
||||
map_square_hole_client_error(error),
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
async fn generate_square_hole_image_data_url(
|
||||
state: &AppState,
|
||||
owner_user_id: &str,
|
||||
session_id: &str,
|
||||
profile_id: &str,
|
||||
slot: &str,
|
||||
asset_kind: &str,
|
||||
prompt: &str,
|
||||
size: &str,
|
||||
failure_context: &str,
|
||||
@@ -1232,11 +1522,220 @@ async fn generate_square_hole_image_data_url(
|
||||
}))
|
||||
})?;
|
||||
|
||||
Ok(format!(
|
||||
let fallback_data_url = format_square_hole_data_url(&image);
|
||||
match persist_square_hole_generated_asset(
|
||||
state,
|
||||
owner_user_id,
|
||||
session_id,
|
||||
profile_id,
|
||||
slot,
|
||||
asset_kind,
|
||||
generated.task_id.as_str(),
|
||||
image,
|
||||
current_utc_micros(),
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(image_src) => Ok(image_src),
|
||||
Err(error) => {
|
||||
tracing::warn!(
|
||||
provider = "square-hole-assets",
|
||||
owner_user_id,
|
||||
session_id,
|
||||
profile_id,
|
||||
slot,
|
||||
asset_kind,
|
||||
message = %error.body_text(),
|
||||
"方洞图片已生成但资产持久化失败,降级回写 Data URL"
|
||||
);
|
||||
Ok(fallback_data_url)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn format_square_hole_data_url(image: &DownloadedOpenAiImage) -> String {
|
||||
format!(
|
||||
"data:{};base64,{}",
|
||||
image.mime_type,
|
||||
BASE64_STANDARD.encode(image.bytes)
|
||||
))
|
||||
BASE64_STANDARD.encode(&image.bytes)
|
||||
)
|
||||
}
|
||||
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
async fn persist_square_hole_generated_asset(
|
||||
state: &AppState,
|
||||
owner_user_id: &str,
|
||||
session_id: &str,
|
||||
profile_id: &str,
|
||||
slot: &str,
|
||||
asset_kind: &str,
|
||||
task_id: &str,
|
||||
image: DownloadedOpenAiImage,
|
||||
generated_at_micros: i64,
|
||||
) -> Result<String, AppError> {
|
||||
let oss_client = state.oss_client().ok_or_else(|| {
|
||||
AppError::from_status(StatusCode::SERVICE_UNAVAILABLE).with_details(json!({
|
||||
"provider": "aliyun-oss",
|
||||
"reason": "OSS 未完成环境变量配置",
|
||||
}))
|
||||
})?;
|
||||
let http_client = reqwest::Client::new();
|
||||
let storage_slot = sanitize_square_hole_asset_segment(slot, "slot");
|
||||
let put_result = oss_client
|
||||
.put_object(
|
||||
&http_client,
|
||||
OssPutObjectRequest {
|
||||
prefix: LegacyAssetPrefix::SquareHoleAssets,
|
||||
path_segments: vec![
|
||||
sanitize_square_hole_asset_segment(session_id, "session"),
|
||||
sanitize_square_hole_asset_segment(profile_id, "profile"),
|
||||
sanitize_square_hole_asset_segment(asset_kind, "asset"),
|
||||
storage_slot.clone(),
|
||||
format!("asset-{generated_at_micros}"),
|
||||
],
|
||||
file_name: format!("image.{}", image.extension),
|
||||
content_type: Some(image.mime_type.clone()),
|
||||
access: OssObjectAccess::Private,
|
||||
metadata: build_square_hole_asset_metadata(
|
||||
asset_kind,
|
||||
owner_user_id,
|
||||
profile_id,
|
||||
slot,
|
||||
),
|
||||
body: image.bytes,
|
||||
},
|
||||
)
|
||||
.await
|
||||
.map_err(map_square_hole_asset_oss_error)?;
|
||||
let head = oss_client
|
||||
.head_object(
|
||||
&http_client,
|
||||
OssHeadObjectRequest {
|
||||
object_key: put_result.object_key.clone(),
|
||||
},
|
||||
)
|
||||
.await
|
||||
.map_err(map_square_hole_asset_oss_error)?;
|
||||
|
||||
match state
|
||||
.spacetime_client()
|
||||
.confirm_asset_object(
|
||||
build_asset_object_upsert_input(
|
||||
generate_asset_object_id(generated_at_micros),
|
||||
head.bucket,
|
||||
head.object_key,
|
||||
AssetObjectAccessPolicy::Private,
|
||||
head.content_type.or(Some(image.mime_type)),
|
||||
head.content_length,
|
||||
head.etag,
|
||||
asset_kind.to_string(),
|
||||
Some(task_id.to_string()),
|
||||
Some(owner_user_id.to_string()),
|
||||
Some(profile_id.to_string()),
|
||||
Some(profile_id.to_string()),
|
||||
generated_at_micros,
|
||||
)
|
||||
.map_err(map_square_hole_asset_field_error)?,
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(asset_object) => {
|
||||
if let Err(error) = state
|
||||
.spacetime_client()
|
||||
.bind_asset_object_to_entity(
|
||||
build_asset_entity_binding_input(
|
||||
generate_asset_binding_id(generated_at_micros),
|
||||
asset_object.asset_object_id,
|
||||
SQUARE_HOLE_ENTITY_KIND.to_string(),
|
||||
profile_id.to_string(),
|
||||
slot.to_string(),
|
||||
asset_kind.to_string(),
|
||||
Some(owner_user_id.to_string()),
|
||||
Some(profile_id.to_string()),
|
||||
generated_at_micros,
|
||||
)
|
||||
.map_err(map_square_hole_asset_field_error)?,
|
||||
)
|
||||
.await
|
||||
{
|
||||
tracing::warn!(
|
||||
provider = "spacetimedb",
|
||||
owner_user_id,
|
||||
session_id,
|
||||
profile_id,
|
||||
slot,
|
||||
asset_kind,
|
||||
error = %error,
|
||||
"方洞图片资产绑定失败,历史素材索引可能缺少绑定记录"
|
||||
);
|
||||
}
|
||||
}
|
||||
Err(error) => {
|
||||
tracing::warn!(
|
||||
provider = "spacetimedb",
|
||||
owner_user_id,
|
||||
session_id,
|
||||
profile_id,
|
||||
slot,
|
||||
asset_kind,
|
||||
error = %error,
|
||||
"方洞图片资产对象确认失败,历史素材索引可能缺少本次记录"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Ok(put_result.legacy_public_path)
|
||||
}
|
||||
|
||||
fn build_square_hole_asset_metadata(
|
||||
asset_kind: &str,
|
||||
owner_user_id: &str,
|
||||
profile_id: &str,
|
||||
slot: &str,
|
||||
) -> BTreeMap<String, String> {
|
||||
BTreeMap::from([
|
||||
("asset_kind".to_string(), asset_kind.to_string()),
|
||||
("owner_user_id".to_string(), owner_user_id.to_string()),
|
||||
("profile_id".to_string(), profile_id.to_string()),
|
||||
(
|
||||
"entity_kind".to_string(),
|
||||
SQUARE_HOLE_ENTITY_KIND.to_string(),
|
||||
),
|
||||
("entity_id".to_string(), profile_id.to_string()),
|
||||
("slot".to_string(), slot.to_string()),
|
||||
])
|
||||
}
|
||||
|
||||
fn map_square_hole_asset_oss_error(error: platform_oss::OssError) -> AppError {
|
||||
map_oss_error(error, "aliyun-oss")
|
||||
}
|
||||
|
||||
fn map_square_hole_asset_field_error(error: AssetObjectFieldError) -> AppError {
|
||||
AppError::from_status(StatusCode::INTERNAL_SERVER_ERROR).with_details(json!({
|
||||
"provider": "square-hole-assets",
|
||||
"message": error.to_string(),
|
||||
}))
|
||||
}
|
||||
|
||||
fn sanitize_square_hole_asset_segment(value: &str, fallback: &str) -> String {
|
||||
let sanitized = value
|
||||
.trim()
|
||||
.chars()
|
||||
.map(|ch| {
|
||||
if ch.is_ascii_alphanumeric() || ('\u{4e00}'..='\u{9fff}').contains(&ch) {
|
||||
ch
|
||||
} else {
|
||||
'-'
|
||||
}
|
||||
})
|
||||
.collect::<String>()
|
||||
.trim_matches('-')
|
||||
.to_string();
|
||||
if sanitized.is_empty() {
|
||||
fallback.to_string()
|
||||
} else {
|
||||
sanitized
|
||||
}
|
||||
}
|
||||
|
||||
fn build_square_hole_cover_prompt(work: &SquareHoleWorkProfileRecord) -> String {
|
||||
@@ -1280,6 +1779,24 @@ fn build_square_hole_shape_prompt(
|
||||
)
|
||||
}
|
||||
|
||||
fn build_square_hole_hole_prompt(
|
||||
work: &SquareHoleWorkProfileRecord,
|
||||
option: &SquareHoleHoleOptionRecord,
|
||||
) -> String {
|
||||
let image_prompt = option.image_prompt.trim();
|
||||
let option_prompt = if image_prompt.is_empty() {
|
||||
format!("{} 主题的 {}", work.theme_text, option.label)
|
||||
} else {
|
||||
image_prompt.to_string()
|
||||
};
|
||||
|
||||
format!(
|
||||
"单个游戏洞口贴图,透明或干净浅色背景。洞口名称:{}。主题贴图:{}。要求主体居中、边缘清晰、适合放在可接收拖拽形状的洞口平面上,不要文字、不要 UI、不要水印。",
|
||||
clean_prompt_text(&option.label, "洞口"),
|
||||
clean_prompt_text(&option_prompt, "主题洞口")
|
||||
)
|
||||
}
|
||||
|
||||
fn build_square_hole_negative_prompt() -> String {
|
||||
"文字、水印、复杂 UI、真实人物、恐怖血腥、低清晰度、过度模糊、主体被裁切、多个主体".to_string()
|
||||
}
|
||||
@@ -1518,6 +2035,7 @@ fn map_square_hole_shape_response(
|
||||
shape_id: item.shape_id,
|
||||
shape_kind: item.shape_kind,
|
||||
label: item.label,
|
||||
target_hole_id: item.target_hole_id,
|
||||
color: item.color,
|
||||
image_src: item.image_src,
|
||||
}
|
||||
@@ -1532,7 +2050,7 @@ fn map_square_hole_hole_response(
|
||||
label: slot.label,
|
||||
x: slot.x,
|
||||
y: slot.y,
|
||||
bonus: slot.bonus,
|
||||
image_src: slot.image_src,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1543,6 +2061,7 @@ fn map_square_hole_shape_option_response(
|
||||
option_id: item.option_id,
|
||||
shape_kind: item.shape_kind,
|
||||
label: item.label,
|
||||
target_hole_id: item.target_hole_id,
|
||||
image_prompt: item.image_prompt,
|
||||
image_src: item.image_src,
|
||||
}
|
||||
@@ -1555,7 +2074,8 @@ fn map_square_hole_hole_option_response(
|
||||
hole_id: item.hole_id,
|
||||
hole_kind: item.hole_kind,
|
||||
label: item.label,
|
||||
bonus: item.bonus,
|
||||
image_prompt: item.image_prompt,
|
||||
image_src: item.image_src,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1566,6 +2086,7 @@ fn map_square_hole_work_shape_option_response(
|
||||
option_id: item.option_id,
|
||||
shape_kind: item.shape_kind,
|
||||
label: item.label,
|
||||
target_hole_id: item.target_hole_id,
|
||||
image_prompt: item.image_prompt,
|
||||
image_src: item.image_src,
|
||||
}
|
||||
@@ -1578,7 +2099,8 @@ fn map_square_hole_work_hole_option_response(
|
||||
hole_id: item.hole_id,
|
||||
hole_kind: item.hole_kind,
|
||||
label: item.label,
|
||||
bonus: item.bonus,
|
||||
image_prompt: item.image_prompt,
|
||||
image_src: item.image_src,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1595,15 +2117,16 @@ fn map_square_hole_feedback_response(
|
||||
fn build_config_from_create_request(
|
||||
payload: &CreateSquareHoleSessionRequest,
|
||||
) -> SquareHoleConfigJson {
|
||||
let theme_text = payload
|
||||
.theme_text
|
||||
.as_deref()
|
||||
.or(payload.seed_text.as_deref())
|
||||
.map(str::trim)
|
||||
.filter(|value| !value.is_empty())
|
||||
.unwrap_or(SQUARE_HOLE_DEFAULT_THEME);
|
||||
let hole_options = normalize_hole_options(Vec::new(), theme_text);
|
||||
SquareHoleConfigJson {
|
||||
theme_text: payload
|
||||
.theme_text
|
||||
.as_deref()
|
||||
.or(payload.seed_text.as_deref())
|
||||
.map(str::trim)
|
||||
.filter(|value| !value.is_empty())
|
||||
.unwrap_or(SQUARE_HOLE_DEFAULT_THEME)
|
||||
.to_string(),
|
||||
theme_text: theme_text.to_string(),
|
||||
twist_rule: payload
|
||||
.twist_rule
|
||||
.as_deref()
|
||||
@@ -1621,20 +2144,11 @@ fn build_config_from_create_request(
|
||||
.clamp(1, 10),
|
||||
shape_options: square_hole_shape_records_to_config_json(normalize_shape_options(
|
||||
Vec::new(),
|
||||
payload
|
||||
.theme_text
|
||||
.as_deref()
|
||||
.or(payload.seed_text.as_deref())
|
||||
.unwrap_or(SQUARE_HOLE_DEFAULT_THEME),
|
||||
theme_text,
|
||||
hole_options.as_slice(),
|
||||
)),
|
||||
hole_options: square_hole_hole_records_to_config_json(normalize_hole_options(Vec::new())),
|
||||
background_prompt: default_background_prompt(
|
||||
payload
|
||||
.theme_text
|
||||
.as_deref()
|
||||
.or(payload.seed_text.as_deref())
|
||||
.unwrap_or(SQUARE_HOLE_DEFAULT_THEME),
|
||||
),
|
||||
hole_options: square_hole_hole_records_to_config_json(hole_options),
|
||||
background_prompt: default_background_prompt(theme_text),
|
||||
cover_image_src: String::new(),
|
||||
background_image_src: String::new(),
|
||||
}
|
||||
@@ -1660,12 +2174,17 @@ fn resolve_config_or_default(
|
||||
twist_rule: SQUARE_HOLE_DEFAULT_TWIST_RULE.to_string(),
|
||||
shape_count: SQUARE_HOLE_DEFAULT_SHAPE_COUNT,
|
||||
difficulty: SQUARE_HOLE_DEFAULT_DIFFICULTY,
|
||||
shape_options: square_hole_shape_records_to_config_json(normalize_shape_options(
|
||||
Vec::new(),
|
||||
SQUARE_HOLE_DEFAULT_THEME,
|
||||
)),
|
||||
shape_options: {
|
||||
let hole_options = normalize_hole_options(Vec::new(), SQUARE_HOLE_DEFAULT_THEME);
|
||||
square_hole_shape_records_to_config_json(normalize_shape_options(
|
||||
Vec::new(),
|
||||
SQUARE_HOLE_DEFAULT_THEME,
|
||||
hole_options.as_slice(),
|
||||
))
|
||||
},
|
||||
hole_options: square_hole_hole_records_to_config_json(normalize_hole_options(
|
||||
Vec::new(),
|
||||
SQUARE_HOLE_DEFAULT_THEME,
|
||||
)),
|
||||
background_prompt: default_background_prompt(SQUARE_HOLE_DEFAULT_THEME),
|
||||
cover_image_src: String::new(),
|
||||
@@ -1730,13 +2249,23 @@ fn square_hole_hole_records_to_config_json(
|
||||
|
||||
fn square_hole_work_shape_options_to_records(
|
||||
options: Vec<SquareHoleWorkShapeOptionResponse>,
|
||||
hole_options: &[SquareHoleHoleOptionRecord],
|
||||
) -> Vec<SquareHoleShapeOptionRecord> {
|
||||
let fallback_hole_id = hole_options
|
||||
.first()
|
||||
.map(|option| option.hole_id.clone())
|
||||
.unwrap_or_else(|| "hole-1".to_string());
|
||||
options
|
||||
.into_iter()
|
||||
.map(|option| SquareHoleShapeOptionRecord {
|
||||
option_id: option.option_id,
|
||||
shape_kind: option.shape_kind,
|
||||
label: option.label,
|
||||
target_hole_id: hole_options
|
||||
.iter()
|
||||
.find(|hole| hole.hole_id == option.target_hole_id)
|
||||
.map(|hole| hole.hole_id.clone())
|
||||
.unwrap_or_else(|| fallback_hole_id.clone()),
|
||||
image_prompt: option.image_prompt,
|
||||
image_src: option.image_src.filter(|value| !value.trim().is_empty()),
|
||||
})
|
||||
@@ -1752,7 +2281,8 @@ fn square_hole_work_hole_options_to_records(
|
||||
hole_id: option.hole_id,
|
||||
hole_kind: option.hole_kind,
|
||||
label: option.label,
|
||||
bonus: option.bonus,
|
||||
image_prompt: option.image_prompt,
|
||||
image_src: option.image_src.filter(|value| !value.trim().is_empty()),
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
@@ -1782,12 +2312,104 @@ fn clean_prompt_text(value: &str, fallback: &str) -> String {
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
enum SquareHoleVisualAssetSlotRequest {
|
||||
Cover,
|
||||
Background,
|
||||
Shape(String),
|
||||
Hole(String),
|
||||
}
|
||||
|
||||
fn normalize_square_hole_visual_asset_slot(
|
||||
slot: Option<&str>,
|
||||
option_id: Option<&str>,
|
||||
) -> Option<SquareHoleVisualAssetSlotRequest> {
|
||||
match slot.map(str::trim).unwrap_or_default() {
|
||||
"cover" => Some(SquareHoleVisualAssetSlotRequest::Cover),
|
||||
"background" => Some(SquareHoleVisualAssetSlotRequest::Background),
|
||||
"shape" => option_id
|
||||
.map(str::trim)
|
||||
.filter(|value| !value.is_empty())
|
||||
.map(|value| SquareHoleVisualAssetSlotRequest::Shape(value.to_string())),
|
||||
"hole" => option_id
|
||||
.map(str::trim)
|
||||
.filter(|value| !value.is_empty())
|
||||
.map(|value| SquareHoleVisualAssetSlotRequest::Hole(value.to_string())),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
fn should_generate_square_hole_cover_image(
|
||||
requested_slot: Option<&SquareHoleVisualAssetSlotRequest>,
|
||||
regenerate_visual_assets: bool,
|
||||
current_image_src: &str,
|
||||
) -> bool {
|
||||
matches!(
|
||||
requested_slot,
|
||||
Some(SquareHoleVisualAssetSlotRequest::Cover)
|
||||
) || (requested_slot.is_none()
|
||||
&& (regenerate_visual_assets || current_image_src.trim().is_empty()))
|
||||
}
|
||||
|
||||
fn should_generate_square_hole_background_image(
|
||||
requested_slot: Option<&SquareHoleVisualAssetSlotRequest>,
|
||||
regenerate_visual_assets: bool,
|
||||
current_image_src: &str,
|
||||
) -> bool {
|
||||
matches!(
|
||||
requested_slot,
|
||||
Some(SquareHoleVisualAssetSlotRequest::Background)
|
||||
) || (requested_slot.is_none()
|
||||
&& (regenerate_visual_assets || current_image_src.trim().is_empty()))
|
||||
}
|
||||
|
||||
fn should_generate_square_hole_shape_image(
|
||||
requested_slot: Option<&SquareHoleVisualAssetSlotRequest>,
|
||||
regenerate_visual_assets: bool,
|
||||
option: &SquareHoleShapeOptionRecord,
|
||||
) -> bool {
|
||||
match requested_slot {
|
||||
Some(SquareHoleVisualAssetSlotRequest::Shape(option_id)) => option.option_id == *option_id,
|
||||
Some(_) => false,
|
||||
None => {
|
||||
regenerate_visual_assets
|
||||
|| option
|
||||
.image_src
|
||||
.as_deref()
|
||||
.map(str::trim)
|
||||
.filter(|value| !value.is_empty())
|
||||
.is_none()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn should_generate_square_hole_hole_image(
|
||||
requested_slot: Option<&SquareHoleVisualAssetSlotRequest>,
|
||||
regenerate_visual_assets: bool,
|
||||
option: &SquareHoleHoleOptionRecord,
|
||||
) -> bool {
|
||||
match requested_slot {
|
||||
Some(SquareHoleVisualAssetSlotRequest::Hole(hole_id)) => option.hole_id == *hole_id,
|
||||
Some(_) => false,
|
||||
None => {
|
||||
regenerate_visual_assets
|
||||
|| option
|
||||
.image_src
|
||||
.as_deref()
|
||||
.map(str::trim)
|
||||
.filter(|value| !value.is_empty())
|
||||
.is_none()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl From<module_square_hole::SquareHoleShapeOption> for SquareHoleConfigShapeOptionJson {
|
||||
fn from(option: module_square_hole::SquareHoleShapeOption) -> Self {
|
||||
Self {
|
||||
option_id: option.option_id,
|
||||
shape_kind: option.shape_kind,
|
||||
label: option.label,
|
||||
target_hole_id: option.target_hole_id,
|
||||
image_prompt: option.image_prompt,
|
||||
image_src: option.image_src.unwrap_or_default(),
|
||||
}
|
||||
@@ -1800,6 +2422,7 @@ impl From<SquareHoleShapeOptionRecord> for SquareHoleConfigShapeOptionJson {
|
||||
option_id: option.option_id,
|
||||
shape_kind: option.shape_kind,
|
||||
label: option.label,
|
||||
target_hole_id: option.target_hole_id,
|
||||
image_prompt: option.image_prompt,
|
||||
image_src: option.image_src.unwrap_or_default(),
|
||||
}
|
||||
@@ -1812,7 +2435,8 @@ impl From<module_square_hole::SquareHoleHoleOption> for SquareHoleConfigHoleOpti
|
||||
hole_id: option.hole_id,
|
||||
hole_kind: option.hole_kind,
|
||||
label: option.label,
|
||||
bonus: option.bonus,
|
||||
image_prompt: option.image_prompt,
|
||||
image_src: option.image_src.unwrap_or_default(),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1823,7 +2447,8 @@ impl From<SquareHoleHoleOptionRecord> for SquareHoleConfigHoleOptionJson {
|
||||
hole_id: option.hole_id,
|
||||
hole_kind: option.hole_kind,
|
||||
label: option.label,
|
||||
bonus: option.bonus,
|
||||
image_prompt: option.image_prompt,
|
||||
image_src: option.image_src.unwrap_or_default(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -84,6 +84,7 @@ struct SquareHoleAgentShapeOptionOutput {
|
||||
option_id: String,
|
||||
shape_kind: String,
|
||||
label: String,
|
||||
target_hole_id: String,
|
||||
image_prompt: String,
|
||||
#[serde(default)]
|
||||
image_src: String,
|
||||
@@ -95,8 +96,9 @@ struct SquareHoleAgentHoleOptionOutput {
|
||||
hole_id: String,
|
||||
hole_kind: String,
|
||||
label: String,
|
||||
image_prompt: String,
|
||||
#[serde(default)]
|
||||
bonus: bool,
|
||||
image_src: String,
|
||||
}
|
||||
|
||||
pub(crate) async fn run_square_hole_agent_turn<F>(
|
||||
@@ -195,12 +197,12 @@ fn parse_model_config(
|
||||
));
|
||||
}
|
||||
|
||||
let theme_text = read_text_field(value, "themeText")
|
||||
.unwrap_or_else(|| session.config.theme_text.clone());
|
||||
let twist_rule = read_text_field(value, "twistRule")
|
||||
.unwrap_or_else(|| session.config.twist_rule.clone());
|
||||
let shape_options = parse_shape_options(value, session, &theme_text);
|
||||
let hole_options = parse_hole_options(value, session);
|
||||
let theme_text =
|
||||
read_text_field(value, "themeText").unwrap_or_else(|| session.config.theme_text.clone());
|
||||
let twist_rule =
|
||||
read_text_field(value, "twistRule").unwrap_or_else(|| session.config.twist_rule.clone());
|
||||
let hole_options = parse_hole_options(value, session, &theme_text);
|
||||
let shape_options = parse_shape_options(value, session, &theme_text, hole_options.as_slice());
|
||||
let background_prompt = read_text_field(value, "backgroundPrompt")
|
||||
.or_else(|| {
|
||||
session
|
||||
@@ -243,6 +245,7 @@ fn parse_shape_options(
|
||||
value: &JsonValue,
|
||||
session: &SquareHoleAgentSessionRecord,
|
||||
theme_text: &str,
|
||||
hole_options: &[SquareHoleHoleOption],
|
||||
) -> Vec<SquareHoleShapeOption> {
|
||||
let parsed = value
|
||||
.get("shapeOptions")
|
||||
@@ -258,8 +261,19 @@ fn parse_shape_options(
|
||||
.unwrap_or_else(|| fallback_shape_kind(index).to_string()),
|
||||
label: read_text_field(item, "label")
|
||||
.unwrap_or_else(|| fallback_shape_label(index).to_string()),
|
||||
target_hole_id: read_text_field(item, "targetHoleId")
|
||||
.filter(|value| hole_options.iter().any(|option| option.hole_id == *value))
|
||||
.unwrap_or_else(|| {
|
||||
hole_options
|
||||
.get(index % hole_options.len().max(1))
|
||||
.map(|option| option.hole_id.clone())
|
||||
.unwrap_or_else(|| fallback_target_hole_id(index).to_string())
|
||||
}),
|
||||
image_prompt: read_text_field(item, "imagePrompt").unwrap_or_else(|| {
|
||||
format!("{theme_text}主题的{}贴纸图,透明背景,明亮游戏资产", fallback_shape_label(index))
|
||||
format!(
|
||||
"{theme_text}主题的{}贴纸图,透明背景,明亮游戏资产",
|
||||
fallback_shape_label(index)
|
||||
)
|
||||
}),
|
||||
image_src: read_text_field(item, "imageSrc"),
|
||||
})
|
||||
@@ -274,18 +288,20 @@ fn parse_shape_options(
|
||||
option_id: option.option_id.clone(),
|
||||
shape_kind: option.shape_kind.clone(),
|
||||
label: option.label.clone(),
|
||||
target_hole_id: option.target_hole_id.clone(),
|
||||
image_prompt: option.image_prompt.clone(),
|
||||
image_src: option.image_src.clone(),
|
||||
})
|
||||
.collect()
|
||||
});
|
||||
|
||||
normalize_shape_options(parsed, theme_text)
|
||||
normalize_shape_options(parsed, theme_text, hole_options)
|
||||
}
|
||||
|
||||
fn parse_hole_options(
|
||||
value: &JsonValue,
|
||||
session: &SquareHoleAgentSessionRecord,
|
||||
theme_text: &str,
|
||||
) -> Vec<SquareHoleHoleOption> {
|
||||
let parsed = value
|
||||
.get("holeOptions")
|
||||
@@ -298,13 +314,16 @@ fn parse_hole_options(
|
||||
hole_id: read_text_field(item, "holeId")
|
||||
.unwrap_or_else(|| format!("hole-option-{index}")),
|
||||
hole_kind: read_text_field(item, "holeKind")
|
||||
.unwrap_or_else(|| fallback_shape_kind(index).to_string()),
|
||||
.unwrap_or_else(|| format!("hole-{}", index + 1)),
|
||||
label: read_text_field(item, "label")
|
||||
.unwrap_or_else(|| fallback_hole_label(index).to_string()),
|
||||
bonus: item
|
||||
.get("bonus")
|
||||
.and_then(JsonValue::as_bool)
|
||||
.unwrap_or(index == 0),
|
||||
image_prompt: read_text_field(item, "imagePrompt").unwrap_or_else(|| {
|
||||
format!(
|
||||
"{theme_text}主题的{}贴纸图,透明背景,明亮游戏资产",
|
||||
fallback_hole_label(index)
|
||||
)
|
||||
}),
|
||||
image_src: read_text_field(item, "imageSrc"),
|
||||
})
|
||||
.collect::<Vec<_>>()
|
||||
})
|
||||
@@ -317,12 +336,13 @@ fn parse_hole_options(
|
||||
hole_id: option.hole_id.clone(),
|
||||
hole_kind: option.hole_kind.clone(),
|
||||
label: option.label.clone(),
|
||||
bonus: option.bonus,
|
||||
image_prompt: option.image_prompt.clone(),
|
||||
image_src: option.image_src.clone(),
|
||||
})
|
||||
.collect()
|
||||
});
|
||||
|
||||
normalize_hole_options(parsed)
|
||||
normalize_hole_options(parsed, theme_text)
|
||||
}
|
||||
|
||||
fn read_text_field(value: &JsonValue, field_name: &str) -> Option<String> {
|
||||
@@ -363,14 +383,15 @@ fn fallback_shape_label(index: usize) -> &'static str {
|
||||
}
|
||||
}
|
||||
|
||||
fn fallback_hole_label(index: usize) -> &'static str {
|
||||
match fallback_shape_kind(index) {
|
||||
"square" => "方洞",
|
||||
"circle" => "圆洞",
|
||||
"triangle" => "三角洞",
|
||||
"diamond" => "菱形洞",
|
||||
"star" => "星形洞",
|
||||
_ => "拱形洞",
|
||||
fn fallback_hole_label(index: usize) -> String {
|
||||
format!("洞口 {}", index + 1)
|
||||
}
|
||||
|
||||
fn fallback_target_hole_id(index: usize) -> &'static str {
|
||||
match index % 3 {
|
||||
0 => "hole-1",
|
||||
1 => "hole-2",
|
||||
_ => "hole-3",
|
||||
}
|
||||
}
|
||||
|
||||
@@ -380,6 +401,7 @@ impl From<SquareHoleShapeOption> for SquareHoleAgentShapeOptionOutput {
|
||||
option_id: option.option_id,
|
||||
shape_kind: option.shape_kind,
|
||||
label: option.label,
|
||||
target_hole_id: option.target_hole_id,
|
||||
image_prompt: option.image_prompt,
|
||||
image_src: option.image_src.unwrap_or_default(),
|
||||
}
|
||||
@@ -392,7 +414,8 @@ impl From<SquareHoleHoleOption> for SquareHoleAgentHoleOptionOutput {
|
||||
hole_id: option.hole_id,
|
||||
hole_kind: option.hole_kind,
|
||||
label: option.label,
|
||||
bonus: option.bonus,
|
||||
image_prompt: option.image_prompt,
|
||||
image_src: option.image_src.unwrap_or_default(),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -472,15 +495,16 @@ mod tests {
|
||||
"optionId": "stamp",
|
||||
"shapeKind": "circle",
|
||||
"label": "圆形印章",
|
||||
"targetHoleId": "folder",
|
||||
"imagePrompt": "办公室圆形印章贴纸"
|
||||
}
|
||||
],
|
||||
"holeOptions": [
|
||||
{
|
||||
"holeId": "folder",
|
||||
"holeKind": "square",
|
||||
"holeKind": "folder",
|
||||
"label": "档案盒方洞",
|
||||
"bonus": true
|
||||
"imagePrompt": "办公室档案盒洞口贴纸"
|
||||
}
|
||||
],
|
||||
"backgroundPrompt": "办公室桌面纸箱玩具背景"
|
||||
@@ -501,8 +525,12 @@ mod tests {
|
||||
assert_eq!(output.next_config.difficulty, 6);
|
||||
assert!(output.next_config.shape_options.len() >= 6);
|
||||
assert_eq!(output.next_config.shape_options[0].label, "圆形印章");
|
||||
assert_eq!(output.next_config.shape_options[0].target_hole_id, "folder");
|
||||
assert_eq!(output.next_config.hole_options[0].label, "档案盒方洞");
|
||||
assert!(output.next_config.hole_options[0].bonus);
|
||||
assert_eq!(
|
||||
output.next_config.hole_options[0].image_prompt,
|
||||
"办公室档案盒洞口贴纸"
|
||||
);
|
||||
assert_eq!(
|
||||
output.next_config.background_prompt,
|
||||
"办公室桌面纸箱玩具背景"
|
||||
|
||||
Reference in New Issue
Block a user