This commit is contained in:
2026-05-09 17:15:23 +08:00
parent 80a4183b45
commit a0ed128bde
43 changed files with 2573 additions and 381 deletions

View File

@@ -10,7 +10,7 @@ axum = { workspace = true, features = ["ws"] }
base64 = { workspace = true }
dotenvy = { workspace = true }
image = { workspace = true, features = ["jpeg", "png", "webp"] }
reqwest = { workspace = true, features = ["json", "rustls-tls"] }
reqwest = { workspace = true, features = ["json", "multipart", "rustls-tls"] }
webp = { workspace = true }
module-ai = { workspace = true }
module-assets = { workspace = true, features = ["server-service"] }

View File

@@ -74,6 +74,10 @@ use crate::{
},
error_middleware::normalize_error_response,
health::health_check,
hyper3d_generation::{
get_hyper3d_downloads, get_hyper3d_task_status, submit_hyper3d_image_to_model,
submit_hyper3d_text_to_model,
},
llm::proxy_llm_chat_completions,
login_options::auth_login_options,
logout::logout,
@@ -166,6 +170,7 @@ use crate::{
const PUZZLE_REFERENCE_IMAGE_BODY_LIMIT_BYTES: usize = 12 * 1024 * 1024;
const PROFILE_FEEDBACK_BODY_LIMIT_BYTES: usize = 6 * 1024 * 1024;
const HYPER3D_IMAGE_TO_MODEL_BODY_LIMIT_BYTES: usize = 56 * 1024 * 1024;
// 统一由这里构造 Axum 路由树,后续再逐项挂接中间件与业务路由。
pub fn build_router(state: AppState) -> Router {
@@ -550,6 +555,38 @@ pub fn build_router(state: AppState) -> Router {
post(resolve_role_asset_workflow).put(put_role_asset_workflow),
)
.route("/api/assets/read-url", get(get_asset_read_url))
.route(
"/api/assets/hyper3d/text-to-model",
post(submit_hyper3d_text_to_model).route_layer(middleware::from_fn_with_state(
state.clone(),
require_bearer_auth,
)),
)
.route(
"/api/assets/hyper3d/image-to-model",
post(submit_hyper3d_image_to_model)
.layer(DefaultBodyLimit::max(
HYPER3D_IMAGE_TO_MODEL_BODY_LIMIT_BYTES,
))
.route_layer(middleware::from_fn_with_state(
state.clone(),
require_bearer_auth,
)),
)
.route(
"/api/assets/hyper3d/status",
post(get_hyper3d_task_status).route_layer(middleware::from_fn_with_state(
state.clone(),
require_bearer_auth,
)),
)
.route(
"/api/assets/hyper3d/download",
post(get_hyper3d_downloads).route_layer(middleware::from_fn_with_state(
state.clone(),
require_bearer_auth,
)),
)
.route(
"/api/assets/history",
get(get_asset_history).route_layer(middleware::from_fn_with_state(

View File

@@ -96,10 +96,13 @@ pub struct AppConfig {
pub dashscope_image_request_timeout_ms: u64,
pub apimart_base_url: String,
pub apimart_api_key: Option<String>,
pub apimart_image_request_timeout_ms: u64,
pub vector_engine_base_url: String,
pub vector_engine_api_key: Option<String>,
pub vector_engine_image_request_timeout_ms: u64,
pub vector_engine_audio_request_timeout_ms: u64,
pub hyper3d_base_url: String,
pub hyper3d_api_key: Option<String>,
pub hyper3d_model_request_timeout_ms: u64,
pub volcengine_speech_api_key: Option<String>,
pub volcengine_speech_app_id: Option<String>,
pub volcengine_speech_access_key: Option<String>,
@@ -203,10 +206,13 @@ impl Default for AppConfig {
dashscope_image_request_timeout_ms: 150_000,
apimart_base_url: String::new(),
apimart_api_key: None,
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_audio_request_timeout_ms: 180_000,
hyper3d_base_url: "https://api.hyper3d.com/api/v2".to_string(),
hyper3d_api_key: None,
hyper3d_model_request_timeout_ms: 180_000,
volcengine_speech_api_key: None,
volcengine_speech_app_id: None,
volcengine_speech_access_key: None,
@@ -567,12 +573,6 @@ impl AppConfig {
config.apimart_api_key = read_first_non_empty_env(&["APIMART_API_KEY"]);
if let Some(apimart_image_request_timeout_ms) =
read_first_positive_u64_env(&["APIMART_IMAGE_REQUEST_TIMEOUT_MS"])
{
config.apimart_image_request_timeout_ms = apimart_image_request_timeout_ms;
}
if let Some(vector_engine_base_url) = read_first_non_empty_env(&["VECTOR_ENGINE_BASE_URL"])
{
config.vector_engine_base_url = vector_engine_base_url;
@@ -580,12 +580,33 @@ impl AppConfig {
config.vector_engine_api_key = read_first_non_empty_env(&["VECTOR_ENGINE_API_KEY"]);
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;
}
if let Some(vector_engine_audio_request_timeout_ms) =
read_first_positive_u64_env(&["VECTOR_ENGINE_AUDIO_REQUEST_TIMEOUT_MS"])
{
config.vector_engine_audio_request_timeout_ms = vector_engine_audio_request_timeout_ms;
}
if let Some(hyper3d_base_url) =
read_first_non_empty_env(&["HYPER3D_BASE_URL", "RODIN_BASE_URL"])
{
config.hyper3d_base_url = hyper3d_base_url;
}
config.hyper3d_api_key = read_first_non_empty_env(&["HYPER3D_API_KEY", "RODIN_API_KEY"]);
if let Some(hyper3d_model_request_timeout_ms) = read_first_positive_u64_env(&[
"HYPER3D_MODEL_REQUEST_TIMEOUT_MS",
"RODIN_MODEL_REQUEST_TIMEOUT_MS",
]) {
config.hyper3d_model_request_timeout_ms = hyper3d_model_request_timeout_ms;
}
config.volcengine_speech_api_key =
read_first_non_empty_env(&["VOLCENGINE_SPEECH_API_KEY", "VOLCENGINE_API_KEY"]);
config.volcengine_speech_app_id =
@@ -910,6 +931,7 @@ mod tests {
assert!(config.apimart_base_url.is_empty());
assert!(config.vector_engine_base_url.is_empty());
assert!(config.ark_character_video_base_url.is_empty());
assert_eq!(config.hyper3d_base_url, "https://api.hyper3d.com/api/v2");
assert!(config.ark_character_video_model.is_empty());
assert!(config.dashscope_scene_image_model.is_empty());
assert!(config.dashscope_reference_image_model.is_empty());
@@ -938,6 +960,8 @@ mod tests {
std::env::remove_var("GENARRATIVE_LLM_MODEL");
std::env::remove_var("APIMART_BASE_URL");
std::env::remove_var("VECTOR_ENGINE_BASE_URL");
std::env::remove_var("VECTOR_ENGINE_IMAGE_REQUEST_TIMEOUT_MS");
std::env::remove_var("HYPER3D_BASE_URL");
std::env::remove_var("DASHSCOPE_SCENE_IMAGE_MODEL");
std::env::remove_var("DASHSCOPE_REFERENCE_IMAGE_MODEL");
std::env::remove_var("DASHSCOPE_COVER_IMAGE_MODEL");
@@ -949,8 +973,10 @@ mod tests {
"https://llm.internal.example/v1",
);
std::env::set_var("GENARRATIVE_LLM_MODEL", "internal-text-model");
std::env::set_var("APIMART_BASE_URL", "https://image.internal.example/v1");
std::env::set_var("VECTOR_ENGINE_BASE_URL", "https://audio.internal.example");
std::env::set_var("APIMART_BASE_URL", "https://responses.internal.example/v1");
std::env::set_var("VECTOR_ENGINE_BASE_URL", "https://vector.internal.example");
std::env::set_var("VECTOR_ENGINE_IMAGE_REQUEST_TIMEOUT_MS", "210000");
std::env::set_var("HYPER3D_BASE_URL", "https://model.internal.example/api/v2");
std::env::set_var("DASHSCOPE_SCENE_IMAGE_MODEL", "scene-model");
std::env::set_var("DASHSCOPE_REFERENCE_IMAGE_MODEL", "reference-model");
std::env::set_var("DASHSCOPE_COVER_IMAGE_MODEL", "cover-model");
@@ -965,10 +991,18 @@ mod tests {
assert_eq!(config.llm_provider, LlmProvider::OpenAiCompatible);
assert_eq!(config.llm_base_url, "https://llm.internal.example/v1");
assert_eq!(config.llm_model, "internal-text-model");
assert_eq!(config.apimart_base_url, "https://image.internal.example/v1");
assert_eq!(
config.apimart_base_url,
"https://responses.internal.example/v1"
);
assert_eq!(
config.vector_engine_base_url,
"https://audio.internal.example"
"https://vector.internal.example"
);
assert_eq!(config.vector_engine_image_request_timeout_ms, 210_000);
assert_eq!(
config.hyper3d_base_url,
"https://model.internal.example/api/v2"
);
assert_eq!(config.dashscope_scene_image_model, "scene-model");
assert_eq!(config.dashscope_reference_image_model, "reference-model");
@@ -985,6 +1019,8 @@ mod tests {
std::env::remove_var("GENARRATIVE_LLM_MODEL");
std::env::remove_var("APIMART_BASE_URL");
std::env::remove_var("VECTOR_ENGINE_BASE_URL");
std::env::remove_var("VECTOR_ENGINE_IMAGE_REQUEST_TIMEOUT_MS");
std::env::remove_var("HYPER3D_BASE_URL");
std::env::remove_var("DASHSCOPE_SCENE_IMAGE_MODEL");
std::env::remove_var("DASHSCOPE_REFERENCE_IMAGE_MODEL");
std::env::remove_var("DASHSCOPE_COVER_IMAGE_MODEL");

File diff suppressed because it is too large Load Diff

View File

@@ -36,6 +36,7 @@ mod custom_world_rpg_draft_prompts;
mod error_middleware;
mod health;
mod http_error;
mod hyper3d_generation;
mod llm;
mod llm_model_routing;
mod login_options;

View File

@@ -1,14 +1,15 @@
use std::time::{Duration, Instant};
use std::time::Duration;
use axum::http::StatusCode;
use base64::{Engine as _, engine::general_purpose::STANDARD as BASE64_STANDARD};
use reqwest::header;
use serde_json::{Map, Value, json};
use tokio::time::sleep;
use crate::{http_error::AppError, state::AppState};
pub(crate) const GPT_IMAGE_2_MODEL: &str = "gpt-image-2";
pub(crate) const VECTOR_ENGINE_GPT_IMAGE_2_MODEL: &str = "gpt-image-2-all";
const VECTOR_ENGINE_PROVIDER: &str = "vector-engine";
#[derive(Clone, Debug)]
pub(crate) struct OpenAiImageSettings {
@@ -31,37 +32,41 @@ pub(crate) struct DownloadedOpenAiImage {
pub extension: String,
}
// 中文注释RPG 图片资产与拼图一样走 APIMart 的 OpenAI 兼容图片入口,避免把密钥或供应商协议暴露到前端。
// 中文注释RPG、方洞等图片资产统一走 VectorEngine GPT-image-2-all,避免把密钥或供应商协议暴露到前端。
pub(crate) fn require_openai_image_settings(
state: &AppState,
) -> Result<OpenAiImageSettings, AppError> {
let base_url = state.config.apimart_base_url.trim().trim_end_matches('/');
let base_url = state
.config
.vector_engine_base_url
.trim()
.trim_end_matches('/');
if base_url.is_empty() {
return Err(
AppError::from_status(StatusCode::SERVICE_UNAVAILABLE).with_details(json!({
"provider": "apimart",
"reason": "APIMART_BASE_URL 未配置",
"provider": VECTOR_ENGINE_PROVIDER,
"reason": "VECTOR_ENGINE_BASE_URL 未配置",
})),
);
}
let api_key = state
.config
.apimart_api_key
.vector_engine_api_key
.as_deref()
.map(str::trim)
.filter(|value| !value.is_empty())
.ok_or_else(|| {
AppError::from_status(StatusCode::SERVICE_UNAVAILABLE).with_details(json!({
"provider": "apimart",
"reason": "APIMART_API_KEY 未配置",
"provider": VECTOR_ENGINE_PROVIDER,
"reason": "VECTOR_ENGINE_API_KEY 未配置",
}))
})?;
Ok(OpenAiImageSettings {
base_url: base_url.to_string(),
api_key: api_key.to_string(),
request_timeout_ms: state.config.apimart_image_request_timeout_ms.max(1),
request_timeout_ms: state.config.vector_engine_image_request_timeout_ms.max(1),
})
}
@@ -73,8 +78,8 @@ pub(crate) fn build_openai_image_http_client(
.build()
.map_err(|error| {
AppError::from_status(StatusCode::INTERNAL_SERVER_ERROR).with_details(json!({
"provider": "apimart",
"message": format!("构造 APIMart 图片生成 HTTP 客户端失败:{error}"),
"provider": VECTOR_ENGINE_PROVIDER,
"message": format!("构造 VectorEngine 图片生成 HTTP 客户端失败:{error}"),
}))
})
}
@@ -97,11 +102,12 @@ pub(crate) async fn create_openai_image_generation(
reference_images,
);
let response = http_client
.post(format!("{}/images/generations", settings.base_url))
.post(vector_engine_images_generation_url(settings))
.header(
header::AUTHORIZATION,
format!("Bearer {}", settings.api_key),
)
.header(header::ACCEPT, "application/json")
.header(header::CONTENT_TYPE, "application/json")
.json(&request_body)
.send()
@@ -124,40 +130,29 @@ pub(crate) async fn create_openai_image_generation(
}
let response_json = parse_json_payload(response_text.as_str(), failure_context)?;
let generation_id = extract_generation_id(&response_json.payload)
.unwrap_or_else(|| format!("vector-engine-{}", current_utc_micros()));
let actual_prompt = find_first_string_by_key(&response_json.payload, "revised_prompt")
.or_else(|| find_first_string_by_key(&response_json.payload, "actual_prompt"));
let image_urls = extract_image_urls(&response_json.payload);
if !image_urls.is_empty() {
return download_images_from_urls(
http_client,
format!("apimart-{}", current_utc_micros()),
image_urls,
candidate_count,
)
.await;
let mut generated =
download_images_from_urls(http_client, generation_id, image_urls, candidate_count)
.await?;
generated.actual_prompt = actual_prompt;
return Ok(generated);
}
let b64_images = extract_b64_images(&response_json.payload);
if !b64_images.is_empty() {
return Ok(images_from_base64(
format!("apimart-{}", current_utc_micros()),
b64_images,
candidate_count,
));
let mut generated = images_from_base64(generation_id, b64_images, candidate_count);
generated.actual_prompt = actual_prompt;
return Ok(generated);
}
let task_id = extract_task_id(&response_json.payload).ok_or_else(|| {
AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({
"provider": "apimart",
"message": format!("{failure_context}:上游未返回 task_id 或图片"),
}))
})?;
wait_openai_generated_images(
http_client,
settings,
task_id.as_str(),
candidate_count,
failure_context,
)
.await
Err(AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({
"provider": VECTOR_ENGINE_PROVIDER,
"message": format!("{failure_context}VectorEngine 未返回图片地址"),
})))
}
pub(crate) fn build_openai_image_request_body(
@@ -170,14 +165,13 @@ pub(crate) fn build_openai_image_request_body(
let mut body = Map::from_iter([
(
"model".to_string(),
Value::String(GPT_IMAGE_2_MODEL.to_string()),
Value::String(VECTOR_ENGINE_GPT_IMAGE_2_MODEL.to_string()),
),
(
"prompt".to_string(),
Value::String(build_prompt_with_negative(prompt, negative_prompt)),
),
("n".to_string(), json!(candidate_count.clamp(1, 4))),
("official_fallback".to_string(), Value::Bool(true)),
(
"size".to_string(),
Value::String(normalize_image_size(size)),
@@ -185,7 +179,7 @@ pub(crate) fn build_openai_image_request_body(
]);
if !reference_images.is_empty() {
body.insert("image_urls".to_string(), json!(reference_images));
body.insert("image".to_string(), json!(reference_images));
}
Value::Object(body)
@@ -205,109 +199,16 @@ fn build_prompt_with_negative(prompt: &str, negative_prompt: Option<&str>) -> St
fn normalize_image_size(size: &str) -> String {
match size.trim() {
"1024*1024" | "1024x1024" | "1:1" => "1:1",
"1280*720" | "1280x720" | "1600*900" | "1600x900" | "16:9" => "16:9",
"1024*1024" | "1024x1024" | "1:1" => "1024x1024",
"1280*720" | "1280x720" | "1600*900" | "1600x900" | "16:9"
| "1536x1024" | "2048x1152" | "2k" => "1536x1024",
"1024*1536" | "1024x1536" | "9:16" => "1024x1536",
value if !value.is_empty() => value,
_ => "1:1",
_ => "1024x1024",
}
.to_string()
}
async fn wait_openai_generated_images(
http_client: &reqwest::Client,
settings: &OpenAiImageSettings,
task_id: &str,
candidate_count: u32,
failure_context: &str,
) -> Result<OpenAiGeneratedImages, AppError> {
let deadline = Instant::now() + Duration::from_millis(settings.request_timeout_ms);
sleep(Duration::from_secs(10)).await;
while Instant::now() < deadline {
let poll_response = http_client
.get(format!("{}/tasks/{}", settings.base_url, task_id))
.header(
header::AUTHORIZATION,
format!("Bearer {}", settings.api_key),
)
.send()
.await
.map_err(|error| {
map_openai_image_request_error(format!(
"{failure_context}:查询图片生成任务失败:{error}"
))
})?;
let poll_status = poll_response.status();
let poll_text = poll_response.text().await.map_err(|error| {
map_openai_image_request_error(format!(
"{failure_context}:读取图片生成任务响应失败:{error}"
))
})?;
if !poll_status.is_success() {
return Err(map_openai_image_upstream_error(
poll_status.as_u16(),
poll_text.as_str(),
failure_context,
));
}
let poll_json = parse_json_payload(poll_text.as_str(), failure_context)?;
let task_status = find_first_string_by_key(&poll_json.payload, "status")
.or_else(|| find_first_string_by_key(&poll_json.payload, "task_status"))
.unwrap_or_default()
.trim()
.to_ascii_lowercase();
if matches!(task_status.as_str(), "completed" | "succeeded" | "success") {
let image_urls = extract_image_urls(&poll_json.payload);
if image_urls.is_empty() {
let b64_images = extract_b64_images(&poll_json.payload);
if b64_images.is_empty() {
return Err(AppError::from_status(StatusCode::BAD_GATEWAY).with_details(
json!({
"provider": "apimart",
"message": format!("{failure_context}:任务成功但未返回图片"),
}),
));
}
let mut generated =
images_from_base64(task_id.to_string(), b64_images, candidate_count);
generated.actual_prompt =
find_first_string_by_key(&poll_json.payload, "actual_prompt");
return Ok(generated);
}
let mut generated = download_images_from_urls(
http_client,
task_id.to_string(),
image_urls,
candidate_count,
)
.await?;
generated.actual_prompt = find_first_string_by_key(&poll_json.payload, "actual_prompt");
return Ok(generated);
}
if matches!(
task_status.as_str(),
"failed" | "error" | "canceled" | "cancelled" | "unknown"
) {
return Err(map_openai_image_upstream_error(
poll_status.as_u16(),
poll_text.as_str(),
failure_context,
));
}
sleep(Duration::from_secs(3)).await;
}
Err(
AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({
"provider": "apimart",
"message": format!("{failure_context}:图片生成超时或未返回图片地址"),
})),
)
}
async fn download_images_from_urls(
http_client: &reqwest::Client,
task_id: String,
@@ -377,7 +278,7 @@ pub(crate) async fn download_remote_image(
if !status.is_success() {
return Err(
AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({
"provider": "apimart",
"provider": VECTOR_ENGINE_PROVIDER,
"message": "下载生成图片失败",
"status": status.as_u16(),
})),
@@ -400,7 +301,7 @@ fn parse_json_payload(
.map(|payload| ParsedJsonPayload { payload })
.map_err(|error| {
AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({
"provider": "apimart",
"provider": VECTOR_ENGINE_PROVIDER,
"message": format!("{failure_context}:解析响应失败:{error}"),
"rawExcerpt": truncate_raw(raw_text),
}))
@@ -409,7 +310,7 @@ fn parse_json_payload(
fn map_openai_image_request_error(message: String) -> AppError {
AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({
"provider": "apimart",
"provider": VECTOR_ENGINE_PROVIDER,
"message": message,
}))
}
@@ -421,14 +322,14 @@ fn map_openai_image_upstream_error(
) -> AppError {
let message = parse_api_error_message(raw_text, failure_context);
tracing::warn!(
provider = "apimart",
provider = VECTOR_ENGINE_PROVIDER,
upstream_status,
raw_excerpt = %truncate_raw(raw_text),
message,
"APIMart 图片生成上游错误"
"VectorEngine 图片生成上游错误"
);
AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({
"provider": "apimart",
"provider": VECTOR_ENGINE_PROVIDER,
"message": message,
"upstreamStatus": upstream_status,
"rawExcerpt": truncate_raw(raw_text),
@@ -516,10 +417,10 @@ fn find_first_string_by_key(value: &Value, target_key: &str) -> Option<String> {
results.into_iter().next()
}
fn extract_task_id(payload: &Value) -> Option<String> {
find_first_string_by_key(payload, "task_id")
.or_else(|| find_first_string_by_key(payload, "taskId"))
.or_else(|| find_first_string_by_key(payload, "id"))
fn extract_generation_id(payload: &Value) -> Option<String> {
find_first_string_by_key(payload, "id")
.or_else(|| find_first_string_by_key(payload, "created"))
.or_else(|| find_first_string_by_key(payload, "request_id"))
}
fn extract_image_urls(payload: &Value) -> Vec<String> {
@@ -542,6 +443,14 @@ fn extract_b64_images(payload: &Value) -> Vec<String> {
values
}
fn vector_engine_images_generation_url(settings: &OpenAiImageSettings) -> String {
if settings.base_url.ends_with("/v1") {
format!("{}/images/generations", settings.base_url)
} else {
format!("{}/v1/images/generations", settings.base_url)
}
}
fn normalize_downloaded_image_mime_type(content_type: &str) -> String {
let mime_type = content_type
.split(';')
@@ -602,7 +511,7 @@ mod tests {
use super::*;
#[test]
fn gpt_image_2_request_normalizes_legacy_sizes_and_reference_images() {
fn gpt_image_2_request_uses_vector_engine_contract() {
let body = build_openai_image_request_body(
"雾海神殿",
Some("文字,水印"),
@@ -611,11 +520,11 @@ mod tests {
&["data:image/png;base64,abcd".to_string()],
);
assert_eq!(body["model"], GPT_IMAGE_2_MODEL);
assert_eq!(body["size"], "16:9");
assert_eq!(body["model"], VECTOR_ENGINE_GPT_IMAGE_2_MODEL);
assert_eq!(body["size"], "1536x1024");
assert_eq!(body["n"], 2);
assert_eq!(body["official_fallback"], true);
assert_eq!(body["image_urls"][0], "data:image/png;base64,abcd");
assert!(body.get("official_fallback").is_none());
assert_eq!(body["image"][0], "data:image/png;base64,abcd");
assert!(body["prompt"].as_str().unwrap_or_default().contains("避免"));
}

View File

@@ -1,6 +1,6 @@
use std::{
collections::BTreeMap,
time::{Duration, Instant, SystemTime, UNIX_EPOCH},
time::{Duration, SystemTime, UNIX_EPOCH},
};
use axum::{
@@ -68,7 +68,6 @@ use spacetime_client::{
PuzzleWorkUpsertRecordInput, SpacetimeClientError,
};
use std::convert::Infallible;
use tokio::time::sleep;
use crate::{
ai_generation_drafts::{AiGenerationDraftContext, AiGenerationDraftWriter},
@@ -80,6 +79,7 @@ use crate::{
auth::AuthenticatedAccessToken,
http_error::AppError,
llm_model_routing::{CREATION_TEMPLATE_LLM_MODEL, PUZZLE_LEVEL_NAME_VISION_LLM_MODEL},
openai_image_generation::VECTOR_ENGINE_GPT_IMAGE_2_MODEL,
platform_errors::map_oss_error,
prompt::puzzle::{
draft::{
@@ -112,8 +112,8 @@ const PUZZLE_IMAGE_GENERATION_POINTS_COST: u64 = 2;
const PUZZLE_ENTITY_KIND: &str = "puzzle_work";
#[cfg(test)]
const PUZZLE_GENERATED_IMAGE_SIZE: &str = "1024*1024";
const PUZZLE_APIMART_GENERATED_IMAGE_SIZE: &str = "1:1";
const PUZZLE_APIMART_GEMINI_RESOLUTION: &str = "1K";
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;
pub async fn create_puzzle_agent_session(
@@ -941,7 +941,7 @@ pub async fn execute_puzzle_agent_action(
Err(error)
if should_skip_asset_operation_billing_for_connectivity(&error) =>
{
// 中文注释:APIMart/OSS 已生成真实图片时,Maincloud 短暂 503 不应让前端看不到本次图片;先返回内存合成快照,待后续操作恢复正常持久化。
// 中文注释:VectorEngine/OSS 已生成真实图片时,SpacetimeDB 短暂 503 不应让前端看不到本次图片;先返回内存合成快照,待后续操作恢复正常持久化。
tracing::warn!(
provider = PUZZLE_AGENT_API_BASE_PROVIDER,
session_id = %session.session_id,
@@ -3172,7 +3172,7 @@ async fn compile_puzzle_draft_with_initial_cover(
.map(|session| (session, false))
.or_else(|error| {
if is_spacetimedb_connectivity_app_error(&error) {
// 中文注释:首图已落 OSS 时,Maincloud 短暂不可用先返回本地快照,避免整次 APIMart 生图被判失败。
// 中文注释:首图已落 OSS 时,SpacetimeDB 短暂不可用先返回本地快照,避免整次 VectorEngine 生图被判失败。
tracing::warn!(
provider = PUZZLE_AGENT_API_BASE_PROVIDER,
session_id = %compiled_session.session_id,
@@ -3271,7 +3271,7 @@ async fn compile_puzzle_draft_with_uploaded_cover(
&target_level.picture_description,
&draft.summary,
);
// 中文注释:关闭 AI 重绘时不请求 APIMart,也不进入光点扣费流程;上传图直接成为首关正式图候选。
// 中文注释:关闭 AI 重绘时不请求 VectorEngine,也不进入光点扣费流程;上传图直接成为首关正式图候选。
let candidate_id = format!(
"{}-candidate-{}",
compiled_session.session_id,
@@ -3874,18 +3874,23 @@ fn is_missing_puzzle_form_draft_procedure_error(error: &SpacetimeClientError) ->
fn map_puzzle_compile_error(error: SpacetimeClientError) -> AppError {
let message = error.to_string();
let provider = if message.contains("APIMart")
let provider = if message.contains("VectorEngine")
|| message.contains("vector-engine")
|| message.contains("VECTOR_ENGINE")
|| message.contains("APIMart")
|| message.contains("apimart")
|| message.contains("APIMART")
{
"apimart"
VECTOR_ENGINE_PROVIDER
} else if message.contains("OSS") || message.contains("oss") || message.contains("参考图") {
"puzzle-assets"
} else {
"spacetimedb"
};
let status = if provider == "apimart"
&& (message.contains("APIMART_API_KEY")
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("未配置"))
{
@@ -3899,6 +3904,9 @@ fn map_puzzle_compile_error(error: SpacetimeClientError) -> AppError {
} else if matches!(error, SpacetimeClientError::Runtime(_))
&& (message.contains("生成")
|| message.contains("上游")
|| message.contains("VectorEngine")
|| message.contains("vector-engine")
|| message.contains("VECTOR_ENGINE")
|| message.contains("APIMart")
|| message.contains("apimart")
|| message.contains("APIMART")

View File

@@ -784,7 +784,7 @@ fn build_creative_agent_gpt5_client(
config.apimart_base_url.clone(),
api_key.to_string(),
platform_agent::CREATIVE_AGENT_GPT5_MODEL.to_string(),
config.apimart_image_request_timeout_ms,
config.llm_request_timeout_ms,
0,
config.llm_retry_backoff_ms,
)?