1
This commit is contained in:
@@ -28,7 +28,9 @@ use webp::Encoder as WebpEncoder;
|
||||
|
||||
use crate::{
|
||||
api_response::json_success_body,
|
||||
asset_billing::execute_billable_asset_operation,
|
||||
asset_billing::{
|
||||
execute_billable_asset_operation, execute_billable_asset_operation_with_cost,
|
||||
},
|
||||
auth::AuthenticatedAccessToken,
|
||||
custom_world_result_prompts::{
|
||||
build_result_entity_system_prompt, build_result_entity_user_prompt,
|
||||
@@ -115,6 +117,12 @@ pub(crate) struct CustomWorldCoverUploadRequest {
|
||||
crop_rect: CustomWorldCoverCropRect,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub(crate) struct CustomWorldOpeningCgGenerateRequest {
|
||||
profile: Value,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
struct GeneratedAssetResponse {
|
||||
@@ -133,6 +141,38 @@ struct GeneratedAssetResponse {
|
||||
actual_prompt: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
struct GeneratedOpeningCgResponse {
|
||||
opening_cg: CustomWorldOpeningCgProfileResponse,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
struct CustomWorldOpeningCgProfileResponse {
|
||||
id: String,
|
||||
status: &'static str,
|
||||
storyboard_image_src: String,
|
||||
storyboard_asset_id: String,
|
||||
video_src: String,
|
||||
video_asset_id: String,
|
||||
poster_image_src: Option<String>,
|
||||
poster_asset_id: Option<String>,
|
||||
storyboard_prompt: String,
|
||||
video_prompt: String,
|
||||
image_model: &'static str,
|
||||
video_model: String,
|
||||
aspect_ratio: &'static str,
|
||||
image_size: &'static str,
|
||||
video_resolution: &'static str,
|
||||
duration_seconds: u32,
|
||||
point_cost: u64,
|
||||
estimated_wait_minutes: u32,
|
||||
generated_at: String,
|
||||
updated_at: String,
|
||||
error_message: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub(crate) struct GeneratedCustomWorldSceneImage {
|
||||
pub image_src: String,
|
||||
@@ -317,6 +357,22 @@ struct DownloadedRemoteImage {
|
||||
}
|
||||
|
||||
const RPG_SCENE_IMAGE_MODEL: &str = GPT_IMAGE_2_MODEL;
|
||||
const OPENING_CG_POINTS_COST: u64 = 80;
|
||||
const OPENING_CG_ESTIMATED_WAIT_MINUTES: u32 = 10;
|
||||
const OPENING_CG_IMAGE_SIZE_LABEL: &str = "2k";
|
||||
const OPENING_CG_STORYBOARD_IMAGE_SIZE: &str = "2048x1152";
|
||||
const OPENING_CG_VIDEO_PROMPT: &str = "利用参考图作为故事板,生成一段连贯的动画,没有旁白";
|
||||
const OPENING_CG_VIDEO_RESOLUTION: &str = "480p";
|
||||
const OPENING_CG_VIDEO_RATIO: &str = "16:9";
|
||||
const OPENING_CG_VIDEO_DURATION_SECONDS: u32 = 15;
|
||||
const OPENING_CG_VIDEO_MIN_REQUEST_TIMEOUT_MS: u64 = 600_000;
|
||||
const OPENING_CG_ASPECT_RATIO: &str = "16:9";
|
||||
const OPENING_CG_STORYBOARD_ASSET_KIND: &str = "custom_world_opening_cg_storyboard";
|
||||
const OPENING_CG_VIDEO_ASSET_KIND: &str = "custom_world_opening_cg_video";
|
||||
const OPENING_CG_ENTITY_KIND: &str = "custom_world_profile";
|
||||
const OPENING_CG_STORYBOARD_SLOT: &str = "opening_cg_storyboard";
|
||||
const OPENING_CG_VIDEO_SLOT: &str = "opening_cg_video";
|
||||
const ARK_VIDEO_TASK_POLL_INTERVAL_MS: u64 = 5_000;
|
||||
|
||||
struct CoverPromptContext {
|
||||
opening_act_title: String,
|
||||
@@ -336,6 +392,39 @@ struct NormalizedSceneImageRequest {
|
||||
reference_image_src: Option<String>,
|
||||
}
|
||||
|
||||
struct NormalizedOpeningCgRequest {
|
||||
profile_id: Option<String>,
|
||||
world_name: String,
|
||||
opening_cg_id: String,
|
||||
storyboard_prompt: String,
|
||||
video_prompt: String,
|
||||
player_role_image_src: String,
|
||||
opening_scene_image_src: String,
|
||||
}
|
||||
|
||||
struct ArkVideoSettings {
|
||||
base_url: String,
|
||||
api_key: String,
|
||||
request_timeout_ms: u64,
|
||||
model: String,
|
||||
}
|
||||
|
||||
struct GeneratedOpeningCgStoryboard {
|
||||
image_src: String,
|
||||
asset_id: String,
|
||||
}
|
||||
|
||||
struct GeneratedOpeningCgVideo {
|
||||
video_src: String,
|
||||
asset_id: String,
|
||||
}
|
||||
|
||||
struct DownloadedRemoteVideo {
|
||||
mime_type: String,
|
||||
extension: String,
|
||||
bytes: Vec<u8>,
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
struct NormalizedCropRect {
|
||||
left: u32,
|
||||
@@ -884,6 +973,119 @@ pub async fn upload_custom_world_cover_image(
|
||||
Ok(json_success_body(Some(&request_context), asset))
|
||||
}
|
||||
|
||||
pub async fn generate_custom_world_opening_cg(
|
||||
State(state): State<AppState>,
|
||||
Extension(request_context): Extension<RequestContext>,
|
||||
Extension(authenticated): Extension<AuthenticatedAccessToken>,
|
||||
payload: Result<Json<CustomWorldOpeningCgGenerateRequest>, JsonRejection>,
|
||||
) -> Result<Json<Value>, Response> {
|
||||
let Json(payload) = payload.map_err(|error| {
|
||||
custom_world_ai_error_response(
|
||||
&request_context,
|
||||
AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({
|
||||
"provider": "custom-world-opening-cg",
|
||||
"message": error.body_text(),
|
||||
})),
|
||||
)
|
||||
})?;
|
||||
|
||||
let owner_user_id = authenticated.claims().user_id().to_string();
|
||||
let normalized = normalize_opening_cg_request(&payload.profile)
|
||||
.map_err(|error| custom_world_ai_error_response(&request_context, error))?;
|
||||
require_openai_image_settings(&state)
|
||||
.map_err(|error| custom_world_ai_error_response(&request_context, error))?;
|
||||
require_ark_video_settings(&state)
|
||||
.map_err(|error| custom_world_ai_error_response(&request_context, error))?;
|
||||
|
||||
let opening_cg_id = normalized.opening_cg_id.clone();
|
||||
let generated = execute_billable_asset_operation_with_cost(
|
||||
&state,
|
||||
&owner_user_id,
|
||||
"custom_world_opening_cg",
|
||||
opening_cg_id.as_str(),
|
||||
OPENING_CG_POINTS_COST,
|
||||
async {
|
||||
let image_settings = require_openai_image_settings(&state)?;
|
||||
let image_http_client = build_openai_image_http_client(&image_settings)?;
|
||||
let video_settings = require_ark_video_settings(&state)?;
|
||||
let video_http_client = build_upstream_http_client(video_settings.request_timeout_ms)?;
|
||||
let player_role_reference = resolve_reference_image_as_data_url(
|
||||
&state,
|
||||
&image_http_client,
|
||||
normalized.player_role_image_src.as_str(),
|
||||
"playerRoleImageSrc",
|
||||
)
|
||||
.await?;
|
||||
let opening_scene_reference = resolve_reference_image_as_data_url(
|
||||
&state,
|
||||
&image_http_client,
|
||||
normalized.opening_scene_image_src.as_str(),
|
||||
"openingSceneImageSrc",
|
||||
)
|
||||
.await?;
|
||||
let storyboard = generate_opening_cg_storyboard(
|
||||
&state,
|
||||
&owner_user_id,
|
||||
&image_http_client,
|
||||
&image_settings,
|
||||
&normalized,
|
||||
&[player_role_reference, opening_scene_reference],
|
||||
)
|
||||
.await?;
|
||||
let storyboard_reference = resolve_reference_image_as_data_url(
|
||||
&state,
|
||||
&image_http_client,
|
||||
storyboard.image_src.as_str(),
|
||||
"storyboardImageSrc",
|
||||
)
|
||||
.await?;
|
||||
let video = generate_opening_cg_video(
|
||||
&state,
|
||||
&owner_user_id,
|
||||
&video_http_client,
|
||||
&video_settings,
|
||||
&normalized,
|
||||
storyboard_reference.as_str(),
|
||||
)
|
||||
.await?;
|
||||
let generated_at = current_utc_iso_text();
|
||||
|
||||
Ok(CustomWorldOpeningCgProfileResponse {
|
||||
id: opening_cg_id.clone(),
|
||||
status: "ready",
|
||||
storyboard_image_src: storyboard.image_src,
|
||||
storyboard_asset_id: storyboard.asset_id,
|
||||
video_src: video.video_src,
|
||||
video_asset_id: video.asset_id,
|
||||
poster_image_src: None,
|
||||
poster_asset_id: None,
|
||||
storyboard_prompt: normalized.storyboard_prompt.clone(),
|
||||
video_prompt: normalized.video_prompt.clone(),
|
||||
image_model: GPT_IMAGE_2_MODEL,
|
||||
video_model: video_settings.model,
|
||||
aspect_ratio: OPENING_CG_ASPECT_RATIO,
|
||||
image_size: OPENING_CG_IMAGE_SIZE_LABEL,
|
||||
video_resolution: OPENING_CG_VIDEO_RESOLUTION,
|
||||
duration_seconds: OPENING_CG_VIDEO_DURATION_SECONDS,
|
||||
point_cost: OPENING_CG_POINTS_COST,
|
||||
estimated_wait_minutes: OPENING_CG_ESTIMATED_WAIT_MINUTES,
|
||||
generated_at: generated_at.clone(),
|
||||
updated_at: generated_at,
|
||||
error_message: None,
|
||||
})
|
||||
},
|
||||
)
|
||||
.await
|
||||
.map_err(|error| custom_world_ai_error_response(&request_context, error))?;
|
||||
|
||||
Ok(json_success_body(
|
||||
Some(&request_context),
|
||||
GeneratedOpeningCgResponse {
|
||||
opening_cg: generated,
|
||||
},
|
||||
))
|
||||
}
|
||||
|
||||
async fn persist_custom_world_asset(
|
||||
state: &AppState,
|
||||
owner_user_id: &str,
|
||||
@@ -974,6 +1176,337 @@ async fn persist_custom_world_asset(
|
||||
Ok(response)
|
||||
}
|
||||
|
||||
async fn generate_opening_cg_storyboard(
|
||||
state: &AppState,
|
||||
owner_user_id: &str,
|
||||
http_client: &reqwest::Client,
|
||||
settings: &crate::openai_image_generation::OpenAiImageSettings,
|
||||
normalized: &NormalizedOpeningCgRequest,
|
||||
reference_images: &[String],
|
||||
) -> Result<GeneratedOpeningCgStoryboard, AppError> {
|
||||
let generated = create_openai_image_generation(
|
||||
http_client,
|
||||
settings,
|
||||
normalized.storyboard_prompt.as_str(),
|
||||
None,
|
||||
OPENING_CG_STORYBOARD_IMAGE_SIZE,
|
||||
1,
|
||||
reference_images,
|
||||
"开局 CG 故事板生成失败",
|
||||
)
|
||||
.await?;
|
||||
let downloaded = generated
|
||||
.images
|
||||
.into_iter()
|
||||
.next()
|
||||
.map(downloaded_openai_to_custom_world_image)
|
||||
.ok_or_else(|| {
|
||||
AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({
|
||||
"provider": "apimart",
|
||||
"message": "开局 CG 故事板生成成功但未返回图片。",
|
||||
}))
|
||||
})?;
|
||||
let asset_id = format!("opening-cg-storyboard-{}", current_utc_millis());
|
||||
let upload = PreparedAssetUpload {
|
||||
prefix: LegacyAssetPrefix::CustomWorldScenes,
|
||||
path_segments: vec![
|
||||
sanitize_storage_segment(
|
||||
normalized
|
||||
.profile_id
|
||||
.as_deref()
|
||||
.unwrap_or(normalized.world_name.as_str()),
|
||||
"world",
|
||||
),
|
||||
"opening-cg".to_string(),
|
||||
sanitize_storage_segment(normalized.opening_cg_id.as_str(), "opening-cg"),
|
||||
],
|
||||
file_name: format!("storyboard.{}", downloaded.extension),
|
||||
content_type: downloaded.mime_type,
|
||||
body: downloaded.bytes,
|
||||
asset_kind: OPENING_CG_STORYBOARD_ASSET_KIND,
|
||||
entity_kind: OPENING_CG_ENTITY_KIND,
|
||||
entity_id: normalized
|
||||
.profile_id
|
||||
.clone()
|
||||
.unwrap_or_else(|| normalized.world_name.clone()),
|
||||
profile_id: normalized.profile_id.clone(),
|
||||
slot: OPENING_CG_STORYBOARD_SLOT,
|
||||
source_job_id: Some(generated.task_id.clone()),
|
||||
};
|
||||
let asset = persist_custom_world_asset(
|
||||
state,
|
||||
owner_user_id,
|
||||
upload,
|
||||
GeneratedAssetResponse {
|
||||
image_src: String::new(),
|
||||
asset_id: asset_id.clone(),
|
||||
source_type: "generated".to_string(),
|
||||
model: Some(GPT_IMAGE_2_MODEL.to_string()),
|
||||
size: Some(OPENING_CG_STORYBOARD_IMAGE_SIZE.to_string()),
|
||||
task_id: Some(generated.task_id.clone()),
|
||||
prompt: Some(normalized.storyboard_prompt.clone()),
|
||||
actual_prompt: generated.actual_prompt,
|
||||
},
|
||||
)
|
||||
.await?;
|
||||
|
||||
Ok(GeneratedOpeningCgStoryboard {
|
||||
image_src: asset.image_src,
|
||||
asset_id,
|
||||
})
|
||||
}
|
||||
|
||||
async fn generate_opening_cg_video(
|
||||
state: &AppState,
|
||||
owner_user_id: &str,
|
||||
http_client: &reqwest::Client,
|
||||
settings: &ArkVideoSettings,
|
||||
normalized: &NormalizedOpeningCgRequest,
|
||||
storyboard_reference_data_url: &str,
|
||||
) -> Result<GeneratedOpeningCgVideo, AppError> {
|
||||
let upstream_task_id = create_ark_storyboard_to_video_task(
|
||||
http_client,
|
||||
settings,
|
||||
normalized.video_prompt.as_str(),
|
||||
storyboard_reference_data_url,
|
||||
)
|
||||
.await?;
|
||||
let video_url =
|
||||
wait_for_ark_content_generation_task(http_client, settings, upstream_task_id.as_str())
|
||||
.await?;
|
||||
let downloaded =
|
||||
download_generated_video(http_client, video_url.as_str(), "下载开局 CG 视频失败").await?;
|
||||
let asset_id = format!("opening-cg-video-{}", current_utc_millis());
|
||||
let video_src = persist_opening_cg_video_asset(
|
||||
state,
|
||||
owner_user_id,
|
||||
normalized,
|
||||
asset_id.as_str(),
|
||||
Some(upstream_task_id.clone()),
|
||||
downloaded,
|
||||
)
|
||||
.await?;
|
||||
|
||||
Ok(GeneratedOpeningCgVideo {
|
||||
video_src,
|
||||
asset_id,
|
||||
})
|
||||
}
|
||||
|
||||
async fn persist_opening_cg_video_asset(
|
||||
state: &AppState,
|
||||
owner_user_id: &str,
|
||||
normalized: &NormalizedOpeningCgRequest,
|
||||
asset_id: &str,
|
||||
source_job_id: Option<String>,
|
||||
video: DownloadedRemoteVideo,
|
||||
) -> Result<String, AppError> {
|
||||
let upload = PreparedAssetUpload {
|
||||
prefix: LegacyAssetPrefix::CustomWorldScenes,
|
||||
path_segments: vec![
|
||||
sanitize_storage_segment(
|
||||
normalized
|
||||
.profile_id
|
||||
.as_deref()
|
||||
.unwrap_or(normalized.world_name.as_str()),
|
||||
"world",
|
||||
),
|
||||
"opening-cg".to_string(),
|
||||
sanitize_storage_segment(normalized.opening_cg_id.as_str(), "opening-cg"),
|
||||
],
|
||||
file_name: format!("opening.{}", video.extension),
|
||||
content_type: video.mime_type,
|
||||
body: video.bytes,
|
||||
asset_kind: OPENING_CG_VIDEO_ASSET_KIND,
|
||||
entity_kind: OPENING_CG_ENTITY_KIND,
|
||||
entity_id: normalized
|
||||
.profile_id
|
||||
.clone()
|
||||
.unwrap_or_else(|| normalized.world_name.clone()),
|
||||
profile_id: normalized.profile_id.clone(),
|
||||
slot: OPENING_CG_VIDEO_SLOT,
|
||||
source_job_id,
|
||||
};
|
||||
let asset = persist_custom_world_asset(
|
||||
state,
|
||||
owner_user_id,
|
||||
upload,
|
||||
GeneratedAssetResponse {
|
||||
image_src: String::new(),
|
||||
asset_id: asset_id.to_string(),
|
||||
source_type: "generated".to_string(),
|
||||
model: Some("ark-seedance".to_string()),
|
||||
size: Some(format!(
|
||||
"{}:{}:{}s",
|
||||
OPENING_CG_VIDEO_RESOLUTION,
|
||||
OPENING_CG_VIDEO_RATIO,
|
||||
OPENING_CG_VIDEO_DURATION_SECONDS
|
||||
)),
|
||||
task_id: None,
|
||||
prompt: Some(normalized.video_prompt.clone()),
|
||||
actual_prompt: None,
|
||||
},
|
||||
)
|
||||
.await?;
|
||||
Ok(asset.image_src)
|
||||
}
|
||||
|
||||
async fn create_ark_storyboard_to_video_task(
|
||||
http_client: &reqwest::Client,
|
||||
settings: &ArkVideoSettings,
|
||||
prompt: &str,
|
||||
storyboard_reference_data_url: &str,
|
||||
) -> Result<String, AppError> {
|
||||
let response = http_client
|
||||
.post(format!("{}/contents/generations/tasks", settings.base_url))
|
||||
.header(
|
||||
reqwest::header::AUTHORIZATION,
|
||||
format!("Bearer {}", settings.api_key),
|
||||
)
|
||||
.header(reqwest::header::CONTENT_TYPE, "application/json")
|
||||
.json(&json!({
|
||||
"model": settings.model,
|
||||
"content": [
|
||||
{
|
||||
"type": "text",
|
||||
"text": prompt,
|
||||
},
|
||||
{
|
||||
"type": "image_url",
|
||||
"image_url": {
|
||||
"url": storyboard_reference_data_url,
|
||||
},
|
||||
"role": "reference_image",
|
||||
}
|
||||
],
|
||||
"resolution": OPENING_CG_VIDEO_RESOLUTION,
|
||||
"ratio": OPENING_CG_VIDEO_RATIO,
|
||||
"duration": OPENING_CG_VIDEO_DURATION_SECONDS,
|
||||
"watermark": false,
|
||||
"audio": true,
|
||||
"generate_audio": true,
|
||||
"web_search": true,
|
||||
"enable_web_search": true,
|
||||
}))
|
||||
.send()
|
||||
.await
|
||||
.map_err(|error| map_ark_video_request_error(format!("请求 Seedance 视频服务失败:{error}")))?;
|
||||
let status = response.status();
|
||||
let text = response.text().await.map_err(|error| {
|
||||
map_ark_video_request_error(format!("读取 Seedance 视频任务响应失败:{error}"))
|
||||
})?;
|
||||
if !status.is_success() {
|
||||
return Err(parse_ark_video_upstream_error(
|
||||
text.as_str(),
|
||||
"创建开局 CG 视频任务失败。",
|
||||
));
|
||||
}
|
||||
let payload = parse_ark_video_json_payload(text.as_str(), "创建开局 CG 视频任务失败。")?;
|
||||
extract_ark_task_id(&payload.payload).ok_or_else(|| {
|
||||
AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({
|
||||
"provider": "ark",
|
||||
"message": "开局 CG 视频任务未返回任务 id。",
|
||||
}))
|
||||
})
|
||||
}
|
||||
|
||||
async fn wait_for_ark_content_generation_task(
|
||||
http_client: &reqwest::Client,
|
||||
settings: &ArkVideoSettings,
|
||||
task_id: &str,
|
||||
) -> Result<String, AppError> {
|
||||
let deadline = Instant::now() + Duration::from_millis(settings.request_timeout_ms);
|
||||
while Instant::now() < deadline {
|
||||
let response = http_client
|
||||
.get(format!(
|
||||
"{}/contents/generations/tasks/{}",
|
||||
settings.base_url, task_id
|
||||
))
|
||||
.header(
|
||||
reqwest::header::AUTHORIZATION,
|
||||
format!("Bearer {}", settings.api_key),
|
||||
)
|
||||
.send()
|
||||
.await
|
||||
.map_err(|error| map_ark_video_request_error(format!("查询 Seedance 视频任务失败:{error}")))?;
|
||||
let status = response.status();
|
||||
let text = response.text().await.map_err(|error| {
|
||||
map_ark_video_request_error(format!("读取 Seedance 视频任务响应失败:{error}"))
|
||||
})?;
|
||||
if !status.is_success() {
|
||||
return Err(parse_ark_video_upstream_error(
|
||||
text.as_str(),
|
||||
"查询开局 CG 视频任务失败。",
|
||||
));
|
||||
}
|
||||
let payload = parse_ark_video_json_payload(text.as_str(), "查询开局 CG 视频任务失败。")?;
|
||||
if let Some(video_url) = extract_video_url(&payload.payload) {
|
||||
return Ok(video_url);
|
||||
}
|
||||
let normalized_status = normalize_generation_task_status(
|
||||
extract_generation_task_status(&payload.payload).as_str(),
|
||||
);
|
||||
if is_completed_generation_task_status(normalized_status.as_str()) {
|
||||
return Err(AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({
|
||||
"provider": "ark",
|
||||
"message": "开局 CG 视频任务完成但没有返回 video_url。",
|
||||
"taskId": task_id,
|
||||
})));
|
||||
}
|
||||
if is_failed_generation_task_status(normalized_status.as_str()) {
|
||||
return Err(parse_ark_video_upstream_error(
|
||||
text.as_str(),
|
||||
"开局 CG 视频任务执行失败。",
|
||||
));
|
||||
}
|
||||
|
||||
sleep(Duration::from_millis(ARK_VIDEO_TASK_POLL_INTERVAL_MS)).await;
|
||||
}
|
||||
|
||||
Err(AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({
|
||||
"provider": "ark",
|
||||
"message": "开局 CG 视频生成超时,请稍后重试。",
|
||||
"taskId": task_id,
|
||||
})))
|
||||
}
|
||||
|
||||
async fn download_generated_video(
|
||||
http_client: &reqwest::Client,
|
||||
video_url: &str,
|
||||
fallback_message: &str,
|
||||
) -> Result<DownloadedRemoteVideo, AppError> {
|
||||
let response = http_client
|
||||
.get(video_url)
|
||||
.send()
|
||||
.await
|
||||
.map_err(|error| map_ark_video_request_error(format!("{fallback_message}:{error}")))?;
|
||||
let status = response.status();
|
||||
let content_type = response
|
||||
.headers()
|
||||
.get(reqwest::header::CONTENT_TYPE)
|
||||
.and_then(|value| value.to_str().ok())
|
||||
.unwrap_or("video/mp4")
|
||||
.to_string();
|
||||
let body = response
|
||||
.bytes()
|
||||
.await
|
||||
.map_err(|error| map_ark_video_request_error(format!("{fallback_message}:{error}")))?;
|
||||
if !status.is_success() {
|
||||
return Err(AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({
|
||||
"provider": "ark",
|
||||
"message": fallback_message,
|
||||
"status": status.as_u16(),
|
||||
})));
|
||||
}
|
||||
let normalized_mime_type = normalize_downloaded_video_mime_type(content_type.as_str());
|
||||
|
||||
Ok(DownloadedRemoteVideo {
|
||||
extension: video_mime_to_extension(normalized_mime_type.as_str()).to_string(),
|
||||
mime_type: normalized_mime_type,
|
||||
bytes: body.to_vec(),
|
||||
})
|
||||
}
|
||||
|
||||
fn build_asset_metadata(
|
||||
asset_kind: &str,
|
||||
owner_user_id: &str,
|
||||
@@ -1225,6 +1758,176 @@ fn normalize_scene_image_request(
|
||||
})
|
||||
}
|
||||
|
||||
fn normalize_opening_cg_request(profile: &Value) -> Result<NormalizedOpeningCgRequest, AppError> {
|
||||
let object = profile.as_object().ok_or_else(|| {
|
||||
AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({
|
||||
"provider": "custom-world-opening-cg",
|
||||
"message": "profile 必须是 JSON object",
|
||||
}))
|
||||
})?;
|
||||
let world_name = read_string_field(object, "name").unwrap_or_else(|| "未命名世界".to_string());
|
||||
let profile_id = read_string_field(object, "id");
|
||||
let world_tone = read_string_field(object, "tone").ok_or_else(|| {
|
||||
missing_opening_cg_field_error("世界基调缺失,无法生成开局 CG。")
|
||||
})?;
|
||||
let world_summary = read_string_field(object, "summary").ok_or_else(|| {
|
||||
missing_opening_cg_field_error("世界概述缺失,无法生成开局 CG。")
|
||||
})?;
|
||||
let core_conflicts = read_string_array_field(object, "coreConflicts");
|
||||
if core_conflicts.is_empty() {
|
||||
return Err(missing_opening_cg_field_error(
|
||||
"核心冲突缺失,无法生成开局 CG。",
|
||||
));
|
||||
}
|
||||
let player_role = object
|
||||
.get("playableNpcs")
|
||||
.and_then(Value::as_array)
|
||||
.and_then(|roles| roles.first())
|
||||
.and_then(Value::as_object)
|
||||
.ok_or_else(|| missing_opening_cg_field_error("缺少玩家扮演角色。"))?;
|
||||
let player_role_image_src = read_string_field(player_role, "imageSrc").ok_or_else(|| {
|
||||
missing_opening_cg_field_error("玩家扮演角色缺少角色参考图。")
|
||||
})?;
|
||||
let player_role_brief = build_opening_cg_player_role_brief(player_role);
|
||||
let opening_scene_image_src = profile
|
||||
.pointer("/sceneChapterBlueprints/0/acts/0/backgroundImageSrc")
|
||||
.and_then(Value::as_str)
|
||||
.map(str::trim)
|
||||
.filter(|value| !value.is_empty())
|
||||
.map(ToOwned::to_owned)
|
||||
.ok_or_else(|| {
|
||||
missing_opening_cg_field_error("首个场景第一幕背景图缺失,无法生成开局 CG。")
|
||||
})?;
|
||||
let opening_cg_id = format!("opening-cg-{}", current_utc_millis());
|
||||
let storyboard_prompt = build_opening_cg_storyboard_prompt(
|
||||
world_tone.as_str(),
|
||||
player_role_brief.as_str(),
|
||||
world_summary.as_str(),
|
||||
core_conflicts.as_slice(),
|
||||
);
|
||||
|
||||
Ok(NormalizedOpeningCgRequest {
|
||||
profile_id,
|
||||
world_name,
|
||||
opening_cg_id,
|
||||
storyboard_prompt,
|
||||
video_prompt: OPENING_CG_VIDEO_PROMPT.to_string(),
|
||||
player_role_image_src,
|
||||
opening_scene_image_src,
|
||||
})
|
||||
}
|
||||
|
||||
fn missing_opening_cg_field_error(message: &str) -> AppError {
|
||||
AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({
|
||||
"provider": "custom-world-opening-cg",
|
||||
"message": message,
|
||||
}))
|
||||
}
|
||||
|
||||
fn build_opening_cg_storyboard_prompt(
|
||||
world_tone: &str,
|
||||
player_role_brief: &str,
|
||||
world_summary: &str,
|
||||
core_conflicts: &[String],
|
||||
) -> String {
|
||||
format!(
|
||||
"以3*4网格格式创建故事板,16:9。像素风角色扮演游戏开场动画CG。\n\n故事流程:先展示角色,展示故事背景,然后表现核心冲突,最后衔接开局场景\n故事基调:{}\n\n玩家扮演:将玩家扮演角色作为角色参考图并引用世界草稿中的角色简介:{}\n故事背景:{}\n核心冲突:{}\n开局场景:将首个场景的第一幕背景图作为参考图",
|
||||
clamp_opening_cg_prompt_text(world_tone, 160),
|
||||
clamp_opening_cg_prompt_text(player_role_brief, 320),
|
||||
clamp_opening_cg_prompt_text(world_summary, 420),
|
||||
clamp_opening_cg_prompt_text(core_conflicts.join(";").as_str(), 360),
|
||||
)
|
||||
}
|
||||
|
||||
fn build_opening_cg_player_role_brief(role: &Map<String, Value>) -> String {
|
||||
[
|
||||
read_string_field(role, "name")
|
||||
.map(|value| format!("姓名:{value}"))
|
||||
.unwrap_or_default(),
|
||||
read_string_field(role, "role")
|
||||
.map(|value| format!("身份:{value}"))
|
||||
.unwrap_or_default(),
|
||||
read_string_field(role, "description")
|
||||
.map(|value| format!("简介:{value}"))
|
||||
.unwrap_or_default(),
|
||||
read_string_field(role, "visualDescription")
|
||||
.map(|value| format!("形象:{value}"))
|
||||
.unwrap_or_default(),
|
||||
]
|
||||
.into_iter()
|
||||
.filter(|value| !value.trim().is_empty())
|
||||
.collect::<Vec<_>>()
|
||||
.join(";")
|
||||
}
|
||||
|
||||
fn read_string_array_field(object: &Map<String, Value>, key: &str) -> Vec<String> {
|
||||
object
|
||||
.get(key)
|
||||
.and_then(Value::as_array)
|
||||
.map(|entries| {
|
||||
entries
|
||||
.iter()
|
||||
.filter_map(Value::as_str)
|
||||
.map(str::trim)
|
||||
.filter(|value| !value.is_empty())
|
||||
.map(ToOwned::to_owned)
|
||||
.collect()
|
||||
})
|
||||
.unwrap_or_default()
|
||||
}
|
||||
|
||||
fn clamp_opening_cg_prompt_text(value: &str, max_length: usize) -> String {
|
||||
clamp_text(value, max_length, false)
|
||||
}
|
||||
|
||||
fn require_ark_video_settings(state: &AppState) -> Result<ArkVideoSettings, AppError> {
|
||||
let base_url = state
|
||||
.config
|
||||
.ark_character_video_base_url
|
||||
.trim()
|
||||
.trim_end_matches('/');
|
||||
if base_url.is_empty() {
|
||||
return Err(AppError::from_status(StatusCode::SERVICE_UNAVAILABLE).with_details(json!({
|
||||
"provider": "ark",
|
||||
"reason": "ARK_CHARACTER_VIDEO_BASE_URL 未配置",
|
||||
})));
|
||||
}
|
||||
let api_key = state
|
||||
.config
|
||||
.ark_character_video_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": "ark",
|
||||
"reason": "ARK_CHARACTER_VIDEO_API_KEY 未配置",
|
||||
}))
|
||||
})?;
|
||||
|
||||
Ok(ArkVideoSettings {
|
||||
base_url: base_url.to_string(),
|
||||
api_key: api_key.to_string(),
|
||||
request_timeout_ms: state
|
||||
.config
|
||||
.ark_character_video_request_timeout_ms
|
||||
.max(OPENING_CG_VIDEO_MIN_REQUEST_TIMEOUT_MS),
|
||||
model: state.config.ark_character_video_model.clone(),
|
||||
})
|
||||
}
|
||||
|
||||
fn build_upstream_http_client(timeout_ms: u64) -> Result<reqwest::Client, AppError> {
|
||||
reqwest::Client::builder()
|
||||
.timeout(Duration::from_millis(timeout_ms))
|
||||
.build()
|
||||
.map_err(|error| {
|
||||
AppError::from_status(StatusCode::INTERNAL_SERVER_ERROR).with_details(json!({
|
||||
"provider": "custom-world-opening-cg",
|
||||
"message": format!("构造上游 HTTP 客户端失败:{error}"),
|
||||
}))
|
||||
})
|
||||
}
|
||||
|
||||
fn require_dashscope_settings(state: &AppState) -> Result<DashScopeSettings, AppError> {
|
||||
// Stage 2 的真实图片生成统一走 DashScope,这里先把配置缺失拦在业务入口前。
|
||||
let base_url = state.config.dashscope_base_url.trim().trim_end_matches('/');
|
||||
@@ -2143,6 +2846,20 @@ fn parse_json_payload(
|
||||
})
|
||||
}
|
||||
|
||||
fn parse_ark_video_json_payload(
|
||||
raw_text: &str,
|
||||
fallback_message: &str,
|
||||
) -> Result<ParsedJsonPayload, AppError> {
|
||||
serde_json::from_str::<Value>(raw_text)
|
||||
.map(|payload| ParsedJsonPayload { payload })
|
||||
.map_err(|error| {
|
||||
AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({
|
||||
"provider": "ark",
|
||||
"message": format!("{fallback_message}:解析响应失败:{error}"),
|
||||
}))
|
||||
})
|
||||
}
|
||||
|
||||
fn parse_api_error_message(raw_text: &str, fallback_message: &str) -> String {
|
||||
if raw_text.trim().is_empty() {
|
||||
return fallback_message.to_string();
|
||||
@@ -2193,6 +2910,13 @@ fn map_dashscope_request_error(message: String) -> AppError {
|
||||
}))
|
||||
}
|
||||
|
||||
fn map_ark_video_request_error(message: String) -> AppError {
|
||||
AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({
|
||||
"provider": "ark",
|
||||
"message": message,
|
||||
}))
|
||||
}
|
||||
|
||||
fn map_dashscope_upstream_error(raw_text: &str, fallback_message: &str) -> AppError {
|
||||
AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({
|
||||
"provider": "dashscope",
|
||||
@@ -2200,6 +2924,13 @@ fn map_dashscope_upstream_error(raw_text: &str, fallback_message: &str) -> AppEr
|
||||
}))
|
||||
}
|
||||
|
||||
fn parse_ark_video_upstream_error(raw_text: &str, fallback_message: &str) -> AppError {
|
||||
AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({
|
||||
"provider": "ark",
|
||||
"message": parse_api_error_message(raw_text, fallback_message),
|
||||
}))
|
||||
}
|
||||
|
||||
fn collect_strings_by_key(value: &Value, target_key: &str, results: &mut Vec<String>) {
|
||||
match value {
|
||||
Value::Array(entries) => {
|
||||
@@ -2236,6 +2967,61 @@ fn extract_task_id(payload: &Value) -> Option<String> {
|
||||
find_first_string_by_key(payload, "task_id")
|
||||
}
|
||||
|
||||
fn extract_ark_task_id(payload: &Value) -> Option<String> {
|
||||
payload
|
||||
.get("id")
|
||||
.and_then(Value::as_str)
|
||||
.map(str::trim)
|
||||
.filter(|value| !value.is_empty())
|
||||
.map(ToOwned::to_owned)
|
||||
.or_else(|| find_first_string_by_key(payload, "task_id"))
|
||||
.or_else(|| find_first_string_by_key(payload, "taskId"))
|
||||
.or_else(|| find_first_string_by_key(payload, "id"))
|
||||
}
|
||||
|
||||
fn extract_video_url(payload: &Value) -> Option<String> {
|
||||
find_first_string_by_key(payload, "video_url")
|
||||
.or_else(|| find_first_string_by_key(payload, "videoUrl"))
|
||||
.or_else(|| find_first_string_by_key(payload, "url"))
|
||||
}
|
||||
|
||||
fn extract_generation_task_status(payload: &Value) -> String {
|
||||
payload
|
||||
.get("status")
|
||||
.and_then(Value::as_str)
|
||||
.map(str::trim)
|
||||
.filter(|value| !value.is_empty())
|
||||
.map(ToOwned::to_owned)
|
||||
.or_else(|| find_first_string_by_key(payload, "task_status"))
|
||||
.or_else(|| find_first_string_by_key(payload, "status"))
|
||||
.unwrap_or_default()
|
||||
}
|
||||
|
||||
fn normalize_generation_task_status(value: &str) -> String {
|
||||
value.trim().to_ascii_lowercase().replace(' ', "_")
|
||||
}
|
||||
|
||||
fn is_completed_generation_task_status(status: &str) -> bool {
|
||||
matches!(
|
||||
status,
|
||||
"completed" | "complete" | "done" | "finished" | "success" | "succeeded" | "succeed"
|
||||
)
|
||||
}
|
||||
|
||||
fn is_failed_generation_task_status(status: &str) -> bool {
|
||||
matches!(
|
||||
status,
|
||||
"failed"
|
||||
| "canceled"
|
||||
| "cancelled"
|
||||
| "error"
|
||||
| "aborted"
|
||||
| "rejected"
|
||||
| "expired"
|
||||
| "unknown"
|
||||
)
|
||||
}
|
||||
|
||||
fn extract_image_urls(payload: &Value) -> Vec<String> {
|
||||
let mut urls = Vec::new();
|
||||
collect_strings_by_key(payload, "image", &mut urls);
|
||||
@@ -2263,6 +3049,20 @@ fn normalize_downloaded_image_mime_type(content_type: &str) -> String {
|
||||
}
|
||||
}
|
||||
|
||||
fn normalize_downloaded_video_mime_type(content_type: &str) -> String {
|
||||
let mime_type = content_type
|
||||
.split(';')
|
||||
.next()
|
||||
.map(str::trim)
|
||||
.unwrap_or("video/mp4");
|
||||
match mime_type {
|
||||
"video/mp4" | "video/quicktime" | "video/webm" | "video/x-msvideo" => {
|
||||
mime_type.to_string()
|
||||
}
|
||||
_ => "video/mp4".to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
fn mime_to_extension(mime_type: &str) -> &str {
|
||||
match mime_type {
|
||||
"image/png" => "png",
|
||||
@@ -2272,6 +3072,15 @@ fn mime_to_extension(mime_type: &str) -> &str {
|
||||
}
|
||||
}
|
||||
|
||||
fn video_mime_to_extension(mime_type: &str) -> &str {
|
||||
match mime_type {
|
||||
"video/quicktime" => "mov",
|
||||
"video/webm" => "webm",
|
||||
"video/x-msvideo" => "avi",
|
||||
_ => "mp4",
|
||||
}
|
||||
}
|
||||
|
||||
fn conditional_prompt_line(prefix: &str, value: &str) -> String {
|
||||
if value.is_empty() {
|
||||
String::new()
|
||||
@@ -2391,6 +3200,12 @@ fn current_utc_micros() -> i64 {
|
||||
i64::try_from(duration.as_micros()).expect("current unix micros should fit in i64")
|
||||
}
|
||||
|
||||
fn current_utc_iso_text() -> String {
|
||||
time::OffsetDateTime::now_utc()
|
||||
.format(&time::format_description::well_known::Rfc3339)
|
||||
.unwrap_or_else(|_| format!("{}.000000Z", current_utc_millis()))
|
||||
}
|
||||
|
||||
fn custom_world_ai_error_response(request_context: &RequestContext, error: AppError) -> Response {
|
||||
error.into_response_with_context(Some(request_context))
|
||||
}
|
||||
|
||||
@@ -58,15 +58,12 @@ mod tests {
|
||||
#[test]
|
||||
fn form_seed_prompt_keeps_only_user_visible_fields() {
|
||||
let prompt = build_puzzle_form_seed_prompt(PuzzleFormSeedPromptParts {
|
||||
title: Some(" 暖灯猫街 "),
|
||||
work_description: Some("雨夜礼物拼图"),
|
||||
title: None,
|
||||
work_description: None,
|
||||
picture_description: Some("猫咪在灯牌下回头"),
|
||||
});
|
||||
|
||||
assert_eq!(
|
||||
prompt,
|
||||
"作品名称:暖灯猫街\n作品描述:雨夜礼物拼图\n画面描述:猫咪在灯牌下回头"
|
||||
);
|
||||
assert_eq!(prompt, "画面描述:猫咪在灯牌下回头");
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
35
server-rs/crates/api-server/src/prompt/puzzle/level_name.rs
Normal file
35
server-rs/crates/api-server/src/prompt/puzzle/level_name.rs
Normal file
@@ -0,0 +1,35 @@
|
||||
/// 拼图首关关卡名生成提示词。
|
||||
///
|
||||
/// 模型只负责把画面描述压缩成可直接展示的中文关卡名;写回草稿和作品卡由业务路由处理。
|
||||
pub(crate) const PUZZLE_FIRST_LEVEL_NAME_SYSTEM_PROMPT: &str = r#"你是一个中文拼图关卡命名编辑。
|
||||
|
||||
你会收到拼图第一关的画面描述。请生成 1 个适合直接展示在游戏关卡卡片上的中文关卡名。
|
||||
|
||||
硬约束:
|
||||
1. 只输出 JSON,不要输出 Markdown、解释或代码块。
|
||||
2. JSON 格式必须是 {"levelName":"关卡名"}。
|
||||
3. levelName 必须是 2 到 8 个中文字符为主。
|
||||
4. 不要输出“第一关”“画面”“拼图”“作品”等泛词。
|
||||
5. 不要输出标点、引号、编号、英文、emoji 或空白。
|
||||
6. 关卡名要抓住画面主体、场景和氛围,读起来像一个具体可玩的关卡。
|
||||
"#;
|
||||
|
||||
pub(crate) fn build_puzzle_first_level_name_user_prompt(picture_description: &str) -> String {
|
||||
format!(
|
||||
"画面描述:{picture_description}\n\n请生成第一关关卡名。",
|
||||
picture_description = picture_description.trim(),
|
||||
)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn level_name_prompt_contains_picture_description() {
|
||||
let prompt = build_puzzle_first_level_name_user_prompt("一只猫在雨夜灯牌下回头。");
|
||||
|
||||
assert!(prompt.contains("画面描述:一只猫在雨夜灯牌下回头。"));
|
||||
assert!(prompt.contains("第一关关卡名"));
|
||||
}
|
||||
}
|
||||
@@ -1,3 +1,5 @@
|
||||
pub(crate) mod agent_chat;
|
||||
pub(crate) mod draft;
|
||||
pub(crate) mod image;
|
||||
pub(crate) mod level_name;
|
||||
pub(crate) mod tags;
|
||||
|
||||
40
server-rs/crates/api-server/src/prompt/puzzle/tags.rs
Normal file
40
server-rs/crates/api-server/src/prompt/puzzle/tags.rs
Normal file
@@ -0,0 +1,40 @@
|
||||
/// 拼图作品标签生成提示词。
|
||||
///
|
||||
/// 这里只负责标签生成的文本契约,业务路由负责调用 LLM、解析结果和写回草稿。
|
||||
pub(crate) const PUZZLE_TAG_GENERATION_SYSTEM_PROMPT: &str = r#"你是一个中文内容标签编辑。
|
||||
|
||||
你会收到拼图作品名称和作品描述。请生成 6 个适合作品广场检索和相似推荐的中文短标签。
|
||||
|
||||
硬约束:
|
||||
1. 只输出 JSON,不要输出 Markdown、解释或代码块。
|
||||
2. JSON 格式必须是 {"tags":["标签1","标签2","标签3","标签4","标签5","标签6"]}。
|
||||
3. tags 必须正好 6 个。
|
||||
4. 每个标签 2 到 6 个中文字符为主,不要整句描述。
|
||||
5. 不要输出空标签、重复标签、英文标签、编号、标点或井号。
|
||||
6. 标签要覆盖题材、主体、氛围、场景、风格和拼图辨识点。
|
||||
"#;
|
||||
|
||||
pub(crate) fn build_puzzle_tag_generation_user_prompt(
|
||||
work_title: &str,
|
||||
work_description: &str,
|
||||
) -> String {
|
||||
format!(
|
||||
"作品名称:{work_title}\n作品描述:{work_description}\n\n请生成 6 个作品标签。",
|
||||
work_title = work_title.trim(),
|
||||
work_description = work_description.trim(),
|
||||
)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn tag_prompt_contains_title_and_description() {
|
||||
let prompt = build_puzzle_tag_generation_user_prompt("雨夜猫街", "一套暖灯街角主题拼图。");
|
||||
|
||||
assert!(prompt.contains("作品名称:雨夜猫街"));
|
||||
assert!(prompt.contains("作品描述:一套暖灯街角主题拼图。"));
|
||||
assert!(prompt.contains("6 个作品标签"));
|
||||
}
|
||||
}
|
||||
@@ -18,6 +18,7 @@ use module_assets::{
|
||||
build_asset_object_upsert_input, generate_asset_binding_id, generate_asset_object_id,
|
||||
};
|
||||
use module_puzzle::{PuzzleGeneratedImageCandidate, PuzzleRuntimeLevelStatus};
|
||||
use platform_llm::{LlmMessage, LlmTextRequest};
|
||||
use platform_oss::{
|
||||
LegacyAssetPrefix, OssHeadObjectRequest, OssObjectAccess, OssPutObjectRequest,
|
||||
OssSignedGetObjectUrlRequest,
|
||||
@@ -76,6 +77,7 @@ use crate::{
|
||||
},
|
||||
auth::AuthenticatedAccessToken,
|
||||
http_error::AppError,
|
||||
llm_model_routing::CREATION_TEMPLATE_LLM_MODEL,
|
||||
platform_errors::map_oss_error,
|
||||
prompt::puzzle::{
|
||||
draft::{
|
||||
@@ -83,6 +85,10 @@ use crate::{
|
||||
resolve_puzzle_draft_cover_prompt, resolve_puzzle_level_image_prompt,
|
||||
},
|
||||
image::{PUZZLE_DEFAULT_NEGATIVE_PROMPT, build_puzzle_image_prompt},
|
||||
level_name::{
|
||||
PUZZLE_FIRST_LEVEL_NAME_SYSTEM_PROMPT, build_puzzle_first_level_name_user_prompt,
|
||||
},
|
||||
tags::{PUZZLE_TAG_GENERATION_SYSTEM_PROMPT, build_puzzle_tag_generation_user_prompt},
|
||||
},
|
||||
puzzle_agent_turn::{
|
||||
PuzzleAgentTurnRequest, build_failed_finalize_record_input, build_finalize_record_input,
|
||||
@@ -527,15 +533,15 @@ pub async fn execute_puzzle_agent_action(
|
||||
});
|
||||
(
|
||||
"compile_puzzle_draft",
|
||||
"完整拼图草稿",
|
||||
"已编译草稿、生成拼图图片并应用为正式图。",
|
||||
"首关拼图草稿",
|
||||
"已编译首关草稿、生成首关画面并写入正式草稿。",
|
||||
session,
|
||||
)
|
||||
}
|
||||
"save_puzzle_form_draft" => {
|
||||
let seed_text = build_puzzle_form_seed_text_from_parts(
|
||||
payload.work_title.as_deref(),
|
||||
payload.work_description.as_deref(),
|
||||
None,
|
||||
None,
|
||||
payload
|
||||
.picture_description
|
||||
.as_deref()
|
||||
@@ -705,6 +711,66 @@ pub async fn execute_puzzle_agent_action(
|
||||
session,
|
||||
)
|
||||
}
|
||||
"generate_puzzle_tags" => {
|
||||
let work_title = payload
|
||||
.work_title
|
||||
.as_deref()
|
||||
.map(str::trim)
|
||||
.filter(|value| !value.is_empty())
|
||||
.ok_or_else(|| {
|
||||
puzzle_bad_request(
|
||||
&request_context,
|
||||
PUZZLE_AGENT_API_BASE_PROVIDER,
|
||||
"作品名称不能为空",
|
||||
)
|
||||
})?;
|
||||
let work_description = payload
|
||||
.work_description
|
||||
.as_deref()
|
||||
.map(str::trim)
|
||||
.filter(|value| !value.is_empty())
|
||||
.ok_or_else(|| {
|
||||
puzzle_bad_request(
|
||||
&request_context,
|
||||
PUZZLE_AGENT_API_BASE_PROVIDER,
|
||||
"作品描述不能为空",
|
||||
)
|
||||
})?;
|
||||
let levels_json = normalize_puzzle_levels_json_for_module(
|
||||
payload.levels_json.as_deref(),
|
||||
)
|
||||
.map_err(|message| {
|
||||
puzzle_error_response(
|
||||
&request_context,
|
||||
PUZZLE_AGENT_API_BASE_PROVIDER,
|
||||
AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({
|
||||
"provider": PUZZLE_AGENT_API_BASE_PROVIDER,
|
||||
"message": message,
|
||||
})),
|
||||
)
|
||||
})?;
|
||||
let generated_tags =
|
||||
generate_puzzle_work_tags(&state, work_title, work_description).await;
|
||||
let session = save_generated_puzzle_tags_to_session(
|
||||
&state,
|
||||
&session_id,
|
||||
&owner_user_id,
|
||||
&payload,
|
||||
generated_tags,
|
||||
levels_json,
|
||||
now,
|
||||
)
|
||||
.await
|
||||
.map_err(|error| {
|
||||
puzzle_error_response(&request_context, PUZZLE_AGENT_API_BASE_PROVIDER, error)
|
||||
});
|
||||
(
|
||||
"generate_puzzle_tags",
|
||||
"作品标签生成",
|
||||
"已生成 6 个作品标签。",
|
||||
session,
|
||||
)
|
||||
}
|
||||
"select_puzzle_image" => {
|
||||
let candidate_id = payload
|
||||
.candidate_id
|
||||
@@ -2058,12 +2124,12 @@ fn build_puzzle_welcome_text(seed_text: &str) -> String {
|
||||
|
||||
fn build_puzzle_form_seed_text(payload: &CreatePuzzleAgentSessionRequest) -> String {
|
||||
build_puzzle_form_seed_prompt(PuzzleFormSeedPromptParts {
|
||||
title: payload
|
||||
.work_title
|
||||
title: None,
|
||||
work_description: None,
|
||||
picture_description: payload
|
||||
.picture_description
|
||||
.as_deref()
|
||||
.or(payload.seed_text.as_deref()),
|
||||
work_description: payload.work_description.as_deref(),
|
||||
picture_description: payload.picture_description.as_deref(),
|
||||
})
|
||||
}
|
||||
|
||||
@@ -2088,8 +2154,8 @@ async fn save_puzzle_form_payload_before_compile(
|
||||
now: i64,
|
||||
) -> Result<String, Response> {
|
||||
let seed_text = build_puzzle_form_seed_text_from_parts(
|
||||
payload.work_title.as_deref(),
|
||||
payload.work_description.as_deref(),
|
||||
None,
|
||||
None,
|
||||
payload
|
||||
.picture_description
|
||||
.as_deref()
|
||||
@@ -2486,6 +2552,176 @@ fn build_stable_puzzle_work_ids(session_id: &str) -> (String, String) {
|
||||
)
|
||||
}
|
||||
|
||||
async fn generate_puzzle_first_level_name(state: &AppState, picture_description: &str) -> String {
|
||||
if let Some(llm_client) = state.llm_client() {
|
||||
let user_prompt = build_puzzle_first_level_name_user_prompt(picture_description);
|
||||
let response = llm_client
|
||||
.request_text(
|
||||
LlmTextRequest::new(vec![
|
||||
LlmMessage::system(PUZZLE_FIRST_LEVEL_NAME_SYSTEM_PROMPT),
|
||||
LlmMessage::user(user_prompt),
|
||||
])
|
||||
.with_model(CREATION_TEMPLATE_LLM_MODEL)
|
||||
.with_responses_api(),
|
||||
)
|
||||
.await;
|
||||
match response {
|
||||
Ok(response) => {
|
||||
if let Some(level_name) =
|
||||
parse_puzzle_first_level_name_from_text(response.content.as_str())
|
||||
{
|
||||
return level_name;
|
||||
}
|
||||
tracing::warn!(
|
||||
provider = PUZZLE_AGENT_API_BASE_PROVIDER,
|
||||
picture_chars = picture_description.chars().count(),
|
||||
"拼图首关名模型返回非法,降级使用关键词名"
|
||||
);
|
||||
}
|
||||
Err(error) => {
|
||||
tracing::warn!(
|
||||
provider = PUZZLE_AGENT_API_BASE_PROVIDER,
|
||||
picture_chars = picture_description.chars().count(),
|
||||
error = %error,
|
||||
"拼图首关名生成失败,降级使用关键词名"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
build_fallback_puzzle_first_level_name(picture_description)
|
||||
}
|
||||
|
||||
fn parse_puzzle_first_level_name_from_text(text: &str) -> Option<String> {
|
||||
let trimmed = text.trim();
|
||||
let json_text = if let Some(start) = trimmed.find('{')
|
||||
&& let Some(end) = trimmed.rfind('}')
|
||||
&& end > start
|
||||
{
|
||||
&trimmed[start..=end]
|
||||
} else {
|
||||
trimmed
|
||||
};
|
||||
let parsed = serde_json::from_str::<Value>(json_text).ok();
|
||||
let raw_name = parsed
|
||||
.as_ref()
|
||||
.and_then(|value| value.get("levelName").and_then(Value::as_str))
|
||||
.or_else(|| {
|
||||
parsed
|
||||
.as_ref()
|
||||
.and_then(|value| value.get("level_name").and_then(Value::as_str))
|
||||
})
|
||||
.unwrap_or(trimmed);
|
||||
normalize_puzzle_first_level_name(raw_name)
|
||||
}
|
||||
|
||||
fn normalize_puzzle_first_level_name(value: &str) -> Option<String> {
|
||||
let normalized = value
|
||||
.trim()
|
||||
.trim_matches(|ch: char| {
|
||||
ch.is_ascii_punctuation()
|
||||
|| matches!(
|
||||
ch,
|
||||
',' | '。' | '、' | ';' | ':' | '!' | '?' | '“' | '”' | '《' | '》'
|
||||
)
|
||||
})
|
||||
.trim_start_matches(|ch: char| ch.is_ascii_digit() || matches!(ch, '.' | '、' | ')' | ')'))
|
||||
.chars()
|
||||
.filter(|ch| {
|
||||
!matches!(
|
||||
ch,
|
||||
'#' | '"'
|
||||
| '\''
|
||||
| '`'
|
||||
| ' '
|
||||
| '\t'
|
||||
| '\r'
|
||||
| '\n'
|
||||
| ','
|
||||
| '。'
|
||||
| '、'
|
||||
| ';'
|
||||
| ':'
|
||||
| '!'
|
||||
| '?'
|
||||
| '“'
|
||||
| '”'
|
||||
| '《'
|
||||
| '》'
|
||||
)
|
||||
})
|
||||
.take(12)
|
||||
.collect::<String>();
|
||||
let normalized = strip_puzzle_level_name_generic_words(normalized);
|
||||
if normalized.chars().count() >= 2
|
||||
&& !matches!(
|
||||
normalized.as_str(),
|
||||
"第一关" | "画面" | "拼图" | "作品" | "关卡"
|
||||
)
|
||||
{
|
||||
Some(normalized)
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
fn strip_puzzle_level_name_generic_words(mut value: String) -> String {
|
||||
for prefix in ["第一关", "关卡名", "关卡"] {
|
||||
value = value.trim_start_matches(prefix).to_string();
|
||||
}
|
||||
for suffix in ["第一关", "关卡名", "关卡", "画面", "拼图", "作品"] {
|
||||
value = value.trim_end_matches(suffix).to_string();
|
||||
}
|
||||
value.chars().take(8).collect()
|
||||
}
|
||||
|
||||
fn build_fallback_puzzle_first_level_name(picture_description: &str) -> String {
|
||||
let source = picture_description.trim();
|
||||
if source.contains("猫") && (source.contains("雨夜") || source.contains('雨')) {
|
||||
return "雨夜猫街".to_string();
|
||||
}
|
||||
if source.contains("猫") && source.contains('灯') {
|
||||
return "暖灯猫街".to_string();
|
||||
}
|
||||
for (keyword, level_name) in [
|
||||
("雨夜", "雨夜灯街"),
|
||||
("猫", "暖灯猫街"),
|
||||
("狗", "花园小狗"),
|
||||
("神庙", "神庙遗光"),
|
||||
("遗迹", "遗迹谜光"),
|
||||
("森林", "森林秘境"),
|
||||
("城市", "霓虹城市"),
|
||||
("机械", "机械迷城"),
|
||||
("蒸汽", "蒸汽街区"),
|
||||
("海", "海岸微光"),
|
||||
("花", "花园晨光"),
|
||||
("雪", "雪境小径"),
|
||||
("龙", "龙影高塔"),
|
||||
("灯", "暖灯街角"),
|
||||
("塔", "塔顶星光"),
|
||||
] {
|
||||
if source.contains(keyword) {
|
||||
return level_name.to_string();
|
||||
}
|
||||
}
|
||||
"奇境初见".to_string()
|
||||
}
|
||||
|
||||
fn build_puzzle_levels_with_primary_name(
|
||||
draft: &PuzzleResultDraftRecord,
|
||||
target_level: &PuzzleDraftLevelRecord,
|
||||
) -> Vec<PuzzleDraftLevelRecord> {
|
||||
let mut levels = draft.levels.clone();
|
||||
if let Some(index) = levels
|
||||
.iter()
|
||||
.position(|level| level.level_id == target_level.level_id)
|
||||
.or_else(|| (!levels.is_empty()).then_some(0))
|
||||
{
|
||||
levels[index].level_name = target_level.level_name.clone();
|
||||
}
|
||||
levels
|
||||
}
|
||||
|
||||
async fn compile_puzzle_draft_with_initial_cover(
|
||||
state: &AppState,
|
||||
session_id: String,
|
||||
@@ -2506,7 +2742,14 @@ async fn compile_puzzle_draft_with_initial_cover(
|
||||
"message": "拼图结果页草稿尚未生成",
|
||||
}))
|
||||
})?;
|
||||
let target_level = select_puzzle_level_for_api(&draft, None)?;
|
||||
let mut target_level = select_puzzle_level_for_api(&draft, None)?;
|
||||
let fallback_level_name = target_level.level_name.clone();
|
||||
let generated_level_name =
|
||||
generate_puzzle_first_level_name(state, &target_level.picture_description).await;
|
||||
target_level.level_name = generated_level_name.clone();
|
||||
let levels_json_with_generated_name = Some(serialize_puzzle_level_records_for_module(
|
||||
&build_puzzle_levels_with_primary_name(&draft, &target_level),
|
||||
)?);
|
||||
let image_prompt = resolve_puzzle_draft_cover_prompt(
|
||||
prompt_text,
|
||||
&target_level.picture_description,
|
||||
@@ -2554,7 +2797,7 @@ async fn compile_puzzle_draft_with_initial_cover(
|
||||
session_id: compiled_session.session_id.clone(),
|
||||
owner_user_id: owner_user_id.clone(),
|
||||
level_id: Some(target_level.level_id.clone()),
|
||||
levels_json: None,
|
||||
levels_json: levels_json_with_generated_name,
|
||||
candidates_json,
|
||||
saved_at_micros: current_utc_micros(),
|
||||
})
|
||||
@@ -2572,7 +2815,13 @@ async fn compile_puzzle_draft_with_initial_cover(
|
||||
"拼图首图已生成但 SpacetimeDB 草稿回写不可用,降级返回本次生成快照"
|
||||
);
|
||||
let session = apply_generated_puzzle_candidates_to_session_snapshot(
|
||||
compiled_session.clone(),
|
||||
apply_generated_puzzle_first_level_name_to_session_snapshot(
|
||||
compiled_session.clone(),
|
||||
target_level.level_id.as_str(),
|
||||
generated_level_name.as_str(),
|
||||
fallback_level_name.as_str(),
|
||||
now,
|
||||
),
|
||||
target_level.level_id.as_str(),
|
||||
candidates.clone(),
|
||||
now,
|
||||
@@ -2655,6 +2904,39 @@ fn apply_generated_puzzle_candidates_to_session_snapshot(
|
||||
session
|
||||
}
|
||||
|
||||
fn apply_generated_puzzle_first_level_name_to_session_snapshot(
|
||||
mut session: PuzzleAgentSessionRecord,
|
||||
target_level_id: &str,
|
||||
level_name: &str,
|
||||
previous_level_name: &str,
|
||||
updated_at_micros: i64,
|
||||
) -> PuzzleAgentSessionRecord {
|
||||
let Some(draft) = session.draft.as_mut() else {
|
||||
return session;
|
||||
};
|
||||
let normalized_name = level_name.trim();
|
||||
if normalized_name.is_empty() {
|
||||
return session;
|
||||
}
|
||||
let Some(target_index) = draft
|
||||
.levels
|
||||
.iter()
|
||||
.position(|level| level.level_id == target_level_id)
|
||||
.or_else(|| (!draft.levels.is_empty()).then_some(0))
|
||||
else {
|
||||
return session;
|
||||
};
|
||||
draft.levels[target_index].level_name = normalized_name.to_string();
|
||||
let should_default_work_title =
|
||||
draft.work_title.trim().is_empty() || draft.work_title.trim() == previous_level_name.trim();
|
||||
if target_index == 0 && should_default_work_title {
|
||||
draft.work_title = normalized_name.to_string();
|
||||
}
|
||||
sync_puzzle_primary_draft_fields_from_level(draft);
|
||||
session.updated_at = format_timestamp_micros(updated_at_micros);
|
||||
session
|
||||
}
|
||||
|
||||
fn sync_puzzle_primary_draft_fields_from_level(draft: &mut PuzzleResultDraftRecord) {
|
||||
let Some(primary_level) = draft.levels.first() else {
|
||||
return;
|
||||
@@ -2677,6 +2959,305 @@ fn replace_puzzle_session_draft_snapshot(
|
||||
session
|
||||
}
|
||||
|
||||
async fn generate_puzzle_work_tags(
|
||||
state: &AppState,
|
||||
work_title: &str,
|
||||
work_description: &str,
|
||||
) -> Vec<String> {
|
||||
if let Some(llm_client) = state.llm_client() {
|
||||
let user_prompt = build_puzzle_tag_generation_user_prompt(work_title, work_description);
|
||||
let response = llm_client
|
||||
.request_text(
|
||||
LlmTextRequest::new(vec![
|
||||
LlmMessage::system(PUZZLE_TAG_GENERATION_SYSTEM_PROMPT),
|
||||
LlmMessage::user(user_prompt),
|
||||
])
|
||||
.with_model(CREATION_TEMPLATE_LLM_MODEL)
|
||||
.with_responses_api(),
|
||||
)
|
||||
.await;
|
||||
match response {
|
||||
Ok(response) => {
|
||||
let tags = normalize_puzzle_tag_candidates(parse_puzzle_tags_from_text(
|
||||
response.content.as_str(),
|
||||
));
|
||||
if tags.len() == module_puzzle::PUZZLE_MAX_TAG_COUNT {
|
||||
return tags;
|
||||
}
|
||||
tracing::warn!(
|
||||
provider = PUZZLE_AGENT_API_BASE_PROVIDER,
|
||||
work_title,
|
||||
"拼图 AI 标签数量不足,降级使用关键词补齐"
|
||||
);
|
||||
}
|
||||
Err(error) => {
|
||||
tracing::warn!(
|
||||
provider = PUZZLE_AGENT_API_BASE_PROVIDER,
|
||||
work_title,
|
||||
error = %error,
|
||||
"拼图 AI 标签生成失败,降级使用关键词标签"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
normalize_puzzle_tag_candidates(build_fallback_puzzle_tags(work_title, work_description))
|
||||
}
|
||||
|
||||
fn parse_puzzle_tags_from_text(text: &str) -> Vec<String> {
|
||||
let trimmed = text.trim();
|
||||
let json_text = if let Some(start) = trimmed.find('{')
|
||||
&& let Some(end) = trimmed.rfind('}')
|
||||
&& end > start
|
||||
{
|
||||
&trimmed[start..=end]
|
||||
} else {
|
||||
trimmed
|
||||
};
|
||||
let Ok(value) = serde_json::from_str::<Value>(json_text) else {
|
||||
return normalize_puzzle_tag_candidates(trimmed.split([',', ',', '、', '\n']));
|
||||
};
|
||||
let Some(tags) = value.get("tags").and_then(Value::as_array) else {
|
||||
return Vec::new();
|
||||
};
|
||||
normalize_puzzle_tag_candidates(tags.iter().filter_map(Value::as_str))
|
||||
}
|
||||
|
||||
fn normalize_puzzle_tag_candidates<S>(candidates: impl IntoIterator<Item = S>) -> Vec<String>
|
||||
where
|
||||
S: AsRef<str>,
|
||||
{
|
||||
let mut tags = Vec::new();
|
||||
for candidate in candidates {
|
||||
let normalized = normalize_puzzle_tag(candidate.as_ref());
|
||||
if normalized.is_empty() || tags.iter().any(|tag| tag == &normalized) {
|
||||
continue;
|
||||
}
|
||||
tags.push(normalized);
|
||||
if tags.len() >= module_puzzle::PUZZLE_MAX_TAG_COUNT {
|
||||
break;
|
||||
}
|
||||
}
|
||||
for fallback in ["拼图", "插画", "清晰构图", "奇幻", "场景", "氛围"] {
|
||||
if tags.len() >= module_puzzle::PUZZLE_MAX_TAG_COUNT {
|
||||
break;
|
||||
}
|
||||
if !tags.iter().any(|tag| tag == fallback) {
|
||||
tags.push(fallback.to_string());
|
||||
}
|
||||
}
|
||||
tags
|
||||
}
|
||||
|
||||
fn normalize_puzzle_tag(value: &str) -> String {
|
||||
value
|
||||
.trim()
|
||||
.trim_matches(|ch: char| {
|
||||
ch.is_ascii_punctuation()
|
||||
|| matches!(
|
||||
ch,
|
||||
',' | '。' | '、' | ';' | ':' | '!' | '?' | '“' | '”' | '《' | '》'
|
||||
)
|
||||
})
|
||||
.trim_start_matches(|ch: char| ch.is_ascii_digit() || matches!(ch, '.' | '、' | ')' | ')'))
|
||||
.trim()
|
||||
.chars()
|
||||
.filter(|ch| !matches!(ch, '#' | '"' | '\'' | '`'))
|
||||
.take(6)
|
||||
.collect::<String>()
|
||||
}
|
||||
|
||||
fn build_fallback_puzzle_tags(work_title: &str, work_description: &str) -> Vec<&'static str> {
|
||||
let source = format!("{work_title} {work_description}");
|
||||
let mut tags = Vec::new();
|
||||
for (keyword, tag) in [
|
||||
("猫", "猫咪"),
|
||||
("狗", "小狗"),
|
||||
("神庙", "神庙遗迹"),
|
||||
("遗迹", "神庙遗迹"),
|
||||
("森林", "童话森林"),
|
||||
("雨", "雨夜"),
|
||||
("夜", "夜景"),
|
||||
("城市", "城市奇景"),
|
||||
("蒸汽", "蒸汽城市"),
|
||||
("机械", "机械幻想"),
|
||||
("海", "海岸"),
|
||||
("花", "花园"),
|
||||
("雪", "雪景"),
|
||||
("龙", "幻想生物"),
|
||||
("灯", "暖灯"),
|
||||
("塔", "高塔"),
|
||||
] {
|
||||
if source.contains(keyword) && !tags.contains(&tag) {
|
||||
tags.push(tag);
|
||||
}
|
||||
}
|
||||
tags.extend(["拼图", "插画", "清晰构图", "奇幻", "场景", "氛围"]);
|
||||
tags
|
||||
}
|
||||
|
||||
async fn save_generated_puzzle_tags_to_session(
|
||||
state: &AppState,
|
||||
session_id: &str,
|
||||
owner_user_id: &str,
|
||||
payload: &ExecutePuzzleAgentActionRequest,
|
||||
generated_tags: Vec<String>,
|
||||
levels_json: Option<String>,
|
||||
now: i64,
|
||||
) -> Result<PuzzleAgentSessionRecord, AppError> {
|
||||
let session = state
|
||||
.spacetime_client()
|
||||
.get_puzzle_agent_session(session_id.to_string(), owner_user_id.to_string())
|
||||
.await
|
||||
.map_err(map_puzzle_client_error)?;
|
||||
let draft = session.draft.clone().ok_or_else(|| {
|
||||
AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({
|
||||
"provider": PUZZLE_AGENT_API_BASE_PROVIDER,
|
||||
"message": "拼图结果页草稿尚未生成",
|
||||
}))
|
||||
})?;
|
||||
let mut levels = if let Some(levels_json) = levels_json.as_deref() {
|
||||
parse_puzzle_level_records_from_module_json(levels_json)?
|
||||
} else {
|
||||
draft.levels.clone()
|
||||
};
|
||||
if levels.is_empty() {
|
||||
levels = draft.levels.clone();
|
||||
}
|
||||
let first_level = levels.first().cloned().ok_or_else(|| {
|
||||
AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({
|
||||
"provider": PUZZLE_AGENT_API_BASE_PROVIDER,
|
||||
"message": "拼图草稿缺少可编辑关卡",
|
||||
}))
|
||||
})?;
|
||||
let work_title = payload
|
||||
.work_title
|
||||
.as_deref()
|
||||
.map(str::trim)
|
||||
.filter(|value| !value.is_empty())
|
||||
.unwrap_or(draft.work_title.as_str())
|
||||
.to_string();
|
||||
let work_description = payload
|
||||
.work_description
|
||||
.as_deref()
|
||||
.map(str::trim)
|
||||
.filter(|value| !value.is_empty())
|
||||
.unwrap_or(draft.work_description.as_str())
|
||||
.to_string();
|
||||
let levels_json = Some(serialize_puzzle_level_records_for_module(&levels)?);
|
||||
let (_, profile_id) = build_stable_puzzle_work_ids(session_id);
|
||||
state
|
||||
.spacetime_client()
|
||||
.update_puzzle_work(PuzzleWorkUpsertRecordInput {
|
||||
profile_id,
|
||||
owner_user_id: owner_user_id.to_string(),
|
||||
work_title: work_title.clone(),
|
||||
work_description: work_description.clone(),
|
||||
level_name: first_level.level_name.clone(),
|
||||
summary: work_description.clone(),
|
||||
theme_tags: generated_tags.clone(),
|
||||
cover_image_src: first_level.cover_image_src.clone(),
|
||||
cover_asset_id: first_level.cover_asset_id.clone(),
|
||||
levels_json,
|
||||
updated_at_micros: now,
|
||||
})
|
||||
.await
|
||||
.map_err(map_puzzle_client_error)?;
|
||||
|
||||
Ok(apply_generated_puzzle_tags_to_session_snapshot(
|
||||
session,
|
||||
generated_tags,
|
||||
work_title,
|
||||
work_description,
|
||||
levels,
|
||||
now,
|
||||
))
|
||||
}
|
||||
|
||||
fn apply_generated_puzzle_tags_to_session_snapshot(
|
||||
mut session: PuzzleAgentSessionRecord,
|
||||
generated_tags: Vec<String>,
|
||||
work_title: String,
|
||||
work_description: String,
|
||||
levels: Vec<PuzzleDraftLevelRecord>,
|
||||
updated_at_micros: i64,
|
||||
) -> PuzzleAgentSessionRecord {
|
||||
let Some(draft) = session.draft.as_mut() else {
|
||||
return session;
|
||||
};
|
||||
draft.work_title = work_title;
|
||||
draft.work_description = work_description.clone();
|
||||
draft.summary = work_description;
|
||||
draft.theme_tags = generated_tags;
|
||||
draft.levels = levels;
|
||||
sync_puzzle_primary_draft_fields_from_level(draft);
|
||||
session.progress_percent = session.progress_percent.max(96);
|
||||
session.stage = if is_puzzle_session_snapshot_publish_ready(draft) {
|
||||
"ready_to_publish".to_string()
|
||||
} else {
|
||||
"image_refining".to_string()
|
||||
};
|
||||
session.last_assistant_reply = Some("作品标签已生成。".to_string());
|
||||
session.updated_at = format_timestamp_micros(updated_at_micros);
|
||||
session
|
||||
}
|
||||
|
||||
fn is_puzzle_session_snapshot_publish_ready(draft: &PuzzleResultDraftRecord) -> bool {
|
||||
!draft.work_title.trim().is_empty()
|
||||
&& !draft.work_description.trim().is_empty()
|
||||
&& draft.theme_tags.len() >= module_puzzle::PUZZLE_MIN_TAG_COUNT
|
||||
&& draft.theme_tags.len() <= module_puzzle::PUZZLE_MAX_TAG_COUNT
|
||||
&& !draft.levels.is_empty()
|
||||
&& draft.levels.iter().all(|level| {
|
||||
!level.level_name.trim().is_empty()
|
||||
&& level
|
||||
.cover_image_src
|
||||
.as_deref()
|
||||
.map(str::trim)
|
||||
.is_some_and(|value| !value.is_empty())
|
||||
})
|
||||
}
|
||||
|
||||
fn serialize_puzzle_level_records_for_module(
|
||||
levels: &[PuzzleDraftLevelRecord],
|
||||
) -> Result<String, AppError> {
|
||||
let payload = levels
|
||||
.iter()
|
||||
.map(|level| {
|
||||
json!({
|
||||
"level_id": level.level_id,
|
||||
"level_name": level.level_name,
|
||||
"picture_description": level.picture_description,
|
||||
"candidates": level
|
||||
.candidates
|
||||
.iter()
|
||||
.map(|candidate| {
|
||||
json!({
|
||||
"candidate_id": candidate.candidate_id,
|
||||
"image_src": candidate.image_src,
|
||||
"asset_id": candidate.asset_id,
|
||||
"prompt": candidate.prompt,
|
||||
"actual_prompt": candidate.actual_prompt,
|
||||
"source_type": candidate.source_type,
|
||||
"selected": candidate.selected,
|
||||
})
|
||||
})
|
||||
.collect::<Vec<_>>(),
|
||||
"selected_candidate_id": level.selected_candidate_id,
|
||||
"cover_image_src": level.cover_image_src,
|
||||
"cover_asset_id": level.cover_asset_id,
|
||||
"generation_status": level.generation_status,
|
||||
})
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
serde_json::to_string(&payload).map_err(|error| {
|
||||
AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({
|
||||
"provider": PUZZLE_AGENT_API_BASE_PROVIDER,
|
||||
"message": format!("拼图关卡列表序列化失败:{error}"),
|
||||
}))
|
||||
})
|
||||
}
|
||||
|
||||
fn is_spacetimedb_connectivity_app_error(error: &AppError) -> bool {
|
||||
matches!(
|
||||
error.status_code(),
|
||||
@@ -3069,6 +3650,84 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn puzzle_first_level_name_parser_accepts_json_and_normalizes_text() {
|
||||
assert_eq!(
|
||||
parse_puzzle_first_level_name_from_text(r#"{"levelName":"雨夜猫街"}"#),
|
||||
Some("雨夜猫街".to_string())
|
||||
);
|
||||
assert_eq!(
|
||||
parse_puzzle_first_level_name_from_text("1. 《暖灯猫街》"),
|
||||
Some("暖灯猫街".to_string())
|
||||
);
|
||||
assert_eq!(
|
||||
parse_puzzle_first_level_name_from_text(r#"{"levelName":"雨夜猫街画面"}"#),
|
||||
Some("雨夜猫街".to_string())
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn puzzle_first_level_name_fallback_uses_picture_keywords() {
|
||||
assert_eq!(
|
||||
build_fallback_puzzle_first_level_name("一只猫在雨夜灯牌下回头。"),
|
||||
"雨夜猫街"
|
||||
);
|
||||
assert_eq!(
|
||||
build_fallback_puzzle_first_level_name("看不出关键词的抽象色块。"),
|
||||
"奇境初见"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn puzzle_first_level_name_snapshot_defaults_work_title() {
|
||||
let levels_json = serde_json::to_string(&vec![json!({
|
||||
"level_id": "puzzle-level-1",
|
||||
"level_name": "猫画面",
|
||||
"picture_description": "一只猫在雨夜灯牌下回头。",
|
||||
"candidates": [],
|
||||
"selected_candidate_id": null,
|
||||
"cover_image_src": null,
|
||||
"cover_asset_id": null,
|
||||
"generation_status": "idle",
|
||||
})])
|
||||
.expect("levels json");
|
||||
let payload = ExecutePuzzleAgentActionRequest {
|
||||
action: "generate_puzzle_images".to_string(),
|
||||
prompt_text: None,
|
||||
reference_image_src: None,
|
||||
image_model: Some(PUZZLE_IMAGE_MODEL_GPT_IMAGE_2.to_string()),
|
||||
candidate_count: Some(1),
|
||||
candidate_id: None,
|
||||
level_id: Some("puzzle-level-1".to_string()),
|
||||
work_title: Some("猫画面".to_string()),
|
||||
work_description: None,
|
||||
picture_description: None,
|
||||
level_name: None,
|
||||
summary: None,
|
||||
theme_tags: Some(vec![]),
|
||||
levels_json: Some(levels_json.clone()),
|
||||
};
|
||||
let session = build_puzzle_session_snapshot_from_action_payload(
|
||||
"puzzle-session-1",
|
||||
&payload,
|
||||
Some(levels_json.as_str()),
|
||||
1_713_686_401_234_567,
|
||||
)
|
||||
.expect("fallback session");
|
||||
|
||||
let renamed = apply_generated_puzzle_first_level_name_to_session_snapshot(
|
||||
session,
|
||||
"puzzle-level-1",
|
||||
"雨夜猫街",
|
||||
"猫画面",
|
||||
1_713_686_401_234_568,
|
||||
);
|
||||
let draft = renamed.draft.expect("draft");
|
||||
assert_eq!(draft.level_name, "雨夜猫街");
|
||||
assert_eq!(draft.work_title, "雨夜猫街");
|
||||
assert_eq!(draft.levels[0].level_name, "雨夜猫街");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn freeze_boundary_sync_only_matches_freeze_invalid_operation() {
|
||||
let invalid_operation =
|
||||
|
||||
@@ -115,7 +115,7 @@ pub async fn begin_story_runtime_session(
|
||||
story_session_payload_from_record(story_result.session),
|
||||
vec![story_event_payload_from_record(story_result.event)],
|
||||
&persisted,
|
||||
persisted.version,
|
||||
None,
|
||||
),
|
||||
},
|
||||
))
|
||||
@@ -257,7 +257,7 @@ pub async fn resolve_story_runtime_action(
|
||||
story_session_payload_from_record(story_result.session),
|
||||
vec![story_event_payload_from_record(story_result.event)],
|
||||
&persisted,
|
||||
resolved.server_version.max(persisted.version),
|
||||
Some(resolved.server_version),
|
||||
),
|
||||
},
|
||||
))
|
||||
@@ -395,7 +395,7 @@ fn build_story_runtime_projection_from_persisted(
|
||||
story_session: StorySessionPayload,
|
||||
story_events: Vec<StoryEventPayload>,
|
||||
record: &RuntimeSnapshotRecord,
|
||||
server_version: u32,
|
||||
resolved_version: Option<u32>,
|
||||
) -> shared_contracts::story::StoryRuntimeProjectionResponse {
|
||||
let snapshot = story_runtime_snapshot_payload_from_record(record);
|
||||
let current_story = snapshot.current_story.as_ref();
|
||||
@@ -405,6 +405,8 @@ fn build_story_runtime_projection_from_persisted(
|
||||
.or_else(|| Some(story_session.latest_narrative_text.clone()));
|
||||
let action_result_text = read_story_runtime_current_field(current_story, "resultText");
|
||||
let toast = read_story_runtime_current_field(current_story, "toast");
|
||||
let server_version =
|
||||
resolve_story_runtime_projection_version(&snapshot.game_state, resolved_version);
|
||||
|
||||
module_runtime_story::build_story_runtime_projection(
|
||||
module_runtime_story::StoryRuntimeProjectionSource {
|
||||
@@ -420,6 +422,15 @@ fn build_story_runtime_projection_from_persisted(
|
||||
)
|
||||
}
|
||||
|
||||
fn resolve_story_runtime_projection_version(
|
||||
game_state: &Value,
|
||||
resolved_version: Option<u32>,
|
||||
) -> u32 {
|
||||
module_runtime_story::read_u32_field(game_state, "runtimeActionVersion")
|
||||
.or(resolved_version)
|
||||
.unwrap_or(1)
|
||||
}
|
||||
|
||||
fn read_story_runtime_current_text(current_story: Option<&Value>) -> Option<String> {
|
||||
read_story_runtime_current_field(current_story, "text")
|
||||
.or_else(|| read_story_runtime_current_field(current_story, "storyText"))
|
||||
@@ -619,10 +630,12 @@ mod tests {
|
||||
use time::OffsetDateTime;
|
||||
use tower::ServiceExt;
|
||||
|
||||
use super::require_story_session_owner;
|
||||
use super::{build_story_runtime_projection_from_persisted, require_story_session_owner};
|
||||
use crate::{
|
||||
app::build_router, config::AppConfig, request_context::RequestContext, state::AppState,
|
||||
};
|
||||
use module_runtime::RuntimeSnapshotRecord;
|
||||
use shared_contracts::story::StorySessionPayload;
|
||||
|
||||
#[tokio::test]
|
||||
async fn begin_story_session_requires_authentication() {
|
||||
@@ -1028,6 +1041,56 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn story_runtime_projection_version_prefers_runtime_action_version() {
|
||||
let projection = build_story_runtime_projection_from_persisted(
|
||||
StorySessionPayload {
|
||||
story_session_id: "storysess_001".to_string(),
|
||||
runtime_session_id: "runtime_001".to_string(),
|
||||
actor_user_id: "user_1".to_string(),
|
||||
world_profile_id: "profile_1".to_string(),
|
||||
initial_prompt: "进入营地".to_string(),
|
||||
opening_summary: Some("营地开场".to_string()),
|
||||
latest_narrative_text: "最新故事".to_string(),
|
||||
latest_choice_function_id: Some("npc_chat".to_string()),
|
||||
status: "active".to_string(),
|
||||
version: 9,
|
||||
created_at: "1.000000Z".to_string(),
|
||||
updated_at: "3.000000Z".to_string(),
|
||||
},
|
||||
vec![],
|
||||
&RuntimeSnapshotRecord {
|
||||
user_id: "user_1".to_string(),
|
||||
version: 2,
|
||||
saved_at: "3.000000Z".to_string(),
|
||||
saved_at_micros: 3,
|
||||
bottom_tab: "adventure".to_string(),
|
||||
game_state: json!({
|
||||
"runtimeSessionId": "runtime_001",
|
||||
"runtimeActionVersion": 7,
|
||||
"playerHp": 30,
|
||||
"playerMaxHp": 40,
|
||||
"playerMana": 10,
|
||||
"playerMaxMana": 20,
|
||||
"playerCurrency": 0,
|
||||
"playerInventory": [],
|
||||
"playerEquipment": { "weapon": null, "armor": null, "relic": null },
|
||||
"inBattle": false,
|
||||
"npcInteractionActive": false,
|
||||
"storyHistory": []
|
||||
}),
|
||||
current_story: None,
|
||||
game_state_json: "{}".to_string(),
|
||||
current_story_json: None,
|
||||
created_at_micros: 1,
|
||||
updated_at_micros: 3,
|
||||
},
|
||||
None,
|
||||
);
|
||||
|
||||
assert_eq!(projection.server_version, 7);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn story_session_owner_guard_rejects_mismatched_actor() {
|
||||
let context = RequestContext::new(
|
||||
|
||||
Reference in New Issue
Block a user