This commit is contained in:
2026-04-27 14:23:19 +08:00
parent 09d3fe59b3
commit fa2dbb310b
75 changed files with 7363 additions and 1487 deletions

View File

@@ -12,12 +12,16 @@ use axum::{
sse::{Event, Sse},
},
};
use base64::{Engine as _, engine::general_purpose::STANDARD as BASE64_STANDARD};
use module_assets::{
AssetObjectAccessPolicy, AssetObjectFieldError, build_asset_entity_binding_input,
build_asset_object_upsert_input, generate_asset_binding_id, generate_asset_object_id,
};
use module_puzzle::PuzzleGeneratedImageCandidate;
use platform_oss::{LegacyAssetPrefix, OssHeadObjectRequest, OssObjectAccess, OssPutObjectRequest};
use platform_oss::{
LegacyAssetPrefix, OssHeadObjectRequest, OssObjectAccess, OssPutObjectRequest,
OssSignedGetObjectUrlRequest,
};
use serde_json::{Map, Value, json};
use shared_contracts::{
puzzle_agent::{
@@ -64,6 +68,7 @@ use crate::{
api_response::json_success_body,
auth::AuthenticatedAccessToken,
http_error::AppError,
prompt::puzzle_image::{PUZZLE_DEFAULT_NEGATIVE_PROMPT, build_puzzle_image_prompt},
puzzle_agent_turn::{
PuzzleAgentTurnRequest, build_failed_finalize_record_input, build_finalize_record_input,
run_puzzle_agent_turn,
@@ -78,8 +83,6 @@ const PUZZLE_GALLERY_PROVIDER: &str = "puzzle-gallery";
const PUZZLE_RUNTIME_PROVIDER: &str = "puzzle-runtime";
const PUZZLE_TEXT_TO_IMAGE_MODEL: &str = "wan2.2-t2i-flash";
const PUZZLE_ENTITY_KIND: &str = "puzzle_work";
const PUZZLE_DEFAULT_NEGATIVE_PROMPT: &str =
"低清晰度,低质量,文字水印,畸形构图,过度模糊,重复肢体,画面脏污";
pub async fn create_puzzle_agent_session(
State(state): State<AppState>,
@@ -216,6 +219,7 @@ pub async fn submit_puzzle_agent_message(
llm_client: state.llm_client(),
session: &submitted_session,
quick_fill_requested: payload.quick_fill_requested.unwrap_or(false),
enable_web_search: state.config.creation_agent_llm_web_search_enabled,
},
|_| {},
)
@@ -320,6 +324,7 @@ pub async fn stream_puzzle_agent_message(
llm_client: state.llm_client(),
session: &session,
quick_fill_requested,
enable_web_search: state.config.creation_agent_llm_web_search_enabled,
},
move |text| {
let _ = reply_tx.send(text.to_string());
@@ -447,7 +452,7 @@ pub async fn execute_puzzle_agent_action(
(
"compile_puzzle_draft",
"完整拼图草稿",
"已编译草稿、生成候选图并应用正式图",
"已编译草稿、生成拼图图片并应用正式图。",
session,
)
}
@@ -468,7 +473,8 @@ pub async fn execute_puzzle_agent_action(
.clone()
.filter(|value| !value.trim().is_empty())
.unwrap_or_else(|| draft.summary.clone());
let candidate_count = payload.candidate_count.unwrap_or(2).clamp(1, 2);
// 拼图结果页从多候选抽卡收口为单图替换,前端传入的旧 candidateCount 只做兼容忽略。
let candidate_count = 1;
let candidate_start_index = draft.candidates.len();
let candidates = generate_puzzle_image_candidates(
&state,
@@ -476,6 +482,7 @@ pub async fn execute_puzzle_agent_action(
&session.session_id,
&draft.level_name,
&prompt,
payload.reference_image_src.as_deref(),
candidate_count,
candidate_start_index,
)
@@ -521,8 +528,8 @@ pub async fn execute_puzzle_agent_action(
};
(
"generate_puzzle_images",
"候选图生成",
"已生成 2 张候选拼图图",
"拼图图片生成",
"已生成并替换当前拼图图",
session,
)
}
@@ -1296,6 +1303,7 @@ fn map_puzzle_run_response(run: PuzzleRunRecord) -> PuzzleRunSnapshotResponse {
previous_level_tags: run.previous_level_tags,
current_level: run.current_level.map(map_puzzle_runtime_level_response),
recommended_next_profile_id: run.recommended_next_profile_id,
leaderboard_entries: Vec::new(),
}
}
@@ -1381,6 +1389,10 @@ fn map_puzzle_runtime_level_response(
cover_image_src: level.cover_image_src,
board: map_puzzle_board_response(level.board),
status: level.status,
started_at_ms: 0,
cleared_at_ms: None,
elapsed_ms: None,
leaderboard_entries: Vec::new(),
}
}
@@ -1476,7 +1488,8 @@ async fn compile_puzzle_draft_with_initial_cover(
&compiled_session.session_id,
&draft.level_name,
&draft.summary,
2,
None,
1,
draft.candidates.len(),
)
.await
@@ -1619,23 +1632,53 @@ async fn generate_puzzle_image_candidates(
session_id: &str,
level_name: &str,
prompt: &str,
reference_image_src: Option<&str>,
candidate_count: u32,
candidate_start_index: usize,
) -> Result<Vec<PuzzleGeneratedImageCandidateRecord>, String> {
let count = candidate_count.clamp(1, 2);
let count = candidate_count.clamp(1, 1);
let settings =
require_puzzle_dashscope_settings(state).map_err(|error| error.message().to_string())?;
let http_client = build_puzzle_dashscope_http_client(&settings)
.map_err(|error| error.message().to_string())?;
let generated = create_puzzle_text_to_image_generation(
&http_client,
&settings,
build_puzzle_image_prompt(level_name, prompt).as_str(),
PUZZLE_DEFAULT_NEGATIVE_PROMPT,
"1024*1024",
count,
)
.await
let actual_prompt = build_puzzle_image_prompt(level_name, prompt);
let reference_image = match reference_image_src
.map(str::trim)
.filter(|value| !value.is_empty())
{
Some(source) => Some(
resolve_puzzle_reference_image_as_data_url(state, &http_client, source)
.await
.map_err(|error| error.message().to_string())?,
),
None => None,
};
// 中文注释SpacetimeDB reducer 不能做外部 I/O参考图读取与 DashScope 图生图都必须停留在 api-server。
let generated = match reference_image.as_deref() {
Some(reference_image) => {
create_puzzle_image_to_image_generation(
&http_client,
&settings,
actual_prompt.as_str(),
PUZZLE_DEFAULT_NEGATIVE_PROMPT,
"1024*1024",
count,
reference_image,
)
.await
}
None => {
create_puzzle_text_to_image_generation(
&http_client,
&settings,
actual_prompt.as_str(),
PUZZLE_DEFAULT_NEGATIVE_PROMPT,
"1024*1024",
count,
)
.await
}
}
.map_err(|error| error.message().to_string())?;
let mut items = Vec::with_capacity(generated.images.len());
@@ -1661,9 +1704,10 @@ async fn generate_puzzle_image_candidates(
image_src: asset.image_src,
asset_id: asset.asset_id,
prompt: prompt.to_string(),
actual_prompt: Some(prompt.to_string()),
actual_prompt: Some(actual_prompt.clone()),
source_type: "generated".to_string(),
selected: candidate_start_index == 0 && index == 0,
// 单图生成结果总是直接成为当前正式图。
selected: index == 0,
});
}
@@ -1740,7 +1784,8 @@ async fn build_local_next_puzzle_run(
&session.session_id,
&draft.level_name,
&draft.summary,
2,
None,
1,
draft.candidates.len(),
)
.await
@@ -1946,6 +1991,7 @@ fn build_local_puzzle_board(grid_size: u32) -> PuzzleBoardRecord {
struct PuzzleDashScopeSettings {
base_url: String,
api_key: String,
reference_image_model: String,
request_timeout_ms: u64,
}
@@ -1960,6 +2006,11 @@ struct PuzzleDownloadedImage {
bytes: Vec<u8>,
}
struct ParsedPuzzleImageDataUrl {
mime_type: String,
bytes: Vec<u8>,
}
struct GeneratedPuzzleAssetResponse {
image_src: String,
asset_id: String,
@@ -1994,6 +2045,7 @@ fn require_puzzle_dashscope_settings(
Ok(PuzzleDashScopeSettings {
base_url: base_url.to_string(),
api_key: api_key.to_string(),
reference_image_model: state.config.dashscope_reference_image_model.clone(),
request_timeout_ms: state.config.dashscope_image_request_timeout_ms.max(1),
})
}
@@ -2036,7 +2088,7 @@ async fn create_puzzle_text_to_image_generation(
candidate_count: u32,
) -> Result<PuzzleGeneratedImages, AppError> {
let mut parameters = Map::from_iter([
("n".to_string(), json!(candidate_count.clamp(1, 2))),
("n".to_string(), json!(candidate_count.clamp(1, 1))),
("size".to_string(), Value::String(size.to_string())),
("prompt_extend".to_string(), Value::Bool(true)),
("watermark".to_string(), Value::Bool(false)),
@@ -2127,7 +2179,7 @@ async fn create_puzzle_text_to_image_generation(
let mut images = Vec::with_capacity(image_urls.len());
for image_url in image_urls
.into_iter()
.take(candidate_count.clamp(1, 2) as usize)
.take(candidate_count.clamp(1, 1) as usize)
{
images.push(download_puzzle_remote_image(http_client, image_url.as_str()).await?);
}
@@ -2150,6 +2202,270 @@ async fn create_puzzle_text_to_image_generation(
)
}
async fn resolve_puzzle_reference_image_as_data_url(
state: &AppState,
http_client: &reqwest::Client,
source: &str,
) -> Result<String, AppError> {
let trimmed = source.trim();
if trimmed.is_empty() {
return Err(
AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({
"provider": "puzzle",
"field": "referenceImageSrc",
"message": "参考图不能为空。",
})),
);
}
if let Some(parsed) = parse_puzzle_image_data_url(trimmed) {
return Ok(format!(
"data:{};base64,{}",
parsed.mime_type,
BASE64_STANDARD.encode(parsed.bytes)
));
}
if !trimmed.starts_with('/') {
return Err(
AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({
"provider": "puzzle",
"field": "referenceImageSrc",
"message": "参考图必须是 Data URL 或 /generated-* 旧路径。",
})),
);
}
let object_key = trimmed.trim_start_matches('/');
if LegacyAssetPrefix::from_object_key(object_key).is_none() {
return Err(
AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({
"provider": "puzzle",
"field": "referenceImageSrc",
"message": "参考图当前只支持 /generated-* 旧路径。",
})),
);
}
let oss_client = state.oss_client().ok_or_else(|| {
AppError::from_status(StatusCode::SERVICE_UNAVAILABLE).with_details(json!({
"provider": "aliyun-oss",
"reason": "OSS 未完成环境变量配置",
}))
})?;
let signed = oss_client
.sign_get_object_url(OssSignedGetObjectUrlRequest {
object_key: object_key.to_string(),
expire_seconds: Some(60),
})
.map_err(map_puzzle_asset_oss_error)?;
let response = http_client
.get(signed.signed_url)
.send()
.await
.map_err(|error| {
map_puzzle_dashscope_request_error(format!("读取拼图参考图失败:{error}"))
})?;
let status = response.status();
let content_type = response
.headers()
.get(reqwest::header::CONTENT_TYPE)
.and_then(|value| value.to_str().ok())
.unwrap_or("image/png")
.to_string();
let body = response.bytes().await.map_err(|error| {
map_puzzle_dashscope_request_error(format!("读取拼图参考图内容失败:{error}"))
})?;
if !status.is_success() {
return Err(
AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({
"provider": "aliyun-oss",
"message": format!("读取参考图失败,状态码:{status}"),
"objectKey": object_key,
})),
);
}
if body.is_empty() {
return Err(
AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({
"provider": "aliyun-oss",
"message": "读取参考图失败:对象内容为空",
"objectKey": object_key,
})),
);
}
Ok(format!(
"data:{};base64,{}",
content_type,
BASE64_STANDARD.encode(body)
))
}
async fn create_puzzle_image_to_image_generation(
http_client: &reqwest::Client,
settings: &PuzzleDashScopeSettings,
prompt: &str,
negative_prompt: &str,
size: &str,
candidate_count: u32,
reference_image: &str,
) -> Result<PuzzleGeneratedImages, AppError> {
let mut content = vec![json!({ "image": reference_image })];
content.push(json!({ "text": prompt }));
let response = http_client
.post(format!(
"{}/services/aigc/multimodal-generation/generation",
settings.base_url
))
.header(
reqwest::header::AUTHORIZATION,
format!("Bearer {}", settings.api_key),
)
.header(reqwest::header::CONTENT_TYPE, "application/json")
.json(&json!({
"model": settings.reference_image_model.as_str(),
"input": {
"messages": [
{
"role": "user",
"content": content,
}
],
},
"parameters": {
"n": candidate_count.clamp(1, 1),
"size": size,
"negative_prompt": negative_prompt,
"prompt_extend": true,
"watermark": false,
},
}))
.send()
.await
.map_err(|error| {
map_puzzle_dashscope_request_error(format!("创建拼图参考图生成任务失败:{error}"))
})?;
let status = response.status();
let response_text = response.text().await.map_err(|error| {
map_puzzle_dashscope_request_error(format!("读取拼图参考图生成响应失败:{error}"))
})?;
if !status.is_success() {
return Err(map_puzzle_dashscope_upstream_error(
response_text.as_str(),
"创建拼图参考图生成任务失败",
));
}
let payload = parse_puzzle_json_payload(response_text.as_str(), "解析拼图参考图生成响应失败")?;
let image_urls = extract_puzzle_image_urls(&payload);
if image_urls.is_empty() {
let task_id = extract_puzzle_task_id(&payload).ok_or_else(|| {
AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({
"provider": "dashscope",
"message": "拼图参考图生成未返回 task_id 或图片地址",
}))
})?;
return wait_puzzle_generated_images(
http_client,
settings,
task_id.as_str(),
candidate_count,
"拼图参考图生成任务失败",
)
.await;
}
let mut images = Vec::with_capacity(candidate_count.clamp(1, 1) as usize);
for image_url in image_urls
.into_iter()
.take(candidate_count.clamp(1, 1) as usize)
{
images.push(download_puzzle_remote_image(http_client, image_url.as_str()).await?);
}
Ok(PuzzleGeneratedImages {
task_id: format!("puzzle-ref-{}", current_utc_micros()),
images,
})
}
async fn wait_puzzle_generated_images(
http_client: &reqwest::Client,
settings: &PuzzleDashScopeSettings,
task_id: &str,
candidate_count: u32,
failure_message: &str,
) -> Result<PuzzleGeneratedImages, AppError> {
let deadline = Instant::now() + Duration::from_millis(settings.request_timeout_ms);
while Instant::now() < deadline {
let poll_response = http_client
.get(format!("{}/tasks/{}", settings.base_url, task_id))
.header(
reqwest::header::AUTHORIZATION,
format!("Bearer {}", settings.api_key),
)
.send()
.await
.map_err(|error| {
map_puzzle_dashscope_request_error(format!("查询拼图图片生成任务失败:{error}"))
})?;
let poll_status = poll_response.status();
let poll_text = poll_response.text().await.map_err(|error| {
map_puzzle_dashscope_request_error(format!("读取拼图图片生成任务响应失败:{error}"))
})?;
if !poll_status.is_success() {
return Err(map_puzzle_dashscope_upstream_error(
poll_text.as_str(),
"查询拼图图片生成任务失败",
));
}
let poll_payload =
parse_puzzle_json_payload(poll_text.as_str(), "解析拼图图片生成任务响应失败")?;
let task_status = find_first_puzzle_string_by_key(&poll_payload, "task_status")
.unwrap_or_default()
.trim()
.to_string();
if task_status == "SUCCEEDED" {
let image_urls = extract_puzzle_image_urls(&poll_payload);
if image_urls.is_empty() {
return Err(
AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({
"provider": "dashscope",
"message": "拼图图片生成成功但未返回图片地址",
})),
);
}
let mut images = Vec::with_capacity(image_urls.len());
for image_url in image_urls
.into_iter()
.take(candidate_count.clamp(1, 1) as usize)
{
images.push(download_puzzle_remote_image(http_client, image_url.as_str()).await?);
}
return Ok(PuzzleGeneratedImages {
task_id: task_id.to_string(),
images,
});
}
if matches!(task_status.as_str(), "FAILED" | "UNKNOWN") {
return Err(map_puzzle_dashscope_upstream_error(
poll_text.as_str(),
failure_message,
));
}
sleep(Duration::from_secs(2)).await;
}
Err(
AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({
"provider": "dashscope",
"message": "拼图图片生成超时或未返回图片地址",
})),
)
}
async fn download_puzzle_remote_image(
http_client: &reqwest::Client,
image_url: &str,
@@ -2278,12 +2594,6 @@ async fn persist_puzzle_generated_asset(
})
}
fn build_puzzle_image_prompt(level_name: &str, prompt: &str) -> String {
format!(
"生成一张适合做正方形拼图关卡的高清插画。关卡名:{level_name}。画面要求:{prompt}。必须有清晰主体、丰富但不混乱的区域层次、适合被切成 3x3 或 4x4 拼图块,避免文字、水印、边框和 UI 元素。"
)
}
fn build_puzzle_asset_metadata(
owner_user_id: &str,
session_id: &str,
@@ -2307,6 +2617,46 @@ fn parse_puzzle_json_payload(raw_text: &str, fallback_message: &str) -> Result<V
})
}
fn parse_puzzle_image_data_url(value: &str) -> Option<ParsedPuzzleImageDataUrl> {
let body = value.strip_prefix("data:")?;
let (mime_type, data) = body.split_once(";base64,")?;
if !mime_type.starts_with("image/") {
return None;
}
let bytes = decode_puzzle_base64(data)?;
Some(ParsedPuzzleImageDataUrl {
mime_type: mime_type.to_string(),
bytes,
})
}
fn decode_puzzle_base64(value: &str) -> Option<Vec<u8>> {
let cleaned = value.trim().replace(char::is_whitespace, "");
let mut output = Vec::with_capacity(cleaned.len() * 3 / 4);
let mut buffer = 0u32;
let mut bits = 0u8;
for byte in cleaned.bytes() {
let value = match byte {
b'A'..=b'Z' => byte - b'A',
b'a'..=b'z' => byte - b'a' + 26,
b'0'..=b'9' => byte - b'0' + 52,
b'+' => 62,
b'/' => 63,
b'=' => break,
_ => return None,
} as u32;
buffer = (buffer << 6) | value;
bits += 6;
while bits >= 8 {
bits -= 8;
output.push(((buffer >> bits) & 0xFF) as u8);
}
}
Some(output)
}
fn extract_puzzle_task_id(payload: &Value) -> Option<String> {
find_first_puzzle_string_by_key(payload, "task_id")
}