fix: stabilize rpg creation entry and opening cg

This commit is contained in:
kdletters
2026-05-21 17:21:38 +08:00
parent 0eed942ce5
commit 41075e41a2
26 changed files with 866 additions and 47 deletions

View File

@@ -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"));

View File

@@ -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");

View File

@@ -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,
}

View File

@@ -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"),

View File

@@ -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();

View File

@@ -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 = "需要消除多少次才能通关";

View File

@@ -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,

View File

@@ -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"
);
}

View File

@@ -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(

View File

@@ -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(), "用户原始设定"),
"用户原始设定"
);
}
}

View File

@@ -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,

View File

@@ -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);

View File

@@ -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)

View File

@@ -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 {