1799 lines
66 KiB
Rust
1799 lines
66 KiB
Rust
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(¤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<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(),
|
||
))
|
||
}
|