feat: complete M5 custom world and agent chain

This commit is contained in:
2026-04-22 14:15:27 +08:00
parent 209e924403
commit 0773a0d0ca
27 changed files with 3359 additions and 159 deletions

View File

@@ -26,13 +26,20 @@ use crate::{
auth_sessions::auth_sessions,
custom_world::{
create_custom_world_agent_session, execute_custom_world_agent_action,
get_custom_world_agent_card_detail,
get_custom_world_agent_operation, get_custom_world_agent_session,
get_custom_world_works,
get_custom_world_gallery_detail, get_custom_world_library,
get_custom_world_library_detail, list_custom_world_gallery,
publish_custom_world_library_profile, put_custom_world_library_profile,
stream_custom_world_agent_message, submit_custom_world_agent_message,
unpublish_custom_world_library_profile,
},
custom_world_ai::{
generate_custom_world_cover_image, generate_custom_world_entity,
generate_custom_world_scene_image, generate_custom_world_scene_npc,
upload_custom_world_cover_image,
},
error_middleware::normalize_error_response,
health::health_check,
llm::proxy_llm_chat_completions,
@@ -54,7 +61,10 @@ use crate::{
put_runtime_snapshot, resume_profile_save_archive,
},
runtime_settings::{get_runtime_settings, put_runtime_settings},
runtime_story::resolve_runtime_story_state,
runtime_story::{
generate_runtime_story_continue, generate_runtime_story_initial,
get_runtime_story_state, resolve_runtime_story_action, resolve_runtime_story_state,
},
state::AppState,
story_battles::{
create_story_battle, create_story_npc_battle, get_story_battle_state, resolve_story_battle,
@@ -297,6 +307,19 @@ pub fn build_router(state: AppState) -> Router {
require_bearer_auth,
)),
)
.route(
"/api/runtime/custom-world/works",
get(get_custom_world_works).route_layer(middleware::from_fn_with_state(
state.clone(),
require_bearer_auth,
)),
)
.route(
"/api/runtime/custom-world/agent/sessions/{session_id}/cards/{card_id}",
get(get_custom_world_agent_card_detail).route_layer(
middleware::from_fn_with_state(state.clone(), require_bearer_auth),
),
)
.route(
"/api/runtime/custom-world/agent/sessions/{session_id}/messages",
post(submit_custom_world_agent_message).route_layer(middleware::from_fn_with_state(
@@ -325,6 +348,62 @@ pub fn build_router(state: AppState) -> Router {
require_bearer_auth,
)),
)
.route(
"/api/custom-world/entity",
post(generate_custom_world_entity).route_layer(middleware::from_fn_with_state(
state.clone(),
require_bearer_auth,
)),
)
.route(
"/api/runtime/custom-world/entity",
post(generate_custom_world_entity).route_layer(middleware::from_fn_with_state(
state.clone(),
require_bearer_auth,
)),
)
.route(
"/api/custom-world/scene-npc",
post(generate_custom_world_scene_npc).route_layer(
middleware::from_fn_with_state(state.clone(), require_bearer_auth),
),
)
.route(
"/api/runtime/custom-world/scene-npc",
post(generate_custom_world_scene_npc).route_layer(
middleware::from_fn_with_state(state.clone(), require_bearer_auth),
),
)
.route(
"/api/custom-world/scene-image",
post(generate_custom_world_scene_image).route_layer(
middleware::from_fn_with_state(state.clone(), require_bearer_auth),
),
)
.route(
"/api/custom-world/cover-image",
post(generate_custom_world_cover_image).route_layer(
middleware::from_fn_with_state(state.clone(), require_bearer_auth),
),
)
.route(
"/api/runtime/custom-world/cover-image",
post(generate_custom_world_cover_image).route_layer(
middleware::from_fn_with_state(state.clone(), require_bearer_auth),
),
)
.route(
"/api/custom-world/cover-upload",
post(upload_custom_world_cover_image).route_layer(
middleware::from_fn_with_state(state.clone(), require_bearer_auth),
),
)
.route(
"/api/runtime/custom-world/cover-upload",
post(upload_custom_world_cover_image).route_layer(
middleware::from_fn_with_state(state.clone(), require_bearer_auth),
),
)
.route(
"/api/runtime/profile/browse-history",
get(get_runtime_browse_history)
@@ -422,6 +501,34 @@ pub fn build_router(state: AppState) -> Router {
require_bearer_auth,
)),
)
.route(
"/api/runtime/story/state/{session_id}",
get(get_runtime_story_state).route_layer(middleware::from_fn_with_state(
state.clone(),
require_bearer_auth,
)),
)
.route(
"/api/runtime/story/actions/resolve",
post(resolve_runtime_story_action).route_layer(middleware::from_fn_with_state(
state.clone(),
require_bearer_auth,
)),
)
.route(
"/api/runtime/story/initial",
post(generate_runtime_story_initial).route_layer(middleware::from_fn_with_state(
state.clone(),
require_bearer_auth,
)),
)
.route(
"/api/runtime/story/continue",
post(generate_runtime_story_continue).route_layer(middleware::from_fn_with_state(
state.clone(),
require_bearer_auth,
)),
)
.route(
"/api/profile/play-stats",
get(get_profile_play_stats).route_layer(middleware::from_fn_with_state(

View File

@@ -11,21 +11,28 @@ use module_custom_world::{
use serde_json::{Map, Value, json};
use shared_contracts::runtime::{
CreateCustomWorldAgentSessionRequest, CustomWorldAgentCheckpointResponse,
CustomWorldAgentCardDetailResponse,
CustomWorldAgentMessageResponse, CustomWorldAgentOperationResponse,
CustomWorldAgentSessionResponse, CustomWorldAgentSessionSnapshotResponse,
CustomWorldDraftCardDetailResponse, CustomWorldDraftCardDetailSectionResponse,
CustomWorldDraftCardSummaryResponse, CustomWorldGalleryCardResponse,
CustomWorldGalleryDetailResponse, CustomWorldGalleryResponse, CustomWorldLibraryEntryResponse,
CustomWorldLibraryMutationResponse, CustomWorldLibraryResponse,
CustomWorldProfileUpsertRequest, CustomWorldSupportedActionResponse,
SendCustomWorldAgentMessageRequest,
CustomWorldPublishGateResponse, CustomWorldResultPreviewBlockerResponse,
CustomWorldWorkSummaryResponse, CustomWorldWorksResponse,
ExecuteCustomWorldAgentActionRequest, SendCustomWorldAgentMessageRequest,
};
use shared_kernel::build_prefixed_uuid_id;
use spacetime_client::{
CustomWorldAgentActionExecuteRecordInput,
CustomWorldAgentCheckpointRecord, CustomWorldAgentMessageRecord,
CustomWorldAgentMessageSubmitRecordInput, CustomWorldAgentOperationRecord,
CustomWorldAgentSessionCreateRecordInput, CustomWorldAgentSessionRecord,
CustomWorldDraftCardDetailRecord, CustomWorldDraftCardDetailSectionRecord,
CustomWorldDraftCardRecord, CustomWorldGalleryEntryRecord, CustomWorldLibraryEntryRecord,
CustomWorldProfileUpsertRecordInput, CustomWorldPublishWorldRecordInput,
CustomWorldProfileUpsertRecordInput, CustomWorldPublishGateRecord,
CustomWorldResultPreviewBlockerRecord, CustomWorldWorkSummaryRecord,
CustomWorldSupportedActionRecord, SpacetimeClientError,
};
@@ -386,6 +393,66 @@ pub async fn get_custom_world_agent_session(
))
}
pub async fn get_custom_world_works(
State(state): State<AppState>,
Extension(request_context): Extension<RequestContext>,
Extension(authenticated): Extension<AuthenticatedAccessToken>,
) -> Result<Json<Value>, Response> {
let items = state
.spacetime_client()
.list_custom_world_works(authenticated.claims().user_id().to_string())
.await
.map_err(|error| {
custom_world_error_response(&request_context, map_custom_world_client_error(error))
})?;
Ok(json_success_body(
Some(&request_context),
CustomWorldWorksResponse {
items: items
.into_iter()
.map(map_custom_world_work_summary_response)
.collect(),
},
))
}
pub async fn get_custom_world_agent_card_detail(
State(state): State<AppState>,
Path((session_id, card_id)): Path<(String, String)>,
Extension(request_context): Extension<RequestContext>,
Extension(authenticated): Extension<AuthenticatedAccessToken>,
) -> Result<Json<Value>, Response> {
if session_id.trim().is_empty() || card_id.trim().is_empty() {
return Err(custom_world_error_response(
&request_context,
AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({
"provider": "custom-world-agent",
"message": "sessionId and cardId are required",
})),
));
}
let card = state
.spacetime_client()
.get_custom_world_agent_card_detail(
session_id,
authenticated.claims().user_id().to_string(),
card_id,
)
.await
.map_err(|error| {
custom_world_error_response(&request_context, map_custom_world_client_error(error))
})?;
Ok(json_success_body(
Some(&request_context),
CustomWorldAgentCardDetailResponse {
card: map_custom_world_draft_card_detail_response(card),
},
))
}
pub async fn submit_custom_world_agent_message(
State(state): State<AppState>,
Path(session_id): Path<String>,
@@ -569,7 +636,7 @@ pub async fn execute_custom_world_agent_action(
Path(session_id): Path<String>,
Extension(request_context): Extension<RequestContext>,
Extension(authenticated): Extension<AuthenticatedAccessToken>,
payload: Result<Json<Value>, JsonRejection>,
payload: Result<Json<ExecuteCustomWorldAgentActionRequest>, JsonRejection>,
) -> Result<Json<Value>, Response> {
let Json(payload) = payload.map_err(|error| {
custom_world_error_response(
@@ -581,84 +648,46 @@ pub async fn execute_custom_world_agent_action(
)
})?;
let action = payload
.get("action")
.and_then(Value::as_str)
.map(str::trim)
.unwrap_or_default();
if action != "publish_world" {
if session_id.trim().is_empty() {
return Err(custom_world_error_response(
&request_context,
AppError::from_status(StatusCode::NOT_IMPLEMENTED).with_details(json!({
AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({
"provider": "custom-world-agent",
"message": "当前 Stage 5 仅支持 publish_world action",
"message": "sessionId is required",
})),
));
}
let profile_id = payload
.get("profileId")
.and_then(Value::as_str)
.map(str::trim)
.filter(|value| !value.is_empty())
.map(ToOwned::to_owned)
.unwrap_or_else(|| format!("agent-draft-{session_id}"));
let draft_profile = payload.get("draftProfile").cloned().ok_or_else(|| {
let action = payload.action.trim().to_string();
if action.is_empty() {
return Err(custom_world_error_response(
&request_context,
AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({
"provider": "custom-world-agent",
"message": "action is required",
})),
));
}
let payload_json = serde_json::to_string(&payload).map_err(|error| {
custom_world_error_response(
&request_context,
AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({
"provider": "custom-world-agent",
"message": "publish_world 当前必须显式提供 draftProfile",
"message": format!("action payload JSON 序列化失败:{error}"),
})),
)
})?;
let setting_text = payload
.get("settingText")
.and_then(Value::as_str)
.map(str::trim)
.filter(|value| !value.is_empty())
.map(ToOwned::to_owned)
.ok_or_else(|| {
custom_world_error_response(
&request_context,
AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({
"provider": "custom-world-agent",
"message": "publish_world 当前必须显式提供 settingText",
})),
)
})?;
let publish_result = state
let result = state
.spacetime_client()
.publish_custom_world_world(CustomWorldPublishWorldRecordInput {
session_id: session_id.clone(),
profile_id,
.execute_custom_world_agent_action(CustomWorldAgentActionExecuteRecordInput {
session_id,
owner_user_id: authenticated.claims().user_id().to_string(),
draft_profile_json: serde_json::to_string(&draft_profile).map_err(|error| {
custom_world_error_response(
&request_context,
AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({
"provider": "custom-world-agent",
"message": format!("draftProfile JSON 序列化失败:{error}"),
})),
)
})?,
legacy_result_profile_json: payload
.get("legacyResultProfile")
.map(serde_json::to_string)
.transpose()
.map_err(|error| {
custom_world_error_response(
&request_context,
AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({
"provider": "custom-world-agent",
"message": format!("legacyResultProfile JSON 序列化失败:{error}"),
})),
)
})?,
setting_text,
author_display_name: resolve_author_display_name(&authenticated),
published_at_micros: current_utc_micros(),
operation_id: build_prefixed_uuid_id("operation-"),
action,
payload_json: Some(payload_json),
submitted_at_micros: current_utc_micros(),
})
.await
.map_err(|error| {
@@ -668,15 +697,7 @@ pub async fn execute_custom_world_agent_action(
Ok(json_success_body(
Some(&request_context),
json!({
"operation": {
"operationId": format!("publish-world-{session_id}"),
"type": "publish_world",
"status": "completed",
"phaseLabel": "世界已发布",
"phaseDetail": format!("正式世界档案已写入作品库:{}。", publish_result.entry.profile_id),
"progress": 100,
"error": Value::Null,
}
"operation": map_custom_world_agent_operation_response(result.operation),
}),
))
}
@@ -722,6 +743,37 @@ fn map_custom_world_gallery_card_response(
}
}
fn map_custom_world_work_summary_response(
item: CustomWorldWorkSummaryRecord,
) -> CustomWorldWorkSummaryResponse {
CustomWorldWorkSummaryResponse {
work_id: item.work_id,
source_type: item.source_type,
status: item.status,
title: item.title,
subtitle: item.subtitle,
summary: item.summary,
cover_image_src: item.cover_image_src,
cover_render_mode: item.cover_render_mode,
cover_character_image_srcs: item.cover_character_image_srcs,
updated_at: item.updated_at,
published_at: item.published_at,
stage: item.stage,
stage_label: item.stage_label,
playable_npc_count: item.playable_npc_count,
landmark_count: item.landmark_count,
role_visual_ready_count: item.role_visual_ready_count,
role_animation_ready_count: item.role_animation_ready_count,
role_asset_summary_label: item.role_asset_summary_label,
session_id: item.session_id,
profile_id: item.profile_id,
can_resume: item.can_resume,
can_enter_world: item.can_enter_world,
blocker_count: item.blocker_count,
publish_ready: item.publish_ready,
}
}
fn map_custom_world_agent_session_response(
session: CustomWorldAgentSessionRecord,
) -> CustomWorldAgentSessionSnapshotResponse {
@@ -763,11 +815,28 @@ fn map_custom_world_agent_session_response(
.into_iter()
.map(map_custom_world_supported_action_response)
.collect(),
publish_gate: session.publish_gate.map(map_custom_world_publish_gate_response),
result_preview: session.result_preview,
updated_at: session.updated_at,
}
}
fn map_custom_world_publish_gate_response(
gate: CustomWorldPublishGateRecord,
) -> CustomWorldPublishGateResponse {
CustomWorldPublishGateResponse {
profile_id: gate.profile_id,
blockers: gate
.blockers
.into_iter()
.map(map_custom_world_result_preview_blocker_response)
.collect(),
blocker_count: gate.blocker_count,
publish_ready: gate.publish_ready,
can_enter_world: gate.can_enter_world,
}
}
fn map_custom_world_agent_message_response(
message: CustomWorldAgentMessageRecord,
) -> CustomWorldAgentMessageResponse {
@@ -812,6 +881,38 @@ fn map_custom_world_draft_card_response(
}
}
fn map_custom_world_draft_card_detail_response(
card: CustomWorldDraftCardDetailRecord,
) -> CustomWorldDraftCardDetailResponse {
CustomWorldDraftCardDetailResponse {
id: card.card_id,
kind: card.kind,
title: card.title,
sections: card
.sections
.into_iter()
.map(map_custom_world_draft_card_detail_section_response)
.collect(),
linked_ids: card.linked_ids,
locked: card.locked,
editable: card.editable,
editable_section_ids: card.editable_section_ids,
warning_messages: card.warning_messages,
asset_status: card.asset_status,
asset_status_label: card.asset_status_label,
}
}
fn map_custom_world_draft_card_detail_section_response(
section: CustomWorldDraftCardDetailSectionRecord,
) -> CustomWorldDraftCardDetailSectionResponse {
CustomWorldDraftCardDetailSectionResponse {
id: section.section_id,
label: section.label,
value: section.value,
}
}
fn map_custom_world_agent_checkpoint_response(
checkpoint: CustomWorldAgentCheckpointRecord,
) -> CustomWorldAgentCheckpointResponse {
@@ -832,6 +933,16 @@ fn map_custom_world_supported_action_response(
}
}
fn map_custom_world_result_preview_blocker_response(
blocker: CustomWorldResultPreviewBlockerRecord,
) -> CustomWorldResultPreviewBlockerResponse {
CustomWorldResultPreviewBlockerResponse {
id: blocker.id,
code: blocker.code,
message: blocker.message,
}
}
fn resolve_stream_reply_text(session: &CustomWorldAgentSessionSnapshotResponse) -> String {
session
.last_assistant_reply

View 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('&', "&amp;")
.replace('<', "&lt;")
.replace('>', "&gt;")
}
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>,
}

View File

@@ -8,6 +8,7 @@ mod auth_session;
mod auth_sessions;
mod config;
mod custom_world;
mod custom_world_ai;
mod error_middleware;
mod health;
mod http_error;