Files
Genarrative/server-rs/crates/api-server/src/custom_world.rs

1092 lines
39 KiB
Rust

use axum::{
Json,
extract::{Extension, Path, State, rejection::JsonRejection},
http::StatusCode,
response::Response,
};
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 crate::{
api_response::json_success_body, auth::AuthenticatedAccessToken, http_error::AppError,
request_context::RequestContext, sse::SseEventBuffer, 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(&authenticated);
let mutation = state
.spacetime_client()
.upsert_custom_world_profile(CustomWorldProfileUpsertRecordInput {
profile_id: profile_id.clone(),
owner_user_id: owner_user_id.clone(),
source_agent_session_id: None,
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 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,
resolve_author_display_name(&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(&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 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))
})?;
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 operation = state
.spacetime_client()
.submit_custom_world_agent_message(CustomWorldAgentMessageSubmitRecordInput {
session_id,
owner_user_id: authenticated.claims().user_id().to_string(),
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))
})?;
Ok(json_success_body(
Some(&request_context),
json!({
"operation": map_custom_world_agent_operation_response(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();
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, owner_user_id)
.await
.map_err(|error| {
custom_world_error_response(&request_context, map_custom_world_client_error(error))
})?;
let session_response = map_custom_world_agent_session_response(session);
let reply_text = resolve_stream_reply_text(&session_response);
// 这里先用“一次性构造完整 SSE 文本”的最小兼容方案,
// 复用 Stage 7 的同步 deterministic 写表逻辑,保证前端当前的 reader 协议可直接消费。
let mut sse = SseEventBuffer::new();
sse.push_json("reply_delta", &json!({ "text": reply_text }))
.map_err(|error| custom_world_error_response(&request_context, error))?;
sse.push_json("session", &json!({ "session": session_response }))
.map_err(|error| custom_world_error_response(&request_context, error))?;
sse.push_json("done", &json!({ "ok": true }))
.map_err(|error| custom_world_error_response(&request_context, error))?;
Ok(sse.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 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": format!("action payload JSON 序列化失败:{error}"),
})),
)
})?;
let result = state
.spacetime_client()
.execute_custom_world_agent_action(CustomWorldAgentActionExecuteRecordInput {
session_id,
owner_user_id: authenticated.claims().user_id().to_string(),
operation_id: build_prefixed_uuid_id("operation-"),
action,
payload_json: Some(payload_json),
submitted_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),
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,
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,
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 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 resolve_stream_reply_text(session: &CustomWorldAgentSessionSnapshotResponse) -> String {
session
.last_assistant_reply
.clone()
.or_else(|| {
session
.messages
.iter()
.rev()
.find(|message| message.role == "assistant")
.map(|message| message.text.clone())
})
.unwrap_or_default()
}
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::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 resolve_author_display_name(_authenticated: &AuthenticatedAccessToken) -> String {
"玩家".to_string()
}
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")
}