fix: stabilize rpg creation entry and opening cg
This commit is contained in:
@@ -619,6 +619,31 @@ mod tests {
|
||||
assert_eq!(body["error"]["details"]["creationTypeId"], "visual-novel");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn disabled_rpg_route_returns_service_unavailable() {
|
||||
let state = AppState::new(AppConfig::default()).expect("state should build");
|
||||
state.set_test_creation_entry_route_enabled("rpg", false);
|
||||
let app = build_router(state);
|
||||
|
||||
let response = app
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.uri("/api/runtime/custom-world/agent/sessions")
|
||||
.body(Body::empty())
|
||||
.expect("request should build"),
|
||||
)
|
||||
.await
|
||||
.expect("request should succeed");
|
||||
|
||||
assert_eq!(response.status(), StatusCode::SERVICE_UNAVAILABLE);
|
||||
let body = read_json_response(response).await;
|
||||
assert_eq!(
|
||||
body["error"]["details"]["reason"],
|
||||
"creation_entry_disabled"
|
||||
);
|
||||
assert_eq!(body["error"]["details"]["creationTypeId"], "rpg");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn healthz_returns_standard_envelope_when_requested() {
|
||||
let app = build_router(AppState::new(AppConfig::default()).expect("state should build"));
|
||||
|
||||
@@ -260,8 +260,11 @@ impl Default for AppConfig {
|
||||
llm_request_timeout_ms: DEFAULT_REQUEST_TIMEOUT_MS,
|
||||
llm_max_retries: DEFAULT_MAX_RETRIES,
|
||||
llm_retry_backoff_ms: DEFAULT_RETRY_BACKOFF_MS,
|
||||
rpg_llm_web_search_enabled: true,
|
||||
creation_agent_llm_web_search_enabled: true,
|
||||
// 中文注释:创作/RPG 的结构化 JSON 链路默认不启用 Responses web_search。
|
||||
// 未开通工具的上游会先吐自然语言再返回 ToolNotOpen,容易污染严格 JSON 结果;
|
||||
// 需要联网增强时由部署环境显式打开对应开关。
|
||||
rpg_llm_web_search_enabled: false,
|
||||
creation_agent_llm_web_search_enabled: false,
|
||||
dashscope_base_url: "https://dashscope.aliyuncs.com/api/v1".to_string(),
|
||||
dashscope_api_key: None,
|
||||
dashscope_scene_image_model: String::new(),
|
||||
@@ -1467,6 +1470,14 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn default_keeps_structured_llm_web_search_disabled() {
|
||||
let config = AppConfig::default();
|
||||
|
||||
assert!(!config.rpg_llm_web_search_enabled);
|
||||
assert!(!config.creation_agent_llm_web_search_enabled);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn from_env_reads_rpg_llm_web_search_switch() {
|
||||
let _guard = ENV_LOCK
|
||||
@@ -1476,11 +1487,11 @@ mod tests {
|
||||
|
||||
unsafe {
|
||||
std::env::remove_var("GENARRATIVE_RPG_LLM_WEB_SEARCH_ENABLED");
|
||||
std::env::set_var("GENARRATIVE_RPG_LLM_WEB_SEARCH_ENABLED", "false");
|
||||
std::env::set_var("GENARRATIVE_RPG_LLM_WEB_SEARCH_ENABLED", "true");
|
||||
}
|
||||
|
||||
let config = AppConfig::from_env();
|
||||
assert!(!config.rpg_llm_web_search_enabled);
|
||||
assert!(config.rpg_llm_web_search_enabled);
|
||||
|
||||
unsafe {
|
||||
std::env::remove_var("GENARRATIVE_RPG_LLM_WEB_SEARCH_ENABLED");
|
||||
@@ -1496,11 +1507,11 @@ mod tests {
|
||||
|
||||
unsafe {
|
||||
std::env::remove_var("GENARRATIVE_CREATION_AGENT_LLM_WEB_SEARCH_ENABLED");
|
||||
std::env::set_var("GENARRATIVE_CREATION_AGENT_LLM_WEB_SEARCH_ENABLED", "false");
|
||||
std::env::set_var("GENARRATIVE_CREATION_AGENT_LLM_WEB_SEARCH_ENABLED", "true");
|
||||
}
|
||||
|
||||
let config = AppConfig::from_env();
|
||||
assert!(!config.creation_agent_llm_web_search_enabled);
|
||||
assert!(config.creation_agent_llm_web_search_enabled);
|
||||
|
||||
unsafe {
|
||||
std::env::remove_var("GENARRATIVE_CREATION_AGENT_LLM_WEB_SEARCH_ENABLED");
|
||||
|
||||
@@ -93,15 +93,74 @@ where
|
||||
F: FnMut(&str),
|
||||
{
|
||||
let mut latest_reply_text = String::new();
|
||||
let turn_output = match request_stream_creation_agent_json_turn_once(
|
||||
llm_client,
|
||||
system_prompt.clone(),
|
||||
user_prompt.clone(),
|
||||
enable_web_search,
|
||||
on_reply_update,
|
||||
&mut latest_reply_text,
|
||||
!enable_web_search,
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(turn_output) => turn_output,
|
||||
Err(CreationAgentJsonTurnFailure::Stream(error))
|
||||
if enable_web_search && is_web_search_tool_unavailable(&error) =>
|
||||
{
|
||||
tracing::warn!(
|
||||
error = %error,
|
||||
"创作 Agent 流式联网搜索插件不可用,自动降级为无联网搜索重试"
|
||||
);
|
||||
latest_reply_text.clear();
|
||||
request_stream_creation_agent_json_turn_once(
|
||||
llm_client,
|
||||
system_prompt,
|
||||
user_prompt,
|
||||
false,
|
||||
on_reply_update,
|
||||
&mut latest_reply_text,
|
||||
true,
|
||||
)
|
||||
.await?
|
||||
}
|
||||
Err(error) => return Err(error),
|
||||
};
|
||||
|
||||
let reply_text = read_reply_text(&turn_output.parsed);
|
||||
if let Some(reply_text) = reply_text.as_deref()
|
||||
&& reply_text != latest_reply_text
|
||||
{
|
||||
on_reply_update(reply_text);
|
||||
}
|
||||
|
||||
Ok(turn_output)
|
||||
}
|
||||
|
||||
async fn request_stream_creation_agent_json_turn_once<F>(
|
||||
llm_client: &LlmClient,
|
||||
system_prompt: String,
|
||||
user_prompt: String,
|
||||
enable_web_search: bool,
|
||||
on_reply_update: &mut F,
|
||||
latest_reply_text: &mut String,
|
||||
emit_reply_updates: bool,
|
||||
) -> Result<CreationAgentJsonTurnOutput, CreationAgentJsonTurnFailure>
|
||||
where
|
||||
F: FnMut(&str),
|
||||
{
|
||||
let response = llm_client
|
||||
.stream_text(
|
||||
build_creation_agent_llm_request(system_prompt, user_prompt, enable_web_search),
|
||||
|delta: &LlmStreamDelta| {
|
||||
if !emit_reply_updates {
|
||||
return;
|
||||
}
|
||||
if let Some(reply_progress) =
|
||||
extract_reply_text_from_partial_json(delta.accumulated_text.as_str())
|
||||
&& reply_progress != latest_reply_text
|
||||
&& reply_progress != *latest_reply_text
|
||||
{
|
||||
latest_reply_text = reply_progress.clone();
|
||||
*latest_reply_text = reply_progress.clone();
|
||||
on_reply_update(reply_progress.as_str());
|
||||
}
|
||||
},
|
||||
@@ -110,12 +169,6 @@ where
|
||||
.map_err(CreationAgentJsonTurnFailure::Stream)?;
|
||||
let parsed = parse_json_response_text(response.content.as_str())
|
||||
.map_err(|_| CreationAgentJsonTurnFailure::Parse)?;
|
||||
let reply_text = read_reply_text(&parsed);
|
||||
if let Some(reply_text) = reply_text.as_deref()
|
||||
&& reply_text != latest_reply_text
|
||||
{
|
||||
on_reply_update(reply_text);
|
||||
}
|
||||
|
||||
Ok(CreationAgentJsonTurnOutput { parsed })
|
||||
}
|
||||
@@ -327,6 +380,7 @@ mod tests {
|
||||
let server = spawn_capturing_mock_server(vec![
|
||||
MockResponse {
|
||||
body: concat!(
|
||||
"data: {\"type\":\"response.output_text.delta\",\"delta\":\"我需要先搜索玩具王国资料。\"}\n\n",
|
||||
"data: {\"type\":\"error\",\"code\":\"ToolNotOpen\",\"message\":\"Your account has not activated web search.\"}\n\n",
|
||||
"data: [DONE]\n\n"
|
||||
)
|
||||
@@ -391,6 +445,55 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn stream_turn_keeps_partial_updates_when_web_search_is_disabled() {
|
||||
let server = spawn_capturing_mock_server(vec![MockResponse {
|
||||
body: concat!(
|
||||
"data: {\"type\":\"response.output_text.delta\",\"delta\":\"{\\\"replyText\\\":\\\"我先\"}\n\n",
|
||||
"data: {\"type\":\"response.output_text.delta\",\"delta\":\"把玩具王国定住。\\\",\\\"progressPercent\\\":12}\"}\n\n",
|
||||
"data: {\"type\":\"response.completed\"}\n\n",
|
||||
)
|
||||
.to_string(),
|
||||
}]);
|
||||
let config = LlmConfig::new(
|
||||
LlmProvider::Ark,
|
||||
server.base_url,
|
||||
"test-key".to_string(),
|
||||
"test-model".to_string(),
|
||||
30_000,
|
||||
0,
|
||||
1,
|
||||
)
|
||||
.expect("LLM config should build");
|
||||
let llm_client = platform_llm::LlmClient::new(config).expect("LLM client should build");
|
||||
let mut visible_replies = Vec::new();
|
||||
|
||||
let output = stream_creation_agent_json_turn(
|
||||
Some(&llm_client),
|
||||
"系统提示".to_string(),
|
||||
"用户提示",
|
||||
false,
|
||||
CreationAgentLlmTurnErrorMessages {
|
||||
model_unavailable: "模型不可用",
|
||||
generation_failed: "生成失败",
|
||||
parse_failed: "解析失败",
|
||||
},
|
||||
|text| visible_replies.push(text.to_string()),
|
||||
|message| message,
|
||||
)
|
||||
.await
|
||||
.expect("stream without web search should succeed");
|
||||
|
||||
assert_eq!(
|
||||
output.parsed["replyText"].as_str(),
|
||||
Some("我先把玩具王国定住。")
|
||||
);
|
||||
assert_eq!(
|
||||
visible_replies,
|
||||
vec!["我先".to_string(), "我先把玩具王国定住。".to_string()]
|
||||
);
|
||||
}
|
||||
|
||||
struct MockResponse {
|
||||
body: String,
|
||||
}
|
||||
|
||||
@@ -96,6 +96,14 @@ pub fn resolve_creation_entry_route_id(path: &str) -> Option<&'static str> {
|
||||
if normalized.starts_with("/api/runtime/big-fish") {
|
||||
return Some("big-fish");
|
||||
}
|
||||
if normalized.starts_with("/api/runtime/custom-world")
|
||||
|| normalized.starts_with("/api/runtime/custom-world-library")
|
||||
|| normalized.starts_with("/api/runtime/custom-world-gallery")
|
||||
|| normalized.starts_with("/api/runtime/chat")
|
||||
|| normalized.starts_with("/api/story")
|
||||
{
|
||||
return Some("rpg");
|
||||
}
|
||||
if normalized.starts_with("/api/runtime/visual-novel") {
|
||||
return Some("visual-novel");
|
||||
}
|
||||
@@ -161,6 +169,26 @@ mod tests {
|
||||
resolve_creation_entry_route_id("/api/creation/visual-novel/sessions"),
|
||||
Some("visual-novel"),
|
||||
);
|
||||
assert_eq!(
|
||||
resolve_creation_entry_route_id("/api/runtime/custom-world/agent/sessions"),
|
||||
Some("rpg"),
|
||||
);
|
||||
assert_eq!(
|
||||
resolve_creation_entry_route_id("/api/runtime/custom-world-library/profile-1"),
|
||||
Some("rpg"),
|
||||
);
|
||||
assert_eq!(
|
||||
resolve_creation_entry_route_id("/api/runtime/custom-world-gallery/user-1/profile-1"),
|
||||
Some("rpg"),
|
||||
);
|
||||
assert_eq!(
|
||||
resolve_creation_entry_route_id("/api/story/sessions/runtime"),
|
||||
Some("rpg"),
|
||||
);
|
||||
assert_eq!(
|
||||
resolve_creation_entry_route_id("/api/runtime/chat/npc/turn/stream"),
|
||||
Some("rpg"),
|
||||
);
|
||||
assert_eq!(
|
||||
resolve_creation_entry_route_id("/api/runtime/bark-battle/works/work-1/config"),
|
||||
Some("bark-battle"),
|
||||
|
||||
@@ -10,7 +10,9 @@ use axum::{
|
||||
response::Response,
|
||||
};
|
||||
use base64::{Engine as _, engine::general_purpose::STANDARD as BASE64_STANDARD};
|
||||
use image::{DynamicImage, GenericImageView, imageops::FilterType};
|
||||
use image::{
|
||||
DynamicImage, GenericImageView, ImageFormat, codecs::jpeg::JpegEncoder, imageops::FilterType,
|
||||
};
|
||||
use module_assets::{
|
||||
AssetObjectAccessPolicy, AssetObjectFieldError, build_asset_entity_binding_input,
|
||||
build_asset_object_upsert_input, generate_asset_binding_id, generate_asset_object_id,
|
||||
@@ -375,6 +377,8 @@ const OPENING_CG_ENTITY_KIND: &str = "custom_world_profile";
|
||||
const OPENING_CG_STORYBOARD_SLOT: &str = "opening_cg_storyboard";
|
||||
const OPENING_CG_VIDEO_SLOT: &str = "opening_cg_video";
|
||||
const ARK_VIDEO_TASK_POLL_INTERVAL_MS: u64 = 5_000;
|
||||
const OPENING_CG_REFERENCE_MAX_EDGE: u32 = 768;
|
||||
const OPENING_CG_REFERENCE_JPEG_QUALITY: u8 = 82;
|
||||
|
||||
struct CoverPromptContext {
|
||||
opening_act_title: String,
|
||||
@@ -1025,6 +1029,16 @@ pub async fn generate_custom_world_opening_cg(
|
||||
"openingSceneImageSrc",
|
||||
)
|
||||
.await?;
|
||||
let player_role_reference = resize_image_reference_data_url(
|
||||
player_role_reference,
|
||||
OPENING_CG_REFERENCE_MAX_EDGE,
|
||||
OPENING_CG_REFERENCE_JPEG_QUALITY,
|
||||
)?;
|
||||
let opening_scene_reference = resize_image_reference_data_url(
|
||||
opening_scene_reference,
|
||||
OPENING_CG_REFERENCE_MAX_EDGE,
|
||||
OPENING_CG_REFERENCE_JPEG_QUALITY,
|
||||
)?;
|
||||
let storyboard = generate_opening_cg_storyboard(
|
||||
&state,
|
||||
&owner_user_id,
|
||||
@@ -1617,6 +1631,52 @@ async fn resolve_reference_image_as_data_url(
|
||||
))
|
||||
}
|
||||
|
||||
fn resize_image_reference_data_url(
|
||||
data_url: String,
|
||||
max_edge: u32,
|
||||
jpeg_quality: u8,
|
||||
) -> Result<String, AppError> {
|
||||
if max_edge == 0 {
|
||||
return Ok(data_url);
|
||||
}
|
||||
let Some(parsed) = parse_image_data_url(data_url.as_str()) else {
|
||||
return Ok(data_url);
|
||||
};
|
||||
let image = image::load_from_memory(parsed.bytes.as_slice()).map_err(|error| {
|
||||
AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({
|
||||
"provider": "custom-world-ai",
|
||||
"message": format!("无法解析参考图:{error}"),
|
||||
}))
|
||||
})?;
|
||||
let (width, height) = image.dimensions();
|
||||
let already_within_budget = width <= max_edge && height <= max_edge;
|
||||
if already_within_budget && parsed.mime_type == "image/jpeg" {
|
||||
return Ok(data_url);
|
||||
}
|
||||
|
||||
// 中文注释:开局 CG 故事板会同时带角色和场景两张参考图;先压到较小 JPEG,避免大图 PNG Data URL 让 VectorEngine 网关在请求发送阶段中断。
|
||||
let resized = if already_within_budget {
|
||||
image
|
||||
} else {
|
||||
image.resize(max_edge, max_edge, FilterType::Triangle)
|
||||
};
|
||||
let encoded_image = DynamicImage::ImageRgb8(resized.to_rgb8());
|
||||
let mut encoded = Vec::new();
|
||||
JpegEncoder::new_with_quality(&mut encoded, jpeg_quality)
|
||||
.encode_image(&encoded_image)
|
||||
.map_err(|error| {
|
||||
AppError::from_status(StatusCode::INTERNAL_SERVER_ERROR).with_details(json!({
|
||||
"provider": "custom-world-ai",
|
||||
"message": format!("压缩参考图失败:{error}"),
|
||||
}))
|
||||
})?;
|
||||
|
||||
Ok(format!(
|
||||
"data:image/jpeg;base64,{}",
|
||||
BASE64_STANDARD.encode(encoded)
|
||||
))
|
||||
}
|
||||
|
||||
async fn create_text_to_image_generation(
|
||||
http_client: &reqwest::Client,
|
||||
settings: &DashScopeSettings,
|
||||
@@ -3065,6 +3125,34 @@ mod tests {
|
||||
assert_eq!(parsed.bytes, b"hello".to_vec());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn opening_cg_reference_data_url_is_resized_to_request_budget() {
|
||||
let image = DynamicImage::ImageRgb8(image::RgbImage::new(2048, 1152));
|
||||
let mut cursor = std::io::Cursor::new(Vec::new());
|
||||
image
|
||||
.write_to(&mut cursor, ImageFormat::Png)
|
||||
.expect("test image should encode");
|
||||
let data_url = format!(
|
||||
"data:image/png;base64,{}",
|
||||
BASE64_STANDARD.encode(cursor.into_inner())
|
||||
);
|
||||
|
||||
let resized = resize_image_reference_data_url(
|
||||
data_url,
|
||||
OPENING_CG_REFERENCE_MAX_EDGE,
|
||||
OPENING_CG_REFERENCE_JPEG_QUALITY,
|
||||
)
|
||||
.expect("reference should resize");
|
||||
let parsed = parse_image_data_url(resized.as_str()).expect("resized data url should parse");
|
||||
let resized_image =
|
||||
image::load_from_memory(parsed.bytes.as_slice()).expect("resized image should decode");
|
||||
let (width, height) = resized_image.dimensions();
|
||||
|
||||
assert!(width <= OPENING_CG_REFERENCE_MAX_EDGE);
|
||||
assert!(height <= OPENING_CG_REFERENCE_MAX_EDGE);
|
||||
assert_eq!(parsed.mime_type, "image/jpeg");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn push_cover_reference_source_keeps_full_data_url() {
|
||||
let mut sources = Vec::new();
|
||||
|
||||
@@ -109,9 +109,8 @@ const MATCH3D_WORK_METADATA_LLM_MODEL: &str = "gpt-4o";
|
||||
const MATCH3D_ITEM_SIZE_LARGE: &str = "大";
|
||||
const MATCH3D_ITEM_SIZE_MEDIUM: &str = "中";
|
||||
const MATCH3D_ITEM_SIZE_SMALL: &str = "小";
|
||||
const MATCH3D_CONTAINER_REFERENCE_IMAGE_BYTES: &[u8] = include_bytes!(
|
||||
"../../../../public/match3d-background-references/pot-fused-reference.png"
|
||||
);
|
||||
const MATCH3D_CONTAINER_REFERENCE_IMAGE_BYTES: &[u8] =
|
||||
include_bytes!("../../../../public/match3d-background-references/pot-fused-reference.png");
|
||||
const MATCH3D_PUBLIC_REFERENCE_IMAGE_PATH_PREFIX: &str = "public/";
|
||||
const MATCH3D_QUESTION_THEME: &str = "你想创作什么题材";
|
||||
const MATCH3D_QUESTION_CLEAR_COUNT: &str = "需要消除多少次才能通关";
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
use super::*;
|
||||
#[cfg(test)]
|
||||
use crate::generated_asset_sheets::crop_generated_asset_sheet_view_edge_matte;
|
||||
use crate::generated_asset_sheets::{
|
||||
GeneratedAssetSheetPersistInput, GeneratedAssetSheetPersistPrompt,
|
||||
GeneratedAssetSheetPromptInput, GeneratedAssetSheetSliceImage,
|
||||
build_generated_asset_sheet_prompt, persist_generated_asset_sheet_bytes,
|
||||
slice_generated_asset_sheet,
|
||||
};
|
||||
#[cfg(test)]
|
||||
use crate::generated_asset_sheets::crop_generated_asset_sheet_view_edge_matte;
|
||||
|
||||
pub(super) async fn generate_match3d_item_assets(
|
||||
state: &AppState,
|
||||
|
||||
@@ -1164,7 +1164,9 @@ fn match3d_container_reference_image_is_embedded_for_api_only_deploy() {
|
||||
assert_eq!(reference.mime_type, "image/png");
|
||||
assert_eq!(reference.file_name, "match3d-container-reference.png");
|
||||
assert!(
|
||||
reference.bytes.starts_with(&[137, 80, 78, 71, 13, 10, 26, 10]),
|
||||
reference
|
||||
.bytes
|
||||
.starts_with(&[137, 80, 78, 71, 13, 10, 26, 10]),
|
||||
"container reference image should be PNG bytes"
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
use std::time::Duration;
|
||||
use std::{error::Error as _, time::Duration};
|
||||
|
||||
use axum::http::StatusCode;
|
||||
use base64::{Engine as _, engine::general_purpose::STANDARD as BASE64_STANDARD};
|
||||
@@ -82,8 +82,6 @@ pub(crate) fn build_openai_image_http_client(
|
||||
) -> Result<reqwest::Client, AppError> {
|
||||
reqwest::Client::builder()
|
||||
.timeout(Duration::from_millis(settings.request_timeout_ms))
|
||||
// 中文注释:同一客户端也会承载 `/v1/images/edits` multipart 图生图请求,强制 HTTP/1.1 可避开部分网关对 HTTP/2 multipart 流的兼容问题。
|
||||
.http1_only()
|
||||
.build()
|
||||
.map_err(|error| {
|
||||
AppError::from_status(StatusCode::INTERNAL_SERVER_ERROR).with_details(json!({
|
||||
@@ -110,21 +108,46 @@ pub(crate) async fn create_openai_image_generation(
|
||||
candidate_count,
|
||||
reference_images,
|
||||
);
|
||||
let request_body_bytes = serde_json::to_vec(&request_body).map_err(|error| {
|
||||
map_openai_image_request_error(format!(
|
||||
"{failure_context}:序列化图片生成请求失败:{error}"
|
||||
))
|
||||
})?;
|
||||
let normalized_size = request_body
|
||||
.get("size")
|
||||
.and_then(Value::as_str)
|
||||
.unwrap_or(size);
|
||||
let reference_lengths = summarize_reference_data_url_lengths(reference_images);
|
||||
let request_url = vector_engine_images_generation_url(settings);
|
||||
tracing::info!(
|
||||
provider = VECTOR_ENGINE_PROVIDER,
|
||||
endpoint = %request_url,
|
||||
model = VECTOR_ENGINE_GPT_IMAGE_2_MODEL,
|
||||
prompt_chars = prompt.chars().count(),
|
||||
size = normalized_size,
|
||||
candidate_count = candidate_count.clamp(1, 4),
|
||||
reference_count = reference_images.len(),
|
||||
reference_data_url_bytes = %reference_lengths,
|
||||
request_body_bytes = request_body_bytes.len(),
|
||||
"VectorEngine 图片生成请求已准备"
|
||||
);
|
||||
let response = http_client
|
||||
.post(vector_engine_images_generation_url(settings))
|
||||
.post(request_url.as_str())
|
||||
.header(
|
||||
header::AUTHORIZATION,
|
||||
format!("Bearer {}", settings.api_key),
|
||||
)
|
||||
.header(header::ACCEPT, "application/json")
|
||||
.header(header::CONTENT_TYPE, "application/json")
|
||||
.json(&request_body)
|
||||
.body(request_body_bytes)
|
||||
.send()
|
||||
.await
|
||||
.map_err(|error| {
|
||||
map_openai_image_request_error(format!(
|
||||
"{failure_context}:创建图片生成任务失败:{error}"
|
||||
))
|
||||
map_openai_image_reqwest_error(
|
||||
format!("{failure_context}:创建图片生成任务失败").as_str(),
|
||||
request_url.as_str(),
|
||||
error,
|
||||
)
|
||||
})?;
|
||||
let response_status = response.status();
|
||||
let response_text = response.text().await.map_err(|error| {
|
||||
@@ -191,8 +214,11 @@ pub(crate) async fn create_openai_image_edit(
|
||||
)
|
||||
.text("n", "1")
|
||||
.text("size", normalize_image_size(size));
|
||||
let response = http_client
|
||||
.post(vector_engine_images_edit_url(settings).as_str())
|
||||
let request_url = vector_engine_images_edit_url(settings);
|
||||
// 中文注释:只对 multipart `/v1/images/edits` 单独强制 HTTP/1.1;大 JSON generations 保持默认协商,避免 HTTP/1.1 网关在发送大请求体时中断连接。
|
||||
let edit_http_client = build_openai_image_edit_http_client(settings)?;
|
||||
let response = edit_http_client
|
||||
.post(request_url.as_str())
|
||||
.header(
|
||||
header::AUTHORIZATION,
|
||||
format!("Bearer {}", settings.api_key),
|
||||
@@ -202,9 +228,11 @@ pub(crate) async fn create_openai_image_edit(
|
||||
.send()
|
||||
.await
|
||||
.map_err(|error| {
|
||||
map_openai_image_request_error(format!(
|
||||
"{failure_context}:创建图片编辑任务失败:{error}"
|
||||
))
|
||||
map_openai_image_reqwest_error(
|
||||
format!("{failure_context}:创建图片编辑任务失败").as_str(),
|
||||
request_url.as_str(),
|
||||
error,
|
||||
)
|
||||
})?;
|
||||
let response_status = response.status();
|
||||
let response_text = response.text().await.map_err(|error| {
|
||||
@@ -242,6 +270,21 @@ pub(crate) async fn create_openai_image_edit(
|
||||
)
|
||||
}
|
||||
|
||||
fn build_openai_image_edit_http_client(
|
||||
settings: &OpenAiImageSettings,
|
||||
) -> Result<reqwest::Client, AppError> {
|
||||
reqwest::Client::builder()
|
||||
.timeout(Duration::from_millis(settings.request_timeout_ms))
|
||||
.http1_only()
|
||||
.build()
|
||||
.map_err(|error| {
|
||||
AppError::from_status(StatusCode::INTERNAL_SERVER_ERROR).with_details(json!({
|
||||
"provider": VECTOR_ENGINE_PROVIDER,
|
||||
"message": format!("构造 VectorEngine 图片编辑 HTTP 客户端失败:{error}"),
|
||||
}))
|
||||
})
|
||||
}
|
||||
|
||||
pub(crate) fn build_openai_image_request_body(
|
||||
prompt: &str,
|
||||
negative_prompt: Option<&str>,
|
||||
@@ -402,6 +445,130 @@ fn map_openai_image_request_error(message: String) -> AppError {
|
||||
}))
|
||||
}
|
||||
|
||||
fn map_openai_image_reqwest_error(
|
||||
context: &str,
|
||||
request_url: &str,
|
||||
error: reqwest::Error,
|
||||
) -> AppError {
|
||||
let message = format!(
|
||||
"{context}:{}",
|
||||
normalize_openai_reqwest_error_message(&error)
|
||||
);
|
||||
let source_chain = reqwest_error_source_chain(&error);
|
||||
let root_source = source_chain.last().cloned().unwrap_or_default();
|
||||
let source_chain_text = source_chain.join(" | ");
|
||||
let is_timeout = error.is_timeout()
|
||||
|| is_openai_image_timeout_message(message.as_str())
|
||||
|| is_openai_image_timeout_message(source_chain_text.as_str());
|
||||
let is_connect = error.is_connect();
|
||||
let status = if is_timeout {
|
||||
StatusCode::GATEWAY_TIMEOUT
|
||||
} else {
|
||||
StatusCode::BAD_GATEWAY
|
||||
};
|
||||
let source = source_chain.first().cloned().unwrap_or_default();
|
||||
let reason = resolve_openai_image_request_failure_reason(&error, source_chain.as_slice());
|
||||
|
||||
tracing::warn!(
|
||||
provider = VECTOR_ENGINE_PROVIDER,
|
||||
endpoint = %request_url,
|
||||
timeout = is_timeout,
|
||||
connect = is_connect,
|
||||
request = error.is_request(),
|
||||
body = error.is_body(),
|
||||
source = %source,
|
||||
root_source = %root_source,
|
||||
source_chain = ?source_chain,
|
||||
message = %message,
|
||||
"VectorEngine 图片请求发送失败"
|
||||
);
|
||||
|
||||
AppError::from_status(status).with_details(json!({
|
||||
"provider": VECTOR_ENGINE_PROVIDER,
|
||||
"message": message,
|
||||
"reason": reason,
|
||||
"endpoint": request_url,
|
||||
"timeout": is_timeout,
|
||||
"connect": is_connect,
|
||||
"request": error.is_request(),
|
||||
"body": error.is_body(),
|
||||
"source": source,
|
||||
"rootSource": root_source,
|
||||
"sourceChain": source_chain,
|
||||
}))
|
||||
}
|
||||
|
||||
fn normalize_openai_reqwest_error_message(error: &reqwest::Error) -> String {
|
||||
error
|
||||
.to_string()
|
||||
.split_whitespace()
|
||||
.collect::<Vec<_>>()
|
||||
.join(" ")
|
||||
}
|
||||
|
||||
fn reqwest_error_source_chain(error: &reqwest::Error) -> Vec<String> {
|
||||
let mut chain = Vec::new();
|
||||
let mut current = error.source();
|
||||
while let Some(source) = current {
|
||||
chain.push(source.to_string());
|
||||
current = source.source();
|
||||
if chain.len() >= 8 {
|
||||
break;
|
||||
}
|
||||
}
|
||||
chain
|
||||
}
|
||||
|
||||
fn resolve_openai_image_request_failure_reason(
|
||||
error: &reqwest::Error,
|
||||
source_chain: &[String],
|
||||
) -> &'static str {
|
||||
let combined = std::iter::once(error.to_string())
|
||||
.chain(source_chain.iter().cloned())
|
||||
.collect::<Vec<_>>()
|
||||
.join(" | ");
|
||||
if error.is_timeout() || is_openai_image_timeout_message(combined.as_str()) {
|
||||
return "VectorEngine 图片生成请求超时,请稍后重试;如果多次复现,检查上游耗时并调大 VECTOR_ENGINE_IMAGE_REQUEST_TIMEOUT_MS";
|
||||
}
|
||||
if error.is_connect() {
|
||||
return "无法连接 VectorEngine 图片生成接口,请检查服务器网络、DNS、防火墙或代理配置";
|
||||
}
|
||||
if error.is_body() {
|
||||
return "发送 VectorEngine 图片生成请求体失败,请重试并检查参考图大小";
|
||||
}
|
||||
if is_openai_image_send_request_interrupted(combined.as_str()) {
|
||||
return "VectorEngine 在接收图片生成请求时中断连接;请重试,若持续复现优先检查参考图体积、上游网关和 HTTP 协议兼容";
|
||||
}
|
||||
"VectorEngine 图片生成请求发送失败,请查看 source 字段中的底层网络错误"
|
||||
}
|
||||
|
||||
fn is_openai_image_send_request_interrupted(message: &str) -> bool {
|
||||
let lower = message.to_ascii_lowercase();
|
||||
lower.contains("sendrequest")
|
||||
|| lower.contains("connection closed")
|
||||
|| lower.contains("connection reset")
|
||||
|| lower.contains("broken pipe")
|
||||
|| lower.contains("unexpected eof")
|
||||
|| lower.contains("stream error")
|
||||
|| lower.contains("body write aborted")
|
||||
}
|
||||
|
||||
fn is_openai_image_timeout_message(message: &str) -> bool {
|
||||
let lower = message.to_ascii_lowercase();
|
||||
lower.contains("timed out")
|
||||
|| lower.contains("timeout")
|
||||
|| lower.contains("operation timed out")
|
||||
|| lower.contains("deadline has elapsed")
|
||||
}
|
||||
|
||||
fn summarize_reference_data_url_lengths(reference_images: &[String]) -> String {
|
||||
reference_images
|
||||
.iter()
|
||||
.map(|value| value.len().to_string())
|
||||
.collect::<Vec<_>>()
|
||||
.join(",")
|
||||
}
|
||||
|
||||
fn map_openai_image_upstream_error(
|
||||
upstream_status: u16,
|
||||
raw_text: &str,
|
||||
@@ -646,6 +813,27 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn vector_engine_reqwest_error_exposes_actionable_reason() {
|
||||
let error = match reqwest::Client::new().get("http://[::1").build() {
|
||||
Ok(_) => panic!("invalid url should fail request build"),
|
||||
Err(error) => error,
|
||||
};
|
||||
let app_error = map_openai_image_reqwest_error(
|
||||
"开局 CG 故事板生成失败:创建图片生成任务失败",
|
||||
"https://api.vectorengine.ai/v1/images/generations",
|
||||
error,
|
||||
);
|
||||
|
||||
assert_eq!(app_error.status_code(), StatusCode::BAD_GATEWAY);
|
||||
assert!(
|
||||
app_error
|
||||
.body_text()
|
||||
.contains("开局 CG 故事板生成失败:创建图片生成任务失败")
|
||||
);
|
||||
assert!(format!("{app_error:?}").contains("sourceChain"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn b64_json_response_decodes_png_image() {
|
||||
let images = images_from_base64(
|
||||
|
||||
@@ -599,6 +599,37 @@ pub fn canonicalize_custom_world_profile_before_save(profile: &mut Value) -> boo
|
||||
true
|
||||
}
|
||||
|
||||
pub fn resolve_custom_world_publish_setting_text(
|
||||
payload: &Map<String, Value>,
|
||||
draft_profile: &Map<String, Value>,
|
||||
seed_text: &str,
|
||||
) -> String {
|
||||
// 中文注释:发布按钮的前端契约只保证提交动作名;正式 settingText 必须从草稿真相补齐,
|
||||
// 避免旧会话 seed_text 为空时通过 publish gate,却在最终 compile/publish 阶段失败。
|
||||
read_nested_text_field(payload, &["settingText"])
|
||||
.or_else(|| {
|
||||
read_nested_text_field(
|
||||
draft_profile,
|
||||
&[
|
||||
"settingText",
|
||||
"creatorIntent.rawSettingText",
|
||||
"creatorIntent.worldHook",
|
||||
"worldHook",
|
||||
"anchorContent.worldPromise",
|
||||
"anchorContent.worldPromise.hook",
|
||||
"summary",
|
||||
"name",
|
||||
"title",
|
||||
],
|
||||
)
|
||||
})
|
||||
.or_else(|| {
|
||||
let seed = seed_text.trim();
|
||||
(!seed.is_empty()).then(|| seed.to_string())
|
||||
})
|
||||
.unwrap_or_default()
|
||||
}
|
||||
|
||||
pub fn empty_agent_anchor_content_json() -> String {
|
||||
r#"{"worldPromise":null,"playerFantasy":null,"themeBoundary":null,"playerEntryPoint":null,"coreConflict":null,"keyRelationships":null,"hiddenLines":null,"iconicElements":null}"#.to_string()
|
||||
}
|
||||
@@ -804,6 +835,32 @@ fn read_text(object: &Map<String, Value>, key: &str) -> Option<String> {
|
||||
.map(ToOwned::to_owned)
|
||||
}
|
||||
|
||||
fn read_nested_text_field(object: &Map<String, Value>, keys: &[&str]) -> Option<String> {
|
||||
for key in keys {
|
||||
let mut current = Value::Object(object.clone());
|
||||
let mut found = true;
|
||||
for segment in key.split('.') {
|
||||
if let Some(next) = current.get(segment) {
|
||||
current = next.clone();
|
||||
} else {
|
||||
found = false;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if found {
|
||||
if let Some(value) = current
|
||||
.as_str()
|
||||
.map(str::trim)
|
||||
.filter(|value| !value.is_empty())
|
||||
{
|
||||
return Some(value.to_string());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
None
|
||||
}
|
||||
|
||||
fn read_string_list(object: &Map<String, Value>, key: &str) -> Vec<String> {
|
||||
object
|
||||
.get(key)
|
||||
@@ -955,3 +1012,56 @@ fn build_compiled_profile_payload_json(
|
||||
serde_json::to_string(&Value::Object(payload))
|
||||
.map_err(|_| CustomWorldFieldError::InvalidDraftProfileJson)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use serde_json::json;
|
||||
|
||||
#[test]
|
||||
fn publish_setting_text_falls_back_to_draft_profile_when_seed_is_empty() {
|
||||
let payload = Map::new();
|
||||
let draft_profile = json!({
|
||||
"settingText": "海雾会吞掉记错航线的人。",
|
||||
"worldHook": "在失真的海图上追查一场被篡改的沉船事故。",
|
||||
"summary": "守灯人与群岛议会围绕沉船旧案对峙。"
|
||||
})
|
||||
.as_object()
|
||||
.cloned()
|
||||
.expect("draft profile should be object");
|
||||
|
||||
let setting_text =
|
||||
resolve_custom_world_publish_setting_text(&payload, &draft_profile, "");
|
||||
|
||||
assert_eq!(setting_text, "海雾会吞掉记错航线的人。");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn publish_setting_text_prefers_payload_then_draft_then_seed() {
|
||||
let mut payload = Map::new();
|
||||
payload.insert(
|
||||
"settingText".to_string(),
|
||||
Value::String("发布载荷设定".to_string()),
|
||||
);
|
||||
let draft_profile = json!({
|
||||
"worldHook": "草稿世界一句话",
|
||||
"summary": "草稿摘要"
|
||||
})
|
||||
.as_object()
|
||||
.cloned()
|
||||
.expect("draft profile should be object");
|
||||
|
||||
assert_eq!(
|
||||
resolve_custom_world_publish_setting_text(&payload, &draft_profile, "用户原始设定"),
|
||||
"发布载荷设定"
|
||||
);
|
||||
assert_eq!(
|
||||
resolve_custom_world_publish_setting_text(&Map::new(), &draft_profile, "用户原始设定"),
|
||||
"草稿世界一句话"
|
||||
);
|
||||
assert_eq!(
|
||||
resolve_custom_world_publish_setting_text(&Map::new(), &Map::new(), "用户原始设定"),
|
||||
"用户原始设定"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -54,9 +54,9 @@ pub fn default_creation_entry_type_snapshots(
|
||||
"rpg",
|
||||
"文字冒险",
|
||||
"经典 RPG 体验",
|
||||
"内测",
|
||||
"可创建",
|
||||
"/creation-type-references/rpg.webp",
|
||||
false,
|
||||
true,
|
||||
true,
|
||||
10,
|
||||
updated_at_micros,
|
||||
|
||||
@@ -236,6 +236,23 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn default_creation_entry_types_open_rpg_entry() {
|
||||
let configs = default_creation_entry_type_snapshots(1);
|
||||
let rpg = configs
|
||||
.iter()
|
||||
.find(|item| item.id == "rpg")
|
||||
.expect("rpg creation entry should be seeded");
|
||||
|
||||
assert_eq!(rpg.title, "文字冒险");
|
||||
assert_eq!(rpg.subtitle, "经典 RPG 体验");
|
||||
assert!(rpg.visible);
|
||||
assert!(rpg.open);
|
||||
assert_eq!(rpg.badge, "可创建");
|
||||
assert_eq!(rpg.sort_order, 10);
|
||||
assert_eq!(rpg.image_src, "/creation-type-references/rpg.webp");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn default_creation_entry_types_include_bark_battle() {
|
||||
let configs = default_creation_entry_type_snapshots(1);
|
||||
|
||||
@@ -2562,6 +2562,18 @@ fn upsert_nested_result_profile_id(
|
||||
}
|
||||
}
|
||||
|
||||
fn resolve_publish_world_setting_text(
|
||||
payload: &JsonMap<String, JsonValue>,
|
||||
draft_profile: &JsonMap<String, JsonValue>,
|
||||
session: &CustomWorldAgentSession,
|
||||
) -> String {
|
||||
module_custom_world::resolve_custom_world_publish_setting_text(
|
||||
payload,
|
||||
draft_profile,
|
||||
&session.seed_text,
|
||||
)
|
||||
}
|
||||
|
||||
fn is_same_agent_draft_profile_candidate(
|
||||
row: &CustomWorldProfile,
|
||||
owner_user_id: &str,
|
||||
@@ -2608,13 +2620,7 @@ fn execute_publish_world_action(
|
||||
.filter(|value| !value.is_empty())
|
||||
.map(ToOwned::to_owned)
|
||||
.unwrap_or_else(|| gate.profile_id.clone());
|
||||
let setting_text = payload
|
||||
.get("settingText")
|
||||
.and_then(JsonValue::as_str)
|
||||
.map(str::trim)
|
||||
.filter(|value| !value.is_empty())
|
||||
.map(ToOwned::to_owned)
|
||||
.unwrap_or_else(|| session.seed_text.clone());
|
||||
let setting_text = resolve_publish_world_setting_text(payload, &draft_profile, session);
|
||||
let legacy_result_profile_json = payload
|
||||
.get("legacyResultProfile")
|
||||
.map(serialize_json_value)
|
||||
|
||||
@@ -179,6 +179,7 @@ fn seed_creation_entry_config_if_missing(ctx: &ReducerContext) {
|
||||
}
|
||||
}
|
||||
|
||||
migrate_rpg_entry_from_old_hidden_default(ctx, now);
|
||||
migrate_visual_novel_entry_from_old_visible_default(ctx, now);
|
||||
migrate_coming_soon_entry_from_old_open_default(
|
||||
ctx,
|
||||
@@ -204,6 +205,36 @@ fn seed_creation_entry_config_if_missing(ctx: &ReducerContext) {
|
||||
);
|
||||
}
|
||||
|
||||
fn migrate_rpg_entry_from_old_hidden_default(ctx: &ReducerContext, now: Timestamp) {
|
||||
let id = "rpg".to_string();
|
||||
let Some(row) = ctx.db.creation_entry_type_config().id().find(&id) else {
|
||||
return;
|
||||
};
|
||||
|
||||
// 中文注释:只开放历史默认隐藏的 RPG 入口,不覆盖后台入口开关后续手动配置。
|
||||
let still_old_hidden_default = row.title == "文字冒险"
|
||||
&& row.subtitle == "经典 RPG 体验"
|
||||
&& row.badge == "内测"
|
||||
&& row.image_src == "/creation-type-references/rpg.webp"
|
||||
&& !row.visible
|
||||
&& row.open
|
||||
&& row.sort_order == 10;
|
||||
if !still_old_hidden_default {
|
||||
return;
|
||||
}
|
||||
|
||||
ctx.db
|
||||
.creation_entry_type_config()
|
||||
.id()
|
||||
.update(CreationEntryTypeConfig {
|
||||
badge: "可创建".to_string(),
|
||||
visible: true,
|
||||
open: true,
|
||||
updated_at: now,
|
||||
..row
|
||||
});
|
||||
}
|
||||
|
||||
fn migrate_visual_novel_entry_from_old_visible_default(ctx: &ReducerContext, now: Timestamp) {
|
||||
let id = "visual-novel".to_string();
|
||||
let Some(row) = ctx.db.creation_entry_type_config().id().find(&id) else {
|
||||
|
||||
Reference in New Issue
Block a user