1675 lines
55 KiB
Rust
1675 lines
55 KiB
Rust
use std::{
|
||
collections::BTreeMap,
|
||
convert::Infallible,
|
||
time::{SystemTime, UNIX_EPOCH},
|
||
};
|
||
|
||
use axum::{
|
||
Json,
|
||
body::to_bytes,
|
||
extract::{Extension, Path, State, rejection::JsonRejection},
|
||
http::{HeaderName, StatusCode, header},
|
||
response::{
|
||
IntoResponse, Response,
|
||
sse::{Event, Sse},
|
||
},
|
||
};
|
||
use base64::{Engine as _, engine::general_purpose::STANDARD as BASE64_STANDARD};
|
||
use module_assets::{
|
||
AssetObjectAccessPolicy, AssetObjectFieldError, build_asset_entity_binding_input,
|
||
build_asset_object_upsert_input, generate_asset_binding_id, generate_asset_object_id,
|
||
};
|
||
use module_square_hole::{
|
||
SQUARE_HOLE_MESSAGE_ID_PREFIX, SQUARE_HOLE_PROFILE_ID_PREFIX, SQUARE_HOLE_RUN_ID_PREFIX,
|
||
SQUARE_HOLE_SESSION_ID_PREFIX, default_background_prompt, normalize_hole_options,
|
||
normalize_shape_options,
|
||
};
|
||
use platform_oss::{LegacyAssetPrefix, OssHeadObjectRequest, OssObjectAccess};
|
||
use serde::{Deserialize, Serialize};
|
||
use serde_json::{Value, json};
|
||
use shared_contracts::{
|
||
square_hole_agent::{
|
||
CreateSquareHoleSessionRequest, ExecuteSquareHoleActionRequest,
|
||
SendSquareHoleMessageRequest, SquareHoleActionResponse, SquareHoleAgentMessageResponse,
|
||
SquareHoleAnchorItemResponse, SquareHoleAnchorPackResponse,
|
||
SquareHoleCreatorConfigResponse, SquareHoleHoleOptionResponse,
|
||
SquareHoleResultDraftResponse, SquareHoleSessionResponse,
|
||
SquareHoleSessionSnapshotResponse, SquareHoleShapeOptionResponse,
|
||
},
|
||
square_hole_runtime::{
|
||
DropSquareHoleShapeRequest, SquareHoleDropFeedbackResponse, SquareHoleDropResponse,
|
||
SquareHoleHoleSnapshotResponse, SquareHoleRunResponse, SquareHoleRunSnapshotResponse,
|
||
SquareHoleShapeSnapshotResponse, StartSquareHoleRunRequest, StopSquareHoleRunRequest,
|
||
},
|
||
square_hole_works::{
|
||
PutSquareHoleWorkRequest, RegenerateSquareHoleWorkImageRequest,
|
||
SquareHoleHoleOptionResponse as SquareHoleWorkHoleOptionResponse,
|
||
SquareHoleShapeOptionResponse as SquareHoleWorkShapeOptionResponse,
|
||
SquareHoleWorkDetailResponse, SquareHoleWorkMutationResponse,
|
||
SquareHoleWorkProfileResponse, SquareHoleWorkSummaryResponse, SquareHoleWorksResponse,
|
||
},
|
||
};
|
||
use shared_kernel::build_prefixed_uuid_id;
|
||
use spacetime_client::{
|
||
SpacetimeClientError, SquareHoleAgentMessageRecord, SquareHoleAgentMessageSubmitRecordInput,
|
||
SquareHoleAgentSessionCreateRecordInput, SquareHoleAgentSessionRecord,
|
||
SquareHoleAnchorItemRecord, SquareHoleAnchorPackRecord, SquareHoleCompileDraftRecordInput,
|
||
SquareHoleCreatorConfigRecord, SquareHoleDropFeedbackRecord, SquareHoleHoleOptionRecord,
|
||
SquareHoleHoleSnapshotRecord, SquareHoleResultDraftRecord, SquareHoleRunDropRecordInput,
|
||
SquareHoleRunRecord, SquareHoleRunRestartRecordInput, SquareHoleRunStartRecordInput,
|
||
SquareHoleRunStopRecordInput, SquareHoleRunTimeUpRecordInput, SquareHoleShapeOptionRecord,
|
||
SquareHoleShapeSnapshotRecord, SquareHoleWorkProfileRecord, SquareHoleWorkUpdateRecordInput,
|
||
};
|
||
|
||
use crate::generated_image_assets::{
|
||
GeneratedImageAssetAdapter, GeneratedImageAssetDataUrl,
|
||
adapter::GeneratedImageAssetAdapterMetadata, adapter::GeneratedImageAssetPersistInput,
|
||
normalize_generated_image_asset_mime,
|
||
};
|
||
use crate::{
|
||
ai_generation_drafts::{AiGenerationDraftContext, AiGenerationDraftWriter},
|
||
api_response::json_success_body,
|
||
auth::AuthenticatedAccessToken,
|
||
http_error::AppError,
|
||
openai_image_generation::{
|
||
DownloadedOpenAiImage, build_openai_image_http_client, create_openai_image_generation,
|
||
require_openai_image_settings,
|
||
},
|
||
platform_errors::map_oss_error,
|
||
request_context::RequestContext,
|
||
square_hole_agent_turn::{
|
||
SquareHoleAgentTurnRequest, build_finalize_record_input, run_square_hole_agent_turn,
|
||
},
|
||
state::AppState,
|
||
work_play_tracking::{WorkPlayTrackingDraft, record_work_play_start_after_success},
|
||
};
|
||
|
||
const SQUARE_HOLE_AGENT_PROVIDER: &str = "square-hole-agent";
|
||
const SQUARE_HOLE_WORKS_PROVIDER: &str = "square-hole-works";
|
||
const SQUARE_HOLE_RUNTIME_PROVIDER: &str = "square-hole-runtime";
|
||
const SQUARE_HOLE_DEFAULT_THEME: &str = "纸箱";
|
||
const SQUARE_HOLE_DEFAULT_TWIST_RULE: &str = "方洞万能";
|
||
const SQUARE_HOLE_DEFAULT_SHAPE_COUNT: u32 = 12;
|
||
const SQUARE_HOLE_DEFAULT_DIFFICULTY: u32 = 4;
|
||
const SQUARE_HOLE_QUESTION_THEME: &str = "你想做什么题材";
|
||
const SQUARE_HOLE_ENTITY_KIND: &str = "square_hole_work";
|
||
const SQUARE_HOLE_COVER_IMAGE_KIND: &str = "square_hole_cover_image";
|
||
const SQUARE_HOLE_BACKGROUND_IMAGE_KIND: &str = "square_hole_background_image";
|
||
const SQUARE_HOLE_SHAPE_IMAGE_KIND: &str = "square_hole_shape_image";
|
||
const SQUARE_HOLE_HOLE_IMAGE_KIND: &str = "square_hole_hole_image";
|
||
|
||
#[derive(Clone, Debug, Serialize, Deserialize)]
|
||
#[serde(rename_all = "camelCase")]
|
||
struct SquareHoleConfigJson {
|
||
theme_text: String,
|
||
twist_rule: String,
|
||
shape_count: u32,
|
||
difficulty: u32,
|
||
#[serde(default)]
|
||
shape_options: Vec<SquareHoleConfigShapeOptionJson>,
|
||
#[serde(default)]
|
||
hole_options: Vec<SquareHoleConfigHoleOptionJson>,
|
||
#[serde(default)]
|
||
background_prompt: String,
|
||
#[serde(default, deserialize_with = "deserialize_optional_string_as_default")]
|
||
cover_image_src: String,
|
||
#[serde(default, deserialize_with = "deserialize_optional_string_as_default")]
|
||
background_image_src: String,
|
||
}
|
||
|
||
#[derive(Clone, Debug, Serialize, Deserialize)]
|
||
#[serde(rename_all = "camelCase")]
|
||
struct SquareHoleConfigShapeOptionJson {
|
||
option_id: String,
|
||
shape_kind: String,
|
||
label: String,
|
||
#[serde(default)]
|
||
target_hole_id: String,
|
||
image_prompt: String,
|
||
#[serde(default, deserialize_with = "deserialize_optional_string_as_default")]
|
||
image_src: String,
|
||
}
|
||
|
||
#[derive(Clone, Debug, Serialize, Deserialize)]
|
||
#[serde(rename_all = "camelCase")]
|
||
struct SquareHoleConfigHoleOptionJson {
|
||
hole_id: String,
|
||
hole_kind: String,
|
||
label: String,
|
||
#[serde(default)]
|
||
image_prompt: String,
|
||
#[serde(default, deserialize_with = "deserialize_optional_string_as_default")]
|
||
image_src: String,
|
||
}
|
||
|
||
#[derive(Clone, Debug, Deserialize)]
|
||
#[serde(rename_all = "camelCase")]
|
||
pub(crate) struct CompileSquareHoleDraftRequest {
|
||
#[serde(default)]
|
||
game_name: Option<String>,
|
||
#[serde(default)]
|
||
summary: Option<String>,
|
||
#[serde(default)]
|
||
tags: Option<Vec<String>>,
|
||
#[serde(default)]
|
||
cover_image_src: Option<String>,
|
||
}
|
||
|
||
pub async fn create_square_hole_agent_session(
|
||
State(state): State<AppState>,
|
||
Extension(request_context): Extension<RequestContext>,
|
||
Extension(authenticated): Extension<AuthenticatedAccessToken>,
|
||
payload: Result<Json<CreateSquareHoleSessionRequest>, JsonRejection>,
|
||
) -> Result<Json<Value>, Response> {
|
||
let Json(payload) = square_hole_json(payload, &request_context, SQUARE_HOLE_AGENT_PROVIDER)?;
|
||
let config = build_config_from_create_request(&payload);
|
||
let seed_text = build_seed_text(&payload, &config);
|
||
let welcome_message_text = SQUARE_HOLE_QUESTION_THEME.to_string();
|
||
|
||
let session = state
|
||
.spacetime_client()
|
||
.create_square_hole_agent_session(SquareHoleAgentSessionCreateRecordInput {
|
||
session_id: build_prefixed_uuid_id(SQUARE_HOLE_SESSION_ID_PREFIX),
|
||
owner_user_id: authenticated.claims().user_id().to_string(),
|
||
seed_text,
|
||
welcome_message_id: build_prefixed_uuid_id(SQUARE_HOLE_MESSAGE_ID_PREFIX),
|
||
welcome_message_text,
|
||
config_json: serialize_square_hole_config(&config),
|
||
created_at_micros: current_utc_micros(),
|
||
})
|
||
.await
|
||
.map_err(|error| {
|
||
square_hole_error_response(
|
||
&request_context,
|
||
SQUARE_HOLE_AGENT_PROVIDER,
|
||
map_square_hole_client_error(error),
|
||
)
|
||
})?;
|
||
|
||
Ok(json_success_body(
|
||
Some(&request_context),
|
||
SquareHoleSessionResponse {
|
||
session: map_square_hole_agent_session_response(session),
|
||
},
|
||
))
|
||
}
|
||
|
||
pub async fn get_square_hole_agent_session(
|
||
State(state): State<AppState>,
|
||
Path(session_id): Path<String>,
|
||
Extension(request_context): Extension<RequestContext>,
|
||
Extension(authenticated): Extension<AuthenticatedAccessToken>,
|
||
) -> Result<Json<Value>, Response> {
|
||
ensure_non_empty(
|
||
&request_context,
|
||
SQUARE_HOLE_AGENT_PROVIDER,
|
||
&session_id,
|
||
"sessionId",
|
||
)?;
|
||
|
||
let session = state
|
||
.spacetime_client()
|
||
.get_square_hole_agent_session(session_id, authenticated.claims().user_id().to_string())
|
||
.await
|
||
.map_err(|error| {
|
||
square_hole_error_response(
|
||
&request_context,
|
||
SQUARE_HOLE_AGENT_PROVIDER,
|
||
map_square_hole_client_error(error),
|
||
)
|
||
})?;
|
||
|
||
Ok(json_success_body(
|
||
Some(&request_context),
|
||
SquareHoleSessionResponse {
|
||
session: map_square_hole_agent_session_response(session),
|
||
},
|
||
))
|
||
}
|
||
|
||
pub async fn submit_square_hole_agent_message(
|
||
State(state): State<AppState>,
|
||
Path(session_id): Path<String>,
|
||
Extension(request_context): Extension<RequestContext>,
|
||
Extension(authenticated): Extension<AuthenticatedAccessToken>,
|
||
payload: Result<Json<SendSquareHoleMessageRequest>, JsonRejection>,
|
||
) -> Result<Json<Value>, Response> {
|
||
let Json(payload) = square_hole_json(payload, &request_context, SQUARE_HOLE_AGENT_PROVIDER)?;
|
||
let session = submit_and_finalize_square_hole_message(
|
||
&state,
|
||
&request_context,
|
||
authenticated.claims().user_id(),
|
||
session_id,
|
||
payload,
|
||
|_| {},
|
||
)
|
||
.await?;
|
||
|
||
Ok(json_success_body(
|
||
Some(&request_context),
|
||
SquareHoleSessionResponse {
|
||
session: map_square_hole_agent_session_response(session),
|
||
},
|
||
))
|
||
}
|
||
|
||
pub async fn stream_square_hole_agent_message(
|
||
State(state): State<AppState>,
|
||
Path(session_id): Path<String>,
|
||
Extension(request_context): Extension<RequestContext>,
|
||
Extension(authenticated): Extension<AuthenticatedAccessToken>,
|
||
payload: Result<Json<SendSquareHoleMessageRequest>, JsonRejection>,
|
||
) -> Result<Response, Response> {
|
||
let Json(payload) = square_hole_json(payload, &request_context, SQUARE_HOLE_AGENT_PROVIDER)?;
|
||
ensure_non_empty(
|
||
&request_context,
|
||
SQUARE_HOLE_AGENT_PROVIDER,
|
||
&session_id,
|
||
"sessionId",
|
||
)?;
|
||
|
||
let owner_user_id = authenticated.claims().user_id().to_string();
|
||
let request_context_for_stream = request_context.clone();
|
||
let state = state.clone();
|
||
let stream = async_stream::stream! {
|
||
let mut draft_writer = AiGenerationDraftWriter::new(AiGenerationDraftContext::new(
|
||
"square-hole",
|
||
owner_user_id.as_str(),
|
||
session_id.as_str(),
|
||
payload.client_message_id.as_str(),
|
||
"方洞挑战生成草稿",
|
||
));
|
||
if let Err(error) = draft_writer.ensure_started(state.spacetime_client()).await {
|
||
tracing::warn!(error = %error, "方洞挑战生成草稿任务启动失败,主生成流程继续执行");
|
||
}
|
||
|
||
let (reply_tx, mut reply_rx) = tokio::sync::mpsc::unbounded_channel::<String>();
|
||
let result = {
|
||
let run_turn = submit_and_finalize_square_hole_message(
|
||
&state,
|
||
&request_context_for_stream,
|
||
owner_user_id.as_str(),
|
||
session_id.clone(),
|
||
payload,
|
||
move |text| {
|
||
let _ = reply_tx.send(text.to_string());
|
||
},
|
||
);
|
||
tokio::pin!(run_turn);
|
||
|
||
loop {
|
||
tokio::select! {
|
||
result = &mut run_turn => break result,
|
||
maybe_text = reply_rx.recv() => {
|
||
if let Some(text) = maybe_text {
|
||
draft_writer.persist_visible_text(state.spacetime_client(), text.as_str()).await;
|
||
yield Ok::<Event, Infallible>(square_hole_sse_json_event_or_error(
|
||
"reply_delta",
|
||
json!({ "text": text }),
|
||
));
|
||
}
|
||
}
|
||
}
|
||
}
|
||
};
|
||
|
||
while let Some(text) = reply_rx.recv().await {
|
||
draft_writer.persist_visible_text(state.spacetime_client(), text.as_str()).await;
|
||
yield Ok::<Event, Infallible>(square_hole_sse_json_event_or_error(
|
||
"reply_delta",
|
||
json!({ "text": text }),
|
||
));
|
||
}
|
||
|
||
let session = match result {
|
||
Ok(session) => session,
|
||
Err(response) => {
|
||
let message = extract_square_hole_response_error_message(response).await;
|
||
yield Ok::<Event, Infallible>(square_hole_sse_json_event_or_error(
|
||
"error",
|
||
json!({ "message": message }),
|
||
));
|
||
return;
|
||
}
|
||
};
|
||
|
||
let session_response = map_square_hole_agent_session_response(session);
|
||
yield Ok::<Event, Infallible>(square_hole_sse_json_event_or_error(
|
||
"session",
|
||
json!({ "session": session_response }),
|
||
));
|
||
yield Ok::<Event, Infallible>(square_hole_sse_json_event_or_error(
|
||
"done",
|
||
json!({ "ok": true }),
|
||
));
|
||
};
|
||
|
||
Ok(Sse::new(stream).into_response())
|
||
}
|
||
|
||
pub async fn execute_square_hole_agent_action(
|
||
State(state): State<AppState>,
|
||
Path(session_id): Path<String>,
|
||
Extension(request_context): Extension<RequestContext>,
|
||
Extension(authenticated): Extension<AuthenticatedAccessToken>,
|
||
payload: Result<Json<ExecuteSquareHoleActionRequest>, JsonRejection>,
|
||
) -> Result<Json<Value>, Response> {
|
||
let Json(payload) = square_hole_json(payload, &request_context, SQUARE_HOLE_AGENT_PROVIDER)?;
|
||
ensure_non_empty(
|
||
&request_context,
|
||
SQUARE_HOLE_AGENT_PROVIDER,
|
||
&session_id,
|
||
"sessionId",
|
||
)?;
|
||
|
||
let session = match payload.action.trim() {
|
||
"square_hole_compile_draft" => {
|
||
compile_square_hole_draft_for_session(
|
||
&state,
|
||
&request_context,
|
||
&authenticated,
|
||
session_id,
|
||
payload.game_name,
|
||
payload.summary,
|
||
payload.tags,
|
||
payload.cover_image_src,
|
||
)
|
||
.await?
|
||
}
|
||
"square_hole_generate_visual_assets" => {
|
||
generate_square_hole_visual_assets_for_session(
|
||
&state,
|
||
&request_context,
|
||
&authenticated,
|
||
session_id,
|
||
payload.regenerate_visual_assets.unwrap_or(false),
|
||
payload.visual_asset_slot,
|
||
payload.visual_asset_option_id,
|
||
)
|
||
.await?
|
||
}
|
||
_ => {
|
||
return Err(square_hole_bad_request(
|
||
&request_context,
|
||
SQUARE_HOLE_AGENT_PROVIDER,
|
||
"unknown square hole action",
|
||
));
|
||
}
|
||
};
|
||
|
||
Ok(json_success_body(
|
||
Some(&request_context),
|
||
SquareHoleActionResponse {
|
||
session: map_square_hole_agent_session_response(session),
|
||
},
|
||
))
|
||
}
|
||
|
||
pub async fn compile_square_hole_agent_draft(
|
||
State(state): State<AppState>,
|
||
Path(session_id): Path<String>,
|
||
Extension(request_context): Extension<RequestContext>,
|
||
Extension(authenticated): Extension<AuthenticatedAccessToken>,
|
||
payload: Result<Json<CompileSquareHoleDraftRequest>, JsonRejection>,
|
||
) -> Result<Json<Value>, Response> {
|
||
let payload = payload
|
||
.map(|Json(payload)| payload)
|
||
.unwrap_or(CompileSquareHoleDraftRequest {
|
||
game_name: None,
|
||
summary: None,
|
||
tags: None,
|
||
cover_image_src: None,
|
||
});
|
||
ensure_non_empty(
|
||
&request_context,
|
||
SQUARE_HOLE_AGENT_PROVIDER,
|
||
&session_id,
|
||
"sessionId",
|
||
)?;
|
||
|
||
let session = compile_square_hole_draft_for_session(
|
||
&state,
|
||
&request_context,
|
||
&authenticated,
|
||
session_id,
|
||
payload.game_name,
|
||
payload.summary,
|
||
payload.tags,
|
||
payload.cover_image_src,
|
||
)
|
||
.await?;
|
||
|
||
Ok(json_success_body(
|
||
Some(&request_context),
|
||
SquareHoleActionResponse {
|
||
session: map_square_hole_agent_session_response(session),
|
||
},
|
||
))
|
||
}
|
||
|
||
pub async fn get_square_hole_works(
|
||
State(state): State<AppState>,
|
||
Extension(request_context): Extension<RequestContext>,
|
||
Extension(authenticated): Extension<AuthenticatedAccessToken>,
|
||
) -> Result<Json<Value>, Response> {
|
||
let items = state
|
||
.spacetime_client()
|
||
.list_square_hole_works(authenticated.claims().user_id().to_string())
|
||
.await
|
||
.map_err(|error| {
|
||
square_hole_error_response(
|
||
&request_context,
|
||
SQUARE_HOLE_WORKS_PROVIDER,
|
||
map_square_hole_client_error(error),
|
||
)
|
||
})?;
|
||
|
||
Ok(json_success_body(
|
||
Some(&request_context),
|
||
SquareHoleWorksResponse {
|
||
items: items
|
||
.into_iter()
|
||
.map(map_square_hole_work_summary_response)
|
||
.collect(),
|
||
},
|
||
))
|
||
}
|
||
|
||
pub async fn list_square_hole_gallery(
|
||
State(state): State<AppState>,
|
||
Extension(request_context): Extension<RequestContext>,
|
||
) -> Result<Json<Value>, Response> {
|
||
let items = state
|
||
.spacetime_client()
|
||
.list_square_hole_gallery()
|
||
.await
|
||
.map_err(|error| {
|
||
square_hole_error_response(
|
||
&request_context,
|
||
SQUARE_HOLE_WORKS_PROVIDER,
|
||
map_square_hole_client_error(error),
|
||
)
|
||
})?;
|
||
|
||
Ok(json_success_body(
|
||
Some(&request_context),
|
||
SquareHoleWorksResponse {
|
||
items: items
|
||
.into_iter()
|
||
.map(map_square_hole_work_summary_response)
|
||
.collect(),
|
||
},
|
||
))
|
||
}
|
||
|
||
pub async fn get_square_hole_work_detail(
|
||
State(state): State<AppState>,
|
||
Path(profile_id): Path<String>,
|
||
Extension(request_context): Extension<RequestContext>,
|
||
Extension(authenticated): Extension<AuthenticatedAccessToken>,
|
||
) -> Result<Json<Value>, Response> {
|
||
ensure_non_empty(
|
||
&request_context,
|
||
SQUARE_HOLE_WORKS_PROVIDER,
|
||
&profile_id,
|
||
"profileId",
|
||
)?;
|
||
|
||
let item = state
|
||
.spacetime_client()
|
||
.get_square_hole_work_detail(profile_id, authenticated.claims().user_id().to_string())
|
||
.await
|
||
.map_err(|error| {
|
||
square_hole_error_response(
|
||
&request_context,
|
||
SQUARE_HOLE_WORKS_PROVIDER,
|
||
map_square_hole_client_error(error),
|
||
)
|
||
})?;
|
||
|
||
Ok(json_success_body(
|
||
Some(&request_context),
|
||
SquareHoleWorkDetailResponse {
|
||
item: map_square_hole_work_profile_response(item),
|
||
},
|
||
))
|
||
}
|
||
|
||
pub async fn put_square_hole_work(
|
||
State(state): State<AppState>,
|
||
Path(profile_id): Path<String>,
|
||
Extension(request_context): Extension<RequestContext>,
|
||
Extension(authenticated): Extension<AuthenticatedAccessToken>,
|
||
payload: Result<Json<PutSquareHoleWorkRequest>, JsonRejection>,
|
||
) -> Result<Json<Value>, Response> {
|
||
let Json(payload) = square_hole_json(payload, &request_context, SQUARE_HOLE_WORKS_PROVIDER)?;
|
||
ensure_non_empty(
|
||
&request_context,
|
||
SQUARE_HOLE_WORKS_PROVIDER,
|
||
&profile_id,
|
||
"profileId",
|
||
)?;
|
||
|
||
let existing = state
|
||
.spacetime_client()
|
||
.get_square_hole_work_detail(
|
||
profile_id.clone(),
|
||
authenticated.claims().user_id().to_string(),
|
||
)
|
||
.await
|
||
.map_err(|error| {
|
||
square_hole_error_response(
|
||
&request_context,
|
||
SQUARE_HOLE_WORKS_PROVIDER,
|
||
map_square_hole_client_error(error),
|
||
)
|
||
})?;
|
||
let theme_text = payload
|
||
.theme_text
|
||
.clone()
|
||
.filter(|value| !value.trim().is_empty())
|
||
.unwrap_or(existing.theme_text);
|
||
let hole_options = payload
|
||
.hole_options
|
||
.clone()
|
||
.map(square_hole_work_hole_options_to_records)
|
||
.unwrap_or_else(|| existing.hole_options.clone());
|
||
let hole_options_json = serialize_square_hole_hole_option_records(&hole_options);
|
||
let shape_options = payload
|
||
.shape_options
|
||
.clone()
|
||
.map(|options| square_hole_work_shape_options_to_records(options, hole_options.as_slice()))
|
||
.unwrap_or_else(|| existing.shape_options.clone());
|
||
let shape_options_json = serialize_square_hole_shape_option_records(&shape_options);
|
||
let item = state
|
||
.spacetime_client()
|
||
.update_square_hole_work(SquareHoleWorkUpdateRecordInput {
|
||
profile_id,
|
||
owner_user_id: authenticated.claims().user_id().to_string(),
|
||
game_name: payload.game_name,
|
||
theme_text,
|
||
twist_rule: payload.twist_rule,
|
||
summary_text: payload.summary,
|
||
tags_json: serde_json::to_string(&normalize_tags(payload.tags)).unwrap_or_default(),
|
||
cover_image_src: payload.cover_image_src.unwrap_or_default(),
|
||
background_prompt: payload
|
||
.background_prompt
|
||
.filter(|value| !value.trim().is_empty())
|
||
.unwrap_or(existing.background_prompt),
|
||
background_image_src: payload
|
||
.background_image_src
|
||
.unwrap_or(existing.background_image_src.unwrap_or_default()),
|
||
shape_options_json,
|
||
hole_options_json,
|
||
shape_count: payload.shape_count,
|
||
difficulty: payload.difficulty,
|
||
updated_at_micros: current_utc_micros(),
|
||
})
|
||
.await
|
||
.map_err(|error| {
|
||
square_hole_error_response(
|
||
&request_context,
|
||
SQUARE_HOLE_WORKS_PROVIDER,
|
||
map_square_hole_client_error(error),
|
||
)
|
||
})?;
|
||
|
||
Ok(json_success_body(
|
||
Some(&request_context),
|
||
SquareHoleWorkMutationResponse {
|
||
item: map_square_hole_work_profile_response(item),
|
||
},
|
||
))
|
||
}
|
||
|
||
pub async fn publish_square_hole_work(
|
||
State(state): State<AppState>,
|
||
Path(profile_id): Path<String>,
|
||
Extension(request_context): Extension<RequestContext>,
|
||
Extension(authenticated): Extension<AuthenticatedAccessToken>,
|
||
) -> Result<Json<Value>, Response> {
|
||
ensure_non_empty(
|
||
&request_context,
|
||
SQUARE_HOLE_WORKS_PROVIDER,
|
||
&profile_id,
|
||
"profileId",
|
||
)?;
|
||
|
||
let item = state
|
||
.spacetime_client()
|
||
.publish_square_hole_work(
|
||
profile_id,
|
||
authenticated.claims().user_id().to_string(),
|
||
current_utc_micros(),
|
||
)
|
||
.await
|
||
.map_err(|error| {
|
||
square_hole_error_response(
|
||
&request_context,
|
||
SQUARE_HOLE_WORKS_PROVIDER,
|
||
map_square_hole_client_error(error),
|
||
)
|
||
})?;
|
||
|
||
Ok(json_success_body(
|
||
Some(&request_context),
|
||
SquareHoleWorkMutationResponse {
|
||
item: map_square_hole_work_profile_response(item),
|
||
},
|
||
))
|
||
}
|
||
|
||
pub async fn regenerate_square_hole_work_image(
|
||
State(state): State<AppState>,
|
||
Path(profile_id): Path<String>,
|
||
Extension(request_context): Extension<RequestContext>,
|
||
Extension(authenticated): Extension<AuthenticatedAccessToken>,
|
||
payload: Result<Json<RegenerateSquareHoleWorkImageRequest>, JsonRejection>,
|
||
) -> Result<Json<Value>, Response> {
|
||
let Json(payload) = square_hole_json(payload, &request_context, SQUARE_HOLE_WORKS_PROVIDER)?;
|
||
ensure_non_empty(
|
||
&request_context,
|
||
SQUARE_HOLE_WORKS_PROVIDER,
|
||
&profile_id,
|
||
"profileId",
|
||
)?;
|
||
|
||
let owner_user_id = authenticated.claims().user_id().to_string();
|
||
let item = regenerate_square_hole_visual_asset_for_work(
|
||
&state,
|
||
&request_context,
|
||
owner_user_id,
|
||
profile_id,
|
||
payload.visual_asset_slot,
|
||
payload.visual_asset_option_id,
|
||
)
|
||
.await?;
|
||
|
||
Ok(json_success_body(
|
||
Some(&request_context),
|
||
SquareHoleWorkMutationResponse {
|
||
item: map_square_hole_work_profile_response(item),
|
||
},
|
||
))
|
||
}
|
||
|
||
pub async fn delete_square_hole_work(
|
||
State(state): State<AppState>,
|
||
Path(profile_id): Path<String>,
|
||
Extension(request_context): Extension<RequestContext>,
|
||
Extension(authenticated): Extension<AuthenticatedAccessToken>,
|
||
) -> Result<Json<Value>, Response> {
|
||
ensure_non_empty(
|
||
&request_context,
|
||
SQUARE_HOLE_WORKS_PROVIDER,
|
||
&profile_id,
|
||
"profileId",
|
||
)?;
|
||
|
||
let items = state
|
||
.spacetime_client()
|
||
.delete_square_hole_work(profile_id, authenticated.claims().user_id().to_string())
|
||
.await
|
||
.map_err(|error| {
|
||
square_hole_error_response(
|
||
&request_context,
|
||
SQUARE_HOLE_WORKS_PROVIDER,
|
||
map_square_hole_client_error(error),
|
||
)
|
||
})?;
|
||
|
||
Ok(json_success_body(
|
||
Some(&request_context),
|
||
SquareHoleWorksResponse {
|
||
items: items
|
||
.into_iter()
|
||
.map(map_square_hole_work_summary_response)
|
||
.collect(),
|
||
},
|
||
))
|
||
}
|
||
|
||
pub async fn start_square_hole_run(
|
||
State(state): State<AppState>,
|
||
Path(profile_id): Path<String>,
|
||
Extension(request_context): Extension<RequestContext>,
|
||
Extension(authenticated): Extension<AuthenticatedAccessToken>,
|
||
payload: Result<Json<StartSquareHoleRunRequest>, JsonRejection>,
|
||
) -> Result<Json<Value>, Response> {
|
||
let maybe_payload = payload.ok().map(|Json(payload)| payload);
|
||
let profile_id = maybe_payload
|
||
.map(|payload| payload.profile_id)
|
||
.filter(|value| !value.trim().is_empty())
|
||
.unwrap_or(profile_id);
|
||
ensure_non_empty(
|
||
&request_context,
|
||
SQUARE_HOLE_RUNTIME_PROVIDER,
|
||
&profile_id,
|
||
"profileId",
|
||
)?;
|
||
|
||
let run = state
|
||
.spacetime_client()
|
||
.start_square_hole_run(SquareHoleRunStartRecordInput {
|
||
run_id: build_prefixed_uuid_id(SQUARE_HOLE_RUN_ID_PREFIX),
|
||
owner_user_id: authenticated.claims().user_id().to_string(),
|
||
profile_id: profile_id.clone(),
|
||
started_at_ms: current_utc_ms(),
|
||
})
|
||
.await
|
||
.map_err(|error| {
|
||
square_hole_error_response(
|
||
&request_context,
|
||
SQUARE_HOLE_RUNTIME_PROVIDER,
|
||
map_square_hole_client_error(error),
|
||
)
|
||
})?;
|
||
|
||
record_work_play_start_after_success(
|
||
&state,
|
||
&request_context,
|
||
WorkPlayTrackingDraft::new(
|
||
"square-hole",
|
||
profile_id.clone(),
|
||
&authenticated,
|
||
"/api/runtime/square-hole/...",
|
||
)
|
||
.profile_id(profile_id.clone())
|
||
.extra(json!({
|
||
"runId": run.run_id,
|
||
})),
|
||
)
|
||
.await;
|
||
|
||
Ok(json_success_body(
|
||
Some(&request_context),
|
||
SquareHoleRunResponse {
|
||
run: map_square_hole_run_response(run),
|
||
},
|
||
))
|
||
}
|
||
|
||
pub async fn get_square_hole_run(
|
||
State(state): State<AppState>,
|
||
Path(run_id): Path<String>,
|
||
Extension(request_context): Extension<RequestContext>,
|
||
Extension(authenticated): Extension<AuthenticatedAccessToken>,
|
||
) -> Result<Json<Value>, Response> {
|
||
ensure_non_empty(
|
||
&request_context,
|
||
SQUARE_HOLE_RUNTIME_PROVIDER,
|
||
&run_id,
|
||
"runId",
|
||
)?;
|
||
|
||
let run = state
|
||
.spacetime_client()
|
||
.get_square_hole_run(run_id, authenticated.claims().user_id().to_string())
|
||
.await
|
||
.map_err(|error| {
|
||
square_hole_error_response(
|
||
&request_context,
|
||
SQUARE_HOLE_RUNTIME_PROVIDER,
|
||
map_square_hole_client_error(error),
|
||
)
|
||
})?;
|
||
|
||
Ok(json_success_body(
|
||
Some(&request_context),
|
||
SquareHoleRunResponse {
|
||
run: map_square_hole_run_response(run),
|
||
},
|
||
))
|
||
}
|
||
|
||
pub async fn drop_square_hole_shape(
|
||
State(state): State<AppState>,
|
||
Path(run_id): Path<String>,
|
||
Extension(request_context): Extension<RequestContext>,
|
||
Extension(authenticated): Extension<AuthenticatedAccessToken>,
|
||
payload: Result<Json<DropSquareHoleShapeRequest>, JsonRejection>,
|
||
) -> Result<Json<Value>, Response> {
|
||
let Json(payload) = square_hole_json(payload, &request_context, SQUARE_HOLE_RUNTIME_PROVIDER)?;
|
||
ensure_non_empty(
|
||
&request_context,
|
||
SQUARE_HOLE_RUNTIME_PROVIDER,
|
||
&run_id,
|
||
"runId",
|
||
)?;
|
||
ensure_non_empty(
|
||
&request_context,
|
||
SQUARE_HOLE_RUNTIME_PROVIDER,
|
||
&payload.hole_id,
|
||
"holeId",
|
||
)?;
|
||
ensure_non_empty(
|
||
&request_context,
|
||
SQUARE_HOLE_RUNTIME_PROVIDER,
|
||
&payload.client_event_id,
|
||
"clientEventId",
|
||
)?;
|
||
|
||
let confirmation = state
|
||
.spacetime_client()
|
||
.drop_square_hole_shape(SquareHoleRunDropRecordInput {
|
||
run_id: payload.run_id.unwrap_or(run_id),
|
||
owner_user_id: authenticated.claims().user_id().to_string(),
|
||
hole_id: payload.hole_id,
|
||
client_snapshot_version: payload.client_snapshot_version,
|
||
client_event_id: payload.client_event_id,
|
||
dropped_at_ms: payload.dropped_at_ms.min(i64::MAX as u64) as i64,
|
||
})
|
||
.await
|
||
.map_err(|error| {
|
||
square_hole_error_response(
|
||
&request_context,
|
||
SQUARE_HOLE_RUNTIME_PROVIDER,
|
||
map_square_hole_client_error(error),
|
||
)
|
||
})?;
|
||
|
||
Ok(json_success_body(
|
||
Some(&request_context),
|
||
SquareHoleDropResponse {
|
||
feedback: map_square_hole_feedback_response(confirmation.feedback),
|
||
run: map_square_hole_run_response(confirmation.run),
|
||
},
|
||
))
|
||
}
|
||
|
||
pub async fn stop_square_hole_run(
|
||
State(state): State<AppState>,
|
||
Path(run_id): Path<String>,
|
||
Extension(request_context): Extension<RequestContext>,
|
||
Extension(authenticated): Extension<AuthenticatedAccessToken>,
|
||
payload: Result<Json<StopSquareHoleRunRequest>, JsonRejection>,
|
||
) -> Result<Json<Value>, Response> {
|
||
let _ = payload.ok();
|
||
ensure_non_empty(
|
||
&request_context,
|
||
SQUARE_HOLE_RUNTIME_PROVIDER,
|
||
&run_id,
|
||
"runId",
|
||
)?;
|
||
|
||
let run = state
|
||
.spacetime_client()
|
||
.stop_square_hole_run(SquareHoleRunStopRecordInput {
|
||
run_id,
|
||
owner_user_id: authenticated.claims().user_id().to_string(),
|
||
stopped_at_ms: current_utc_ms(),
|
||
})
|
||
.await
|
||
.map_err(|error| {
|
||
square_hole_error_response(
|
||
&request_context,
|
||
SQUARE_HOLE_RUNTIME_PROVIDER,
|
||
map_square_hole_client_error(error),
|
||
)
|
||
})?;
|
||
|
||
Ok(json_success_body(
|
||
Some(&request_context),
|
||
SquareHoleRunResponse {
|
||
run: map_square_hole_run_response(run),
|
||
},
|
||
))
|
||
}
|
||
|
||
pub async fn restart_square_hole_run(
|
||
State(state): State<AppState>,
|
||
Path(run_id): Path<String>,
|
||
Extension(request_context): Extension<RequestContext>,
|
||
Extension(authenticated): Extension<AuthenticatedAccessToken>,
|
||
) -> Result<Json<Value>, Response> {
|
||
ensure_non_empty(
|
||
&request_context,
|
||
SQUARE_HOLE_RUNTIME_PROVIDER,
|
||
&run_id,
|
||
"runId",
|
||
)?;
|
||
|
||
let run = state
|
||
.spacetime_client()
|
||
.restart_square_hole_run(SquareHoleRunRestartRecordInput {
|
||
source_run_id: run_id,
|
||
next_run_id: build_prefixed_uuid_id(SQUARE_HOLE_RUN_ID_PREFIX),
|
||
owner_user_id: authenticated.claims().user_id().to_string(),
|
||
restarted_at_ms: current_utc_ms(),
|
||
})
|
||
.await
|
||
.map_err(|error| {
|
||
square_hole_error_response(
|
||
&request_context,
|
||
SQUARE_HOLE_RUNTIME_PROVIDER,
|
||
map_square_hole_client_error(error),
|
||
)
|
||
})?;
|
||
|
||
Ok(json_success_body(
|
||
Some(&request_context),
|
||
SquareHoleRunResponse {
|
||
run: map_square_hole_run_response(run),
|
||
},
|
||
))
|
||
}
|
||
|
||
pub async fn finish_square_hole_time_up(
|
||
State(state): State<AppState>,
|
||
Path(run_id): Path<String>,
|
||
Extension(request_context): Extension<RequestContext>,
|
||
Extension(authenticated): Extension<AuthenticatedAccessToken>,
|
||
) -> Result<Json<Value>, Response> {
|
||
ensure_non_empty(
|
||
&request_context,
|
||
SQUARE_HOLE_RUNTIME_PROVIDER,
|
||
&run_id,
|
||
"runId",
|
||
)?;
|
||
|
||
let run = state
|
||
.spacetime_client()
|
||
.finish_square_hole_time_up(SquareHoleRunTimeUpRecordInput {
|
||
run_id,
|
||
owner_user_id: authenticated.claims().user_id().to_string(),
|
||
finished_at_ms: current_utc_ms(),
|
||
})
|
||
.await
|
||
.map_err(|error| {
|
||
square_hole_error_response(
|
||
&request_context,
|
||
SQUARE_HOLE_RUNTIME_PROVIDER,
|
||
map_square_hole_client_error(error),
|
||
)
|
||
})?;
|
||
|
||
Ok(json_success_body(
|
||
Some(&request_context),
|
||
SquareHoleRunResponse {
|
||
run: map_square_hole_run_response(run),
|
||
},
|
||
))
|
||
}
|
||
|
||
async fn submit_and_finalize_square_hole_message(
|
||
state: &AppState,
|
||
request_context: &RequestContext,
|
||
owner_user_id: &str,
|
||
session_id: String,
|
||
payload: SendSquareHoleMessageRequest,
|
||
on_reply_update: impl FnMut(&str),
|
||
) -> Result<SquareHoleAgentSessionRecord, Response> {
|
||
ensure_non_empty(
|
||
request_context,
|
||
SQUARE_HOLE_AGENT_PROVIDER,
|
||
&session_id,
|
||
"sessionId",
|
||
)?;
|
||
ensure_non_empty(
|
||
request_context,
|
||
SQUARE_HOLE_AGENT_PROVIDER,
|
||
&payload.client_message_id,
|
||
"clientMessageId",
|
||
)?;
|
||
ensure_non_empty(
|
||
request_context,
|
||
SQUARE_HOLE_AGENT_PROVIDER,
|
||
&payload.text,
|
||
"text",
|
||
)?;
|
||
|
||
let submitted = state
|
||
.spacetime_client()
|
||
.submit_square_hole_agent_message(SquareHoleAgentMessageSubmitRecordInput {
|
||
session_id: session_id.clone(),
|
||
owner_user_id: owner_user_id.to_string(),
|
||
user_message_id: payload.client_message_id.clone(),
|
||
user_message_text: payload.text.clone(),
|
||
submitted_at_micros: current_utc_micros(),
|
||
})
|
||
.await
|
||
.map_err(|error| {
|
||
square_hole_error_response(
|
||
request_context,
|
||
SQUARE_HOLE_AGENT_PROVIDER,
|
||
map_square_hole_client_error(error),
|
||
)
|
||
})?;
|
||
let turn_result = run_square_hole_agent_turn(
|
||
SquareHoleAgentTurnRequest {
|
||
llm_client: state.llm_client(),
|
||
session: &submitted,
|
||
quick_fill_requested: payload.quick_fill_requested.unwrap_or(false),
|
||
enable_web_search: state.config.creation_agent_llm_web_search_enabled,
|
||
},
|
||
on_reply_update,
|
||
)
|
||
.await
|
||
.map_err(|error| {
|
||
square_hole_error_response(
|
||
request_context,
|
||
SQUARE_HOLE_AGENT_PROVIDER,
|
||
AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({
|
||
"provider": SQUARE_HOLE_AGENT_PROVIDER,
|
||
"message": error.to_string(),
|
||
})),
|
||
)
|
||
})?;
|
||
|
||
let finalize_input = build_finalize_record_input(
|
||
session_id,
|
||
owner_user_id.to_string(),
|
||
build_prefixed_uuid_id(SQUARE_HOLE_MESSAGE_ID_PREFIX),
|
||
turn_result,
|
||
current_utc_micros(),
|
||
);
|
||
|
||
state
|
||
.spacetime_client()
|
||
.finalize_square_hole_agent_message(finalize_input)
|
||
.await
|
||
.map_err(|error| {
|
||
square_hole_error_response(
|
||
request_context,
|
||
SQUARE_HOLE_AGENT_PROVIDER,
|
||
map_square_hole_client_error(error),
|
||
)
|
||
})
|
||
}
|
||
|
||
async fn compile_square_hole_draft_for_session(
|
||
state: &AppState,
|
||
request_context: &RequestContext,
|
||
authenticated: &AuthenticatedAccessToken,
|
||
session_id: String,
|
||
game_name: Option<String>,
|
||
summary: Option<String>,
|
||
tags: Option<Vec<String>>,
|
||
cover_image_src: Option<String>,
|
||
) -> Result<SquareHoleAgentSessionRecord, Response> {
|
||
let owner_user_id = authenticated.claims().user_id().to_string();
|
||
let session = state
|
||
.spacetime_client()
|
||
.get_square_hole_agent_session(session_id.clone(), owner_user_id.clone())
|
||
.await
|
||
.map_err(|error| {
|
||
square_hole_error_response(
|
||
request_context,
|
||
SQUARE_HOLE_AGENT_PROVIDER,
|
||
map_square_hole_client_error(error),
|
||
)
|
||
})?;
|
||
if session.current_turn < 2 || session.progress_percent < 100 {
|
||
return Err(square_hole_bad_request(
|
||
request_context,
|
||
SQUARE_HOLE_AGENT_PROVIDER,
|
||
"square hole 创作配置尚未确认完成",
|
||
));
|
||
}
|
||
|
||
let config = resolve_config_or_default(Some(&session.config));
|
||
let tags_json = tags
|
||
.as_ref()
|
||
.map(|tags| serde_json::to_string(&normalize_tags(tags.clone())).unwrap_or_default());
|
||
|
||
state
|
||
.spacetime_client()
|
||
.compile_square_hole_draft(SquareHoleCompileDraftRecordInput {
|
||
session_id,
|
||
owner_user_id,
|
||
profile_id: build_prefixed_uuid_id(SQUARE_HOLE_PROFILE_ID_PREFIX),
|
||
author_display_name: resolve_author_display_name(state, authenticated),
|
||
game_name: game_name.or_else(|| Some(format!("{}方洞挑战", config.theme_text))),
|
||
summary_text: summary,
|
||
tags_json,
|
||
cover_image_src,
|
||
compiled_at_micros: current_utc_micros(),
|
||
})
|
||
.await
|
||
.map_err(|error| {
|
||
square_hole_error_response(
|
||
request_context,
|
||
SQUARE_HOLE_AGENT_PROVIDER,
|
||
map_square_hole_client_error(error),
|
||
)
|
||
})
|
||
}
|
||
|
||
mod visual_assets;
|
||
|
||
use visual_assets::{
|
||
generate_square_hole_visual_assets_for_session, regenerate_square_hole_visual_asset_for_work,
|
||
};
|
||
|
||
mod mappers;
|
||
|
||
use mappers::*;
|
||
|
||
fn build_config_from_create_request(
|
||
payload: &CreateSquareHoleSessionRequest,
|
||
) -> SquareHoleConfigJson {
|
||
let theme_text = payload
|
||
.theme_text
|
||
.as_deref()
|
||
.or(payload.seed_text.as_deref())
|
||
.map(str::trim)
|
||
.filter(|value| !value.is_empty())
|
||
.unwrap_or(SQUARE_HOLE_DEFAULT_THEME);
|
||
let hole_options = normalize_hole_options(Vec::new(), theme_text);
|
||
SquareHoleConfigJson {
|
||
theme_text: theme_text.to_string(),
|
||
twist_rule: payload
|
||
.twist_rule
|
||
.as_deref()
|
||
.map(str::trim)
|
||
.filter(|value| !value.is_empty())
|
||
.unwrap_or(SQUARE_HOLE_DEFAULT_TWIST_RULE)
|
||
.to_string(),
|
||
shape_count: payload
|
||
.shape_count
|
||
.unwrap_or(SQUARE_HOLE_DEFAULT_SHAPE_COUNT)
|
||
.max(1),
|
||
difficulty: payload
|
||
.difficulty
|
||
.unwrap_or(SQUARE_HOLE_DEFAULT_DIFFICULTY)
|
||
.clamp(1, 10),
|
||
shape_options: square_hole_shape_records_to_config_json(normalize_shape_options(
|
||
Vec::new(),
|
||
theme_text,
|
||
hole_options.as_slice(),
|
||
)),
|
||
hole_options: square_hole_hole_records_to_config_json(hole_options),
|
||
background_prompt: default_background_prompt(theme_text),
|
||
cover_image_src: String::new(),
|
||
background_image_src: String::new(),
|
||
}
|
||
}
|
||
|
||
fn resolve_config_or_default(
|
||
config: Option<&SquareHoleCreatorConfigRecord>,
|
||
) -> SquareHoleConfigJson {
|
||
config
|
||
.map(|config| SquareHoleConfigJson {
|
||
theme_text: config.theme_text.clone(),
|
||
twist_rule: config.twist_rule.clone(),
|
||
shape_count: config.shape_count.max(1),
|
||
difficulty: config.difficulty.clamp(1, 10),
|
||
shape_options: square_hole_shape_records_to_config_json(config.shape_options.clone()),
|
||
hole_options: square_hole_hole_records_to_config_json(config.hole_options.clone()),
|
||
background_prompt: config.background_prompt.clone(),
|
||
cover_image_src: config.cover_image_src.clone().unwrap_or_default(),
|
||
background_image_src: config.background_image_src.clone().unwrap_or_default(),
|
||
})
|
||
.unwrap_or_else(|| SquareHoleConfigJson {
|
||
theme_text: SQUARE_HOLE_DEFAULT_THEME.to_string(),
|
||
twist_rule: SQUARE_HOLE_DEFAULT_TWIST_RULE.to_string(),
|
||
shape_count: SQUARE_HOLE_DEFAULT_SHAPE_COUNT,
|
||
difficulty: SQUARE_HOLE_DEFAULT_DIFFICULTY,
|
||
shape_options: {
|
||
let hole_options = normalize_hole_options(Vec::new(), SQUARE_HOLE_DEFAULT_THEME);
|
||
square_hole_shape_records_to_config_json(normalize_shape_options(
|
||
Vec::new(),
|
||
SQUARE_HOLE_DEFAULT_THEME,
|
||
hole_options.as_slice(),
|
||
))
|
||
},
|
||
hole_options: square_hole_hole_records_to_config_json(normalize_hole_options(
|
||
Vec::new(),
|
||
SQUARE_HOLE_DEFAULT_THEME,
|
||
)),
|
||
background_prompt: default_background_prompt(SQUARE_HOLE_DEFAULT_THEME),
|
||
cover_image_src: String::new(),
|
||
background_image_src: String::new(),
|
||
})
|
||
}
|
||
|
||
fn serialize_square_hole_config(config: &SquareHoleConfigJson) -> Option<String> {
|
||
serde_json::to_string(config).ok()
|
||
}
|
||
|
||
fn deserialize_optional_string_as_default<'de, D>(deserializer: D) -> Result<String, D::Error>
|
||
where
|
||
D: serde::Deserializer<'de>,
|
||
{
|
||
Ok(Option::<String>::deserialize(deserializer)?.unwrap_or_default())
|
||
}
|
||
|
||
fn build_seed_text(
|
||
payload: &CreateSquareHoleSessionRequest,
|
||
config: &SquareHoleConfigJson,
|
||
) -> String {
|
||
payload
|
||
.seed_text
|
||
.as_deref()
|
||
.map(str::trim)
|
||
.filter(|value| !value.is_empty())
|
||
.map(str::to_string)
|
||
.unwrap_or_else(|| {
|
||
format!(
|
||
"{}题材,{},{} 个形状,难度{}",
|
||
config.theme_text, config.twist_rule, config.shape_count, config.difficulty
|
||
)
|
||
})
|
||
}
|
||
|
||
fn normalize_tags(tags: Vec<String>) -> Vec<String> {
|
||
let mut result = Vec::new();
|
||
for tag in tags {
|
||
let trimmed = tag.trim();
|
||
if !trimmed.is_empty() && !result.iter().any(|value| value == trimmed) {
|
||
result.push(trimmed.to_string());
|
||
}
|
||
if result.len() >= 6 {
|
||
break;
|
||
}
|
||
}
|
||
result
|
||
}
|
||
|
||
fn square_hole_shape_records_to_config_json(
|
||
options: Vec<impl Into<SquareHoleConfigShapeOptionJson>>,
|
||
) -> Vec<SquareHoleConfigShapeOptionJson> {
|
||
options.into_iter().map(Into::into).collect()
|
||
}
|
||
|
||
fn square_hole_hole_records_to_config_json(
|
||
options: Vec<impl Into<SquareHoleConfigHoleOptionJson>>,
|
||
) -> Vec<SquareHoleConfigHoleOptionJson> {
|
||
options.into_iter().map(Into::into).collect()
|
||
}
|
||
|
||
fn square_hole_work_shape_options_to_records(
|
||
options: Vec<SquareHoleWorkShapeOptionResponse>,
|
||
hole_options: &[SquareHoleHoleOptionRecord],
|
||
) -> Vec<SquareHoleShapeOptionRecord> {
|
||
let fallback_hole_id = hole_options
|
||
.first()
|
||
.map(|option| option.hole_id.clone())
|
||
.unwrap_or_else(|| "hole-1".to_string());
|
||
options
|
||
.into_iter()
|
||
.map(|option| SquareHoleShapeOptionRecord {
|
||
option_id: option.option_id,
|
||
shape_kind: option.shape_kind,
|
||
label: option.label,
|
||
target_hole_id: hole_options
|
||
.iter()
|
||
.find(|hole| hole.hole_id == option.target_hole_id)
|
||
.map(|hole| hole.hole_id.clone())
|
||
.unwrap_or_else(|| fallback_hole_id.clone()),
|
||
image_prompt: option.image_prompt,
|
||
image_src: option.image_src.filter(|value| !value.trim().is_empty()),
|
||
})
|
||
.collect()
|
||
}
|
||
|
||
fn square_hole_work_hole_options_to_records(
|
||
options: Vec<SquareHoleWorkHoleOptionResponse>,
|
||
) -> Vec<SquareHoleHoleOptionRecord> {
|
||
options
|
||
.into_iter()
|
||
.map(|option| SquareHoleHoleOptionRecord {
|
||
hole_id: option.hole_id,
|
||
hole_kind: option.hole_kind,
|
||
label: option.label,
|
||
image_prompt: option.image_prompt,
|
||
image_src: option.image_src.filter(|value| !value.trim().is_empty()),
|
||
})
|
||
.collect()
|
||
}
|
||
|
||
fn serialize_square_hole_shape_option_records(options: &[SquareHoleShapeOptionRecord]) -> String {
|
||
let json_options: Vec<SquareHoleConfigShapeOptionJson> =
|
||
options.iter().cloned().map(Into::into).collect();
|
||
serde_json::to_string(&json_options).unwrap_or_default()
|
||
}
|
||
|
||
fn serialize_square_hole_hole_option_records(options: &[SquareHoleHoleOptionRecord]) -> String {
|
||
let json_options: Vec<SquareHoleConfigHoleOptionJson> =
|
||
options.iter().cloned().map(Into::into).collect();
|
||
serde_json::to_string(&json_options).unwrap_or_default()
|
||
}
|
||
|
||
fn clean_prompt_text(value: &str, fallback: &str) -> String {
|
||
let cleaned = value
|
||
.trim()
|
||
.split_whitespace()
|
||
.collect::<Vec<_>>()
|
||
.join(" ");
|
||
if cleaned.is_empty() {
|
||
fallback.to_string()
|
||
} else {
|
||
cleaned.chars().take(180).collect()
|
||
}
|
||
}
|
||
|
||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||
enum SquareHoleVisualAssetSlotRequest {
|
||
Cover,
|
||
Background,
|
||
Shape(String),
|
||
Hole(String),
|
||
}
|
||
|
||
fn normalize_square_hole_visual_asset_slot(
|
||
slot: Option<&str>,
|
||
option_id: Option<&str>,
|
||
) -> Option<SquareHoleVisualAssetSlotRequest> {
|
||
match slot.map(str::trim).unwrap_or_default() {
|
||
"cover" => Some(SquareHoleVisualAssetSlotRequest::Cover),
|
||
"background" => Some(SquareHoleVisualAssetSlotRequest::Background),
|
||
"shape" => option_id
|
||
.map(str::trim)
|
||
.filter(|value| !value.is_empty())
|
||
.map(|value| SquareHoleVisualAssetSlotRequest::Shape(value.to_string())),
|
||
"hole" => option_id
|
||
.map(str::trim)
|
||
.filter(|value| !value.is_empty())
|
||
.map(|value| SquareHoleVisualAssetSlotRequest::Hole(value.to_string())),
|
||
_ => None,
|
||
}
|
||
}
|
||
|
||
fn should_generate_square_hole_cover_image(
|
||
requested_slot: Option<&SquareHoleVisualAssetSlotRequest>,
|
||
regenerate_visual_assets: bool,
|
||
current_image_src: &str,
|
||
) -> bool {
|
||
matches!(
|
||
requested_slot,
|
||
Some(SquareHoleVisualAssetSlotRequest::Cover)
|
||
) || (requested_slot.is_none()
|
||
&& (regenerate_visual_assets || current_image_src.trim().is_empty()))
|
||
}
|
||
|
||
fn should_generate_square_hole_background_image(
|
||
requested_slot: Option<&SquareHoleVisualAssetSlotRequest>,
|
||
regenerate_visual_assets: bool,
|
||
current_image_src: &str,
|
||
) -> bool {
|
||
matches!(
|
||
requested_slot,
|
||
Some(SquareHoleVisualAssetSlotRequest::Background)
|
||
) || (requested_slot.is_none()
|
||
&& (regenerate_visual_assets || current_image_src.trim().is_empty()))
|
||
}
|
||
|
||
fn should_generate_square_hole_shape_image(
|
||
requested_slot: Option<&SquareHoleVisualAssetSlotRequest>,
|
||
regenerate_visual_assets: bool,
|
||
option: &SquareHoleShapeOptionRecord,
|
||
) -> bool {
|
||
match requested_slot {
|
||
Some(SquareHoleVisualAssetSlotRequest::Shape(option_id)) => option.option_id == *option_id,
|
||
Some(_) => false,
|
||
None => {
|
||
regenerate_visual_assets
|
||
|| option
|
||
.image_src
|
||
.as_deref()
|
||
.map(str::trim)
|
||
.filter(|value| !value.is_empty())
|
||
.is_none()
|
||
}
|
||
}
|
||
}
|
||
|
||
fn should_generate_square_hole_hole_image(
|
||
requested_slot: Option<&SquareHoleVisualAssetSlotRequest>,
|
||
regenerate_visual_assets: bool,
|
||
option: &SquareHoleHoleOptionRecord,
|
||
) -> bool {
|
||
match requested_slot {
|
||
Some(SquareHoleVisualAssetSlotRequest::Hole(hole_id)) => option.hole_id == *hole_id,
|
||
Some(_) => false,
|
||
None => {
|
||
regenerate_visual_assets
|
||
|| option
|
||
.image_src
|
||
.as_deref()
|
||
.map(str::trim)
|
||
.filter(|value| !value.is_empty())
|
||
.is_none()
|
||
}
|
||
}
|
||
}
|
||
|
||
impl From<module_square_hole::SquareHoleShapeOption> for SquareHoleConfigShapeOptionJson {
|
||
fn from(option: module_square_hole::SquareHoleShapeOption) -> Self {
|
||
Self {
|
||
option_id: option.option_id,
|
||
shape_kind: option.shape_kind,
|
||
label: option.label,
|
||
target_hole_id: option.target_hole_id,
|
||
image_prompt: option.image_prompt,
|
||
image_src: option.image_src.unwrap_or_default(),
|
||
}
|
||
}
|
||
}
|
||
|
||
impl From<SquareHoleShapeOptionRecord> for SquareHoleConfigShapeOptionJson {
|
||
fn from(option: SquareHoleShapeOptionRecord) -> Self {
|
||
Self {
|
||
option_id: option.option_id,
|
||
shape_kind: option.shape_kind,
|
||
label: option.label,
|
||
target_hole_id: option.target_hole_id,
|
||
image_prompt: option.image_prompt,
|
||
image_src: option.image_src.unwrap_or_default(),
|
||
}
|
||
}
|
||
}
|
||
|
||
impl From<module_square_hole::SquareHoleHoleOption> for SquareHoleConfigHoleOptionJson {
|
||
fn from(option: module_square_hole::SquareHoleHoleOption) -> Self {
|
||
Self {
|
||
hole_id: option.hole_id,
|
||
hole_kind: option.hole_kind,
|
||
label: option.label,
|
||
image_prompt: option.image_prompt,
|
||
image_src: option.image_src.unwrap_or_default(),
|
||
}
|
||
}
|
||
}
|
||
|
||
impl From<SquareHoleHoleOptionRecord> for SquareHoleConfigHoleOptionJson {
|
||
fn from(option: SquareHoleHoleOptionRecord) -> Self {
|
||
Self {
|
||
hole_id: option.hole_id,
|
||
hole_kind: option.hole_kind,
|
||
label: option.label,
|
||
image_prompt: option.image_prompt,
|
||
image_src: option.image_src.unwrap_or_default(),
|
||
}
|
||
}
|
||
}
|
||
|
||
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 normalize_square_hole_run_status(value: &str) -> &str {
|
||
match value {
|
||
"Running" | "running" => "running",
|
||
"Won" | "won" => "won",
|
||
"Failed" | "failed" => "failed",
|
||
"Stopped" | "stopped" => "stopped",
|
||
_ => value,
|
||
}
|
||
}
|
||
|
||
fn ensure_non_empty(
|
||
request_context: &RequestContext,
|
||
provider: &str,
|
||
value: &str,
|
||
field_name: &str,
|
||
) -> Result<(), Response> {
|
||
if value.trim().is_empty() {
|
||
return Err(square_hole_bad_request(
|
||
request_context,
|
||
provider,
|
||
format!("{field_name} is required").as_str(),
|
||
));
|
||
}
|
||
Ok(())
|
||
}
|
||
|
||
fn square_hole_json<T>(
|
||
payload: Result<Json<T>, JsonRejection>,
|
||
request_context: &RequestContext,
|
||
provider: &str,
|
||
) -> Result<Json<T>, Response> {
|
||
payload.map_err(|error| {
|
||
square_hole_error_response(
|
||
request_context,
|
||
provider,
|
||
AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({
|
||
"provider": provider,
|
||
"message": error.body_text(),
|
||
})),
|
||
)
|
||
})
|
||
}
|
||
|
||
async fn extract_square_hole_response_error_message(response: Response) -> String {
|
||
let status = response.status();
|
||
let body = match to_bytes(response.into_body(), 64 * 1024).await {
|
||
Ok(body) => body,
|
||
Err(_) => return format!("方洞挑战生成失败:{status}"),
|
||
};
|
||
let body_text = String::from_utf8_lossy(&body).trim().to_string();
|
||
if body_text.is_empty() {
|
||
return format!("方洞挑战生成失败:{status}");
|
||
}
|
||
|
||
if let Ok(body_json) = serde_json::from_str::<Value>(&body_text)
|
||
&& let Some(message) = find_square_hole_error_message(&body_json)
|
||
{
|
||
return message;
|
||
}
|
||
|
||
body_text
|
||
}
|
||
|
||
fn find_square_hole_error_message(value: &Value) -> Option<String> {
|
||
if let Some(message) = value
|
||
.get("details")
|
||
.and_then(|details| details.get("message"))
|
||
.and_then(Value::as_str)
|
||
.map(str::trim)
|
||
.filter(|message| !message.is_empty())
|
||
{
|
||
return Some(message.to_string());
|
||
}
|
||
|
||
if let Some(message) = value
|
||
.get("message")
|
||
.and_then(Value::as_str)
|
||
.map(str::trim)
|
||
.filter(|message| !message.is_empty())
|
||
{
|
||
return Some(message.to_string());
|
||
}
|
||
|
||
match value {
|
||
Value::Array(items) => items.iter().find_map(find_square_hole_error_message),
|
||
Value::Object(object) => object.values().find_map(find_square_hole_error_message),
|
||
_ => None,
|
||
}
|
||
}
|
||
|
||
fn square_hole_bad_request(
|
||
request_context: &RequestContext,
|
||
provider: &str,
|
||
message: &str,
|
||
) -> Response {
|
||
square_hole_error_response(
|
||
request_context,
|
||
provider,
|
||
AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({
|
||
"provider": provider,
|
||
"message": message,
|
||
})),
|
||
)
|
||
}
|
||
|
||
fn map_square_hole_client_error(error: SpacetimeClientError) -> AppError {
|
||
let status = match &error {
|
||
SpacetimeClientError::Runtime(_) => StatusCode::BAD_REQUEST,
|
||
SpacetimeClientError::Procedure(message)
|
||
if message.contains("不存在")
|
||
|| message.contains("not found")
|
||
|| message.contains("does not exist") =>
|
||
{
|
||
StatusCode::NOT_FOUND
|
||
}
|
||
SpacetimeClientError::Procedure(message)
|
||
if message.contains("发布需要")
|
||
|| message.contains("不能为空")
|
||
|| message.contains("必须") =>
|
||
{
|
||
StatusCode::BAD_REQUEST
|
||
}
|
||
_ => StatusCode::BAD_GATEWAY,
|
||
};
|
||
|
||
AppError::from_status(status).with_details(json!({
|
||
"provider": "spacetimedb",
|
||
"message": error.to_string(),
|
||
}))
|
||
}
|
||
|
||
fn square_hole_error_response(
|
||
request_context: &RequestContext,
|
||
provider: &str,
|
||
error: AppError,
|
||
) -> Response {
|
||
let mut response = error.into_response_with_context(Some(request_context));
|
||
response.headers_mut().insert(
|
||
HeaderName::from_static("x-genarrative-provider"),
|
||
header::HeaderValue::from_str(provider)
|
||
.unwrap_or_else(|_| header::HeaderValue::from_static("square-hole")),
|
||
);
|
||
response
|
||
}
|
||
|
||
fn square_hole_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 square_hole_sse_json_event_or_error(event_name: &str, payload: Value) -> Event {
|
||
match square_hole_sse_json_event(event_name, payload) {
|
||
Ok(event) => event,
|
||
Err(error) => Event::default().event("error").data(format!("{error:?}")),
|
||
}
|
||
}
|
||
|
||
fn current_utc_micros() -> i64 {
|
||
let duration = SystemTime::now()
|
||
.duration_since(UNIX_EPOCH)
|
||
.unwrap_or_default();
|
||
(duration.as_secs() as i64) * 1_000_000 + i64::from(duration.subsec_micros())
|
||
}
|
||
|
||
fn current_utc_ms() -> i64 {
|
||
current_utc_micros().saturating_div(1000)
|
||
}
|