This commit is contained in:
2026-05-10 13:18:46 +08:00
parent dada5a4797
commit 1c16152708
17 changed files with 1197 additions and 99 deletions

View File

@@ -1,6 +1,6 @@
use std::{
collections::BTreeMap,
time::{Duration, SystemTime, UNIX_EPOCH},
time::{Duration, Instant, SystemTime, UNIX_EPOCH},
};
use axum::{
@@ -912,7 +912,11 @@ pub async fn execute_puzzle_agent_action(
let generated_level_name = target_level.level_name.clone();
let levels_json_with_generated_name =
Some(serialize_puzzle_level_records_for_module(
&build_puzzle_levels_with_primary_name(&draft, &target_level),
&build_puzzle_levels_with_primary_update(
&draft,
&target_level,
payload.reference_image_src.as_deref(),
),
)?);
let candidates_json = serde_json::to_string(
&candidates
@@ -962,6 +966,7 @@ pub async fn execute_puzzle_agent_action(
),
target_level.level_id.as_str(),
candidates.into_records(),
payload.reference_image_src.as_deref(),
now,
))
}
@@ -3081,9 +3086,10 @@ fn build_fallback_puzzle_first_level_name(picture_description: &str) -> String {
"奇境初见".to_string()
}
fn build_puzzle_levels_with_primary_name(
fn build_puzzle_levels_with_primary_update(
draft: &PuzzleResultDraftRecord,
target_level: &PuzzleDraftLevelRecord,
picture_reference: Option<&str>,
) -> Vec<PuzzleDraftLevelRecord> {
let mut levels = draft.levels.clone();
if let Some(index) = levels
@@ -3092,6 +3098,11 @@ fn build_puzzle_levels_with_primary_name(
.or_else(|| (!levels.is_empty()).then_some(0))
{
levels[index].level_name = target_level.level_name.clone();
if let Some(picture_reference) =
picture_reference.map(str::trim).filter(|value| !value.is_empty())
{
levels[index].picture_reference = Some(picture_reference.to_string());
}
}
levels
}
@@ -3161,7 +3172,7 @@ async fn compile_puzzle_draft_with_initial_cover(
}
let generated_level_name = target_level.level_name.clone();
let levels_json_with_generated_name = Some(serialize_puzzle_level_records_for_module(
&build_puzzle_levels_with_primary_name(&draft, &target_level),
&build_puzzle_levels_with_primary_update(&draft, &target_level, reference_image_src),
)?);
let candidates_json = serde_json::to_string(
&candidates
@@ -3208,6 +3219,7 @@ async fn compile_puzzle_draft_with_initial_cover(
),
target_level.level_id.as_str(),
candidates.into_records(),
reference_image_src,
now,
);
Ok((session, true))
@@ -3311,7 +3323,7 @@ async fn compile_puzzle_draft_with_uploaded_cover(
}
let generated_level_name = target_level.level_name.clone();
let levels_json_with_generated_name = Some(serialize_puzzle_level_records_for_module(
&build_puzzle_levels_with_primary_name(&draft, &target_level),
&build_puzzle_levels_with_primary_update(&draft, &target_level, reference_image_src),
)?);
let persisted_upload = persist_puzzle_generated_asset(
state,
@@ -3374,6 +3386,7 @@ async fn compile_puzzle_draft_with_uploaded_cover(
),
target_level.level_id.as_str(),
vec![candidate.clone()],
reference_image_src,
now,
);
Ok((session, true))
@@ -3413,6 +3426,7 @@ fn apply_generated_puzzle_candidates_to_session_snapshot(
mut session: PuzzleAgentSessionRecord,
target_level_id: &str,
candidates: Vec<PuzzleGeneratedImageCandidateRecord>,
picture_reference: Option<&str>,
updated_at_micros: i64,
) -> PuzzleAgentSessionRecord {
let Some(draft) = session.draft.as_mut() else {
@@ -3443,6 +3457,12 @@ fn apply_generated_puzzle_candidates_to_session_snapshot(
level.selected_candidate_id = Some(selected.candidate_id.clone());
level.cover_image_src = Some(selected.image_src.clone());
level.cover_asset_id = Some(selected.asset_id.clone());
if let Some(picture_reference) = picture_reference
.map(str::trim)
.filter(|value| !value.is_empty())
{
level.picture_reference = Some(picture_reference.to_string());
}
level.generation_status = "ready".to_string();
if target_index == 0 {
sync_puzzle_primary_draft_fields_from_level(draft);
@@ -3892,9 +3912,13 @@ fn is_missing_puzzle_form_draft_procedure_error(error: &SpacetimeClientError) ->
fn map_puzzle_compile_error(error: SpacetimeClientError) -> AppError {
let message = error.to_string();
// 中文注释:历史运行态或旧 SpacetimeDB 错误快照可能仍带 APIMart 图片网关文案;当前 GPT-image-2 已统一迁移到 VectorEngine返回给前端前先归一避免误导排障。
let is_legacy_apimart_image_error =
message.contains("APIMart") || message.contains("apimart") || message.contains("APIMART");
let provider = if message.contains("VectorEngine")
|| message.contains("vector-engine")
|| message.contains("VECTOR_ENGINE")
|| is_legacy_apimart_image_error
{
VECTOR_ENGINE_PROVIDER
} else if message.contains("OSS") || message.contains("oss") || message.contains("参考图") {
@@ -3905,6 +3929,8 @@ fn map_puzzle_compile_error(error: SpacetimeClientError) -> AppError {
let status = if provider == VECTOR_ENGINE_PROVIDER
&& (message.contains("VECTOR_ENGINE_API_KEY")
|| message.contains("VECTOR_ENGINE_BASE_URL")
|| message.contains("APIMART_API_KEY")
|| message.contains("APIMART_BASE_URL")
|| message.contains("未配置"))
{
StatusCode::SERVICE_UNAVAILABLE
@@ -3920,6 +3946,7 @@ fn map_puzzle_compile_error(error: SpacetimeClientError) -> AppError {
|| message.contains("VectorEngine")
|| message.contains("vector-engine")
|| message.contains("VECTOR_ENGINE")
|| is_legacy_apimart_image_error
|| message.contains("参考图")
|| message.contains("图片")
|| message.contains("OSS")
@@ -3949,13 +3976,28 @@ fn map_puzzle_compile_error(error: SpacetimeClientError) -> AppError {
_ => StatusCode::BAD_GATEWAY,
}
};
let user_message = normalize_legacy_puzzle_image_error_message(message.as_str());
AppError::from_status(status).with_details(json!({
"provider": provider,
"message": message,
"message": user_message,
}))
}
fn normalize_legacy_puzzle_image_error_message(message: &str) -> String {
message
.replace(
"APIMart 图片生成密钥未配置",
"VectorEngine 图片生成密钥未配置",
)
.replace(
"APIMart 图片生成地址未配置",
"VectorEngine 图片生成地址未配置",
)
.replace("APIMART_API_KEY", "VECTOR_ENGINE_API_KEY")
.replace("APIMART_BASE_URL", "VECTOR_ENGINE_BASE_URL")
}
fn puzzle_error_response(
request_context: &RequestContext,
provider: &str,
@@ -4021,10 +4063,15 @@ async fn generate_puzzle_image_candidates(
candidate_count: u32,
candidate_start_index: usize,
) -> Result<Vec<GeneratedPuzzleImageCandidate>, AppError> {
let total_started_at = Instant::now();
let count = candidate_count.clamp(1, 1);
let resolved_model = resolve_puzzle_image_model(image_model);
let actual_prompt = build_puzzle_image_prompt(level_name, prompt);
let http_client = build_puzzle_image_http_client(state, resolved_model)?;
let has_reference_image = reference_image_src
.map(str::trim)
.map(|value| !value.is_empty())
.unwrap_or(false);
tracing::info!(
provider = resolved_model.provider_name(),
image_model = resolved_model.request_model_name(),
@@ -4032,24 +4079,45 @@ async fn generate_puzzle_image_candidates(
level_name,
prompt_chars = prompt.chars().count(),
actual_prompt_chars = actual_prompt.chars().count(),
has_reference_image = reference_image_src
.map(str::trim)
.map(|value| !value.is_empty())
.unwrap_or(false),
has_reference_image,
"拼图图片生成请求已准备"
);
let reference_image_started_at = Instant::now();
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?)
let resolved =
resolve_puzzle_reference_image_as_data_url(state, &http_client, source).await?;
tracing::info!(
provider = resolved_model.provider_name(),
image_model = resolved_model.request_model_name(),
session_id,
level_name,
reference_mime = %resolved.mime_type,
reference_bytes = resolved.bytes_len,
elapsed_ms = reference_image_started_at.elapsed().as_millis() as u64,
"拼图参考图解析完成"
);
Some(resolved)
}
None => None,
};
if !has_reference_image {
tracing::info!(
provider = resolved_model.provider_name(),
image_model = resolved_model.request_model_name(),
session_id,
level_name,
elapsed_ms = reference_image_started_at.elapsed().as_millis() as u64,
"拼图参考图解析跳过"
);
}
// 中文注释SpacetimeDB reducer 不能做外部 I/O参考图读取与外部生图都必须停留在 api-server。
// 中文注释:拼图作品资产统一按 1:1 正方形生成,前端运行时也按正方形棋盘切块承载。
let settings = require_puzzle_vector_engine_settings(state)?;
let vector_engine_started_at = Instant::now();
let generated = create_puzzle_vector_engine_image_generation(
&http_client,
&settings,
@@ -4058,10 +4126,21 @@ async fn generate_puzzle_image_candidates(
PUZZLE_DEFAULT_NEGATIVE_PROMPT,
PUZZLE_VECTOR_ENGINE_GENERATED_IMAGE_SIZE,
count,
reference_image.as_deref(),
reference_image
.as_ref()
.map(|image| image.data_url.as_str()),
)
.await
.map_err(map_puzzle_generation_endpoint_error)?;
tracing::info!(
provider = resolved_model.provider_name(),
image_model = resolved_model.request_model_name(),
session_id,
level_name,
generated_image_count = generated.images.len(),
elapsed_ms = vector_engine_started_at.elapsed().as_millis() as u64,
"拼图 VectorEngine 生图与下载完成"
);
let mut items = Vec::with_capacity(generated.images.len());
for (index, image) in generated.images.into_iter().enumerate() {
@@ -4070,6 +4149,7 @@ async fn generate_puzzle_image_candidates(
candidate_start_index + index + 1
);
let downloaded_image = image.clone();
let persist_started_at = Instant::now();
let asset = persist_puzzle_generated_asset(
state,
owner_user_id,
@@ -4082,6 +4162,17 @@ async fn generate_puzzle_image_candidates(
)
.await
.map_err(map_puzzle_generation_endpoint_error)?;
tracing::info!(
provider = resolved_model.provider_name(),
image_model = resolved_model.request_model_name(),
session_id,
level_name,
candidate_id = %candidate_id,
image_bytes = downloaded_image.bytes.len(),
image_mime = %downloaded_image.mime_type,
elapsed_ms = persist_started_at.elapsed().as_millis() as u64,
"拼图生成图片已写入 OSS 与资产索引"
);
items.push(GeneratedPuzzleImageCandidate {
record: PuzzleGeneratedImageCandidateRecord {
candidate_id,
@@ -4097,6 +4188,16 @@ async fn generate_puzzle_image_candidates(
});
}
tracing::info!(
provider = resolved_model.provider_name(),
image_model = resolved_model.request_model_name(),
session_id,
level_name,
candidate_count = items.len(),
has_reference_image,
elapsed_ms = total_started_at.elapsed().as_millis() as u64,
"拼图图片候选生成完成"
);
Ok(items)
}
@@ -4144,6 +4245,30 @@ mod tests {
assert_eq!(response.status(), StatusCode::SERVICE_UNAVAILABLE);
}
#[tokio::test]
async fn puzzle_compile_error_normalizes_legacy_apimart_image_message() {
let error = map_puzzle_compile_error(SpacetimeClientError::Runtime(
"APIMart 图片生成密钥未配置".to_string(),
));
let response = error.into_response();
assert_eq!(response.status(), StatusCode::SERVICE_UNAVAILABLE);
let body = response.into_body();
let bytes = axum::body::to_bytes(body, usize::MAX)
.await
.expect("body bytes should read");
let payload: Value =
serde_json::from_slice(&bytes).expect("error response should be valid json");
assert_eq!(
payload["error"]["details"]["provider"],
Value::String(VECTOR_ENGINE_PROVIDER.to_string())
);
assert_eq!(
payload["error"]["details"]["message"],
Value::String("VectorEngine 图片生成密钥未配置".to_string())
);
}
#[test]
fn puzzle_image_generation_builds_fallback_session_from_levels_snapshot() {
let levels_json = serde_json::to_string(&vec![json!({
@@ -4293,6 +4418,116 @@ mod tests {
assert_eq!(draft.levels[0].level_name, "雨夜猫街");
}
fn test_puzzle_anchor_pack_record() -> PuzzleAnchorPackRecord {
let item = PuzzleAnchorItemRecord {
key: "visualSubject".to_string(),
label: "画面".to_string(),
value: "雨夜猫街".to_string(),
status: "confirmed".to_string(),
};
PuzzleAnchorPackRecord {
theme_promise: item.clone(),
visual_subject: item.clone(),
visual_mood: item.clone(),
composition_hooks: item.clone(),
tags_and_forbidden: item,
}
}
fn test_puzzle_draft_record() -> PuzzleResultDraftRecord {
let anchor_pack = test_puzzle_anchor_pack_record();
PuzzleResultDraftRecord {
work_title: "雨夜猫街".to_string(),
work_description: "一只猫在雨夜灯牌下回头。".to_string(),
level_name: "猫画面".to_string(),
summary: "一只猫在雨夜灯牌下回头。".to_string(),
theme_tags: vec![],
forbidden_directives: vec![],
creator_intent: None,
anchor_pack,
candidates: vec![],
selected_candidate_id: None,
cover_image_src: None,
cover_asset_id: None,
generation_status: "idle".to_string(),
levels: vec![PuzzleDraftLevelRecord {
level_id: "puzzle-level-1".to_string(),
level_name: "猫画面".to_string(),
picture_description: "一只猫在雨夜灯牌下回头。".to_string(),
picture_reference: None,
candidates: vec![],
selected_candidate_id: None,
cover_image_src: None,
cover_asset_id: None,
generation_status: "idle".to_string(),
}],
form_draft: None,
}
}
#[test]
fn puzzle_primary_level_update_preserves_reference_for_regeneration() {
let draft = test_puzzle_draft_record();
let mut target_level = draft.levels[0].clone();
target_level.level_name = "雨夜猫街".to_string();
let levels = build_puzzle_levels_with_primary_update(
&draft,
&target_level,
Some("data:image/png;base64,abcd"),
);
assert_eq!(levels[0].level_name, "雨夜猫街");
assert_eq!(
levels[0].picture_reference.as_deref(),
Some("data:image/png;base64,abcd")
);
}
#[test]
fn puzzle_generated_fallback_snapshot_preserves_picture_reference() {
let anchor_pack = test_puzzle_anchor_pack_record();
let session = PuzzleAgentSessionRecord {
session_id: "puzzle-session-1".to_string(),
seed_text: "雨夜猫街".to_string(),
current_turn: 1,
progress_percent: 0,
stage: "draft_ready".to_string(),
anchor_pack: anchor_pack.clone(),
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 candidate = PuzzleGeneratedImageCandidateRecord {
candidate_id: "puzzle-session-1-candidate-1".to_string(),
image_src: "/generated-puzzle-assets/puzzle-session-1/1/cover.png".to_string(),
asset_id: "puzzle-cover-1".to_string(),
prompt: "雨夜猫街".to_string(),
actual_prompt: Some("雨夜猫街".to_string()),
source_type: "generated:gpt-image-2".to_string(),
selected: true,
};
let session = apply_generated_puzzle_candidates_to_session_snapshot(
session,
"puzzle-level-1",
vec![candidate],
Some("data:image/png;base64,abcd"),
1_713_686_401_234_568,
);
let draft = session.draft.expect("draft");
assert_eq!(
draft.levels[0].picture_reference.as_deref(),
Some("data:image/png;base64,abcd")
);
}
#[test]
fn freeze_boundary_sync_only_matches_freeze_invalid_operation() {
let invalid_operation =
@@ -4347,6 +4582,12 @@ struct PuzzleGeneratedImages {
images: Vec<PuzzleDownloadedImage>,
}
struct PuzzleResolvedReferenceImage {
data_url: String,
mime_type: String,
bytes_len: usize,
}
struct GeneratedPuzzleImageCandidate {
record: PuzzleGeneratedImageCandidateRecord,
downloaded_image: PuzzleDownloadedImage,
@@ -4490,8 +4731,14 @@ async fn create_puzzle_vector_engine_image_generation(
candidate_count,
reference_image,
);
let request_url = puzzle_vector_engine_images_generation_url(settings);
let has_reference_image = reference_image
.map(str::trim)
.map(|value| !value.is_empty())
.unwrap_or(false);
let request_started_at = Instant::now();
let response = http_client
.post(puzzle_vector_engine_images_generation_url(settings))
.post(request_url.as_str())
.header(
reqwest::header::AUTHORIZATION,
format!("Bearer {}", settings.api_key),
@@ -4507,6 +4754,18 @@ async fn create_puzzle_vector_engine_image_generation(
))
})?;
let status = response.status();
let upstream_elapsed_ms = request_started_at.elapsed().as_millis() as u64;
tracing::info!(
provider = VECTOR_ENGINE_PROVIDER,
image_model = image_model.request_model_name(),
endpoint = %request_url,
status = status.as_u16(),
prompt_chars = prompt.chars().count(),
size,
has_reference_image,
elapsed_ms = upstream_elapsed_ms,
"拼图 VectorEngine 图片生成 HTTP 返回"
);
let response_text = response.text().await.map_err(|error| {
map_puzzle_vector_engine_request_error(format!(
"读取拼图 VectorEngine 图片生成响应失败:{error}"
@@ -4526,13 +4785,22 @@ async fn create_puzzle_vector_engine_image_generation(
)?;
let image_urls = extract_puzzle_image_urls(&payload);
if !image_urls.is_empty() {
return download_puzzle_images_from_urls(
let download_started_at = Instant::now();
let images = download_puzzle_images_from_urls(
http_client,
format!("vector-engine-{}", current_utc_micros()),
image_urls,
candidate_count,
)
.await;
.await?;
tracing::info!(
provider = VECTOR_ENGINE_PROVIDER,
image_model = image_model.request_model_name(),
image_count = images.images.len(),
elapsed_ms = download_started_at.elapsed().as_millis() as u64,
"拼图 VectorEngine 图片下载完成"
);
return Ok(images);
}
Err(
@@ -4612,7 +4880,7 @@ async fn resolve_puzzle_reference_image_as_data_url(
state: &AppState,
http_client: &reqwest::Client,
source: &str,
) -> Result<String, AppError> {
) -> Result<PuzzleResolvedReferenceImage, AppError> {
let trimmed = source.trim();
if trimmed.is_empty() {
return Err(
@@ -4625,11 +4893,17 @@ async fn resolve_puzzle_reference_image_as_data_url(
}
if let Some(parsed) = parse_puzzle_image_data_url(trimmed) {
return Ok(format!(
let bytes_len = parsed.bytes.len();
let data_url = format!(
"data:{};base64,{}",
parsed.mime_type,
BASE64_STANDARD.encode(parsed.bytes)
));
BASE64_STANDARD.encode(&parsed.bytes)
);
return Ok(PuzzleResolvedReferenceImage {
data_url,
mime_type: parsed.mime_type,
bytes_len,
});
}
if !trimmed.starts_with('/') {
@@ -4699,11 +4973,13 @@ async fn resolve_puzzle_reference_image_as_data_url(
);
}
Ok(format!(
"data:{};base64,{}",
content_type,
BASE64_STANDARD.encode(body)
))
let mime_type = normalize_puzzle_downloaded_image_mime_type(content_type.as_str());
let bytes_len = body.len();
Ok(PuzzleResolvedReferenceImage {
data_url: format!("data:{};base64,{}", mime_type, BASE64_STANDARD.encode(body)),
mime_type,
bytes_len,
})
}
async fn download_puzzle_remote_image(

View File

@@ -357,7 +357,7 @@ impl VolcengineSpeechConfig {
}
VolcengineSpeechAuthMode::LegacyApp => {
headers.insert(
"X-Api-App-Id",
"X-Api-App-Key",
header_value(self.app_id.as_deref().unwrap_or(""))?,
);
headers.insert(