This commit is contained in:
2026-04-25 22:19:04 +08:00
parent 2ebfd1cf55
commit 8404081d7b
149 changed files with 10508 additions and 2732 deletions

View File

@@ -45,6 +45,7 @@ use crate::{
character_visual_assets::{
generate_character_visual, get_character_visual_job, publish_character_visual,
},
creation_agent_document_input::parse_creation_agent_document_input,
custom_world::{
create_custom_world_agent_session, delete_custom_world_agent_session,
delete_custom_world_library_profile, execute_custom_world_agent_action,
@@ -91,7 +92,10 @@ use crate::{
},
runtime_chat::stream_runtime_npc_chat_turn,
runtime_inventory::get_runtime_inventory_state,
runtime_profile::{get_profile_dashboard, get_profile_play_stats, get_profile_wallet_ledger},
runtime_profile::{
create_profile_recharge_order, get_profile_dashboard, get_profile_play_stats,
get_profile_recharge_center, get_profile_wallet_ledger,
},
runtime_save::{
delete_runtime_snapshot, get_runtime_snapshot, list_profile_save_archives,
put_runtime_snapshot, resume_profile_save_archive,
@@ -245,6 +249,13 @@ pub fn build_router(state: AppState) -> Router {
require_bearer_auth,
)),
)
.route(
"/api/runtime/creation-agent/document-inputs/parse",
post(parse_creation_agent_document_input).route_layer(middleware::from_fn_with_state(
state.clone(),
require_bearer_auth,
)),
)
.route(
"/api/auth/logout",
post(logout)
@@ -773,6 +784,34 @@ pub fn build_router(state: AppState) -> Router {
require_bearer_auth,
)),
)
.route(
"/api/runtime/profile/recharge-center",
get(get_profile_recharge_center).route_layer(middleware::from_fn_with_state(
state.clone(),
require_bearer_auth,
)),
)
.route(
"/api/profile/recharge-center",
get(get_profile_recharge_center).route_layer(middleware::from_fn_with_state(
state.clone(),
require_bearer_auth,
)),
)
.route(
"/api/runtime/profile/recharge/orders",
post(create_profile_recharge_order).route_layer(middleware::from_fn_with_state(
state.clone(),
require_bearer_auth,
)),
)
.route(
"/api/profile/recharge/orders",
post(create_profile_recharge_order).route_layer(middleware::from_fn_with_state(
state.clone(),
require_bearer_auth,
)),
)
.route(
"/api/runtime/profile/play-stats",
get(get_profile_play_stats).route_layer(middleware::from_fn_with_state(

View File

@@ -1,5 +1,5 @@
use module_big_fish::{BigFishAnchorPack, BigFishAnchorStatus, BigFishCreationStage};
use platform_llm::{LlmClient, LlmMessage, LlmStreamDelta, LlmTextRequest};
use platform_llm::LlmClient;
use serde::{Deserialize, Serialize};
use serde_json::{Value as JsonValue, json};
use spacetime_client::{
@@ -10,6 +10,9 @@ use crate::creation_agent_anchor_templates::{
get_creation_agent_anchor_template, render_anchor_question_block,
};
use crate::creation_agent_chat::render_quick_fill_extra_rules;
use crate::creation_agent_llm_turn::{
CreationAgentLlmTurnErrorMessages, stream_creation_agent_json_turn,
};
#[derive(Clone, Debug)]
pub(crate) struct BigFishAgentTurnRequest<'a> {
@@ -109,41 +112,26 @@ const BIG_FISH_AGENT_OUTPUT_CONTRACT: &str = r#"请严格按以下 JSON 输出
pub(crate) async fn run_big_fish_agent_turn<F>(
request: BigFishAgentTurnRequest<'_>,
mut on_reply_update: F,
on_reply_update: F,
) -> Result<BigFishAgentTurnResult, BigFishAgentTurnError>
where
F: FnMut(&str),
{
let llm_client = request
.llm_client
.ok_or_else(|| BigFishAgentTurnError::new("当前模型不可用,请稍后重试。"))?;
let prompt = build_big_fish_agent_prompt(request.session, request.quick_fill_requested);
let mut latest_reply_text = String::new();
let response = llm_client
.stream_text(
LlmTextRequest::new(vec![
LlmMessage::system(format!("{BIG_FISH_AGENT_SYSTEM_PROMPT}\n\n{prompt}")),
LlmMessage::user("请按约定输出这一轮的 JSON"),
]),
|delta: &LlmStreamDelta| {
if let Some(reply_progress) =
extract_reply_text_from_partial_json(delta.accumulated_text.as_str())
&& reply_progress != latest_reply_text
{
latest_reply_text = reply_progress.clone();
on_reply_update(reply_progress.as_str());
}
},
)
.await
.map_err(|_| BigFishAgentTurnError::new("大鱼吃小鱼聊天生成失败,请稍后重试。"))?;
let parsed = parse_json_response_text(response.content.as_str())
.map_err(|_| BigFishAgentTurnError::new("大鱼吃小鱼聊天结果解析失败,请稍后重试。"))?;
let output = parse_big_fish_model_output(&parsed)?;
if output.reply_text != latest_reply_text {
on_reply_update(output.reply_text.as_str());
}
let turn_output = stream_creation_agent_json_turn(
request.llm_client,
format!("{BIG_FISH_AGENT_SYSTEM_PROMPT}\n\n{prompt}"),
"请按约定输出这一轮的 JSON。",
CreationAgentLlmTurnErrorMessages {
model_unavailable: "当前模型不可用,请稍后重试",
generation_failed: "大鱼吃小鱼聊天生成失败,请稍后重试。",
parse_failed: "大鱼吃小鱼聊天结果解析失败,请稍后重试。",
},
on_reply_update,
BigFishAgentTurnError::new,
)
.await?;
let output = parse_big_fish_model_output(&turn_output.parsed)?;
Ok(BigFishAgentTurnResult {
assistant_reply_text: output.reply_text,
@@ -373,57 +361,6 @@ fn parse_big_fish_anchor_status(value: &str) -> BigFishAnchorStatus {
}
}
fn parse_json_response_text(text: &str) -> Result<JsonValue, serde_json::Error> {
if let Ok(value) = serde_json::from_str::<JsonValue>(text) {
return Ok(value);
}
let Some(start) = text.find('{') else {
return serde_json::from_str(text);
};
let Some(end) = text.rfind('}') else {
return serde_json::from_str(text);
};
serde_json::from_str(&text[start..=end])
}
fn extract_reply_text_from_partial_json(text: &str) -> Option<String> {
let marker = "\"replyText\"";
let marker_index = text.find(marker)?;
let after_marker = &text[marker_index + marker.len()..];
let colon_index = after_marker.find(':')?;
let after_colon = after_marker[colon_index + 1..].trim_start();
let content = after_colon.strip_prefix('"')?;
let mut result = String::new();
let mut escaped = false;
for character in content.chars() {
if escaped {
result.push(match character {
'n' => '\n',
'r' => '\r',
't' => '\t',
'"' => '"',
'\\' => '\\',
other => other,
});
escaped = false;
continue;
}
if character == '\\' {
escaped = true;
continue;
}
if character == '"' {
return Some(result);
}
result.push(character);
}
if result.is_empty() {
None
} else {
Some(result)
}
}
#[cfg(test)]
mod tests {
use super::build_big_fish_agent_prompt;

View File

@@ -35,6 +35,7 @@ use crate::{
api_response::json_success_body,
custom_world_asset_prompts::{
build_character_visual_negative_prompt, build_character_visual_prompt,
build_fallback_moderation_safe_character_visual_prompt,
},
http_error::AppError,
request_context::RequestContext,
@@ -47,6 +48,7 @@ const CHARACTER_VISUAL_ASSET_KIND: &str = "character_visual";
const CHARACTER_VISUAL_ENTITY_KIND: &str = "character";
const CHARACTER_VISUAL_SLOT: &str = "primary_visual";
const CHARACTER_VISUAL_TASK_POLL_INTERVAL_MS: u64 = 2_500;
const CHARACTER_VISUAL_MODERATION_FALLBACK_MAX_ATTEMPTS: u8 = 2;
#[derive(Clone, Debug)]
pub(crate) struct GeneratedCharacterPrimaryVisual {
@@ -76,6 +78,10 @@ pub async fn generate_character_visual(
payload.prompt_text.as_str(),
payload.character_brief_text.as_deref(),
);
let fallback_prompt = build_fallback_moderation_safe_character_visual_prompt(
payload.prompt_text.as_str(),
payload.character_brief_text.as_deref(),
);
let character_id = normalize_required_text(payload.character_id.as_str(), "character");
let model = normalize_required_text(payload.image_model.as_str(), CHARACTER_VISUAL_MODEL);
let size = normalize_required_text(payload.size.as_str(), "1024*1024");
@@ -170,6 +176,7 @@ pub async fn generate_character_visual(
&settings,
model.as_str(),
prompt.as_str(),
fallback_prompt.as_str(),
size.as_str(),
candidate_count,
&reference_images,
@@ -185,7 +192,7 @@ pub async fn generate_character_visual(
generated
.actual_prompt
.clone()
.unwrap_or_else(|| prompt.clone()),
.unwrap_or_else(|| generated.submitted_prompt.clone()),
),
structured_payload_json: Some(
json!({
@@ -193,6 +200,7 @@ pub async fn generate_character_visual(
"taskId": generated.task_id,
"model": model,
"imageCount": generated.images.len(),
"moderationFallbackApplied": generated.moderation_fallback_applied,
})
.to_string(),
),
@@ -305,6 +313,10 @@ pub(crate) async fn generate_character_primary_visual_for_profile(
payload.prompt_text.as_str(),
payload.character_brief_text.as_deref(),
);
let fallback_prompt = build_fallback_moderation_safe_character_visual_prompt(
payload.prompt_text.as_str(),
payload.character_brief_text.as_deref(),
);
let character_id = normalize_required_text(payload.character_id.as_str(), "character");
let model = normalize_required_text(payload.image_model.as_str(), CHARACTER_VISUAL_MODEL);
let size = normalize_required_text(payload.size.as_str(), "1024*1024");
@@ -327,6 +339,7 @@ pub(crate) async fn generate_character_primary_visual_for_profile(
&settings,
model.as_str(),
prompt.as_str(),
fallback_prompt.as_str(),
size.as_str(),
1,
&[],
@@ -908,6 +921,57 @@ async fn resolve_reference_image_as_data_url(
}
async fn create_character_visual_generation(
http_client: &reqwest::Client,
settings: &DashScopeSettings,
model: &str,
prompt: &str,
fallback_prompt: &str,
size: &str,
candidate_count: u32,
reference_images: &[String],
) -> Result<GeneratedCharacterVisuals, AppError> {
let mut active_prompt = prompt;
let mut moderation_fallback_applied = false;
let mut last_moderation_error = String::new();
for attempt_index in 0..CHARACTER_VISUAL_MODERATION_FALLBACK_MAX_ATTEMPTS {
match create_character_visual_generation_once(
http_client,
settings,
model,
active_prompt,
size,
candidate_count,
reference_images,
)
.await
{
Ok(mut generated) => {
generated.submitted_prompt = active_prompt.to_string();
generated.moderation_fallback_applied = moderation_fallback_applied;
return Ok(generated);
}
Err(error)
if attempt_index == 0
&& !fallback_prompt.trim().is_empty()
&& fallback_prompt.trim() != prompt.trim()
&& is_dashscope_moderation_error(&error) =>
{
last_moderation_error = error.body_text();
active_prompt = fallback_prompt;
moderation_fallback_applied = true;
}
Err(error) => return Err(error),
}
}
Err(map_dashscope_request_error(format!(
"角色主形象安全兜底重试未返回结果:{}",
last_moderation_error.if_empty_then("上游内容审核仍未通过。")
)))
}
async fn create_character_visual_generation_once(
http_client: &reqwest::Client,
settings: &DashScopeSettings,
model: &str,
@@ -1025,6 +1089,8 @@ async fn create_character_visual_generation(
return Ok(GeneratedCharacterVisuals {
task_id,
actual_prompt: find_first_string_by_key(&poll_json.payload, "actual_prompt"),
submitted_prompt: prompt.to_string(),
moderation_fallback_applied: false,
images,
});
}
@@ -1362,9 +1428,23 @@ fn map_dashscope_upstream_error(raw_text: &str, fallback_message: &str) -> AppEr
AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({
"provider": "dashscope",
"message": parse_api_error_message(raw_text, fallback_message),
"raw": raw_text.trim(),
}))
}
fn is_dashscope_moderation_error(error: &AppError) -> bool {
let text = error.body_text();
let normalized = text.to_ascii_lowercase();
normalized.contains("ipinfringementsuspect")
|| normalized.contains("inappropriate")
|| normalized.contains("sensitive")
|| normalized.contains("risk")
|| text.contains("内容审核")
|| text.contains("疑似侵权")
|| text.contains("IP 侵权")
|| text.contains("知识产权")
}
fn collect_strings_by_key(value: &Value, target_key: &str, results: &mut Vec<String>) {
match value {
Value::Array(entries) => {
@@ -1962,6 +2042,8 @@ struct DashScopeSettings {
struct GeneratedCharacterVisuals {
task_id: String,
actual_prompt: Option<String>,
submitted_prompt: String,
moderation_fallback_applied: bool,
images: Vec<DownloadedGeneratedImage>,
}
@@ -1990,7 +2072,30 @@ mod tests {
assert!(prompt.contains("潮雾港向导"));
assert!(prompt.contains("右向斜侧身"));
assert!(prompt.contains("纯绿色背景"));
assert!(prompt.contains("纯绿色绿幕"));
}
#[test]
fn fallback_character_visual_prompt_removes_risky_specific_names() {
let prompt = build_fallback_moderation_safe_character_visual_prompt(
"艾瑞克,银发剑士,红色长披风",
Some("某知名设定参考"),
);
assert!(prompt.contains("原创"));
assert!(prompt.contains("不参考任何现有"));
assert!(!prompt.contains("艾瑞克"));
assert!(!prompt.contains("某知名设定参考"));
}
#[test]
fn dashscope_ip_infringement_error_uses_moderation_fallback() {
let error = map_dashscope_upstream_error(
r#"{"request_id":"a18fb05d","output":{"task_id":"cb768c95","task_status":"FAILED","code":"IPInfringementSuspect","message":"Input data is suspected of being involved in IP infringement."}}"#,
"角色主形象任务执行失败。",
);
assert!(is_dashscope_moderation_error(&error));
}
#[test]

View File

@@ -0,0 +1,324 @@
use axum::{Json, extract::Extension, http::StatusCode};
use base64::{Engine as _, engine::general_purpose::STANDARD as BASE64_STANDARD};
use serde_json::{Value, json};
use shared_contracts::creation_agent_document_input::{
CreationAgentDocumentInputPayload, ParseCreationAgentDocumentInputRequest,
ParseCreationAgentDocumentInputResponse,
};
use crate::{
api_response::json_success_body, http_error::AppError, request_context::RequestContext,
};
const MAX_DOCUMENT_INPUT_BYTES: usize = 256 * 1024;
const MAX_DOCUMENT_INPUT_BASE64_CHARS: usize = 360 * 1024;
const SUPPORTED_DOCUMENT_EXTENSIONS: &[&str] = &["txt", "md", "markdown", "csv", "json"];
pub async fn parse_creation_agent_document_input(
Extension(request_context): Extension<RequestContext>,
Json(payload): Json<ParseCreationAgentDocumentInputRequest>,
) -> Result<Json<Value>, AppError> {
let file_name = normalize_file_name(&payload.file_name)?;
ensure_supported_extension(&file_name)?;
let content_base64 = payload.content_base64.trim();
if content_base64.len() > MAX_DOCUMENT_INPUT_BASE64_CHARS {
return Err(
AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({
"message": "文档过大,请上传 256KB 以内的文本文件。",
"field": "contentBase64",
"maxSizeBytes": MAX_DOCUMENT_INPUT_BYTES,
})),
);
}
let decoded = BASE64_STANDARD.decode(content_base64).map_err(|_| {
AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({
"message": "文档内容编码无效,请重新选择文件。",
"field": "contentBase64",
}))
})?;
if decoded.is_empty() {
return Err(
AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({
"message": "文档内容为空,请选择有内容的文件。",
"field": "contentBase64",
})),
);
}
if decoded.len() > MAX_DOCUMENT_INPUT_BYTES {
return Err(
AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({
"message": "文档过大,请上传 256KB 以内的文本文件。",
"field": "contentBase64",
"maxSizeBytes": MAX_DOCUMENT_INPUT_BYTES,
"actualSizeBytes": decoded.len(),
})),
);
}
let text = String::from_utf8(decoded.clone()).map_err(|_| {
AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({
"message": "暂时只支持 UTF-8 文本文档,请转换编码后再上传。",
"field": "contentBase64",
}))
})?;
let normalized_text = normalize_document_text(&text);
if normalized_text.trim().is_empty() {
return Err(
AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({
"message": "文档解析后没有可用文本,请换一个文件。",
"field": "contentBase64",
})),
);
}
Ok(json_success_body(
Some(&request_context),
ParseCreationAgentDocumentInputResponse {
document: CreationAgentDocumentInputPayload {
file_name,
content_type: payload
.content_type
.as_deref()
.map(str::trim)
.filter(|value| !value.is_empty())
.map(str::to_string),
size_bytes: decoded.len(),
text: normalized_text,
},
},
))
}
fn normalize_file_name(value: &str) -> Result<String, AppError> {
let normalized = value
.trim()
.rsplit(['/', '\\'])
.next()
.unwrap_or_default()
.trim()
.to_string();
if normalized.is_empty() {
return Err(
AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({
"message": "缺少文档文件名。",
"field": "fileName",
})),
);
}
Ok(normalized)
}
fn ensure_supported_extension(file_name: &str) -> Result<(), AppError> {
let extension = file_name
.rsplit_once('.')
.map(|(_, extension)| extension.trim().to_ascii_lowercase())
.filter(|extension| !extension.is_empty())
.ok_or_else(|| unsupported_document_error(file_name))?;
if !SUPPORTED_DOCUMENT_EXTENSIONS.contains(&extension.as_str()) {
return Err(unsupported_document_error(file_name));
}
Ok(())
}
fn unsupported_document_error(file_name: &str) -> AppError {
AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({
"message": "暂时只支持 txt、md、csv、json 文本文档。",
"field": "fileName",
"fileName": file_name,
"supportedExtensions": SUPPORTED_DOCUMENT_EXTENSIONS,
}))
}
fn normalize_document_text(value: &str) -> String {
value
.trim_start_matches('\u{feff}')
.replace("\r\n", "\n")
.replace('\r', "\n")
.trim()
.to_string()
}
#[cfg(test)]
mod tests {
use axum::{body::Body, http::Request};
use base64::{Engine as _, engine::general_purpose::STANDARD as BASE64_STANDARD};
use http_body_util::BodyExt;
use module_auth::{PhoneAuthScene, PhoneLoginInput, SendPhoneCodeInput};
use platform_auth::{
AccessTokenClaims, AccessTokenClaimsInput, AuthProvider, BindingStatus, sign_access_token,
};
use serde_json::{Value, json};
use std::path::PathBuf;
use time::OffsetDateTime;
use tower::ServiceExt;
use super::MAX_DOCUMENT_INPUT_BASE64_CHARS;
use crate::{app::build_router, config::AppConfig, state::AppState};
#[tokio::test]
async fn parse_document_input_returns_text_payload() {
let state = build_test_state("ok").await;
let access_token = seed_authenticated_token(&state, "13800138110").await;
let app = build_router(state);
let response = app
.oneshot(
Request::builder()
.method("POST")
.uri("/api/runtime/creation-agent/document-inputs/parse")
.header("authorization", format!("Bearer {access_token}"))
.header("content-type", "application/json")
.header("x-genarrative-response-envelope", "1")
.body(Body::from(
json!({
"fileName": "世界设定.md",
"contentType": "text/markdown",
"contentBase64": BASE64_STANDARD.encode("第一章\r\n潮湿的港口")
})
.to_string(),
))
.expect("request should build"),
)
.await
.expect("request should succeed");
assert_eq!(response.status(), axum::http::StatusCode::OK);
let body = response
.into_body()
.collect()
.await
.expect("body should collect")
.to_bytes();
let payload: Value =
serde_json::from_slice(&body).expect("response body should be valid json");
assert_eq!(payload["ok"], Value::Bool(true));
assert_eq!(
payload["data"]["document"]["fileName"],
json!("世界设定.md")
);
assert_eq!(
payload["data"]["document"]["text"],
json!("第一章\n潮湿的港口")
);
}
#[tokio::test]
async fn parse_document_input_rejects_unsupported_extension() {
let state = build_test_state("bad-ext").await;
let access_token = seed_authenticated_token(&state, "13800138111").await;
let app = build_router(state);
let response = app
.oneshot(
Request::builder()
.method("POST")
.uri("/api/runtime/creation-agent/document-inputs/parse")
.header("authorization", format!("Bearer {access_token}"))
.header("content-type", "application/json")
.header("x-genarrative-response-envelope", "1")
.body(Body::from(
json!({
"fileName": "世界设定.docx",
"contentBase64": BASE64_STANDARD.encode("binary")
})
.to_string(),
))
.expect("request should build"),
)
.await
.expect("request should succeed");
assert_eq!(response.status(), axum::http::StatusCode::BAD_REQUEST);
}
#[tokio::test]
async fn parse_document_input_rejects_large_base64_before_decode() {
let state = build_test_state("large-base64").await;
let access_token = seed_authenticated_token(&state, "13800138112").await;
let app = build_router(state);
let response = app
.oneshot(
Request::builder()
.method("POST")
.uri("/api/runtime/creation-agent/document-inputs/parse")
.header("authorization", format!("Bearer {access_token}"))
.header("content-type", "application/json")
.header("x-genarrative-response-envelope", "1")
.body(Body::from(
json!({
"fileName": "世界设定.txt",
"contentBase64": "A".repeat(MAX_DOCUMENT_INPUT_BASE64_CHARS + 1)
})
.to_string(),
))
.expect("request should build"),
)
.await
.expect("request should succeed");
assert_eq!(response.status(), axum::http::StatusCode::BAD_REQUEST);
}
async fn seed_authenticated_token(state: &AppState, phone_number: &str) -> String {
let now = OffsetDateTime::now_utc();
state
.phone_auth_service()
.send_code(
SendPhoneCodeInput {
phone_number: phone_number.to_string(),
scene: PhoneAuthScene::Login,
},
now,
)
.await
.expect("phone code should send");
let user = state
.phone_auth_service()
.login(
PhoneLoginInput {
phone_number: phone_number.to_string(),
verify_code: "123456".to_string(),
},
now + time::Duration::seconds(1),
)
.await
.expect("phone login should create user")
.user;
let claims = AccessTokenClaims::from_input(
AccessTokenClaimsInput {
user_id: user.id,
session_id: "sess_creation_doc_input".to_string(),
provider: AuthProvider::Password,
roles: vec!["user".to_string()],
token_version: user.token_version,
phone_verified: true,
binding_status: BindingStatus::Active,
display_name: Some(user.display_name),
},
state.auth_jwt_config(),
OffsetDateTime::now_utc(),
)
.expect("claims should build");
sign_access_token(&claims, state.auth_jwt_config()).expect("token should sign")
}
async fn build_test_state(label: &str) -> AppState {
let mut config = AppConfig::default();
config.auth_store_path = PathBuf::from(format!(
".codex-temp/api-server-auth-store-creation-doc-{label}.json"
));
let _ = std::fs::remove_file(&config.auth_store_path);
AppState::new(config).expect("state should build")
}
}

View File

@@ -0,0 +1,170 @@
use platform_llm::{LlmClient, LlmMessage, LlmStreamDelta, LlmTextRequest};
use serde_json::Value as JsonValue;
#[derive(Clone, Copy, Debug)]
pub(crate) struct CreationAgentLlmTurnErrorMessages<'a> {
pub model_unavailable: &'a str,
pub generation_failed: &'a str,
pub parse_failed: &'a str,
}
#[derive(Clone, Debug)]
pub(crate) struct CreationAgentJsonTurnOutput {
pub parsed: JsonValue,
}
/**
* 创作 Agent 的通用流式 JSON turn 调用。
* 这里只处理跨玩法一致的 LLM 调用骨架prompt 内容和领域 JSON 解析仍由调用方负责。
*/
pub(crate) async fn stream_creation_agent_json_turn<F, E>(
llm_client: Option<&LlmClient>,
system_prompt: String,
user_prompt: impl Into<String>,
messages: CreationAgentLlmTurnErrorMessages<'_>,
mut on_reply_update: F,
build_error: impl Fn(String) -> E,
) -> Result<CreationAgentJsonTurnOutput, E>
where
F: FnMut(&str),
{
let llm_client =
llm_client.ok_or_else(|| build_error(messages.model_unavailable.to_string()))?;
let mut latest_reply_text = String::new();
let response = llm_client
.stream_text(
LlmTextRequest::new(vec![
LlmMessage::system(system_prompt),
LlmMessage::user(user_prompt.into()),
]),
|delta: &LlmStreamDelta| {
if let Some(reply_progress) =
extract_reply_text_from_partial_json(delta.accumulated_text.as_str())
&& reply_progress != latest_reply_text
{
latest_reply_text = reply_progress.clone();
on_reply_update(reply_progress.as_str());
}
},
)
.await
.map_err(|_| build_error(messages.generation_failed.to_string()))?;
let parsed = parse_json_response_text(response.content.as_str())
.map_err(|_| build_error(messages.parse_failed.to_string()))?;
let reply_text = read_reply_text(&parsed);
if let Some(reply_text) = reply_text.as_deref()
&& reply_text != latest_reply_text
{
on_reply_update(reply_text);
}
Ok(CreationAgentJsonTurnOutput { parsed })
}
pub(crate) async fn request_creation_agent_json_turn<E>(
llm_client: &LlmClient,
system_prompt: String,
user_prompt: String,
build_error: impl Fn(String) -> E,
) -> Result<JsonValue, E> {
let response = llm_client
.request_text(LlmTextRequest::new(vec![
LlmMessage::system(system_prompt),
LlmMessage::user(user_prompt),
]))
.await
.map_err(|error| build_error(error.to_string()))?;
parse_json_response_text(response.content.as_str())
.map_err(|error| build_error(error.to_string()))
}
pub(crate) fn parse_json_response_text(text: &str) -> Result<JsonValue, serde_json::Error> {
let trimmed = text.trim();
if let Some(start) = trimmed.find('{')
&& let Some(end) = trimmed.rfind('}')
&& end > start
{
return serde_json::from_str::<JsonValue>(&trimmed[start..=end]);
}
serde_json::from_str::<JsonValue>(trimmed)
}
pub(crate) fn extract_reply_text_from_partial_json(text: &str) -> Option<String> {
let key_index = text.find("\"replyText\"")?;
let colon_index = text[key_index..].find(':')? + key_index;
let mut cursor = colon_index + 1;
while cursor < text.len() && text.as_bytes()[cursor].is_ascii_whitespace() {
cursor += 1;
}
if text.as_bytes().get(cursor).copied() != Some(b'"') {
return None;
}
cursor += 1;
let mut decoded = String::new();
let remainder = text.get(cursor..)?;
let mut characters = remainder.chars().peekable();
while let Some(current) = characters.next() {
if current == '"' {
return Some(decoded);
}
if current == '\\' {
let escaped = characters.next()?;
match escaped {
'"' => decoded.push('"'),
'\\' => decoded.push('\\'),
'/' => decoded.push('/'),
'b' => decoded.push('\u{0008}'),
'f' => decoded.push('\u{000C}'),
'n' => decoded.push('\n'),
'r' => decoded.push('\r'),
't' => decoded.push('\t'),
'u' => {
let mut hex = String::new();
for _ in 0..4 {
hex.push(characters.next()?);
}
if let Ok(code) = u16::from_str_radix(hex.as_str(), 16)
&& let Some(character) = char::from_u32(code as u32)
{
decoded.push(character);
}
}
other => decoded.push(other),
}
continue;
}
decoded.push(current);
}
Some(decoded)
}
fn read_reply_text(parsed: &JsonValue) -> Option<String> {
parsed
.get("replyText")
.and_then(JsonValue::as_str)
.map(str::trim)
.filter(|value| !value.is_empty())
.map(str::to_string)
}
#[cfg(test)]
mod tests {
use super::{extract_reply_text_from_partial_json, parse_json_response_text};
#[test]
fn extracts_reply_text_from_partial_json_with_chinese_text() {
let partial_json = r#"{"replyText":"你好,潮雾列岛","progressPercent":32"#;
let extracted = extract_reply_text_from_partial_json(partial_json);
assert_eq!(extracted.as_deref(), Some("你好,潮雾列岛"));
}
#[test]
fn parses_json_inside_model_markdown_noise() {
let parsed = parse_json_response_text("```json\n{\"replyText\":\"\"}\n```")
.expect("应能截取模型返回中的 JSON 对象");
assert_eq!(parsed["replyText"].as_str(), Some(""));
}
}

View File

@@ -41,6 +41,7 @@ use spacetime_client::{
CustomWorldWorkSummaryRecord, SpacetimeClientError,
};
use std::{collections::BTreeSet, convert::Infallible, sync::Arc, time::Instant};
use time::{OffsetDateTime, format_description::well_known::Rfc3339};
use tokio::sync::Semaphore;
use tokio::task::JoinSet;
use tracing::info;
@@ -71,6 +72,92 @@ use crate::{
};
const DRAFT_ASSET_GENERATION_MAX_ATTEMPTS: u32 = 3;
const DRAFT_FOUNDATION_PROGRESS_FRAMEWORK_START: u32 = 12;
const DRAFT_FOUNDATION_PROGRESS_FRAMEWORK_DONE: u32 = 97;
const DRAFT_FOUNDATION_PROGRESS_ASSET_START: u32 = 97;
const DRAFT_FOUNDATION_PROGRESS_CARD_START: u32 = 98;
const DRAFT_FOUNDATION_PROGRESS_WRITEBACK_START: u32 = 99;
const DRAFT_ROLE_ASSET_TEXT_FIELDS: [&str; 3] = [
"visualDescription",
"actionDescription",
"sceneVisualDescription",
];
fn timestamp_micros_to_rfc3339(value: i64) -> String {
match OffsetDateTime::from_unix_timestamp_nanos(i128::from(value) * 1_000) {
Ok(timestamp) => timestamp
.format(&Rfc3339)
.unwrap_or_else(|_| "1970-01-01T00:00:00Z".to_string()),
Err(_) => "1970-01-01T00:00:00Z".to_string(),
}
}
fn fallback_draft_foundation_failure_progress(phase_label: &str) -> u32 {
if phase_label.contains("写入") {
return DRAFT_FOUNDATION_PROGRESS_WRITEBACK_START;
}
if phase_label.contains("草稿卡") {
return DRAFT_FOUNDATION_PROGRESS_CARD_START;
}
if phase_label.contains("素材")
|| phase_label.contains("角色主形象")
|| phase_label.contains("幕背景图")
{
return DRAFT_FOUNDATION_PROGRESS_ASSET_START;
}
if phase_label.contains("底稿") {
return DRAFT_FOUNDATION_PROGRESS_FRAMEWORK_DONE;
}
DRAFT_FOUNDATION_PROGRESS_FRAMEWORK_START
}
fn reusable_draft_profile_for_asset_generation(
session: &CustomWorldAgentSessionRecord,
) -> Option<Value> {
let object = session
.draft_profile
.as_object()
.filter(|object| !object.is_empty())?;
let profile = Value::Object(object.clone());
match missing_role_asset_text_report(&profile) {
Some(report) => {
// 中文注释:旧失败会话可能保存了缺少角色形象文本的半成品底稿;
// 这种底稿不能直接续跑到生图阶段,必须回到文本底稿链路重新生成。
tracing::warn!(
session_id = %session.session_id,
missing_report = %report,
"已保存 RPG 底稿缺少角色形象设定文本,跳过复用并重新生成底稿"
);
None
}
None => Some(profile),
}
}
fn missing_role_asset_text_report(draft_profile: &Value) -> Option<String> {
let profile_object = draft_profile.as_object()?;
let mut missing_items = Vec::new();
for key in ["playableNpcs", "storyNpcs"] {
if let Some(roles) = profile_object.get(key).and_then(Value::as_array) {
for (index, role) in roles.iter().enumerate() {
let name = json_text_from_value(role, "name")
.unwrap_or_else(|| format!("{}-{}", key, index + 1));
let missing_fields = DRAFT_ROLE_ASSET_TEXT_FIELDS
.into_iter()
.filter(|field| json_text_from_value(role, field).is_none())
.collect::<Vec<_>>();
if !missing_fields.is_empty() {
missing_items.push(format!("角色「{name}」缺少 {}", missing_fields.join("/")));
}
}
}
}
if missing_items.is_empty() {
None
} else {
Some(missing_items.join(""))
}
}
pub async fn get_custom_world_library(
State(state): State<AppState>,
@@ -1077,6 +1164,40 @@ pub async fn execute_custom_world_agent_action(
})?;
generation_result.payload_json
}
} else if action == "publish_world" {
let mut publish_payload = serde_json::to_value(&payload).map_err(|error| {
custom_world_error_response(
&request_context,
AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({
"provider": "custom-world-agent",
"message": format!("action payload JSON 序列化失败:{error}"),
})),
)
})?;
if let Some(object) = publish_payload.as_object_mut() {
// 发布到广场时必须写入真实作者公开信息,避免 gallery 投影落成匿名兜底数据。
object.insert(
"authorPublicUserCode".to_string(),
Value::String(resolve_author_public_user_code(
&state,
&authenticated,
&request_context,
)?),
);
object.insert(
"authorDisplayName".to_string(),
Value::String(resolve_author_display_name(&state, &authenticated)),
);
}
serde_json::to_string(&publish_payload).map_err(|error| {
custom_world_error_response(
&request_context,
AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({
"provider": "custom-world-agent",
"message": format!("action payload JSON 序列化失败:{error}"),
})),
)
})?
} else {
serde_json::to_string(&payload).map_err(|error| {
custom_world_error_response(
@@ -1142,7 +1263,7 @@ fn spawn_custom_world_draft_foundation_job(
"failed",
"底稿生成失败",
"服务端尚未配置可用的 LLM API Key",
100,
DRAFT_FOUNDATION_PROGRESS_FRAMEWORK_START,
Some("服务端尚未配置可用的 LLM API Key".to_string()),
)
.await;
@@ -1153,7 +1274,31 @@ fn spawn_custom_world_draft_foundation_job(
let progress_session_id = session.session_id.clone();
let progress_owner_user_id = owner_user_id.clone();
let progress_operation_id = operation_id.clone();
let draft_result =
let existing_draft_profile = reusable_draft_profile_for_asset_generation(&session);
let draft_result = if let Some(profile) = existing_draft_profile {
// 失败后“继续生成草稿”复用已经写入 session 的底稿,
// 只继续执行素材补齐、草稿卡编译和结果页写回。
let _ = upsert_custom_world_draft_foundation_progress(
&state,
&session.session_id,
&owner_user_id,
&operation_id,
"running",
"继续生成草稿",
"已读取上次保存的世界底稿,正在继续补齐素材与结果页。",
DRAFT_FOUNDATION_PROGRESS_ASSET_START,
None,
)
.await;
match serde_json::to_string(&profile) {
Ok(draft_profile_json) => Ok(
crate::custom_world_foundation_draft::CustomWorldFoundationDraftResult {
draft_profile_json,
},
),
Err(error) => Err(format!("已保存底稿序列化失败:{error}")),
}
} else {
generate_custom_world_foundation_draft(&llm_client, &session, move |progress| {
let progress_state = progress_state.clone();
let session_id = progress_session_id.clone();
@@ -1174,7 +1319,8 @@ fn spawn_custom_world_draft_foundation_job(
.await;
});
})
.await;
.await
};
let draft_result = match draft_result {
Ok(result) => result,
@@ -1187,7 +1333,7 @@ fn spawn_custom_world_draft_foundation_job(
"failed",
"底稿生成失败",
message.clone().as_str(),
100,
fallback_draft_foundation_failure_progress("底稿生成失败"),
Some(message.clone()),
)
.await;
@@ -1208,7 +1354,7 @@ fn spawn_custom_world_draft_foundation_job(
"failed",
"底稿素材生成失败",
message.as_str(),
100,
fallback_draft_foundation_failure_progress("底稿素材生成失败"),
Some(message.clone()),
)
.await;
@@ -1224,7 +1370,7 @@ fn spawn_custom_world_draft_foundation_job(
"failed",
"底稿素材生成失败",
message.as_str(),
100,
fallback_draft_foundation_failure_progress("底稿素材生成失败"),
Some(message.clone()),
)
.await;
@@ -1316,7 +1462,7 @@ fn spawn_custom_world_draft_foundation_job(
"failed",
"底稿素材写回失败",
message.as_str(),
100,
fallback_draft_foundation_failure_progress("底稿素材写回失败"),
Some(message.clone()),
)
.await;
@@ -1356,7 +1502,7 @@ fn spawn_custom_world_draft_foundation_job(
"failed",
"底稿写入失败",
message.clone().as_str(),
100,
fallback_draft_foundation_failure_progress("底稿写入失败"),
Some(message),
)
.await;
@@ -1385,7 +1531,7 @@ fn spawn_custom_world_draft_foundation_job(
"failed",
"底稿写入失败",
message.clone().as_str(),
100,
fallback_draft_foundation_failure_progress("底稿写入失败"),
Some(message),
)
.await;
@@ -2050,7 +2196,7 @@ async fn persist_partial_draft_foundation_after_asset_failure(
phase_label: phase_label.to_string(),
phase_detail: format!("已保存成功生成的素材,失败项超过 {DRAFT_ASSET_GENERATION_MAX_ATTEMPTS} 次重试:{error_message}"),
operation_status: "failed".to_string(),
operation_progress: 100,
operation_progress: fallback_draft_foundation_failure_progress(phase_label),
stage: session.stage.clone(),
progress_percent: session.progress_percent,
focus_card_id: session.focus_card_id.clone(),
@@ -2078,7 +2224,7 @@ async fn persist_partial_draft_foundation_after_asset_failure(
"failed",
phase_label,
error_message,
100,
fallback_draft_foundation_failure_progress(phase_label),
Some(error_message.to_string()),
)
.await;
@@ -2113,6 +2259,15 @@ async fn upsert_custom_world_draft_foundation_progress(
},
)
.await
.map(|operation| {
info!(
operation_id = %operation.operation_id,
phase_label = %operation.phase_label,
progress = operation.progress,
"世界草稿生成阶段进度已写入"
);
operation
})
}
fn map_custom_world_library_entry_response(
@@ -2383,6 +2538,7 @@ fn map_custom_world_agent_operation_response(
phase_detail: operation.phase_detail,
progress: operation.progress,
error: operation.error_message,
updated_at: Some(timestamp_micros_to_rfc3339(operation.updated_at_micros)),
}
}
@@ -2701,6 +2857,71 @@ fn current_utc_micros() -> i64 {
mod tests {
use super::*;
#[test]
fn incomplete_role_asset_text_draft_profile_is_not_reused() {
let mut session = CustomWorldAgentSessionRecord {
session_id: "session-with-broken-draft".to_string(),
seed_text: "深海异常调查".to_string(),
current_turn: 1,
anchor_content: json!({}),
progress_percent: 80,
last_assistant_reply: None,
stage: "foundation_review".to_string(),
focus_card_id: None,
creator_intent: json!({}),
creator_intent_readiness: json!({}),
anchor_pack: json!({}),
lock_state: json!({}),
draft_profile: json!({
"name": "深海裂隙",
"playableNpcs": [{
"name": "海洋生物学家",
"title": "深海观察员",
"role": "调查者",
"description": "记录异常海沟的人"
}],
"storyNpcs": []
}),
messages: Vec::new(),
draft_cards: Vec::new(),
pending_clarifications: Vec::new(),
suggested_actions: Vec::new(),
recommended_replies: Vec::new(),
quality_findings: Vec::new(),
asset_coverage: json!({}),
checkpoints: Vec::new(),
supported_actions: Vec::new(),
publish_gate: None,
result_preview: None,
updated_at: "2026-04-25T00:00:00Z".to_string(),
};
assert!(reusable_draft_profile_for_asset_generation(&session).is_none());
if let Some(role) = session
.draft_profile
.get_mut("playableNpcs")
.and_then(Value::as_array_mut)
.and_then(|roles| roles.first_mut())
.and_then(Value::as_object_mut)
{
role.insert(
"visualDescription".to_string(),
json!("防水研究外套挂满盐痕,护目镜映着蓝绿海光,手提样本箱。"),
);
role.insert(
"actionDescription".to_string(),
json!("蹲身取样并快速记录潮汐数据,遇险时护住样本箱后撤。"),
);
role.insert(
"sceneVisualDescription".to_string(),
json!("她常站在潮湿实验船甲板边,身后是发光海沟与摇晃仪器。"),
);
}
assert!(reusable_draft_profile_for_asset_generation(&session).is_some());
}
#[test]
fn collect_scene_act_refs_accepts_scene_prompt_text_alias() {
let draft_profile = json!({

View File

@@ -2,15 +2,18 @@ use module_custom_world::{
empty_agent_anchor_content_json, empty_agent_asset_coverage_json,
empty_agent_creator_intent_readiness_json, empty_json_array, empty_json_object,
};
use platform_llm::{LlmClient, LlmMessage, LlmStreamDelta, LlmTextRequest};
use platform_llm::LlmClient;
use serde::{Deserialize, Serialize};
use serde_json::{Value as JsonValue, json};
use crate::creation_agent_llm_turn::{
CreationAgentLlmTurnErrorMessages, request_creation_agent_json_turn,
stream_creation_agent_json_turn,
};
use crate::custom_world_rpg_draft_prompts::{
BASE_SYSTEM_PROMPT, GLOBAL_HARD_RULES, OUTPUT_CONTRACT_REMINDER,
STATE_INFERENCE_OUTPUT_CONTRACT, STATE_INFERENCE_SYSTEM_PROMPT,
extract_reply_text_from_partial_json, mode_rules, parse_conversation_mode, parse_drift_risk,
parse_json_response_text, parse_user_input_signal, quick_fill_extra_rules,
STATE_INFERENCE_OUTPUT_CONTRACT, STATE_INFERENCE_SYSTEM_PROMPT, mode_rules,
parse_conversation_mode, parse_drift_risk, parse_user_input_signal, quick_fill_extra_rules,
render_chat_history_context, render_current_anchor_context, render_dynamic_state_context,
user_signal_rules,
};
@@ -561,7 +564,7 @@ async fn stream_single_turn<F>(
progress_percent: u32,
quick_fill_requested: bool,
current_anchor_content: &EightAnchorContent,
mut on_reply_update: F,
on_reply_update: F,
) -> Result<SingleTurnModelOutput, CustomWorldTurnError>
where
F: FnMut(&str),
@@ -586,31 +589,20 @@ where
&chat_history,
&dynamic_state,
);
let mut latest_reply_text = String::new();
let response = llm_client
.stream_text(
LlmTextRequest::new(vec![
LlmMessage::system(prompt),
LlmMessage::user("请按约定输出这一轮的 JSON"),
]),
|delta: &LlmStreamDelta| {
if let Some(reply_progress) =
extract_reply_text_from_partial_json(delta.accumulated_text.as_str())
&& reply_progress != latest_reply_text
{
latest_reply_text = reply_progress.clone();
on_reply_update(reply_progress.as_str());
}
},
)
.await;
let response =
response.map_err(|_| CustomWorldTurnError::new("这一轮设定生成失败,请稍后重试。"))?;
let parsed = parse_json_response_text(response.content.as_str())
.map_err(|_| CustomWorldTurnError::new("模型返回结果解析失败,请稍后重试。"))?;
let turn_output = stream_creation_agent_json_turn(
Some(llm_client),
prompt,
"请按约定输出这一轮的 JSON。",
CreationAgentLlmTurnErrorMessages {
model_unavailable: "当前模型不可用,请稍后重试。",
generation_failed: "这一轮设定生成失败,请稍后重试",
parse_failed: "模型返回结果解析失败,请稍后重试。",
},
on_reply_update,
CustomWorldTurnError::new,
)
.await?;
let parsed = turn_output.parsed;
let next_anchor_content =
normalize_eight_anchor_content(parsed.get("nextAnchorContent").unwrap_or(&JsonValue::Null));
@@ -621,9 +613,6 @@ where
};
let reply_text = to_text(parsed.get("replyText"))
.ok_or_else(|| CustomWorldTurnError::new("模型返回结果缺少有效回复,请稍后重试。"))?;
if reply_text != latest_reply_text {
on_reply_update(reply_text.as_str());
}
Ok(SingleTurnModelOutput {
next_anchor_content,
@@ -656,16 +645,14 @@ async fn resolve_dynamic_state(
chat_history,
);
let response = llm_client
.request_text(LlmTextRequest::new(vec![
LlmMessage::system(system_prompt),
LlmMessage::user(user_prompt),
]))
.await;
let Ok(response) = response else {
return fallback;
};
let Ok(parsed) = parse_json_response_text(response.content.as_str()) else {
let Ok(parsed) = request_creation_agent_json_turn(
llm_client,
system_prompt,
user_prompt,
CustomWorldTurnError::new,
)
.await
else {
return fallback;
};
build_prompt_dynamic_state(
@@ -1610,7 +1597,7 @@ impl PromptConversationMode {
#[cfg(test)]
mod tests {
use crate::custom_world_rpg_draft_prompts::extract_reply_text_from_partial_json;
use crate::creation_agent_llm_turn::extract_reply_text_from_partial_json;
#[test]
fn extract_reply_text_from_partial_json_preserves_chinese_characters() {

View File

@@ -2548,14 +2548,24 @@ mod tests {
name: Some("礁石神殿".to_string()),
description: Some("古老礁石上的半沉神殿。".to_string()),
};
let manual_prompt = build_custom_world_scene_image_prompt(
&profile_input,
&landmark,
let manual_prompt = build_custom_world_scene_image_prompt(SceneImagePromptParams {
profile: SceneImagePromptProfile {
name: profile_input.name.as_deref().unwrap_or_default(),
subtitle: profile_input.subtitle.as_deref().unwrap_or_default(),
tone: profile_input.tone.as_deref().unwrap_or_default(),
player_goal: profile_input.player_goal.as_deref().unwrap_or_default(),
summary: profile_input.summary.as_deref().unwrap_or_default(),
setting_text: profile_input.setting_text.as_deref().unwrap_or_default(),
},
landmark: SceneImagePromptLandmark {
name: landmark.name.as_deref().unwrap_or_default(),
description: landmark.description.as_deref().unwrap_or_default(),
},
user_prompt,
false,
Some("礁石神殿"),
"雾海群岛",
);
has_reference_image: false,
fallback_landmark_name: Some("礁石神殿"),
fallback_world_name: "雾海群岛",
});
let normalized = normalize_scene_image_request(CustomWorldSceneImageRequest {
profile_id: Some("profile_001".to_string()),

View File

@@ -3,4 +3,5 @@
};
pub(crate) use crate::prompt::character_visual::{
build_character_visual_negative_prompt, build_character_visual_prompt,
build_fallback_moderation_safe_character_visual_prompt,
};

View File

@@ -5,6 +5,7 @@ use crate::prompt::foundation_draft::{
build_custom_world_landmark_seed_batch_json_repair_prompt,
build_custom_world_landmark_seed_batch_prompt,
build_custom_world_role_batch_json_repair_prompt, build_custom_world_role_batch_prompt,
build_custom_world_role_outline_asset_fields_repair_prompt,
build_custom_world_role_outline_batch_json_repair_prompt,
build_custom_world_role_outline_batch_prompt,
};
@@ -274,7 +275,13 @@ async fn generate_foundation_role_outline_entries(
)
.await?;
let key = role_key(role_type);
merged_entries.extend(array_field(&raw, key).into_iter().take(batch_count));
let raw_entries = array_field(&raw, key)
.into_iter()
.take(batch_count)
.collect();
let repaired_entries =
ensure_role_outline_asset_fields(llm_client, role_type, raw_entries).await?;
merged_entries.extend(repaired_entries);
}
let merged_entries: Vec<JsonValue> = merged_entries.into_iter().take(total_count).collect();
let role_label = if role_type == "playable" {
@@ -350,6 +357,89 @@ async fn generate_foundation_landmark_seed_entries(
Ok(merged_entries)
}
async fn ensure_role_outline_asset_fields(
llm_client: &LlmClient,
role_type: &str,
entries: Vec<JsonValue>,
) -> Result<Vec<JsonValue>, String> {
let missing_report = role_asset_field_missing_report(&entries);
if missing_report.is_empty() {
return Ok(entries);
}
let key = role_key(role_type);
let expected_names = names_from_entries(&entries);
let repaired = request_foundation_json_stage(
llm_client,
build_custom_world_role_outline_asset_fields_repair_prompt(
role_type,
&entries,
missing_report.as_str(),
),
format!("agent-foundation-{role_type}-outline-asset-fields-repair").as_str(),
|response_text| {
build_custom_world_role_outline_batch_json_repair_prompt(
response_text,
role_type,
entries.len(),
&[],
)
},
format!("agent-foundation-{role_type}-outline-asset-fields-json-repair").as_str(),
"角色形象设定文本修复阶段没有返回有效内容。",
)
.await?;
let repaired_entries = array_field(&repaired, key)
.into_iter()
.take(entries.len())
.collect::<Vec<_>>();
let merged_entries = merge_entries_by_name(&entries, &repaired_entries);
validate_role_outline_asset_fields(&merged_entries, &expected_names)?;
Ok(merged_entries)
}
fn validate_role_outline_asset_fields(
entries: &[JsonValue],
expected_names: &[String],
) -> Result<(), String> {
let missing_report = role_asset_field_missing_report(entries);
if !missing_report.is_empty() {
return Err(format!(
"角色形象设定文本生成不完整:{missing_report}。请重新生成底稿。"
));
}
for expected_name in expected_names {
if !entries
.iter()
.any(|entry| json_text(entry, "name").as_deref() == Some(expected_name.as_str()))
{
return Err(format!(
"角色形象设定文本修复后缺少原角色「{expected_name}」。请重新生成底稿。"
));
}
}
Ok(())
}
fn role_asset_field_missing_report(entries: &[JsonValue]) -> String {
let mut missing_items = Vec::new();
for (index, entry) in entries.iter().enumerate() {
let name = json_text(entry, "name").unwrap_or_else(|| format!("角色{}", index + 1));
let missing_fields = [
"visualDescription",
"actionDescription",
"sceneVisualDescription",
]
.into_iter()
.filter(|field| json_text(entry, field).is_none())
.collect::<Vec<_>>();
if !missing_fields.is_empty() {
missing_items.push(format!("角色「{name}」缺少 {}", missing_fields.join("/")));
}
}
missing_items.join("")
}
async fn expand_foundation_landmark_network_entries(
llm_client: &LlmClient,
framework: &JsonValue,
@@ -594,25 +684,36 @@ fn build_foundation_generation_seed_text(session: &CustomWorldAgentSessionRecord
fn build_eight_anchor_foundation_text(anchor_content: &JsonValue) -> String {
let mut sections = Vec::new();
for key in [
"worldPromise",
"playerEntryPoint",
"coreLoop",
"mainConflict",
"keyCharacters",
"keyPlaces",
"toneAndStyle",
"firstScene",
for (key, label) in [
("worldPromise", "世界承诺"),
("playerFantasy", "玩家幻想"),
("themeBoundary", "主题边界"),
("playerEntryPoint", "玩家切入口"),
("coreConflict", "核心冲突"),
("keyRelationships", "关键关系"),
("hiddenLines", "暗线与揭示节奏"),
("iconicElements", "标志元素与硬规则"),
] {
if let Some(value) = anchor_content.get(key)
&& !value.is_null()
&& has_meaningful_anchor_value(value)
{
sections.push(format!("{key}{}", compact_json_text(value)));
// foundation draft 必须直接吃 Agent session 当前八锚点,避免旧字段名把 8 个锚点压缩成残缺 seed。
sections.push(format!("{label}{}", compact_json_text(value)));
}
}
sections.join("\n")
}
fn has_meaningful_anchor_value(value: &JsonValue) -> bool {
match value {
JsonValue::Null => false,
JsonValue::Bool(_) | JsonValue::Number(_) => true,
JsonValue::String(text) => !text.trim().is_empty(),
JsonValue::Array(items) => items.iter().any(has_meaningful_anchor_value),
JsonValue::Object(object) => object.values().any(has_meaningful_anchor_value),
}
}
#[cfg(test)]
fn build_foundation_draft_user_prompt(session: &CustomWorldAgentSessionRecord) -> String {
let anchor_content = to_pretty_json(&session.anchor_content);
@@ -1519,6 +1620,23 @@ mod tests {
use super::*;
#[test]
fn role_asset_field_missing_report_lists_visual_text_fields() {
let entries = vec![json!({
"name": "海洋生物学家",
"title": "深海观察员",
"role": "调查者",
"description": "记录异常海沟的人"
})];
let report = role_asset_field_missing_report(&entries);
assert!(report.contains("海洋生物学家"));
assert!(report.contains("visualDescription"));
assert!(report.contains("actionDescription"));
assert!(report.contains("sceneVisualDescription"));
}
#[test]
fn scene_chapter_blueprints_use_landmark_act_background_prompts() {
let landmarks = vec![json!({
@@ -1649,6 +1767,81 @@ mod tests {
assert!(!prompt.contains("seedTextcustom-world-agent-session-1"));
}
#[test]
fn foundation_seed_text_keeps_current_eight_anchor_content() {
let mut session = build_test_session();
session.anchor_content = json!({
"worldPromise": {
"hook": "海雾会吞掉记错航线的人。",
"differentiator": "每张航线图都会主动撒谎。",
"desiredExperience": "调查、压迫、反转"
},
"playerFantasy": {
"playerRole": "返乡守灯人",
"corePursuit": "找回父亲沉船真相",
"fearOfLoss": "最后一盏灯也被议会熄灭"
},
"themeBoundary": {
"toneKeywords": ["海雾悬疑", "群岛旧案"],
"aestheticDirectives": ["湿冷灯塔", "错位航线"],
"forbiddenDirectives": ["现代都市校园"]
},
"playerEntryPoint": {
"openingIdentity": "被停职返乡的守灯人",
"openingProblem": "灯塔记录被人改写",
"entryMotivation": "查清父亲沉船真相"
},
"coreConflict": {
"surfaceConflicts": ["群岛议会封锁旧档案"],
"hiddenCrisis": "沉船事故其实是一次祭灯仪式失败",
"firstTouchedConflict": "玩家回到旧灯塔时发现灯火按假航线闪烁"
},
"keyRelationships": [
{
"pairs": "玩家 / 灯童丁",
"relationshipType": "证人与守护者",
"secretOrCost": "灯童丁说出真相会失去家族庇护"
}
],
"hiddenLines": {
"hiddenTruths": ["父亲没有死在事故当晚"],
"misdirectionHints": ["议会伪造的潮汐记录"],
"revealPacing": "先露出旧档错页,再揭开祭灯失败"
},
"iconicElements": {
"iconicMotifs": ["错位灯火", "会变字的海图"],
"institutionsOrArtifacts": ["灯塔署", "群岛议会"],
"hardRules": ["海雾中读错灯语会失去一段记忆"]
}
});
session.anchor_pack = json!({
"creatorIntentSummary": "不应该回退到这个五段摘要。"
});
let seed_text = build_foundation_generation_seed_text(&session);
for label in [
"世界承诺",
"玩家幻想",
"主题边界",
"玩家切入口",
"核心冲突",
"关键关系",
"暗线与揭示节奏",
"标志元素与硬规则",
] {
assert!(
seed_text.contains(label),
"seed text should include {label}"
);
}
assert!(seed_text.contains("返乡守灯人"));
assert!(seed_text.contains("父亲没有死在事故当晚"));
assert!(!seed_text.contains("不应该回退到这个五段摘要"));
assert!(!seed_text.contains("coreLoop"));
assert!(!seed_text.contains("mainConflict"));
}
#[test]
fn build_draft_foundation_action_payload_json_injects_generated_profile() {
let payload = ExecuteCustomWorldAgentActionRequest {
@@ -1701,6 +1894,60 @@ mod tests {
);
}
#[tokio::test]
async fn role_outline_missing_asset_fields_are_repaired_before_details() {
let request_capture = Arc::new(Mutex::new(Vec::new()));
let server_url = spawn_mock_server(
request_capture.clone(),
vec![llm_response(
r#"{"storyNpcs":[{"name":"海洋生物学家","title":"深海观察员","role":"调查者","description":"记录异常海沟的人","visualDescription":"防水研究外套挂满盐痕,护目镜映着蓝绿海光,手提样本箱。","actionDescription":"蹲身取样并快速记录潮汐数据,遇险时护住样本箱后撤。","sceneVisualDescription":"她常站在潮湿实验船甲板边,身后是发光海沟与摇晃仪器。","initialAffinity":18,"relationshipHooks":["深海样本"],"tags":["科学家"]}]}"#,
)],
);
let llm_client = build_test_llm_client(server_url);
let entries = vec![json!({
"name": "海洋生物学家",
"title": "深海观察员",
"role": "调查者",
"description": "记录异常海沟的人",
"initialAffinity": 18,
"relationshipHooks": ["深海样本"],
"tags": ["科学家"]
})];
let repaired = ensure_role_outline_asset_fields(&llm_client, "story", entries)
.await
.expect("missing asset fields should be repaired");
let captured_requests = request_capture
.lock()
.expect("request capture should lock")
.clone();
let request_text = captured_requests.join("\n---request---\n");
assert_eq!(captured_requests.len(), 1);
assert!(request_text.contains("角色「海洋生物学家」缺少 visualDescription"));
assert_eq!(
repaired
.first()
.and_then(|entry| entry.get("visualDescription"))
.and_then(JsonValue::as_str),
Some("防水研究外套挂满盐痕,护目镜映着蓝绿海光,手提样本箱。")
);
assert_eq!(
repaired
.first()
.and_then(|entry| entry.get("actionDescription"))
.and_then(JsonValue::as_str),
Some("蹲身取样并快速记录潮汐数据,遇险时护住样本箱后撤。")
);
assert_eq!(
repaired
.first()
.and_then(|entry| entry.get("sceneVisualDescription"))
.and_then(JsonValue::as_str),
Some("她常站在潮湿实验船甲板边,身后是发光海沟与摇晃仪器。")
);
}
#[tokio::test]
async fn generate_custom_world_foundation_draft_uses_seed_text_and_normalizes_fields() {
let request_capture = Arc::new(Mutex::new(Vec::new()));
@@ -1711,19 +1958,19 @@ mod tests {
r#"{"name":"雾港归航","subtitle":"失灯旧案","summary":"守灯人与群岛议会围绕沉船旧案对峙。","tone":"海雾悬疑","playerGoal":"查清父亲沉船真相","templateWorldType":"WUXIA","majorFactions":["群岛议会","灯塔署"],"coreConflicts":["守灯塔的旧档案被人改写。"],"camp":{"name":"旧灯塔归舍","description":"海雾边缘的守灯人旧居。"}}"#,
),
llm_response(
r#"{"playableNpcs":[{"name":"岑灯","title":"返乡守灯人","role":"主角代理","description":"追查旧案的人","initialAffinity":24,"relationshipHooks":["旧案牵连"],"tags":["守灯人"]}]}"#,
r#"{"playableNpcs":[{"name":"岑灯","title":"返乡守灯人","role":"主角代理","description":"追查旧案的人","visualDescription":"灰蓝旧灯披风压着海盐痕,腰侧挂旧海图筒和短灯杖。","actionDescription":"举灯照海图,短杖点地辨认潮声。","sceneVisualDescription":"旧灯塔回廊被海雾压低,墙上挂满潮湿航线图。","initialAffinity":24,"relationshipHooks":["旧案牵连"],"tags":["守灯人"]}]}"#,
),
llm_response(
r#"{"storyNpcs":[{"name":"议长甲","title":"群岛议长","role":"遮掩者","description":"压住旧档的人","visualDescription":"深色议会长袍垂到靴边,银扣像封蜡,手里总夹着旧档袋。","actionDescription":"抬手下令封锁,动作缓慢却压迫感强。","sceneVisualDescription":"他常出现在议会石厅高处,旧档柜阴影切过半张脸。","initialAffinity":-10,"relationshipHooks":["旧档案"],"tags":["议会"]},{"name":"潮医乙","title":"潮汐医师","role":"证人","description":"知道沉船伤痕","initialAffinity":20,"relationshipHooks":["救治记录"],"tags":["证人"]}]}"#,
r#"{"storyNpcs":[{"name":"议长甲","title":"群岛议长","role":"遮掩者","description":"压住旧档的人","visualDescription":"深色议会长袍垂到靴边,银扣像封蜡,手里总夹着旧档袋。","actionDescription":"抬手下令封锁,动作缓慢却压迫感强。","sceneVisualDescription":"他常出现在议会石厅高处,旧档柜阴影切过半张脸。","initialAffinity":-10,"relationshipHooks":["旧档案"],"tags":["议会"]},{"name":"潮医乙","title":"潮汐医师","role":"证人","description":"知道沉船伤痕","visualDescription":"浅灰防潮医袍挽到肘部,药箱铜扣发暗,袖口沾着海盐。","actionDescription":"俯身检查伤痕并快速记录潮汐症状,动作谨慎而利落。","sceneVisualDescription":"他常在沉船湾临时诊台前,背后是湿木棚和摇晃药灯。","initialAffinity":20,"relationshipHooks":["救治记录"],"tags":["证人"]}]}"#,
),
llm_response(
r#"{"storyNpcs":[{"name":"雾商丙","title":"雾港商人","role":"中间人","description":"贩卖航线的人","initialAffinity":5,"relationshipHooks":["伪造海图"],"tags":["商人"]},{"name":"灯童丁","title":"灯塔学徒","role":"目击者","description":"听见夜钟的人","initialAffinity":30,"relationshipHooks":["夜钟"],"tags":["学徒"]}]}"#,
r#"{"storyNpcs":[{"name":"雾商丙","title":"雾港商人","role":"中间人","description":"贩卖航线的人","visualDescription":"暗绿长外套挂满防水口袋,帽檐压低,腰间藏着卷曲海图。","actionDescription":"摊开假海图低声议价,手指总按着袖中短刃。","sceneVisualDescription":"他常站在雾港货棚阴影里,周围堆着封蜡货箱和潮湿灯牌。","initialAffinity":5,"relationshipHooks":["伪造海图"],"tags":["商人"]},{"name":"灯童丁","title":"灯塔学徒","role":"目击者","description":"听见夜钟的人","visualDescription":"瘦小学徒披着过大的灯塔制服,怀里抱黄铜小灯和旧钥匙。","actionDescription":"抱灯快步穿过回廊,听见夜钟时会突然停住回头。","sceneVisualDescription":"他常出现在灯塔螺旋楼梯间,雾光从窄窗切进灰墙。","initialAffinity":30,"relationshipHooks":["夜钟"],"tags":["学徒"]}]}"#,
),
llm_response(
r#"{"storyNpcs":[{"name":"船魂戊","title":"沉船残魂","role":"异类","description":"困在潮声里","initialAffinity":-20,"relationshipHooks":["沉船真相"],"tags":["异类"]},{"name":"巡海己","title":"巡海队长","role":"追捕者","description":"封锁海岸线","initialAffinity":-15,"relationshipHooks":["封锁令"],"tags":["巡海"]}]}"#,
r#"{"storyNpcs":[{"name":"船魂戊","title":"沉船残魂","role":"异类","description":"困在潮声里","visualDescription":"半透明水渍轮廓披着破碎船员衣,胸口嵌着发暗船钉。","actionDescription":"随潮声漂移抬手指路,情绪激烈时水雾会拉长身影。","sceneVisualDescription":"它常浮在沉船湾退潮泥滩上,身后旧船骨像黑色肋骨。","initialAffinity":-20,"relationshipHooks":["沉船真相"],"tags":["异类"]},{"name":"巡海己","title":"巡海队长","role":"追捕者","description":"封锁海岸线","visualDescription":"深蓝巡海甲衣覆着雨水,肩章锋利,手握带灯长枪。","actionDescription":"举枪封路并用灯束扫过海岸,步伐整齐带压迫感。","sceneVisualDescription":"他常立在封锁栈桥尽头,巡海灯和铁链把退路切断。","initialAffinity":-15,"relationshipHooks":["封锁令"],"tags":["巡海"]}]}"#,
),
llm_response(
r#"{"storyNpcs":[{"name":"档吏庚","title":"旧档吏","role":"保管者","description":"藏起原始卷宗","initialAffinity":10,"relationshipHooks":["原始卷宗"],"tags":["档案"]},{"name":"潮女辛","title":"听潮女","role":"引路人","description":"听懂海雾低语","initialAffinity":35,"relationshipHooks":["海雾低语"],"tags":["引路"]}]}"#,
r#"{"storyNpcs":[{"name":"档吏庚","title":"旧档吏","role":"保管者","description":"藏起原始卷宗","visualDescription":"褐色旧档袍袖口磨白,背着沉重文书匣,眼镜后目光闪躲。","actionDescription":"翻找卷宗时动作极快,被追问便把文书匣抱紧后退。","sceneVisualDescription":"他常守在潮湿档案室深处,旧柜标签被盐雾泡卷。","initialAffinity":10,"relationshipHooks":["原始卷宗"],"tags":["档案"]},{"name":"潮女辛","title":"听潮女","role":"引路人","description":"听懂海雾低语","visualDescription":"银灰长发被贝壳绳束起,披轻薄潮纹披肩,赤足沾水。","actionDescription":"侧耳听潮后抬手指向雾中路径,步伐像避开暗流。","sceneVisualDescription":"她常站在礁石浅水间,海雾绕过脚踝,远处灯火错位。","initialAffinity":35,"relationshipHooks":["海雾低语"],"tags":["引路"]}]}"#,
),
llm_response(
r#"{"landmarks":[{"name":"旧灯塔","description":"雾中仍亮着错位灯火"},{"name":"沉船湾","description":"退潮后露出旧船骨"}]}"#,

View File

@@ -1,515 +1 @@
use crate::creation_agent_chat::render_quick_fill_extra_rules;
use crate::custom_world_agent_turn::{
EightAnchorContent, PromptConversationMode, PromptDriftRisk, PromptDynamicState,
PromptUserInputSignal,
};
use module_custom_world::empty_agent_anchor_content_json;
use serde_json::Value as JsonValue;
pub(crate) const BASE_SYSTEM_PROMPT: &str = r#"你是一个负责共创游戏世界设定的专业策划。
你正在和用户一起共创一个游戏世界。每一轮你都必须读取:
1. 当前完整设定结构
2. 用户聊天记录
然后输出:
1. 一版新的完整设定结构
2. 当前 progress 百分比
3. 一段直接回复用户的话
你必须把“新的完整设定结构”视为下一轮的唯一有效版本。
你的输出会直接覆盖上一版设定结构。
你不是在做局部 patch。
你不是在做解释报告。
你不是在给开发者写分析。
你是在同时完成:
1. 世界设定更新
2. 当前推进程度判断
3. 对用户的共创回复"#;
pub(crate) const GLOBAL_HARD_RULES: &str = r#"全局硬约束:
1. 必须输出完整的设定结构,而不是只输出变化部分。
2. 新的设定结构会直接覆盖旧内容,因此不得随意丢失仍然成立的重要信息。
3. 如果用户明确修正旧设定,必须在新的设定结构中直接体现修正结果。
4. 如果用户输入信息不足,可以保留上一版中仍然成立的内容。
5. progressPercent 最低为 0不允许为负数。
6. replyText 会直接发送给用户,因此要自然、直接、可继续聊天。
7. 不要输出额外解释,不要输出 markdown 代码块,不要输出开发备注。
8. replyText 不要写成长篇策划文,不要展开大段世界观百科。
9. replyText 默认只推进当前最关键的一步,不要同时抛出很多话题。
10. replyText 不要提及“八锚点”“锚点”“结构字段”“框架字段”等内部概念词。
11. 你输出的 JSON 必须可以被直接解析。
12. 输出字段顺序必须固定为replyText、progressPercent、nextAnchorContent。"#;
pub(crate) fn quick_fill_extra_rules() -> String {
render_quick_fill_extra_rules(
"当前 RPG 世界方向里的剩余设定",
"不要要求用户再提供世界观、角色、冲突或禁忌信息",
"直接输出一版尽量完整的设定结构",
"进入“生成游戏设定草稿”",
)
}
pub(crate) const STATE_INFERENCE_SYSTEM_PROMPT: &str = r#"你是正式生成世界设定前的一步“创作状态识别器”。
你的职责不是直接生成新设定,而是先判断:下一轮正式生成应该用什么推进策略,尤其要判断 replyText 应该更偏确认、吸收、收束、纠偏,还是启发式提问。
你必须综合以下信息判断:
1. 当前轮次 currentTurn
2. 当前完成度 progressPercent
3. 用户是否要求自动补全 quickFillRequested
4. 当前完整设定结构
5. 最近聊天记录,尤其是最近 1 到 3 轮用户消息
你需要输出 4 个字段:
1. userInputSignal只能是 rich / normal / sparse / correction / delegate
2. driftRisk只能是 low / medium / high
3. conversationMode只能是 bootstrap / expand / compress / repair_direction / force_complete / closing
4. judgementSummary1 到 2 句中文,概括你为什么这样判断,以及正式生成时最该注意什么
请按下面的语义判断。
一、userInputSignal 定义
1. rich
- 用户这一轮给了多条可直接落地的有效信息
- 这些信息可能同时覆盖世界方向、玩家处境、开局事件、冲突、关系、标志元素中的多个
- 正式生成时应优先高密度吸收,不要只更新一个点
2. normal
- 用户在顺着当前方向做正常补充
- 信息量中等,有明确新增内容,但没有明显推翻旧方向,也没有把决定权交给系统
- 正式生成时应稳定推进并自然接住用户内容
3. sparse
- 用户输入很短、很虚、很笼统,或几乎没有新增有效事实
- 例如只有一个题材词、一个气质词、一句很概括的话、一个很短的倾向表达
- 这种情况下,正式生成阶段的 replyText 应优先采用启发式提问
- 启发式提问的要求是:只问一个最容易回答、最能推动落地设计的问题
4. correction
- 用户这轮核心动作是在修正、替换、推翻、重定向旧设定
- 即使文字不长,只要主意图是“之前那个不对,现在改成这个”,也应优先判为 correction
- correction 的优先级高于 rich 和 normal
5. delegate
- 用户把部分决定权交给系统
- 例如“你来定”“你帮我补”“按你觉得合理的来”“先给我一个默认方案”
- delegate 关注的是授权关系,不只是信息多寡
二、driftRisk 定义
1. low
- 当前轮输入与已有方向基本一致
- 没有明显改口或冲突
2. medium
- 当前轮带来一定方向变化或扩张
- 还没有明显推翻旧方向,但如果处理不好,容易让设定开始发散
3. high
- 用户明确纠偏、改口、替换方向,或最近多轮反复修正
- 这时最重要的是防止旧方向重新回流到正式生成结果里
三、conversationMode 选择原则
1. bootstrap
- 适用于前期、信息少、核心方向未稳定
- replyText 更适合低压力确认和单点启发
2. expand
- 适用于方向已成形,正在顺着现有路线继续补充
- replyText 更适合总结已接住的内容并往前推一步
3. compress
- 适用于中后段,已有骨架,需要开始收束
- replyText 更适合聚焦最关键缺口,而不是继续开支线
4. repair_direction
- 适用于用户正在纠偏
- replyText 更适合先承认修正,再沿修正后的方向继续推进
5. force_complete
- 适用于用户明确要求自动补全
- replyText 不再提问,而应给出完成感和下一步引导
6. closing
- 适用于接近完成但并非强制一键补全
- replyText 更像确认与收束,而不是前期式探索
四、优先级规则
1. 如果 quickFillRequested 为 trueconversationMode 必须优先判为 force_complete
2. 如果用户核心意图是修正旧方向userInputSignal 优先判为 correctionconversationMode 通常优先考虑 repair_direction
3. 如果用户核心意图是授权系统替他补完userInputSignal 优先判为 delegate
4. 只有在没有明显纠偏、也没有明确自动补全要求时,才主要依据 currentTurn、progressPercent 和信息密度,在 bootstrap / expand / compress / closing 之间选择
五、关于 replyText 风格的专门判断要求
1. 如果用户输入较少、较虚或不够落地,正式生成阶段的 replyText 应采用启发式提问
2. 启发式提问一次最多只能提 1 个问题,不能连问两个或更多
3. 启发式提问必须问“最能推动当前设计落地”的那个问题,而不是泛泛而谈
4. 如果用户输入已经足够 rich就不要再机械提问优先吸收和推进
5. 如果用户在 correction 或 delegate 状态下replyText 是否提问要服从更高目标:纠偏生效或代为补全,不要机械套 sparse 的问法
六、关于 replyText 用语的硬约束
1. replyText 禁止提及内部结构名、锚点名、字段名、schema 名、框架词
2. 禁止出现这类内部表达:世界承诺、玩家幻想、主题边界、玩家入口、核心冲突、关键关系、隐藏线、标志元素、字段、结构、模块、八锚点
3. replyText 只能用通俗、直接、面向创作沟通的语言回应用户
4. replyText 应该围绕用户正在讨论的具体内容来落地,比如身份、开场处境、冲突、人物关系、地点、规则、气质,而不是抽象谈结构
5. judgementSummary 可以简洁提到“这轮更适合启发式提问”或“这轮应优先吸收修正”,但也不要堆内部术语
七、关于 judgementSummary 的写法
1. 必须简洁,不要写成长篇分析
2. 必须直接服务于下一轮正式生成
3. 最好同时包含两层信息:
- 为什么这么判断
- 正式生成时最该优先做什么,或最该避免什么
八、硬性约束
1. 只能输出 JSON不能输出解释、代码块或额外说明
2. 不能发明上下文里不存在的设定事实
3. 你的任务是“判断生成策略”,不是“代替正式生成直接写新设定”
4. 即使信息不完全,也必须在给定枚举里选出最合理的一组状态
5. judgementSummary 必须是中文
6. 输出值必须严格落在给定枚举中"#;
pub(crate) const STATE_INFERENCE_OUTPUT_CONTRACT: &str = r#"请严格按以下 JSON 结构输出,不要输出其他文字:
{
"userInputSignal": "normal",
"driftRisk": "low",
"conversationMode": "expand",
"judgementSummary": ""
}"#;
pub(crate) const OUTPUT_CONTRACT_REMINDER: &str = r#"请严格按以下 JSON 结构输出,不要输出其他文字:
{
"replyText": "",
"progressPercent": 0,
"nextAnchorContent": {
"worldPromise": {
"hook": "",
"differentiator": "",
"desiredExperience": ""
},
"playerFantasy": {
"playerRole": "",
"corePursuit": "",
"fearOfLoss": ""
},
"themeBoundary": {
"toneKeywords": [],
"aestheticDirectives": [],
"forbiddenDirectives": []
},
"playerEntryPoint": {
"openingIdentity": "",
"openingProblem": "",
"entryMotivation": ""
},
"coreConflict": {
"surfaceConflicts": [],
"hiddenCrisis": "",
"firstTouchedConflict": ""
},
"keyRelationships": [
{
"pairs": "",
"relationshipType": "",
"secretOrCost": ""
}
],
"hiddenLines": {
"hiddenTruths": [],
"misdirectionHints": [],
"revealPacing": ""
},
"iconicElements": {
"iconicMotifs": [],
"institutionsOrArtifacts": [],
"hardRules": []
}
}
}"#;
pub(crate) fn render_dynamic_state_context(dynamic_state: &PromptDynamicState) -> String {
format!(
"上一轮预判得到的创作状态如下。\n正式生成时必须把它作为本轮策略输入直接执行,不要重新另起一套判断。\n\n创作状态:\n- userInputSignal: {}\n- driftRisk: {}\n- conversationMode: {}\n- judgementSummary: {}",
dynamic_state.user_input_signal.as_str(),
dynamic_state.drift_risk.as_str(),
dynamic_state.conversation_mode.as_str(),
dynamic_state.judgement_summary
)
}
pub(crate) fn render_current_anchor_context(anchor_content: &EightAnchorContent) -> String {
format!(
"当前完整设定结构如下。\n你必须把它视为上一版有效世界底子。\n\n如果用户没有否定其中某部分内容,且该部分仍然成立,可以继续保留。\n如果用户明确修正了某部分内容,新的完整设定结构必须体现修正后的版本。\n\n当前完整设定结构:\n{}",
serde_json::to_string_pretty(anchor_content)
.unwrap_or_else(|_| empty_agent_anchor_content_json())
)
}
pub(crate) fn render_chat_history_context(chat_history: &[JsonValue]) -> String {
format!(
"以下是用户聊天记录。\n请重点理解最近几轮里用户新增、修正、强调的设定信息。\n不要把早期已经被用户否定的内容继续当成最终结论。\n\n用户聊天记录:\n{}",
serde_json::to_string_pretty(chat_history).unwrap_or_else(|_| "[]".to_string())
)
}
pub(crate) fn parse_json_response_text(text: &str) -> Result<JsonValue, serde_json::Error> {
let trimmed = text.trim();
if let Some(start) = trimmed.find('{')
&& let Some(end) = trimmed.rfind('}')
&& end > start
{
return serde_json::from_str::<JsonValue>(&trimmed[start..=end]);
}
serde_json::from_str::<JsonValue>(trimmed)
}
pub(crate) fn extract_reply_text_from_partial_json(text: &str) -> Option<String> {
let key_index = text.find("\"replyText\"")?;
let colon_index = text[key_index..].find(':')? + key_index;
let mut cursor = colon_index + 1;
while cursor < text.len() && text.as_bytes()[cursor].is_ascii_whitespace() {
cursor += 1;
}
if text.as_bytes().get(cursor).copied() != Some(b'"') {
return None;
}
cursor += 1;
let mut decoded = String::new();
let remainder = text.get(cursor..)?;
let mut characters = remainder.chars().peekable();
while let Some(current) = characters.next() {
if current == '"' {
return Some(decoded);
}
if current == '\\' {
let escaped = characters.next()?;
match escaped {
'"' => decoded.push('"'),
'\\' => decoded.push('\\'),
'/' => decoded.push('/'),
'b' => decoded.push('\u{0008}'),
'f' => decoded.push('\u{000C}'),
'n' => decoded.push('\n'),
'r' => decoded.push('\r'),
't' => decoded.push('\t'),
'u' => {
let mut hex = String::new();
for _ in 0..4 {
hex.push(characters.next()?);
}
if let Ok(code) = u16::from_str_radix(hex.as_str(), 16)
&& let Some(character) = char::from_u32(code as u32)
{
decoded.push(character);
}
}
other => decoded.push(other),
}
continue;
}
decoded.push(current);
}
Some(decoded)
}
pub(crate) fn parse_user_input_signal(value: Option<&JsonValue>) -> Option<PromptUserInputSignal> {
match value.and_then(JsonValue::as_str)? {
"rich" => Some(PromptUserInputSignal::Rich),
"normal" => Some(PromptUserInputSignal::Normal),
"sparse" => Some(PromptUserInputSignal::Sparse),
"correction" => Some(PromptUserInputSignal::Correction),
"delegate" => Some(PromptUserInputSignal::Delegate),
_ => None,
}
}
pub(crate) fn parse_drift_risk(value: Option<&JsonValue>) -> Option<PromptDriftRisk> {
match value.and_then(JsonValue::as_str)? {
"low" => Some(PromptDriftRisk::Low),
"medium" => Some(PromptDriftRisk::Medium),
"high" => Some(PromptDriftRisk::High),
_ => None,
}
}
pub(crate) fn parse_conversation_mode(value: Option<&JsonValue>) -> Option<PromptConversationMode> {
match value.and_then(JsonValue::as_str)? {
"bootstrap" => Some(PromptConversationMode::Bootstrap),
"expand" => Some(PromptConversationMode::Expand),
"compress" => Some(PromptConversationMode::Compress),
"repair_direction" => Some(PromptConversationMode::RepairDirection),
"force_complete" => Some(PromptConversationMode::ForceComplete),
"closing" => Some(PromptConversationMode::Closing),
_ => None,
}
}
pub(crate) fn mode_rules(mode: PromptConversationMode) -> &'static str {
match mode {
PromptConversationMode::Bootstrap => {
r#"当前模式bootstrap
目标:
1. 先把世界的基本方向抓住
2. 不要一次塞太多新设定
3. 回复要降低用户开口压力
本轮行为要求:
1. 优先从用户输入里抓世界方向、玩家视角、主题边界的线索
2. 如果用户信息很少,不要强行把整套结构一次补满
3. replyText 要像共创搭档,而不是像审问
4. 默认只推进一个最关键的问题方向
5. 如果用户刚开口,优先给“被理解感”,再轻轻推进下一步
6. 可以用一句很短的话先确认你抓到的核心方向,再提一个最好回答的问题
7. 不要把问题问得像表单采集,不要一口气追问多个维度
用户体验要求:
1. 让用户觉得“现在很容易继续往下说”
2. 不要制造被考试、被拷问、被策划问卷追着跑的感觉
3. replyText 最好短、稳、可接话
4. 如果用户信息很少,也不要显得冷淡或机械"#
}
PromptConversationMode::Expand => {
r#"当前模式expand
目标:
1. 在保持现有方向的前提下,把设定结构逐步补全
2. 尽量让一轮输入覆盖多个关键维度
本轮行为要求:
1. 继续保留上一版里仍成立的设定
2. 优先把用户本轮输入映射进多个关键维度,而不是只更新一个字段
3. replyText 要明确体现“你已经理解了哪些内容”
4. 不要突然大幅改写已经成形的世界
5. 如果用户这一轮给了多条有效信息replyText 应先把这些信息自然串起来,再决定下一步
6. 可以适度替用户整理,但不要把回复写成总结报告
7. 默认继续往前推一步,不要在还没必要时突然收束或突然跳到成稿感
用户体验要求:
1. 让用户感到“我刚说的内容都被接住了”
2. 回复里可以带一点顺势整理感,但不要太像会议纪要
3. 不要无视用户刚提供的高价值细节
4. 不要让用户觉得系统在自顾自重写世界"#
}
PromptConversationMode::Compress => {
r#"当前模式compress
目标:
1. 开始收束当前设定
2. 减少无效发散
3. 让 progress 更接近可进入下一阶段
本轮行为要求:
1. 新的设定结构优先保留稳定内容,不要无端重写
2. 对用户本轮输入做高密度吸收
3. replyText 要更聚焦,不要绕圈
4. 默认只推进当前最影响 completion 的一步
5. 如果用户还在补细节,优先把细节挂回现有骨架,而不是继续开新分支
6. 可以适度提醒“还差哪类关键空位”,但不要把回复写成 checklist
7. 如果已有信息足够replyText 可以更像“确认并收束”,少一点继续发散式追问
用户体验要求:
1. 让用户感觉世界正在变得更稳,而不是越来越散
2. 让推进感更明确,但不要显得催促
3. 回复语气应更笃定一些,减少反复横跳
4. 不要把用户刚补进来的细节又冲淡掉"#
}
PromptConversationMode::RepairDirection => {
r#"当前模式repair_direction
目标:
1. 处理用户对既有设定的修正
2. 避免世界方向飘散或自相矛盾
本轮行为要求:
1. 如果用户明确改口,新的设定结构必须体现修正后的方向
2. 对已经不再成立的旧设定,不要机械保留
3. progressPercent 可以停滞,也可以小幅回落,但不能为负
4. replyText 要承认用户的修正,并顺着修正后的方向继续聊
5. 先处理“改掉什么”,再决定“往哪里继续推”
6. 不要一边口头承认用户修正,一边在设定结构里偷偷留住旧方向
7. 如果修正幅度很大replyText 可以帮助用户确认新方向已经接管当前语境
用户体验要求:
1. 让用户感到“我刚刚的纠偏真的生效了”
2. 不要和用户辩论旧方案为什么也行
3. 不要表现出对修正的不情愿
4. 回复要体现重心已经切到新方向,而不是停留在旧世界观惯性里"#
}
PromptConversationMode::ForceComplete => {
r#"当前模式force_complete
目标:
1. 基于当前方向直接补齐剩余设定
2. 生成一版尽量完整、可进入下一阶段的设定结构
3. 结束当前收集阶段
本轮行为要求:
1. 尽量保留已经形成的世界方向
2. 对明显缺失的关键维度进行合理补全
3. 不要继续拉长聊天,不要再追问用户
4. progressPercent 直接输出为 100
5. replyText 要自然引导用户点击“生成游戏设定草稿”
6. 补全时要优先做“顺着已有方向补齐”,而不是突然换题材、换气质、换主冲突
7. 可以让结果更完整,但不要补得过满、过死、过像定稿圣经
8. replyText 更像阶段完成提示,不再像继续采集信息的对话
用户体验要求:
1. 让用户感到“系统已经帮我把能补的补好了”
2. 不要在这一步突然冒出很多陌生设定把用户吓出戏
3. 回复要有完成感,但不要太官话
4. 清楚告诉用户下一步可以做什么"#
}
PromptConversationMode::Closing => {
r#"当前模式closing
目标:
1. 尽量形成一版可用的设定底子
2. 不再继续发散新世界观
本轮行为要求:
1. 优先收束,而不是扩写
2. 不要大改已经成形的核心设定
3. progressPercent 接近完成时replyText 要更像确认与推进
4. 如果用户没有大改方向,尽量让下一版内容更稳定
5. 可以轻微补足缺口,但不要再大开新支线
6. replyText 应减少探索式措辞,增加“已经基本成形”的稳定感
7. 如果只差少量空位,优先把这些空位自然补平,而不是重新打开大话题
用户体验要求:
1. 让用户感觉作品已经快成了,而不是还在无穷试探
2. 回复可以更像确认和轻推,不要继续像前期那样频繁试探
3. 保持留白感,不要把所有东西都一次说死
4. 让用户自然过渡到下一阶段,而不是突然被切断对话"#
}
}
}
pub(crate) fn user_signal_rules(signal: PromptUserInputSignal) -> &'static str {
match signal {
PromptUserInputSignal::Rich => {
r#"本轮用户输入信息密度高。
请尽量从这一轮里提取多个锚点,不要只更新单一方向。
如果一条输入同时影响世界方向、冲突和关系,请在新的完整设定结构中一起体现。"#
}
PromptUserInputSignal::Normal => {
r#"本轮用户输入为正常补充。
请优先顺着当前方向稳定更新,不要主动扩写太多新设定。"#
}
PromptUserInputSignal::Sparse => {
r#"本轮用户输入较少或较虚。
请保留上一版中仍然成立的内容,不要为了凑完整度而强行发明过多新设定。
replyText 要让用户容易继续往下说。"#
}
PromptUserInputSignal::Correction => {
r#"本轮用户在修正或推翻旧设定。
请优先吸收修正,不要机械复读旧版本。
新的完整设定结构必须以修正后的方向为准。"#
}
PromptUserInputSignal::Delegate => {
r#"本轮用户把部分决定权交给你。
你可以在 replyText 中给出有限度的建议,但不要突然补满整套设定。
新的完整设定结构仍应尽量建立在已有世界方向上,而不是完全重做。"#
}
}
}
pub(crate) use crate::prompt::agent_chat::*;

View File

@@ -17,6 +17,8 @@ mod character_visual_assets;
mod config;
mod creation_agent_anchor_templates;
mod creation_agent_chat;
mod creation_agent_document_input;
mod creation_agent_llm_turn;
mod custom_world;
mod custom_world_agent_entities;
mod custom_world_agent_turn;

View File

@@ -0,0 +1,455 @@
use crate::creation_agent_chat::render_quick_fill_extra_rules;
use crate::custom_world_agent_turn::{
EightAnchorContent, PromptConversationMode, PromptDriftRisk, PromptDynamicState,
PromptUserInputSignal,
};
use module_custom_world::empty_agent_anchor_content_json;
use serde_json::Value as JsonValue;
pub(crate) const BASE_SYSTEM_PROMPT: &str = r#"你是一个负责共创游戏世界设定的专业策划。
你正在和用户一起共创一个游戏世界。每一轮你都必须读取:
1. 当前完整设定结构
2. 用户聊天记录
然后输出:
1. 一版新的完整设定结构
2. 当前 progress 百分比
3. 一段直接回复用户的话
你必须把“新的完整设定结构”视为下一轮的唯一有效版本。
你的输出会直接覆盖上一版设定结构。
你不是在做局部 patch。
你不是在做解释报告。
你不是在给开发者写分析。
你是在同时完成:
1. 世界设定更新
2. 当前推进程度判断
3. 对用户的共创回复"#;
pub(crate) const GLOBAL_HARD_RULES: &str = r#"全局硬约束:
1. 必须输出完整的设定结构,而不是只输出变化部分。
2. 新的设定结构会直接覆盖旧内容,因此不得随意丢失仍然成立的重要信息。
3. 如果用户明确修正旧设定,必须在新的设定结构中直接体现修正结果。
4. 如果用户输入信息不足,可以保留上一版中仍然成立的内容。
5. progressPercent 最低为 0不允许为负数。
6. replyText 会直接发送给用户,因此要自然、直接、可继续聊天。
7. 不要输出额外解释,不要输出 markdown 代码块,不要输出开发备注。
8. replyText 不要写成长篇策划文,不要展开大段世界观百科。
9. replyText 默认只推进当前最关键的一步,不要同时抛出很多话题。
10. replyText 不要提及“八锚点”“锚点”“结构字段”“框架字段”等内部概念词。
11. 你输出的 JSON 必须可以被直接解析。
12. 输出字段顺序必须固定为replyText、progressPercent、nextAnchorContent。"#;
pub(crate) fn quick_fill_extra_rules() -> String {
render_quick_fill_extra_rules(
"当前 RPG 世界方向里的剩余设定",
"不要要求用户再提供世界观、角色、冲突或禁忌信息",
"直接输出一版尽量完整的设定结构",
"进入“生成游戏设定草稿”",
)
}
pub(crate) const STATE_INFERENCE_SYSTEM_PROMPT: &str = r#"你是正式生成世界设定前的一步“创作状态识别器”。
你的职责不是直接生成新设定,而是先判断:下一轮正式生成应该用什么推进策略,尤其要判断 replyText 应该更偏确认、吸收、收束、纠偏,还是启发式提问。
你必须综合以下信息判断:
1. 当前轮次 currentTurn
2. 当前完成度 progressPercent
3. 用户是否要求自动补全 quickFillRequested
4. 当前完整设定结构
5. 最近聊天记录,尤其是最近 1 到 3 轮用户消息
你需要输出 4 个字段:
1. userInputSignal只能是 rich / normal / sparse / correction / delegate
2. driftRisk只能是 low / medium / high
3. conversationMode只能是 bootstrap / expand / compress / repair_direction / force_complete / closing
4. judgementSummary1 到 2 句中文,概括你为什么这样判断,以及正式生成时最该注意什么
请按下面的语义判断。
一、userInputSignal 定义
1. rich
- 用户这一轮给了多条可直接落地的有效信息
- 这些信息可能同时覆盖世界方向、玩家处境、开局事件、冲突、关系、标志元素中的多个
- 正式生成时应优先高密度吸收,不要只更新一个点
2. normal
- 用户在顺着当前方向做正常补充
- 信息量中等,有明确新增内容,但没有明显推翻旧方向,也没有把决定权交给系统
- 正式生成时应稳定推进并自然接住用户内容
3. sparse
- 用户输入很短、很虚、很笼统,或几乎没有新增有效事实
- 例如只有一个题材词、一个气质词、一句很概括的话、一个很短的倾向表达
- 这种情况下,正式生成阶段的 replyText 应优先采用启发式提问
- 启发式提问的要求是:只问一个最容易回答、最能推动落地设计的问题
4. correction
- 用户这轮核心动作是在修正、替换、推翻、重定向旧设定
- 即使文字不长,只要主意图是“之前那个不对,现在改成这个”,也应优先判为 correction
- correction 的优先级高于 rich 和 normal
5. delegate
- 用户把部分决定权交给系统
- 例如“你来定”“你帮我补”“按你觉得合理的来”“先给我一个默认方案”
- delegate 关注的是授权关系,不只是信息多寡
二、driftRisk 定义
1. low
- 当前轮输入与已有方向基本一致
- 没有明显改口或冲突
2. medium
- 当前轮带来一定方向变化或扩张
- 还没有明显推翻旧方向,但如果处理不好,容易让设定开始发散
3. high
- 用户明确纠偏、改口、替换方向,或最近多轮反复修正
- 这时最重要的是防止旧方向重新回流到正式生成结果里
三、conversationMode 选择原则
1. bootstrap
- 适用于前期、信息少、核心方向未稳定
- replyText 更适合低压力确认和单点启发
2. expand
- 适用于方向已成形,正在顺着现有路线继续补充
- replyText 更适合总结已接住的内容并往前推一步
3. compress
- 适用于中后段,已有骨架,需要开始收束
- replyText 更适合聚焦最关键缺口,而不是继续开支线
4. repair_direction
- 适用于用户正在纠偏
- replyText 更适合先承认修正,再沿修正后的方向继续推进
5. force_complete
- 适用于用户明确要求自动补全
- replyText 不再提问,而应给出完成感和下一步引导
6. closing
- 适用于接近完成但并非强制一键补全
- replyText 更像确认与收束,而不是前期式探索
四、优先级规则
1. 如果 quickFillRequested 为 trueconversationMode 必须优先判为 force_complete
2. 如果用户核心意图是修正旧方向userInputSignal 优先判为 correctionconversationMode 通常优先考虑 repair_direction
3. 如果用户核心意图是授权系统替他补完userInputSignal 优先判为 delegate
4. 只有在没有明显纠偏、也没有明确自动补全要求时,才主要依据 currentTurn、progressPercent 和信息密度,在 bootstrap / expand / compress / closing 之间选择
五、关于 replyText 风格的专门判断要求
1. 如果用户输入较少、较虚或不够落地,正式生成阶段的 replyText 应采用启发式提问
2. 启发式提问一次最多只能提 1 个问题,不能连问两个或更多
3. 启发式提问必须问“最能推动当前设计落地”的那个问题,而不是泛泛而谈
4. 如果用户输入已经足够 rich就不要再机械提问优先吸收和推进
5. 如果用户在 correction 或 delegate 状态下replyText 是否提问要服从更高目标:纠偏生效或代为补全,不要机械套 sparse 的问法
六、关于 replyText 用语的硬约束
1. replyText 禁止提及内部结构名、锚点名、字段名、schema 名、框架词
2. 禁止出现这类内部表达:世界承诺、玩家幻想、主题边界、玩家入口、核心冲突、关键关系、隐藏线、标志元素、字段、结构、模块、八锚点
3. replyText 只能用通俗、直接、面向创作沟通的语言回应用户
4. replyText 应该围绕用户正在讨论的具体内容来落地,比如身份、开场处境、冲突、人物关系、地点、规则、气质,而不是抽象谈结构
5. judgementSummary 可以简洁提到“这轮更适合启发式提问”或“这轮应优先吸收修正”,但也不要堆内部术语
七、关于 judgementSummary 的写法
1. 必须简洁,不要写成长篇分析
2. 必须直接服务于下一轮正式生成
3. 最好同时包含两层信息:
- 为什么这么判断
- 正式生成时最该优先做什么,或最该避免什么
八、硬性约束
1. 只能输出 JSON不能输出解释、代码块或额外说明
2. 不能发明上下文里不存在的设定事实
3. 你的任务是“判断生成策略”,不是“代替正式生成直接写新设定”
4. 即使信息不完全,也必须在给定枚举里选出最合理的一组状态
5. judgementSummary 必须是中文
6. 输出值必须严格落在给定枚举中"#;
pub(crate) const STATE_INFERENCE_OUTPUT_CONTRACT: &str = r#"请严格按以下 JSON 结构输出,不要输出其他文字:
{
"userInputSignal": "normal",
"driftRisk": "low",
"conversationMode": "expand",
"judgementSummary": ""
}"#;
pub(crate) const OUTPUT_CONTRACT_REMINDER: &str = r#"请严格按以下 JSON 结构输出,不要输出其他文字:
{
"replyText": "",
"progressPercent": 0,
"nextAnchorContent": {
"worldPromise": {
"hook": "",
"differentiator": "",
"desiredExperience": ""
},
"playerFantasy": {
"playerRole": "",
"corePursuit": "",
"fearOfLoss": ""
},
"themeBoundary": {
"toneKeywords": [],
"aestheticDirectives": [],
"forbiddenDirectives": []
},
"playerEntryPoint": {
"openingIdentity": "",
"openingProblem": "",
"entryMotivation": ""
},
"coreConflict": {
"surfaceConflicts": [],
"hiddenCrisis": "",
"firstTouchedConflict": ""
},
"keyRelationships": [
{
"pairs": "",
"relationshipType": "",
"secretOrCost": ""
}
],
"hiddenLines": {
"hiddenTruths": [],
"misdirectionHints": [],
"revealPacing": ""
},
"iconicElements": {
"iconicMotifs": [],
"institutionsOrArtifacts": [],
"hardRules": []
}
}
}"#;
pub(crate) fn render_dynamic_state_context(dynamic_state: &PromptDynamicState) -> String {
format!(
"上一轮预判得到的创作状态如下。\n正式生成时必须把它作为本轮策略输入直接执行,不要重新另起一套判断。\n\n创作状态:\n- userInputSignal: {}\n- driftRisk: {}\n- conversationMode: {}\n- judgementSummary: {}",
dynamic_state.user_input_signal.as_str(),
dynamic_state.drift_risk.as_str(),
dynamic_state.conversation_mode.as_str(),
dynamic_state.judgement_summary
)
}
pub(crate) fn render_current_anchor_context(anchor_content: &EightAnchorContent) -> String {
format!(
"当前完整设定结构如下。\n你必须把它视为上一版有效世界底子。\n\n如果用户没有否定其中某部分内容,且该部分仍然成立,可以继续保留。\n如果用户明确修正了某部分内容,新的完整设定结构必须体现修正后的版本。\n\n当前完整设定结构:\n{}",
serde_json::to_string_pretty(anchor_content)
.unwrap_or_else(|_| empty_agent_anchor_content_json())
)
}
pub(crate) fn render_chat_history_context(chat_history: &[JsonValue]) -> String {
format!(
"以下是用户聊天记录。\n请重点理解最近几轮里用户新增、修正、强调的设定信息。\n不要把早期已经被用户否定的内容继续当成最终结论。\n\n用户聊天记录:\n{}",
serde_json::to_string_pretty(chat_history).unwrap_or_else(|_| "[]".to_string())
)
}
pub(crate) fn parse_user_input_signal(value: Option<&JsonValue>) -> Option<PromptUserInputSignal> {
match value.and_then(JsonValue::as_str)? {
"rich" => Some(PromptUserInputSignal::Rich),
"normal" => Some(PromptUserInputSignal::Normal),
"sparse" => Some(PromptUserInputSignal::Sparse),
"correction" => Some(PromptUserInputSignal::Correction),
"delegate" => Some(PromptUserInputSignal::Delegate),
_ => None,
}
}
pub(crate) fn parse_drift_risk(value: Option<&JsonValue>) -> Option<PromptDriftRisk> {
match value.and_then(JsonValue::as_str)? {
"low" => Some(PromptDriftRisk::Low),
"medium" => Some(PromptDriftRisk::Medium),
"high" => Some(PromptDriftRisk::High),
_ => None,
}
}
pub(crate) fn parse_conversation_mode(value: Option<&JsonValue>) -> Option<PromptConversationMode> {
match value.and_then(JsonValue::as_str)? {
"bootstrap" => Some(PromptConversationMode::Bootstrap),
"expand" => Some(PromptConversationMode::Expand),
"compress" => Some(PromptConversationMode::Compress),
"repair_direction" => Some(PromptConversationMode::RepairDirection),
"force_complete" => Some(PromptConversationMode::ForceComplete),
"closing" => Some(PromptConversationMode::Closing),
_ => None,
}
}
pub(crate) fn mode_rules(mode: PromptConversationMode) -> &'static str {
match mode {
PromptConversationMode::Bootstrap => {
r#"当前模式bootstrap
目标:
1. 先把世界的基本方向抓住
2. 不要一次塞太多新设定
3. 回复要降低用户开口压力
本轮行为要求:
1. 优先从用户输入里抓世界方向、玩家视角、主题边界的线索
2. 如果用户信息很少,不要强行把整套结构一次补满
3. replyText 要像共创搭档,而不是像审问
4. 默认只推进一个最关键的问题方向
5. 如果用户刚开口,优先给“被理解感”,再轻轻推进下一步
6. 可以用一句很短的话先确认你抓到的核心方向,再提一个最好回答的问题
7. 不要把问题问得像表单采集,不要一口气追问多个维度
用户体验要求:
1. 让用户觉得“现在很容易继续往下说”
2. 不要制造被考试、被拷问、被策划问卷追着跑的感觉
3. replyText 最好短、稳、可接话
4. 如果用户信息很少,也不要显得冷淡或机械"#
}
PromptConversationMode::Expand => {
r#"当前模式expand
目标:
1. 在保持现有方向的前提下,把设定结构逐步补全
2. 尽量让一轮输入覆盖多个关键维度
本轮行为要求:
1. 继续保留上一版里仍成立的设定
2. 优先把用户本轮输入映射进多个关键维度,而不是只更新一个字段
3. replyText 要明确体现“你已经理解了哪些内容”
4. 不要突然大幅改写已经成形的世界
5. 如果用户这一轮给了多条有效信息replyText 应先把这些信息自然串起来,再决定下一步
6. 可以适度替用户整理,但不要把回复写成总结报告
7. 默认继续往前推一步,不要在还没必要时突然收束或突然跳到成稿感
用户体验要求:
1. 让用户感到“我刚说的内容都被接住了”
2. 回复里可以带一点顺势整理感,但不要太像会议纪要
3. 不要无视用户刚提供的高价值细节
4. 不要让用户觉得系统在自顾自重写世界"#
}
PromptConversationMode::Compress => {
r#"当前模式compress
目标:
1. 开始收束当前设定
2. 减少无效发散
3. 让 progress 更接近可进入下一阶段
本轮行为要求:
1. 新的设定结构优先保留稳定内容,不要无端重写
2. 对用户本轮输入做高密度吸收
3. replyText 要更聚焦,不要绕圈
4. 默认只推进当前最影响 completion 的一步
5. 如果用户还在补细节,优先把细节挂回现有骨架,而不是继续开新分支
6. 可以适度提醒“还差哪类关键空位”,但不要把回复写成 checklist
7. 如果已有信息足够replyText 可以更像“确认并收束”,少一点继续发散式追问
用户体验要求:
1. 让用户感觉世界正在变得更稳,而不是越来越散
2. 让推进感更明确,但不要显得催促
3. 回复语气应更笃定一些,减少反复横跳
4. 不要把用户刚补进来的细节又冲淡掉"#
}
PromptConversationMode::RepairDirection => {
r#"当前模式repair_direction
目标:
1. 处理用户对既有设定的修正
2. 避免世界方向飘散或自相矛盾
本轮行为要求:
1. 如果用户明确改口,新的设定结构必须体现修正后的方向
2. 对已经不再成立的旧设定,不要机械保留
3. progressPercent 可以停滞,也可以小幅回落,但不能为负
4. replyText 要承认用户的修正,并顺着修正后的方向继续聊
5. 先处理“改掉什么”,再决定“往哪里继续推”
6. 不要一边口头承认用户修正,一边在设定结构里偷偷留住旧方向
7. 如果修正幅度很大replyText 可以帮助用户确认新方向已经接管当前语境
用户体验要求:
1. 让用户感到“我刚刚的纠偏真的生效了”
2. 不要和用户辩论旧方案为什么也行
3. 不要表现出对修正的不情愿
4. 回复要体现重心已经切到新方向,而不是停留在旧世界观惯性里"#
}
PromptConversationMode::ForceComplete => {
r#"当前模式force_complete
目标:
1. 基于当前方向直接补齐剩余设定
2. 生成一版尽量完整、可进入下一阶段的设定结构
3. 结束当前收集阶段
本轮行为要求:
1. 尽量保留已经形成的世界方向
2. 对明显缺失的关键维度进行合理补全
3. 不要继续拉长聊天,不要再追问用户
4. progressPercent 直接输出为 100
5. replyText 要自然引导用户点击“生成游戏设定草稿”
6. 补全时要优先做“顺着已有方向补齐”,而不是突然换题材、换气质、换主冲突
7. 可以让结果更完整,但不要补得过满、过死、过像定稿圣经
8. replyText 更像阶段完成提示,不再像继续采集信息的对话
用户体验要求:
1. 让用户感到“系统已经帮我把能补的补好了”
2. 不要在这一步突然冒出很多陌生设定把用户吓出戏
3. 回复要有完成感,但不要太官话
4. 清楚告诉用户下一步可以做什么"#
}
PromptConversationMode::Closing => {
r#"当前模式closing
目标:
1. 尽量形成一版可用的设定底子
2. 不再继续发散新世界观
本轮行为要求:
1. 优先收束,而不是扩写
2. 不要大改已经成形的核心设定
3. progressPercent 接近完成时replyText 要更像确认与推进
4. 如果用户没有大改方向,尽量让下一版内容更稳定
5. 可以轻微补足缺口,但不要再大开新支线
6. replyText 应减少探索式措辞,增加“已经基本成形”的稳定感
7. 如果只差少量空位,优先把这些空位自然补平,而不是重新打开大话题
用户体验要求:
1. 让用户感觉作品已经快成了,而不是还在无穷试探
2. 回复可以更像确认和轻推,不要继续像前期那样频繁试探
3. 保持留白感,不要把所有东西都一次说死
4. 让用户自然过渡到下一阶段,而不是突然被切断对话"#
}
}
}
pub(crate) fn user_signal_rules(signal: PromptUserInputSignal) -> &'static str {
match signal {
PromptUserInputSignal::Rich => {
r#"本轮用户输入信息密度高。
请尽量从这一轮里提取多个锚点,不要只更新单一方向。
如果一条输入同时影响世界方向、冲突和关系,请在新的完整设定结构中一起体现。"#
}
PromptUserInputSignal::Normal => {
r#"本轮用户输入为正常补充。
请优先顺着当前方向稳定更新,不要主动扩写太多新设定。"#
}
PromptUserInputSignal::Sparse => {
r#"本轮用户输入较少或较虚。
请保留上一版中仍然成立的内容,不要为了凑完整度而强行发明过多新设定。
replyText 要让用户容易继续往下说。"#
}
PromptUserInputSignal::Correction => {
r#"本轮用户在修正或推翻旧设定。
请优先吸收修正,不要机械复读旧版本。
新的完整设定结构必须以修正后的方向为准。"#
}
PromptUserInputSignal::Delegate => {
r#"本轮用户把部分决定权交给你。
你可以在 replyText 中给出有限度的建议,但不要突然补满整套设定。
新的完整设定结构仍应尽量建立在已有世界方向上,而不是完全重做。"#
}
}
}

View File

@@ -198,12 +198,9 @@ fn build_video_action_prompt(
use_chroma_key: bool,
) -> String {
[
format!("单人全身角色动作视频,动作英文名是 {}", action_id),
"角色固定为图1同一角色保持右向斜侧身动作视角镜头稳定轮廓清晰不要退化成完全 90 度纯右视图。".to_string(),
"视角要求:角色采用横版动作素材常用的右向斜侧身站姿,身体整体朝右,但保留少量正面信息,能读到面部轮廓与胸肩结构,不是完全 90 度纯右视图,也不是正面立绘".to_string(),
"主体要求:画面中只保留单个角色主体,不要额外人物、动物、召唤物、载具或陪体。".to_string(),
"画面要求1:1 正方形画布,画面中心构图,角色主体完整置于画面中央,不要裁切主体顶部和底部,不要镜头透视,不要特写。背景固定为纯绿色绿幕,只作为抠像底色,不出现建筑、室内布景、风景、地面道具、漂浮物、烟雾叙事元素或其他角色以外的场景内容。".to_string(),
"风格要求:横版像素动作角色体型,头身比优先控制在 1 到 1.5 头身,保留清楚的头、躯干、双臂和双腿轮廓。明确的像素动作角色设定稿气质,整体按像素游戏角色设计方向组织,使用深色清楚轮廓、稳定剪影、有限大色块和硬朗边缘,不要柔和厚涂插画感,发型、服装、配饰优先形成醒目可读的像素级识别点,适合横版动作 sprite 资产。高可读性游戏角色设定图,形体清晰,服装层次明确,便于后续连续动作生成。".to_string(),
format!("生成有创意细节饱满的角色动作视频,动作英文名是 {}", action_id),
"角色固定为图1同一角色保持右向斜侧身动作视角镜头稳定轮廓清晰禁止退化成完全 90 度纯右视图。".to_string(),
"画面要求:1:1 正方形画布,画面中心构图,角色主体完整置于画面中央,不要裁切主体顶部和底部,不要镜头透视,不要特写。背景固定为纯绿色绿幕,只作为抠像底色,不出现建筑、室内布景、风景等场景内容".to_string(),
format!("动作结构:{}。结尾要求:动作收束清楚,便于后续抽帧。", action_sequence),
if use_chroma_key {
"背景为纯绿色绿幕,无其他人物和场景元素,方便后期抽帧与抠像。".to_string()

View File

@@ -13,17 +13,62 @@ pub(crate) fn build_character_visual_prompt(
build_master_prompt(character_brief.as_str())
}
/// 角色主图被供应商内容审核拦截时使用的安全兜底提示词。
///
/// 这里刻意不继续携带角色姓名、作品名和长设定文本,避免把可疑专名原样送回上游导致连续失败。
pub(crate) fn build_fallback_moderation_safe_character_visual_prompt(
prompt_text: &str,
character_brief_text: Option<&str>,
) -> String {
let source = [character_brief_text.unwrap_or_default(), prompt_text].join(" ");
let archetype = resolve_original_role_archetype(source.as_str());
build_master_prompt(
[
format!("角色定位:{}", archetype),
"原创奇幻冒险角色,成年类人骨架,站姿稳定,表情中性,服装为无品牌旅行装、轻甲或职业装备的原创组合。".to_string(),
"不参考任何现有动漫、游戏、影视、小说角色,不使用可识别 IP 元素、商标、队徽、作品名、角色名或知名角色标志性发型服装。".to_string(),
"所有图案、配色、武器、饰品都采用原创通用设计,只保留横版像素动作角色所需的清晰轮廓和可读职业特征。".to_string(),
]
.join("\n")
.as_str(),
)
}
fn resolve_original_role_archetype(source: &str) -> &'static str {
if source.contains("法师") || source.contains("魔法") || source.contains("术士") {
return "原创法术职业冒险者";
}
if source.contains("骑士") || source.contains("守卫") || source.contains("圣骑") {
return "原创重装守护者";
}
if source.contains("") || source.contains("猎人") || source.contains("游侠") {
return "原创远程游侠";
}
if source.contains("刺客") || source.contains("盗贼") || source.contains("潜行") {
return "原创敏捷潜行者";
}
if source.contains("") || source.contains("战士") || source.contains("武士") {
return "原创近战剑士";
}
if source.contains("祭司") || source.contains("牧师") || source.contains("治疗") {
return "原创支援祭司";
}
"原创冒险者"
}
/// 角色主图统一提示词骨架,迁移自旧共享 qwenSprite 主链。
fn build_master_prompt(character_brief: &str) -> String {
[
"单人2D 横版游戏角色标准设定图,主体完整可见,底部轮廓完整,身体比例稳定,轮廓清楚,适合后续制作 sprite sheet 动画。".to_string(),
"单人2D 横版游戏角色标准设定图,主体完整可见,底部轮廓完整,身体比例稳定,轮廓清楚,细节精致,设计感足,适合后续制作 sprite sheet 动画。".to_string(),
"视角要求:角色采用横版动作素材常用的右向斜侧身站姿,身体整体朝右,但保留少量正面信息,能读到面部轮廓与胸肩结构,不是完全 90 度纯右视图,也不是正面立绘。".to_string(),
"主体要求:画面中只保留单个角色主体,不要额外人物、动物、召唤物、载具或陪体。".to_string(),
"画面要求1:1 正方形画布,画面中心构图,角色主体完整置于画面中央,不要裁切主体顶部和底部,不要镜头透视,不要特写。背景固定为纯绿色绿幕,只作为抠像底色,不出现建筑、室内布景、风景、地面道具、漂浮物、烟雾叙事元素、文字或其他角色以外的场景内容。".to_string(),
"风格要求:横版像素动作角色体型,头身比优先控制在 1 到 1.5 头身,保留清楚的头、躯干、双臂和双腿轮廓。明确的像素动作角色设定稿气质,整体按像素游戏角色设计方向组织,使用深色清楚轮廓、稳定剪影、有限大色块和硬朗边缘,不要柔和厚涂插画感,发型、服装、配饰优先形成醒目可读的像素级识别点,适合横版动作 sprite 资产。高可读性游戏角色设定图,形体清晰,服装层次明确,便于后续连续动作生成".to_string(),
"请先拆解设定中的“身份词、主题词、身体结构词”。如果文字设定没有明确要求非人身体结构,默认优先使用参考图对应的人类或类人动作角色骨架,保持清楚的头、躯干、手臂和双腿轮廓,只有当文字设定明确要求非人结构时,才改为对应非人身体。".to_string(),
"主题词默认只作用在角色自身的服装剪裁、材质、纹样、饰品、发光细节上,不要把主题词自动扩写成背景建筑、自然场景、漂浮装饰或额外环境物件".to_string(),
"视觉优先级应当是:身体结构词第一,身份词第二,主题词第三。没有明确身体结构词时,默认用人形拟人化表现,再把主题词转译成服装和装饰。".to_string(),
"风格要求:横版像素角色,头身比必须控制在 1 到 1.5 头身使用深色清楚轮廓、稳定剪影、有限大色块和硬朗边缘,不要柔和厚涂插画感,发型、服装、配饰优先形成醒目可读的像素级识别点。".to_string(),
"如果角色形象设定没有明确要求非人身体结构,默认优先使用人类或类人动作角色骨架\
默认将角色形象设定作用在角色自身的服装剪裁、材质、纹样、饰品、发光细节上。".to_string(),
"角色形象设定:".to_string(),
character_brief.trim().to_string(),
]
.into_iter()

View File

@@ -34,7 +34,7 @@ pub(crate) fn build_custom_world_framework_prompt(setting_text: &str) -> String
"- 这一步只输出顶层 9 个字段name、subtitle、summary、tone、playerGoal、templateWorldType、majorFactions、coreConflicts、camp。".to_string(),
"- 这是一个完全独立的自定义世界;不要在任何正文里直接写出“武侠世界”“仙侠世界”等现成世界名。".to_string(),
"- templateWorldType 只是系统兼容字段,不代表正文应当引用的世界名称。".to_string(),
"- camp 必须表示玩家开局时的落脚处,名字不要直接写成“某某营地”,更接近归舍、住处、栖居、前哨居所这类“家/归处”的概念。".to_string(),
"- camp 必须表示玩家开局时的落脚处,更接近归舍、住处、栖居、前哨居所这类“家/归处”的概念。".to_string(),
"- camp.sceneTaskDescription 必须描述玩家首次进入开局场景时要完成的核心任务,会作为游戏章节任务生成上下文,控制在 24 到 56 个汉字内。".to_string(),
"- camp.actBackgroundPromptTexts 必须恰好 3 条,分别对应开局场景第 1/2/3 幕背景图画面内容描述;每条都必须可直接交给生图模型,控制在 40 到 90 个汉字内。".to_string(),
"- camp.actEventDescriptions 必须恰好 3 条,分别描述每一幕发生的事件;事件必须和当前幕对面的角色强相关,控制在 24 到 56 个汉字内。".to_string(),
@@ -134,6 +134,37 @@ pub(crate) fn build_custom_world_role_outline_batch_json_repair_prompt(
response_text.trim().to_string(),
].into_iter().filter(|value| !value.is_empty()).collect::<Vec<_>>().join("\n")
}
pub(crate) fn build_custom_world_role_outline_asset_fields_repair_prompt(
role_type: &str,
role_entries: &[JsonValue],
missing_report: &str,
) -> String {
let key = role_key(role_type);
let label = if role_type == "playable" {
"可扮演角色"
} else {
"场景角色"
};
[
format!("下面这批{label}框架名单已经能解析为 JSON但有角色缺少资产默认描述字段。"),
"请只输出修复后的单个 JSON 对象,不要输出 Markdown、代码块、注释或解释。".to_string(),
format!("顶层必须只包含一个 {key} 数组。"),
"必须保留原有角色数量、顺序和 name不得新增、删除或改名。".to_string(),
"每个角色只包含name、title、role、description、visualDescription、actionDescription、sceneVisualDescription、initialAffinity、relationshipHooks、tags。".to_string(),
"visualDescription 必须具体到体型、服装、轮廓与识别点,控制在 24 到 60 个汉字内,不能复制 description。".to_string(),
"actionDescription 必须体现该角色默认动作节奏、武器或行动方式,控制在 18 到 48 个汉字内。".to_string(),
"sceneVisualDescription 必须描述该角色常出现或关联的场景画面,控制在 24 到 60 个汉字内。".to_string(),
"缺失报告:".to_string(),
missing_report.trim().to_string(),
"原始角色 JSON".to_string(),
compact_json_text(&JsonValue::Array(role_entries.to_vec())),
]
.into_iter()
.filter(|value| !value.is_empty())
.collect::<Vec<_>>()
.join("\n")
}
pub(crate) fn build_custom_world_landmark_seed_batch_prompt(
framework: &JsonValue,
batch_count: usize,

View File

@@ -1,4 +1,6 @@
pub(crate) mod character_animation;
pub(crate) mod agent_chat;
pub(crate) mod character_animation;
pub(crate) mod character_visual;
pub(crate) mod foundation_draft;
pub(crate) mod runtime_chat;
pub(crate) mod scene_background;

View File

@@ -0,0 +1,114 @@
use serde_json::{Value, json};
#[derive(Clone, Debug)]
pub(crate) struct RuntimeStoryTextPromptParams<'a> {
pub world_type: &'a str,
pub character: Value,
pub monsters: Value,
pub history: Value,
pub choice: Value,
pub context: Value,
pub available_options: Value,
}
#[derive(Clone, Debug)]
pub(crate) struct RuntimeNpcDialoguePromptParams<'a> {
pub world_type: &'a str,
pub character: &'a Value,
pub encounter: &'a Value,
pub monsters: Vec<Value>,
pub history: Vec<Value>,
pub context: Value,
pub topic: &'a str,
pub result_summary: &'a str,
pub requested_option: Value,
pub available_options: Vec<Value>,
}
#[derive(Clone, Debug)]
pub(crate) struct RuntimeReasonedStoryPromptParams<'a> {
pub world_type: &'a str,
pub character: &'a Value,
pub monsters: Vec<Value>,
pub history: Vec<Value>,
pub context: Value,
pub choice: &'a str,
pub result_summary: &'a str,
pub requested_option: Value,
pub available_options: Vec<Value>,
}
pub(crate) fn runtime_story_director_system_prompt(initial: bool) -> &'static str {
if initial {
"你是游戏运行时剧情导演。请用中文输出一段可直接展示给玩家的开局剧情,不要输出 JSON。"
} else {
"你是游戏运行时剧情导演。请用中文根据玩家选择续写一段剧情,不要输出 JSON。"
}
}
pub(crate) fn build_runtime_story_director_user_prompt(
params: RuntimeStoryTextPromptParams<'_>,
) -> String {
json!({
"worldType": params.world_type,
"character": params.character,
"monsters": params.monsters,
"history": params.history,
"choice": params.choice,
"context": params.context,
"availableOptions": params.available_options,
})
.to_string()
}
pub(crate) fn runtime_npc_dialogue_system_prompt() -> &'static str {
"你是游戏运行时 NPC 对话导演。只输出中文正文,不要输出 JSON、Markdown 或规则说明;不要新增系统尚未结算的奖励、任务结果或战斗结果。"
}
pub(crate) fn build_runtime_npc_dialogue_user_prompt(
npc_name: &str,
params: RuntimeNpcDialoguePromptParams<'_>,
) -> String {
let state_prompt = json!({
"worldType": params.world_type,
"character": params.character,
"encounter": params.encounter,
"monsters": params.monsters,
"history": params.history,
"context": params.context,
"topic": params.topic,
"resultSummary": params.result_summary,
"requestedOption": params.requested_option,
"availableOptions": params.available_options,
})
.to_string();
format!(
"请基于以下运行时状态,把玩家这一轮选择改写成 2 到 5 行可直接展示的 NPC 对话。可以使用“你:”和“{npc_name}:”格式,必须保留既有结算含义。\n{state_prompt}"
)
}
pub(crate) fn runtime_reasoned_story_system_prompt() -> &'static str {
"你是游戏运行时剧情导演。只输出中文剧情正文,不要输出 JSON、Markdown 或规则说明;必须尊重已结算的战斗 outcome、伤害和状态不要发明额外奖励。"
}
pub(crate) fn build_runtime_reasoned_story_user_prompt(
params: RuntimeReasonedStoryPromptParams<'_>,
) -> String {
let state_prompt = json!({
"worldType": params.world_type,
"character": params.character,
"monsters": params.monsters,
"history": params.history,
"context": params.context,
"choice": params.choice,
"resultSummary": params.result_summary,
"requestedOption": params.requested_option,
"availableOptions": params.available_options,
})
.to_string();
format!(
"请基于以下运行时状态,为这一轮战斗结算生成一段 120 字以内的结果叙事,并自然引出下一组选项。\n{state_prompt}"
)
}

View File

@@ -1,5 +1,5 @@
use module_puzzle::{PuzzleAgentStage, PuzzleAnchorPack, PuzzleAnchorStatus, empty_anchor_pack};
use platform_llm::{LlmClient, LlmMessage, LlmStreamDelta, LlmTextRequest};
use platform_llm::LlmClient;
use serde::{Deserialize, Serialize};
use serde_json::{Value as JsonValue, json};
use spacetime_client::{
@@ -10,6 +10,9 @@ use crate::creation_agent_anchor_templates::{
get_creation_agent_anchor_template, render_anchor_question_block,
};
use crate::creation_agent_chat::render_quick_fill_extra_rules;
use crate::creation_agent_llm_turn::{
CreationAgentLlmTurnErrorMessages, stream_creation_agent_json_turn,
};
#[derive(Clone, Debug)]
pub(crate) struct PuzzleAgentTurnRequest<'a> {
@@ -115,42 +118,26 @@ const PUZZLE_AGENT_OUTPUT_CONTRACT: &str = r#"请严格按以下 JSON 输出,
pub(crate) async fn run_puzzle_agent_turn<F>(
request: PuzzleAgentTurnRequest<'_>,
mut on_reply_update: F,
on_reply_update: F,
) -> Result<PuzzleAgentTurnResult, PuzzleAgentTurnError>
where
F: FnMut(&str),
{
let llm_client = request
.llm_client
.ok_or_else(|| PuzzleAgentTurnError::new("当前模型不可用,请稍后重试。"))?;
let prompt = build_puzzle_agent_prompt(request.session, request.quick_fill_requested);
let mut latest_reply_text = String::new();
let response = llm_client
.stream_text(
LlmTextRequest::new(vec![
LlmMessage::system(format!("{PUZZLE_AGENT_SYSTEM_PROMPT}\n\n{prompt}")),
LlmMessage::user("请按约定输出这一轮的 JSON"),
]),
|delta: &LlmStreamDelta| {
if let Some(reply_progress) =
extract_reply_text_from_partial_json(delta.accumulated_text.as_str())
&& reply_progress != latest_reply_text
{
latest_reply_text = reply_progress.clone();
on_reply_update(reply_progress.as_str());
}
},
)
.await
.map_err(|_| PuzzleAgentTurnError::new("拼图聊天生成失败,请稍后重试。"))?;
let parsed = parse_json_response_text(response.content.as_str())
.map_err(|_| PuzzleAgentTurnError::new("拼图聊天结果解析失败,请稍后重试。"))?;
let output = parse_model_output(&parsed)?;
if output.reply_text != latest_reply_text {
on_reply_update(output.reply_text.as_str());
}
let turn_output = stream_creation_agent_json_turn(
request.llm_client,
format!("{PUZZLE_AGENT_SYSTEM_PROMPT}\n\n{prompt}"),
"请按约定输出这一轮的 JSON。",
CreationAgentLlmTurnErrorMessages {
model_unavailable: "当前模型不可用,请稍后重试",
generation_failed: "拼图聊天生成失败,请稍后重试。",
parse_failed: "拼图聊天结果解析失败,请稍后重试。",
},
on_reply_update,
PuzzleAgentTurnError::new,
)
.await?;
let output = parse_model_output(&turn_output.parsed)?;
Ok(PuzzleAgentTurnResult {
assistant_reply_text: output.reply_text,
@@ -389,74 +376,13 @@ fn parse_anchor_status(value: &str) -> PuzzleAnchorStatus {
}
}
fn parse_json_response_text(text: &str) -> Result<JsonValue, serde_json::Error> {
let trimmed = text.trim();
if let Some(start) = trimmed.find('{')
&& let Some(end) = trimmed.rfind('}')
&& end > start
{
return serde_json::from_str::<JsonValue>(&trimmed[start..=end]);
}
serde_json::from_str::<JsonValue>(trimmed)
}
fn extract_reply_text_from_partial_json(text: &str) -> Option<String> {
let key_index = text.find("\"replyText\"")?;
let colon_index = text[key_index..].find(':')? + key_index;
let mut cursor = colon_index + 1;
while cursor < text.len() && text.as_bytes()[cursor].is_ascii_whitespace() {
cursor += 1;
}
if text.as_bytes().get(cursor).copied() != Some(b'"') {
return None;
}
cursor += 1;
let mut decoded = String::new();
let remainder = text.get(cursor..)?;
let mut characters = remainder.chars().peekable();
while let Some(current) = characters.next() {
if current == '"' {
return Some(decoded);
}
if current == '\\' {
let escaped = characters.next()?;
match escaped {
'"' => decoded.push('"'),
'\\' => decoded.push('\\'),
'/' => decoded.push('/'),
'b' => decoded.push('\u{0008}'),
'f' => decoded.push('\u{000C}'),
'n' => decoded.push('\n'),
'r' => decoded.push('\r'),
't' => decoded.push('\t'),
'u' => {
let mut hex = String::new();
for _ in 0..4 {
hex.push(characters.next()?);
}
if let Ok(code) = u16::from_str_radix(hex.as_str(), 16)
&& let Some(character) = char::from_u32(code as u32)
{
decoded.push(character);
}
}
other => decoded.push(other),
}
continue;
}
decoded.push(current);
}
Some(decoded)
}
#[cfg(test)]
mod tests {
use module_puzzle::PuzzleAnchorStatus;
use serde_json::json;
use super::{
build_puzzle_agent_prompt, extract_reply_text_from_partial_json, parse_model_output,
};
use super::{build_puzzle_agent_prompt, parse_model_output};
use crate::creation_agent_llm_turn::extract_reply_text_from_partial_json;
fn empty_session_record() -> spacetime_client::PuzzleAgentSessionRecord {
spacetime_client::PuzzleAgentSessionRecord {

View File

@@ -417,3 +417,36 @@ fn build_event_stream_response(body: String) -> Response {
fn runtime_chat_error_response(request_context: &RequestContext, error: AppError) -> Response {
error.into_response_with_context(Some(request_context))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn npc_chat_affinity_delta_keeps_node_keyword_rules() {
assert_eq!(
compute_npc_chat_affinity_delta("谢谢你愿意帮忙", "放心,我明白。", 0.0),
3
);
assert_eq!(
compute_npc_chat_affinity_delta("快说,别装。", "与你无关。", 2.0),
-3
);
assert_eq!(
compute_npc_chat_affinity_delta("这里怎么了", "我还在想。", 0.0),
1
);
assert_eq!(
compute_npc_chat_affinity_delta("这里怎么了", "我还在想。", 2.0),
0
);
}
#[test]
fn npc_chat_suggestion_parser_strips_list_markers() {
assert_eq!(
parse_line_list_content("1. 继续问线索\n- 表明立场\n* 拉近关系\n4. 多余", 3),
vec!["继续问线索", "表明立场", "拉近关系"]
);
}
}

View File

@@ -4,13 +4,21 @@ use axum::{
http::StatusCode,
response::Response,
};
use module_runtime::{
PROFILE_RECHARGE_PAYMENT_CHANNEL_MOCK, RuntimeProfileMembershipBenefitRecord,
RuntimeProfileRechargeCenterRecord, RuntimeProfileRechargeOrderRecord,
RuntimeProfileRechargeProductRecord,
};
use serde_json::{Value, json};
use shared_contracts::runtime::{
PROFILE_WALLET_LEDGER_SOURCE_TYPE_SNAPSHOT_SYNC, ProfileDashboardSummaryResponse,
ProfilePlayStatsResponse, ProfilePlayedWorkSummaryResponse, ProfileWalletLedgerEntryResponse,
CreateProfileRechargeOrderRequest, CreateProfileRechargeOrderResponse,
ProfileDashboardSummaryResponse, ProfileMembershipBenefitResponse, ProfileMembershipResponse,
ProfilePlayStatsResponse, ProfilePlayedWorkSummaryResponse, ProfileRechargeCenterResponse,
ProfileRechargeOrderResponse, ProfileRechargeProductResponse, ProfileWalletLedgerEntryResponse,
ProfileWalletLedgerResponse,
};
use spacetime_client::SpacetimeClientError;
use time::OffsetDateTime;
use crate::{
api_response::json_success_body, auth::AuthenticatedAccessToken, http_error::AppError,
@@ -71,11 +79,7 @@ pub async fn get_profile_wallet_ledger(
id: entry.wallet_ledger_id,
amount_delta: entry.amount_delta,
balance_after: entry.balance_after,
source_type: match entry.source_type {
module_runtime::RuntimeProfileWalletLedgerSourceType::SnapshotSync => {
PROFILE_WALLET_LEDGER_SOURCE_TYPE_SNAPSHOT_SYNC.to_string()
}
},
source_type: entry.source_type.as_str().to_string(),
created_at: entry.created_at,
})
.collect(),
@@ -83,6 +87,65 @@ pub async fn get_profile_wallet_ledger(
))
}
pub async fn get_profile_recharge_center(
State(state): State<AppState>,
Extension(request_context): Extension<RequestContext>,
Extension(authenticated): Extension<AuthenticatedAccessToken>,
) -> Result<Json<Value>, Response> {
let user_id = authenticated.claims().user_id().to_string();
let record = state
.spacetime_client()
.get_profile_recharge_center(user_id)
.await
.map_err(|error| {
runtime_profile_error_response(
&request_context,
map_runtime_profile_client_error(error),
)
})?;
Ok(json_success_body(
Some(&request_context),
build_profile_recharge_center_response(record),
))
}
pub async fn create_profile_recharge_order(
State(state): State<AppState>,
Extension(request_context): Extension<RequestContext>,
Extension(authenticated): Extension<AuthenticatedAccessToken>,
Json(payload): Json<CreateProfileRechargeOrderRequest>,
) -> Result<Json<Value>, Response> {
let user_id = authenticated.claims().user_id().to_string();
let payment_channel = payload
.payment_channel
.unwrap_or_else(|| PROFILE_RECHARGE_PAYMENT_CHANNEL_MOCK.to_string());
let created_at_micros = OffsetDateTime::now_utc().unix_timestamp_nanos() / 1_000;
let (center, order) = state
.spacetime_client()
.create_profile_recharge_order(
user_id,
payload.product_id,
payment_channel,
created_at_micros as i64,
)
.await
.map_err(|error| {
runtime_profile_error_response(
&request_context,
map_runtime_profile_client_error(error),
)
})?;
Ok(json_success_body(
Some(&request_context),
CreateProfileRechargeOrderResponse {
order: build_profile_recharge_order_response(order),
center: build_profile_recharge_center_response(center),
},
))
}
pub async fn get_profile_play_stats(
State(state): State<AppState>,
Extension(request_context): Extension<RequestContext>,
@@ -140,6 +203,87 @@ fn runtime_profile_error_response(request_context: &RequestContext, error: AppEr
error.into_response_with_context(Some(request_context))
}
fn build_profile_recharge_center_response(
record: RuntimeProfileRechargeCenterRecord,
) -> ProfileRechargeCenterResponse {
ProfileRechargeCenterResponse {
wallet_balance: record.wallet_balance,
membership: ProfileMembershipResponse {
status: record.membership.status.as_str().to_string(),
tier: record.membership.tier.as_str().to_string(),
started_at: record.membership.started_at,
expires_at: record.membership.expires_at,
updated_at: record.membership.updated_at,
},
point_products: record
.point_products
.into_iter()
.map(build_profile_recharge_product_response)
.collect(),
membership_products: record
.membership_products
.into_iter()
.map(build_profile_recharge_product_response)
.collect(),
benefits: record
.benefits
.into_iter()
.map(build_profile_membership_benefit_response)
.collect(),
latest_order: record
.latest_order
.map(build_profile_recharge_order_response),
has_points_recharged: record.has_points_recharged,
}
}
fn build_profile_recharge_product_response(
record: RuntimeProfileRechargeProductRecord,
) -> ProfileRechargeProductResponse {
ProfileRechargeProductResponse {
product_id: record.product_id,
title: record.title,
price_cents: record.price_cents,
kind: record.kind.as_str().to_string(),
points_amount: record.points_amount,
bonus_points: record.bonus_points,
duration_days: record.duration_days,
badge_label: record.badge_label,
description: record.description,
tier: record.tier.as_str().to_string(),
}
}
fn build_profile_membership_benefit_response(
record: RuntimeProfileMembershipBenefitRecord,
) -> ProfileMembershipBenefitResponse {
ProfileMembershipBenefitResponse {
benefit_name: record.benefit_name,
normal_value: record.normal_value,
month_value: record.month_value,
season_value: record.season_value,
year_value: record.year_value,
}
}
fn build_profile_recharge_order_response(
record: RuntimeProfileRechargeOrderRecord,
) -> ProfileRechargeOrderResponse {
ProfileRechargeOrderResponse {
order_id: record.order_id,
product_id: record.product_id,
product_title: record.product_title,
kind: record.kind.as_str().to_string(),
amount_cents: record.amount_cents,
status: record.status.as_str().to_string(),
payment_channel: record.payment_channel,
paid_at: record.paid_at,
created_at: record.created_at,
points_delta: record.points_delta,
membership_expires_at: record.membership_expires_at,
}
}
#[cfg(test)]
mod tests {
use axum::{
@@ -210,6 +354,43 @@ mod tests {
assert_eq!(response.status(), StatusCode::UNAUTHORIZED);
}
#[tokio::test]
async fn profile_recharge_center_requires_authentication() {
let app = build_router(AppState::new(AppConfig::default()).expect("state should build"));
let response = app
.oneshot(
Request::builder()
.method("GET")
.uri("/api/profile/recharge-center")
.body(Body::empty())
.expect("request should build"),
)
.await
.expect("request should succeed");
assert_eq!(response.status(), StatusCode::UNAUTHORIZED);
}
#[tokio::test]
async fn profile_recharge_order_requires_authentication() {
let app = build_router(AppState::new(AppConfig::default()).expect("state should build"));
let response = app
.oneshot(
Request::builder()
.method("POST")
.uri("/api/profile/recharge/orders")
.header("content-type", "application/json")
.body(Body::from(r#"{"productId":"points_10"}"#))
.expect("request should build"),
)
.await
.expect("request should succeed");
assert_eq!(response.status(), StatusCode::UNAUTHORIZED);
}
#[tokio::test]
async fn profile_dashboard_compat_route_matches_main_route_error_shape() {
assert_compat_route_matches_main_route_error_shape(

View File

@@ -1,4 +1,10 @@
use super::*;
use crate::prompt::runtime_chat::{
RuntimeNpcDialoguePromptParams, RuntimeReasonedStoryPromptParams, RuntimeStoryTextPromptParams,
build_runtime_npc_dialogue_user_prompt, build_runtime_reasoned_story_user_prompt,
build_runtime_story_director_user_prompt, runtime_npc_dialogue_system_prompt,
runtime_reasoned_story_system_prompt, runtime_story_director_system_prompt,
};
pub(super) async fn build_runtime_story_ai_response(
state: &AppState,
@@ -25,21 +31,16 @@ pub(super) async fn generate_ai_story_text(
initial: bool,
) -> Option<String> {
let llm_client = state.llm_client()?;
let system_prompt = if initial {
"你是游戏运行时剧情导演。请用中文输出一段可直接展示给玩家的开局剧情,不要输出 JSON。"
} else {
"你是游戏运行时剧情导演。请用中文根据玩家选择续写一段剧情,不要输出 JSON。"
};
let user_prompt = json!({
"worldType": payload.world_type,
"character": payload.character,
"monsters": payload.monsters,
"history": payload.history,
"choice": payload.choice,
"context": payload.context,
"availableOptions": payload.request_options.available_options,
})
.to_string();
let system_prompt = runtime_story_director_system_prompt(initial);
let user_prompt = build_runtime_story_director_user_prompt(RuntimeStoryTextPromptParams {
world_type: payload.world_type.as_str(),
character: payload.character.clone(),
monsters: Value::Array(payload.monsters.clone()),
history: Value::Array(payload.history.clone()),
choice: Value::String(payload.choice.clone()),
context: payload.context.clone(),
available_options: Value::Array(payload.request_options.available_options.clone()),
});
let mut request = LlmTextRequest::new(vec![
LlmMessage::system(system_prompt),
LlmMessage::user(user_prompt),
@@ -119,26 +120,27 @@ pub(super) async fn generate_npc_dialogue_payload(
let npc_name = read_optional_string_field(encounter, "npcName")
.or_else(|| read_optional_string_field(encounter, "name"))
.unwrap_or_else(|| "对方".to_string());
let user_prompt = json!({
"worldType": world_type,
"character": character,
"encounter": encounter,
"monsters": read_array_field(game_state, "sceneHostileNpcs").into_iter().cloned().collect::<Vec<_>>(),
"history": build_action_story_history(game_state, action_text, result_text),
"context": build_action_story_prompt_context(game_state, None),
"topic": action_text,
"resultSummary": result_text,
"requestedOption": request.action.payload,
"availableOptions": build_action_prompt_options(deferred_options),
})
.to_string();
let user_prompt = build_runtime_npc_dialogue_user_prompt(
npc_name.as_str(),
RuntimeNpcDialoguePromptParams {
world_type: world_type.as_str(),
character: &character,
encounter,
monsters: read_array_field(game_state, "sceneHostileNpcs")
.into_iter()
.cloned()
.collect::<Vec<_>>(),
history: build_action_story_history(game_state, action_text, result_text),
context: build_action_story_prompt_context(game_state, None),
topic: action_text,
result_summary: result_text,
requested_option: request.action.payload.clone().unwrap_or(Value::Null),
available_options: build_action_prompt_options(deferred_options),
},
);
let mut llm_request = LlmTextRequest::new(vec![
LlmMessage::system(
"你是游戏运行时 NPC 对话导演。只输出中文正文,不要输出 JSON、Markdown 或规则说明;不要新增系统尚未结算的奖励、任务结果或战斗结果。",
),
LlmMessage::user(format!(
"请基于以下运行时状态,把玩家这一轮选择改写成 2 到 5 行可直接展示的 NPC 对话。可以使用“你:”和“{npc_name}:”格式,必须保留既有结算含义。\n{user_prompt}"
)),
LlmMessage::system(runtime_npc_dialogue_system_prompt()),
LlmMessage::user(user_prompt),
]);
llm_request.max_tokens = Some(700);
llm_request.enable_web_search = enable_web_search;
@@ -173,25 +175,23 @@ pub(super) async fn generate_reasoned_story_payload(
) -> Option<GeneratedStoryPayload> {
let world_type = current_world_type(game_state)?;
let character = read_object_field(game_state, "playerCharacter")?.clone();
let user_prompt = json!({
"worldType": world_type,
"character": character,
"monsters": read_array_field(game_state, "sceneHostileNpcs").into_iter().cloned().collect::<Vec<_>>(),
"history": build_action_story_history(game_state, action_text, result_text),
"context": build_action_story_prompt_context(game_state, battle),
"choice": action_text,
"resultSummary": result_text,
"requestedOption": request.action.payload,
"availableOptions": build_action_prompt_options(options),
})
.to_string();
let user_prompt = build_runtime_reasoned_story_user_prompt(RuntimeReasonedStoryPromptParams {
world_type: world_type.as_str(),
character: &character,
monsters: read_array_field(game_state, "sceneHostileNpcs")
.into_iter()
.cloned()
.collect::<Vec<_>>(),
history: build_action_story_history(game_state, action_text, result_text),
context: build_action_story_prompt_context(game_state, battle),
choice: action_text,
result_summary: result_text,
requested_option: request.action.payload.clone().unwrap_or(Value::Null),
available_options: build_action_prompt_options(options),
});
let mut llm_request = LlmTextRequest::new(vec![
LlmMessage::system(
"你是游戏运行时剧情导演。只输出中文剧情正文,不要输出 JSON、Markdown 或规则说明;必须尊重已结算的战斗 outcome、伤害和状态不要发明额外奖励。",
),
LlmMessage::user(format!(
"请基于以下运行时状态,为这一轮战斗结算生成一段 120 字以内的结果叙事,并自然引出下一组选项。\n{user_prompt}"
)),
LlmMessage::system(runtime_reasoned_story_system_prompt()),
LlmMessage::user(user_prompt),
]);
llm_request.max_tokens = Some(700);
llm_request.enable_web_search = enable_web_search;