1092 lines
39 KiB
Rust
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")
|
|
}
|