1
This commit is contained in:
@@ -30,7 +30,7 @@ where
|
||||
.await
|
||||
}
|
||||
|
||||
/// 生图等特殊操作可声明独立光点成本,避免修改全局资产操作默认价格。
|
||||
/// 生图等特殊操作可声明独立泥点成本,避免修改全局资产操作默认价格。
|
||||
pub(crate) async fn execute_billable_asset_operation_with_cost<T, Fut>(
|
||||
state: &AppState,
|
||||
owner_user_id: &str,
|
||||
@@ -63,7 +63,7 @@ where
|
||||
}
|
||||
}
|
||||
|
||||
/// 资产操作统一预扣光点;扣费流水 ID 由业务资源 ID 参与构造,保证重试幂等。
|
||||
/// 资产操作统一预扣泥点;扣费流水 ID 由业务资源 ID 参与构造,保证重试幂等。
|
||||
async fn consume_asset_operation_points(
|
||||
state: &AppState,
|
||||
owner_user_id: &str,
|
||||
@@ -93,7 +93,7 @@ async fn consume_asset_operation_points(
|
||||
asset_kind,
|
||||
asset_id,
|
||||
error = %error,
|
||||
"资产操作光点预扣因 SpacetimeDB 连接不可用而降级跳过"
|
||||
"资产操作泥点预扣因 SpacetimeDB 连接不可用而降级跳过"
|
||||
);
|
||||
Ok(false)
|
||||
}
|
||||
@@ -128,7 +128,7 @@ async fn refund_asset_operation_points(
|
||||
asset_kind,
|
||||
asset_id,
|
||||
error = %error,
|
||||
"资产操作失败后的光点退款失败"
|
||||
"资产操作失败后的泥点退款失败"
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -138,10 +138,10 @@ pub(crate) fn map_asset_operation_wallet_error(error: SpacetimeClientError) -> A
|
||||
tracing::warn!(
|
||||
provider = "profile-wallet",
|
||||
error = %message,
|
||||
"资产操作光点预扣失败"
|
||||
"资产操作泥点预扣失败"
|
||||
);
|
||||
let status = match &error {
|
||||
SpacetimeClientError::Procedure(message) if message.contains("光点余额不足") => {
|
||||
SpacetimeClientError::Procedure(message) if message.contains("泥点余额不足") => {
|
||||
StatusCode::CONFLICT
|
||||
}
|
||||
_ => StatusCode::BAD_GATEWAY,
|
||||
@@ -191,7 +191,7 @@ mod tests {
|
||||
),
|
||||
));
|
||||
assert!(!should_skip_asset_operation_billing_for_connectivity(
|
||||
&SpacetimeClientError::Procedure("光点余额不足".to_string()),
|
||||
&SpacetimeClientError::Procedure("泥点余额不足".to_string()),
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -20,7 +20,7 @@ pub async fn get_public_user_by_code(
|
||||
.get_user_by_public_user_code(&code)
|
||||
.map_err(map_public_user_search_error)?
|
||||
.ok_or_else(|| {
|
||||
AppError::from_status(StatusCode::NOT_FOUND).with_message("未找到对应百梦号用户")
|
||||
AppError::from_status(StatusCode::NOT_FOUND).with_message("未找到对应陶泥号用户")
|
||||
})?;
|
||||
|
||||
Ok(json_success_body(
|
||||
@@ -60,7 +60,7 @@ pub async fn get_public_user_by_id(
|
||||
fn map_public_user_search_error(error: module_auth::PasswordEntryError) -> AppError {
|
||||
match error {
|
||||
module_auth::PasswordEntryError::InvalidPublicUserCode => {
|
||||
AppError::from_status(StatusCode::BAD_REQUEST).with_message("百梦号格式不正确")
|
||||
AppError::from_status(StatusCode::BAD_REQUEST).with_message("陶泥号格式不正确")
|
||||
}
|
||||
module_auth::PasswordEntryError::Store(_)
|
||||
| module_auth::PasswordEntryError::PasswordHash(_)
|
||||
|
||||
@@ -1294,7 +1294,7 @@ fn build_puzzle_result_profile_id(session_id: &str) -> String {
|
||||
}
|
||||
|
||||
fn build_creative_agent_system_prompt() -> &'static str {
|
||||
"你是创意互动内容生成 Agent。当前只开放拼图模板;必须显式展示模板选择、选择理由和预计光点范围,用户确认后才能创建草稿。"
|
||||
"你是创意互动内容生成 Agent。当前只开放拼图模板;必须显式展示模板选择、选择理由和预计泥点范围,用户确认后才能创建草稿。"
|
||||
}
|
||||
|
||||
fn map_puzzle_field_error(error: module_puzzle::PuzzleFieldError) -> AppError {
|
||||
|
||||
@@ -3487,7 +3487,7 @@ fn resolve_author_public_user_code(
|
||||
request_context,
|
||||
AppError::from_status(StatusCode::INTERNAL_SERVER_ERROR).with_details(json!({
|
||||
"provider": "custom-world-library",
|
||||
"message": format!("作者百梦号读取失败:{error}"),
|
||||
"message": format!("作者陶泥号读取失败:{error}"),
|
||||
})),
|
||||
)
|
||||
})?
|
||||
@@ -3498,7 +3498,7 @@ fn resolve_author_public_user_code(
|
||||
request_context,
|
||||
AppError::from_status(StatusCode::UNAUTHORIZED).with_details(json!({
|
||||
"provider": "custom-world-library",
|
||||
"message": "当前登录用户缺少百梦号",
|
||||
"message": "当前登录用户缺少陶泥号",
|
||||
})),
|
||||
)
|
||||
})
|
||||
|
||||
@@ -43,7 +43,7 @@ pub async fn generate_custom_world_foundation_draft(
|
||||
emit_foundation_draft_progress(
|
||||
&mut on_progress,
|
||||
"整理世界骨架",
|
||||
"正在根据百梦主锚点生成第一版世界框架。",
|
||||
"正在根据陶泥儿主锚点生成第一版世界框架。",
|
||||
12,
|
||||
);
|
||||
let mut framework = request_foundation_json_stage(
|
||||
|
||||
@@ -74,8 +74,8 @@ use crate::{
|
||||
config::AppConfig,
|
||||
http_error::AppError,
|
||||
openai_image_generation::{
|
||||
DownloadedOpenAiImage, build_openai_image_http_client, create_openai_image_generation,
|
||||
require_openai_image_settings,
|
||||
DownloadedOpenAiImage, OpenAiGeneratedImages, build_openai_image_http_client,
|
||||
create_openai_image_generation, require_openai_image_settings,
|
||||
},
|
||||
platform_errors::map_oss_error,
|
||||
request_context::RequestContext,
|
||||
@@ -1839,7 +1839,7 @@ async fn compile_match3d_draft_for_session(
|
||||
.await
|
||||
}
|
||||
|
||||
/// 中文注释:抓大鹅草稿生成是一次完整外部生成动作,按 session/profile 幂等预扣 10 光点。
|
||||
/// 中文注释:抓大鹅草稿生成是一次完整外部生成动作,按 session/profile 幂等预扣 10 泥点。
|
||||
async fn execute_billable_match3d_draft_generation<T, Fut>(
|
||||
state: &AppState,
|
||||
request_context: &RequestContext,
|
||||
@@ -1896,7 +1896,7 @@ async fn consume_match3d_draft_generation_points(
|
||||
owner_user_id,
|
||||
billing_asset_id,
|
||||
error = %error,
|
||||
"抓大鹅草稿光点预扣因 SpacetimeDB 连接不可用而降级跳过"
|
||||
"抓大鹅草稿泥点预扣因 SpacetimeDB 连接不可用而降级跳过"
|
||||
);
|
||||
Ok(false)
|
||||
}
|
||||
@@ -1931,7 +1931,7 @@ async fn refund_match3d_draft_generation_points(
|
||||
owner_user_id,
|
||||
billing_asset_id,
|
||||
error = %error,
|
||||
"抓大鹅草稿生成失败后的光点退款失败"
|
||||
"抓大鹅草稿生成失败后的泥点退款失败"
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -2530,6 +2530,26 @@ fn match3d_bad_gateway(message: impl Into<String>) -> AppError {
|
||||
}))
|
||||
}
|
||||
|
||||
fn match3d_background_music_missing_error(message: impl Into<String>) -> AppError {
|
||||
AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({
|
||||
"provider": MATCH3D_AGENT_PROVIDER,
|
||||
"message": message.into(),
|
||||
"missingAssets": ["背景音乐"],
|
||||
}))
|
||||
}
|
||||
|
||||
fn require_match3d_background_music_title(
|
||||
plan: &Match3DGeneratedBackgroundMusicPlan,
|
||||
) -> Result<String, AppError> {
|
||||
let title = normalize_match3d_audio_title(plan.title.as_str());
|
||||
if title.is_empty() {
|
||||
return Err(match3d_background_music_missing_error(
|
||||
"抓大鹅草稿背景音乐名称为空,无法完成背景音乐生成",
|
||||
));
|
||||
}
|
||||
Ok(title)
|
||||
}
|
||||
|
||||
fn map_match3d_work_profile_response(item: Match3DWorkProfileRecord) -> Match3DWorkProfileResponse {
|
||||
Match3DWorkProfileResponse {
|
||||
summary: map_match3d_work_summary_response(item),
|
||||
@@ -3164,7 +3184,7 @@ async fn generate_match3d_item_assets(
|
||||
&& assets
|
||||
.iter()
|
||||
.take(target_item_count)
|
||||
.any(|asset| asset.background_music.is_some())
|
||||
.any(has_match3d_background_music_audio)
|
||||
{
|
||||
return Ok(assets.into_iter().take(target_item_count).collect());
|
||||
}
|
||||
@@ -3369,9 +3389,11 @@ async fn generate_match3d_item_image_assets_in_batches(
|
||||
.iter()
|
||||
.position(|seed| !seed.persist_asset)
|
||||
.unwrap_or(chunk_seeds.len());
|
||||
debug_assert!(chunk_seeds[persisted_seed_count..]
|
||||
.iter()
|
||||
.all(|seed| !seed.persist_asset));
|
||||
debug_assert!(
|
||||
chunk_seeds[persisted_seed_count..]
|
||||
.iter()
|
||||
.all(|seed| !seed.persist_asset)
|
||||
);
|
||||
let persisted_seeds = chunk_seeds
|
||||
.into_iter()
|
||||
.take(persisted_seed_count)
|
||||
@@ -3585,13 +3607,16 @@ async fn ensure_match3d_background_music_asset(
|
||||
.min_by_key(|(_, asset)| match3d_item_sort_index(asset.item_id.as_str()))
|
||||
.map(|(index, _)| index)
|
||||
else {
|
||||
return Ok(assets);
|
||||
return Err(match3d_error_response(
|
||||
request_context,
|
||||
MATCH3D_AGENT_PROVIDER,
|
||||
match3d_background_music_missing_error("抓大鹅草稿缺少可写入背景音乐的物品素材"),
|
||||
));
|
||||
};
|
||||
|
||||
let title = normalize_match3d_audio_title(plan.title.as_str());
|
||||
if title.is_empty() {
|
||||
return Ok(assets);
|
||||
}
|
||||
let title = require_match3d_background_music_title(plan).map_err(|error| {
|
||||
match3d_error_response(request_context, MATCH3D_AGENT_PROVIDER, error)
|
||||
})?;
|
||||
let style = normalize_match3d_audio_style(plan.style.as_str());
|
||||
match generate_match3d_background_music_asset(state, owner_user_id, profile_id, &title, &style)
|
||||
.await
|
||||
@@ -3615,13 +3640,18 @@ async fn ensure_match3d_background_music_asset(
|
||||
.await?;
|
||||
}
|
||||
Err(error) => {
|
||||
tracing::warn!(
|
||||
tracing::error!(
|
||||
provider = MATCH3D_AGENT_PROVIDER,
|
||||
session_id,
|
||||
profile_id,
|
||||
error = %error,
|
||||
"抓大鹅草稿背景音乐生成失败,保留草稿并允许结果页重试"
|
||||
"抓大鹅草稿背景音乐生成失败,终止本次草稿生成并等待重试"
|
||||
);
|
||||
return Err(match3d_error_response(
|
||||
request_context,
|
||||
MATCH3D_AGENT_PROVIDER,
|
||||
error,
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4382,6 +4412,14 @@ fn is_match3d_generated_asset_image_ready(asset: &Match3DGeneratedItemAsset) ->
|
||||
view_count >= MATCH3D_ITEM_VIEW_COUNT
|
||||
}
|
||||
|
||||
fn has_match3d_background_music_audio(asset: &Match3DGeneratedItemAsset) -> bool {
|
||||
asset
|
||||
.background_music
|
||||
.as_ref()
|
||||
.map(|music| music.audio_src.trim())
|
||||
.is_some_and(|value| !value.is_empty())
|
||||
}
|
||||
|
||||
fn has_match3d_required_item_images(
|
||||
assets: &[Match3DGeneratedItemAsset],
|
||||
required_item_count: usize,
|
||||
@@ -4984,7 +5022,11 @@ async fn wait_match3d_apimart_generated_images(
|
||||
}
|
||||
let b64_images = extract_match3d_b64_images(&payload);
|
||||
if !b64_images.is_empty() {
|
||||
return Ok(match3d_images_from_base64(task_id.to_string(), b64_images, 1));
|
||||
return Ok(match3d_images_from_base64(
|
||||
task_id.to_string(),
|
||||
b64_images,
|
||||
1,
|
||||
));
|
||||
}
|
||||
return Err(
|
||||
AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({
|
||||
@@ -5026,7 +5068,8 @@ async fn download_match3d_images_from_urls(
|
||||
.into_iter()
|
||||
.take(candidate_count.clamp(1, 4) as usize)
|
||||
{
|
||||
images.push(download_match3d_remote_image(http_client, image_url.as_str(), provider).await?);
|
||||
images
|
||||
.push(download_match3d_remote_image(http_client, image_url.as_str(), provider).await?);
|
||||
}
|
||||
Ok(OpenAiGeneratedImages {
|
||||
task_id,
|
||||
@@ -6459,6 +6502,79 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn match3d_background_music_ready_requires_audio_src() {
|
||||
let mut asset = Match3DGeneratedItemAsset {
|
||||
item_id: "match3d-item-1".to_string(),
|
||||
item_name: "草莓".to_string(),
|
||||
image_src: None,
|
||||
image_object_key: None,
|
||||
image_views: Vec::new(),
|
||||
model_src: None,
|
||||
model_object_key: None,
|
||||
model_file_name: None,
|
||||
task_uuid: None,
|
||||
subscription_key: None,
|
||||
sound_prompt: None,
|
||||
background_music_title: Some("果园轻舞".to_string()),
|
||||
background_music_style: Some("轻快, 休闲".to_string()),
|
||||
background_music_prompt: Some(String::new()),
|
||||
background_music: None,
|
||||
click_sound: None,
|
||||
background_asset: None,
|
||||
status: "image_ready".to_string(),
|
||||
error: None,
|
||||
};
|
||||
|
||||
assert!(
|
||||
!has_match3d_background_music_audio(&asset),
|
||||
"只有音乐元信息时不能把草稿音乐阶段视为完成"
|
||||
);
|
||||
|
||||
asset.background_music = Some(CreationAudioAsset {
|
||||
task_id: "suno-task-1".to_string(),
|
||||
provider: "vector-engine-suno".to_string(),
|
||||
asset_object_id: Some("assetobj_1".to_string()),
|
||||
asset_kind: Some("match3d_background_music".to_string()),
|
||||
audio_src: "/generated-match3d-assets/music.mp3".to_string(),
|
||||
prompt: Some(String::new()),
|
||||
title: Some("果园轻舞".to_string()),
|
||||
updated_at: Some("2026-05-14T00:00:00Z".to_string()),
|
||||
});
|
||||
|
||||
assert!(has_match3d_background_music_audio(&asset));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn match3d_background_music_missing_error_lists_required_asset() {
|
||||
let error = match3d_background_music_missing_error("抓大鹅草稿背景音乐名称为空");
|
||||
let body = error.body_text();
|
||||
|
||||
assert!(body.contains("抓大鹅草稿背景音乐名称为空"));
|
||||
assert!(body.contains("背景音乐"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn match3d_background_music_title_is_required_for_auto_draft() {
|
||||
let missing = require_match3d_background_music_title(&Match3DGeneratedBackgroundMusicPlan {
|
||||
title: " ,。 ".to_string(),
|
||||
style: "轻快, 休闲".to_string(),
|
||||
prompt: String::new(),
|
||||
})
|
||||
.expect_err("自动草稿背景音乐必须有可提交给 Suno 的曲名");
|
||||
|
||||
assert!(missing.body_text().contains("背景音乐"));
|
||||
|
||||
let title = require_match3d_background_music_title(&Match3DGeneratedBackgroundMusicPlan {
|
||||
title: " 果园轻舞。 ".to_string(),
|
||||
style: "轻快, 休闲".to_string(),
|
||||
prompt: String::new(),
|
||||
})
|
||||
.expect("valid title should pass");
|
||||
|
||||
assert_eq!(title, "果园轻舞");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn match3d_work_metadata_parses_gpt4o_json() {
|
||||
let metadata = parse_match3d_work_metadata(
|
||||
@@ -6618,10 +6734,14 @@ mod tests {
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
|
||||
let plan = build_match3d_item_asset_append_plan(vec!["新物品".to_string()], &existing_assets);
|
||||
let plan =
|
||||
build_match3d_item_asset_append_plan(vec!["新物品".to_string()], &existing_assets);
|
||||
|
||||
assert_eq!(plan.requested_item_names, vec!["新物品"]);
|
||||
assert_eq!(plan.padded_item_names.len(), MATCH3D_MATERIAL_ITEM_BATCH_SIZE);
|
||||
assert_eq!(
|
||||
plan.padded_item_names.len(),
|
||||
MATCH3D_MATERIAL_ITEM_BATCH_SIZE
|
||||
);
|
||||
assert_eq!(plan.padded_item_names[0], "新物品");
|
||||
}
|
||||
|
||||
@@ -6660,6 +6780,29 @@ mod tests {
|
||||
assert!(negative_prompt.contains("真实 3D 渲染"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn match3d_material_sheet_request_uses_apimart_nanobanana_contract() {
|
||||
let body = build_match3d_apimart_nanobanana_image_request_body(
|
||||
"生成水果素材图",
|
||||
"文字、水印",
|
||||
MATCH3D_MATERIAL_APIMART_SIZE,
|
||||
);
|
||||
|
||||
assert_eq!(body["model"], MATCH3D_MATERIAL_APIMART_MODEL);
|
||||
assert_eq!(body["size"], MATCH3D_MATERIAL_APIMART_SIZE);
|
||||
assert_eq!(body["resolution"], MATCH3D_MATERIAL_APIMART_RESOLUTION);
|
||||
assert_eq!(body["n"], 1);
|
||||
assert_eq!(body["official_fallback"], true);
|
||||
assert!(body.get("image").is_none());
|
||||
assert!(body.get("image_urls").is_none());
|
||||
assert!(
|
||||
body["prompt"]
|
||||
.as_str()
|
||||
.unwrap_or_default()
|
||||
.contains("文字、水印")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn match3d_background_and_container_prompts_keep_ui_layers_split() {
|
||||
let config = config("水果", 3, 3);
|
||||
|
||||
@@ -96,7 +96,7 @@ fn map_password_entry_error(error: PasswordEntryError) -> AppError {
|
||||
"field": "password",
|
||||
})),
|
||||
PasswordEntryError::InvalidPublicUserCode => AppError::from_status(StatusCode::BAD_REQUEST)
|
||||
.with_message("百梦号格式不正确")
|
||||
.with_message("陶泥号格式不正确")
|
||||
.with_details(json!({
|
||||
"field": "phone",
|
||||
})),
|
||||
|
||||
@@ -10,7 +10,7 @@ use crate::creation_agent_anchor_templates::{
|
||||
};
|
||||
use crate::creation_agent_chat::render_quick_fill_extra_rules;
|
||||
|
||||
pub(crate) const BIG_FISH_AGENT_SYSTEM_PROMPT: &str = r#"你是一个负责和百梦主共创“大鱼吃小鱼”竖屏玩法的中文创意策划。
|
||||
pub(crate) const BIG_FISH_AGENT_SYSTEM_PROMPT: &str = r#"你是一个负责和陶泥儿主共创“大鱼吃小鱼”竖屏玩法的中文创意策划。
|
||||
|
||||
你必须把用户灵感收束成可以编译为可玩草稿的玩法、生态视觉、成长阶梯和风险节奏。
|
||||
|
||||
|
||||
@@ -12,7 +12,7 @@ use crate::creation_agent_chat::render_quick_fill_extra_rules;
|
||||
/// 拼图共创 Agent 的系统提示词。
|
||||
///
|
||||
/// 这里作为拼图聊天提示词主源,业务文件只负责调用 LLM、解析结果和写回状态。
|
||||
pub(crate) const PUZZLE_AGENT_SYSTEM_PROMPT: &str = r#"你是一个负责和百梦主共创拼图画面的中文创意策划。
|
||||
pub(crate) const PUZZLE_AGENT_SYSTEM_PROMPT: &str = r#"你是一个负责和陶泥儿主共创拼图画面的中文创意策划。
|
||||
|
||||
你要帮助用户把一句灵感逐步收束成可以发布成拼图关卡的视觉方案。
|
||||
|
||||
|
||||
@@ -14,7 +14,7 @@ pub(crate) const PUZZLE_TEXT_TO_IMAGE_PROMPT_MAX_CHARS: usize = 500;
|
||||
const PUZZLE_IMAGE_LEVEL_NAME_MAX_CHARS: usize = 40;
|
||||
const PUZZLE_IMAGE_PROMPT_FALLBACK: &str = "清晰、有辨识度的拼图画面";
|
||||
|
||||
/// 根据拼图关卡名和百梦主输入构造最终发给图片模型的提示词。
|
||||
/// 根据拼图关卡名和陶泥儿主输入构造最终发给图片模型的提示词。
|
||||
pub(crate) fn build_puzzle_image_prompt(level_name: &str, prompt: &str) -> String {
|
||||
let level_name =
|
||||
truncate_puzzle_prompt_segment(level_name.trim(), PUZZLE_IMAGE_LEVEL_NAME_MAX_CHARS);
|
||||
|
||||
@@ -6,7 +6,7 @@ use crate::creation_agent_chat::render_quick_fill_extra_rules;
|
||||
/// 方洞挑战共创 Agent 的系统提示词。
|
||||
///
|
||||
/// 这里只定义模型职责与输出约束,具体的模型调用、解析和写库由方洞 Agent turn 负责。
|
||||
pub(crate) const SQUARE_HOLE_AGENT_SYSTEM_PROMPT: &str = r#"你是一个负责和百梦主共创“方洞挑战”竖屏玩法的中文创意策划。
|
||||
pub(crate) const SQUARE_HOLE_AGENT_SYSTEM_PROMPT: &str = r#"你是一个负责和陶泥儿主共创“方洞挑战”竖屏玩法的中文创意策划。
|
||||
|
||||
你要把用户灵感收束成一个反直觉形状分拣小游戏:玩家会本能把形状投入对应洞口,但真实规则可能让所有形状都优先进入方洞,形成类似参考视频“所有东西都进方洞”的喜剧反差。
|
||||
|
||||
|
||||
@@ -6,7 +6,7 @@ use shared_contracts::visual_novel::{VisualNovelResultDraft, VisualNovelRuntimeS
|
||||
|
||||
use crate::llm_model_routing::CREATION_TEMPLATE_LLM_MODEL;
|
||||
|
||||
pub(crate) const VISUAL_NOVEL_CREATION_SYSTEM_PROMPT: &str = r#"你是百梦平台内的视觉小说模板创作导演。
|
||||
pub(crate) const VISUAL_NOVEL_CREATION_SYSTEM_PROMPT: &str = r#"你是陶泥儿平台内的视觉小说模板创作导演。
|
||||
|
||||
你的任务是把用户的一句话、文档摘要或空白创建意图,生成一份可以进入结果页继续编辑的 VisualNovelResultDraft。
|
||||
|
||||
@@ -21,7 +21,7 @@ pub(crate) const VISUAL_NOVEL_CREATION_SYSTEM_PROMPT: &str = r#"你是百梦平
|
||||
8. publishReady 只有在 opening 场景、主要角色、剧情阶段和 2 到 4 个 initialChoices 都齐备时才可以为 true。
|
||||
"#;
|
||||
|
||||
pub(crate) const VISUAL_NOVEL_RUNTIME_GM_SYSTEM_PROMPT: &str = r#"你是百梦视觉小说运行时 GM。
|
||||
pub(crate) const VISUAL_NOVEL_RUNTIME_GM_SYSTEM_PROMPT: &str = r#"你是陶泥儿视觉小说运行时 GM。
|
||||
|
||||
你的任务是读取作品底稿、当前 run snapshot、玩家动作和最近历史,然后输出下一轮 VisualNovelRuntimeStep[]。
|
||||
|
||||
|
||||
@@ -258,7 +258,7 @@ pub async fn generate_puzzle_onboarding_work(
|
||||
profile_id: format!("onboarding-profile-{now}"),
|
||||
owner_user_id: "onboarding-guest".to_string(),
|
||||
source_session_id: None,
|
||||
author_display_name: "百梦主".to_string(),
|
||||
author_display_name: "陶泥儿主".to_string(),
|
||||
work_title: level_name.clone(),
|
||||
work_description: prompt_text.clone(),
|
||||
level_name,
|
||||
@@ -3436,18 +3436,20 @@ fn attach_puzzle_level_ui_background(
|
||||
levels[index].ui_background_image_object_key = Some(generated.object_key);
|
||||
}
|
||||
|
||||
async fn try_generate_puzzle_background_music(
|
||||
async fn generate_puzzle_background_music_required(
|
||||
state: &AppState,
|
||||
owner_user_id: &str,
|
||||
session_id: &str,
|
||||
profile_id: &str,
|
||||
title: &str,
|
||||
) -> Option<CreationAudioAsset> {
|
||||
) -> Result<CreationAudioAsset, AppError> {
|
||||
let normalized_title = title.trim();
|
||||
if normalized_title.is_empty() {
|
||||
return None;
|
||||
return Err(AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({
|
||||
"provider": PUZZLE_AGENT_API_BASE_PROVIDER,
|
||||
"message": "拼图草稿背景音乐名称为空,无法完成背景音乐生成",
|
||||
})));
|
||||
}
|
||||
match generate_background_music_asset_for_creation(
|
||||
generate_background_music_asset_for_creation(
|
||||
state,
|
||||
owner_user_id,
|
||||
String::new(),
|
||||
@@ -3464,50 +3466,72 @@ async fn try_generate_puzzle_background_music(
|
||||
},
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(music) => Some(music),
|
||||
Err(error) => {
|
||||
tracing::warn!(
|
||||
provider = PUZZLE_AGENT_API_BASE_PROVIDER,
|
||||
session_id,
|
||||
profile_id,
|
||||
error = %error,
|
||||
"拼图草稿背景音乐生成失败,保留草稿并允许结果页重试"
|
||||
);
|
||||
None
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async fn try_generate_puzzle_initial_ui_background(
|
||||
async fn generate_puzzle_initial_ui_background_required(
|
||||
state: &AppState,
|
||||
owner_user_id: &str,
|
||||
session_id: &str,
|
||||
draft: &PuzzleResultDraftRecord,
|
||||
target_level: &PuzzleDraftLevelRecord,
|
||||
) -> Option<(String, GeneratedPuzzleUiBackgroundResponse)> {
|
||||
) -> Result<(String, GeneratedPuzzleUiBackgroundResponse), AppError> {
|
||||
let prompt = normalize_puzzle_ui_background_prompt("", draft, target_level);
|
||||
match generate_puzzle_ui_background_image(
|
||||
let generated = generate_puzzle_ui_background_image(
|
||||
state,
|
||||
owner_user_id,
|
||||
session_id,
|
||||
target_level.level_name.as_str(),
|
||||
prompt.as_str(),
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(generated) => Some((prompt, generated)),
|
||||
Err(error) => {
|
||||
tracing::warn!(
|
||||
provider = PUZZLE_AGENT_API_BASE_PROVIDER,
|
||||
session_id,
|
||||
level_id = %target_level.level_id,
|
||||
error = %error,
|
||||
"拼图草稿 UI 背景图自动生成失败,保留草稿并允许结果页重试"
|
||||
);
|
||||
None
|
||||
}
|
||||
.await?;
|
||||
Ok((prompt, generated))
|
||||
}
|
||||
|
||||
fn ensure_puzzle_initial_level_assets_ready(
|
||||
level: &PuzzleDraftLevelRecord,
|
||||
) -> Result<(), AppError> {
|
||||
let has_background_music = level
|
||||
.background_music
|
||||
.as_ref()
|
||||
.map(|music| music.audio_src.trim())
|
||||
.is_some_and(|value| !value.is_empty());
|
||||
let has_ui_background = level
|
||||
.ui_background_image_src
|
||||
.as_deref()
|
||||
.map(str::trim)
|
||||
.is_some_and(|value| !value.is_empty())
|
||||
|| level
|
||||
.ui_background_image_object_key
|
||||
.as_deref()
|
||||
.map(str::trim)
|
||||
.is_some_and(|value| !value.is_empty());
|
||||
if has_background_music && has_ui_background {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let mut missing = Vec::new();
|
||||
if !has_background_music {
|
||||
missing.push("背景音乐");
|
||||
}
|
||||
if !has_ui_background {
|
||||
missing.push("UI背景图");
|
||||
}
|
||||
|
||||
Err(AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({
|
||||
"provider": PUZZLE_AGENT_API_BASE_PROVIDER,
|
||||
"message": format!("拼图草稿资源生成未完成:缺少{}", missing.join("、")),
|
||||
"missingAssets": missing,
|
||||
})))
|
||||
}
|
||||
|
||||
fn find_puzzle_level_for_initial_asset_check<'a>(
|
||||
levels: &'a [PuzzleDraftLevelRecord],
|
||||
level_id: &str,
|
||||
) -> Option<&'a PuzzleDraftLevelRecord> {
|
||||
levels
|
||||
.iter()
|
||||
.find(|level| level.level_id == level_id)
|
||||
.or_else(|| levels.first())
|
||||
}
|
||||
|
||||
async fn compile_puzzle_draft_with_initial_cover(
|
||||
@@ -3587,38 +3611,41 @@ async fn compile_puzzle_draft_with_initial_cover(
|
||||
let mut updated_levels =
|
||||
build_puzzle_levels_with_primary_update(&draft, &target_level, reference_image_src);
|
||||
let music_title = resolve_puzzle_background_music_title(&draft, &target_level);
|
||||
// 中文注释:音乐和 UI 背景都只依赖最终关卡名与草稿快照,名称确定后即可并行生成。
|
||||
let (music_result, ui_background_result) = tokio::join!(
|
||||
try_generate_puzzle_background_music(
|
||||
// 中文注释:UI 背景先生成,避免其失败后留下已经扣费但未写入草稿的音乐资产。
|
||||
let (ui_prompt, ui_background) = generate_puzzle_initial_ui_background_required(
|
||||
state,
|
||||
owner_user_id.as_str(),
|
||||
compiled_session.session_id.as_str(),
|
||||
&draft,
|
||||
&target_level,
|
||||
)
|
||||
.await?;
|
||||
attach_puzzle_level_ui_background(
|
||||
&mut updated_levels,
|
||||
target_level.level_id.as_str(),
|
||||
ui_prompt,
|
||||
ui_background,
|
||||
);
|
||||
attach_puzzle_level_background_music(
|
||||
&mut updated_levels,
|
||||
target_level.level_id.as_str(),
|
||||
generate_puzzle_background_music_required(
|
||||
state,
|
||||
owner_user_id.as_str(),
|
||||
compiled_session.session_id.as_str(),
|
||||
profile_id.as_str(),
|
||||
music_title.as_str(),
|
||||
),
|
||||
try_generate_puzzle_initial_ui_background(
|
||||
state,
|
||||
owner_user_id.as_str(),
|
||||
compiled_session.session_id.as_str(),
|
||||
&draft,
|
||||
&target_level,
|
||||
),
|
||||
)
|
||||
.await?,
|
||||
);
|
||||
if let Some(music) = music_result {
|
||||
attach_puzzle_level_background_music(
|
||||
&mut updated_levels,
|
||||
target_level.level_id.as_str(),
|
||||
music,
|
||||
);
|
||||
}
|
||||
if let Some((ui_prompt, ui_background)) = ui_background_result {
|
||||
attach_puzzle_level_ui_background(
|
||||
&mut updated_levels,
|
||||
target_level.level_id.as_str(),
|
||||
ui_prompt,
|
||||
ui_background,
|
||||
);
|
||||
}
|
||||
let ready_level =
|
||||
find_puzzle_level_for_initial_asset_check(&updated_levels, target_level.level_id.as_str())
|
||||
.ok_or_else(|| {
|
||||
AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({
|
||||
"provider": PUZZLE_AGENT_API_BASE_PROVIDER,
|
||||
"message": "拼图草稿资源生成完成后未找到目标关卡",
|
||||
}))
|
||||
})?;
|
||||
ensure_puzzle_initial_level_assets_ready(ready_level)?;
|
||||
let levels_json_with_generated_name =
|
||||
Some(serialize_puzzle_level_records_for_module(&updated_levels)?);
|
||||
let candidates_json = serde_json::to_string(
|
||||
@@ -3794,38 +3821,41 @@ async fn compile_puzzle_draft_with_uploaded_cover(
|
||||
let mut updated_levels =
|
||||
build_puzzle_levels_with_primary_update(&draft, &target_level, reference_image_src);
|
||||
let music_title = resolve_puzzle_background_music_title(&draft, &target_level);
|
||||
// 中文注释:直用上传图时,名称分支和上传图落库完成后,再并行补齐音乐与 UI 背景。
|
||||
let (music_result, ui_background_result) = tokio::join!(
|
||||
try_generate_puzzle_background_music(
|
||||
// 中文注释:直用上传图时同样先补 UI 背景,再生成会单独扣费的音乐资产。
|
||||
let (ui_prompt, ui_background) = generate_puzzle_initial_ui_background_required(
|
||||
state,
|
||||
owner_user_id.as_str(),
|
||||
compiled_session.session_id.as_str(),
|
||||
&draft,
|
||||
&target_level,
|
||||
)
|
||||
.await?;
|
||||
attach_puzzle_level_ui_background(
|
||||
&mut updated_levels,
|
||||
target_level.level_id.as_str(),
|
||||
ui_prompt,
|
||||
ui_background,
|
||||
);
|
||||
attach_puzzle_level_background_music(
|
||||
&mut updated_levels,
|
||||
target_level.level_id.as_str(),
|
||||
generate_puzzle_background_music_required(
|
||||
state,
|
||||
owner_user_id.as_str(),
|
||||
compiled_session.session_id.as_str(),
|
||||
profile_id.as_str(),
|
||||
music_title.as_str(),
|
||||
),
|
||||
try_generate_puzzle_initial_ui_background(
|
||||
state,
|
||||
owner_user_id.as_str(),
|
||||
compiled_session.session_id.as_str(),
|
||||
&draft,
|
||||
&target_level,
|
||||
),
|
||||
)
|
||||
.await?,
|
||||
);
|
||||
if let Some(music) = music_result {
|
||||
attach_puzzle_level_background_music(
|
||||
&mut updated_levels,
|
||||
target_level.level_id.as_str(),
|
||||
music,
|
||||
);
|
||||
}
|
||||
if let Some((ui_prompt, ui_background)) = ui_background_result {
|
||||
attach_puzzle_level_ui_background(
|
||||
&mut updated_levels,
|
||||
target_level.level_id.as_str(),
|
||||
ui_prompt,
|
||||
ui_background,
|
||||
);
|
||||
}
|
||||
let ready_level =
|
||||
find_puzzle_level_for_initial_asset_check(&updated_levels, target_level.level_id.as_str())
|
||||
.ok_or_else(|| {
|
||||
AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({
|
||||
"provider": PUZZLE_AGENT_API_BASE_PROVIDER,
|
||||
"message": "拼图草稿资源生成完成后未找到目标关卡",
|
||||
}))
|
||||
})?;
|
||||
ensure_puzzle_initial_level_assets_ready(ready_level)?;
|
||||
let levels_json_with_generated_name =
|
||||
Some(serialize_puzzle_level_records_for_module(&updated_levels)?);
|
||||
let candidate = PuzzleGeneratedImageCandidateRecord {
|
||||
@@ -5268,6 +5298,36 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn puzzle_initial_draft_assets_must_include_music_and_ui_background() {
|
||||
let mut draft = test_puzzle_draft_record();
|
||||
let missing_all = ensure_puzzle_initial_level_assets_ready(&draft.levels[0])
|
||||
.expect_err("缺少自动生成资产时不能把草稿标记为完成");
|
||||
assert_eq!(missing_all.status_code(), StatusCode::BAD_GATEWAY);
|
||||
assert!(missing_all.body_text().contains("背景音乐"));
|
||||
assert!(missing_all.body_text().contains("UI背景图"));
|
||||
|
||||
draft.levels[0].ui_background_image_src =
|
||||
Some("/generated-puzzle-assets/session/ui/background.png".to_string());
|
||||
let missing_music = ensure_puzzle_initial_level_assets_ready(&draft.levels[0])
|
||||
.expect_err("只有 UI 背景时仍不能完成草稿");
|
||||
assert!(missing_music.body_text().contains("背景音乐"));
|
||||
|
||||
draft.levels[0].background_music = Some(PuzzleAudioAssetRecord {
|
||||
task_id: "suno-task-1".to_string(),
|
||||
provider: "vector-engine-suno".to_string(),
|
||||
asset_object_id: Some("assetobj_1".to_string()),
|
||||
asset_kind: Some("puzzle_background_music".to_string()),
|
||||
audio_src: "/generated-puzzle-assets/session/music.mp3".to_string(),
|
||||
prompt: Some(String::new()),
|
||||
title: Some("雨夜猫街".to_string()),
|
||||
updated_at: Some("2026-05-14T00:00:00Z".to_string()),
|
||||
});
|
||||
|
||||
ensure_puzzle_initial_level_assets_ready(&draft.levels[0])
|
||||
.expect("音乐和 UI 背景都存在时才能完成自动草稿");
|
||||
}
|
||||
|
||||
fn test_puzzle_anchor_pack_record() -> PuzzleAnchorPackRecord {
|
||||
let item = PuzzleAnchorItemRecord {
|
||||
key: "visualSubject".to_string(),
|
||||
@@ -5391,7 +5451,7 @@ mod tests {
|
||||
}));
|
||||
let other_error = AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({
|
||||
"provider": "spacetimedb",
|
||||
"message": "光点余额不足",
|
||||
"message": "泥点余额不足",
|
||||
}));
|
||||
|
||||
assert!(should_sync_puzzle_freeze_boundary(&invalid_operation, true));
|
||||
|
||||
@@ -24,7 +24,7 @@ pub async fn grant_new_user_registration_wallet_reward(
|
||||
operation = request_context.operation(),
|
||||
user_id = user_id,
|
||||
error = %error,
|
||||
"新用户注册光点赠送失败,注册流程继续"
|
||||
"新用户注册泥点赠送失败,注册流程继续"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -509,7 +509,7 @@ pub fn build_wechat_payment_request(
|
||||
) -> WechatMiniProgramOrderRequest {
|
||||
WechatMiniProgramOrderRequest {
|
||||
order_id,
|
||||
description: format!("百梦 - {product_title}"),
|
||||
description: format!("陶泥儿 - {product_title}"),
|
||||
amount_cents,
|
||||
payer_openid,
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user