1435 lines
54 KiB
Rust
1435 lines
54 KiB
Rust
use std::{
|
||
convert::Infallible,
|
||
time::{SystemTime, UNIX_EPOCH},
|
||
};
|
||
|
||
use axum::{
|
||
Json,
|
||
extract::{Extension, Path, State, rejection::JsonRejection},
|
||
http::StatusCode,
|
||
response::{
|
||
IntoResponse, Response,
|
||
sse::{Event, Sse},
|
||
},
|
||
};
|
||
use module_puzzle::{
|
||
CreativePuzzleDraftToolInput as DomainCreativePuzzleDraftToolInput,
|
||
CreativePuzzleLevelDraftInput as DomainCreativePuzzleLevelDraftInput,
|
||
PuzzleCreativeDraftEditableFieldPath as DomainPuzzleDraftEditableFieldPath,
|
||
PuzzleCreativeImageGenerationPolicy as DomainPuzzleImageGenerationPolicy,
|
||
PuzzleCreativeLevelGenerationMode as DomainPuzzleLevelGenerationMode,
|
||
PuzzleCreativePricingUnit as DomainPuzzlePricingUnit,
|
||
PuzzleCreativeSupportedLevelMode as DomainPuzzleSupportedLevelMode,
|
||
PuzzleCreativeTemplateProtocol as DomainPuzzleTemplateProtocol,
|
||
PuzzleCreativeTemplateSelection as DomainPuzzleTemplateSelection,
|
||
PuzzleLevelImagePlanInput as DomainPuzzleLevelImagePlanInput,
|
||
build_puzzle_draft_from_creative_fields, plan_puzzle_level_images,
|
||
retrieve_puzzle_template_catalog, validate_puzzle_template_selection,
|
||
};
|
||
use platform_agent::{
|
||
CreativeAgentCallbackKind, CreativeAgentCallbacks, CreativeAgentExecutor, FunctionAgentLimits,
|
||
Gpt5ResponsesAgentClient, PuzzlePhase1AgentInput,
|
||
};
|
||
use serde_json::{Value, json};
|
||
use shared_contracts::{
|
||
creative_agent::{
|
||
ConfirmCreativePuzzleTemplateRequest, CreateCreativeAgentSessionRequest,
|
||
CreativeAgentDoneEvent, CreativeAgentEntryContext, CreativeAgentInputPart,
|
||
CreativeAgentInputPartType, CreativeAgentMessage, CreativeAgentMessageDeltaEvent,
|
||
CreativeAgentMessageKind, CreativeAgentMessageRole, CreativeAgentSessionResponse,
|
||
CreativeAgentSessionSnapshot, CreativeAgentSseEventType, CreativeAgentStage,
|
||
CreativeAgentStageEvent, CreativeAgentTemplateCatalogEvent,
|
||
CreativeAgentThoughtSummaryDeltaEvent, CreativeAgentToolEvent, CreativeCapabilityStatus,
|
||
CreativeDraftEditResult, CreativeDraftEditStreamRequest, CreativeImageSummary,
|
||
CreativeInputSummary, CreativeTargetPlayType, CreativeTargetSessionBinding,
|
||
CreativeTargetStage, CreativeUnsupportedCapability, CreativeUnsupportedPlayType,
|
||
StreamCreativeAgentMessageRequest,
|
||
},
|
||
puzzle_creative_template::{
|
||
PuzzleCreativeTemplateProtocol, PuzzleCreativeTemplateSelection,
|
||
PuzzleDraftEditableFieldPath, PuzzleDraftFieldPatch, PuzzleDraftFieldPatchOperation,
|
||
PuzzleImageGenerationPlan, PuzzleImageGenerationPlanLevel, PuzzleLevelGenerationMode,
|
||
PuzzleSupportedLevelMode, PuzzleTemplateCostRange, PuzzleTemplateImageGenerationPolicy,
|
||
PuzzleTemplatePricingUnit,
|
||
},
|
||
};
|
||
use shared_kernel::{build_prefixed_uuid_id, format_timestamp_micros, normalize_required_string};
|
||
use spacetime_client::{PuzzleAgentSessionCreateRecordInput, PuzzleWorkUpsertRecordInput};
|
||
use tokio::sync::mpsc;
|
||
use tokio_stream::wrappers::UnboundedReceiverStream;
|
||
|
||
use crate::{
|
||
api_response::json_success_body,
|
||
auth::AuthenticatedAccessToken,
|
||
creative_agent_sse::{
|
||
creative_sse_error_event, creative_sse_json_event, creative_sse_json_value_event,
|
||
},
|
||
http_error::AppError,
|
||
request_context::RequestContext,
|
||
state::AppState,
|
||
};
|
||
|
||
const CREATIVE_AGENT_PROVIDER: &str = "creative-agent";
|
||
type CreativeSseItem = Result<Event, Infallible>;
|
||
type CreativeSseSender = mpsc::UnboundedSender<CreativeSseItem>;
|
||
|
||
pub async fn create_creative_agent_session(
|
||
State(state): State<AppState>,
|
||
Extension(request_context): Extension<RequestContext>,
|
||
Extension(authenticated): Extension<AuthenticatedAccessToken>,
|
||
payload: Result<Json<CreateCreativeAgentSessionRequest>, JsonRejection>,
|
||
) -> Result<Json<Value>, Response> {
|
||
let Json(payload) = payload.map_err(|error| {
|
||
creative_error_response(
|
||
&request_context,
|
||
AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({
|
||
"provider": CREATIVE_AGENT_PROVIDER,
|
||
"message": error.body_text(),
|
||
})),
|
||
)
|
||
})?;
|
||
|
||
let now = current_utc_micros();
|
||
let session = CreativeAgentSessionSnapshot {
|
||
session_id: build_prefixed_uuid_id("creative-session-"),
|
||
stage: CreativeAgentStage::Idle,
|
||
input_summary: build_input_summary_from_session_request(&payload),
|
||
messages: Vec::new(),
|
||
puzzle_template_catalog: Vec::new(),
|
||
puzzle_template_selection: None,
|
||
puzzle_image_generation_plan: None,
|
||
target_binding: None,
|
||
updated_at: format_timestamp_micros(now),
|
||
};
|
||
state.put_creative_agent_session(
|
||
authenticated.claims().user_id().to_string(),
|
||
session.clone(),
|
||
);
|
||
|
||
Ok(json_success_body(
|
||
Some(&request_context),
|
||
CreativeAgentSessionResponse { session },
|
||
))
|
||
}
|
||
|
||
pub async fn get_creative_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(&session_id, "sessionId").map_err(|error| {
|
||
creative_error_response(
|
||
&request_context,
|
||
error.with_details(json!({
|
||
"provider": CREATIVE_AGENT_PROVIDER,
|
||
"message": "sessionId is required",
|
||
})),
|
||
)
|
||
})?;
|
||
|
||
let session = state
|
||
.get_creative_agent_session(&session_id, authenticated.claims().user_id())
|
||
.ok_or_else(|| {
|
||
creative_error_response(
|
||
&request_context,
|
||
AppError::from_status(StatusCode::NOT_FOUND).with_details(json!({
|
||
"provider": CREATIVE_AGENT_PROVIDER,
|
||
"message": "创意 Agent 会话不存在",
|
||
})),
|
||
)
|
||
})?;
|
||
|
||
Ok(json_success_body(
|
||
Some(&request_context),
|
||
CreativeAgentSessionResponse { session },
|
||
))
|
||
}
|
||
|
||
pub async fn stream_creative_agent_message(
|
||
State(state): State<AppState>,
|
||
Path(session_id): Path<String>,
|
||
Extension(request_context): Extension<RequestContext>,
|
||
Extension(authenticated): Extension<AuthenticatedAccessToken>,
|
||
payload: Result<Json<StreamCreativeAgentMessageRequest>, JsonRejection>,
|
||
) -> Result<Response, Response> {
|
||
let Json(payload) = payload.map_err(|error| {
|
||
creative_error_response(
|
||
&request_context,
|
||
AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({
|
||
"provider": CREATIVE_AGENT_PROVIDER,
|
||
"message": error.body_text(),
|
||
})),
|
||
)
|
||
})?;
|
||
ensure_non_empty(&session_id, "sessionId")
|
||
.map_err(|error| creative_error_response(&request_context, error))?;
|
||
if payload.client_message_id.trim().is_empty() {
|
||
return Err(creative_bad_request(
|
||
&request_context,
|
||
"clientMessageId is required",
|
||
));
|
||
}
|
||
|
||
let owner_user_id = authenticated.claims().user_id().to_string();
|
||
let Some(mut session) = state.get_creative_agent_session(&session_id, &owner_user_id) else {
|
||
return Err(creative_not_found(
|
||
&request_context,
|
||
"创意 Agent 会话不存在",
|
||
));
|
||
};
|
||
let (user_text, image_urls) = normalize_message_content(&payload.content)
|
||
.map_err(|error| creative_error_response(&request_context, error))?;
|
||
if user_text.trim().is_empty() && image_urls.is_empty() {
|
||
return Err(creative_bad_request(
|
||
&request_context,
|
||
"创意 Agent 输入文本和图片不能同时为空",
|
||
));
|
||
}
|
||
|
||
let now = current_utc_micros();
|
||
session.stage = CreativeAgentStage::Perceiving;
|
||
session.input_summary =
|
||
build_input_summary_from_message(&session.input_summary, &payload.content);
|
||
session.messages.push(CreativeAgentMessage {
|
||
id: payload.client_message_id.clone(),
|
||
role: CreativeAgentMessageRole::User,
|
||
kind: CreativeAgentMessageKind::Chat,
|
||
text: user_text.clone(),
|
||
created_at: format_timestamp_micros(now),
|
||
});
|
||
session.updated_at = format_timestamp_micros(now);
|
||
state.put_creative_agent_session(owner_user_id.clone(), session.clone());
|
||
|
||
let (event_tx, event_rx) = mpsc::unbounded_channel::<CreativeSseItem>();
|
||
tokio::spawn(run_creative_agent_message_stream(
|
||
state,
|
||
session_id,
|
||
owner_user_id,
|
||
session,
|
||
user_text,
|
||
image_urls,
|
||
event_tx,
|
||
));
|
||
|
||
Ok(Sse::new(UnboundedReceiverStream::new(event_rx)).into_response())
|
||
}
|
||
|
||
async fn run_creative_agent_message_stream(
|
||
stream_state: AppState,
|
||
stream_session_id: String,
|
||
stream_owner_user_id: String,
|
||
initial_session: CreativeAgentSessionSnapshot,
|
||
user_text: String,
|
||
image_urls: Vec<String>,
|
||
event_tx: CreativeSseSender,
|
||
) {
|
||
if !send_creative_sse_event(
|
||
&event_tx,
|
||
creative_sse_json_event(
|
||
CreativeAgentSseEventType::Stage,
|
||
CreativeAgentStageEvent {
|
||
session_id: stream_session_id.clone(),
|
||
stage: CreativeAgentStage::Perceiving,
|
||
},
|
||
),
|
||
) {
|
||
return;
|
||
}
|
||
|
||
let (callback_tx, mut callback_rx) = mpsc::unbounded_channel::<String>();
|
||
let callbacks = CreativeAgentCallbacks::new(move |event| {
|
||
if matches!(event.kind, CreativeAgentCallbackKind::Stage) {
|
||
let _ = callback_tx.send(event.label);
|
||
}
|
||
});
|
||
let executor = stream_state.creative_agent_executor();
|
||
let agent_input = PuzzlePhase1AgentInput {
|
||
session_id: stream_session_id.clone(),
|
||
user_text: user_text.clone(),
|
||
image_urls: image_urls.clone(),
|
||
limits: FunctionAgentLimits::default(),
|
||
};
|
||
let agent_result = executor.run_puzzle_phase1(agent_input, callbacks).await;
|
||
|
||
while let Ok(label) = callback_rx.try_recv() {
|
||
if let Some(stage) = parse_creative_stage_label(&label) {
|
||
if !send_creative_sse_event(
|
||
&event_tx,
|
||
creative_sse_json_event(
|
||
CreativeAgentSseEventType::Stage,
|
||
CreativeAgentStageEvent {
|
||
session_id: stream_session_id.clone(),
|
||
stage,
|
||
},
|
||
),
|
||
) {
|
||
return;
|
||
}
|
||
}
|
||
}
|
||
|
||
if let Some(llm_client) = stream_state.creative_agent_gpt5_client().cloned() {
|
||
let gpt5_client = Gpt5ResponsesAgentClient::new(llm_client);
|
||
if let Err(error) = gpt5_client
|
||
.request(
|
||
build_creative_agent_system_prompt(),
|
||
user_text.clone(),
|
||
image_urls.clone(),
|
||
)
|
||
.await
|
||
{
|
||
mark_creative_agent_stream_failed(
|
||
&stream_state,
|
||
&stream_session_id,
|
||
&stream_owner_user_id,
|
||
&initial_session,
|
||
);
|
||
let _ = send_creative_sse_event(
|
||
&event_tx,
|
||
creative_sse_error_event(
|
||
Some(stream_session_id),
|
||
"GPT5_REQUEST_FAILED",
|
||
error.to_string(),
|
||
),
|
||
);
|
||
return;
|
||
}
|
||
}
|
||
|
||
if let Err(error) = agent_result {
|
||
mark_creative_agent_stream_failed(
|
||
&stream_state,
|
||
&stream_session_id,
|
||
&stream_owner_user_id,
|
||
&initial_session,
|
||
);
|
||
let _ = send_creative_sse_event(
|
||
&event_tx,
|
||
creative_sse_error_event(
|
||
Some(stream_session_id),
|
||
"AGENT_EXECUTION_FAILED",
|
||
error.to_string(),
|
||
),
|
||
);
|
||
return;
|
||
}
|
||
|
||
let catalog = retrieve_puzzle_template_catalog()
|
||
.into_iter()
|
||
.map(from_domain_template_protocol)
|
||
.collect::<Vec<_>>();
|
||
let assistant_text = if catalog.is_empty() {
|
||
"我先整理一下拼图模板。".to_string()
|
||
} else {
|
||
format!("我先给你准备了 {} 个拼图模板,请先选一个。", catalog.len())
|
||
};
|
||
let mut next_session = stream_state
|
||
.get_creative_agent_session(&stream_session_id, &stream_owner_user_id)
|
||
.unwrap_or(initial_session);
|
||
next_session.stage = CreativeAgentStage::WaitingTemplateConfirmation;
|
||
next_session.puzzle_template_catalog = catalog.clone();
|
||
next_session.messages.push(CreativeAgentMessage {
|
||
id: format!("assistant-{}-{}", stream_session_id, current_utc_micros()),
|
||
role: CreativeAgentMessageRole::Assistant,
|
||
kind: CreativeAgentMessageKind::Chat,
|
||
text: assistant_text.clone(),
|
||
created_at: format_timestamp_micros(current_utc_micros()),
|
||
});
|
||
next_session.updated_at = format_timestamp_micros(current_utc_micros());
|
||
stream_state.put_creative_agent_session(stream_owner_user_id.clone(), next_session.clone());
|
||
|
||
send_creative_message_stream_success_events(
|
||
&event_tx,
|
||
stream_session_id,
|
||
catalog,
|
||
assistant_text,
|
||
next_session,
|
||
&user_text,
|
||
image_urls.len(),
|
||
);
|
||
}
|
||
|
||
fn send_creative_message_stream_success_events(
|
||
event_tx: &CreativeSseSender,
|
||
stream_session_id: String,
|
||
catalog: Vec<PuzzleCreativeTemplateProtocol>,
|
||
assistant_text: String,
|
||
next_session: CreativeAgentSessionSnapshot,
|
||
user_text: &str,
|
||
image_count: usize,
|
||
) {
|
||
let thought_id = format!("thought-{}", current_utc_micros());
|
||
let catalog_tool_call_id = format!("tool-{}", current_utc_micros());
|
||
let events = [
|
||
creative_sse_json_event(
|
||
CreativeAgentSseEventType::ThoughtSummaryDelta,
|
||
CreativeAgentThoughtSummaryDeltaEvent {
|
||
session_id: stream_session_id.clone(),
|
||
thought_id: thought_id.clone(),
|
||
text_delta: build_perception_thought_summary(user_text, image_count),
|
||
},
|
||
),
|
||
creative_sse_json_event(
|
||
CreativeAgentSseEventType::ToolStarted,
|
||
CreativeAgentToolEvent {
|
||
session_id: stream_session_id.clone(),
|
||
tool_call_id: catalog_tool_call_id.clone(),
|
||
tool_name: "retrieve_puzzle_template_catalog".to_string(),
|
||
summary: Some("读取拼图模板".to_string()),
|
||
},
|
||
),
|
||
creative_sse_json_event(
|
||
CreativeAgentSseEventType::ToolCompleted,
|
||
CreativeAgentToolEvent {
|
||
session_id: stream_session_id.clone(),
|
||
tool_call_id: catalog_tool_call_id,
|
||
tool_name: "retrieve_puzzle_template_catalog".to_string(),
|
||
summary: Some("已读取拼图模板".to_string()),
|
||
},
|
||
),
|
||
creative_sse_json_event(
|
||
CreativeAgentSseEventType::PuzzleTemplateCatalog,
|
||
CreativeAgentTemplateCatalogEvent {
|
||
session_id: stream_session_id.clone(),
|
||
templates: catalog.clone(),
|
||
},
|
||
),
|
||
creative_sse_json_event(
|
||
CreativeAgentSseEventType::ThoughtSummaryDelta,
|
||
CreativeAgentThoughtSummaryDeltaEvent {
|
||
session_id: stream_session_id.clone(),
|
||
thought_id: thought_id.clone(),
|
||
text_delta: build_catalog_thought_summary(&catalog),
|
||
},
|
||
),
|
||
creative_sse_json_event(
|
||
CreativeAgentSseEventType::ThoughtSummaryDelta,
|
||
CreativeAgentThoughtSummaryDeltaEvent {
|
||
session_id: stream_session_id.clone(),
|
||
thought_id,
|
||
text_delta: "先选一个模板,再确认关卡模式和关卡数。".to_string(),
|
||
},
|
||
),
|
||
creative_sse_json_event(
|
||
CreativeAgentSseEventType::AgentMessageDelta,
|
||
CreativeAgentMessageDeltaEvent {
|
||
session_id: stream_session_id.clone(),
|
||
message_id: format!("assistant-{}", stream_session_id),
|
||
role: CreativeAgentMessageRole::Assistant,
|
||
kind: CreativeAgentMessageKind::Chat,
|
||
text_delta: assistant_text,
|
||
},
|
||
),
|
||
creative_sse_json_event(
|
||
CreativeAgentSseEventType::Session,
|
||
json!({ "session": next_session }),
|
||
),
|
||
creative_sse_json_event(
|
||
CreativeAgentSseEventType::Done,
|
||
CreativeAgentDoneEvent {
|
||
session_id: stream_session_id,
|
||
},
|
||
),
|
||
];
|
||
|
||
for event in events {
|
||
if !send_creative_sse_event(event_tx, event) {
|
||
return;
|
||
}
|
||
}
|
||
}
|
||
|
||
fn mark_creative_agent_stream_failed(
|
||
state: &AppState,
|
||
session_id: &str,
|
||
owner_user_id: &str,
|
||
fallback_session: &CreativeAgentSessionSnapshot,
|
||
) {
|
||
let mut failed = state
|
||
.get_creative_agent_session(session_id, owner_user_id)
|
||
.unwrap_or_else(|| fallback_session.clone());
|
||
failed.stage = CreativeAgentStage::Failed;
|
||
failed.updated_at = format_timestamp_micros(current_utc_micros());
|
||
state.put_creative_agent_session(owner_user_id.to_string(), failed);
|
||
}
|
||
|
||
fn send_creative_sse_event(sender: &CreativeSseSender, event: Event) -> bool {
|
||
sender.send(Ok(event)).is_ok()
|
||
}
|
||
|
||
fn build_perception_thought_summary(user_text: &str, image_count: usize) -> String {
|
||
let text = normalize_required_string(user_text).unwrap_or_default();
|
||
let text_summary = if text.is_empty() {
|
||
"正在理解参考素材".to_string()
|
||
} else {
|
||
format!("正在理解用户输入:{text}")
|
||
};
|
||
match image_count {
|
||
0 => text_summary,
|
||
1 => format!("{text_summary},并结合 1 张参考图。"),
|
||
count => format!("{text_summary},并结合 {count} 张参考图。"),
|
||
}
|
||
}
|
||
|
||
fn build_catalog_thought_summary(catalog: &[PuzzleCreativeTemplateProtocol]) -> String {
|
||
let template_titles = catalog
|
||
.iter()
|
||
.take(3)
|
||
.map(|template| template.title.as_str())
|
||
.collect::<Vec<_>>()
|
||
.join("、");
|
||
if template_titles.is_empty() {
|
||
"正在整理拼图模板目录。".to_string()
|
||
} else {
|
||
format!("已整理出拼图模板目录:{template_titles}。")
|
||
}
|
||
}
|
||
|
||
pub async fn confirm_creative_puzzle_template(
|
||
State(state): State<AppState>,
|
||
Path(session_id): Path<String>,
|
||
Extension(request_context): Extension<RequestContext>,
|
||
Extension(authenticated): Extension<AuthenticatedAccessToken>,
|
||
payload: Result<Json<ConfirmCreativePuzzleTemplateRequest>, JsonRejection>,
|
||
) -> Result<Json<Value>, Response> {
|
||
let Json(payload) = payload.map_err(|error| {
|
||
creative_error_response(
|
||
&request_context,
|
||
AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({
|
||
"provider": CREATIVE_AGENT_PROVIDER,
|
||
"message": error.body_text(),
|
||
})),
|
||
)
|
||
})?;
|
||
let owner_user_id = authenticated.claims().user_id().to_string();
|
||
let Some(mut session) = state.get_creative_agent_session(&session_id, &owner_user_id) else {
|
||
return Err(creative_not_found(
|
||
&request_context,
|
||
"创意 Agent 会话不存在",
|
||
));
|
||
};
|
||
let domain_selection = to_domain_template_selection(&payload.selection).map_err(|error| {
|
||
creative_error_response(&request_context, map_puzzle_field_error(error))
|
||
})?;
|
||
validate_puzzle_template_selection(&domain_selection).map_err(|error| {
|
||
creative_error_response(&request_context, map_puzzle_field_error(error))
|
||
})?;
|
||
|
||
let creative_levels =
|
||
build_creative_levels_from_selection(&payload.selection, &session.input_summary);
|
||
let domain_cost_range = to_domain_cost_range(&payload.selection.cost_range);
|
||
let creative_draft =
|
||
build_puzzle_draft_from_creative_fields(DomainCreativePuzzleDraftToolInput {
|
||
template_id: payload.selection.template_id.clone(),
|
||
template_cost_range: domain_cost_range.clone(),
|
||
work_title: build_work_title_from_input(&session.input_summary),
|
||
work_description: build_work_description_from_input(&session.input_summary),
|
||
work_tags: build_work_tags_from_input(&session.input_summary),
|
||
levels: creative_levels.clone(),
|
||
})
|
||
.map_err(|error| {
|
||
creative_error_response(&request_context, map_puzzle_field_error(error))
|
||
})?;
|
||
let plan = plan_puzzle_level_images(DomainPuzzleLevelImagePlanInput {
|
||
template_id: payload.selection.template_id.clone(),
|
||
selected_level_mode: to_domain_level_generation_mode(
|
||
&payload.selection.selected_level_mode,
|
||
),
|
||
levels: creative_levels,
|
||
cost_range: domain_cost_range,
|
||
candidate_count_per_level: Some(1),
|
||
})
|
||
.map_err(|error| creative_error_response(&request_context, map_puzzle_field_error(error)))?;
|
||
let plan = from_domain_image_plan(plan);
|
||
|
||
let now = current_utc_micros();
|
||
let target_session_id = build_prefixed_uuid_id("puzzle-session-");
|
||
let seed_text = build_puzzle_form_seed_from_creative_draft(&creative_draft);
|
||
let welcome_message_text = "已按智能创作模板准备拼图草稿。".to_string();
|
||
state
|
||
.spacetime_client()
|
||
.create_puzzle_agent_session(PuzzleAgentSessionCreateRecordInput {
|
||
session_id: target_session_id.clone(),
|
||
owner_user_id: owner_user_id.clone(),
|
||
seed_text,
|
||
welcome_message_id: build_prefixed_uuid_id("puzzle-message-"),
|
||
welcome_message_text,
|
||
created_at_micros: now,
|
||
})
|
||
.await
|
||
.map_err(|error| {
|
||
creative_error_response(
|
||
&request_context,
|
||
map_spacetime_error(error.to_string(), "创建拼图草稿失败"),
|
||
)
|
||
})?;
|
||
state
|
||
.spacetime_client()
|
||
.compile_puzzle_agent_draft(target_session_id.clone(), owner_user_id.clone(), now)
|
||
.await
|
||
.map_err(|error| {
|
||
creative_error_response(
|
||
&request_context,
|
||
map_spacetime_error(error.to_string(), "编译拼图草稿失败"),
|
||
)
|
||
})?;
|
||
|
||
let target_binding = CreativeTargetSessionBinding {
|
||
play_type: CreativeTargetPlayType::Puzzle,
|
||
target_session_id: target_session_id.clone(),
|
||
target_stage: CreativeTargetStage::PuzzleResult,
|
||
result_profile_id: Some(build_puzzle_result_profile_id(&target_session_id)),
|
||
};
|
||
session.stage = CreativeAgentStage::TargetReady;
|
||
session.puzzle_template_selection = Some(payload.selection);
|
||
session.puzzle_image_generation_plan = Some(plan);
|
||
session.target_binding = Some(target_binding);
|
||
session.updated_at = format_timestamp_micros(current_utc_micros());
|
||
state.put_creative_agent_session(owner_user_id, session.clone());
|
||
|
||
Ok(json_success_body(
|
||
Some(&request_context),
|
||
CreativeAgentSessionResponse { session },
|
||
))
|
||
}
|
||
|
||
pub async fn stream_creative_draft_edit(
|
||
State(state): State<AppState>,
|
||
Path(session_id): Path<String>,
|
||
Extension(request_context): Extension<RequestContext>,
|
||
Extension(authenticated): Extension<AuthenticatedAccessToken>,
|
||
payload: Result<Json<CreativeDraftEditStreamRequest>, JsonRejection>,
|
||
) -> Result<Response, Response> {
|
||
let Json(payload) = payload.map_err(|error| {
|
||
creative_error_response(
|
||
&request_context,
|
||
AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({
|
||
"provider": CREATIVE_AGENT_PROVIDER,
|
||
"message": error.body_text(),
|
||
})),
|
||
)
|
||
})?;
|
||
let owner_user_id = authenticated.claims().user_id().to_string();
|
||
let Some(mut session) = state.get_creative_agent_session(&session_id, &owner_user_id) else {
|
||
return Err(creative_not_found(
|
||
&request_context,
|
||
"创意 Agent 会话不存在",
|
||
));
|
||
};
|
||
let Some(binding) = session.target_binding.clone() else {
|
||
return Err(creative_bad_request(&request_context, "尚未绑定拼图草稿"));
|
||
};
|
||
if binding.target_session_id != payload.target_puzzle_session_id {
|
||
return Err(creative_bad_request(
|
||
&request_context,
|
||
"目标拼图 session 不匹配",
|
||
));
|
||
}
|
||
let instruction = normalize_required_string(&payload.instruction)
|
||
.ok_or_else(|| creative_bad_request(&request_context, "instruction is required"))?;
|
||
let (patch, patched_draft) =
|
||
build_draft_edit_patch(&instruction, payload.current_draft.clone())
|
||
.map_err(|error| creative_error_response(&request_context, error))?;
|
||
if let Some(profile_id) = binding.result_profile_id.clone() {
|
||
let update_input =
|
||
build_puzzle_work_update_from_draft(profile_id, owner_user_id.clone(), &patched_draft)
|
||
.map_err(|error| creative_error_response(&request_context, error))?;
|
||
state
|
||
.spacetime_client()
|
||
.update_puzzle_work(update_input)
|
||
.await
|
||
.map_err(|error| {
|
||
creative_error_response(
|
||
&request_context,
|
||
map_spacetime_error(error.to_string(), "写回拼图草稿失败"),
|
||
)
|
||
})?;
|
||
}
|
||
|
||
session.stage = CreativeAgentStage::TargetReady;
|
||
session.messages.push(CreativeAgentMessage {
|
||
id: payload.client_message_id,
|
||
role: CreativeAgentMessageRole::User,
|
||
kind: CreativeAgentMessageKind::Chat,
|
||
text: instruction,
|
||
created_at: format_timestamp_micros(current_utc_micros()),
|
||
});
|
||
session.updated_at = format_timestamp_micros(current_utc_micros());
|
||
state.put_creative_agent_session(owner_user_id, session.clone());
|
||
let result = CreativeDraftEditResult {
|
||
edit_instructions: vec![patch],
|
||
session: session.clone(),
|
||
puzzle_session: json!({
|
||
"sessionId": binding.target_session_id,
|
||
"stage": "draft_ready",
|
||
"draft": patched_draft,
|
||
"updatedAt": session.updated_at,
|
||
}),
|
||
};
|
||
|
||
let stream = async_stream::stream! {
|
||
yield Ok::<Event, Infallible>(creative_sse_json_event(
|
||
CreativeAgentSseEventType::Stage,
|
||
CreativeAgentStageEvent {
|
||
session_id: session.session_id.clone(),
|
||
stage: CreativeAgentStage::Reflecting,
|
||
},
|
||
));
|
||
yield Ok::<Event, Infallible>(creative_sse_json_value_event(
|
||
"draft_edit_result",
|
||
serde_json::to_value(&result).unwrap_or_else(|_| json!({ "session": session })),
|
||
));
|
||
yield Ok::<Event, Infallible>(creative_sse_json_event(
|
||
CreativeAgentSseEventType::Session,
|
||
json!({
|
||
"editInstructions": result.edit_instructions,
|
||
"session": result.session,
|
||
"puzzleSession": result.puzzle_session,
|
||
}),
|
||
));
|
||
yield Ok::<Event, Infallible>(creative_sse_json_event(
|
||
CreativeAgentSseEventType::Done,
|
||
CreativeAgentDoneEvent {
|
||
session_id: session_id,
|
||
},
|
||
));
|
||
};
|
||
|
||
Ok(Sse::new(stream).into_response())
|
||
}
|
||
|
||
pub async fn cancel_creative_agent_session(
|
||
State(state): State<AppState>,
|
||
Path(session_id): Path<String>,
|
||
Extension(request_context): Extension<RequestContext>,
|
||
Extension(authenticated): Extension<AuthenticatedAccessToken>,
|
||
) -> Result<Json<Value>, Response> {
|
||
let owner_user_id = authenticated.claims().user_id().to_string();
|
||
let Some(mut session) = state.get_creative_agent_session(&session_id, &owner_user_id) else {
|
||
return Err(creative_not_found(
|
||
&request_context,
|
||
"创意 Agent 会话不存在",
|
||
));
|
||
};
|
||
session.stage = CreativeAgentStage::WaitingUser;
|
||
session.updated_at = format_timestamp_micros(current_utc_micros());
|
||
state.put_creative_agent_session(owner_user_id, session.clone());
|
||
|
||
Ok(json_success_body(
|
||
Some(&request_context),
|
||
CreativeAgentSessionResponse { session },
|
||
))
|
||
}
|
||
|
||
fn build_input_summary_from_session_request(
|
||
payload: &CreateCreativeAgentSessionRequest,
|
||
) -> CreativeInputSummary {
|
||
CreativeInputSummary {
|
||
text: payload.text.as_ref().and_then(normalize_required_string),
|
||
entry_context: payload
|
||
.entry_context
|
||
.clone()
|
||
.unwrap_or(CreativeAgentEntryContext::CreationHome),
|
||
images: payload
|
||
.images
|
||
.iter()
|
||
.map(|image| CreativeImageSummary {
|
||
asset_id: Some(image.asset_id.clone()),
|
||
read_url: Some(image.read_url.clone()),
|
||
thumbnail_url: image.thumbnail_url.clone(),
|
||
width: image.width,
|
||
height: image.height,
|
||
summary: None,
|
||
})
|
||
.collect(),
|
||
material_summary: payload.text.as_ref().and_then(normalize_required_string),
|
||
unsupported_capabilities: unsupported_capabilities(),
|
||
}
|
||
}
|
||
|
||
fn build_input_summary_from_message(
|
||
previous: &CreativeInputSummary,
|
||
content: &[CreativeAgentInputPart],
|
||
) -> CreativeInputSummary {
|
||
let text = content
|
||
.iter()
|
||
.filter(|part| part.part_type == CreativeAgentInputPartType::InputText)
|
||
.filter_map(|part| part.text.as_deref())
|
||
.filter_map(normalize_required_string)
|
||
.collect::<Vec<_>>()
|
||
.join("\n");
|
||
let images = content
|
||
.iter()
|
||
.filter(|part| part.part_type == CreativeAgentInputPartType::InputImage)
|
||
.map(|part| CreativeImageSummary {
|
||
asset_id: part.asset_id.clone(),
|
||
read_url: part.image_url.clone(),
|
||
thumbnail_url: part.thumbnail_url.clone(),
|
||
width: None,
|
||
height: None,
|
||
summary: None,
|
||
})
|
||
.collect::<Vec<_>>();
|
||
|
||
CreativeInputSummary {
|
||
text: normalize_required_string(&text).or_else(|| previous.text.clone()),
|
||
entry_context: previous.entry_context.clone(),
|
||
images: if images.is_empty() {
|
||
previous.images.clone()
|
||
} else {
|
||
images
|
||
},
|
||
material_summary: normalize_required_string(&text)
|
||
.or_else(|| previous.material_summary.clone()),
|
||
unsupported_capabilities: unsupported_capabilities(),
|
||
}
|
||
}
|
||
|
||
fn unsupported_capabilities() -> Vec<CreativeUnsupportedCapability> {
|
||
[
|
||
(CreativeUnsupportedPlayType::Rpg, "RPG 世界"),
|
||
(CreativeUnsupportedPlayType::Match3d, "抓大鹅"),
|
||
(CreativeUnsupportedPlayType::BigFish, "大鱼吃小鱼"),
|
||
(CreativeUnsupportedPlayType::SquareHole, "方洞挑战"),
|
||
]
|
||
.into_iter()
|
||
.map(|(play_type, title)| CreativeUnsupportedCapability {
|
||
play_type,
|
||
title: title.to_string(),
|
||
status: CreativeCapabilityStatus::Unsupported,
|
||
reason: "Phase 1 只开放拼图模板".to_string(),
|
||
})
|
||
.collect()
|
||
}
|
||
|
||
fn normalize_message_content(
|
||
content: &[CreativeAgentInputPart],
|
||
) -> Result<(String, Vec<String>), AppError> {
|
||
let text = content
|
||
.iter()
|
||
.filter(|part| part.part_type == CreativeAgentInputPartType::InputText)
|
||
.filter_map(|part| part.text.as_deref())
|
||
.filter_map(normalize_required_string)
|
||
.collect::<Vec<_>>()
|
||
.join("\n");
|
||
let image_urls = content
|
||
.iter()
|
||
.filter(|part| part.part_type == CreativeAgentInputPartType::InputImage)
|
||
.filter_map(|part| part.image_url.as_deref())
|
||
.filter_map(normalize_required_string)
|
||
.collect::<Vec<_>>();
|
||
|
||
if image_urls.len() > 6 {
|
||
return Err(
|
||
AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({
|
||
"provider": CREATIVE_AGENT_PROVIDER,
|
||
"message": "图片数量超出 Phase 1 限制",
|
||
})),
|
||
);
|
||
}
|
||
|
||
Ok((text, image_urls))
|
||
}
|
||
|
||
fn build_creative_levels_from_selection(
|
||
selection: &PuzzleCreativeTemplateSelection,
|
||
input: &CreativeInputSummary,
|
||
) -> Vec<DomainCreativePuzzleLevelDraftInput> {
|
||
let reference = input
|
||
.images
|
||
.first()
|
||
.and_then(|image| image.read_url.clone().or_else(|| image.asset_id.clone()));
|
||
let description = build_picture_description_from_input(input);
|
||
(0..selection.planned_level_count.max(1))
|
||
.map(|index| DomainCreativePuzzleLevelDraftInput {
|
||
level_name: if selection.planned_level_count > 1 {
|
||
format!("第{}关", index + 1)
|
||
} else {
|
||
"第一关".to_string()
|
||
},
|
||
picture_description: description.clone(),
|
||
picture_reference: reference.clone(),
|
||
})
|
||
.collect()
|
||
}
|
||
|
||
fn build_work_title_from_input(input: &CreativeInputSummary) -> String {
|
||
input
|
||
.text
|
||
.as_deref()
|
||
.and_then(|text| {
|
||
let title = text
|
||
.chars()
|
||
.take(16)
|
||
.collect::<String>()
|
||
.trim_matches([',', '。', ',', '.', ' '])
|
||
.to_string();
|
||
normalize_required_string(title)
|
||
})
|
||
.unwrap_or_else(|| "创意拼图".to_string())
|
||
}
|
||
|
||
fn build_work_description_from_input(input: &CreativeInputSummary) -> String {
|
||
input
|
||
.text
|
||
.clone()
|
||
.or_else(|| input.material_summary.clone())
|
||
.unwrap_or_else(|| "根据图文素材生成的拼图作品。".to_string())
|
||
}
|
||
|
||
fn build_picture_description_from_input(input: &CreativeInputSummary) -> String {
|
||
input
|
||
.material_summary
|
||
.clone()
|
||
.or_else(|| input.text.clone())
|
||
.unwrap_or_else(|| "根据参考素材生成的拼图画面。".to_string())
|
||
}
|
||
|
||
fn build_work_tags_from_input(input: &CreativeInputSummary) -> Vec<String> {
|
||
let source = input.text.as_deref().unwrap_or_default();
|
||
let mut tags = vec!["创意".to_string(), "拼图".to_string(), "灵感".to_string()];
|
||
for tag in ["旅行", "家庭", "节日", "角色", "风景", "纪念"] {
|
||
if source.contains(tag) && !tags.iter().any(|item| item == tag) {
|
||
tags.push(tag.to_string());
|
||
}
|
||
}
|
||
tags.truncate(6);
|
||
tags
|
||
}
|
||
|
||
fn build_puzzle_form_seed_from_creative_draft(draft: &module_puzzle::PuzzleResultDraft) -> String {
|
||
let first_level = draft.levels.first();
|
||
[
|
||
("作品名称", normalize_required_string(&draft.work_title)),
|
||
(
|
||
"作品描述",
|
||
normalize_required_string(&draft.work_description),
|
||
),
|
||
(
|
||
"画面描述",
|
||
first_level.and_then(|level| normalize_required_string(&level.picture_description)),
|
||
),
|
||
]
|
||
.into_iter()
|
||
.filter_map(|(label, value)| value.map(|value| format!("{label}:{value}")))
|
||
.collect::<Vec<_>>()
|
||
.join("\n")
|
||
}
|
||
|
||
fn build_draft_edit_patch(
|
||
instruction: &str,
|
||
mut current_draft: Value,
|
||
) -> Result<(PuzzleDraftFieldPatch, Value), AppError> {
|
||
let (field_path, value, level_id) =
|
||
if instruction.contains("标题") || instruction.contains("名称") {
|
||
(
|
||
PuzzleDraftEditableFieldPath::WorkTitle,
|
||
json!(clean_instruction_value(instruction)),
|
||
None,
|
||
)
|
||
} else if instruction.contains("标签") {
|
||
(
|
||
PuzzleDraftEditableFieldPath::WorkTags,
|
||
json!(["创意", "拼图", "灵感"]),
|
||
None,
|
||
)
|
||
} else if instruction.contains("参考") {
|
||
(
|
||
PuzzleDraftEditableFieldPath::LevelPictureReference,
|
||
json!(clean_instruction_value(instruction)),
|
||
current_draft["levels"]
|
||
.as_array()
|
||
.and_then(|levels| levels.first())
|
||
.and_then(|level| level["levelId"].as_str())
|
||
.map(ToString::to_string),
|
||
)
|
||
} else {
|
||
(
|
||
PuzzleDraftEditableFieldPath::LevelPictureDescription,
|
||
json!(clean_instruction_value(instruction)),
|
||
current_draft["levels"]
|
||
.as_array()
|
||
.and_then(|levels| levels.first())
|
||
.and_then(|level| level["levelId"].as_str())
|
||
.map(ToString::to_string),
|
||
)
|
||
};
|
||
|
||
apply_patch_to_camel_draft(
|
||
&mut current_draft,
|
||
&field_path,
|
||
value.clone(),
|
||
level_id.as_deref(),
|
||
)?;
|
||
|
||
Ok((
|
||
PuzzleDraftFieldPatch {
|
||
field_path,
|
||
operation: PuzzleDraftFieldPatchOperation::Set,
|
||
level_id,
|
||
value,
|
||
rationale: "根据用户自然语言要求生成的 Phase 1 字段 patch".to_string(),
|
||
},
|
||
current_draft,
|
||
))
|
||
}
|
||
|
||
fn apply_patch_to_camel_draft(
|
||
draft: &mut Value,
|
||
field_path: &PuzzleDraftEditableFieldPath,
|
||
value: Value,
|
||
level_id: Option<&str>,
|
||
) -> Result<(), AppError> {
|
||
match field_path {
|
||
PuzzleDraftEditableFieldPath::WorkTitle => draft["workTitle"] = value,
|
||
PuzzleDraftEditableFieldPath::WorkDescription => {
|
||
draft["workDescription"] = value.clone();
|
||
draft["summary"] = value;
|
||
}
|
||
PuzzleDraftEditableFieldPath::WorkTags => draft["themeTags"] = value,
|
||
PuzzleDraftEditableFieldPath::LevelName
|
||
| PuzzleDraftEditableFieldPath::LevelPictureDescription
|
||
| PuzzleDraftEditableFieldPath::LevelPictureReference => {
|
||
let Some(levels) = draft["levels"].as_array_mut() else {
|
||
return Err(
|
||
AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({
|
||
"provider": CREATIVE_AGENT_PROVIDER,
|
||
"message": "currentDraft.levels is required",
|
||
})),
|
||
);
|
||
};
|
||
let level_index = level_id
|
||
.and_then(|target_id| {
|
||
levels
|
||
.iter()
|
||
.position(|level| level["levelId"].as_str() == Some(target_id))
|
||
})
|
||
.unwrap_or(0);
|
||
let Some(level) = levels.get_mut(level_index) else {
|
||
return Err(
|
||
AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({
|
||
"provider": CREATIVE_AGENT_PROVIDER,
|
||
"message": "currentDraft.levels is required",
|
||
})),
|
||
);
|
||
};
|
||
match field_path {
|
||
PuzzleDraftEditableFieldPath::LevelName => level["levelName"] = value,
|
||
PuzzleDraftEditableFieldPath::LevelPictureDescription => {
|
||
level["pictureDescription"] = value
|
||
}
|
||
PuzzleDraftEditableFieldPath::LevelPictureReference => {
|
||
level["pictureReference"] = value
|
||
}
|
||
_ => {}
|
||
}
|
||
}
|
||
}
|
||
Ok(())
|
||
}
|
||
|
||
fn build_puzzle_work_update_from_draft(
|
||
profile_id: String,
|
||
owner_user_id: String,
|
||
draft: &Value,
|
||
) -> Result<PuzzleWorkUpsertRecordInput, AppError> {
|
||
let levels = draft["levels"]
|
||
.as_array()
|
||
.ok_or_else(|| {
|
||
AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({
|
||
"provider": CREATIVE_AGENT_PROVIDER,
|
||
"message": "currentDraft.levels is required",
|
||
}))
|
||
})?
|
||
.iter()
|
||
.map(camel_level_to_module_json)
|
||
.collect::<Vec<_>>();
|
||
|
||
Ok(PuzzleWorkUpsertRecordInput {
|
||
profile_id,
|
||
owner_user_id,
|
||
work_title: draft["workTitle"]
|
||
.as_str()
|
||
.unwrap_or("创意拼图")
|
||
.to_string(),
|
||
work_description: draft["workDescription"]
|
||
.as_str()
|
||
.or_else(|| draft["summary"].as_str())
|
||
.unwrap_or("拼图作品")
|
||
.to_string(),
|
||
level_name: levels
|
||
.first()
|
||
.and_then(|level| level["level_name"].as_str())
|
||
.unwrap_or("第一关")
|
||
.to_string(),
|
||
summary: draft["summary"]
|
||
.as_str()
|
||
.or_else(|| draft["workDescription"].as_str())
|
||
.unwrap_or("拼图作品")
|
||
.to_string(),
|
||
theme_tags: draft["themeTags"]
|
||
.as_array()
|
||
.map(|tags| {
|
||
tags.iter()
|
||
.filter_map(|tag| tag.as_str().map(ToString::to_string))
|
||
.collect::<Vec<_>>()
|
||
})
|
||
.unwrap_or_else(|| vec!["创意".to_string(), "拼图".to_string(), "灵感".to_string()]),
|
||
cover_image_src: draft["coverImageSrc"].as_str().map(ToString::to_string),
|
||
cover_asset_id: draft["coverAssetId"].as_str().map(ToString::to_string),
|
||
levels_json: Some(serde_json::to_string(&levels).map_err(|error| {
|
||
AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({
|
||
"provider": CREATIVE_AGENT_PROVIDER,
|
||
"message": format!("拼图关卡序列化失败:{error}"),
|
||
}))
|
||
})?),
|
||
updated_at_micros: current_utc_micros(),
|
||
})
|
||
}
|
||
|
||
fn camel_level_to_module_json(level: &Value) -> Value {
|
||
json!({
|
||
"level_id": level["levelId"].as_str().unwrap_or("puzzle-level-1"),
|
||
"level_name": level["levelName"].as_str().unwrap_or("第一关"),
|
||
"picture_description": level["pictureDescription"].as_str().unwrap_or("拼图画面"),
|
||
"picture_reference": level["pictureReference"].as_str(),
|
||
"candidates": level["candidates"].as_array().cloned().unwrap_or_default().into_iter().map(|candidate| {
|
||
json!({
|
||
"candidate_id": candidate["candidateId"].as_str().unwrap_or("candidate-1"),
|
||
"image_src": candidate["imageSrc"].as_str().unwrap_or(""),
|
||
"asset_id": candidate["assetId"].as_str().unwrap_or(""),
|
||
"prompt": candidate["prompt"].as_str().unwrap_or(""),
|
||
"actual_prompt": candidate["actualPrompt"].as_str(),
|
||
"source_type": candidate["sourceType"].as_str().unwrap_or("generated"),
|
||
"selected": candidate["selected"].as_bool().unwrap_or(false),
|
||
})
|
||
}).collect::<Vec<_>>(),
|
||
"selected_candidate_id": level["selectedCandidateId"].as_str(),
|
||
"cover_image_src": level["coverImageSrc"].as_str(),
|
||
"cover_asset_id": level["coverAssetId"].as_str(),
|
||
"generation_status": level["generationStatus"].as_str().unwrap_or("idle"),
|
||
})
|
||
}
|
||
|
||
fn clean_instruction_value(instruction: &str) -> String {
|
||
instruction
|
||
.replace("把", "")
|
||
.replace("改成", "")
|
||
.replace("修改为", "")
|
||
.replace("标题", "")
|
||
.replace("名称", "")
|
||
.trim_matches([':', ':', ',', '。', ' '])
|
||
.trim()
|
||
.to_string()
|
||
}
|
||
|
||
fn parse_creative_stage_label(label: &str) -> Option<CreativeAgentStage> {
|
||
match label {
|
||
"perceiving" => Some(CreativeAgentStage::Perceiving),
|
||
"thinking" => Some(CreativeAgentStage::Thinking),
|
||
"remembering" => Some(CreativeAgentStage::Remembering),
|
||
"selecting_puzzle_template" => Some(CreativeAgentStage::SelectingPuzzleTemplate),
|
||
"waiting_template_confirmation" => Some(CreativeAgentStage::WaitingTemplateConfirmation),
|
||
"planning_puzzle_levels" => Some(CreativeAgentStage::PlanningPuzzleLevels),
|
||
"acting" => Some(CreativeAgentStage::Acting),
|
||
"reflecting" => Some(CreativeAgentStage::Reflecting),
|
||
"collaborating" => Some(CreativeAgentStage::Collaborating),
|
||
"target_ready" => Some(CreativeAgentStage::TargetReady),
|
||
_ => None,
|
||
}
|
||
}
|
||
|
||
fn from_domain_template_protocol(
|
||
template: DomainPuzzleTemplateProtocol,
|
||
) -> PuzzleCreativeTemplateProtocol {
|
||
PuzzleCreativeTemplateProtocol {
|
||
template_id: template.template_id,
|
||
title: template.title,
|
||
summary: template.summary,
|
||
preview_image_src: template.preview_image_src,
|
||
supported_level_mode: from_domain_supported_level_mode(template.supported_level_mode),
|
||
min_level_count: template.min_level_count,
|
||
max_level_count: template.max_level_count,
|
||
default_level_count: template.default_level_count,
|
||
cost_range: PuzzleTemplateCostRange {
|
||
min_points: template.cost_range.min_points,
|
||
max_points: template.cost_range.max_points,
|
||
pricing_unit: PuzzleTemplatePricingUnit::Point,
|
||
reason: template.cost_range.reason,
|
||
},
|
||
required_draft_fields: template
|
||
.required_draft_fields
|
||
.into_iter()
|
||
.map(from_domain_editable_field_path)
|
||
.collect(),
|
||
image_policy: from_domain_image_policy(template.image_policy),
|
||
}
|
||
}
|
||
|
||
fn from_domain_supported_level_mode(
|
||
mode: DomainPuzzleSupportedLevelMode,
|
||
) -> PuzzleSupportedLevelMode {
|
||
match mode {
|
||
DomainPuzzleSupportedLevelMode::Single => PuzzleSupportedLevelMode::Single,
|
||
DomainPuzzleSupportedLevelMode::Multi => PuzzleSupportedLevelMode::Multi,
|
||
DomainPuzzleSupportedLevelMode::SingleOrMulti => PuzzleSupportedLevelMode::SingleOrMulti,
|
||
}
|
||
}
|
||
|
||
fn from_domain_editable_field_path(
|
||
field_path: DomainPuzzleDraftEditableFieldPath,
|
||
) -> PuzzleDraftEditableFieldPath {
|
||
match field_path {
|
||
DomainPuzzleDraftEditableFieldPath::WorkTitle => PuzzleDraftEditableFieldPath::WorkTitle,
|
||
DomainPuzzleDraftEditableFieldPath::WorkDescription => {
|
||
PuzzleDraftEditableFieldPath::WorkDescription
|
||
}
|
||
DomainPuzzleDraftEditableFieldPath::WorkTags => PuzzleDraftEditableFieldPath::WorkTags,
|
||
DomainPuzzleDraftEditableFieldPath::LevelName => PuzzleDraftEditableFieldPath::LevelName,
|
||
DomainPuzzleDraftEditableFieldPath::LevelPictureDescription => {
|
||
PuzzleDraftEditableFieldPath::LevelPictureDescription
|
||
}
|
||
DomainPuzzleDraftEditableFieldPath::LevelPictureReference => {
|
||
PuzzleDraftEditableFieldPath::LevelPictureReference
|
||
}
|
||
}
|
||
}
|
||
|
||
fn from_domain_image_policy(
|
||
image_policy: DomainPuzzleImageGenerationPolicy,
|
||
) -> PuzzleTemplateImageGenerationPolicy {
|
||
PuzzleTemplateImageGenerationPolicy {
|
||
allow_uploaded_image_directly: image_policy.allow_uploaded_image_directly,
|
||
allow_generated_images: image_policy.allow_generated_images,
|
||
allow_per_level_reference_image: image_policy.allow_per_level_reference_image,
|
||
default_candidate_count_per_level: image_policy.default_candidate_count_per_level,
|
||
}
|
||
}
|
||
|
||
fn to_domain_template_selection(
|
||
selection: &PuzzleCreativeTemplateSelection,
|
||
) -> Result<DomainPuzzleTemplateSelection, module_puzzle::PuzzleFieldError> {
|
||
retrieve_puzzle_template_catalog()
|
||
.into_iter()
|
||
.find(|template| template.template_id == selection.template_id)
|
||
.ok_or(module_puzzle::PuzzleFieldError::InvalidOperation)?;
|
||
Ok(DomainPuzzleTemplateSelection {
|
||
template_id: selection.template_id.clone(),
|
||
title: selection.title.clone(),
|
||
reason: selection.reason.clone(),
|
||
cost_range: to_domain_cost_range(&selection.cost_range),
|
||
supported_level_mode: to_domain_supported_level_mode(&selection.supported_level_mode),
|
||
selected_level_mode: to_domain_level_generation_mode(&selection.selected_level_mode),
|
||
planned_level_count: selection.planned_level_count,
|
||
requires_user_confirmation: selection.requires_user_confirmation,
|
||
})
|
||
}
|
||
|
||
fn to_domain_cost_range(
|
||
cost_range: &PuzzleTemplateCostRange,
|
||
) -> module_puzzle::PuzzleCreativeCostRange {
|
||
module_puzzle::PuzzleCreativeCostRange {
|
||
min_points: cost_range.min_points,
|
||
max_points: cost_range.max_points,
|
||
pricing_unit: DomainPuzzlePricingUnit::Point,
|
||
reason: cost_range.reason.clone(),
|
||
}
|
||
}
|
||
|
||
fn to_domain_level_generation_mode(
|
||
mode: &PuzzleLevelGenerationMode,
|
||
) -> DomainPuzzleLevelGenerationMode {
|
||
match mode {
|
||
PuzzleLevelGenerationMode::SingleLevel => DomainPuzzleLevelGenerationMode::SingleLevel,
|
||
PuzzleLevelGenerationMode::MultiLevel => DomainPuzzleLevelGenerationMode::MultiLevel,
|
||
}
|
||
}
|
||
|
||
fn to_domain_supported_level_mode(
|
||
mode: &PuzzleSupportedLevelMode,
|
||
) -> DomainPuzzleSupportedLevelMode {
|
||
match mode {
|
||
PuzzleSupportedLevelMode::Single => DomainPuzzleSupportedLevelMode::Single,
|
||
PuzzleSupportedLevelMode::Multi => DomainPuzzleSupportedLevelMode::Multi,
|
||
PuzzleSupportedLevelMode::SingleOrMulti => DomainPuzzleSupportedLevelMode::SingleOrMulti,
|
||
}
|
||
}
|
||
|
||
fn from_domain_image_plan(
|
||
plan: module_puzzle::PuzzleImageGenerationPlan,
|
||
) -> PuzzleImageGenerationPlan {
|
||
PuzzleImageGenerationPlan {
|
||
mode: match plan.mode {
|
||
DomainPuzzleLevelGenerationMode::SingleLevel => PuzzleLevelGenerationMode::SingleLevel,
|
||
DomainPuzzleLevelGenerationMode::MultiLevel => PuzzleLevelGenerationMode::MultiLevel,
|
||
},
|
||
template_id: plan.template_id,
|
||
estimated_cost_range: PuzzleTemplateCostRange {
|
||
min_points: plan.estimated_cost_range.min_points,
|
||
max_points: plan.estimated_cost_range.max_points,
|
||
pricing_unit: PuzzleTemplatePricingUnit::Point,
|
||
reason: plan.estimated_cost_range.reason,
|
||
},
|
||
levels: plan
|
||
.levels
|
||
.into_iter()
|
||
.map(|level| PuzzleImageGenerationPlanLevel {
|
||
level_id: level.level_id,
|
||
level_name: level.level_name,
|
||
picture_description: level.picture_description,
|
||
image_prompt: level.image_prompt,
|
||
picture_reference: level.picture_reference,
|
||
candidate_count: level.candidate_count,
|
||
})
|
||
.collect(),
|
||
}
|
||
}
|
||
|
||
fn build_puzzle_result_profile_id(session_id: &str) -> String {
|
||
let suffix = session_id
|
||
.strip_prefix("puzzle-session-")
|
||
.unwrap_or(session_id);
|
||
format!("puzzle-profile-{suffix}")
|
||
}
|
||
|
||
fn build_creative_agent_system_prompt() -> &'static str {
|
||
"你是创意互动内容生成 Agent。当前只开放拼图模板;必须显式展示模板选择、选择理由和预计泥点范围,用户确认后才能创建草稿。"
|
||
}
|
||
|
||
fn map_puzzle_field_error(error: module_puzzle::PuzzleFieldError) -> AppError {
|
||
AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({
|
||
"provider": "module-puzzle",
|
||
"message": error.to_string(),
|
||
}))
|
||
}
|
||
|
||
fn map_spacetime_error(error: String, fallback: &str) -> AppError {
|
||
AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({
|
||
"provider": "spacetimedb",
|
||
"message": if error.trim().is_empty() { fallback.to_string() } else { error },
|
||
}))
|
||
}
|
||
|
||
fn ensure_non_empty(value: &str, _field_name: &str) -> Result<(), AppError> {
|
||
if value.trim().is_empty() {
|
||
return Err(AppError::from_status(StatusCode::BAD_REQUEST));
|
||
}
|
||
Ok(())
|
||
}
|
||
|
||
fn creative_bad_request(request_context: &RequestContext, message: &str) -> Response {
|
||
creative_error_response(
|
||
request_context,
|
||
AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({
|
||
"provider": CREATIVE_AGENT_PROVIDER,
|
||
"message": message,
|
||
})),
|
||
)
|
||
}
|
||
|
||
fn creative_not_found(request_context: &RequestContext, message: &str) -> Response {
|
||
creative_error_response(
|
||
request_context,
|
||
AppError::from_status(StatusCode::NOT_FOUND).with_details(json!({
|
||
"provider": CREATIVE_AGENT_PROVIDER,
|
||
"message": message,
|
||
})),
|
||
)
|
||
}
|
||
|
||
fn creative_error_response(request_context: &RequestContext, error: AppError) -> Response {
|
||
error.into_response_with_context(Some(request_context))
|
||
}
|
||
|
||
fn current_utc_micros() -> i64 {
|
||
let duration = SystemTime::now()
|
||
.duration_since(UNIX_EPOCH)
|
||
.unwrap_or_default();
|
||
i64::try_from(duration.as_micros()).unwrap_or(i64::MAX)
|
||
}
|
||
|
||
#[cfg(test)]
|
||
mod tests {
|
||
use super::*;
|
||
use module_puzzle::PUZZLE_PHASE1_TEMPLATE_ID;
|
||
|
||
fn cost_range() -> PuzzleTemplateCostRange {
|
||
PuzzleTemplateCostRange {
|
||
min_points: 2,
|
||
max_points: 12,
|
||
pricing_unit: PuzzleTemplatePricingUnit::Point,
|
||
reason: "按关卡数和每关图片生成次数估算,实际扣费以后端任务结算为准".to_string(),
|
||
}
|
||
}
|
||
|
||
#[test]
|
||
fn selection_maps_to_domain_and_rejects_non_puzzle_template() {
|
||
let mut selection = PuzzleCreativeTemplateSelection {
|
||
template_id: PUZZLE_PHASE1_TEMPLATE_ID.to_string(),
|
||
title: "创意拼图".to_string(),
|
||
reason: "适合拼图".to_string(),
|
||
cost_range: cost_range(),
|
||
supported_level_mode: PuzzleSupportedLevelMode::SingleOrMulti,
|
||
selected_level_mode: PuzzleLevelGenerationMode::SingleLevel,
|
||
planned_level_count: 1,
|
||
requires_user_confirmation: true,
|
||
};
|
||
|
||
assert!(to_domain_template_selection(&selection).is_ok());
|
||
selection.template_id = "rpg.unsupported".to_string();
|
||
assert!(to_domain_template_selection(&selection).is_err());
|
||
}
|
||
|
||
#[test]
|
||
fn selection_maps_catalog_subtemplate_to_domain() {
|
||
let selection = PuzzleCreativeTemplateSelection {
|
||
template_id: module_puzzle::PUZZLE_TRAVEL_MEMORY_TEMPLATE_ID.to_string(),
|
||
title: "旅行记忆拼图".to_string(),
|
||
reason: "适合旅行素材".to_string(),
|
||
cost_range: PuzzleTemplateCostRange {
|
||
min_points: 4,
|
||
max_points: 16,
|
||
pricing_unit: PuzzleTemplatePricingUnit::Point,
|
||
reason: "按旅行节点和每关图片生成次数估算,实际扣费以后端任务结算为准".to_string(),
|
||
},
|
||
supported_level_mode: PuzzleSupportedLevelMode::SingleOrMulti,
|
||
selected_level_mode: PuzzleLevelGenerationMode::MultiLevel,
|
||
planned_level_count: 3,
|
||
requires_user_confirmation: true,
|
||
};
|
||
|
||
let domain_selection =
|
||
to_domain_template_selection(&selection).expect("subtemplate should map");
|
||
|
||
assert_eq!(
|
||
domain_selection.template_id,
|
||
module_puzzle::PUZZLE_TRAVEL_MEMORY_TEMPLATE_ID
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn draft_edit_patch_only_updates_allowed_camel_field() {
|
||
let draft = json!({
|
||
"workTitle": "旧标题",
|
||
"workDescription": "旧描述",
|
||
"summary": "旧描述",
|
||
"themeTags": ["创意", "拼图", "灵感"],
|
||
"levels": [{
|
||
"levelId": "puzzle-level-1",
|
||
"levelName": "第一关",
|
||
"pictureDescription": "旧图面",
|
||
"generationStatus": "idle",
|
||
"candidates": []
|
||
}]
|
||
});
|
||
|
||
let (patch, next) =
|
||
build_draft_edit_patch("把标题改成轻松家庭拼图", draft).expect("patch should build");
|
||
|
||
assert_eq!(patch.field_path, PuzzleDraftEditableFieldPath::WorkTitle);
|
||
assert_eq!(next["workTitle"], json!("轻松家庭拼图"));
|
||
assert_eq!(next["levels"][0]["pictureDescription"], json!("旧图面"));
|
||
}
|
||
}
|