Increase VectorEngine timeouts and add image UI

Add VectorEngine image generation config and raise request timeouts (env + scripts) from 180000 to 1000000ms. Introduce a reusable CreativeImageInputPanel component with tests and wire up mobile keyboard-focus helpers; update generation views and related tests (CustomWorldGenerationView, BarkBattle editor, Match3D, Puzzle flows). Improve API error handling / VectorEngine request guidance (packages/shared http.ts and docs), and apply multiple backend/frontend fixes for puzzle/match3d/prompt handling. Also include extensive docs and decision-log updates describing UI/UX decisions and verification steps.
This commit is contained in:
2026-05-15 02:40:59 +08:00
parent 4642855fd0
commit 74fd9a33ac
87 changed files with 5508 additions and 1261 deletions

View File

@@ -13,6 +13,7 @@ use platform_speech::{
const DEFAULT_INTERNAL_API_SECRET: &str = "genarrative-dev-internal-bridge";
const DEFAULT_AUTH_STORE_PATH: &str = "server-rs/.data/auth-store.json";
const SPACETIME_LOCAL_CONFIG_FILE: &str = "spacetime.local.json";
pub(crate) const DEFAULT_VECTOR_ENGINE_IMAGE_REQUEST_TIMEOUT_MS: u64 = 1_000_000;
// 集中管理 api-server 的启动配置,避免入口层直接散落环境变量解析逻辑。
#[derive(Clone, Debug)]
@@ -248,7 +249,7 @@ impl Default for AppConfig {
apimart_image_request_timeout_ms: 180_000,
vector_engine_base_url: String::new(),
vector_engine_api_key: None,
vector_engine_image_request_timeout_ms: 180_000,
vector_engine_image_request_timeout_ms: DEFAULT_VECTOR_ENGINE_IMAGE_REQUEST_TIMEOUT_MS,
vector_engine_audio_request_timeout_ms: 180_000,
hyper3d_base_url: "https://api.hyper3d.com/api/v2".to_string(),
hyper3d_api_key: None,
@@ -675,7 +676,9 @@ impl AppConfig {
if let Some(vector_engine_image_request_timeout_ms) =
read_first_positive_u64_env(&["VECTOR_ENGINE_IMAGE_REQUEST_TIMEOUT_MS"])
{
config.vector_engine_image_request_timeout_ms = vector_engine_image_request_timeout_ms;
// 中文注释VectorEngine image-2 实测可能超过 500 秒;旧环境文件中常见的 180 秒值不能再提前截断真实生图。
config.vector_engine_image_request_timeout_ms = vector_engine_image_request_timeout_ms
.max(DEFAULT_VECTOR_ENGINE_IMAGE_REQUEST_TIMEOUT_MS);
}
if let Some(vector_engine_audio_request_timeout_ms) =
@@ -1009,7 +1012,7 @@ fn parse_positive_u16(raw: &str) -> Option<u16> {
#[cfg(test)]
mod tests {
use super::{AppConfig, LlmProvider};
use super::{AppConfig, DEFAULT_VECTOR_ENGINE_IMAGE_REQUEST_TIMEOUT_MS, LlmProvider};
use std::sync::{Mutex, OnceLock};
static ENV_LOCK: OnceLock<Mutex<()>> = OnceLock::new();
@@ -1094,7 +1097,10 @@ mod tests {
config.vector_engine_base_url,
"https://vector.internal.example"
);
assert_eq!(config.vector_engine_image_request_timeout_ms, 210_000);
assert_eq!(
config.vector_engine_image_request_timeout_ms,
DEFAULT_VECTOR_ENGINE_IMAGE_REQUEST_TIMEOUT_MS
);
assert_eq!(
config.hyper3d_base_url,
"https://model.internal.example/api/v2"

View File

@@ -15,6 +15,7 @@ use serde_json::{Value, json};
use crate::{
api_response::json_success_body,
character_visual_assets::try_apply_background_alpha_to_png,
config::DEFAULT_VECTOR_ENGINE_IMAGE_REQUEST_TIMEOUT_MS,
http_error::AppError,
openai_image_generation::{
DownloadedOpenAiImage, OpenAiImageSettings, build_openai_image_http_client,
@@ -27,7 +28,6 @@ use crate::{
const BABY_OBJECT_MATCH_PROVIDER: &str = "vector-engine-gpt-image-2";
const BABY_OBJECT_MATCH_IMAGE_SIZE: &str = "1024x1024";
const BABY_OBJECT_MATCH_BACKGROUND_IMAGE_SIZE: &str = "1536x1024";
const BABY_OBJECT_MATCH_VECTOR_ENGINE_REQUEST_TIMEOUT_MS: u64 = 480_000;
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
@@ -207,7 +207,7 @@ fn build_baby_object_match_negative_prompt() -> &'static str {
fn with_baby_object_match_image_timeout(mut settings: OpenAiImageSettings) -> OpenAiImageSettings {
settings.request_timeout_ms = settings
.request_timeout_ms
.max(BABY_OBJECT_MATCH_VECTOR_ENGINE_REQUEST_TIMEOUT_MS);
.max(DEFAULT_VECTOR_ENGINE_IMAGE_REQUEST_TIMEOUT_MS);
settings
}
@@ -617,7 +617,7 @@ mod tests {
assert_eq!(
settings.request_timeout_ms,
BABY_OBJECT_MATCH_VECTOR_ENGINE_REQUEST_TIMEOUT_MS
DEFAULT_VECTOR_ENGINE_IMAGE_REQUEST_TIMEOUT_MS
);
}

View File

@@ -1023,32 +1023,14 @@ pub async fn generate_match3d_cover_image(
.await
.map_err(|error| match3d_error_response(&request_context, MATCH3D_WORKS_PROVIDER, error))?;
upsert_match3d_draft_snapshot(
let item = update_match3d_work_cover_only(
&state,
&request_context,
&authenticated,
context.session_id.clone(),
context.owner_user_id.clone(),
profile_id.clone(),
Some(context.profile.game_name),
Some(context.profile.summary),
Some(serde_json::to_string(&context.profile.tags).unwrap_or_default()),
Some(generated_cover.src.clone()),
None,
None,
context.owner_user_id.as_str(),
context.profile,
generated_cover.src.as_str(),
)
.await?;
let item = state
.spacetime_client()
.get_match3d_work_detail(profile_id.clone(), context.owner_user_id)
.await
.map_err(|error| {
match3d_error_response(
&request_context,
MATCH3D_WORKS_PROVIDER,
map_match3d_client_error(error),
)
})?;
Ok(json_success_body(
Some(&request_context),
@@ -1061,6 +1043,39 @@ pub async fn generate_match3d_cover_image(
))
}
async fn update_match3d_work_cover_only(
state: &AppState,
request_context: &RequestContext,
owner_user_id: &str,
profile: Match3DWorkProfileRecord,
cover_image_src: &str,
) -> Result<Match3DWorkProfileRecord, Response> {
// 中文注释:封面生成是定向图片槽位更新,不能复用草稿编译路径重算题材、难度或素材 JSON。
state
.spacetime_client()
.update_match3d_work(Match3DWorkUpdateRecordInput {
profile_id: profile.profile_id,
owner_user_id: owner_user_id.to_string(),
game_name: profile.game_name,
theme_text: profile.theme_text,
summary_text: profile.summary,
tags_json: serde_json::to_string(&normalize_tags(profile.tags)).unwrap_or_default(),
cover_image_src: cover_image_src.to_string(),
cover_asset_id: profile.cover_asset_id.unwrap_or_default(),
clear_count: profile.clear_count,
difficulty: profile.difficulty,
updated_at_micros: current_utc_micros(),
})
.await
.map_err(|error| {
match3d_error_response(
request_context,
MATCH3D_WORKS_PROVIDER,
map_match3d_client_error(error),
)
})
}
pub async fn generate_match3d_background_image_for_work(
State(state): State<AppState>,
Path(profile_id): Path<String>,
@@ -4804,6 +4819,7 @@ async fn generate_match3d_background_image(
"message": "抓大鹅容器 UI 图生成失败:未返回图片",
}))
})?;
let container_image = make_match3d_container_image_transparent(container_image)?;
let container_upload = persist_match3d_generated_bytes(
state,
owner_user_id,
@@ -4864,6 +4880,7 @@ async fn generate_match3d_container_image(
"message": "抓大鹅容器 UI 图生成失败:未返回图片",
}))
})?;
let container_image = make_match3d_container_image_transparent(container_image)?;
let container_upload = persist_match3d_generated_bytes(
state,
owner_user_id,
@@ -4956,10 +4973,40 @@ fn build_match3d_container_generation_prompt(config: &Match3DConfigJson, prompt:
.map(|style| format!("整体美术风格参考:{style}"))
.unwrap_or_default();
format!(
"{prompt}\n{style_clause}生成一张 1:1 抓大鹅中心容器 UI 图,只绘制一个贴合题材设定的圆形或浅盘状竞技容器。严格参考输入参考图的容器范围和视图角度:容器外轮廓必须接近画布四边,占画布宽度约 86%-92%、高度约 82%-90%,中心在画布中心略偏下,只保留少量透明或纯净留白;视角为轻俯视 3/4 上方视角,能看到圆形碗体外壁、厚实前沿和横向椭圆形内口,不能画成正俯视扁圆盘、侧视碗、小托盘或居中的小容器。容器需要有清晰外沿、内侧可放置 2D 物品的干净空间、轻微阴影和高辨识边界;背景必须透明感或纯净留白,不能做成整页背景。禁止文字、水印、按钮、倒计时、分数、物品、角色、手、教程浮层和菜单。"
"{prompt}\n{style_clause}生成一张 1:1 抓大鹅中心容器 UI 图,只绘制一个贴合题材设定的圆形或浅盘状竞技容器。严格参考输入参考图的容器范围和视图角度:容器外轮廓必须接近画布四边,占画布宽度约 86%-92%、高度约 82%-90%,中心在画布中心略偏下,只保留少量透明留白;视角为轻俯视 3/4 上方视角,能看到圆形碗体外壁、厚实前沿和横向椭圆形内口,不能画成正俯视扁圆盘、侧视碗、小托盘或居中的小容器。容器需要有清晰外沿、内侧可放置 2D 物品的干净空间、轻微阴影和高辨识边界;背景必须透明 alpha不得出现白底、纯色底、渐变底、场景底或整页背景。禁止文字、水印、按钮、倒计时、分数、物品、角色、手、教程浮层和菜单。"
)
}
fn make_match3d_container_image_transparent(
image: DownloadedOpenAiImage,
) -> Result<DownloadedOpenAiImage, AppError> {
let source = image::load_from_memory(image.bytes.as_slice()).map_err(|error| {
AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({
"provider": "match3d-assets",
"message": format!("抓大鹅容器图解码失败:{error}"),
}))
})?;
let mut rgba = source.to_rgba8();
let (width, height) = rgba.dimensions();
remove_match3d_container_plain_background(rgba.as_mut(), width as usize, height as usize);
let mut encoded = std::io::Cursor::new(Vec::new());
image::DynamicImage::ImageRgba8(rgba)
.write_to(&mut encoded, ImageFormat::Png)
.map_err(|error| {
AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({
"provider": "match3d-assets",
"message": format!("抓大鹅容器图透明化失败:{error}"),
}))
})?;
Ok(DownloadedOpenAiImage {
bytes: encoded.into_inner(),
mime_type: "image/png".to_string(),
extension: "png".to_string(),
})
}
async fn generate_match3d_material_sheet(
state: &AppState,
config: &Match3DConfigJson,
@@ -6232,6 +6279,45 @@ fn remove_match3d_material_green_screen_background(
}
}
// 中文注释:较厚的抗锯齿绿边可能低于 hard 阈值;先沿整张 sheet 的透明背景向内吃掉
// 软绿边,再进入格子裁剪,避免每张切图自带绿色描边。
let soft_green_cleanup_rounds = (width.min(height) / 40).clamp(4, 14);
for _ in 0..soft_green_cleanup_rounds {
let mut expanded_mask = background_mask.clone();
let mut changed_this_round = false;
for y in 0..height {
for x in 0..width {
let pixel_index = y * width + x;
if background_mask[pixel_index] != 0 {
continue;
}
let offset = pixel_index * 4;
let pixel = [
pixels[offset],
pixels[offset + 1],
pixels[offset + 2],
pixels[offset + 3],
];
let green_score = green_scores[pixel_index];
let white_score = white_scores[pixel_index];
if !is_match3d_material_soft_green_matte_pixel(pixel, green_score, white_score) {
continue;
}
if !touches_match3d_material_background_mask(x, y, width, height, &background_mask)
{
continue;
}
expanded_mask[pixel_index] = 1;
changed_this_round = true;
}
}
background_mask = expanded_mask;
if !changed_this_round {
break;
}
}
// 中文注释:主体边缘常带一圈绿幕或白底抗锯齿,扩一层软边,避免切割后残留毛边。
for _ in 0..2 {
let mut expanded_mask = background_mask.clone();
@@ -6372,9 +6458,10 @@ fn remove_match3d_material_green_screen_background(
}
} else {
if green_score > 0.04 {
green = green
.max(red.max(blue))
.max((green - (green - red.max(blue)) * 0.78).round());
let toned_green = (green - (green - red.max(blue)) * 0.78)
.round()
.max(red.max(blue));
green = green.min(toned_green).min(red.max(blue) + 18.0);
}
if white_score > 0.12 {
@@ -6417,6 +6504,50 @@ fn remove_match3d_material_green_screen_background(
changed
}
fn touches_match3d_material_background_mask(
x: usize,
y: usize,
width: usize,
height: usize,
background_mask: &[u8],
) -> bool {
for offset_y in -1i32..=1 {
for offset_x in -1i32..=1 {
if offset_x == 0 && offset_y == 0 {
continue;
}
let next_x = x as i32 + offset_x;
let next_y = y as i32 + offset_y;
if next_x < 0 || next_x >= width as i32 || next_y < 0 || next_y >= height as i32 {
return true;
}
if background_mask[next_y as usize * width + next_x as usize] != 0 {
return true;
}
}
}
false
}
fn is_match3d_material_soft_green_matte_pixel(
pixel: [u8; 4],
green_score: f32,
white_score: f32,
) -> bool {
if pixel[3] == 0 || green_score < MATCH3D_MATERIAL_GREEN_SCREEN_MIN_SCORE {
return false;
}
let red = pixel[0];
let green = pixel[1];
let blue = pixel[2];
let foreground_mix = red.max(blue);
green >= 188
&& white_score < 0.34
&& green.saturating_sub(foreground_mix) >= 42
&& (red >= 48 || blue >= 96 || pixel[3] < 236)
}
fn compute_match3d_material_green_screen_score(pixel: [u8; 4]) -> f32 {
if pixel[3] == 0 {
return 1.0;
@@ -6463,6 +6594,146 @@ fn compute_match3d_material_white_screen_score(pixel: [u8; 4]) -> f32 {
clamp_match3d_material_unit(neutrality * (brightness * 0.85 + floor * 0.15))
}
fn remove_match3d_container_plain_background(
pixels: &mut [u8],
width: usize,
height: usize,
) -> bool {
let pixel_count = width.saturating_mul(height);
if pixel_count == 0 || pixels.len() < pixel_count.saturating_mul(4) {
return false;
}
let mut background_mask = vec![0u8; pixel_count];
let mut queue = Vec::<usize>::new();
let mut queue_index = 0usize;
let seed_pixel = |pixel_index: usize, background_mask: &mut [u8], queue: &mut Vec<usize>| {
if background_mask[pixel_index] != 0 {
return;
}
let offset = pixel_index * 4;
if is_match3d_container_background_pixel([
pixels[offset],
pixels[offset + 1],
pixels[offset + 2],
pixels[offset + 3],
]) {
background_mask[pixel_index] = 1;
queue.push(pixel_index);
}
};
for x in 0..width {
seed_pixel(x, &mut background_mask, &mut queue);
seed_pixel((height - 1) * width + x, &mut background_mask, &mut queue);
}
for y in 1..height.saturating_sub(1) {
seed_pixel(y * width, &mut background_mask, &mut queue);
seed_pixel(y * width + width - 1, &mut background_mask, &mut queue);
}
while queue_index < queue.len() {
let pixel_index = queue[queue_index];
queue_index += 1;
let x = pixel_index % width;
let y = pixel_index / width;
let neighbors = [
(x > 0).then(|| pixel_index - 1),
(x + 1 < width).then_some(pixel_index + 1),
(y > 0).then(|| pixel_index - width),
(y + 1 < height).then_some(pixel_index + width),
];
for next_pixel_index in neighbors.into_iter().flatten() {
if background_mask[next_pixel_index] != 0 {
continue;
}
let offset = next_pixel_index * 4;
if is_match3d_container_background_pixel([
pixels[offset],
pixels[offset + 1],
pixels[offset + 2],
pixels[offset + 3],
]) {
background_mask[next_pixel_index] = 1;
queue.push(next_pixel_index);
}
}
}
// 中文注释:图生图偶尔会在容器边缘留下白底抗锯齿,扩一层只清理连到背景的浅色边。
for _ in 0..2 {
let mut expanded_mask = background_mask.clone();
for y in 0..height {
for x in 0..width {
let pixel_index = y * width + x;
if background_mask[pixel_index] != 0 {
continue;
}
let offset = pixel_index * 4;
let pixel = [
pixels[offset],
pixels[offset + 1],
pixels[offset + 2],
pixels[offset + 3],
];
if !is_match3d_container_soft_background_pixel(pixel) {
continue;
}
let mut adjacent_background_count = 0usize;
for offset_y in -1i32..=1 {
for offset_x in -1i32..=1 {
if offset_x == 0 && offset_y == 0 {
continue;
}
let next_x = x as i32 + offset_x;
let next_y = y as i32 + offset_y;
if next_x < 0
|| next_x >= width as i32
|| next_y < 0
|| next_y >= height as i32
{
adjacent_background_count += 1;
continue;
}
if background_mask[next_y as usize * width + next_x as usize] != 0 {
adjacent_background_count += 1;
}
}
}
if adjacent_background_count >= 3 {
expanded_mask[pixel_index] = 1;
}
}
}
background_mask = expanded_mask;
}
let mut changed = false;
for pixel_index in 0..pixel_count {
if background_mask[pixel_index] == 0 {
continue;
}
let offset = pixel_index * 4;
if pixels[offset + 3] != 0 {
pixels[offset + 3] = 0;
changed = true;
}
}
changed
}
fn is_match3d_container_background_pixel(pixel: [u8; 4]) -> bool {
pixel[3] < 16 || compute_match3d_material_white_screen_score(pixel) > 0.34
}
fn is_match3d_container_soft_background_pixel(pixel: [u8; 4]) -> bool {
pixel[3] < 80 || compute_match3d_material_white_screen_score(pixel) > 0.18
}
fn collect_match3d_material_foreground_neighbor_color(
pixels: &[u8],
width: usize,
@@ -7148,6 +7419,51 @@ mod tests {
);
}
#[test]
fn match3d_material_sheet_slicing_removes_soft_green_matte_before_crop() {
let width = 500;
let height = 500;
let item_names = vec!["草莓".to_string()];
let mut sheet = image::RgbaImage::from_pixel(width, height, image::Rgba([0, 255, 0, 255]));
for y in 28..72 {
for x in 28..72 {
sheet.put_pixel(x, y, image::Rgba([64, 198, 112, 255]));
}
}
for y in 36..64 {
for x in 36..64 {
sheet.put_pixel(x, y, image::Rgba([220, 32, 48, 255]));
}
}
let mut encoded = std::io::Cursor::new(Vec::new());
image::DynamicImage::ImageRgba8(sheet)
.write_to(&mut encoded, ImageFormat::Png)
.expect("sheet should encode");
let image = DownloadedOpenAiImage {
bytes: encoded.into_inner(),
mime_type: "image/png".to_string(),
extension: "png".to_string(),
};
let slices = slice_match3d_material_sheet(&image, &item_names).expect("sheet should slice");
let decoded = image::load_from_memory(slices[0][0].bytes.as_slice())
.expect("view should decode")
.to_rgba8();
assert!(
decoded.pixels().all(|pixel| {
let [red, green, blue, alpha] = pixel.0;
alpha == 0 || green <= red.max(blue).saturating_add(32)
}),
"整张 sheet 去绿后再裁剪,输出 PNG 不能保留可见软绿边"
);
assert!(
decoded.pixels().any(|pixel| pixel.0 == [220, 32, 48, 255]),
"软绿边清理不能误删物品主体"
);
}
#[test]
fn match3d_material_sheet_slicing_cleans_white_matte_edge() {
let width = 500;
@@ -7193,6 +7509,46 @@ mod tests {
);
}
#[test]
fn match3d_container_image_postprocess_removes_plain_background() {
let width = 256;
let height = 256;
let mut image =
image::RgbaImage::from_pixel(width, height, image::Rgba([248, 248, 246, 255]));
for y in 68..190 {
for x in 38..218 {
image.put_pixel(x, y, image::Rgba([160, 104, 54, 255]));
}
}
let mut encoded = std::io::Cursor::new(Vec::new());
image::DynamicImage::ImageRgba8(image)
.write_to(&mut encoded, ImageFormat::Png)
.expect("container should encode");
let processed = make_match3d_container_image_transparent(DownloadedOpenAiImage {
bytes: encoded.into_inner(),
mime_type: "image/png".to_string(),
extension: "png".to_string(),
})
.expect("container should postprocess");
let decoded = image::load_from_memory(processed.bytes.as_slice())
.expect("processed container should decode")
.to_rgba8();
assert_eq!(processed.mime_type, "image/png");
assert_eq!(processed.extension, "png");
assert_eq!(
decoded.get_pixel(0, 0).0[3],
0,
"容器图四周白底必须在入库前转成透明 alpha"
);
assert_eq!(
decoded.get_pixel(width / 2, height / 2).0[3],
255,
"容器主体不能被透明化误删"
);
}
#[test]
fn match3d_work_metadata_parses_gpt4o_json() {
let metadata = parse_match3d_work_metadata(
@@ -7544,12 +7900,12 @@ mod tests {
let root_settings = Match3DVectorEngineGeminiImageSettings {
base_url: "https://api.vectorengine.cn".to_string(),
api_key: "test-key".to_string(),
request_timeout_ms: 180_000,
request_timeout_ms: 1_000_000,
};
let v1_settings = Match3DVectorEngineGeminiImageSettings {
base_url: "https://api.vectorengine.cn/v1".to_string(),
api_key: "test-key".to_string(),
request_timeout_ms: 180_000,
request_timeout_ms: 1_000_000,
};
assert_eq!(
@@ -7584,7 +7940,9 @@ mod tests {
assert!(container_prompt.contains("轻俯视 3/4"));
assert!(container_prompt.contains("横向椭圆形内口"));
assert!(container_prompt.contains("不能画成正俯视扁圆盘"));
assert!(container_prompt.contains("不能做成整页背景"));
assert!(container_prompt.contains("透明 alpha"));
assert!(container_prompt.contains("白底"));
assert!(container_prompt.contains("整页背景"));
assert!(container_prompt.contains("禁止文字"));
}

View File

@@ -628,12 +628,12 @@ mod tests {
let root_settings = OpenAiImageSettings {
base_url: "https://vector.example".to_string(),
api_key: "test-key".to_string(),
request_timeout_ms: 180_000,
request_timeout_ms: 1_000_000,
};
let v1_settings = OpenAiImageSettings {
base_url: "https://vector.example/v1".to_string(),
api_key: "test-key".to_string(),
request_timeout_ms: 180_000,
request_timeout_ms: 1_000_000,
};
assert_eq!(

View File

@@ -1,34 +1,38 @@
/// 拼图首关关卡名与 UI 背景提示词生成提示词。
/// 拼图首关关卡名、作品元信息与 UI 背景提示词生成提示词。
///
/// 模型只负责把画面描述压缩成可直接展示的中文关卡名,并产出运行态 UI 背景的正向视觉提示词;
/// 写回草稿和作品卡由业务路由处理。
/// 模型只负责把画面描述压缩成可直接展示的中文关卡名、作品描述、作品标签,
/// 并产出运行态 UI 背景的正向视觉提示词;写回草稿和作品卡由业务路由处理。
pub(crate) const PUZZLE_FIRST_LEVEL_NAME_SYSTEM_PROMPT: &str = r#"你是一个中文拼图关卡命名编辑。
你会收到拼图第一关的画面描述,部分请求还会附带已经生成完成的正式图片。请综合图片内容和画面描述,同时生成:
- 1 个适合直接展示在游戏关卡卡片上的中文关卡名。
- 1 段适合默认填入拼图草稿的中文作品描述。
- 6 个适合作品广场检索和相似推荐的中文作品标签。
- 1 段用于生成 9:16 拼图运行态 UI 纯背景图的中文正向视觉提示词。
硬约束:
1. 只输出 JSON不要输出 Markdown、解释或代码块。
2. JSON 格式必须是 {"levelName":"关卡名","uiBackgroundPrompt":"提示词"}。
2. JSON 格式必须是 {"levelName":"关卡名","workDescription":"作品描述","workTags":["标签1","标签2","标签3","标签4","标签5","标签6"],"uiBackgroundPrompt":"提示词"}。
3. levelName 必须是 2 到 8 个中文字符为主。
4. 不要输出“第一关”“画面”“拼图”“作品”等泛词。
5. 不要输出标点、引号、编号、英文、emoji 或空白。
6. 关卡名要抓住画面主体、场景和氛围,读起来像一个具体可玩的关卡。
7. uiBackgroundPrompt 必须是 30160 个中文字符,描述题材氛围、环境、色彩、光影和空间层次
8. uiBackgroundPrompt 只写正向画面描述不要写规则说明不要出现拼图槽、棋盘、HUD、按钮、文字、水印、数字、拼图碎片、完整拼图图像或教程浮层
7. workDescription 必须是 1880 个中文字符,描述这套拼图的画面主题、氛围和游玩期待,不要复述字段名
8. workTags 必须正好 6 个,每个标签 2 到 6 个中文字符为主,覆盖题材、主体、氛围、场景、风格和拼图辨识点
9. uiBackgroundPrompt 必须是 30 到 160 个中文字符,描述题材氛围、环境、色彩、光影和空间层次。
10. uiBackgroundPrompt 只写正向画面描述不要写规则说明不要出现拼图槽、棋盘、HUD、按钮、文字、水印、数字、拼图碎片、完整拼图图像或教程浮层。
"#;
pub(crate) fn build_puzzle_first_level_name_user_prompt(picture_description: &str) -> String {
format!(
"画面描述:{picture_description}\n\n请生成第一关关卡名和 UI 背景提示词。",
"画面描述:{picture_description}\n\n请生成第一关关卡名、作品描述、6 个作品标签和 UI 背景提示词。",
picture_description = picture_description.trim(),
)
}
pub(crate) fn build_puzzle_first_level_name_vision_user_text(picture_description: &str) -> String {
format!(
"画面描述:{picture_description}\n\n请观察随消息附带的正式拼图图片,生成第一关关卡名和 UI 背景提示词。",
"画面描述:{picture_description}\n\n请观察随消息附带的正式拼图图片,生成第一关关卡名、作品描述、6 个作品标签和 UI 背景提示词。",
picture_description = picture_description.trim(),
)
}
@@ -43,6 +47,8 @@ mod tests {
assert!(prompt.contains("画面描述:一只猫在雨夜灯牌下回头。"));
assert!(prompt.contains("第一关关卡名"));
assert!(prompt.contains("作品描述"));
assert!(prompt.contains("6 个作品标签"));
assert!(prompt.contains("UI 背景提示词"));
}
@@ -52,6 +58,8 @@ mod tests {
assert!(prompt.contains("画面描述:一只猫在雨夜灯牌下回头。"));
assert!(prompt.contains("正式拼图图片"));
assert!(prompt.contains("作品描述"));
assert!(prompt.contains("6 个作品标签"));
assert!(prompt.contains("UI 背景提示词"));
}
}

View File

@@ -122,7 +122,9 @@ const PUZZLE_GENERATED_IMAGE_SIZE: &str = "1024*1024";
const PUZZLE_VECTOR_ENGINE_GENERATED_IMAGE_SIZE: &str = "1024x1024";
const VECTOR_ENGINE_PROVIDER: &str = "vector-engine";
const PUZZLE_LEVEL_NAME_VISION_IMAGE_MAX_SIDE: u32 = 768;
const PUZZLE_LEVEL_NAME_VISION_MAX_TOKENS: u32 = 512;
const PUZZLE_REFERENCE_IMAGE_MAX_BYTES: usize = 8 * 1024 * 1024;
const PUZZLE_REFERENCE_IMAGE_SOURCE_LIMIT: usize = 5;
const PUZZLE_VECTOR_ENGINE_IMAGE_EDIT_MODEL: &str = "gpt-image-2";
const PUZZLE_UI_BACKGROUND_PROMPT_FALLBACK_MARKER: &str =
"移动端拼图游戏纯背景,题材氛围清晰,不包含拼图槽或 UI 元素";
@@ -718,16 +720,20 @@ pub async fn execute_puzzle_agent_action(
.as_deref()
.map(|value| value.chars().count())
.unwrap_or(0),
has_reference_image = payload
.reference_image_src
.as_deref()
.map(|value| !value.trim().is_empty())
.unwrap_or(false),
has_reference_image = has_puzzle_reference_images(
payload.reference_image_src.as_deref(),
payload.reference_image_srcs.as_slice(),
),
"拼图 Agent action 开始执行"
);
let (operation_type, phase_label, phase_detail, session) = match action.as_str() {
"compile_puzzle_draft" => {
let ai_redraw = payload.ai_redraw.unwrap_or(true);
let reference_image_sources = collect_puzzle_reference_image_sources(
payload.reference_image_src.as_deref(),
payload.reference_image_srcs.as_slice(),
);
let primary_reference_image_src = reference_image_sources.first().map(String::as_str);
let prompt_text = payload
.picture_description
.as_deref()
@@ -760,7 +766,7 @@ pub async fn execute_puzzle_agent_action(
compile_session_id.clone(),
owner_user_id.clone(),
prompt_text,
payload.reference_image_src.as_deref(),
primary_reference_image_src,
payload.image_model.as_deref(),
now,
)
@@ -891,6 +897,12 @@ pub async fn execute_puzzle_agent_action(
payload.prompt_text.as_deref(),
&target_level.picture_description,
);
let reference_image_sources = collect_puzzle_reference_image_sources(
payload.reference_image_src.as_deref(),
payload.reference_image_srcs.as_slice(),
);
let primary_reference_image_src =
reference_image_sources.first().map(String::as_str);
// 拼图结果页从多候选抽卡收口为单图替换,前端传入的旧 candidateCount 只做兼容忽略。
let candidate_count = 1;
let candidate_start_index = target_level.candidates.len();
@@ -900,7 +912,7 @@ pub async fn execute_puzzle_agent_action(
&session.session_id,
&target_level.level_name,
&prompt,
payload.reference_image_src.as_deref(),
primary_reference_image_src,
payload.ai_redraw.unwrap_or(true),
payload.image_model.as_deref(),
candidate_count,
@@ -934,7 +946,7 @@ pub async fn execute_puzzle_agent_action(
&build_puzzle_levels_with_primary_update(
&draft,
&target_level,
payload.reference_image_src.as_deref(),
primary_reference_image_src,
),
)?);
let candidates_json = serde_json::to_string(
@@ -985,7 +997,7 @@ pub async fn execute_puzzle_agent_action(
),
target_level.level_id.as_str(),
candidates.into_records(),
payload.reference_image_src.as_deref(),
primary_reference_image_src,
now,
))
}
@@ -3067,6 +3079,8 @@ fn build_stable_puzzle_work_ids(session_id: &str) -> (String, String) {
#[derive(Clone, Debug, Eq, PartialEq)]
struct PuzzleLevelNaming {
level_name: String,
work_description: Option<String>,
work_tags: Vec<String>,
ui_background_prompt: Option<String>,
}
@@ -3074,6 +3088,8 @@ impl PuzzleLevelNaming {
fn fallback(picture_description: &str) -> Self {
Self {
level_name: build_fallback_puzzle_first_level_name(picture_description),
work_description: None,
work_tags: Vec::new(),
ui_background_prompt: None,
}
}
@@ -3150,7 +3166,7 @@ async fn generate_puzzle_first_level_name_from_image(
]),
])
.with_model(PUZZLE_LEVEL_NAME_VISION_LLM_MODEL)
.with_max_tokens(80),
.with_max_tokens(PUZZLE_LEVEL_NAME_VISION_MAX_TOKENS),
)
.await;
@@ -3217,6 +3233,9 @@ fn parse_puzzle_level_naming_from_text(text: &str) -> Option<PuzzleLevelNaming>
trimmed
};
let parsed = serde_json::from_str::<Value>(json_text).ok();
if parsed.is_none() && looks_like_puzzle_json_fragment(trimmed) {
return None;
}
let raw_name = parsed
.as_ref()
.and_then(|value| value.get("levelName").and_then(Value::as_str))
@@ -3227,12 +3246,21 @@ fn parse_puzzle_level_naming_from_text(text: &str) -> Option<PuzzleLevelNaming>
})
.unwrap_or(trimmed);
let level_name = normalize_puzzle_first_level_name(raw_name)?;
let work_description = parsed
.as_ref()
.and_then(parse_puzzle_generated_work_description_field);
let work_tags = parsed
.as_ref()
.and_then(parse_puzzle_generated_work_tags_field)
.unwrap_or_default();
let ui_background_prompt = parsed
.as_ref()
.and_then(parse_puzzle_ui_background_prompt_field);
Some(PuzzleLevelNaming {
level_name,
work_description,
work_tags,
ui_background_prompt,
})
}
@@ -3250,6 +3278,55 @@ fn parse_puzzle_ui_background_prompt_field(value: &Value) -> Option<String> {
.and_then(normalize_puzzle_generated_ui_background_prompt)
}
fn parse_puzzle_generated_work_description_field(value: &Value) -> Option<String> {
value
.get("workDescription")
.and_then(Value::as_str)
.or_else(|| value.get("work_description").and_then(Value::as_str))
.and_then(normalize_puzzle_generated_work_description)
}
fn normalize_puzzle_generated_work_description(value: &str) -> Option<String> {
let normalized = value
.trim()
.trim_matches(|ch: char| {
ch.is_ascii_punctuation()
|| matches!(
ch,
'' | '。' | '、' | '' | '' | '' | '' | '“' | '”' | '《' | '》'
)
})
.split_whitespace()
.collect::<Vec<_>>()
.join("");
let description = normalized.chars().take(80).collect::<String>();
(description.chars().count() >= 8 && !looks_like_puzzle_json_field_name(&description))
.then_some(description)
}
fn parse_puzzle_generated_work_tags_field(value: &Value) -> Option<Vec<String>> {
let tags_value = value
.get("workTags")
.or_else(|| value.get("work_tags"))
.or_else(|| value.get("themeTags"))
.or_else(|| value.get("theme_tags"))
.or_else(|| value.get("tags"))?;
let raw_tags = match tags_value {
Value::Array(items) => items
.iter()
.filter_map(Value::as_str)
.map(ToString::to_string)
.collect::<Vec<_>>(),
Value::String(text) => text
.split([',', '', '、', '\n', '|', '/'])
.map(ToString::to_string)
.collect::<Vec<_>>(),
_ => Vec::new(),
};
let tags = normalize_puzzle_generated_work_tag_candidates(raw_tags);
(tags.len() == module_puzzle::PUZZLE_MAX_TAG_COUNT).then_some(tags)
}
fn normalize_puzzle_generated_ui_background_prompt(value: &str) -> Option<String> {
let normalized = value
.trim()
@@ -3331,6 +3408,7 @@ fn normalize_puzzle_first_level_name(value: &str) -> Option<String> {
normalized.as_str(),
"第一关" | "画面" | "拼图" | "作品" | "关卡"
)
&& !looks_like_puzzle_json_field_name(&normalized)
{
Some(normalized)
} else {
@@ -3338,6 +3416,52 @@ fn normalize_puzzle_first_level_name(value: &str) -> Option<String> {
}
}
fn looks_like_puzzle_json_field_name(value: &str) -> bool {
let normalized = value.trim().trim_matches(|ch: char| {
ch.is_ascii_punctuation()
|| matches!(
ch,
'' | '。' | '、' | '' | '' | '' | '' | '“' | '”' | '《' | '》'
)
});
let compact = normalized.to_ascii_lowercase().replace('_', "");
matches!(compact.as_str(), "levelnam" | "levelname")
|| [
"levelname",
"workdescription",
"worktags",
"themetags",
"uibackgroundprompt",
]
.iter()
.any(|field| {
compact == *field
|| (compact.len() >= 6 && field.starts_with(compact.as_str()))
|| compact.starts_with(field)
})
}
fn looks_like_puzzle_json_fragment(value: &str) -> bool {
let trimmed = value.trim();
if trimmed.starts_with('{') || trimmed.starts_with('[') {
return true;
}
let lower = trimmed.to_ascii_lowercase();
[
"\"levelnam",
"\"levelname\"",
"\"level_name\"",
"\"workdescription\"",
"\"work_description\"",
"\"worktags\"",
"\"work_tags\"",
"\"uibackgroundprompt\"",
"\"ui_background_prompt\"",
]
.iter()
.any(|field| lower.contains(field))
}
fn strip_puzzle_level_name_generic_words(mut value: String) -> String {
for prefix in ["第一关", "关卡名", "关卡"] {
value = value.trim_start_matches(prefix).to_string();
@@ -3406,6 +3530,28 @@ fn build_puzzle_levels_with_primary_update(
levels
}
fn attach_selected_puzzle_candidate_to_levels(
levels: &mut [PuzzleDraftLevelRecord],
target_level_id: &str,
candidate: &PuzzleGeneratedImageCandidateRecord,
) {
if let Some(index) = levels
.iter()
.position(|level| level.level_id == target_level_id)
.or_else(|| (!levels.is_empty()).then_some(0))
{
let level = &mut levels[index];
level.candidates.clear();
let mut candidate = candidate.clone();
candidate.selected = true;
level.selected_candidate_id = Some(candidate.candidate_id.clone());
level.cover_image_src = Some(candidate.image_src.clone());
level.cover_asset_id = Some(candidate.asset_id.clone());
level.candidates.push(candidate);
level.generation_status = "ready".to_string();
}
}
fn resolve_puzzle_initial_ui_background_prompt(
draft: &PuzzleResultDraftRecord,
target_level: &PuzzleDraftLevelRecord,
@@ -3596,7 +3742,8 @@ async fn compile_puzzle_draft_with_initial_cover(
);
let (generated_naming, candidates_result) = tokio::join!(level_name_future, candidates_future);
target_level.level_name = generated_naming.level_name.clone();
target_level.ui_background_prompt = generated_naming.ui_background_prompt;
target_level.ui_background_prompt = generated_naming.ui_background_prompt.clone();
let mut generated_metadata = generated_naming;
let candidates = candidates_result?;
let selected_candidate_id = candidates
.iter()
@@ -3620,6 +3767,14 @@ async fn compile_puzzle_draft_with_initial_cover(
if refined_naming.ui_background_prompt.is_some() {
target_level.ui_background_prompt = refined_naming.ui_background_prompt;
}
if refined_naming.work_description.is_some() {
generated_metadata.work_description = refined_naming.work_description;
}
if refined_naming.work_tags.len() == module_puzzle::PUZZLE_MAX_TAG_COUNT {
generated_metadata.work_tags = refined_naming.work_tags;
}
generated_metadata.level_name = target_level.level_name.clone();
generated_metadata.ui_background_prompt = target_level.ui_background_prompt.clone();
}
let generated_level_name = target_level.level_name.clone();
let mut updated_levels =
@@ -3639,6 +3794,17 @@ async fn compile_puzzle_draft_with_initial_cover(
ui_prompt,
ui_background,
);
if let Some(selected_candidate) = candidates
.iter()
.find(|candidate| candidate.record.selected)
.or_else(|| candidates.first())
{
attach_selected_puzzle_candidate_to_levels(
&mut updated_levels,
target_level.level_id.as_str(),
&selected_candidate.record,
);
}
let ready_level =
find_puzzle_level_for_initial_asset_check(&updated_levels, target_level.level_id.as_str())
.ok_or_else(|| {
@@ -3650,6 +3816,28 @@ async fn compile_puzzle_draft_with_initial_cover(
ensure_puzzle_initial_level_assets_ready(ready_level)?;
let levels_json_with_generated_name =
Some(serialize_puzzle_level_records_for_module(&updated_levels)?);
let work_title = if draft.work_title.trim().is_empty()
|| draft.work_title.trim() == fallback_level_name.trim()
{
generated_level_name.clone()
} else {
draft.work_title.clone()
};
let work_description = if draft.work_description.trim().is_empty() {
generated_metadata
.work_description
.clone()
.unwrap_or_else(|| draft.work_description.clone())
} else {
draft.work_description.clone()
};
let theme_tags = if draft.theme_tags.is_empty()
&& generated_metadata.work_tags.len() == module_puzzle::PUZZLE_MAX_TAG_COUNT
{
generated_metadata.work_tags.clone()
} else {
draft.theme_tags.clone()
};
let candidates_json = serde_json::to_string(
&candidates
.iter()
@@ -3707,6 +3895,43 @@ async fn compile_puzzle_draft_with_initial_cover(
Err(error)
}
})?;
let (_, profile_id) = build_stable_puzzle_work_ids(&compiled_session.session_id);
match state
.spacetime_client()
.update_puzzle_work(PuzzleWorkUpsertRecordInput {
profile_id,
owner_user_id: owner_user_id.clone(),
work_title,
work_description: work_description.clone(),
level_name: generated_level_name.clone(),
summary: work_description,
theme_tags,
cover_image_src: ready_level.cover_image_src.clone(),
cover_asset_id: ready_level.cover_asset_id.clone(),
levels_json: levels_json_with_generated_name.clone(),
updated_at_micros: now,
})
.await
.map_err(map_puzzle_client_error)
{
Ok(_) => {}
Err(error) if is_spacetimedb_connectivity_app_error(&error) => {
tracing::warn!(
provider = PUZZLE_AGENT_API_BASE_PROVIDER,
session_id = %compiled_session.session_id,
owner_user_id = %owner_user_id,
message = %error.body_text(),
"拼图首图生成后作品元信息投影回写不可用,继续使用会话草稿快照"
);
}
Err(error) => return Err(error),
}
let saved_session = apply_generated_puzzle_initial_metadata_to_session_snapshot(
saved_session,
&generated_metadata,
fallback_level_name.as_str(),
now,
);
if save_used_fallback {
return Ok(saved_session);
}
@@ -3721,7 +3946,12 @@ async fn compile_puzzle_draft_with_initial_cover(
})
.await
{
Ok(session) => Ok(session),
Ok(session) => Ok(apply_generated_puzzle_initial_metadata_to_session_snapshot(
session,
&generated_metadata,
fallback_level_name.as_str(),
now,
)),
Err(error) if should_skip_asset_operation_billing_for_connectivity(&error) => {
tracing::warn!(
provider = PUZZLE_AGENT_API_BASE_PROVIDER,
@@ -3821,9 +4051,18 @@ async fn compile_puzzle_draft_with_uploaded_cover(
if refined_naming.ui_background_prompt.is_some() {
generated_naming.ui_background_prompt = refined_naming.ui_background_prompt;
}
if refined_naming.work_description.is_some() {
generated_naming.work_description = refined_naming.work_description;
}
if refined_naming.work_tags.len() == module_puzzle::PUZZLE_MAX_TAG_COUNT {
generated_naming.work_tags = refined_naming.work_tags;
}
}
target_level.level_name = generated_naming.level_name;
target_level.ui_background_prompt = generated_naming.ui_background_prompt;
target_level.level_name = generated_naming.level_name.clone();
target_level.ui_background_prompt = generated_naming.ui_background_prompt.clone();
let mut generated_metadata = generated_naming;
generated_metadata.level_name = target_level.level_name.clone();
generated_metadata.ui_background_prompt = target_level.ui_background_prompt.clone();
let generated_level_name = target_level.level_name.clone();
let persisted_upload = persisted_upload_result?;
let mut updated_levels =
@@ -3843,6 +4082,19 @@ async fn compile_puzzle_draft_with_uploaded_cover(
ui_prompt,
ui_background,
);
attach_selected_puzzle_candidate_to_levels(
&mut updated_levels,
target_level.level_id.as_str(),
&PuzzleGeneratedImageCandidateRecord {
candidate_id: candidate_id.clone(),
image_src: persisted_upload.image_src.clone(),
asset_id: persisted_upload.asset_id.clone(),
prompt: image_prompt.clone(),
actual_prompt: None,
source_type: "uploaded".to_string(),
selected: true,
},
);
let ready_level =
find_puzzle_level_for_initial_asset_check(&updated_levels, target_level.level_id.as_str())
.ok_or_else(|| {
@@ -3854,6 +4106,28 @@ async fn compile_puzzle_draft_with_uploaded_cover(
ensure_puzzle_initial_level_assets_ready(ready_level)?;
let levels_json_with_generated_name =
Some(serialize_puzzle_level_records_for_module(&updated_levels)?);
let work_title = if draft.work_title.trim().is_empty()
|| draft.work_title.trim() == fallback_level_name.trim()
{
generated_level_name.clone()
} else {
draft.work_title.clone()
};
let work_description = if draft.work_description.trim().is_empty() {
generated_metadata
.work_description
.clone()
.unwrap_or_else(|| draft.work_description.clone())
} else {
draft.work_description.clone()
};
let theme_tags = if draft.theme_tags.is_empty()
&& generated_metadata.work_tags.len() == module_puzzle::PUZZLE_MAX_TAG_COUNT
{
generated_metadata.work_tags.clone()
} else {
draft.theme_tags.clone()
};
let candidate = PuzzleGeneratedImageCandidateRecord {
candidate_id: candidate_id.clone(),
image_src: persisted_upload.image_src,
@@ -3916,6 +4190,43 @@ async fn compile_puzzle_draft_with_uploaded_cover(
Err(error)
}
})?;
let (_, profile_id) = build_stable_puzzle_work_ids(&compiled_session.session_id);
match state
.spacetime_client()
.update_puzzle_work(PuzzleWorkUpsertRecordInput {
profile_id,
owner_user_id: owner_user_id.clone(),
work_title,
work_description: work_description.clone(),
level_name: generated_level_name.clone(),
summary: work_description,
theme_tags,
cover_image_src: ready_level.cover_image_src.clone(),
cover_asset_id: ready_level.cover_asset_id.clone(),
levels_json: levels_json_with_generated_name.clone(),
updated_at_micros: now,
})
.await
.map_err(map_puzzle_client_error)
{
Ok(_) => {}
Err(error) if is_spacetimedb_connectivity_app_error(&error) => {
tracing::warn!(
provider = PUZZLE_AGENT_API_BASE_PROVIDER,
session_id = %compiled_session.session_id,
owner_user_id = %owner_user_id,
message = %error.body_text(),
"拼图上传图草稿作品元信息投影回写不可用,继续使用会话草稿快照"
);
}
Err(error) => return Err(error),
}
let saved_session = apply_generated_puzzle_initial_metadata_to_session_snapshot(
saved_session,
&generated_metadata,
fallback_level_name.as_str(),
now,
);
if save_used_fallback {
return Ok(saved_session);
}
@@ -3930,7 +4241,12 @@ async fn compile_puzzle_draft_with_uploaded_cover(
})
.await
{
Ok(session) => Ok(session),
Ok(session) => Ok(apply_generated_puzzle_initial_metadata_to_session_snapshot(
session,
&generated_metadata,
fallback_level_name.as_str(),
now,
)),
Err(error) if should_skip_asset_operation_billing_for_connectivity(&error) => {
tracing::warn!(
provider = PUZZLE_AGENT_API_BASE_PROVIDER,
@@ -4046,6 +4362,53 @@ fn apply_generated_puzzle_first_level_name_to_session_snapshot(
session
}
fn apply_generated_puzzle_initial_metadata_to_session_snapshot(
mut session: PuzzleAgentSessionRecord,
metadata: &PuzzleLevelNaming,
previous_level_name: &str,
updated_at_micros: i64,
) -> PuzzleAgentSessionRecord {
let Some(draft) = session.draft.as_mut() else {
return session;
};
apply_generated_puzzle_initial_metadata_to_draft(
draft,
metadata,
previous_level_name,
updated_at_micros,
);
session.updated_at = format_timestamp_micros(updated_at_micros);
session
}
fn apply_generated_puzzle_initial_metadata_to_draft(
draft: &mut PuzzleResultDraftRecord,
metadata: &PuzzleLevelNaming,
previous_level_name: &str,
_updated_at_micros: i64,
) {
let should_default_work_title =
draft.work_title.trim().is_empty() || draft.work_title.trim() == previous_level_name.trim();
if should_default_work_title {
draft.work_title = metadata.level_name.clone();
}
if draft.work_description.trim().is_empty()
&& let Some(description) = metadata.work_description.as_ref()
{
draft.work_description = description.clone();
draft.summary = description.clone();
}
if draft.theme_tags.is_empty()
&& metadata.work_tags.len() == module_puzzle::PUZZLE_MAX_TAG_COUNT
{
draft.theme_tags = metadata.work_tags.clone();
}
sync_puzzle_primary_draft_fields_from_level(draft);
}
fn sync_puzzle_primary_draft_fields_from_level(draft: &mut PuzzleResultDraftRecord) {
let Some(primary_level) = draft.levels.first() else {
return;
@@ -4056,6 +4419,16 @@ fn sync_puzzle_primary_draft_fields_from_level(draft: &mut PuzzleResultDraftReco
draft.cover_image_src = primary_level.cover_image_src.clone();
draft.cover_asset_id = primary_level.cover_asset_id.clone();
draft.generation_status = primary_level.generation_status.clone();
draft.summary = draft.work_description.clone();
if draft.form_draft.is_some() {
draft.form_draft = Some(PuzzleFormDraftRecord {
work_title: (!draft.work_title.trim().is_empty()).then_some(draft.work_title.clone()),
work_description: (!draft.work_description.trim().is_empty())
.then_some(draft.work_description.clone()),
picture_description: (!primary_level.picture_description.trim().is_empty())
.then_some(primary_level.picture_description.clone()),
});
}
}
fn replace_puzzle_session_draft_snapshot(
@@ -4170,6 +4543,9 @@ where
{
let mut tags = Vec::new();
for candidate in candidates {
if looks_like_puzzle_json_field_name(candidate.as_ref()) {
continue;
}
let normalized = normalize_puzzle_tag(candidate.as_ref());
if normalized.is_empty() || tags.iter().any(|tag| tag == &normalized) {
continue;
@@ -4190,6 +4566,29 @@ where
tags
}
fn normalize_puzzle_generated_work_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()
|| looks_like_puzzle_json_field_name(&normalized)
|| tags.iter().any(|tag| tag == &normalized)
{
continue;
}
tags.push(normalized);
if tags.len() >= module_puzzle::PUZZLE_MAX_TAG_COUNT {
break;
}
}
tags
}
fn normalize_puzzle_tag(value: &str) -> String {
value
.trim()
@@ -4931,6 +5330,26 @@ mod tests {
));
}
#[test]
fn puzzle_reference_image_sources_are_deduped_and_limited() {
let sources = collect_puzzle_reference_image_sources(
Some("data:image/png;base64,a"),
&[
"data:image/png;base64,a".to_string(),
"data:image/png;base64,b".to_string(),
"data:image/png;base64,c".to_string(),
"data:image/png;base64,d".to_string(),
"data:image/png;base64,e".to_string(),
"data:image/png;base64,f".to_string(),
],
);
assert_eq!(sources.len(), 5);
assert_eq!(sources[0], "data:image/png;base64,a");
assert_eq!(sources[1], "data:image/png;base64,b");
assert!(!sources.contains(&"data:image/png;base64,f".to_string()));
}
#[test]
fn puzzle_vector_engine_timeout_maps_to_gateway_timeout() {
let error = map_puzzle_vector_engine_request_error(
@@ -5008,6 +5427,7 @@ mod tests {
action: "generate_puzzle_images".to_string(),
prompt_text: None,
reference_image_src: None,
reference_image_srcs: Vec::new(),
image_model: Some(PUZZLE_IMAGE_MODEL_GPT_IMAGE_2.to_string()),
ai_redraw: None,
candidate_count: Some(1),
@@ -5055,16 +5475,28 @@ mod tests {
parse_puzzle_first_level_name_from_text(r#"{"levelName":"雨夜猫街画面"}"#),
Some("雨夜猫街".to_string())
);
assert_eq!(
parse_puzzle_first_level_name_from_text(r#"{"levelNam"#),
None
);
}
#[test]
fn puzzle_level_naming_parser_accepts_ui_background_prompt() {
fn puzzle_level_naming_parser_accepts_metadata_and_ui_background_prompt() {
let naming = parse_puzzle_level_naming_from_text(
r#"{"levelName":"雨夜猫街","uiBackgroundPrompt":"雨夜老街延展成竖屏空间,湿润石板路倒映暖色灯牌,远处屋檐和薄雾形成柔和层次"}"#,
r#"{"levelName":"雨夜猫街","workDescription":"在湿润灯牌与猫影之间完成一套雨夜街角拼图。","workTags":["雨夜","猫咪","灯牌","街角","暖色","插画"],"uiBackgroundPrompt":"雨夜老街延展成竖屏空间,湿润石板路倒映暖色灯牌,远处屋檐和薄雾形成柔和层次"}"#,
)
.expect("naming should parse");
assert_eq!(naming.level_name, "雨夜猫街");
assert_eq!(
naming.work_description.as_deref(),
Some("在湿润灯牌与猫影之间完成一套雨夜街角拼图")
);
assert_eq!(naming.work_tags.len(), module_puzzle::PUZZLE_MAX_TAG_COUNT);
assert!(naming.work_tags.contains(&"雨夜".to_string()));
assert!(naming.work_tags.contains(&"猫咪".to_string()));
assert!(naming.work_tags.contains(&"灯牌".to_string()));
assert_eq!(
naming.ui_background_prompt.as_deref(),
Some("雨夜老街延展成竖屏空间,湿润石板路倒映暖色灯牌,远处屋檐和薄雾形成柔和层次")
@@ -5139,6 +5571,7 @@ mod tests {
action: "generate_puzzle_images".to_string(),
prompt_text: None,
reference_image_src: None,
reference_image_srcs: Vec::new(),
image_model: Some(PUZZLE_IMAGE_MODEL_GPT_IMAGE_2.to_string()),
ai_redraw: None,
candidate_count: Some(1),
@@ -5173,6 +5606,61 @@ mod tests {
assert_eq!(draft.levels[0].level_name, "雨夜猫街");
}
#[test]
fn puzzle_initial_metadata_defaults_empty_work_description_and_tags() {
let mut session = PuzzleAgentSessionRecord {
session_id: "puzzle-session-1".to_string(),
seed_text: "画面描述:一只猫在雨夜灯牌下回头。".to_string(),
current_turn: 1,
progress_percent: 94,
stage: "ready_to_publish".to_string(),
anchor_pack: test_puzzle_anchor_pack_record(),
draft: Some(test_puzzle_draft_record()),
messages: Vec::new(),
last_assistant_reply: None,
published_profile_id: None,
suggested_actions: Vec::new(),
result_preview: None,
updated_at: "2024-01-01T00:00:00Z".to_string(),
};
{
let draft = session.draft.as_mut().expect("draft");
draft.work_title = "猫画面".to_string();
draft.work_description = String::new();
draft.summary = String::new();
draft.theme_tags = Vec::new();
}
let metadata = PuzzleLevelNaming {
level_name: "雨夜猫街".to_string(),
work_description: Some("在湿润灯牌与猫影之间完成一套雨夜街角拼图".to_string()),
work_tags: vec![
"插画".to_string(),
"灯牌".to_string(),
"街角".to_string(),
"猫咪".to_string(),
"暖色".to_string(),
"雨夜".to_string(),
],
ui_background_prompt: None,
};
let session = apply_generated_puzzle_initial_metadata_to_session_snapshot(
session,
&metadata,
"猫画面",
1_713_686_401_234_568,
);
let draft = session.draft.expect("draft");
assert_eq!(draft.work_title, "雨夜猫街");
assert_eq!(
draft.work_description,
"在湿润灯牌与猫影之间完成一套雨夜街角拼图"
);
assert_eq!(draft.summary, draft.work_description);
assert_eq!(draft.theme_tags, metadata.work_tags);
}
#[test]
fn puzzle_level_audio_asset_roundtrips_between_response_and_module_json() {
let level = PuzzleDraftLevelResponse {
@@ -5981,6 +6469,40 @@ fn has_puzzle_reference_image(reference_image_src: Option<&str>) -> bool {
.unwrap_or(false)
}
fn collect_puzzle_reference_image_sources(
legacy_reference_image_src: Option<&str>,
reference_image_srcs: &[String],
) -> Vec<String> {
let mut sources = Vec::new();
for source in legacy_reference_image_src
.into_iter()
.chain(reference_image_srcs.iter().map(String::as_str))
{
let normalized = source.trim();
if normalized.is_empty() {
continue;
}
if !sources
.iter()
.any(|existing: &String| existing == normalized)
{
sources.push(normalized.to_string());
}
if sources.len() >= PUZZLE_REFERENCE_IMAGE_SOURCE_LIMIT {
break;
}
}
sources
}
fn has_puzzle_reference_images(
legacy_reference_image_src: Option<&str>,
reference_image_srcs: &[String],
) -> bool {
!collect_puzzle_reference_image_sources(legacy_reference_image_src, reference_image_srcs)
.is_empty()
}
fn should_use_puzzle_reference_image_edit(
reference_image_src: Option<&str>,
use_reference_image_edit: bool,

View File

@@ -476,6 +476,10 @@ mod tests {
RuntimeProfileWalletLedgerSourceType::SnapshotSync.as_str(),
"snapshot_sync"
);
assert_eq!(
RuntimeProfileWalletLedgerSourceType::NewUserRegistrationReward.as_str(),
"new_user_registration_reward"
);
assert_eq!(
RuntimeProfileWalletLedgerSourceType::PointsRecharge.as_str(),
"points_recharge"
@@ -494,6 +498,19 @@ mod tests {
);
}
#[test]
fn new_user_registration_wallet_reward_starts_with_ten_points() {
assert_eq!(PROFILE_NEW_USER_INITIAL_WALLET_POINTS, 10);
assert_eq!(
calculate_runtime_profile_wallet_balance(
0,
PROFILE_NEW_USER_INITIAL_WALLET_POINTS as i64,
)
.expect("new user registration reward should fit wallet balance"),
10
);
}
#[test]
fn runtime_profile_beijing_day_key_uses_business_day_boundary() {
let before_beijing_midnight = 1_714_927_999_999_999;

View File

@@ -16,6 +16,8 @@ pub struct CreatePuzzleAgentSessionRequest {
#[serde(default)]
pub reference_image_src: Option<String>,
#[serde(default)]
pub reference_image_srcs: Vec<String>,
#[serde(default)]
pub image_model: Option<String>,
#[serde(default)]
pub ai_redraw: Option<bool>,
@@ -39,6 +41,8 @@ pub struct ExecutePuzzleAgentActionRequest {
#[serde(default)]
pub reference_image_src: Option<String>,
#[serde(default)]
pub reference_image_srcs: Vec<String>,
#[serde(default)]
pub image_model: Option<String>,
#[serde(default)]
pub ai_redraw: Option<bool>,

View File

@@ -544,6 +544,16 @@ fn update_match3d_work_tx(
input: Match3DWorkUpdateInput,
) -> Result<Match3DWorkSnapshot, String> {
let current = find_owned_work(ctx, &input.profile_id, &input.owner_user_id)?;
let next = build_updated_match3d_work_row(&current, &input)?;
let snapshot = build_work_snapshot(&next)?;
replace_work(ctx, &current, next);
Ok(snapshot)
}
fn build_updated_match3d_work_row(
current: &Match3DWorkProfileRow,
input: &Match3DWorkUpdateInput,
) -> Result<Match3DWorkProfileRow, String> {
let tags = parse_tags(&input.tags_json)?;
let config = Match3DCreatorConfigSnapshot {
theme_text: clean_string(&input.theme_text, "经典消除"),
@@ -563,7 +573,7 @@ fn update_match3d_work_tx(
author_display_name: current.author_display_name.clone(),
game_name: clean_string(&input.game_name, "未命名抓大鹅"),
theme_text: config.theme_text.clone(),
summary_text: clean_string(&input.summary_text, "经典消除玩法"),
summary_text: input.summary_text.trim().to_string(),
tags_json: to_json_string(&tags),
cover_image_src: input.cover_image_src.trim().to_string(),
cover_asset_id: input.cover_asset_id.trim().to_string(),
@@ -576,9 +586,7 @@ fn update_match3d_work_tx(
published_at: current.published_at,
generated_item_assets_json: current.generated_item_assets_json.clone(),
};
let snapshot = build_work_snapshot(&next)?;
replace_work(ctx, &current, next);
Ok(snapshot)
Ok(next)
}
fn publish_match3d_work_tx(
@@ -1881,6 +1889,65 @@ mod tests {
);
}
#[test]
fn match3d_work_update_preserves_assets_and_allows_empty_summary() {
let existing = Match3DWorkProfileRow {
profile_id: "profile-1".to_string(),
owner_user_id: "user-1".to_string(),
source_session_id: "session-1".to_string(),
author_display_name: "作者".to_string(),
game_name: "水果抓大鹅".to_string(),
theme_text: "水果".to_string(),
summary_text: "保留描述".to_string(),
tags_json: "[\"水果\"]".to_string(),
cover_image_src: "/old-cover.png".to_string(),
cover_asset_id: "cover-asset-1".to_string(),
clear_count: 12,
difficulty: 4,
config_json: to_json_string(&Match3DCreatorConfigSnapshot {
theme_text: "水果".to_string(),
reference_image_src: None,
clear_count: 12,
difficulty: 4,
asset_style_id: None,
asset_style_label: None,
asset_style_prompt: None,
generate_click_sound: false,
}),
publication_status: MATCH3D_PUBLICATION_DRAFT.to_string(),
play_count: 2,
updated_at: Timestamp::from_micros_since_unix_epoch(1),
published_at: None,
generated_item_assets_json: Some(
r#"[{"itemId":"match3d-item-1","itemName":"草莓","imageSrc":"/generated-match3d-assets/session/profile/items/item/image.png","status":"image_ready"}]"#
.to_string(),
),
};
let input = Match3DWorkUpdateInput {
profile_id: existing.profile_id.clone(),
owner_user_id: existing.owner_user_id.clone(),
game_name: existing.game_name.clone(),
theme_text: existing.theme_text.clone(),
summary_text: " ".to_string(),
tags_json: existing.tags_json.clone(),
cover_image_src: "/new-cover.png".to_string(),
cover_asset_id: existing.cover_asset_id.clone(),
clear_count: existing.clear_count,
difficulty: existing.difficulty,
updated_at_micros: 2,
};
let next = build_updated_match3d_work_row(&existing, &input).unwrap();
assert_eq!(next.summary_text, "");
assert_eq!(next.cover_image_src, "/new-cover.png");
assert_eq!(next.clear_count, 12);
assert_eq!(next.difficulty, 4);
assert_eq!(
next.generated_item_assets_json.as_deref(),
existing.generated_item_assets_json.as_deref()
);
}
#[test]
fn match3d_publish_ready_requires_five_image_views_per_item() {
let base_work = Match3DWorkProfileRow {