use std::{collections::BTreeMap, convert::Infallible}; use axum::{ Json, extract::{Extension, Path, State, rejection::JsonRejection}, http::StatusCode, response::{ IntoResponse, Response, sse::{Event, Sse}, }, }; use module_visual_novel as domain; use serde::{Serialize, de::DeserializeOwned}; use serde_json::{Value, json}; use shared_contracts::visual_novel as contract; use shared_kernel::{ build_prefixed_uuid_id, format_rfc3339, format_timestamp_micros, normalize_required_string, offset_datetime_to_unix_micros, }; use spacetime_client::{ SpacetimeClientError, VisualNovelAgentMessageFinalizeRecordInput, VisualNovelAgentMessageRecord, VisualNovelAgentMessageSubmitRecordInput, VisualNovelAgentSessionCreateRecordInput, VisualNovelAgentSessionRecord, VisualNovelHistoryEntryRecord, VisualNovelHistoryEntryRecordInput, VisualNovelRunRecord, VisualNovelRunSnapshotRecordInput, VisualNovelRunStartRecordInput, VisualNovelRuntimeEventRecordInput, VisualNovelWorkCompileRecordInput, VisualNovelWorkProfileRecord, VisualNovelWorkUpdateRecordInput, }; use time::OffsetDateTime; use crate::{ api_response::json_success_body, auth::AuthenticatedAccessToken, http_error::AppError, prompt::visual_novel as vn_prompt, request_context::RequestContext, state::AppState, work_author::resolve_work_author_by_user_id, work_play_tracking::{WorkPlayTrackingDraft, record_work_play_start_after_success}, }; const VISUAL_NOVEL_PROVIDER: &str = "visual-novel"; const VISUAL_NOVEL_RUNTIME_KIND: &str = "visual-novel"; const VISUAL_NOVEL_SESSION_ID_PREFIX: &str = "vn-session-"; const VISUAL_NOVEL_MESSAGE_ID_PREFIX: &str = "vn-message-"; const VISUAL_NOVEL_WORK_ID_PREFIX: &str = "vn-work-"; const VISUAL_NOVEL_EVENT_ID_PREFIX: &str = "vn-event-"; const VISUAL_NOVEL_DOCUMENT_SUMMARY_MAX_CHARS: usize = 4_000; pub async fn create_visual_novel_session( State(state): State, Extension(request_context): Extension, Extension(authenticated): Extension, payload: Result, JsonRejection>, ) -> Result, Response> { let Json(payload) = parse_json_payload(&request_context, payload)?; let owner_user_id = authenticated.claims().user_id().to_string(); let now_micros = current_utc_micros(); let seed_text = payload.seed_text.unwrap_or_default(); let source_mode = source_mode_to_wire(&payload.source_mode).to_string(); let session = state .spacetime_client() .create_visual_novel_agent_session(VisualNovelAgentSessionCreateRecordInput { session_id: build_prefixed_uuid_id(VISUAL_NOVEL_SESSION_ID_PREFIX), owner_user_id, source_mode, seed_text, source_asset_ids_json: to_json_string(&payload.source_asset_ids)?, welcome_message_id: build_prefixed_uuid_id(VISUAL_NOVEL_MESSAGE_ID_PREFIX), welcome_message_text: "已创建视觉小说创作会话。".to_string(), draft_json: None, created_at_micros: now_micros, }) .await .map_err(|error| { visual_novel_error_response(&request_context, map_spacetime_error(error)) })?; Ok(json_success_body( Some(&request_context), contract::VisualNovelSessionResponse { session: map_session_record(session)?, }, )) } pub async fn get_visual_novel_session( State(state): State, Path(session_id): Path, Extension(request_context): Extension, Extension(authenticated): Extension, ) -> Result, Response> { ensure_non_empty(&session_id, "sessionId")?; let session = state .spacetime_client() .get_visual_novel_agent_session(session_id, authenticated.claims().user_id().to_string()) .await .map_err(|error| { visual_novel_error_response(&request_context, map_spacetime_error(error)) })?; Ok(json_success_body( Some(&request_context), contract::VisualNovelSessionResponse { session: map_session_record(session)?, }, )) } pub async fn submit_visual_novel_message( State(state): State, Path(session_id): Path, Extension(request_context): Extension, Extension(authenticated): Extension, payload: Result, JsonRejection>, ) -> Result, Response> { let Json(payload) = parse_json_payload(&request_context, payload)?; let owner_user_id = authenticated.claims().user_id().to_string(); let session = submit_visual_novel_message_turn( &state, owner_user_id, session_id, payload.client_message_id, payload.text, ) .await?; Ok(json_success_body( Some(&request_context), contract::VisualNovelSessionResponse { session }, )) } pub async fn stream_visual_novel_message( State(state): State, Path(session_id): Path, Extension(request_context): Extension, Extension(authenticated): Extension, payload: Result, JsonRejection>, ) -> Result { let Json(payload) = parse_json_payload(&request_context, payload)?; ensure_non_empty(&session_id, "sessionId")?; ensure_non_empty(&payload.client_message_id, "clientMessageId")?; ensure_non_empty(&payload.text, "text")?; let owner_user_id = authenticated.claims().user_id().to_string(); let stream = async_stream::stream! { yield Ok::(sse_contract_event(&contract::VisualNovelAgentStreamEvent::Start { session_id: session_id.clone(), })); yield Ok::(sse_contract_event(&contract::VisualNovelAgentStreamEvent::Phase { phase: contract::VisualNovelAgentPhase::Perception, })); match submit_visual_novel_message_turn( &state, owner_user_id, session_id, payload.client_message_id, payload.text, ) .await { Ok(session) => { if let Some(message) = session.messages.last() { yield Ok::(sse_contract_event(&contract::VisualNovelAgentStreamEvent::TextDelta { text: message.text.clone(), })); } yield Ok::(sse_contract_event(&contract::VisualNovelAgentStreamEvent::Complete { session })); yield Ok::(sse_contract_event(&contract::VisualNovelAgentStreamEvent::Done {})); } Err(_error) => { yield Ok::(sse_error_event("视觉小说流式创作失败".to_string())); } } }; Ok(Sse::new(stream).into_response()) } pub async fn execute_visual_novel_action( State(state): State, Path(session_id): Path, Extension(request_context): Extension, Extension(authenticated): Extension, payload: Result, JsonRejection>, ) -> Result, Response> { let Json(payload) = parse_json_payload(&request_context, payload)?; ensure_non_empty(&session_id, "sessionId")?; let owner_user_id = authenticated.claims().user_id().to_string(); if payload.kind == contract::VisualNovelAgentActionKind::CompileWorkProfile { return compile_visual_novel_session_inner( &state, &request_context, owner_user_id, session_id, ) .await .map(|payload| json_success_body(Some(&request_context), payload)); } let current = state .spacetime_client() .get_visual_novel_agent_session(session_id.clone(), owner_user_id.clone()) .await .map_err(|error| { visual_novel_error_response(&request_context, map_spacetime_error(error)) })?; let draft = resolve_action_draft(¤t, &payload)?; let assistant_reply = if matches!( payload.kind, contract::VisualNovelAgentActionKind::GenerateDraft | contract::VisualNovelAgentActionKind::PatchWorld | contract::VisualNovelAgentActionKind::PatchCharacter | contract::VisualNovelAgentActionKind::PatchScene | contract::VisualNovelAgentActionKind::PatchStoryPhase ) { "视觉小说底稿已更新。" } else { "该视觉小说 action 已记录,后续资产生成由 VN-10 接入。" }; let finalized = finalize_creation_session(&state, owner_user_id, session_id, draft, assistant_reply) .await?; Ok(json_success_body( Some(&request_context), contract::VisualNovelSessionResponse { session: finalized }, )) } pub async fn compile_visual_novel_session( State(state): State, Path(session_id): Path, Extension(request_context): Extension, Extension(authenticated): Extension, ) -> Result, Response> { ensure_non_empty(&session_id, "sessionId")?; let payload = compile_visual_novel_session_inner( &state, &request_context, authenticated.claims().user_id().to_string(), session_id, ) .await?; Ok(json_success_body(Some(&request_context), payload)) } pub async fn list_visual_novel_works( State(state): State, Extension(request_context): Extension, Extension(authenticated): Extension, ) -> Result, Response> { let owner_user_id = authenticated.claims().user_id().to_string(); let works = state .spacetime_client() .list_visual_novel_works(owner_user_id) .await .map_err(|error| { visual_novel_error_response(&request_context, map_spacetime_error(error)) })?; Ok(json_success_body( Some(&request_context), contract::VisualNovelWorksResponse { works: works .into_iter() .map(map_work_summary) .collect::, _>>() .map_err(|error| visual_novel_error_response(&request_context, error))?, }, )) } pub async fn get_visual_novel_work( State(state): State, Path(profile_id): Path, Extension(request_context): Extension, Extension(authenticated): Extension, ) -> Result, Response> { ensure_non_empty(&profile_id, "profileId")?; let work = state .spacetime_client() .get_visual_novel_work_detail(profile_id, authenticated.claims().user_id().to_string()) .await .map_err(|error| { visual_novel_error_response(&request_context, map_spacetime_error(error)) })?; Ok(json_success_body( Some(&request_context), contract::VisualNovelWorkResponse { work: map_work_detail(&state, work)?, }, )) } pub async fn update_visual_novel_work( State(state): State, Path(profile_id): Path, Extension(request_context): Extension, Extension(authenticated): Extension, payload: Result, JsonRejection>, ) -> Result, Response> { let Json(payload) = parse_json_payload(&request_context, payload)?; ensure_non_empty(&profile_id, "profileId")?; let owner_user_id = authenticated.claims().user_id().to_string(); let mut draft = payload.draft; prepare_draft_for_session( &mut draft, Some(profile_id.clone()), current_utc_iso().as_str(), ); let projection = project_draft_for_work(&draft, &profile_id)?; state .spacetime_client() .update_visual_novel_work(VisualNovelWorkUpdateRecordInput { profile_id: profile_id.clone(), owner_user_id: owner_user_id.clone(), work_title: projection.work_title, work_description: projection.work_description, tags_json: projection.tags_json, cover_image_src: projection.cover_image_src, source_asset_ids_json: to_json_string(&draft.source_asset_ids)?, draft_json: to_json_string(&projection.draft)?, publish_ready: projection.publish_ready, updated_at_micros: current_utc_micros(), }) .await .map_err(|error| { visual_novel_error_response(&request_context, map_spacetime_error(error)) })?; let work = state .spacetime_client() .get_visual_novel_work_detail(profile_id, owner_user_id) .await .map_err(|error| { visual_novel_error_response(&request_context, map_spacetime_error(error)) })?; Ok(json_success_body( Some(&request_context), contract::VisualNovelWorkResponse { work: map_work_detail(&state, work)?, }, )) } pub async fn delete_visual_novel_work( State(state): State, Path(profile_id): Path, Extension(request_context): Extension, Extension(authenticated): Extension, ) -> Result, Response> { ensure_non_empty(&profile_id, "profileId")?; let works = state .spacetime_client() .delete_visual_novel_work(profile_id, authenticated.claims().user_id().to_string()) .await .map_err(|error| { visual_novel_error_response(&request_context, map_spacetime_error(error)) })?; Ok(json_success_body( Some(&request_context), contract::VisualNovelWorksResponse { works: works .into_iter() .map(map_work_summary) .collect::, _>>() .map_err(|error| visual_novel_error_response(&request_context, error))?, }, )) } pub async fn publish_visual_novel_work( State(state): State, Path(profile_id): Path, Extension(request_context): Extension, Extension(authenticated): Extension, ) -> Result, Response> { ensure_non_empty(&profile_id, "profileId")?; let work = state .spacetime_client() .publish_visual_novel_work( profile_id, authenticated.claims().user_id().to_string(), current_utc_micros(), ) .await .map_err(|error| { visual_novel_error_response(&request_context, map_spacetime_error(error)) })?; Ok(json_success_body( Some(&request_context), contract::VisualNovelWorkResponse { work: map_work_detail(&state, work)?, }, )) } pub async fn list_visual_novel_gallery( State(state): State, Extension(request_context): Extension, ) -> Result, Response> { let works = state .spacetime_client() .list_visual_novel_gallery() .await .map_err(|error| { visual_novel_error_response(&request_context, map_spacetime_error(error)) })?; Ok(json_success_body( Some(&request_context), contract::VisualNovelWorksResponse { works: works .into_iter() .map(map_work_summary) .collect::, _>>() .map_err(|error| visual_novel_error_response(&request_context, error))?, }, )) } pub async fn start_visual_novel_run( State(state): State, Path(profile_id): Path, Extension(request_context): Extension, Extension(authenticated): Extension, payload: Result, JsonRejection>, ) -> Result, Response> { let Json(payload) = parse_json_payload(&request_context, payload)?; ensure_non_empty(&profile_id, "profileId")?; if normalize_required_string(&payload.profile_id) .as_deref() .is_some_and(|value| value != profile_id) { return Err(visual_novel_bad_request( &request_context, "path 和 body 的 profileId 必须一致", )); } let run = state .spacetime_client() .start_visual_novel_run(VisualNovelRunStartRecordInput { run_id: build_prefixed_uuid_id(domain::VISUAL_NOVEL_RUN_ID_PREFIX), owner_user_id: authenticated.claims().user_id().to_string(), profile_id: profile_id.clone(), mode: run_mode_to_wire(&payload.mode).to_string(), snapshot_json: None, started_at_micros: current_utc_micros(), }) .await .map_err(|error| { visual_novel_error_response(&request_context, map_spacetime_error(error)) })?; record_work_play_start_after_success( &state, &request_context, WorkPlayTrackingDraft::new( "visual-novel", profile_id.clone(), &authenticated, "/api/runtime/visual-novel/...", ) .profile_id(profile_id.clone()) .extra(json!({ "mode": run_mode_to_wire(&payload.mode), "runId": run.run_id, })), ) .await; Ok(json_success_body( Some(&request_context), contract::VisualNovelRunResponse { run: map_run_record(run)?, }, )) } pub async fn get_visual_novel_run( State(state): State, Path(run_id): Path, Extension(request_context): Extension, Extension(authenticated): Extension, ) -> Result, Response> { ensure_non_empty(&run_id, "runId")?; let run = state .spacetime_client() .get_visual_novel_run(run_id, authenticated.claims().user_id().to_string()) .await .map_err(|error| { visual_novel_error_response(&request_context, map_spacetime_error(error)) })?; Ok(json_success_body( Some(&request_context), contract::VisualNovelRunResponse { run: map_run_record(run)?, }, )) } pub async fn stream_visual_novel_action( State(state): State, Path(run_id): Path, Extension(request_context): Extension, Extension(authenticated): Extension, payload: Result, JsonRejection>, ) -> Result { let Json(payload) = parse_json_payload(&request_context, payload)?; ensure_non_empty(&run_id, "runId")?; ensure_non_empty(&payload.client_event_id, "clientEventId")?; let owner_user_id = authenticated.claims().user_id().to_string(); let run = state .spacetime_client() .get_visual_novel_run(run_id.clone(), owner_user_id.clone()) .await .map_err(|error| { visual_novel_error_response(&request_context, map_spacetime_error(error)) })?; let work = state .spacetime_client() .get_visual_novel_work_detail(run.profile_id.clone(), owner_user_id.clone()) .await .map_err(|error| { visual_novel_error_response(&request_context, map_spacetime_error(error)) })?; let work = map_work_detail(&state, work)?; let stream = async_stream::stream! { yield Ok::(sse_contract_event(&contract::VisualNovelRuntimeStreamEvent::Start { run_id: run_id.clone(), })); match produce_runtime_turn(&state, &work, run, payload).await { Ok((snapshot, steps)) => { yield Ok::(sse_contract_event(&contract::VisualNovelRuntimeStreamEvent::Step { step: steps.first().cloned().unwrap_or(contract::VisualNovelRuntimeStep::Narration { text: "已推进视觉小说运行时。".to_string() }), })); yield Ok::(sse_contract_event(&contract::VisualNovelRuntimeStreamEvent::Snapshot { run: snapshot.clone(), })); yield Ok::(sse_contract_event(&contract::VisualNovelRuntimeStreamEvent::Complete { run: snapshot, })); yield Ok::(sse_contract_event(&contract::VisualNovelRuntimeStreamEvent::Done {})); } Err(_error) => { yield Ok::(sse_error_event("视觉小说运行时推进失败".to_string())); } } }; Ok(Sse::new(stream).into_response()) } pub async fn list_visual_novel_history( State(state): State, Path(run_id): Path, Extension(request_context): Extension, Extension(authenticated): Extension, ) -> Result, Response> { ensure_non_empty(&run_id, "runId")?; let history = state .spacetime_client() .list_visual_novel_runtime_history(run_id, authenticated.claims().user_id().to_string()) .await .map_err(|error| { visual_novel_error_response(&request_context, map_spacetime_error(error)) })?; Ok(json_success_body( Some(&request_context), contract::VisualNovelHistoryResponse { history: history .into_iter() .map(map_history_entry) .collect::, _>>() .map_err(|error| visual_novel_error_response(&request_context, error))?, }, )) } pub async fn regenerate_visual_novel_run( State(state): State, Path(run_id): Path, Extension(request_context): Extension, Extension(authenticated): Extension, payload: Result, JsonRejection>, ) -> Result, Response> { let Json(payload) = parse_json_payload(&request_context, payload)?; ensure_non_empty(&run_id, "runId")?; ensure_non_empty(&payload.history_entry_id, "historyEntryId")?; let owner_user_id = authenticated.claims().user_id().to_string(); let run = state .spacetime_client() .get_visual_novel_run(run_id.clone(), owner_user_id.clone()) .await .map_err(|error| { visual_novel_error_response(&request_context, map_spacetime_error(error)) })?; let work_record = state .spacetime_client() .get_visual_novel_work_detail(run.profile_id.clone(), owner_user_id.clone()) .await .map_err(|error| { visual_novel_error_response(&request_context, map_spacetime_error(error)) })?; let snapshot = map_run_record(run.clone())?; let work = map_work_detail(&state, work_record)?; let regenerated = domain::regenerate_from_history( &snapshot_to_domain_run(&snapshot), &payload.history_entry_id, work.draft.runtime_config.allow_history_regeneration, &format_timestamp_micros(current_utc_micros()), ) .map_err(|error| { visual_novel_error_response(&request_context, domain_error_to_app_error(error)) })?; let next = persist_runtime_snapshot(&state, owner_user_id, &run.profile_id, regenerated).await?; Ok(json_success_body( Some(&request_context), contract::VisualNovelRunResponse { run: next }, )) } #[derive(Clone, Debug)] struct DraftWorkProjection { work_title: String, work_description: String, tags_json: String, cover_image_src: Option, publish_ready: bool, draft: contract::VisualNovelResultDraft, } async fn produce_runtime_turn( state: &AppState, work: &contract::VisualNovelWorkDetail, run: VisualNovelRunRecord, payload: contract::VisualNovelRuntimeActionRequest, ) -> Result< ( contract::VisualNovelRunSnapshot, Vec, ), Response, > { let snapshot = map_run_record(run.clone())?; let profile = work_detail_to_domain_profile(work)?; let domain_snapshot = snapshot_to_domain_run(&snapshot); let action = runtime_request_to_domain_action(payload.clone())?; domain::build_runtime_prompt_context(&profile, &domain_snapshot, &action) .map_err(|error| domain_error_to_app_error(error).into_response())?; let steps = generate_runtime_steps(state, work, &snapshot, &payload).await?; let domain_steps = contract_to_domain::<_, Vec>(&steps) .map_err(IntoResponse::into_response)?; let history_entry_id = build_prefixed_uuid_id(domain::VISUAL_NOVEL_HISTORY_ID_PREFIX); let now = format_timestamp_micros(current_utc_micros()); let next = domain::apply_runtime_steps( &domain_snapshot, domain_steps.as_slice(), history_entry_id.as_str(), now.as_str(), ) .map_err(|error| domain_error_to_app_error(error).into_response())?; let history = next .history .last() .cloned() .ok_or_else(|| visual_novel_internal_error("运行时历史写入前快照缺少新增节点"))?; state .spacetime_client() .append_visual_novel_runtime_history_entry(VisualNovelHistoryEntryRecordInput { entry_id: history.entry_id.clone(), run_id: history.run_id.clone(), owner_user_id: next.owner_user_id.clone(), turn_index: history.turn_index, source: history_source_to_wire(&history.source).to_string(), action_text: history.action_text.clone(), steps_json: to_json_string(&steps).map_err(IntoResponse::into_response)?, snapshot_before_hash: history.snapshot_before_hash.clone(), snapshot_after_hash: history.snapshot_after_hash.clone(), created_at_micros: current_utc_micros(), }) .await .map_err(|error| map_spacetime_error(error).into_response())?; let next_profile_id = next.profile_id.clone(); let persisted = persist_runtime_snapshot(state, next.owner_user_id.clone(), &next_profile_id, next).await?; let _ = record_runtime_event( state, &persisted, "action", Some(payload.client_event_id), Some(history.entry_id), json!({ "runtimeKind": VISUAL_NOVEL_RUNTIME_KIND }), ) .await; Ok((persisted, steps)) } async fn generate_runtime_steps( state: &AppState, work: &contract::VisualNovelWorkDetail, snapshot: &contract::VisualNovelRunSnapshot, payload: &contract::VisualNovelRuntimeActionRequest, ) -> Result, Response> { if let Some(llm_client) = state.llm_client() { let request = vn_prompt::build_visual_novel_runtime_llm_request( vn_prompt::VisualNovelRuntimePromptParams { work_profile: &to_value(work).map_err(IntoResponse::into_response)?, run_snapshot: &to_value(snapshot).map_err(IntoResponse::into_response)?, runtime_action: &to_value(payload).map_err(IntoResponse::into_response)?, recent_history: &snapshot .history .iter() .rev() .take(6) .rev() .map(to_value) .collect::, _>>() .map_err(IntoResponse::into_response)?, max_assistant_step_count_per_turn: work .draft .runtime_config .max_assistant_step_count_per_turn, }, ); if let Ok(response) = llm_client.request_text(request).await { if let Ok(steps) = vn_prompt::parse_visual_novel_runtime_steps_fixture(response.content.as_str()) { return Ok(steps); } } } Ok(fallback_runtime_steps(work, payload)) } async fn persist_runtime_snapshot( state: &AppState, owner_user_id: String, profile_id: &str, snapshot: domain::VisualNovelRunSnapshot, ) -> Result { let contract_snapshot = domain_to_contract::<_, contract::VisualNovelRunSnapshot>(&snapshot) .map_err(IntoResponse::into_response)?; let persisted = state .spacetime_client() .upsert_visual_novel_run_snapshot(VisualNovelRunSnapshotRecordInput { run_id: snapshot.run_id, owner_user_id, status: run_status_to_wire(&snapshot.status).to_string(), current_scene_id: snapshot.current_scene_id, current_phase_id: snapshot.current_phase_id, visible_character_ids_json: to_json_string(&snapshot.visible_character_ids) .map_err(IntoResponse::into_response)?, flags_json: to_json_string(&contract_snapshot.flags) .map_err(IntoResponse::into_response)?, metrics_json: to_json_string(&contract_snapshot.metrics) .map_err(IntoResponse::into_response)?, available_choices_json: to_json_string(&contract_snapshot.available_choices) .map_err(IntoResponse::into_response)?, text_mode_enabled: snapshot.text_mode_enabled, snapshot_json: Some( to_json_string(&contract_snapshot).map_err(IntoResponse::into_response)?, ), updated_at_micros: current_utc_micros(), }) .await .map_err(|error| map_spacetime_error(error).into_response())?; let mut next = map_run_record(persisted)?; // 中文注释:procedure 返回 run 时 history 来自表;刚写入的 contract snapshot 保留更完整的本轮历史。 if next.history.is_empty() && !contract_snapshot.history.is_empty() { next.history = contract_snapshot.history; } if next.profile_id.trim().is_empty() { next.profile_id = profile_id.to_string(); } Ok(next) } async fn record_runtime_event( state: &AppState, snapshot: &contract::VisualNovelRunSnapshot, event_kind: &str, client_event_id: Option, history_entry_id: Option, payload: Value, ) -> Result<(), AppError> { state .spacetime_client() .record_visual_novel_runtime_event(VisualNovelRuntimeEventRecordInput { event_id: build_prefixed_uuid_id(VISUAL_NOVEL_EVENT_ID_PREFIX), run_id: snapshot.run_id.clone(), owner_user_id: snapshot.owner_user_id.clone(), profile_id: Some(snapshot.profile_id.clone()), event_kind: event_kind.to_string(), client_event_id, history_entry_id, payload_json: to_json_string(&payload)?, occurred_at_micros: current_utc_micros(), }) .await .map_err(map_spacetime_error)?; Ok(()) } fn map_session_record( record: VisualNovelAgentSessionRecord, ) -> Result { Ok(contract::VisualNovelAgentSessionSnapshot { session_id: record.session_id, owner_user_id: record.owner_user_id, source_mode: parse_source_mode(record.source_mode.as_str()), status: parse_agent_status(record.status.as_str()), messages: record .messages .into_iter() .map(map_agent_message) .collect::>(), draft: record.draft.map(parse_value).transpose()?, pending_action: record.pending_action.map(parse_value).transpose()?, created_at: record.created_at, updated_at: record.updated_at, }) } fn map_agent_message(record: VisualNovelAgentMessageRecord) -> contract::VisualNovelAgentMessage { contract::VisualNovelAgentMessage { id: record.message_id, role: parse_message_role(record.role.as_str()), kind: parse_message_kind(record.kind.as_str()), text: record.text, created_at: record.created_at, } } fn map_work_summary( record: VisualNovelWorkProfileRecord, ) -> Result { Ok(contract::VisualNovelWorkSummary { runtime_kind: VISUAL_NOVEL_RUNTIME_KIND.to_string(), profile_id: record.profile_id, owner_user_id: record.owner_user_id, title: record.work_title, description: record.work_description, cover_image_src: record.cover_image_src, tags: record.tags, publish_status: record.publication_status, publish_ready: record.publish_ready, play_count: record.play_count, updated_at: record.updated_at, published_at: record.published_at, }) } fn map_work_detail( state: &AppState, record: VisualNovelWorkProfileRecord, ) -> Result { let author = resolve_work_author_by_user_id( state, record.owner_user_id.as_str(), Some(record.author_display_name.as_str()), None, ); let summary = map_work_summary(record.clone())?; Ok(contract::VisualNovelWorkDetail { work_id: record.work_id, summary, source_session_id: record.source_session_id, author_display_name: author.display_name, source_asset_ids: record.source_asset_ids, draft: parse_value(record.draft)?, created_at: record.created_at, }) } fn map_run_record( record: VisualNovelRunRecord, ) -> Result { Ok(contract::VisualNovelRunSnapshot { run_id: record.run_id, owner_user_id: record.owner_user_id, profile_id: record.profile_id, mode: parse_run_mode(record.mode.as_str()), status: parse_run_status(record.status.as_str()), current_scene_id: record.current_scene_id, current_phase_id: record.current_phase_id, visible_character_ids: record.visible_character_ids, flags: value_to_map(record.flags)?, metrics: value_to_f64_map(record.metrics)?, history: record .history .into_iter() .map(map_history_entry) .collect::, _>>()?, available_choices: parse_value(record.available_choices)?, text_mode_enabled: record.text_mode_enabled, created_at: record.created_at, updated_at: record.updated_at, }) } fn map_history_entry( record: VisualNovelHistoryEntryRecord, ) -> Result { Ok(contract::VisualNovelHistoryEntry { entry_id: record.entry_id, run_id: record.run_id, turn_index: record.turn_index, source: parse_history_source(record.source.as_str()), action_text: record.action_text, steps: parse_value(record.steps)?, snapshot_before_hash: record.snapshot_before_hash, snapshot_after_hash: record.snapshot_after_hash, created_at: record.created_at, }) } fn project_draft_for_work( draft: &contract::VisualNovelResultDraft, profile_id: &str, ) -> Result { let mut projected = draft.clone(); projected.profile_id = Some(profile_id.to_string()); let domain_draft = contract_to_domain::<_, domain::VisualNovelResultDraft>(&projected)?; let profile = domain::compile_visual_novel_profile(&domain_draft).map_err(domain_error_to_app_error)?; let normalized_draft = domain_to_contract::<_, contract::VisualNovelResultDraft>(&profile.draft)?; Ok(DraftWorkProjection { work_title: profile.work_title, work_description: profile.work_description, tags_json: to_json_string(&profile.work_tags)?, cover_image_src: profile.cover_image_src, publish_ready: normalized_draft.publish_ready, draft: normalized_draft, }) } fn prepare_draft_for_session( draft: &mut contract::VisualNovelResultDraft, profile_id: Option, updated_at: &str, ) { if profile_id.is_some() { draft.profile_id = profile_id; } draft.updated_at = updated_at.to_string(); let issues = domain::validate_visual_novel_draft( &contract_to_domain::<_, domain::VisualNovelResultDraft>(draft.clone()) .unwrap_or_else(|_| fallback_domain_draft(updated_at)), ); draft.validation_issues = issues .into_iter() .filter_map(|issue| domain_to_contract(issue).ok()) .collect(); draft.publish_ready = draft.validation_issues.is_empty(); } fn fallback_result_draft( session: &VisualNovelAgentSessionRecord, latest_user_text: Option<&str>, updated_at: &str, ) -> contract::VisualNovelResultDraft { let seed = latest_user_text .and_then(normalize_required_string) .or_else(|| normalize_required_string(session.seed_text.as_str())) .unwrap_or_else(|| "一段尚未命名的视觉小说".to_string()); let title = seed.chars().take(14).collect::(); let mut draft = contract::VisualNovelResultDraft { profile_id: None, work_title: if title.is_empty() { "未命名视觉小说".to_string() } else { title }, work_description: format!("{seed}。"), work_tags: vec!["视觉小说".to_string()], cover_image_src: None, source_mode: parse_source_mode(session.source_mode.as_str()), source_asset_ids: session.source_asset_ids.clone(), world: contract::VisualNovelWorldDraft { title: "未命名世界".to_string(), summary: seed.clone(), background: "故事发生在一个等待创作者继续补完的世界。".to_string(), premise: seed.clone(), literary_style: "细腻、对话驱动".to_string(), player_role: "故事的亲历者".to_string(), default_tone: "温柔而带有悬念".to_string(), }, characters: vec![contract::VisualNovelCharacterDraft { character_id: "char-main-1".to_string(), name: "引路人".to_string(), gender: None, role: contract::VisualNovelCharacterRole::Main, appearance: "适合视觉小说半身立绘的神秘引路人。".to_string(), personality: "温和、谨慎,愿意引导玩家理解世界。".to_string(), tone: "轻声、克制。".to_string(), background: "与故事开端紧密相关的人物。".to_string(), relationship_to_player: "在开场与玩家相遇。".to_string(), image_assets: Vec::new(), default_expression: None, is_player_visible: false, }], scenes: vec![contract::VisualNovelSceneDraft { scene_id: "scene-opening".to_string(), name: "开场场景".to_string(), description: "适合生成视觉小说背景图的开场空间。".to_string(), background_image_src: None, music_src: None, ambient_sound_src: None, availability: contract::VisualNovelSceneAvailability::Opening, phase_ids: vec!["phase-opening".to_string()], }], story_phases: vec![contract::VisualNovelStoryPhaseDraft { phase_id: "phase-opening".to_string(), title: "故事开端".to_string(), goal: "让玩家理解当前处境。".to_string(), summary: seed.clone(), entry_condition: "opening".to_string(), exit_condition: "玩家做出第一轮选择。".to_string(), scene_ids: vec!["scene-opening".to_string()], character_ids: vec!["char-main-1".to_string()], suggested_choices: vec!["询问发生了什么".to_string(), "观察四周".to_string()], }], opening: contract::VisualNovelOpeningDraft { scene_id: Some("scene-opening".to_string()), narration: format!("{seed}。故事从这里开始。"), speaker_character_id: Some("char-main-1".to_string()), first_dialogue: Some("先别急,我们还有时间把一切讲清楚。".to_string()), initial_choices: vec![ contract::VisualNovelChoiceDraft { choice_id: "choice-opening-1".to_string(), text: "询问发生了什么".to_string(), action_hint: None, }, contract::VisualNovelChoiceDraft { choice_id: "choice-opening-2".to_string(), text: "观察四周".to_string(), action_hint: None, }, ], }, runtime_config: contract::VisualNovelRuntimeConfigDraft { text_mode_enabled: true, default_text_mode: false, max_history_entries: domain::VISUAL_NOVEL_DEFAULT_MAX_HISTORY_ENTRIES, max_assistant_step_count_per_turn: domain::VISUAL_NOVEL_DEFAULT_MAX_ASSISTANT_STEP_COUNT_PER_TURN, allow_free_text_action: true, allow_history_regeneration: true, attribute_panel_mode: contract::VisualNovelAttributePanelMode::Off, save_archive_enabled: true, }, publish_ready: false, validation_issues: Vec::new(), updated_at: updated_at.to_string(), }; prepare_draft_for_session(&mut draft, None, updated_at); draft } fn fallback_domain_draft(updated_at: &str) -> domain::VisualNovelResultDraft { domain::VisualNovelResultDraft { profile_id: Some("vn-profile-fallback".to_string()), work_title: "未命名视觉小说".to_string(), work_description: "视觉小说草稿".to_string(), work_tags: vec!["视觉小说".to_string()], cover_image_src: None, source_mode: domain::VisualNovelSourceMode::Idea, source_asset_ids: Vec::new(), world: domain::VisualNovelWorldDraft { title: "未命名世界".to_string(), summary: "视觉小说草稿".to_string(), background: "待补完。".to_string(), premise: "待补完。".to_string(), literary_style: "对话驱动".to_string(), player_role: "亲历者".to_string(), default_tone: "温柔".to_string(), }, characters: Vec::new(), scenes: Vec::new(), story_phases: Vec::new(), opening: domain::VisualNovelOpeningDraft { scene_id: None, narration: "故事从这里开始。".to_string(), speaker_character_id: None, first_dialogue: None, initial_choices: Vec::new(), }, runtime_config: domain::VisualNovelRuntimeConfigDraft::default(), publish_ready: false, validation_issues: Vec::new(), updated_at: updated_at.to_string(), } } fn fallback_runtime_steps( work: &contract::VisualNovelWorkDetail, payload: &contract::VisualNovelRuntimeActionRequest, ) -> Vec { let action_text = payload .text .as_deref() .and_then(normalize_required_string) .or_else(|| { payload.choice_id.as_ref().and_then(|choice_id| { work.draft .opening .initial_choices .iter() .find(|choice| choice.choice_id == *choice_id) .map(|choice| choice.text.clone()) }) }) .unwrap_or_else(|| "继续".to_string()); let character = work.draft.characters.first(); let character_id = character .map(|character| character.character_id.clone()) .unwrap_or_else(|| "char-main-1".to_string()); let character_name = character .map(|character| character.name.clone()) .unwrap_or_else(|| "引路人".to_string()); vec![ contract::VisualNovelRuntimeStep::Narration { text: format!("你选择了:{action_text}。"), }, contract::VisualNovelRuntimeStep::Dialogue { character_id, character_name, expression: None, text: "故事继续向前推进。".to_string(), }, contract::VisualNovelRuntimeStep::Choice { choices: vec![ contract::VisualNovelChoiceDraft { choice_id: build_prefixed_uuid_id("choice-vn-"), text: "继续追问".to_string(), action_hint: None, }, contract::VisualNovelChoiceDraft { choice_id: build_prefixed_uuid_id("choice-vn-"), text: "观察变化".to_string(), action_hint: None, }, ], }, ] } fn work_detail_to_domain_profile( work: &contract::VisualNovelWorkDetail, ) -> Result { Ok(domain::VisualNovelWorkProfile { profile_id: work.summary.profile_id.clone(), work_title: work.summary.title.clone(), work_description: work.summary.description.clone(), work_tags: work.summary.tags.clone(), cover_image_src: work.summary.cover_image_src.clone(), source_mode: contract_to_domain(work.draft.source_mode.clone()) .map_err(IntoResponse::into_response)?, draft: contract_to_domain(work.draft.clone()).map_err(IntoResponse::into_response)?, }) } fn snapshot_to_domain_run( snapshot: &contract::VisualNovelRunSnapshot, ) -> domain::VisualNovelRunSnapshot { contract_to_domain(snapshot.clone()).unwrap_or_else(|_| domain::VisualNovelRunSnapshot { run_id: snapshot.run_id.clone(), owner_user_id: snapshot.owner_user_id.clone(), profile_id: snapshot.profile_id.clone(), mode: domain::VisualNovelRunMode::Test, status: domain::VisualNovelRunStatus::Active, current_scene_id: snapshot.current_scene_id.clone(), current_phase_id: snapshot.current_phase_id.clone(), visible_character_ids: snapshot.visible_character_ids.clone(), flags: BTreeMap::new(), metrics: BTreeMap::new(), history: Vec::new(), available_choices: Vec::new(), text_mode_enabled: snapshot.text_mode_enabled, created_at: snapshot.created_at.clone(), updated_at: snapshot.updated_at.clone(), }) } fn runtime_request_to_domain_action( payload: contract::VisualNovelRuntimeActionRequest, ) -> Result { contract_to_domain(payload).map_err(IntoResponse::into_response) } fn source_mode_to_wire(value: &contract::VisualNovelSourceMode) -> &'static str { match value { contract::VisualNovelSourceMode::Idea => "idea", contract::VisualNovelSourceMode::Document => "document", contract::VisualNovelSourceMode::Blank => "blank", } } fn run_mode_to_wire(value: &contract::VisualNovelRunMode) -> &'static str { match value { contract::VisualNovelRunMode::Test => "test", contract::VisualNovelRunMode::Play => "play", } } fn run_status_to_wire(value: &domain::VisualNovelRunStatus) -> &'static str { match value { domain::VisualNovelRunStatus::Active => "active", domain::VisualNovelRunStatus::Completed => "completed", domain::VisualNovelRunStatus::Failed => "failed", } } fn history_source_to_wire(value: &domain::VisualNovelHistorySource) -> &'static str { match value { domain::VisualNovelHistorySource::Player => "player", domain::VisualNovelHistorySource::Assistant => "assistant", domain::VisualNovelHistorySource::System => "system", } } fn parse_source_mode(value: &str) -> contract::VisualNovelSourceMode { match value { "document" => contract::VisualNovelSourceMode::Document, "blank" => contract::VisualNovelSourceMode::Blank, _ => contract::VisualNovelSourceMode::Idea, } } fn parse_agent_status(value: &str) -> contract::VisualNovelAgentStatus { match value { "drafting" => contract::VisualNovelAgentStatus::Drafting, "ready" => contract::VisualNovelAgentStatus::Ready, "failed" => contract::VisualNovelAgentStatus::Failed, _ => contract::VisualNovelAgentStatus::Collecting, } } fn parse_message_role(value: &str) -> contract::VisualNovelAgentMessageRole { match value { "assistant" => contract::VisualNovelAgentMessageRole::Assistant, "system" => contract::VisualNovelAgentMessageRole::System, _ => contract::VisualNovelAgentMessageRole::User, } } fn parse_message_kind(value: &str) -> contract::VisualNovelAgentMessageKind { match value { "summary" => contract::VisualNovelAgentMessageKind::Summary, "action_result" => contract::VisualNovelAgentMessageKind::ActionResult, "warning" => contract::VisualNovelAgentMessageKind::Warning, _ => contract::VisualNovelAgentMessageKind::Chat, } } fn parse_run_mode(value: &str) -> contract::VisualNovelRunMode { match value { "play" => contract::VisualNovelRunMode::Play, _ => contract::VisualNovelRunMode::Test, } } fn parse_run_status(value: &str) -> contract::VisualNovelRunStatus { match value { "completed" => contract::VisualNovelRunStatus::Completed, "failed" => contract::VisualNovelRunStatus::Failed, _ => contract::VisualNovelRunStatus::Active, } } fn parse_history_source(value: &str) -> contract::VisualNovelHistorySource { match value { "assistant" => contract::VisualNovelHistorySource::Assistant, "system" => contract::VisualNovelHistorySource::System, _ => contract::VisualNovelHistorySource::Player, } } fn message_record_to_prompt_value(record: &VisualNovelAgentMessageRecord) -> Value { json!({ "role": record.role, "kind": record.kind, "text": record.text, "createdAt": record.created_at, }) } fn value_to_map( value: Value, ) -> Result, AppError> { parse_value(value) } fn value_to_f64_map(value: Value) -> Result, AppError> { parse_value(value) } fn parse_value(value: Value) -> Result where T: DeserializeOwned, { serde_json::from_value(value).map_err(|error| { AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({ "provider": VISUAL_NOVEL_PROVIDER, "message": format!("视觉小说数据结构解析失败:{error}"), })) }) } fn to_json_string(value: &T) -> Result where T: Serialize, { serde_json::to_string(value).map_err(|error| { AppError::from_status(StatusCode::INTERNAL_SERVER_ERROR).with_details(json!({ "provider": VISUAL_NOVEL_PROVIDER, "message": format!("视觉小说 JSON 序列化失败:{error}"), })) }) } fn to_value(value: T) -> Result where T: Serialize, { serde_json::to_value(value).map_err(|error| { AppError::from_status(StatusCode::INTERNAL_SERVER_ERROR).with_details(json!({ "provider": VISUAL_NOVEL_PROVIDER, "message": format!("视觉小说 JSON 序列化失败:{error}"), })) }) } fn contract_to_domain(value: T) -> Result where T: Serialize, U: DeserializeOwned, { serde_json::to_value(value) .and_then(serde_json::from_value) .map_err(|error| { AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({ "provider": VISUAL_NOVEL_PROVIDER, "message": format!("视觉小说契约到领域模型转换失败:{error}"), })) }) } fn domain_to_contract(value: T) -> Result where T: Serialize, U: DeserializeOwned, { serde_json::to_value(value) .and_then(serde_json::from_value) .map_err(|error| { AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({ "provider": VISUAL_NOVEL_PROVIDER, "message": format!("视觉小说领域模型到契约转换失败:{error}"), })) }) } fn parse_json_payload( request_context: &RequestContext, payload: Result, JsonRejection>, ) -> Result, Response> { payload.map_err(|error| { visual_novel_error_response( request_context, AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({ "provider": VISUAL_NOVEL_PROVIDER, "message": error.body_text(), })), ) }) } fn ensure_non_empty(value: &str, label: &str) -> Result<(), Response> { if normalize_required_string(value).is_some() { Ok(()) } else { Err(AppError::from_status(StatusCode::BAD_REQUEST) .with_details(json!({ "provider": VISUAL_NOVEL_PROVIDER, "message": format!("{label} 不能为空"), })) .into_response()) } } fn visual_novel_bad_request(request_context: &RequestContext, message: &str) -> Response { visual_novel_error_response( request_context, AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({ "provider": VISUAL_NOVEL_PROVIDER, "message": message, })), ) } fn visual_novel_internal_error(message: &str) -> Response { AppError::from_status(StatusCode::INTERNAL_SERVER_ERROR) .with_details(json!({ "provider": VISUAL_NOVEL_PROVIDER, "message": message, })) .into_response() } fn visual_novel_error_response(request_context: &RequestContext, error: AppError) -> Response { error.into_response_with_context(Some(request_context)) } fn map_spacetime_error(error: SpacetimeClientError) -> AppError { let status = match &error { SpacetimeClientError::Runtime(_) => StatusCode::BAD_REQUEST, SpacetimeClientError::Procedure(message) if message.contains("不存在") || message.contains("not found") || message.contains("Not found") => { StatusCode::NOT_FOUND } SpacetimeClientError::Procedure(message) if message.contains("无权") || message.contains("forbidden") || message.contains("Forbidden") => { StatusCode::FORBIDDEN } SpacetimeClientError::Timeout => StatusCode::GATEWAY_TIMEOUT, SpacetimeClientError::ConnectDropped => StatusCode::SERVICE_UNAVAILABLE, _ => StatusCode::BAD_GATEWAY, }; AppError::from_status(status).with_details(json!({ "provider": VISUAL_NOVEL_PROVIDER, "message": error.to_string(), })) } fn domain_error_to_app_error(error: domain::VisualNovelDomainError) -> AppError { AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({ "provider": VISUAL_NOVEL_PROVIDER, "message": error.to_string(), })) } fn sse_contract_event(payload: &T) -> Event where T: Serialize, { Event::default().json_data(payload).unwrap_or_else(|_| { Event::default() .event("error") .data("{\"type\":\"error\",\"message\":\"SSE payload 序列化失败\",\"retryable\":false}") }) } fn sse_error_event(message: String) -> Event { sse_contract_event(&contract::VisualNovelRuntimeStreamEvent::Error { message, retryable: true, }) } fn current_utc_micros() -> i64 { offset_datetime_to_unix_micros(OffsetDateTime::now_utc()) } fn current_utc_iso() -> String { format_rfc3339(OffsetDateTime::now_utc()) .unwrap_or_else(|_| format_timestamp_micros(current_utc_micros())) } #[cfg(test)] mod tests { use super::*; #[test] fn fallback_draft_is_publishable_enough_for_creation_preview() { let record = VisualNovelAgentSessionRecord { session_id: "vn-session-test".to_string(), owner_user_id: "user-test".to_string(), source_mode: "idea".to_string(), status: "collecting".to_string(), seed_text: "雨夜书店".to_string(), source_asset_ids: Vec::new(), current_turn: 0, progress_percent: 0, messages: Vec::new(), draft: None, pending_action: None, last_assistant_reply: None, published_profile_id: None, created_at: "0.000000Z".to_string(), updated_at: "0.000000Z".to_string(), }; let draft = fallback_result_draft(&record, None, "2026-05-05T00:00:00Z"); assert_eq!(draft.source_mode, contract::VisualNovelSourceMode::Idea); assert_eq!(draft.characters.len(), 1); assert_eq!(draft.opening.initial_choices.len(), 2); assert!(draft.publish_ready); } #[test] fn visual_novel_runtime_kind_uses_platform_archive_namespace() { assert_eq!(VISUAL_NOVEL_RUNTIME_KIND, "visual-novel"); } #[test] fn document_creation_prompt_uses_bounded_summary() { let record = VisualNovelAgentSessionRecord { session_id: "vn-session-doc".to_string(), owner_user_id: "user-test".to_string(), source_mode: "document".to_string(), status: "collecting".to_string(), seed_text: "旧书店".repeat(2_000), source_asset_ids: vec!["asset-doc-1".to_string()], current_turn: 0, progress_percent: 0, messages: Vec::new(), draft: None, pending_action: None, last_assistant_reply: None, published_profile_id: None, created_at: "0.000000Z".to_string(), updated_at: "0.000000Z".to_string(), }; let summary = resolve_document_summary_for_prompt(&record, None) .expect("document session should build summary"); assert_eq!( summary.chars().count(), VISUAL_NOVEL_DOCUMENT_SUMMARY_MAX_CHARS ); assert!(summary.contains("旧书店")); } #[test] fn idea_creation_prompt_omits_document_summary() { let record = VisualNovelAgentSessionRecord { session_id: "vn-session-idea".to_string(), owner_user_id: "user-test".to_string(), source_mode: "idea".to_string(), status: "collecting".to_string(), seed_text: "雨夜书店".to_string(), source_asset_ids: Vec::new(), current_turn: 0, progress_percent: 0, messages: Vec::new(), draft: None, pending_action: None, last_assistant_reply: None, published_profile_id: None, created_at: "0.000000Z".to_string(), updated_at: "0.000000Z".to_string(), }; assert!(resolve_document_summary_for_prompt(&record, None).is_none()); } } async fn submit_visual_novel_message_turn( state: &AppState, owner_user_id: String, session_id: String, client_message_id: String, text: String, ) -> Result { ensure_non_empty(&session_id, "sessionId")?; ensure_non_empty(&client_message_id, "clientMessageId")?; ensure_non_empty(&text, "text")?; let submitted = state .spacetime_client() .submit_visual_novel_agent_message(VisualNovelAgentMessageSubmitRecordInput { session_id: session_id.clone(), owner_user_id: owner_user_id.clone(), user_message_id: client_message_id, user_message_text: text.clone(), submitted_at_micros: current_utc_micros(), }) .await .map_err(|error| map_spacetime_error(error).into_response())?; let draft = create_or_update_creation_draft(state, &submitted, Some(text)).await?; finalize_creation_session( state, owner_user_id, session_id, draft, "已根据输入更新视觉小说底稿。", ) .await } async fn create_or_update_creation_draft( state: &AppState, session: &VisualNovelAgentSessionRecord, latest_user_text: Option, ) -> Result { let now_iso = current_utc_iso(); let document_summary = resolve_document_summary_for_prompt(session, latest_user_text.as_deref()); if let Some(llm_client) = state.llm_client() { let current_draft = session.draft.as_ref(); let recent_messages = session .messages .iter() .rev() .take(8) .rev() .map(message_record_to_prompt_value) .collect::>(); let request = vn_prompt::build_visual_novel_creation_llm_request( vn_prompt::VisualNovelCreationPromptParams { source_mode: session.source_mode.as_str(), seed_text: latest_user_text .as_deref() .or_else(|| Some(session.seed_text.as_str())), source_asset_ids: &session.source_asset_ids, document_summary: document_summary.as_deref(), current_draft, recent_messages: &recent_messages, now_iso: now_iso.as_str(), }, false, ); if let Ok(response) = llm_client.request_text(request).await { if let Ok(mut draft) = vn_prompt::parse_visual_novel_result_draft_fixture(response.content.as_str()) { prepare_draft_for_session(&mut draft, None, &now_iso); return Ok(draft); } } } Ok(fallback_result_draft( session, latest_user_text.as_deref(), now_iso.as_str(), )) } async fn finalize_creation_session( state: &AppState, owner_user_id: String, session_id: String, draft: contract::VisualNovelResultDraft, assistant_reply: &str, ) -> Result { let finalized = state .spacetime_client() .finalize_visual_novel_agent_message(VisualNovelAgentMessageFinalizeRecordInput { session_id, owner_user_id, assistant_message_id: Some(build_prefixed_uuid_id(VISUAL_NOVEL_MESSAGE_ID_PREFIX)), assistant_reply_text: Some(assistant_reply.to_string()), draft_json: Some(to_json_string(&draft).map_err(IntoResponse::into_response)?), pending_action_json: None, status: "ready".to_string(), progress_percent: 70, updated_at_micros: current_utc_micros(), error_message: None, }) .await .map_err(|error| map_spacetime_error(error).into_response())?; map_session_record(finalized).map_err(IntoResponse::into_response) } fn resolve_document_summary_for_prompt( session: &VisualNovelAgentSessionRecord, latest_user_text: Option<&str>, ) -> Option { if session.source_mode.as_str() != "document" { return None; } let source = latest_user_text .map(str::trim) .filter(|value| !value.is_empty()) .or_else(|| { let seed_text = session.seed_text.trim(); (!seed_text.is_empty()).then_some(seed_text) })?; Some( source .chars() .take(VISUAL_NOVEL_DOCUMENT_SUMMARY_MAX_CHARS) .collect(), ) } async fn compile_visual_novel_session_inner( state: &AppState, request_context: &RequestContext, owner_user_id: String, session_id: String, ) -> Result { let session = state .spacetime_client() .get_visual_novel_agent_session(session_id.clone(), owner_user_id.clone()) .await .map_err(|error| { visual_novel_error_response(request_context, map_spacetime_error(error)) })?; let draft = session.draft.clone().ok_or_else(|| { visual_novel_bad_request(request_context, "视觉小说 session 尚未生成底稿") })?; let mut draft = parse_value::(draft)?; let profile_id = draft .profile_id .clone() .unwrap_or_else(|| build_prefixed_uuid_id(domain::VISUAL_NOVEL_PROFILE_ID_PREFIX)); prepare_draft_for_session( &mut draft, Some(profile_id.clone()), current_utc_iso().as_str(), ); let projection = project_draft_for_work(&draft, &profile_id)?; let author = resolve_work_author_by_user_id(state, &owner_user_id, None, None); let compiled_session = state .spacetime_client() .compile_visual_novel_work_profile(VisualNovelWorkCompileRecordInput { session_id: session_id.clone(), owner_user_id: owner_user_id.clone(), profile_id: profile_id.clone(), work_id: Some(build_prefixed_uuid_id(VISUAL_NOVEL_WORK_ID_PREFIX)), author_display_name: author.display_name, work_title: Some(projection.work_title), work_description: Some(projection.work_description), tags_json: Some(projection.tags_json), cover_image_src: projection.cover_image_src, compiled_at_micros: current_utc_micros(), }) .await .map_err(|error| { visual_novel_error_response(request_context, map_spacetime_error(error)) })?; let work = state .spacetime_client() .get_visual_novel_work_detail(profile_id, owner_user_id) .await .map_err(|error| { visual_novel_error_response(request_context, map_spacetime_error(error)) })?; Ok(contract::VisualNovelCompileResponse { session: map_session_record(compiled_session)?, work: map_work_detail(state, work)?, }) } fn resolve_action_draft( session: &VisualNovelAgentSessionRecord, payload: &contract::ExecuteVisualNovelAgentActionRequest, ) -> Result { if let Some(draft_value) = payload .payload .as_ref() .and_then(|payload| payload.get("draft").cloned()) { return parse_value(draft_value).map_err(IntoResponse::into_response); } if let Some(draft) = session.draft.clone() { return parse_value(draft).map_err(IntoResponse::into_response); } Ok(fallback_result_draft( session, session.seed_text.as_str().into(), current_utc_iso().as_str(), )) }