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; type CreativeSseSender = mpsc::UnboundedSender; pub async fn create_creative_agent_session( State(state): State, Extension(request_context): Extension, Extension(authenticated): Extension, payload: Result, JsonRejection>, ) -> Result, 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, Path(session_id): Path, Extension(request_context): Extension, Extension(authenticated): Extension, ) -> Result, 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, Path(session_id): Path, Extension(request_context): Extension, Extension(authenticated): Extension, payload: Result, JsonRejection>, ) -> Result { 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::(); 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, 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::(); 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::>(); 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, 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::>() .join("、"); if template_titles.is_empty() { "正在整理拼图模板目录。".to_string() } else { format!("已整理出拼图模板目录:{template_titles}。") } } pub async fn confirm_creative_puzzle_template( State(state): State, Path(session_id): Path, Extension(request_context): Extension, Extension(authenticated): Extension, payload: Result, JsonRejection>, ) -> Result, 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, Path(session_id): Path, Extension(request_context): Extension, Extension(authenticated): Extension, payload: Result, JsonRejection>, ) -> Result { 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::(creative_sse_json_event( CreativeAgentSseEventType::Stage, CreativeAgentStageEvent { session_id: session.session_id.clone(), stage: CreativeAgentStage::Reflecting, }, )); yield Ok::(creative_sse_json_value_event( "draft_edit_result", serde_json::to_value(&result).unwrap_or_else(|_| json!({ "session": session })), )); yield Ok::(creative_sse_json_event( CreativeAgentSseEventType::Session, json!({ "editInstructions": result.edit_instructions, "session": result.session, "puzzleSession": result.puzzle_session, }), )); yield Ok::(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, Path(session_id): Path, Extension(request_context): Extension, Extension(authenticated): Extension, ) -> Result, 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::>() .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::>(); 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 { [ (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), 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::>() .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::>(); 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 { 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::() .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 { 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::>() .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 { 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::>(); 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::>() }) .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::>(), "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 { 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 { 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!("旧图面")); } }