Files
Genarrative/server-rs/crates/api-server/src/visual_novel.rs
历冰郁-hermes版 3ad1075227
Some checks failed
CI / verify (push) Has been cancelled
feat: add work-level play tracking
2026-05-09 19:57:22 +08:00

1799 lines
66 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::{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<AppState>,
Extension(request_context): Extension<RequestContext>,
Extension(authenticated): Extension<AuthenticatedAccessToken>,
payload: Result<Json<contract::CreateVisualNovelSessionRequest>, JsonRejection>,
) -> Result<Json<Value>, 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<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")?;
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<AppState>,
Path(session_id): Path<String>,
Extension(request_context): Extension<RequestContext>,
Extension(authenticated): Extension<AuthenticatedAccessToken>,
payload: Result<Json<contract::SendVisualNovelMessageRequest>, JsonRejection>,
) -> Result<Json<Value>, 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<AppState>,
Path(session_id): Path<String>,
Extension(request_context): Extension<RequestContext>,
Extension(authenticated): Extension<AuthenticatedAccessToken>,
payload: Result<Json<contract::SendVisualNovelMessageRequest>, JsonRejection>,
) -> Result<Response, Response> {
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::<Event, Infallible>(sse_contract_event(&contract::VisualNovelAgentStreamEvent::Start {
session_id: session_id.clone(),
}));
yield Ok::<Event, Infallible>(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::<Event, Infallible>(sse_contract_event(&contract::VisualNovelAgentStreamEvent::TextDelta {
text: message.text.clone(),
}));
}
yield Ok::<Event, Infallible>(sse_contract_event(&contract::VisualNovelAgentStreamEvent::Complete { session }));
yield Ok::<Event, Infallible>(sse_contract_event(&contract::VisualNovelAgentStreamEvent::Done {}));
}
Err(_error) => {
yield Ok::<Event, Infallible>(sse_error_event("视觉小说流式创作失败".to_string()));
}
}
};
Ok(Sse::new(stream).into_response())
}
pub async fn execute_visual_novel_action(
State(state): State<AppState>,
Path(session_id): Path<String>,
Extension(request_context): Extension<RequestContext>,
Extension(authenticated): Extension<AuthenticatedAccessToken>,
payload: Result<Json<contract::ExecuteVisualNovelAgentActionRequest>, JsonRejection>,
) -> Result<Json<Value>, 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(&current, &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<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")?;
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<AppState>,
Extension(request_context): Extension<RequestContext>,
Extension(authenticated): Extension<AuthenticatedAccessToken>,
) -> Result<Json<Value>, 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::<Result<Vec<_>, _>>()
.map_err(|error| visual_novel_error_response(&request_context, error))?,
},
))
}
pub async fn get_visual_novel_work(
State(state): State<AppState>,
Path(profile_id): Path<String>,
Extension(request_context): Extension<RequestContext>,
Extension(authenticated): Extension<AuthenticatedAccessToken>,
) -> Result<Json<Value>, Response> {
ensure_non_empty(&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<AppState>,
Path(profile_id): Path<String>,
Extension(request_context): Extension<RequestContext>,
Extension(authenticated): Extension<AuthenticatedAccessToken>,
payload: Result<Json<contract::UpdateVisualNovelWorkRequest>, JsonRejection>,
) -> Result<Json<Value>, 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<AppState>,
Path(profile_id): Path<String>,
Extension(request_context): Extension<RequestContext>,
Extension(authenticated): Extension<AuthenticatedAccessToken>,
) -> Result<Json<Value>, 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::<Result<Vec<_>, _>>()
.map_err(|error| visual_novel_error_response(&request_context, error))?,
},
))
}
pub async fn publish_visual_novel_work(
State(state): State<AppState>,
Path(profile_id): Path<String>,
Extension(request_context): Extension<RequestContext>,
Extension(authenticated): Extension<AuthenticatedAccessToken>,
) -> Result<Json<Value>, Response> {
ensure_non_empty(&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<AppState>,
Extension(request_context): Extension<RequestContext>,
) -> Result<Json<Value>, 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::<Result<Vec<_>, _>>()
.map_err(|error| visual_novel_error_response(&request_context, error))?,
},
))
}
pub async fn start_visual_novel_run(
State(state): State<AppState>,
Path(profile_id): Path<String>,
Extension(request_context): Extension<RequestContext>,
Extension(authenticated): Extension<AuthenticatedAccessToken>,
payload: Result<Json<contract::VisualNovelStartRunRequest>, JsonRejection>,
) -> Result<Json<Value>, 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<AppState>,
Path(run_id): Path<String>,
Extension(request_context): Extension<RequestContext>,
Extension(authenticated): Extension<AuthenticatedAccessToken>,
) -> Result<Json<Value>, 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<AppState>,
Path(run_id): Path<String>,
Extension(request_context): Extension<RequestContext>,
Extension(authenticated): Extension<AuthenticatedAccessToken>,
payload: Result<Json<contract::VisualNovelRuntimeActionRequest>, JsonRejection>,
) -> Result<Response, Response> {
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::<Event, Infallible>(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::<Event, Infallible>(sse_contract_event(&contract::VisualNovelRuntimeStreamEvent::Step {
step: steps.first().cloned().unwrap_or(contract::VisualNovelRuntimeStep::Narration { text: "已推进视觉小说运行时。".to_string() }),
}));
yield Ok::<Event, Infallible>(sse_contract_event(&contract::VisualNovelRuntimeStreamEvent::Snapshot {
run: snapshot.clone(),
}));
yield Ok::<Event, Infallible>(sse_contract_event(&contract::VisualNovelRuntimeStreamEvent::Complete {
run: snapshot,
}));
yield Ok::<Event, Infallible>(sse_contract_event(&contract::VisualNovelRuntimeStreamEvent::Done {}));
}
Err(_error) => {
yield Ok::<Event, Infallible>(sse_error_event("视觉小说运行时推进失败".to_string()));
}
}
};
Ok(Sse::new(stream).into_response())
}
pub async fn list_visual_novel_history(
State(state): State<AppState>,
Path(run_id): Path<String>,
Extension(request_context): Extension<RequestContext>,
Extension(authenticated): Extension<AuthenticatedAccessToken>,
) -> Result<Json<Value>, Response> {
ensure_non_empty(&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::<Result<Vec<_>, _>>()
.map_err(|error| visual_novel_error_response(&request_context, error))?,
},
))
}
pub async fn regenerate_visual_novel_run(
State(state): State<AppState>,
Path(run_id): Path<String>,
Extension(request_context): Extension<RequestContext>,
Extension(authenticated): Extension<AuthenticatedAccessToken>,
payload: Result<Json<contract::VisualNovelRegenerateRequest>, JsonRejection>,
) -> Result<Json<Value>, 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<String>,
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<contract::VisualNovelRuntimeStep>,
),
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<domain::VisualNovelRuntimeStep>>(&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<Vec<contract::VisualNovelRuntimeStep>, 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::<Result<Vec<_>, _>>()
.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<contract::VisualNovelRunSnapshot, Response> {
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<String>,
history_entry_id: Option<String>,
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<contract::VisualNovelAgentSessionSnapshot, AppError> {
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::<Vec<_>>(),
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<contract::VisualNovelWorkSummary, AppError> {
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<contract::VisualNovelWorkDetail, AppError> {
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<contract::VisualNovelRunSnapshot, AppError> {
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::<Result<Vec<_>, _>>()?,
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<contract::VisualNovelHistoryEntry, AppError> {
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<DraftWorkProjection, AppError> {
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<String>,
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::<String>();
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<contract::VisualNovelRuntimeStep> {
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<domain::VisualNovelWorkProfile, Response> {
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<domain::VisualNovelRuntimeAction, Response> {
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<BTreeMap<String, contract::VisualNovelFlagValue>, AppError> {
parse_value(value)
}
fn value_to_f64_map(value: Value) -> Result<BTreeMap<String, f64>, AppError> {
parse_value(value)
}
fn parse_value<T>(value: Value) -> Result<T, AppError>
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<T>(value: &T) -> Result<String, AppError>
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<T>(value: T) -> Result<Value, AppError>
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<T, U>(value: T) -> Result<U, AppError>
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<T, U>(value: T) -> Result<U, AppError>
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<T>(
request_context: &RequestContext,
payload: Result<Json<T>, JsonRejection>,
) -> Result<Json<T>, 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<T>(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<contract::VisualNovelAgentSessionSnapshot, Response> {
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<String>,
) -> Result<contract::VisualNovelResultDraft, Response> {
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::<Vec<_>>();
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<contract::VisualNovelAgentSessionSnapshot, Response> {
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<String> {
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<contract::VisualNovelCompileResponse, Response> {
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::<contract::VisualNovelResultDraft>(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<contract::VisualNovelResultDraft, Response> {
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(),
))
}