1587 lines
58 KiB
Rust
1587 lines
58 KiB
Rust
use axum::{
|
|
Json,
|
|
extract::{Extension, Path, State, rejection::JsonRejection},
|
|
http::StatusCode,
|
|
response::{
|
|
IntoResponse, Response,
|
|
sse::{Event, Sse},
|
|
},
|
|
};
|
|
use module_custom_world::{
|
|
CustomWorldThemeMode, empty_agent_anchor_content_json, empty_agent_asset_coverage_json,
|
|
empty_agent_creator_intent_readiness_json, empty_json_array, empty_json_object,
|
|
};
|
|
use serde_json::{Map, Value, json};
|
|
use shared_contracts::runtime::{
|
|
CreateCustomWorldAgentSessionRequest, CustomWorldAgentCardDetailResponse,
|
|
CustomWorldAgentCheckpointResponse, CustomWorldAgentMessageResponse,
|
|
CustomWorldAgentOperationResponse, CustomWorldAgentSessionResponse,
|
|
CustomWorldAgentSessionSnapshotResponse, CustomWorldDraftCardDetailResponse,
|
|
CustomWorldDraftCardDetailSectionResponse, CustomWorldDraftCardSummaryResponse,
|
|
CustomWorldGalleryCardResponse, CustomWorldGalleryDetailResponse, CustomWorldGalleryResponse,
|
|
CustomWorldLibraryEntryResponse, CustomWorldLibraryMutationResponse,
|
|
CustomWorldLibraryResponse, CustomWorldProfileUpsertRequest, CustomWorldPublishGateResponse,
|
|
CustomWorldResultPreviewBlockerResponse, CustomWorldSupportedActionResponse,
|
|
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, CustomWorldPublishGateRecord,
|
|
CustomWorldResultPreviewBlockerRecord, CustomWorldSupportedActionRecord,
|
|
CustomWorldWorkSummaryRecord, SpacetimeClientError,
|
|
};
|
|
use std::convert::Infallible;
|
|
use tracing::info;
|
|
|
|
use crate::{
|
|
api_response::json_success_body,
|
|
auth::AuthenticatedAccessToken,
|
|
custom_world_agent_entities::generate_custom_world_agent_entities,
|
|
custom_world_agent_turn::{
|
|
CustomWorldAgentTurnRequest, build_failed_finalize_record_input,
|
|
build_finalize_record_input, run_custom_world_agent_turn,
|
|
},
|
|
custom_world_foundation_draft::{
|
|
DraftFoundationPayloadError, build_draft_foundation_action_payload_json,
|
|
generate_custom_world_foundation_draft,
|
|
},
|
|
http_error::AppError,
|
|
request_context::RequestContext,
|
|
state::AppState,
|
|
};
|
|
|
|
pub async fn get_custom_world_library(
|
|
State(state): State<AppState>,
|
|
Extension(request_context): Extension<RequestContext>,
|
|
Extension(authenticated): Extension<AuthenticatedAccessToken>,
|
|
) -> Result<Json<Value>, Response> {
|
|
let owner_user_id = authenticated.claims().user_id().to_string();
|
|
let entries = state
|
|
.spacetime_client()
|
|
.list_custom_world_profiles(owner_user_id)
|
|
.await
|
|
.map_err(|error| {
|
|
custom_world_error_response(&request_context, map_custom_world_client_error(error))
|
|
})?;
|
|
|
|
Ok(json_success_body(
|
|
Some(&request_context),
|
|
CustomWorldLibraryResponse {
|
|
entries: entries
|
|
.into_iter()
|
|
.map(map_custom_world_library_entry_response)
|
|
.collect(),
|
|
},
|
|
))
|
|
}
|
|
|
|
pub async fn get_custom_world_library_detail(
|
|
State(state): State<AppState>,
|
|
Extension(request_context): Extension<RequestContext>,
|
|
Extension(authenticated): Extension<AuthenticatedAccessToken>,
|
|
Path(profile_id): Path<String>,
|
|
) -> Result<Json<Value>, Response> {
|
|
if profile_id.trim().is_empty() {
|
|
return Err(custom_world_error_response(
|
|
&request_context,
|
|
AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({
|
|
"provider": "custom-world-library",
|
|
"message": "profileId is required",
|
|
})),
|
|
));
|
|
}
|
|
|
|
let detail = state
|
|
.spacetime_client()
|
|
.get_custom_world_library_detail(authenticated.claims().user_id().to_string(), profile_id)
|
|
.await
|
|
.map_err(|error| {
|
|
custom_world_error_response(&request_context, map_custom_world_client_error(error))
|
|
})?;
|
|
|
|
Ok(json_success_body(
|
|
Some(&request_context),
|
|
CustomWorldGalleryDetailResponse {
|
|
entry: map_custom_world_library_entry_response(detail.entry),
|
|
},
|
|
))
|
|
}
|
|
|
|
pub async fn put_custom_world_library_profile(
|
|
State(state): State<AppState>,
|
|
Extension(request_context): Extension<RequestContext>,
|
|
Extension(authenticated): Extension<AuthenticatedAccessToken>,
|
|
Path(profile_id): Path<String>,
|
|
payload: Result<Json<CustomWorldProfileUpsertRequest>, JsonRejection>,
|
|
) -> Result<Json<Value>, Response> {
|
|
let Json(payload) = payload.map_err(|error| {
|
|
custom_world_error_response(
|
|
&request_context,
|
|
AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({
|
|
"provider": "custom-world-library",
|
|
"message": error.body_text(),
|
|
})),
|
|
)
|
|
})?;
|
|
|
|
let owner_user_id = authenticated.claims().user_id().to_string();
|
|
if profile_id.trim().is_empty() {
|
|
return Err(custom_world_error_response(
|
|
&request_context,
|
|
AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({
|
|
"provider": "custom-world-library",
|
|
"message": "profileId is required",
|
|
})),
|
|
));
|
|
}
|
|
|
|
let metadata = extract_custom_world_metadata(&payload.profile).map_err(|error| {
|
|
custom_world_error_response(
|
|
&request_context,
|
|
AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({
|
|
"provider": "custom-world-library",
|
|
"message": error,
|
|
})),
|
|
)
|
|
})?;
|
|
let author_display_name = resolve_author_display_name(&state, &authenticated);
|
|
let author_public_user_code =
|
|
resolve_author_public_user_code(&state, &authenticated, &request_context)?;
|
|
let mutation = state
|
|
.spacetime_client()
|
|
.upsert_custom_world_profile(CustomWorldProfileUpsertRecordInput {
|
|
profile_id: profile_id.clone(),
|
|
owner_user_id: owner_user_id.clone(),
|
|
public_work_code: None,
|
|
author_public_user_code: Some(author_public_user_code),
|
|
source_agent_session_id: payload.source_agent_session_id.clone(),
|
|
world_name: metadata.world_name,
|
|
subtitle: metadata.subtitle,
|
|
summary_text: metadata.summary_text,
|
|
theme_mode: metadata.theme_mode,
|
|
cover_image_src: metadata.cover_image_src,
|
|
profile_payload_json: serde_json::to_string(&payload.profile).map_err(|error| {
|
|
custom_world_error_response(
|
|
&request_context,
|
|
AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({
|
|
"provider": "custom-world-library",
|
|
"message": format!("profile JSON 序列化失败:{error}"),
|
|
})),
|
|
)
|
|
})?,
|
|
playable_npc_count: metadata.playable_npc_count,
|
|
landmark_count: metadata.landmark_count,
|
|
author_display_name,
|
|
updated_at_micros: current_utc_micros(),
|
|
})
|
|
.await
|
|
.map_err(|error| {
|
|
custom_world_error_response(&request_context, map_custom_world_client_error(error))
|
|
})?;
|
|
|
|
Ok(json_success_body(
|
|
Some(&request_context),
|
|
CustomWorldLibraryMutationResponse {
|
|
entry: map_custom_world_library_entry_response(mutation.entry.clone()),
|
|
entries: vec![map_custom_world_library_entry_response(mutation.entry)],
|
|
},
|
|
))
|
|
}
|
|
|
|
pub async fn delete_custom_world_library_profile(
|
|
State(state): State<AppState>,
|
|
Extension(request_context): Extension<RequestContext>,
|
|
Extension(authenticated): Extension<AuthenticatedAccessToken>,
|
|
Path(profile_id): Path<String>,
|
|
) -> Result<Json<Value>, Response> {
|
|
let owner_user_id = authenticated.claims().user_id().to_string();
|
|
if profile_id.trim().is_empty() {
|
|
return Err(custom_world_error_response(
|
|
&request_context,
|
|
AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({
|
|
"provider": "custom-world-library",
|
|
"message": "profileId is required",
|
|
})),
|
|
));
|
|
}
|
|
|
|
let entries = state
|
|
.spacetime_client()
|
|
.delete_custom_world_profile(profile_id, owner_user_id, current_utc_micros())
|
|
.await
|
|
.map_err(|error| {
|
|
custom_world_error_response(&request_context, map_custom_world_client_error(error))
|
|
})?;
|
|
|
|
Ok(json_success_body(
|
|
Some(&request_context),
|
|
CustomWorldLibraryResponse {
|
|
entries: entries
|
|
.into_iter()
|
|
.map(map_custom_world_library_entry_response)
|
|
.collect(),
|
|
},
|
|
))
|
|
}
|
|
|
|
pub async fn publish_custom_world_library_profile(
|
|
State(state): State<AppState>,
|
|
Extension(request_context): Extension<RequestContext>,
|
|
Extension(authenticated): Extension<AuthenticatedAccessToken>,
|
|
Path(profile_id): Path<String>,
|
|
) -> Result<Json<Value>, Response> {
|
|
let owner_user_id = authenticated.claims().user_id().to_string();
|
|
if profile_id.trim().is_empty() {
|
|
return Err(custom_world_error_response(
|
|
&request_context,
|
|
AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({
|
|
"provider": "custom-world-library",
|
|
"message": "profileId is required",
|
|
})),
|
|
));
|
|
}
|
|
|
|
let mutation = state
|
|
.spacetime_client()
|
|
.publish_custom_world_profile(
|
|
profile_id,
|
|
owner_user_id,
|
|
None,
|
|
resolve_author_public_user_code(&state, &authenticated, &request_context)?,
|
|
resolve_author_display_name(&state, &authenticated),
|
|
current_utc_micros(),
|
|
)
|
|
.await
|
|
.map_err(|error| {
|
|
custom_world_error_response(&request_context, map_custom_world_client_error(error))
|
|
})?;
|
|
|
|
Ok(json_success_body(
|
|
Some(&request_context),
|
|
CustomWorldLibraryMutationResponse {
|
|
entry: map_custom_world_library_entry_response(mutation.entry.clone()),
|
|
entries: vec![map_custom_world_library_entry_response(mutation.entry)],
|
|
},
|
|
))
|
|
}
|
|
|
|
pub async fn unpublish_custom_world_library_profile(
|
|
State(state): State<AppState>,
|
|
Extension(request_context): Extension<RequestContext>,
|
|
Extension(authenticated): Extension<AuthenticatedAccessToken>,
|
|
Path(profile_id): Path<String>,
|
|
) -> Result<Json<Value>, Response> {
|
|
let owner_user_id = authenticated.claims().user_id().to_string();
|
|
if profile_id.trim().is_empty() {
|
|
return Err(custom_world_error_response(
|
|
&request_context,
|
|
AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({
|
|
"provider": "custom-world-library",
|
|
"message": "profileId is required",
|
|
})),
|
|
));
|
|
}
|
|
|
|
let mutation = state
|
|
.spacetime_client()
|
|
.unpublish_custom_world_profile(
|
|
profile_id,
|
|
owner_user_id,
|
|
resolve_author_display_name(&state, &authenticated),
|
|
current_utc_micros(),
|
|
)
|
|
.await
|
|
.map_err(|error| {
|
|
custom_world_error_response(&request_context, map_custom_world_client_error(error))
|
|
})?;
|
|
|
|
Ok(json_success_body(
|
|
Some(&request_context),
|
|
CustomWorldLibraryMutationResponse {
|
|
entry: map_custom_world_library_entry_response(mutation.entry.clone()),
|
|
entries: vec![map_custom_world_library_entry_response(mutation.entry)],
|
|
},
|
|
))
|
|
}
|
|
|
|
pub async fn list_custom_world_gallery(
|
|
State(state): State<AppState>,
|
|
Extension(request_context): Extension<RequestContext>,
|
|
) -> Result<Json<Value>, Response> {
|
|
let entries = state
|
|
.spacetime_client()
|
|
.list_custom_world_gallery_entries()
|
|
.await
|
|
.map_err(|error| {
|
|
custom_world_error_response(&request_context, map_custom_world_client_error(error))
|
|
})?;
|
|
|
|
Ok(json_success_body(
|
|
Some(&request_context),
|
|
CustomWorldGalleryResponse {
|
|
entries: entries
|
|
.into_iter()
|
|
.map(map_custom_world_gallery_card_response)
|
|
.collect(),
|
|
},
|
|
))
|
|
}
|
|
|
|
pub async fn get_custom_world_gallery_detail(
|
|
State(state): State<AppState>,
|
|
Path((owner_user_id, profile_id)): Path<(String, String)>,
|
|
Extension(request_context): Extension<RequestContext>,
|
|
) -> Result<Json<Value>, Response> {
|
|
if owner_user_id.trim().is_empty() || profile_id.trim().is_empty() {
|
|
return Err(custom_world_error_response(
|
|
&request_context,
|
|
AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({
|
|
"provider": "custom-world-gallery",
|
|
"message": "ownerUserId and profileId are required",
|
|
})),
|
|
));
|
|
}
|
|
|
|
let detail = state
|
|
.spacetime_client()
|
|
.get_custom_world_gallery_detail(owner_user_id, profile_id)
|
|
.await
|
|
.map_err(|error| {
|
|
custom_world_error_response(&request_context, map_custom_world_client_error(error))
|
|
})?;
|
|
|
|
Ok(json_success_body(
|
|
Some(&request_context),
|
|
CustomWorldGalleryDetailResponse {
|
|
entry: map_custom_world_library_entry_response(detail.entry),
|
|
},
|
|
))
|
|
}
|
|
|
|
pub async fn get_custom_world_gallery_detail_by_code(
|
|
State(state): State<AppState>,
|
|
Path(code): Path<String>,
|
|
Extension(request_context): Extension<RequestContext>,
|
|
) -> Result<Json<Value>, Response> {
|
|
if code.trim().is_empty() {
|
|
return Err(custom_world_error_response(
|
|
&request_context,
|
|
AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({
|
|
"provider": "custom-world-gallery",
|
|
"message": "code is required",
|
|
})),
|
|
));
|
|
}
|
|
|
|
let detail = state
|
|
.spacetime_client()
|
|
.get_custom_world_gallery_detail_by_code(code)
|
|
.await
|
|
.map_err(|error| {
|
|
custom_world_error_response(&request_context, map_custom_world_client_error(error))
|
|
})?;
|
|
|
|
Ok(json_success_body(
|
|
Some(&request_context),
|
|
CustomWorldGalleryDetailResponse {
|
|
entry: map_custom_world_library_entry_response(detail.entry),
|
|
},
|
|
))
|
|
}
|
|
|
|
pub async fn create_custom_world_agent_session(
|
|
State(state): State<AppState>,
|
|
Extension(request_context): Extension<RequestContext>,
|
|
Extension(authenticated): Extension<AuthenticatedAccessToken>,
|
|
payload: Result<Json<CreateCustomWorldAgentSessionRequest>, JsonRejection>,
|
|
) -> Result<Json<Value>, Response> {
|
|
let Json(payload) = payload.map_err(|error| {
|
|
custom_world_error_response(
|
|
&request_context,
|
|
AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({
|
|
"provider": "custom-world-agent",
|
|
"message": error.body_text(),
|
|
})),
|
|
)
|
|
})?;
|
|
|
|
let seed_text = payload.seed_text.unwrap_or_default().trim().to_string();
|
|
let welcome_message_text = build_custom_world_agent_welcome_text(&seed_text);
|
|
let session = state
|
|
.spacetime_client()
|
|
.create_custom_world_agent_session(CustomWorldAgentSessionCreateRecordInput {
|
|
session_id: build_prefixed_uuid_id("custom-world-agent-session-"),
|
|
owner_user_id: authenticated.claims().user_id().to_string(),
|
|
seed_text,
|
|
welcome_message_id: build_prefixed_uuid_id("message-"),
|
|
welcome_message_text,
|
|
anchor_content_json: empty_agent_anchor_content_json(),
|
|
creator_intent_json: Some(empty_json_object()),
|
|
creator_intent_readiness_json: empty_agent_creator_intent_readiness_json(),
|
|
anchor_pack_json: Some(empty_json_object()),
|
|
lock_state_json: Some(empty_json_object()),
|
|
draft_profile_json: Some(empty_json_object()),
|
|
pending_clarifications_json: empty_json_array(),
|
|
suggested_actions_json: empty_json_array(),
|
|
recommended_replies_json: empty_json_array(),
|
|
quality_findings_json: empty_json_array(),
|
|
asset_coverage_json: empty_agent_asset_coverage_json(),
|
|
checkpoints_json: empty_json_array(),
|
|
created_at_micros: current_utc_micros(),
|
|
})
|
|
.await
|
|
.map_err(|error| {
|
|
custom_world_error_response(&request_context, map_custom_world_client_error(error))
|
|
})?;
|
|
|
|
Ok(json_success_body(
|
|
Some(&request_context),
|
|
CustomWorldAgentSessionResponse {
|
|
session: map_custom_world_agent_session_response(session),
|
|
},
|
|
))
|
|
}
|
|
|
|
pub async fn get_custom_world_agent_session(
|
|
State(state): State<AppState>,
|
|
Path(session_id): Path<String>,
|
|
Extension(request_context): Extension<RequestContext>,
|
|
Extension(authenticated): Extension<AuthenticatedAccessToken>,
|
|
) -> Result<Json<Value>, Response> {
|
|
if session_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 is required",
|
|
})),
|
|
));
|
|
}
|
|
|
|
let session = state
|
|
.spacetime_client()
|
|
.get_custom_world_agent_session(session_id, authenticated.claims().user_id().to_string())
|
|
.await
|
|
.map_err(|error| {
|
|
custom_world_error_response(&request_context, map_custom_world_client_error(error))
|
|
})?;
|
|
log_custom_world_publish_gate_diagnostics("get_session", &session);
|
|
|
|
Ok(json_success_body(
|
|
Some(&request_context),
|
|
map_custom_world_agent_session_response(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>,
|
|
Extension(request_context): Extension<RequestContext>,
|
|
Extension(authenticated): Extension<AuthenticatedAccessToken>,
|
|
payload: Result<Json<SendCustomWorldAgentMessageRequest>, JsonRejection>,
|
|
) -> Result<Json<Value>, Response> {
|
|
let Json(payload) = payload.map_err(|error| {
|
|
custom_world_error_response(
|
|
&request_context,
|
|
AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({
|
|
"provider": "custom-world-agent",
|
|
"message": error.body_text(),
|
|
})),
|
|
)
|
|
})?;
|
|
|
|
if session_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 is required",
|
|
})),
|
|
));
|
|
}
|
|
|
|
let client_message_id = payload.client_message_id.trim().to_string();
|
|
let message_text = payload.text.trim().to_string();
|
|
if client_message_id.is_empty() || message_text.is_empty() {
|
|
return Err(custom_world_error_response(
|
|
&request_context,
|
|
AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({
|
|
"provider": "custom-world-agent",
|
|
"message": "clientMessageId and text are required",
|
|
})),
|
|
));
|
|
}
|
|
|
|
let owner_user_id = authenticated.claims().user_id().to_string();
|
|
let operation_id = build_prefixed_uuid_id("operation-");
|
|
let submitted_at_micros = current_utc_micros();
|
|
let operation = state
|
|
.spacetime_client()
|
|
.submit_custom_world_agent_message(CustomWorldAgentMessageSubmitRecordInput {
|
|
session_id: session_id.clone(),
|
|
owner_user_id: owner_user_id.clone(),
|
|
user_message_id: client_message_id,
|
|
user_message_text: message_text,
|
|
operation_id: operation_id.clone(),
|
|
submitted_at_micros,
|
|
})
|
|
.await
|
|
.map_err(|error| {
|
|
custom_world_error_response(&request_context, map_custom_world_client_error(error))
|
|
})?;
|
|
|
|
let session = state
|
|
.spacetime_client()
|
|
.get_custom_world_agent_session(session_id.clone(), owner_user_id.clone())
|
|
.await
|
|
.map_err(|error| {
|
|
custom_world_error_response(&request_context, map_custom_world_client_error(error))
|
|
})?;
|
|
let turn_result = run_custom_world_agent_turn(
|
|
CustomWorldAgentTurnRequest {
|
|
llm_client: state.llm_client(),
|
|
session: &session,
|
|
quick_fill_requested: payload.quick_fill_requested.unwrap_or(false),
|
|
focus_card_id: payload.focus_card_id.clone(),
|
|
},
|
|
|_| {},
|
|
)
|
|
.await;
|
|
let finalize_input = match turn_result {
|
|
Ok(turn_result) => build_finalize_record_input(
|
|
session_id.clone(),
|
|
owner_user_id.clone(),
|
|
operation_id.clone(),
|
|
format!("assistant-{}", operation.operation_id),
|
|
turn_result,
|
|
current_utc_micros(),
|
|
),
|
|
Err(error) => build_failed_finalize_record_input(
|
|
session_id.clone(),
|
|
owner_user_id.clone(),
|
|
operation_id.clone(),
|
|
&session,
|
|
error.to_string(),
|
|
current_utc_micros(),
|
|
),
|
|
};
|
|
let finalized_operation = state
|
|
.spacetime_client()
|
|
.finalize_custom_world_agent_message(finalize_input)
|
|
.await
|
|
.map_err(|error| {
|
|
custom_world_error_response(&request_context, map_custom_world_client_error(error))
|
|
})?;
|
|
|
|
if finalized_operation.status == "failed" {
|
|
let message = finalized_operation
|
|
.error_message
|
|
.clone()
|
|
.unwrap_or_else(|| "消息处理失败".to_string());
|
|
return Err(custom_world_error_response(
|
|
&request_context,
|
|
AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({
|
|
"provider": "custom-world-agent",
|
|
"message": message,
|
|
"operationId": finalized_operation.operation_id,
|
|
})),
|
|
));
|
|
}
|
|
|
|
Ok(json_success_body(
|
|
Some(&request_context),
|
|
json!({
|
|
"operation": map_custom_world_agent_operation_response(finalized_operation),
|
|
}),
|
|
))
|
|
}
|
|
|
|
pub async fn stream_custom_world_agent_message(
|
|
State(state): State<AppState>,
|
|
Path(session_id): Path<String>,
|
|
Extension(request_context): Extension<RequestContext>,
|
|
Extension(authenticated): Extension<AuthenticatedAccessToken>,
|
|
payload: Result<Json<SendCustomWorldAgentMessageRequest>, JsonRejection>,
|
|
) -> Result<Response, Response> {
|
|
let Json(payload) = payload.map_err(|error| {
|
|
custom_world_error_response(
|
|
&request_context,
|
|
AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({
|
|
"provider": "custom-world-agent",
|
|
"message": error.body_text(),
|
|
})),
|
|
)
|
|
})?;
|
|
|
|
if session_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 is required",
|
|
})),
|
|
));
|
|
}
|
|
|
|
let client_message_id = payload.client_message_id.trim().to_string();
|
|
let message_text = payload.text.trim().to_string();
|
|
if client_message_id.is_empty() || message_text.is_empty() {
|
|
return Err(custom_world_error_response(
|
|
&request_context,
|
|
AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({
|
|
"provider": "custom-world-agent",
|
|
"message": "clientMessageId and text are required",
|
|
})),
|
|
));
|
|
}
|
|
|
|
let owner_user_id = authenticated.claims().user_id().to_string();
|
|
let operation = state
|
|
.spacetime_client()
|
|
.submit_custom_world_agent_message(CustomWorldAgentMessageSubmitRecordInput {
|
|
session_id: session_id.clone(),
|
|
owner_user_id: owner_user_id.clone(),
|
|
user_message_id: client_message_id,
|
|
user_message_text: message_text,
|
|
operation_id: build_prefixed_uuid_id("operation-"),
|
|
submitted_at_micros: current_utc_micros(),
|
|
})
|
|
.await
|
|
.map_err(|error| {
|
|
custom_world_error_response(&request_context, map_custom_world_client_error(error))
|
|
})?;
|
|
|
|
let session = state
|
|
.spacetime_client()
|
|
.get_custom_world_agent_session(session_id.clone(), owner_user_id.clone())
|
|
.await
|
|
.map_err(|error| {
|
|
custom_world_error_response(&request_context, map_custom_world_client_error(error))
|
|
})?;
|
|
let quick_fill_requested = payload.quick_fill_requested.unwrap_or(false);
|
|
let focus_card_id = payload.focus_card_id.clone();
|
|
let state = state.clone();
|
|
let session_id_for_stream = session_id.clone();
|
|
let owner_user_id_for_stream = owner_user_id.clone();
|
|
let operation_id = operation.operation_id.clone();
|
|
let stream = async_stream::stream! {
|
|
// 聊天回复必须等本轮模型解析、进度与会话快照全部落库后,
|
|
// 再随最终 session 一次性返回,避免玩家先看到回复而进度仍停在旧状态。
|
|
let turn_result = run_custom_world_agent_turn(
|
|
CustomWorldAgentTurnRequest {
|
|
llm_client: state.llm_client(),
|
|
session: &session,
|
|
quick_fill_requested,
|
|
focus_card_id,
|
|
},
|
|
|_| {},
|
|
)
|
|
.await;
|
|
|
|
let finalize_input = match turn_result {
|
|
Ok(turn_result) => build_finalize_record_input(
|
|
session_id_for_stream.clone(),
|
|
owner_user_id_for_stream.clone(),
|
|
operation_id.clone(),
|
|
format!("assistant-{operation_id}"),
|
|
turn_result,
|
|
current_utc_micros(),
|
|
),
|
|
Err(error) => build_failed_finalize_record_input(
|
|
session_id_for_stream.clone(),
|
|
owner_user_id_for_stream.clone(),
|
|
operation_id.clone(),
|
|
&session,
|
|
error.to_string(),
|
|
current_utc_micros(),
|
|
),
|
|
};
|
|
let finalize_result = state
|
|
.spacetime_client()
|
|
.finalize_custom_world_agent_message(finalize_input)
|
|
.await;
|
|
let _finalized_operation = match finalize_result {
|
|
Ok(operation) => operation,
|
|
Err(error) => {
|
|
yield Ok::<Event, Infallible>(custom_world_sse_json_event_or_error(
|
|
"error",
|
|
json!({ "message": error.to_string() }),
|
|
));
|
|
return;
|
|
}
|
|
};
|
|
if _finalized_operation.status == "failed" {
|
|
yield Ok::<Event, Infallible>(custom_world_sse_json_event_or_error(
|
|
"error",
|
|
json!({
|
|
"message": _finalized_operation
|
|
.error_message
|
|
.clone()
|
|
.unwrap_or_else(|| "消息处理失败".to_string())
|
|
}),
|
|
));
|
|
return;
|
|
}
|
|
|
|
let final_session = match state
|
|
.spacetime_client()
|
|
.get_custom_world_agent_session(
|
|
session_id_for_stream,
|
|
owner_user_id_for_stream,
|
|
)
|
|
.await
|
|
{
|
|
Ok(session) => session,
|
|
Err(error) => {
|
|
yield Ok::<Event, Infallible>(custom_world_sse_json_event_or_error(
|
|
"error",
|
|
json!({ "message": error.to_string() }),
|
|
));
|
|
return;
|
|
}
|
|
};
|
|
let session_response = map_custom_world_agent_session_response(final_session);
|
|
|
|
yield Ok::<Event, Infallible>(custom_world_sse_json_event_or_error(
|
|
"session",
|
|
json!({ "session": session_response }),
|
|
));
|
|
yield Ok::<Event, Infallible>(custom_world_sse_json_event_or_error(
|
|
"done",
|
|
json!({ "ok": true }),
|
|
));
|
|
};
|
|
Ok(Sse::new(stream).into_response())
|
|
}
|
|
|
|
pub async fn get_custom_world_agent_operation(
|
|
State(state): State<AppState>,
|
|
Path((session_id, operation_id)): Path<(String, String)>,
|
|
Extension(request_context): Extension<RequestContext>,
|
|
Extension(authenticated): Extension<AuthenticatedAccessToken>,
|
|
) -> Result<Json<Value>, Response> {
|
|
if session_id.trim().is_empty() || operation_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 operationId are required",
|
|
})),
|
|
));
|
|
}
|
|
|
|
let operation = state
|
|
.spacetime_client()
|
|
.get_custom_world_agent_operation(
|
|
session_id,
|
|
authenticated.claims().user_id().to_string(),
|
|
operation_id,
|
|
)
|
|
.await
|
|
.map_err(|error| {
|
|
custom_world_error_response(&request_context, map_custom_world_client_error(error))
|
|
})?;
|
|
|
|
Ok(json_success_body(
|
|
Some(&request_context),
|
|
map_custom_world_agent_operation_response(operation),
|
|
))
|
|
}
|
|
|
|
pub async fn execute_custom_world_agent_action(
|
|
State(state): State<AppState>,
|
|
Path(session_id): Path<String>,
|
|
Extension(request_context): Extension<RequestContext>,
|
|
Extension(authenticated): Extension<AuthenticatedAccessToken>,
|
|
payload: Result<Json<ExecuteCustomWorldAgentActionRequest>, JsonRejection>,
|
|
) -> Result<Json<Value>, Response> {
|
|
let Json(payload) = payload.map_err(|error| {
|
|
custom_world_error_response(
|
|
&request_context,
|
|
AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({
|
|
"provider": "custom-world-agent",
|
|
"message": error.body_text(),
|
|
})),
|
|
)
|
|
})?;
|
|
|
|
if session_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 is required",
|
|
})),
|
|
));
|
|
}
|
|
|
|
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 owner_user_id = authenticated.claims().user_id().to_string();
|
|
let submitted_at_micros = current_utc_micros();
|
|
let payload_json = if matches!(
|
|
action.as_str(),
|
|
"draft_foundation" | "generate_characters" | "generate_landmarks"
|
|
) {
|
|
let session = state
|
|
.spacetime_client()
|
|
.get_custom_world_agent_session(session_id.clone(), owner_user_id.clone())
|
|
.await
|
|
.map_err(|error| {
|
|
custom_world_error_response(&request_context, map_custom_world_client_error(error))
|
|
})?;
|
|
let llm_client = state.llm_client().ok_or_else(|| {
|
|
custom_world_error_response(
|
|
&request_context,
|
|
AppError::from_status(StatusCode::SERVICE_UNAVAILABLE).with_details(json!({
|
|
"provider": "custom-world-agent",
|
|
"message": "服务端尚未配置可用的 LLM API Key",
|
|
})),
|
|
)
|
|
})?;
|
|
if action == "draft_foundation" {
|
|
if session.progress_percent < 100 {
|
|
return Err(custom_world_error_response(
|
|
&request_context,
|
|
AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({
|
|
"provider": "custom-world-agent",
|
|
"message": "draft_foundation requires progressPercent >= 100",
|
|
})),
|
|
));
|
|
}
|
|
let draft_result = generate_custom_world_foundation_draft(llm_client, &session)
|
|
.await
|
|
.map_err(|message| {
|
|
custom_world_error_response(
|
|
&request_context,
|
|
AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({
|
|
"provider": "custom-world-agent",
|
|
"message": message,
|
|
})),
|
|
)
|
|
})?;
|
|
build_draft_foundation_action_payload_json(&payload, &draft_result.draft_profile_json)
|
|
.map_err(|error| {
|
|
let (status, message) = match error {
|
|
DraftFoundationPayloadError::SerializePayload(message) => {
|
|
(StatusCode::BAD_REQUEST, message)
|
|
}
|
|
DraftFoundationPayloadError::InvalidPayloadShape => (
|
|
StatusCode::BAD_REQUEST,
|
|
"action payload 必须是 object".to_string(),
|
|
),
|
|
DraftFoundationPayloadError::InvalidGeneratedDraft(message) => {
|
|
(StatusCode::BAD_GATEWAY, message)
|
|
}
|
|
};
|
|
custom_world_error_response(
|
|
&request_context,
|
|
AppError::from_status(status).with_details(json!({
|
|
"provider": "custom-world-agent",
|
|
"message": message,
|
|
})),
|
|
)
|
|
})?
|
|
} else {
|
|
let generation_result =
|
|
generate_custom_world_agent_entities(llm_client, &session, &payload)
|
|
.await
|
|
.map_err(|message| {
|
|
custom_world_error_response(
|
|
&request_context,
|
|
AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({
|
|
"provider": "custom-world-agent",
|
|
"message": message,
|
|
})),
|
|
)
|
|
})?;
|
|
generation_result.payload_json
|
|
}
|
|
} else {
|
|
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": format!("action payload JSON 序列化失败:{error}"),
|
|
})),
|
|
)
|
|
})?
|
|
};
|
|
|
|
let result = state
|
|
.spacetime_client()
|
|
.execute_custom_world_agent_action(CustomWorldAgentActionExecuteRecordInput {
|
|
session_id: session_id.clone(),
|
|
owner_user_id: owner_user_id.clone(),
|
|
operation_id: build_prefixed_uuid_id("operation-"),
|
|
action: action.clone(),
|
|
payload_json: Some(payload_json),
|
|
submitted_at_micros,
|
|
})
|
|
.await
|
|
.map_err(|error| {
|
|
custom_world_error_response(&request_context, map_custom_world_client_error(error))
|
|
})?;
|
|
|
|
if matches!(
|
|
action.as_str(),
|
|
"sync_result_profile" | "publish_world" | "draft_foundation"
|
|
) {
|
|
if let Ok(session) = state
|
|
.spacetime_client()
|
|
.get_custom_world_agent_session(session_id.clone(), owner_user_id.clone())
|
|
.await
|
|
{
|
|
log_custom_world_publish_gate_diagnostics(action.as_str(), &session);
|
|
}
|
|
}
|
|
|
|
Ok(json_success_body(
|
|
Some(&request_context),
|
|
json!({
|
|
"operation": map_custom_world_agent_operation_response(result.operation),
|
|
}),
|
|
))
|
|
}
|
|
|
|
fn map_custom_world_library_entry_response(
|
|
entry: CustomWorldLibraryEntryRecord,
|
|
) -> CustomWorldLibraryEntryResponse {
|
|
CustomWorldLibraryEntryResponse {
|
|
owner_user_id: entry.owner_user_id,
|
|
profile_id: entry.profile_id,
|
|
public_work_code: entry.public_work_code,
|
|
author_public_user_code: entry.author_public_user_code,
|
|
profile: entry.profile,
|
|
visibility: entry.visibility,
|
|
published_at: entry.published_at,
|
|
updated_at: entry.updated_at,
|
|
author_display_name: entry.author_display_name,
|
|
world_name: entry.world_name,
|
|
subtitle: entry.subtitle,
|
|
summary_text: entry.summary_text,
|
|
cover_image_src: entry.cover_image_src,
|
|
theme_mode: entry.theme_mode,
|
|
playable_npc_count: entry.playable_npc_count,
|
|
landmark_count: entry.landmark_count,
|
|
}
|
|
}
|
|
|
|
fn map_custom_world_gallery_card_response(
|
|
entry: CustomWorldGalleryEntryRecord,
|
|
) -> CustomWorldGalleryCardResponse {
|
|
CustomWorldGalleryCardResponse {
|
|
owner_user_id: entry.owner_user_id,
|
|
profile_id: entry.profile_id,
|
|
public_work_code: entry.public_work_code,
|
|
author_public_user_code: entry.author_public_user_code,
|
|
visibility: entry.visibility,
|
|
published_at: entry.published_at,
|
|
updated_at: entry.updated_at,
|
|
author_display_name: entry.author_display_name,
|
|
world_name: entry.world_name,
|
|
subtitle: entry.subtitle,
|
|
summary_text: entry.summary_text,
|
|
cover_image_src: entry.cover_image_src,
|
|
theme_mode: entry.theme_mode,
|
|
playable_npc_count: entry.playable_npc_count,
|
|
landmark_count: entry.landmark_count,
|
|
}
|
|
}
|
|
|
|
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 {
|
|
CustomWorldAgentSessionSnapshotResponse {
|
|
session_id: session.session_id,
|
|
current_turn: session.current_turn,
|
|
anchor_content: session.anchor_content,
|
|
progress_percent: session.progress_percent,
|
|
last_assistant_reply: session.last_assistant_reply,
|
|
stage: session.stage,
|
|
focus_card_id: session.focus_card_id,
|
|
creator_intent: session.creator_intent,
|
|
creator_intent_readiness: session.creator_intent_readiness,
|
|
anchor_pack: session.anchor_pack,
|
|
lock_state: session.lock_state,
|
|
draft_profile: session.draft_profile,
|
|
messages: session
|
|
.messages
|
|
.into_iter()
|
|
.map(map_custom_world_agent_message_response)
|
|
.collect(),
|
|
draft_cards: session
|
|
.draft_cards
|
|
.into_iter()
|
|
.map(map_custom_world_draft_card_response)
|
|
.collect(),
|
|
pending_clarifications: session.pending_clarifications,
|
|
suggested_actions: session.suggested_actions,
|
|
recommended_replies: session.recommended_replies,
|
|
quality_findings: session.quality_findings,
|
|
asset_coverage: session.asset_coverage,
|
|
checkpoints: session
|
|
.checkpoints
|
|
.into_iter()
|
|
.map(map_custom_world_agent_checkpoint_response)
|
|
.collect(),
|
|
supported_actions: session
|
|
.supported_actions
|
|
.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 log_custom_world_publish_gate_diagnostics(
|
|
source: &str,
|
|
session: &CustomWorldAgentSessionRecord,
|
|
) {
|
|
let draft_profile = session.draft_profile.as_object();
|
|
let preview_profile = session
|
|
.result_preview
|
|
.as_ref()
|
|
.and_then(|preview| preview.get("preview"))
|
|
.and_then(Value::as_object);
|
|
let profile = preview_profile.or(draft_profile);
|
|
let blocker_codes = session
|
|
.publish_gate
|
|
.as_ref()
|
|
.map(|gate| {
|
|
gate.blockers
|
|
.iter()
|
|
.map(|blocker| blocker.code.as_str())
|
|
.collect::<Vec<_>>()
|
|
.join(",")
|
|
})
|
|
.unwrap_or_default();
|
|
|
|
info!(
|
|
target: "custom_world.publish_gate",
|
|
source,
|
|
session_id = %session.session_id,
|
|
stage = %session.stage,
|
|
publish_ready = session.publish_gate.as_ref().map(|gate| gate.publish_ready).unwrap_or(false),
|
|
blocker_count = session.publish_gate.as_ref().map(|gate| gate.blocker_count).unwrap_or(0),
|
|
blocker_codes = %blocker_codes,
|
|
has_draft_profile = session.draft_profile.as_object().map(|value| !value.is_empty()).unwrap_or(false),
|
|
has_result_preview = session.result_preview.is_some(),
|
|
preview_source = session.result_preview.as_ref().and_then(|value| value.get("source")).and_then(serde_json::Value::as_str).unwrap_or(""),
|
|
has_world_hook = has_custom_world_publish_text(profile, &["worldHook", "creatorIntent.worldHook", "anchorContent.worldPromise.hook", "settingText"]),
|
|
has_player_premise = has_custom_world_publish_text(profile, &["playerPremise", "creatorIntent.playerPremise", "anchorContent.playerEntryPoint.openingIdentity", "anchorContent.playerEntryPoint.openingProblem", "anchorContent.playerEntryPoint.entryMotivation"]),
|
|
has_core_conflicts = has_custom_world_non_empty_text_array(profile, "coreConflicts"),
|
|
has_main_chapter = has_custom_world_array(profile, "chapters") || has_custom_world_array(profile, "sceneChapterBlueprints") || has_custom_world_array(profile, "sceneChapters"),
|
|
has_scene_act = has_custom_world_scene_act(profile),
|
|
"custom world publish gate diagnostics"
|
|
);
|
|
}
|
|
|
|
fn has_custom_world_publish_text(profile: Option<&Map<String, Value>>, paths: &[&str]) -> bool {
|
|
paths.iter().any(|path| {
|
|
let mut segments = path.split('.');
|
|
let Some(first_segment) = segments.next() else {
|
|
return false;
|
|
};
|
|
let mut current = profile.and_then(|value| value.get(first_segment));
|
|
for segment in segments {
|
|
current = current.and_then(|value| value.get(segment));
|
|
}
|
|
current
|
|
.and_then(Value::as_str)
|
|
.map(str::trim)
|
|
.map(|value| !value.is_empty())
|
|
.unwrap_or(false)
|
|
})
|
|
}
|
|
|
|
fn has_custom_world_non_empty_text_array(profile: Option<&Map<String, Value>>, key: &str) -> bool {
|
|
profile
|
|
.and_then(|value| value.get(key))
|
|
.and_then(Value::as_array)
|
|
.map(|items| {
|
|
items.iter().any(|item| {
|
|
item.as_str()
|
|
.map(str::trim)
|
|
.is_some_and(|value| !value.is_empty())
|
|
})
|
|
})
|
|
.unwrap_or(false)
|
|
}
|
|
|
|
fn has_custom_world_array(profile: Option<&Map<String, Value>>, key: &str) -> bool {
|
|
profile
|
|
.and_then(|value| value.get(key))
|
|
.and_then(Value::as_array)
|
|
.map(|items| !items.is_empty())
|
|
.unwrap_or(false)
|
|
}
|
|
|
|
fn has_custom_world_scene_act(profile: Option<&Map<String, Value>>) -> bool {
|
|
profile
|
|
.and_then(|value| {
|
|
value
|
|
.get("sceneChapterBlueprints")
|
|
.or_else(|| value.get("sceneChapters"))
|
|
})
|
|
.and_then(Value::as_array)
|
|
.map(|chapters| {
|
|
chapters.iter().any(|chapter| {
|
|
chapter
|
|
.get("acts")
|
|
.and_then(Value::as_array)
|
|
.map(|acts| !acts.is_empty())
|
|
.unwrap_or(false)
|
|
})
|
|
})
|
|
.unwrap_or(false)
|
|
}
|
|
|
|
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 {
|
|
CustomWorldAgentMessageResponse {
|
|
id: message.message_id,
|
|
role: message.role,
|
|
kind: message.kind,
|
|
text: message.text,
|
|
created_at: message.created_at,
|
|
related_operation_id: message.related_operation_id,
|
|
}
|
|
}
|
|
|
|
fn map_custom_world_agent_operation_response(
|
|
operation: CustomWorldAgentOperationRecord,
|
|
) -> CustomWorldAgentOperationResponse {
|
|
CustomWorldAgentOperationResponse {
|
|
operation_id: operation.operation_id,
|
|
operation_type: operation.operation_type,
|
|
status: operation.status,
|
|
phase_label: operation.phase_label,
|
|
phase_detail: operation.phase_detail,
|
|
progress: operation.progress,
|
|
error: operation.error_message,
|
|
}
|
|
}
|
|
|
|
fn map_custom_world_draft_card_response(
|
|
card: CustomWorldDraftCardRecord,
|
|
) -> CustomWorldDraftCardSummaryResponse {
|
|
CustomWorldDraftCardSummaryResponse {
|
|
id: card.card_id,
|
|
kind: card.kind,
|
|
title: card.title,
|
|
subtitle: card.subtitle,
|
|
summary: card.summary,
|
|
status: card.status,
|
|
linked_ids: card.linked_ids,
|
|
warning_count: card.warning_count,
|
|
asset_status: card.asset_status,
|
|
asset_status_label: card.asset_status_label,
|
|
}
|
|
}
|
|
|
|
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 {
|
|
CustomWorldAgentCheckpointResponse {
|
|
checkpoint_id: checkpoint.checkpoint_id,
|
|
created_at: checkpoint.created_at,
|
|
label: checkpoint.label,
|
|
}
|
|
}
|
|
|
|
fn map_custom_world_supported_action_response(
|
|
action: CustomWorldSupportedActionRecord,
|
|
) -> CustomWorldSupportedActionResponse {
|
|
CustomWorldSupportedActionResponse {
|
|
action: action.action,
|
|
enabled: action.enabled,
|
|
reason: action.reason,
|
|
}
|
|
}
|
|
|
|
fn map_custom_world_result_preview_blocker_response(
|
|
blocker: CustomWorldResultPreviewBlockerRecord,
|
|
) -> CustomWorldResultPreviewBlockerResponse {
|
|
CustomWorldResultPreviewBlockerResponse {
|
|
id: blocker.id,
|
|
code: blocker.code,
|
|
message: blocker.message,
|
|
}
|
|
}
|
|
|
|
fn map_custom_world_client_error(error: SpacetimeClientError) -> AppError {
|
|
let status = match &error {
|
|
SpacetimeClientError::Procedure(message)
|
|
if message.contains("custom_world_profile 不存在") =>
|
|
{
|
|
StatusCode::NOT_FOUND
|
|
}
|
|
SpacetimeClientError::Procedure(message)
|
|
if message.contains("custom_world_agent_session 不存在") =>
|
|
{
|
|
StatusCode::NOT_FOUND
|
|
}
|
|
SpacetimeClientError::Procedure(message)
|
|
if message.contains("custom_world_agent_operation 不存在") =>
|
|
{
|
|
StatusCode::NOT_FOUND
|
|
}
|
|
SpacetimeClientError::Procedure(message)
|
|
if message.contains("当前模型不可用")
|
|
|| message.contains("设定生成失败")
|
|
|| message.contains("解析失败")
|
|
|| message.contains("缺少有效回复") =>
|
|
{
|
|
StatusCode::BAD_GATEWAY
|
|
}
|
|
SpacetimeClientError::Runtime(_) => StatusCode::BAD_REQUEST,
|
|
_ => StatusCode::BAD_GATEWAY,
|
|
};
|
|
|
|
AppError::from_status(status).with_details(json!({
|
|
"provider": "spacetimedb",
|
|
"message": error.to_string(),
|
|
}))
|
|
}
|
|
|
|
fn custom_world_error_response(request_context: &RequestContext, error: AppError) -> Response {
|
|
error.into_response_with_context(Some(request_context))
|
|
}
|
|
|
|
fn custom_world_sse_json_event(event_name: &str, payload: Value) -> Result<Event, AppError> {
|
|
Event::default()
|
|
.event(event_name)
|
|
.json_data(payload)
|
|
.map_err(|error| {
|
|
AppError::from_status(StatusCode::INTERNAL_SERVER_ERROR).with_details(json!({
|
|
"provider": "sse",
|
|
"message": format!("SSE payload 序列化失败:{error}"),
|
|
}))
|
|
})
|
|
}
|
|
|
|
fn custom_world_sse_json_event_or_error(event_name: &str, payload: Value) -> Event {
|
|
match custom_world_sse_json_event(event_name, payload) {
|
|
Ok(event) => event,
|
|
Err(_) => custom_world_sse_error_event_message("SSE payload 序列化失败".to_string()),
|
|
}
|
|
}
|
|
|
|
fn custom_world_sse_error_event_message(message: String) -> Event {
|
|
let payload = format!(
|
|
"{{\"message\":{}}}",
|
|
serde_json::to_string(&message)
|
|
.unwrap_or_else(|_| "\"SSE 错误事件序列化失败\"".to_string())
|
|
);
|
|
Event::default().event("error").data(payload)
|
|
}
|
|
|
|
fn resolve_author_display_name(
|
|
state: &AppState,
|
|
authenticated: &AuthenticatedAccessToken,
|
|
) -> String {
|
|
state
|
|
.auth_user_service()
|
|
.get_user_by_id(authenticated.claims().user_id())
|
|
.ok()
|
|
.flatten()
|
|
.map(|user| user.display_name)
|
|
.filter(|value| !value.trim().is_empty())
|
|
.unwrap_or_else(|| "玩家".to_string())
|
|
}
|
|
|
|
fn resolve_author_public_user_code(
|
|
state: &AppState,
|
|
authenticated: &AuthenticatedAccessToken,
|
|
request_context: &RequestContext,
|
|
) -> Result<String, Response> {
|
|
state
|
|
.auth_user_service()
|
|
.get_user_by_id(authenticated.claims().user_id())
|
|
.map_err(|error| {
|
|
custom_world_error_response(
|
|
request_context,
|
|
AppError::from_status(StatusCode::INTERNAL_SERVER_ERROR).with_details(json!({
|
|
"provider": "custom-world-library",
|
|
"message": format!("作者叙世号读取失败:{error}"),
|
|
})),
|
|
)
|
|
})?
|
|
.map(|user| user.public_user_code)
|
|
.filter(|value| !value.trim().is_empty())
|
|
.ok_or_else(|| {
|
|
custom_world_error_response(
|
|
request_context,
|
|
AppError::from_status(StatusCode::UNAUTHORIZED).with_details(json!({
|
|
"provider": "custom-world-library",
|
|
"message": "当前登录用户缺少叙世号",
|
|
})),
|
|
)
|
|
})
|
|
}
|
|
|
|
fn build_custom_world_agent_welcome_text(seed_text: &str) -> String {
|
|
if seed_text.trim().is_empty() {
|
|
return "我会先帮你把世界的核心锚点整理出来。你可以从世界钩子、玩家身份、主题氛围、核心冲突、关键关系或标志性元素开始。"
|
|
.to_string();
|
|
}
|
|
|
|
"我已经收到你的世界起点,会先把它整理成创作锚点。你可以继续补充玩家身份、核心冲突、关键关系或标志性元素。".to_string()
|
|
}
|
|
|
|
struct CustomWorldProfileMetadata {
|
|
world_name: String,
|
|
subtitle: String,
|
|
summary_text: String,
|
|
cover_image_src: Option<String>,
|
|
theme_mode: CustomWorldThemeMode,
|
|
playable_npc_count: u32,
|
|
landmark_count: u32,
|
|
}
|
|
|
|
fn extract_custom_world_metadata(profile: &Value) -> Result<CustomWorldProfileMetadata, String> {
|
|
let object = profile
|
|
.as_object()
|
|
.ok_or_else(|| "profile 必须是 JSON object".to_string())?;
|
|
|
|
let world_name = read_string_field(object, "name").unwrap_or_else(|| "未命名世界".to_string());
|
|
let subtitle = read_string_field(object, "subtitle").unwrap_or_default();
|
|
let summary_text = read_string_field(object, "summary").unwrap_or_default();
|
|
let cover_image_src = resolve_cover_image_src(object);
|
|
let theme_mode = read_string_field(object, "themeMode")
|
|
.and_then(|value| CustomWorldThemeMode::from_client_str(&value))
|
|
.unwrap_or(CustomWorldThemeMode::Mythic);
|
|
let playable_npc_count = count_profile_roles(object);
|
|
let landmark_count = object
|
|
.get("landmarks")
|
|
.and_then(Value::as_array)
|
|
.map(|entries| entries.len() as u32)
|
|
.unwrap_or(0);
|
|
|
|
Ok(CustomWorldProfileMetadata {
|
|
world_name,
|
|
subtitle,
|
|
summary_text,
|
|
cover_image_src,
|
|
theme_mode,
|
|
playable_npc_count,
|
|
landmark_count,
|
|
})
|
|
}
|
|
|
|
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 resolve_cover_image_src(object: &Map<String, Value>) -> Option<String> {
|
|
object
|
|
.get("cover")
|
|
.and_then(Value::as_object)
|
|
.and_then(|cover| read_string_field(cover, "imageSrc"))
|
|
.or_else(|| {
|
|
object
|
|
.get("camp")
|
|
.and_then(Value::as_object)
|
|
.and_then(|camp| read_string_field(camp, "imageSrc"))
|
|
})
|
|
.or_else(|| {
|
|
object
|
|
.get("landmarks")
|
|
.and_then(Value::as_array)
|
|
.and_then(|entries| entries.first())
|
|
.and_then(Value::as_object)
|
|
.and_then(|landmark| read_string_field(landmark, "imageSrc"))
|
|
})
|
|
}
|
|
|
|
fn count_profile_roles(object: &Map<String, Value>) -> u32 {
|
|
let playable = object
|
|
.get("playableNpcs")
|
|
.and_then(Value::as_array)
|
|
.map(|entries| entries.len() as u32)
|
|
.unwrap_or(0);
|
|
let story = object
|
|
.get("storyNpcs")
|
|
.and_then(Value::as_array)
|
|
.map(|entries| entries.len() as u32)
|
|
.unwrap_or(0);
|
|
|
|
playable.saturating_add(story)
|
|
}
|
|
|
|
fn current_utc_micros() -> i64 {
|
|
use std::time::{SystemTime, UNIX_EPOCH};
|
|
|
|
let duration = SystemTime::now()
|
|
.duration_since(UNIX_EPOCH)
|
|
.expect("system clock should be after unix epoch");
|
|
i64::try_from(duration.as_micros()).expect("current unix micros should fit in i64")
|
|
}
|