Files
Genarrative/server-rs/crates/api-server/src/creative_agent.rs
2026-05-14 14:21:17 +08:00

1435 lines
54 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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!("旧图面"));
}
}