feat: complete M5 custom world and agent chain
This commit is contained in:
@@ -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(
|
||||
|
||||
@@ -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
|
||||
|
||||
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>,
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user