feat: complete M5 custom world and agent chain
This commit is contained in:
636
server-rs/crates/api-server/src/custom_world_ai.rs
Normal file
636
server-rs/crates/api-server/src/custom_world_ai.rs
Normal file
@@ -0,0 +1,636 @@
|
||||
use std::{
|
||||
fs,
|
||||
path::{Path, PathBuf},
|
||||
};
|
||||
|
||||
use axum::{
|
||||
Json,
|
||||
extract::{Extension, State, rejection::JsonRejection},
|
||||
http::StatusCode,
|
||||
response::Response,
|
||||
};
|
||||
use platform_llm::{LlmMessage, LlmTextRequest};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::{Map, Value, json};
|
||||
|
||||
use crate::{
|
||||
api_response::json_success_body, auth::AuthenticatedAccessToken, http_error::AppError,
|
||||
request_context::RequestContext, state::AppState,
|
||||
};
|
||||
|
||||
#[derive(Clone, Debug, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub(crate) struct CustomWorldEntityRequest {
|
||||
profile: Value,
|
||||
kind: String,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub(crate) struct CustomWorldSceneNpcRequest {
|
||||
profile: Value,
|
||||
landmark_id: String,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub(crate) struct CustomWorldSceneImageRequest {
|
||||
#[serde(default)]
|
||||
profile_id: Option<String>,
|
||||
#[serde(default)]
|
||||
world_name: Option<String>,
|
||||
#[serde(default)]
|
||||
landmark_id: Option<String>,
|
||||
#[serde(default)]
|
||||
landmark_name: Option<String>,
|
||||
#[serde(default)]
|
||||
prompt: Option<String>,
|
||||
#[serde(default)]
|
||||
size: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub(crate) struct CustomWorldCoverImageRequest {
|
||||
profile: Value,
|
||||
#[serde(default)]
|
||||
user_prompt: Option<String>,
|
||||
#[serde(default)]
|
||||
size: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub(crate) struct CustomWorldCoverUploadRequest {
|
||||
#[serde(default)]
|
||||
profile_id: Option<String>,
|
||||
#[serde(default)]
|
||||
world_name: Option<String>,
|
||||
image_data_url: String,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
struct GeneratedAssetResponse {
|
||||
image_src: String,
|
||||
asset_id: String,
|
||||
source_type: String,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
model: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
size: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
task_id: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
prompt: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
actual_prompt: Option<String>,
|
||||
}
|
||||
|
||||
pub async fn generate_custom_world_entity(
|
||||
State(state): State<AppState>,
|
||||
Extension(request_context): Extension<RequestContext>,
|
||||
Extension(_authenticated): Extension<AuthenticatedAccessToken>,
|
||||
payload: Result<Json<CustomWorldEntityRequest>, JsonRejection>,
|
||||
) -> Result<Json<Value>, Response> {
|
||||
let Json(payload) = payload.map_err(|error| {
|
||||
custom_world_ai_error_response(
|
||||
&request_context,
|
||||
AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({
|
||||
"provider": "custom-world-ai",
|
||||
"message": error.body_text(),
|
||||
})),
|
||||
)
|
||||
})?;
|
||||
|
||||
let kind = payload.kind.trim();
|
||||
if !matches!(kind, "playable" | "story" | "landmark") {
|
||||
return Err(custom_world_ai_error_response(
|
||||
&request_context,
|
||||
AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({
|
||||
"provider": "custom-world-ai",
|
||||
"message": "kind 必须是 playable、story 或 landmark",
|
||||
})),
|
||||
));
|
||||
}
|
||||
|
||||
let entity = generate_entity_with_fallback(&state, &payload.profile, kind).await;
|
||||
|
||||
Ok(json_success_body(
|
||||
Some(&request_context),
|
||||
json!({
|
||||
"kind": kind,
|
||||
"entity": entity,
|
||||
}),
|
||||
))
|
||||
}
|
||||
|
||||
pub async fn generate_custom_world_scene_npc(
|
||||
State(state): State<AppState>,
|
||||
Extension(request_context): Extension<RequestContext>,
|
||||
Extension(_authenticated): Extension<AuthenticatedAccessToken>,
|
||||
payload: Result<Json<CustomWorldSceneNpcRequest>, JsonRejection>,
|
||||
) -> Result<Json<Value>, Response> {
|
||||
let Json(payload) = payload.map_err(|error| {
|
||||
custom_world_ai_error_response(
|
||||
&request_context,
|
||||
AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({
|
||||
"provider": "custom-world-ai",
|
||||
"message": error.body_text(),
|
||||
})),
|
||||
)
|
||||
})?;
|
||||
|
||||
let landmark_id = payload.landmark_id.trim();
|
||||
if landmark_id.is_empty() {
|
||||
return Err(custom_world_ai_error_response(
|
||||
&request_context,
|
||||
AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({
|
||||
"provider": "custom-world-ai",
|
||||
"message": "landmarkId is required",
|
||||
})),
|
||||
));
|
||||
}
|
||||
|
||||
let npc = generate_scene_npc_with_fallback(&state, &payload.profile, landmark_id).await;
|
||||
Ok(json_success_body(
|
||||
Some(&request_context),
|
||||
json!({ "npc": npc }),
|
||||
))
|
||||
}
|
||||
|
||||
pub async fn generate_custom_world_scene_image(
|
||||
Extension(request_context): Extension<RequestContext>,
|
||||
Extension(_authenticated): Extension<AuthenticatedAccessToken>,
|
||||
payload: Result<Json<CustomWorldSceneImageRequest>, JsonRejection>,
|
||||
) -> Result<Json<Value>, Response> {
|
||||
let Json(payload) = payload.map_err(|error| {
|
||||
custom_world_ai_error_response(
|
||||
&request_context,
|
||||
AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({
|
||||
"provider": "custom-world-ai",
|
||||
"message": error.body_text(),
|
||||
})),
|
||||
)
|
||||
})?;
|
||||
|
||||
let asset = save_placeholder_asset(
|
||||
"generated-custom-world-scenes",
|
||||
payload
|
||||
.profile_id
|
||||
.as_deref()
|
||||
.or(payload.world_name.as_deref())
|
||||
.unwrap_or("world"),
|
||||
payload
|
||||
.landmark_id
|
||||
.as_deref()
|
||||
.or(payload.landmark_name.as_deref())
|
||||
.unwrap_or("scene"),
|
||||
"scene",
|
||||
payload.size.as_deref().unwrap_or("1280*720"),
|
||||
payload.prompt.as_deref(),
|
||||
)
|
||||
.map_err(|error| custom_world_ai_error_response(&request_context, error))?;
|
||||
|
||||
Ok(json_success_body(Some(&request_context), asset))
|
||||
}
|
||||
|
||||
pub async fn generate_custom_world_cover_image(
|
||||
Extension(request_context): Extension<RequestContext>,
|
||||
Extension(_authenticated): Extension<AuthenticatedAccessToken>,
|
||||
payload: Result<Json<CustomWorldCoverImageRequest>, JsonRejection>,
|
||||
) -> Result<Json<Value>, Response> {
|
||||
let Json(payload) = payload.map_err(|error| {
|
||||
custom_world_ai_error_response(
|
||||
&request_context,
|
||||
AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({
|
||||
"provider": "custom-world-ai",
|
||||
"message": error.body_text(),
|
||||
})),
|
||||
)
|
||||
})?;
|
||||
|
||||
let profile = payload.profile.as_object().cloned().unwrap_or_default();
|
||||
let world_name = read_string_field(&profile, "name").unwrap_or_else(|| "world".to_string());
|
||||
let asset = save_placeholder_asset(
|
||||
"generated-custom-world-covers",
|
||||
&read_string_field(&profile, "id").unwrap_or_else(|| world_name.clone()),
|
||||
"cover",
|
||||
"cover",
|
||||
payload.size.as_deref().unwrap_or("1600*900"),
|
||||
payload.user_prompt.as_deref(),
|
||||
)
|
||||
.map_err(|error| custom_world_ai_error_response(&request_context, error))?;
|
||||
|
||||
Ok(json_success_body(Some(&request_context), asset))
|
||||
}
|
||||
|
||||
pub async fn upload_custom_world_cover_image(
|
||||
Extension(request_context): Extension<RequestContext>,
|
||||
Extension(_authenticated): Extension<AuthenticatedAccessToken>,
|
||||
payload: Result<Json<CustomWorldCoverUploadRequest>, JsonRejection>,
|
||||
) -> Result<Json<Value>, Response> {
|
||||
let Json(payload) = payload.map_err(|error| {
|
||||
custom_world_ai_error_response(
|
||||
&request_context,
|
||||
AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({
|
||||
"provider": "custom-world-ai",
|
||||
"message": error.body_text(),
|
||||
})),
|
||||
)
|
||||
})?;
|
||||
|
||||
let parsed = parse_image_data_url(payload.image_data_url.trim()).ok_or_else(|| {
|
||||
custom_world_ai_error_response(
|
||||
&request_context,
|
||||
AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({
|
||||
"provider": "custom-world-ai",
|
||||
"message": "imageDataUrl 必须是有效的图片 Data URL",
|
||||
})),
|
||||
)
|
||||
})?;
|
||||
let asset_id = format!("custom-cover-upload-{}", current_utc_millis());
|
||||
let world_segment = sanitize_path_segment(
|
||||
payload
|
||||
.profile_id
|
||||
.as_deref()
|
||||
.or(payload.world_name.as_deref())
|
||||
.unwrap_or("world"),
|
||||
"world",
|
||||
);
|
||||
let relative_dir = PathBuf::from("generated-custom-world-covers")
|
||||
.join(world_segment)
|
||||
.join(&asset_id);
|
||||
let output_dir = resolve_public_output_dir(&relative_dir)
|
||||
.map_err(|error| custom_world_ai_error_response(&request_context, error))?;
|
||||
fs::create_dir_all(&output_dir)
|
||||
.map_err(io_error)
|
||||
.map_err(|error| custom_world_ai_error_response(&request_context, error))?;
|
||||
let file_name = match parsed.mime_type.as_str() {
|
||||
"image/png" => "cover.png",
|
||||
"image/webp" => "cover.webp",
|
||||
_ => "cover.jpg",
|
||||
};
|
||||
fs::write(output_dir.join(file_name), parsed.bytes)
|
||||
.map_err(io_error)
|
||||
.map_err(|error| custom_world_ai_error_response(&request_context, error))?;
|
||||
let image_src = format!(
|
||||
"/{}/{}",
|
||||
relative_dir.to_string_lossy().replace('\\', "/"),
|
||||
file_name
|
||||
);
|
||||
|
||||
Ok(json_success_body(
|
||||
Some(&request_context),
|
||||
GeneratedAssetResponse {
|
||||
image_src,
|
||||
asset_id,
|
||||
source_type: "uploaded".to_string(),
|
||||
model: None,
|
||||
size: None,
|
||||
task_id: None,
|
||||
prompt: None,
|
||||
actual_prompt: None,
|
||||
},
|
||||
))
|
||||
}
|
||||
|
||||
async fn generate_entity_with_fallback(
|
||||
state: &AppState,
|
||||
profile: &Value,
|
||||
kind: &str,
|
||||
) -> Value {
|
||||
let fallback = build_entity_fallback(profile, kind);
|
||||
let Some(llm_client) = state.llm_client() else {
|
||||
return fallback;
|
||||
};
|
||||
let request = LlmTextRequest::new(vec![
|
||||
LlmMessage::system(
|
||||
"你是 RPG 自定义世界实体生成器。只输出一个 JSON 对象,不要输出 Markdown。",
|
||||
),
|
||||
LlmMessage::user(
|
||||
json!({
|
||||
"task": "generate_custom_world_entity",
|
||||
"kind": kind,
|
||||
"profile": profile,
|
||||
"fallback": fallback,
|
||||
})
|
||||
.to_string(),
|
||||
),
|
||||
]);
|
||||
|
||||
llm_client
|
||||
.request_text(request)
|
||||
.await
|
||||
.ok()
|
||||
.and_then(|response| serde_json::from_str::<Value>(response.content.trim()).ok())
|
||||
.unwrap_or(fallback)
|
||||
}
|
||||
|
||||
async fn generate_scene_npc_with_fallback(
|
||||
state: &AppState,
|
||||
profile: &Value,
|
||||
landmark_id: &str,
|
||||
) -> Value {
|
||||
let fallback = build_scene_npc_fallback(profile, landmark_id);
|
||||
let Some(llm_client) = state.llm_client() else {
|
||||
return fallback;
|
||||
};
|
||||
let request = LlmTextRequest::new(vec![
|
||||
LlmMessage::system(
|
||||
"你是 RPG 自定义世界场景 NPC 生成器。只输出一个 JSON 对象,不要输出 Markdown。",
|
||||
),
|
||||
LlmMessage::user(
|
||||
json!({
|
||||
"task": "generate_custom_world_scene_npc",
|
||||
"landmarkId": landmark_id,
|
||||
"profile": profile,
|
||||
"fallback": fallback,
|
||||
})
|
||||
.to_string(),
|
||||
),
|
||||
]);
|
||||
|
||||
llm_client
|
||||
.request_text(request)
|
||||
.await
|
||||
.ok()
|
||||
.and_then(|response| serde_json::from_str::<Value>(response.content.trim()).ok())
|
||||
.unwrap_or(fallback)
|
||||
}
|
||||
|
||||
fn build_entity_fallback(profile: &Value, kind: &str) -> Value {
|
||||
let object = profile.as_object().cloned().unwrap_or_default();
|
||||
let world_name = read_string_field(&object, "name").unwrap_or_else(|| "自定义世界".to_string());
|
||||
match kind {
|
||||
"playable" => build_role_fallback("playable", "新同行者", &world_name, 18),
|
||||
"story" => build_role_fallback("story", "新场景角色", &world_name, 6),
|
||||
"landmark" => build_landmark_fallback(&world_name),
|
||||
_ => json!({}),
|
||||
}
|
||||
}
|
||||
|
||||
fn build_scene_npc_fallback(profile: &Value, landmark_id: &str) -> Value {
|
||||
let object = profile.as_object().cloned().unwrap_or_default();
|
||||
let world_name = read_string_field(&object, "name").unwrap_or_else(|| "自定义世界".to_string());
|
||||
let landmark_name = object
|
||||
.get("landmarks")
|
||||
.and_then(Value::as_array)
|
||||
.and_then(|entries| {
|
||||
entries.iter().find_map(|entry| {
|
||||
let object = entry.as_object()?;
|
||||
(read_string_field(object, "id").as_deref() == Some(landmark_id))
|
||||
.then(|| read_string_field(object, "name"))
|
||||
.flatten()
|
||||
})
|
||||
})
|
||||
.unwrap_or_else(|| "当前场景".to_string());
|
||||
let mut npc = build_role_fallback("story", &format!("{landmark_name}来客"), &world_name, 6);
|
||||
if let Some(object) = npc.as_object_mut() {
|
||||
object.insert(
|
||||
"description".to_string(),
|
||||
Value::String(format!("长期活动于{landmark_name},熟悉这里的局势与暗线。")),
|
||||
);
|
||||
}
|
||||
npc
|
||||
}
|
||||
|
||||
fn build_role_fallback(prefix: &str, name: &str, world_name: &str, affinity: i64) -> Value {
|
||||
let suffix = current_utc_millis();
|
||||
json!({
|
||||
"id": format!("{prefix}-{}", suffix),
|
||||
"name": name,
|
||||
"title": "关键角色",
|
||||
"role": "关键角色",
|
||||
"description": format!("围绕《{world_name}》当前主线冲突生成的新增角色。"),
|
||||
"backstory": format!("他与《{world_name}》正在展开的局势存在直接牵连。"),
|
||||
"personality": "谨慎、敏锐,先观察再表态。",
|
||||
"motivation": "希望借玩家的介入改变当前失衡局面。",
|
||||
"combatStyle": "偏向试探与控场。",
|
||||
"initialAffinity": affinity,
|
||||
"relationshipHooks": ["与玩家保持试探", "掌握局势暗线"],
|
||||
"relations": [],
|
||||
"tags": ["自定义", "生成"],
|
||||
"backstoryReveal": {
|
||||
"publicSummary": "一个掌握部分旧线索的关键角色。",
|
||||
"chapters": [
|
||||
{ "id": "surface", "title": "表层来意", "affinityRequired": 6, "teaser": "他知道这里正在发生什么。", "content": "他一直在观察这片区域的变化。", "contextSnippet": "" },
|
||||
{ "id": "scar", "title": "旧事裂痕", "affinityRequired": 12, "teaser": "他与旧案有直接关联。", "content": "过往的一次事件把他绑定在这条线里。", "contextSnippet": "" },
|
||||
{ "id": "hidden", "title": "隐藏执念", "affinityRequired": 18, "teaser": "他真正想推动的局面还没说出口。", "content": "他一直在寻找能撬动局面的机会。", "contextSnippet": "" },
|
||||
{ "id": "final", "title": "最终底牌", "affinityRequired": 24, "teaser": "他手里还压着一张底牌。", "content": "一旦局势逼近临界点,他会出手。", "contextSnippet": "" }
|
||||
]
|
||||
},
|
||||
"skills": [
|
||||
{ "id": format!("skill-{}-1", suffix), "name": "试探起手", "summary": "先判断局势与对手意图。", "style": "试探压制" },
|
||||
{ "id": format!("skill-{}-2", suffix), "name": "借势压场", "summary": "利用环境为自己制造主动权。", "style": "环境协同" },
|
||||
{ "id": format!("skill-{}-3", suffix), "name": "暗线反制", "summary": "在关键节点打乱对方节奏。", "style": "后手翻盘" }
|
||||
],
|
||||
"initialItems": [
|
||||
{ "id": format!("item-{}-1", suffix), "name": "随身兵装", "category": "武器", "quantity": 1, "rarity": "rare", "description": "常备的近身装备。", "tags": ["自定义"] },
|
||||
{ "id": format!("item-{}-2", suffix), "name": "私人物件", "category": "道具", "quantity": 1, "rarity": "uncommon", "description": "可在关键时刻调用的人情或凭证。", "tags": ["自定义"] },
|
||||
{ "id": format!("item-{}-3", suffix), "name": "线索残页", "category": "专属物品", "quantity": 1, "rarity": "rare", "description": "记录部分隐藏线索。", "tags": ["线索"] }
|
||||
]
|
||||
})
|
||||
}
|
||||
|
||||
fn build_landmark_fallback(world_name: &str) -> Value {
|
||||
let suffix = current_utc_millis();
|
||||
json!({
|
||||
"id": format!("landmark-{}", suffix),
|
||||
"name": "新场景",
|
||||
"description": format!("围绕《{world_name}》当前主线冲突扩展出的关键场景。"),
|
||||
"visualDescription": "低照度、层次复杂、带有明显环境叙事痕迹。",
|
||||
"dangerLevel": "medium",
|
||||
"sceneNpcIds": [],
|
||||
"connections": [],
|
||||
"narrativeResidues": [],
|
||||
})
|
||||
}
|
||||
|
||||
fn save_placeholder_asset(
|
||||
root_segment: &str,
|
||||
world_segment_seed: &str,
|
||||
leaf_segment_seed: &str,
|
||||
file_stem: &str,
|
||||
size: &str,
|
||||
prompt: Option<&str>,
|
||||
) -> Result<GeneratedAssetResponse, AppError> {
|
||||
let asset_id = format!("{file_stem}-{}", current_utc_millis());
|
||||
let relative_dir = PathBuf::from(root_segment)
|
||||
.join(sanitize_path_segment(world_segment_seed, "world"))
|
||||
.join(sanitize_path_segment(leaf_segment_seed, file_stem))
|
||||
.join(&asset_id);
|
||||
let output_dir = resolve_public_output_dir(&relative_dir)?;
|
||||
fs::create_dir_all(&output_dir).map_err(io_error)?;
|
||||
let file_name = format!("{file_stem}.svg");
|
||||
let svg = build_placeholder_svg(size, prompt.unwrap_or(file_stem));
|
||||
fs::write(output_dir.join(&file_name), svg).map_err(io_error)?;
|
||||
|
||||
Ok(GeneratedAssetResponse {
|
||||
image_src: format!("/{}/{}", relative_dir.to_string_lossy().replace('\\', "/"), file_name),
|
||||
asset_id: asset_id.clone(),
|
||||
source_type: "generated".to_string(),
|
||||
model: Some("rust-placeholder".to_string()),
|
||||
size: Some(size.to_string()),
|
||||
task_id: Some(asset_id),
|
||||
prompt: prompt.map(ToOwned::to_owned),
|
||||
actual_prompt: prompt.map(ToOwned::to_owned),
|
||||
})
|
||||
}
|
||||
|
||||
fn build_placeholder_svg(size: &str, label: &str) -> String {
|
||||
let (width, height) = parse_size(size);
|
||||
format!(
|
||||
r##"<svg xmlns="http://www.w3.org/2000/svg" width="{width}" height="{height}" viewBox="0 0 {width} {height}">
|
||||
<defs>
|
||||
<linearGradient id="bg" x1="0" y1="0" x2="1" y2="1">
|
||||
<stop offset="0%" stop-color="#0f172a"/>
|
||||
<stop offset="55%" stop-color="#164e63"/>
|
||||
<stop offset="100%" stop-color="#0b1120"/>
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<rect width="100%" height="100%" fill="url(#bg)"/>
|
||||
<circle cx="{cx1}" cy="{cy1}" r="{r1}" fill="rgba(255,255,255,0.12)"/>
|
||||
<circle cx="{cx2}" cy="{cy2}" r="{r2}" fill="rgba(125,211,252,0.14)"/>
|
||||
<text x="50%" y="46%" text-anchor="middle" fill="#e2e8f0" font-size="{font_main}" font-family="Microsoft YaHei, PingFang SC, sans-serif">{title}</text>
|
||||
<text x="50%" y="56%" text-anchor="middle" fill="#bae6fd" font-size="{font_sub}" font-family="Microsoft YaHei, PingFang SC, sans-serif">Rust fallback asset</text>
|
||||
</svg>"##,
|
||||
width = width,
|
||||
height = height,
|
||||
cx1 = width / 3,
|
||||
cy1 = height / 3,
|
||||
r1 = (width.min(height) / 7).max(24),
|
||||
cx2 = width * 3 / 4,
|
||||
cy2 = height / 4,
|
||||
r2 = (width.min(height) / 9).max(18),
|
||||
font_main = (width.min(height) / 12).max(20),
|
||||
font_sub = (width.min(height) / 24).max(12),
|
||||
title = escape_svg_text(label),
|
||||
)
|
||||
}
|
||||
|
||||
fn parse_size(size: &str) -> (u32, u32) {
|
||||
let mut parts = size.split('*');
|
||||
let width = parts
|
||||
.next()
|
||||
.and_then(|value| value.trim().parse::<u32>().ok())
|
||||
.filter(|value| *value > 0)
|
||||
.unwrap_or(1280);
|
||||
let height = parts
|
||||
.next()
|
||||
.and_then(|value| value.trim().parse::<u32>().ok())
|
||||
.filter(|value| *value > 0)
|
||||
.unwrap_or(720);
|
||||
(width, height)
|
||||
}
|
||||
|
||||
fn escape_svg_text(value: &str) -> String {
|
||||
value
|
||||
.replace('&', "&")
|
||||
.replace('<', "<")
|
||||
.replace('>', ">")
|
||||
}
|
||||
|
||||
fn sanitize_path_segment(value: &str, fallback: &str) -> String {
|
||||
let sanitized = value
|
||||
.trim()
|
||||
.chars()
|
||||
.map(|ch| {
|
||||
if ch.is_ascii_alphanumeric() || ('\u{4e00}'..='\u{9fff}').contains(&ch) {
|
||||
ch
|
||||
} else {
|
||||
'-'
|
||||
}
|
||||
})
|
||||
.collect::<String>()
|
||||
.trim_matches('-')
|
||||
.to_string();
|
||||
if sanitized.is_empty() {
|
||||
fallback.to_string()
|
||||
} else {
|
||||
sanitized
|
||||
}
|
||||
}
|
||||
|
||||
fn resolve_public_output_dir(relative_dir: &Path) -> Result<PathBuf, AppError> {
|
||||
let workspace_root = Path::new(env!("CARGO_MANIFEST_DIR"))
|
||||
.ancestors()
|
||||
.nth(3)
|
||||
.ok_or_else(|| {
|
||||
AppError::from_status(StatusCode::INTERNAL_SERVER_ERROR)
|
||||
.with_message("无法解析仓库根目录")
|
||||
})?;
|
||||
Ok(workspace_root.join("public").join(relative_dir))
|
||||
}
|
||||
|
||||
fn parse_image_data_url(value: &str) -> Option<ParsedImageDataUrl> {
|
||||
let prefix = "data:";
|
||||
let separator = ";base64,";
|
||||
let body = value.strip_prefix(prefix)?;
|
||||
let (mime_type, data) = body.split_once(separator)?;
|
||||
let bytes = decode_base64(data)?;
|
||||
Some(ParsedImageDataUrl {
|
||||
mime_type: mime_type.to_string(),
|
||||
bytes,
|
||||
})
|
||||
}
|
||||
|
||||
fn decode_base64(value: &str) -> Option<Vec<u8>> {
|
||||
let cleaned = value.trim().replace(char::is_whitespace, "");
|
||||
let mut output = Vec::with_capacity(cleaned.len() * 3 / 4);
|
||||
let mut buffer = 0u32;
|
||||
let mut bits = 0u8;
|
||||
|
||||
for byte in cleaned.bytes() {
|
||||
let value = match byte {
|
||||
b'A'..=b'Z' => byte - b'A',
|
||||
b'a'..=b'z' => byte - b'a' + 26,
|
||||
b'0'..=b'9' => byte - b'0' + 52,
|
||||
b'+' => 62,
|
||||
b'/' => 63,
|
||||
b'=' => break,
|
||||
_ => return None,
|
||||
} as u32;
|
||||
buffer = (buffer << 6) | value;
|
||||
bits += 6;
|
||||
while bits >= 8 {
|
||||
bits -= 8;
|
||||
output.push(((buffer >> bits) & 0xFF) as u8);
|
||||
}
|
||||
}
|
||||
|
||||
Some(output)
|
||||
}
|
||||
|
||||
fn read_string_field(object: &Map<String, Value>, key: &str) -> Option<String> {
|
||||
object
|
||||
.get(key)
|
||||
.and_then(Value::as_str)
|
||||
.map(str::trim)
|
||||
.filter(|value| !value.is_empty())
|
||||
.map(ToOwned::to_owned)
|
||||
}
|
||||
|
||||
fn current_utc_millis() -> i64 {
|
||||
use std::time::{SystemTime, UNIX_EPOCH};
|
||||
let duration = SystemTime::now()
|
||||
.duration_since(UNIX_EPOCH)
|
||||
.expect("system time should be after unix epoch");
|
||||
i64::try_from(duration.as_millis()).expect("current unix millis should fit in i64")
|
||||
}
|
||||
|
||||
fn io_error(error: std::io::Error) -> AppError {
|
||||
AppError::from_status(StatusCode::INTERNAL_SERVER_ERROR).with_details(json!({
|
||||
"provider": "custom-world-ai",
|
||||
"message": format!("文件写入失败:{error}"),
|
||||
}))
|
||||
}
|
||||
|
||||
fn custom_world_ai_error_response(request_context: &RequestContext, error: AppError) -> Response {
|
||||
error.into_response_with_context(Some(request_context))
|
||||
}
|
||||
|
||||
struct ParsedImageDataUrl {
|
||||
mime_type: String,
|
||||
bytes: Vec<u8>,
|
||||
}
|
||||
Reference in New Issue
Block a user