1
This commit is contained in:
@@ -49,6 +49,11 @@ use crate::{
|
||||
generate_character_visual, get_character_visual_job, publish_character_visual,
|
||||
},
|
||||
creation_agent_document_input::parse_creation_agent_document_input,
|
||||
creative_agent::{
|
||||
cancel_creative_agent_session, confirm_creative_puzzle_template,
|
||||
create_creative_agent_session, get_creative_agent_session, stream_creative_agent_message,
|
||||
stream_creative_draft_edit,
|
||||
},
|
||||
custom_world::{
|
||||
create_custom_world_agent_session, delete_custom_world_agent_session,
|
||||
delete_custom_world_library_profile, execute_custom_world_agent_action,
|
||||
@@ -140,6 +145,14 @@ use crate::{
|
||||
begin_story_runtime_session, begin_story_session, continue_story,
|
||||
get_story_runtime_projection, get_story_session_state, resolve_story_runtime_action,
|
||||
},
|
||||
visual_novel::{
|
||||
compile_visual_novel_session, create_visual_novel_session, delete_visual_novel_work,
|
||||
execute_visual_novel_action, get_visual_novel_run, get_visual_novel_session,
|
||||
get_visual_novel_work, list_visual_novel_gallery, list_visual_novel_history,
|
||||
list_visual_novel_works, publish_visual_novel_work, regenerate_visual_novel_run,
|
||||
start_visual_novel_run, stream_visual_novel_action, stream_visual_novel_message,
|
||||
submit_visual_novel_message, update_visual_novel_work,
|
||||
},
|
||||
wechat_auth::{bind_wechat_phone, handle_wechat_callback, start_wechat_login},
|
||||
};
|
||||
|
||||
@@ -1014,6 +1027,8 @@ pub fn build_router(state: AppState) -> Router {
|
||||
require_bearer_auth,
|
||||
)),
|
||||
)
|
||||
.merge(creative_agent_router(state.clone()))
|
||||
.merge(visual_novel_router(state.clone()))
|
||||
.route(
|
||||
"/api/runtime/puzzle/onboarding/generate",
|
||||
post(generate_puzzle_onboarding_work).layer(DefaultBodyLimit::max(
|
||||
@@ -1428,6 +1443,173 @@ pub fn build_router(state: AppState) -> Router {
|
||||
.with_state(state)
|
||||
}
|
||||
|
||||
fn creative_agent_router(state: AppState) -> Router<AppState> {
|
||||
Router::new()
|
||||
.route(
|
||||
"/api/runtime/creative-agent/sessions",
|
||||
post(create_creative_agent_session)
|
||||
// 中文注释:创意 Agent 首轮允许携带参考图 URL/Data URL,沿用拼图参考图入口上限。
|
||||
.layer(DefaultBodyLimit::max(
|
||||
PUZZLE_REFERENCE_IMAGE_BODY_LIMIT_BYTES,
|
||||
))
|
||||
.route_layer(middleware::from_fn_with_state(
|
||||
state.clone(),
|
||||
require_bearer_auth,
|
||||
)),
|
||||
)
|
||||
.route(
|
||||
"/api/runtime/creative-agent/sessions/{session_id}",
|
||||
get(get_creative_agent_session).route_layer(middleware::from_fn_with_state(
|
||||
state.clone(),
|
||||
require_bearer_auth,
|
||||
)),
|
||||
)
|
||||
.route(
|
||||
"/api/runtime/creative-agent/sessions/{session_id}/messages/stream",
|
||||
post(stream_creative_agent_message)
|
||||
// 中文注释:message stream 同样可能带图片素材,避免默认 JSON limit 过早拒绝。
|
||||
.layer(DefaultBodyLimit::max(
|
||||
PUZZLE_REFERENCE_IMAGE_BODY_LIMIT_BYTES,
|
||||
))
|
||||
.route_layer(middleware::from_fn_with_state(
|
||||
state.clone(),
|
||||
require_bearer_auth,
|
||||
)),
|
||||
)
|
||||
.route(
|
||||
"/api/runtime/creative-agent/sessions/{session_id}/confirm-template",
|
||||
post(confirm_creative_puzzle_template).route_layer(middleware::from_fn_with_state(
|
||||
state.clone(),
|
||||
require_bearer_auth,
|
||||
)),
|
||||
)
|
||||
.route(
|
||||
"/api/runtime/creative-agent/sessions/{session_id}/draft-edits/stream",
|
||||
post(stream_creative_draft_edit)
|
||||
// 中文注释:草稿编辑会携带当前 puzzle draft JSON,保持和拼图草稿入口一致的 body 上限。
|
||||
.layer(DefaultBodyLimit::max(
|
||||
PUZZLE_REFERENCE_IMAGE_BODY_LIMIT_BYTES,
|
||||
))
|
||||
.route_layer(middleware::from_fn_with_state(
|
||||
state.clone(),
|
||||
require_bearer_auth,
|
||||
)),
|
||||
)
|
||||
.route(
|
||||
"/api/runtime/creative-agent/sessions/{session_id}/cancel",
|
||||
post(cancel_creative_agent_session)
|
||||
.route_layer(middleware::from_fn_with_state(state, require_bearer_auth)),
|
||||
)
|
||||
}
|
||||
|
||||
fn visual_novel_router(state: AppState) -> Router<AppState> {
|
||||
Router::new()
|
||||
.route(
|
||||
"/api/creation/visual-novel/sessions",
|
||||
post(create_visual_novel_session).route_layer(middleware::from_fn_with_state(
|
||||
state.clone(),
|
||||
require_bearer_auth,
|
||||
)),
|
||||
)
|
||||
.route(
|
||||
"/api/creation/visual-novel/sessions/{session_id}",
|
||||
get(get_visual_novel_session).route_layer(middleware::from_fn_with_state(
|
||||
state.clone(),
|
||||
require_bearer_auth,
|
||||
)),
|
||||
)
|
||||
.route(
|
||||
"/api/creation/visual-novel/sessions/{session_id}/messages",
|
||||
post(submit_visual_novel_message).route_layer(middleware::from_fn_with_state(
|
||||
state.clone(),
|
||||
require_bearer_auth,
|
||||
)),
|
||||
)
|
||||
.route(
|
||||
"/api/creation/visual-novel/sessions/{session_id}/messages/stream",
|
||||
post(stream_visual_novel_message).route_layer(middleware::from_fn_with_state(
|
||||
state.clone(),
|
||||
require_bearer_auth,
|
||||
)),
|
||||
)
|
||||
.route(
|
||||
"/api/creation/visual-novel/sessions/{session_id}/actions",
|
||||
post(execute_visual_novel_action).route_layer(middleware::from_fn_with_state(
|
||||
state.clone(),
|
||||
require_bearer_auth,
|
||||
)),
|
||||
)
|
||||
.route(
|
||||
"/api/creation/visual-novel/sessions/{session_id}/compile",
|
||||
post(compile_visual_novel_session).route_layer(middleware::from_fn_with_state(
|
||||
state.clone(),
|
||||
require_bearer_auth,
|
||||
)),
|
||||
)
|
||||
.route(
|
||||
"/api/creation/visual-novel/works",
|
||||
get(list_visual_novel_works).route_layer(middleware::from_fn_with_state(
|
||||
state.clone(),
|
||||
require_bearer_auth,
|
||||
)),
|
||||
)
|
||||
.route(
|
||||
"/api/creation/visual-novel/works/{profile_id}",
|
||||
get(get_visual_novel_work)
|
||||
.put(update_visual_novel_work)
|
||||
.patch(update_visual_novel_work)
|
||||
.delete(delete_visual_novel_work)
|
||||
.route_layer(middleware::from_fn_with_state(
|
||||
state.clone(),
|
||||
require_bearer_auth,
|
||||
)),
|
||||
)
|
||||
.route(
|
||||
"/api/creation/visual-novel/works/{profile_id}/publish",
|
||||
post(publish_visual_novel_work).route_layer(middleware::from_fn_with_state(
|
||||
state.clone(),
|
||||
require_bearer_auth,
|
||||
)),
|
||||
)
|
||||
.route(
|
||||
"/api/runtime/visual-novel/gallery",
|
||||
get(list_visual_novel_gallery),
|
||||
)
|
||||
.route(
|
||||
"/api/runtime/visual-novel/works/{profile_id}/runs",
|
||||
post(start_visual_novel_run).route_layer(middleware::from_fn_with_state(
|
||||
state.clone(),
|
||||
require_bearer_auth,
|
||||
)),
|
||||
)
|
||||
.route(
|
||||
"/api/runtime/visual-novel/runs/{run_id}",
|
||||
get(get_visual_novel_run).route_layer(middleware::from_fn_with_state(
|
||||
state.clone(),
|
||||
require_bearer_auth,
|
||||
)),
|
||||
)
|
||||
.route(
|
||||
"/api/runtime/visual-novel/runs/{run_id}/actions/stream",
|
||||
post(stream_visual_novel_action).route_layer(middleware::from_fn_with_state(
|
||||
state.clone(),
|
||||
require_bearer_auth,
|
||||
)),
|
||||
)
|
||||
.route(
|
||||
"/api/runtime/visual-novel/runs/{run_id}/history",
|
||||
get(list_visual_novel_history).route_layer(middleware::from_fn_with_state(
|
||||
state.clone(),
|
||||
require_bearer_auth,
|
||||
)),
|
||||
)
|
||||
.route(
|
||||
"/api/runtime/visual-novel/runs/{run_id}/regenerate",
|
||||
post(regenerate_visual_novel_run)
|
||||
.route_layer(middleware::from_fn_with_state(state, require_bearer_auth)),
|
||||
)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use axum::{
|
||||
@@ -1450,6 +1632,8 @@ mod tests {
|
||||
use super::build_router;
|
||||
|
||||
const TEST_PASSWORD: &str = "secret123";
|
||||
const INTERNAL_TEST_SECRET: &str = "test-internal-secret";
|
||||
|
||||
async fn seed_phone_user_with_password(
|
||||
state: &AppState,
|
||||
phone_number: &str,
|
||||
@@ -1507,6 +1691,43 @@ mod tests {
|
||||
.expect("password login request should succeed")
|
||||
}
|
||||
|
||||
fn build_internal_creative_agent_app() -> Router {
|
||||
let mut config = AppConfig::default();
|
||||
config.internal_api_secret = Some(INTERNAL_TEST_SECRET.to_string());
|
||||
build_router(AppState::new(config).expect("state should build"))
|
||||
}
|
||||
|
||||
fn internal_creative_agent_request(method: &str, uri: &str, body: Value) -> Request<Body> {
|
||||
Request::builder()
|
||||
.method(method)
|
||||
.uri(uri)
|
||||
.header("content-type", "application/json")
|
||||
.header("x-genarrative-authenticated-user-id", "user-creative-test")
|
||||
.header("x-genarrative-internal-api-secret", INTERNAL_TEST_SECRET)
|
||||
.body(Body::from(body.to_string()))
|
||||
.expect("creative agent request should build")
|
||||
}
|
||||
|
||||
async fn read_json_response(response: axum::response::Response) -> Value {
|
||||
let body = response
|
||||
.into_body()
|
||||
.collect()
|
||||
.await
|
||||
.expect("response body should collect")
|
||||
.to_bytes();
|
||||
serde_json::from_slice(&body).expect("response body should be valid json")
|
||||
}
|
||||
|
||||
async fn read_text_response(response: axum::response::Response) -> String {
|
||||
let body = response
|
||||
.into_body()
|
||||
.collect()
|
||||
.await
|
||||
.expect("response body should collect")
|
||||
.to_bytes();
|
||||
String::from_utf8(body.to_vec()).expect("response body should be utf8")
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn healthz_returns_legacy_compatible_payload_and_headers() {
|
||||
let app = build_router(AppState::new(AppConfig::default()).expect("state should build"));
|
||||
@@ -1600,6 +1821,244 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn creative_agent_draft_edit_rejects_unconfirmed_template_session() {
|
||||
let app = build_internal_creative_agent_app();
|
||||
|
||||
let create_response = app
|
||||
.clone()
|
||||
.oneshot(internal_creative_agent_request(
|
||||
"POST",
|
||||
"/api/runtime/creative-agent/sessions",
|
||||
serde_json::json!({
|
||||
"text": "做一个生日拼图",
|
||||
"entryContext": "creation_home"
|
||||
}),
|
||||
))
|
||||
.await
|
||||
.expect("create session request should succeed");
|
||||
assert_eq!(create_response.status(), StatusCode::OK);
|
||||
let create_payload = read_json_response(create_response).await;
|
||||
let session_id = create_payload["session"]["sessionId"]
|
||||
.as_str()
|
||||
.expect("session id should exist");
|
||||
|
||||
let edit_response = app
|
||||
.clone()
|
||||
.oneshot(internal_creative_agent_request(
|
||||
"POST",
|
||||
&format!("/api/runtime/creative-agent/sessions/{session_id}/draft-edits/stream"),
|
||||
serde_json::json!({
|
||||
"clientMessageId": "creative-edit-test",
|
||||
"instruction": "把标题改轻松一点",
|
||||
"targetPuzzleSessionId": "puzzle-session-unconfirmed",
|
||||
"currentDraft": {
|
||||
"workTitle": "旧标题",
|
||||
"workDescription": "旧描述",
|
||||
"summary": "旧描述",
|
||||
"themeTags": ["创意", "拼图", "灵感"],
|
||||
"levels": [{
|
||||
"levelId": "puzzle-level-1",
|
||||
"levelName": "第一关",
|
||||
"pictureDescription": "旧图面",
|
||||
"pictureReference": null,
|
||||
"generationStatus": "idle",
|
||||
"candidates": []
|
||||
}]
|
||||
}
|
||||
}),
|
||||
))
|
||||
.await
|
||||
.expect("draft edit request should be handled");
|
||||
|
||||
assert_eq!(edit_response.status(), StatusCode::BAD_REQUEST);
|
||||
let edit_payload = read_json_response(edit_response).await;
|
||||
assert_eq!(
|
||||
edit_payload["error"]["details"]["message"],
|
||||
Value::String("尚未绑定拼图草稿".to_string())
|
||||
);
|
||||
|
||||
let session_response = app
|
||||
.oneshot(internal_creative_agent_request(
|
||||
"GET",
|
||||
&format!("/api/runtime/creative-agent/sessions/{session_id}"),
|
||||
Value::Null,
|
||||
))
|
||||
.await
|
||||
.expect("get session request should succeed");
|
||||
let session_payload = read_json_response(session_response).await;
|
||||
assert_eq!(session_payload["session"]["targetBinding"], Value::Null);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn creative_agent_message_stream_returns_template_confirmation_events() {
|
||||
let app = build_internal_creative_agent_app();
|
||||
|
||||
let create_response = app
|
||||
.clone()
|
||||
.oneshot(internal_creative_agent_request(
|
||||
"POST",
|
||||
"/api/runtime/creative-agent/sessions",
|
||||
serde_json::json!({
|
||||
"text": "做一个生日拼图",
|
||||
"entryContext": "creation_home"
|
||||
}),
|
||||
))
|
||||
.await
|
||||
.expect("create session request should succeed");
|
||||
assert_eq!(create_response.status(), StatusCode::OK);
|
||||
let create_payload = read_json_response(create_response).await;
|
||||
let session_id = create_payload["session"]["sessionId"]
|
||||
.as_str()
|
||||
.expect("session id should exist");
|
||||
|
||||
let stream_response = app
|
||||
.clone()
|
||||
.oneshot(internal_creative_agent_request(
|
||||
"POST",
|
||||
&format!("/api/runtime/creative-agent/sessions/{session_id}/messages/stream"),
|
||||
serde_json::json!({
|
||||
"clientMessageId": "creative-message-stream-test",
|
||||
"content": [{
|
||||
"type": "input_text",
|
||||
"text": "做一个温暖的生日拼图"
|
||||
}]
|
||||
}),
|
||||
))
|
||||
.await
|
||||
.expect("message stream request should be handled");
|
||||
|
||||
assert_eq!(stream_response.status(), StatusCode::OK);
|
||||
assert_eq!(
|
||||
stream_response
|
||||
.headers()
|
||||
.get("content-type")
|
||||
.and_then(|value| value.to_str().ok()),
|
||||
Some("text/event-stream")
|
||||
);
|
||||
let stream_body = read_text_response(stream_response).await;
|
||||
|
||||
assert!(stream_body.contains("event: stage"));
|
||||
assert!(stream_body.contains("event: tool_started"));
|
||||
assert!(stream_body.contains("event: tool_completed"));
|
||||
assert!(stream_body.contains("event: puzzle_template_catalog"));
|
||||
assert!(!stream_body.contains("event: puzzle_template_selection"));
|
||||
assert!(!stream_body.contains("event: puzzle_cost_range"));
|
||||
assert!(stream_body.contains("event: done"));
|
||||
let tool_started_id = stream_body
|
||||
.lines()
|
||||
.skip_while(|line| *line != "event: tool_started")
|
||||
.nth(1)
|
||||
.and_then(|line| line.strip_prefix("data: "))
|
||||
.and_then(|data| serde_json::from_str::<Value>(data).ok())
|
||||
.and_then(|payload| payload["toolCallId"].as_str().map(ToString::to_string))
|
||||
.expect("tool_started should include toolCallId");
|
||||
let tool_completed_id = stream_body
|
||||
.lines()
|
||||
.skip_while(|line| *line != "event: tool_completed")
|
||||
.nth(1)
|
||||
.and_then(|line| line.strip_prefix("data: "))
|
||||
.and_then(|data| serde_json::from_str::<Value>(data).ok())
|
||||
.and_then(|payload| payload["toolCallId"].as_str().map(ToString::to_string))
|
||||
.expect("tool_completed should include toolCallId");
|
||||
assert_eq!(tool_started_id, tool_completed_id);
|
||||
|
||||
let session_response = app
|
||||
.oneshot(internal_creative_agent_request(
|
||||
"GET",
|
||||
&format!("/api/runtime/creative-agent/sessions/{session_id}"),
|
||||
Value::Null,
|
||||
))
|
||||
.await
|
||||
.expect("get session request should succeed");
|
||||
let session_payload = read_json_response(session_response).await;
|
||||
assert_eq!(
|
||||
session_payload["session"]["stage"],
|
||||
Value::String("waiting_template_confirmation".to_string())
|
||||
);
|
||||
assert_eq!(
|
||||
session_payload["session"]["puzzleTemplateSelection"],
|
||||
Value::Null
|
||||
);
|
||||
assert!(
|
||||
session_payload["session"]["puzzleTemplateCatalog"]
|
||||
.as_array()
|
||||
.map(|templates| templates.len() >= 3)
|
||||
.unwrap_or(false)
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn creative_agent_confirm_template_rejects_non_puzzle_template() {
|
||||
let app = build_internal_creative_agent_app();
|
||||
|
||||
let create_response = app
|
||||
.clone()
|
||||
.oneshot(internal_creative_agent_request(
|
||||
"POST",
|
||||
"/api/runtime/creative-agent/sessions",
|
||||
serde_json::json!({
|
||||
"text": "做一个角色扮演开场",
|
||||
"entryContext": "creation_home"
|
||||
}),
|
||||
))
|
||||
.await
|
||||
.expect("create session request should succeed");
|
||||
assert_eq!(create_response.status(), StatusCode::OK);
|
||||
let create_payload = read_json_response(create_response).await;
|
||||
let session_id = create_payload["session"]["sessionId"]
|
||||
.as_str()
|
||||
.expect("session id should exist");
|
||||
|
||||
let confirm_response = app
|
||||
.clone()
|
||||
.oneshot(internal_creative_agent_request(
|
||||
"POST",
|
||||
&format!("/api/runtime/creative-agent/sessions/{session_id}/confirm-template"),
|
||||
serde_json::json!({
|
||||
"selection": {
|
||||
"templateId": "rpg.unsupported",
|
||||
"title": "RPG",
|
||||
"reason": "用户想创建 RPG",
|
||||
"costRange": {
|
||||
"minPoints": 2,
|
||||
"maxPoints": 12,
|
||||
"pricingUnit": "point",
|
||||
"reason": "按关卡数和每关图片生成次数估算,实际扣费以后端任务结算为准"
|
||||
},
|
||||
"supportedLevelMode": "single_or_multi",
|
||||
"selectedLevelMode": "single_level",
|
||||
"plannedLevelCount": 1,
|
||||
"requiresUserConfirmation": true
|
||||
}
|
||||
}),
|
||||
))
|
||||
.await
|
||||
.expect("confirm template request should be handled");
|
||||
|
||||
assert_eq!(confirm_response.status(), StatusCode::BAD_REQUEST);
|
||||
let confirm_payload = read_json_response(confirm_response).await;
|
||||
assert_eq!(
|
||||
confirm_payload["error"]["details"]["provider"],
|
||||
Value::String("module-puzzle".to_string())
|
||||
);
|
||||
|
||||
let session_response = app
|
||||
.oneshot(internal_creative_agent_request(
|
||||
"GET",
|
||||
&format!("/api/runtime/creative-agent/sessions/{session_id}"),
|
||||
Value::Null,
|
||||
))
|
||||
.await
|
||||
.expect("get session request should succeed");
|
||||
let session_payload = read_json_response(session_response).await;
|
||||
assert_eq!(
|
||||
session_payload["session"]["stage"],
|
||||
Value::String("idle".to_string())
|
||||
);
|
||||
assert_eq!(session_payload["session"]["targetBinding"], Value::Null);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn runtime_story_legacy_routes_are_not_mounted() {
|
||||
let app = build_router(AppState::new(AppConfig::default()).expect("state should build"));
|
||||
@@ -3982,4 +4441,58 @@ mod tests {
|
||||
|
||||
assert_eq!(debug_response.status(), StatusCode::UNAUTHORIZED);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn visual_novel_creation_route_requires_authentication() {
|
||||
let app = build_router(AppState::new(AppConfig::default()).expect("state should build"));
|
||||
|
||||
let response = app
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.method("POST")
|
||||
.uri("/api/creation/visual-novel/sessions")
|
||||
.header("content-type", "application/json")
|
||||
.body(Body::from(
|
||||
serde_json::json!({
|
||||
"sourceMode": "idea",
|
||||
"seedText": "雨夜书店",
|
||||
"sourceAssetIds": []
|
||||
})
|
||||
.to_string(),
|
||||
))
|
||||
.expect("request should build"),
|
||||
)
|
||||
.await
|
||||
.expect("request should succeed");
|
||||
|
||||
assert_eq!(response.status(), StatusCode::UNAUTHORIZED);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn visual_novel_forbidden_playback_routes_are_not_mounted() {
|
||||
let app = build_router(AppState::new(AppConfig::default()).expect("state should build"));
|
||||
let legacy_playback_segment = concat!("re", "play");
|
||||
|
||||
for path in [
|
||||
format!("/api/creation/visual-novel/{legacy_playback_segment}"),
|
||||
format!("/api/runtime/visual-novel/{legacy_playback_segment}"),
|
||||
format!("/api/runtime/visual-novel/{legacy_playback_segment}s"),
|
||||
format!("/api/visual/{legacy_playback_segment}"),
|
||||
format!("/api/galgame/{legacy_playback_segment}"),
|
||||
format!("/api/txt/{legacy_playback_segment}"),
|
||||
] {
|
||||
let response = app
|
||||
.clone()
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.uri(path.as_str())
|
||||
.body(Body::empty())
|
||||
.expect("request should build"),
|
||||
)
|
||||
.await
|
||||
.expect("request should succeed");
|
||||
|
||||
assert_eq!(response.status(), StatusCode::NOT_FOUND, "{path}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -191,6 +191,7 @@ fn allows_internal_forwarded_auth(path: &str) -> bool {
|
||||
// Node 代理已经完成平台账号 JWT 校验,Rust 运行时只信任这些明确的内部转发路径。
|
||||
path.starts_with("/api/runtime/big-fish/")
|
||||
|| path.starts_with("/api/runtime/chat/")
|
||||
|| path.starts_with("/api/runtime/creative-agent/")
|
||||
|| path.starts_with("/api/runtime/puzzle/")
|
||||
}
|
||||
|
||||
@@ -287,6 +288,9 @@ mod tests {
|
||||
assert!(allows_internal_forwarded_auth(
|
||||
"/api/runtime/chat/npc/turn/stream"
|
||||
));
|
||||
assert!(allows_internal_forwarded_auth(
|
||||
"/api/runtime/creative-agent/sessions"
|
||||
));
|
||||
assert!(allows_internal_forwarded_auth("/api/runtime/puzzle/works"));
|
||||
assert!(!allows_internal_forwarded_auth("/api/auth/me"));
|
||||
}
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
use std::io::{Cursor, Read};
|
||||
|
||||
use axum::{Json, extract::Extension, http::StatusCode};
|
||||
use base64::{Engine as _, engine::general_purpose::STANDARD as BASE64_STANDARD};
|
||||
use serde_json::{Value, json};
|
||||
@@ -12,7 +14,7 @@ use crate::{
|
||||
|
||||
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"];
|
||||
const SUPPORTED_DOCUMENT_EXTENSIONS: &[&str] = &["txt", "md", "markdown", "docx", "csv", "json"];
|
||||
|
||||
pub async fn parse_creation_agent_document_input(
|
||||
Extension(request_context): Extension<RequestContext>,
|
||||
@@ -58,12 +60,8 @@ pub async fn parse_creation_agent_document_input(
|
||||
);
|
||||
}
|
||||
|
||||
let text = String::from_utf8(decoded.clone()).map_err(|_| {
|
||||
AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({
|
||||
"message": "暂时只支持 UTF-8 文本文档,请转换编码后再上传。",
|
||||
"field": "contentBase64",
|
||||
}))
|
||||
})?;
|
||||
let extension = document_extension(&file_name)?;
|
||||
let text = decode_document_text(&decoded, extension.as_str())?;
|
||||
let normalized_text = normalize_document_text(&text);
|
||||
|
||||
if normalized_text.trim().is_empty() {
|
||||
@@ -88,6 +86,7 @@ pub async fn parse_creation_agent_document_input(
|
||||
.map(str::to_string),
|
||||
size_bytes: decoded.len(),
|
||||
text: normalized_text,
|
||||
source_asset_id: None,
|
||||
},
|
||||
},
|
||||
))
|
||||
@@ -115,11 +114,7 @@ fn normalize_file_name(value: &str) -> Result<String, AppError> {
|
||||
}
|
||||
|
||||
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))?;
|
||||
let extension = document_extension(file_name)?;
|
||||
|
||||
if !SUPPORTED_DOCUMENT_EXTENSIONS.contains(&extension.as_str()) {
|
||||
return Err(unsupported_document_error(file_name));
|
||||
@@ -128,15 +123,100 @@ fn ensure_supported_extension(file_name: &str) -> Result<(), AppError> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn document_extension(file_name: &str) -> Result<String, AppError> {
|
||||
file_name
|
||||
.rsplit_once('.')
|
||||
.map(|(_, extension)| extension.trim().to_ascii_lowercase())
|
||||
.filter(|extension| !extension.is_empty())
|
||||
.ok_or_else(|| unsupported_document_error(file_name))
|
||||
}
|
||||
|
||||
fn unsupported_document_error(file_name: &str) -> AppError {
|
||||
AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({
|
||||
"message": "暂时只支持 txt、md、csv、json 文本文档。",
|
||||
"message": "暂时只支持 txt、md、docx、csv、json 文档。",
|
||||
"field": "fileName",
|
||||
"fileName": file_name,
|
||||
"supportedExtensions": SUPPORTED_DOCUMENT_EXTENSIONS,
|
||||
}))
|
||||
}
|
||||
|
||||
fn decode_document_text(bytes: &[u8], extension: &str) -> Result<String, AppError> {
|
||||
if extension == "docx" {
|
||||
return extract_docx_text(bytes);
|
||||
}
|
||||
|
||||
String::from_utf8(bytes.to_vec()).map_err(|_| {
|
||||
AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({
|
||||
"message": "暂时只支持 UTF-8 文本文档,请转换编码后再上传。",
|
||||
"field": "contentBase64",
|
||||
}))
|
||||
})
|
||||
}
|
||||
|
||||
fn extract_docx_text(bytes: &[u8]) -> Result<String, AppError> {
|
||||
let reader = Cursor::new(bytes);
|
||||
let mut archive = zip::ZipArchive::new(reader).map_err(|_| {
|
||||
AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({
|
||||
"message": "docx 文档结构无效,请重新选择文件。",
|
||||
"field": "contentBase64",
|
||||
}))
|
||||
})?;
|
||||
let mut document_xml = String::new();
|
||||
archive
|
||||
.by_name("word/document.xml")
|
||||
.map_err(|_| {
|
||||
AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({
|
||||
"message": "docx 文档缺少正文内容。",
|
||||
"field": "contentBase64",
|
||||
}))
|
||||
})?
|
||||
.read_to_string(&mut document_xml)
|
||||
.map_err(|_| {
|
||||
AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({
|
||||
"message": "docx 文档正文读取失败。",
|
||||
"field": "contentBase64",
|
||||
}))
|
||||
})?;
|
||||
|
||||
Ok(extract_docx_visible_text(document_xml.as_str()))
|
||||
}
|
||||
|
||||
fn extract_docx_visible_text(xml: &str) -> String {
|
||||
let mut output = String::new();
|
||||
let mut cursor = 0usize;
|
||||
|
||||
while let Some(start_offset) = xml[cursor..].find("<w:t") {
|
||||
let start = cursor + start_offset;
|
||||
let Some(tag_end_offset) = xml[start..].find('>') else {
|
||||
break;
|
||||
};
|
||||
let text_start = start + tag_end_offset + 1;
|
||||
let Some(end_offset) = xml[text_start..].find("</w:t>") else {
|
||||
break;
|
||||
};
|
||||
let text_end = text_start + end_offset;
|
||||
output.push_str(&decode_xml_text(&xml[text_start..text_end]));
|
||||
cursor = text_end + "</w:t>".len();
|
||||
|
||||
if let Some(next_break) = xml[cursor..].find("<w:br") {
|
||||
if next_break == 0 {
|
||||
output.push('\n');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
output
|
||||
}
|
||||
|
||||
fn decode_xml_text(value: &str) -> String {
|
||||
value
|
||||
.replace("<", "<")
|
||||
.replace(">", ">")
|
||||
.replace(""", "\"")
|
||||
.replace("'", "'")
|
||||
.replace("&", "&")
|
||||
}
|
||||
|
||||
fn normalize_document_text(value: &str) -> String {
|
||||
value
|
||||
.trim_start_matches('\u{feff}')
|
||||
|
||||
1434
server-rs/crates/api-server/src/creative_agent.rs
Normal file
1434
server-rs/crates/api-server/src/creative_agent.rs
Normal file
File diff suppressed because it is too large
Load Diff
69
server-rs/crates/api-server/src/creative_agent_sse.rs
Normal file
69
server-rs/crates/api-server/src/creative_agent_sse.rs
Normal file
@@ -0,0 +1,69 @@
|
||||
use axum::response::sse::Event;
|
||||
use serde::Serialize;
|
||||
use serde_json::{Value, json};
|
||||
use shared_contracts::creative_agent::{CreativeAgentErrorEvent, CreativeAgentSseEventType};
|
||||
|
||||
pub fn creative_sse_json_event<T>(event: CreativeAgentSseEventType, payload: T) -> Event
|
||||
where
|
||||
T: Serialize,
|
||||
{
|
||||
let event_name = creative_event_name(event);
|
||||
match serde_json::to_value(payload)
|
||||
.ok()
|
||||
.and_then(|value| Event::default().event(event_name).json_data(value).ok())
|
||||
{
|
||||
Some(event) => event,
|
||||
None => creative_sse_error_event(None, "SSE_SERIALIZE_FAILED", "SSE payload 序列化失败"),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn creative_sse_json_value_event(event_name: &str, payload: Value) -> Event {
|
||||
Event::default()
|
||||
.event(event_name)
|
||||
.json_data(payload)
|
||||
.unwrap_or_else(|_| {
|
||||
creative_sse_error_event(None, "SSE_SERIALIZE_FAILED", "SSE payload 序列化失败")
|
||||
})
|
||||
}
|
||||
|
||||
pub fn creative_sse_error_event(
|
||||
session_id: Option<String>,
|
||||
code: impl Into<String>,
|
||||
message: impl Into<String>,
|
||||
) -> Event {
|
||||
let payload = serde_json::to_string(&CreativeAgentErrorEvent {
|
||||
session_id,
|
||||
code: code.into(),
|
||||
message: message.into(),
|
||||
recoverable: false,
|
||||
})
|
||||
.unwrap_or_else(|_| {
|
||||
json!({
|
||||
"sessionId": null,
|
||||
"code": "SSE_ERROR_SERIALIZE_FAILED",
|
||||
"message": "SSE 错误事件序列化失败",
|
||||
"recoverable": false,
|
||||
})
|
||||
.to_string()
|
||||
});
|
||||
Event::default().event("error").data(payload)
|
||||
}
|
||||
|
||||
fn creative_event_name(event: CreativeAgentSseEventType) -> &'static str {
|
||||
match event {
|
||||
CreativeAgentSseEventType::Stage => "stage",
|
||||
CreativeAgentSseEventType::AgentMessageDelta => "agent_message_delta",
|
||||
CreativeAgentSseEventType::ThoughtSummaryDelta => "thought_summary_delta",
|
||||
CreativeAgentSseEventType::PuzzleTemplateCatalog => "puzzle_template_catalog",
|
||||
CreativeAgentSseEventType::PuzzleTemplateSelection => "puzzle_template_selection",
|
||||
CreativeAgentSseEventType::PuzzleCostRange => "puzzle_cost_range",
|
||||
CreativeAgentSseEventType::PuzzleLevelPlan => "puzzle_level_plan",
|
||||
CreativeAgentSseEventType::ToolStarted => "tool_started",
|
||||
CreativeAgentSseEventType::ToolCompleted => "tool_completed",
|
||||
CreativeAgentSseEventType::Reflection => "reflection",
|
||||
CreativeAgentSseEventType::TargetSession => "target_session",
|
||||
CreativeAgentSseEventType::Session => "session",
|
||||
CreativeAgentSseEventType::Error => "error",
|
||||
CreativeAgentSseEventType::Done => "done",
|
||||
}
|
||||
}
|
||||
@@ -89,6 +89,12 @@ impl IntoResponse for AppError {
|
||||
}
|
||||
}
|
||||
|
||||
impl From<AppError> for Response {
|
||||
fn from(error: AppError) -> Self {
|
||||
error.into_response()
|
||||
}
|
||||
}
|
||||
|
||||
fn resolve_http_error(status_code: StatusCode) -> (&'static str, &'static str) {
|
||||
match status_code {
|
||||
StatusCode::BAD_REQUEST => ("BAD_REQUEST", "请求参数不合法"),
|
||||
|
||||
@@ -23,6 +23,8 @@ mod creation_agent_anchor_templates;
|
||||
mod creation_agent_chat;
|
||||
mod creation_agent_document_input;
|
||||
mod creation_agent_llm_turn;
|
||||
mod creative_agent;
|
||||
mod creative_agent_sse;
|
||||
mod custom_world;
|
||||
mod custom_world_agent_entities;
|
||||
mod custom_world_agent_turn;
|
||||
@@ -66,11 +68,13 @@ mod square_hole_agent_turn;
|
||||
mod state;
|
||||
mod story_battles;
|
||||
mod story_sessions;
|
||||
mod visual_novel;
|
||||
mod wechat_auth;
|
||||
mod wechat_provider;
|
||||
mod work_author;
|
||||
|
||||
use shared_logging::init_tracing;
|
||||
use std::{collections::HashSet, env, fs, io, panic, thread};
|
||||
use tokio::net::TcpListener;
|
||||
use tokio::runtime::Builder as TokioRuntimeBuilder;
|
||||
use tracing::info;
|
||||
@@ -79,30 +83,30 @@ use crate::{app::build_router, config::AppConfig, state::AppState};
|
||||
|
||||
const API_SERVER_STARTUP_STACK_SIZE_BYTES: usize = 32 * 1024 * 1024;
|
||||
|
||||
fn main() -> Result<(), std::io::Error> {
|
||||
fn main() -> Result<(), io::Error> {
|
||||
// Windows 本地调试下 Axum 路由树和启动恢复链较重,显式放大启动线程栈,避免 debug 构建在进入监听前栈溢出。
|
||||
std::thread::Builder::new()
|
||||
let server_thread = thread::Builder::new()
|
||||
.name("api-server-bootstrap".to_string())
|
||||
.stack_size(API_SERVER_STARTUP_STACK_SIZE_BYTES)
|
||||
.spawn(run_api_server_with_runtime)?
|
||||
.join()
|
||||
.map_err(|_| std::io::Error::other("api-server 启动线程异常退出"))?
|
||||
.spawn(|| {
|
||||
TokioRuntimeBuilder::new_multi_thread()
|
||||
.enable_all()
|
||||
.thread_name("api-server-worker")
|
||||
.thread_stack_size(API_SERVER_STARTUP_STACK_SIZE_BYTES)
|
||||
.build()?
|
||||
.block_on(run_server())
|
||||
})?;
|
||||
|
||||
match server_thread.join() {
|
||||
Ok(result) => result,
|
||||
Err(payload) => panic::resume_unwind(payload),
|
||||
}
|
||||
}
|
||||
|
||||
fn run_api_server_with_runtime() -> Result<(), std::io::Error> {
|
||||
TokioRuntimeBuilder::new_multi_thread()
|
||||
.enable_all()
|
||||
.thread_name("api-server-worker")
|
||||
.thread_stack_size(API_SERVER_STARTUP_STACK_SIZE_BYTES)
|
||||
.build()?
|
||||
.block_on(run_api_server())
|
||||
}
|
||||
|
||||
async fn run_api_server() -> Result<(), std::io::Error> {
|
||||
// 运行本地开发与联调时,优先从仓库根目录加载本地变量,避免手工逐项导出 OSS / APIMart 配置。
|
||||
let _ = dotenvy::from_filename(".env");
|
||||
let _ = dotenvy::from_filename(".env.local");
|
||||
let _ = dotenvy::from_filename(".env.secrets.local");
|
||||
async fn run_server() -> Result<(), io::Error> {
|
||||
// 运行本地开发与联调时,优先从仓库根目录加载本地变量。
|
||||
// 只尊重外层 shell 先注入的变量;.env.local 需要能覆盖 .env。
|
||||
load_local_env_files();
|
||||
|
||||
// 统一先从配置对象读取监听地址,避免后续把环境变量读取散落到入口和路由层。
|
||||
let config = AppConfig::from_env();
|
||||
@@ -120,3 +124,92 @@ async fn run_api_server() -> Result<(), std::io::Error> {
|
||||
|
||||
axum::serve(listener, router).await
|
||||
}
|
||||
|
||||
fn load_local_env_files() {
|
||||
let shell_env_keys = env::vars().map(|(key, _)| key).collect::<HashSet<_>>();
|
||||
|
||||
for path in [".env", ".env.local", ".env.secrets.local"] {
|
||||
load_env_file(path, &shell_env_keys);
|
||||
}
|
||||
}
|
||||
|
||||
fn load_env_file(path: &str, shell_env_keys: &HashSet<String>) {
|
||||
let Ok(raw_text) = fs::read_to_string(path) else {
|
||||
return;
|
||||
};
|
||||
|
||||
let raw_text = raw_text.trim_start_matches('\u{feff}');
|
||||
|
||||
for raw_line in raw_text.split('\n') {
|
||||
let line = raw_line.trim();
|
||||
if line.is_empty() || line.starts_with('#') {
|
||||
continue;
|
||||
}
|
||||
|
||||
let Some((raw_key, raw_value)) = line.split_once('=') else {
|
||||
continue;
|
||||
};
|
||||
let key = raw_key.trim().trim_start_matches('\u{feff}');
|
||||
if !is_valid_env_key(key) || shell_env_keys.contains(key) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// 这里只在启动前、Tokio runtime 创建前写入进程环境,避免并发读写 env。
|
||||
unsafe {
|
||||
env::set_var(key, strip_env_value(raw_value));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn strip_env_value(raw_value: &str) -> String {
|
||||
let value = raw_value.trim_end_matches('\r');
|
||||
if value.len() >= 2 {
|
||||
let bytes = value.as_bytes();
|
||||
let first = bytes[0];
|
||||
let last = bytes[value.len() - 1];
|
||||
if (first == b'"' && last == b'"') || (first == b'\'' && last == b'\'') {
|
||||
return value[1..value.len() - 1].to_string();
|
||||
}
|
||||
}
|
||||
|
||||
value.to_string()
|
||||
}
|
||||
|
||||
fn is_valid_env_key(key: &str) -> bool {
|
||||
let mut chars = key.chars();
|
||||
match chars.next() {
|
||||
Some(first) if first == '_' || first.is_ascii_alphabetic() => {}
|
||||
_ => return false,
|
||||
}
|
||||
|
||||
chars.all(|ch| ch == '_' || ch.is_ascii_alphanumeric())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::{is_valid_env_key, strip_env_value};
|
||||
|
||||
#[test]
|
||||
fn strip_env_value_removes_wrapping_quotes() {
|
||||
assert_eq!(strip_env_value("\"true\""), "true");
|
||||
assert_eq!(strip_env_value("'aliyun'"), "aliyun");
|
||||
assert_eq!(strip_env_value("plain\r"), "plain");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn load_env_key_can_strip_utf8_bom_prefix() {
|
||||
let key = "\u{feff}SMS_AUTH_ENABLED"
|
||||
.trim()
|
||||
.trim_start_matches('\u{feff}');
|
||||
|
||||
assert_eq!(key, "SMS_AUTH_ENABLED");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn is_valid_env_key_accepts_dotenv_key_subset() {
|
||||
assert!(is_valid_env_key("SMS_AUTH_ENABLED"));
|
||||
assert!(is_valid_env_key("_LOCAL_KEY_1"));
|
||||
assert!(!is_valid_env_key("1_BAD"));
|
||||
assert!(!is_valid_env_key("BAD-KEY"));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@ pub(crate) mod puzzle;
|
||||
pub(crate) mod rpg;
|
||||
pub(crate) mod scene_background;
|
||||
pub(crate) mod square_hole;
|
||||
pub(crate) mod visual_novel;
|
||||
|
||||
pub(crate) use rpg::agent_chat;
|
||||
pub(crate) use rpg::foundation_draft;
|
||||
|
||||
690
server-rs/crates/api-server/src/prompt/visual_novel.rs
Normal file
690
server-rs/crates/api-server/src/prompt/visual_novel.rs
Normal file
@@ -0,0 +1,690 @@
|
||||
#![allow(dead_code)]
|
||||
|
||||
use platform_llm::{LlmMessage, LlmTextRequest};
|
||||
use serde_json::{Value as JsonValue, json};
|
||||
use shared_contracts::visual_novel::{VisualNovelResultDraft, VisualNovelRuntimeStep};
|
||||
|
||||
use crate::llm_model_routing::CREATION_TEMPLATE_LLM_MODEL;
|
||||
|
||||
pub(crate) const VISUAL_NOVEL_CREATION_SYSTEM_PROMPT: &str = r#"你是百梦平台内的视觉小说模板创作导演。
|
||||
|
||||
你的任务是把用户的一句话、文档摘要或空白创建意图,生成一份可以进入结果页继续编辑的 VisualNovelResultDraft。
|
||||
|
||||
硬约束:
|
||||
1. 只能输出一个 JSON 对象,不要输出 Markdown、代码块、解释或 UI 规则说明。
|
||||
2. 输出内容必须是中文视觉小说底稿,补齐世界观、玩家身份、角色、场景、剧情阶段和开场。
|
||||
3. 每个角色必须有可生成立绘的 appearance,每个场景必须有可生成背景图的 description。
|
||||
4. sourceMode 必须沿用输入的 idea、document 或 blank。
|
||||
5. 图片、音乐、文档只能写平台资产引用或 null,不能写大段 data URL。
|
||||
6. 不要输出旧 TXT 播放记录、分享播放包、外部商业、运营、活动、展示横幅、交易或独立账号字段。
|
||||
7. 不要发明第二套存档、发布、钱包、广场或资产系统。
|
||||
8. publishReady 只有在 opening 场景、主要角色、剧情阶段和 2 到 4 个 initialChoices 都齐备时才可以为 true。
|
||||
"#;
|
||||
|
||||
pub(crate) const VISUAL_NOVEL_RUNTIME_GM_SYSTEM_PROMPT: &str = r#"你是百梦视觉小说运行时 GM。
|
||||
|
||||
你的任务是读取作品底稿、当前 run snapshot、玩家动作和最近历史,然后输出下一轮 VisualNovelRuntimeStep[]。
|
||||
|
||||
硬约束:
|
||||
1. 只能输出一个 JSON 数组,不要输出对象包裹、Markdown、代码块、解释或 UI 规则说明。
|
||||
2. 每轮 step 数量不能超过输入的 maxAssistantStepCountPerTurn。
|
||||
3. 场景变化必须先输出 scene_change。
|
||||
4. 旁白使用 narration,角色说话使用 dialogue,转场使用 transition。
|
||||
5. 需要玩家选择时必须输出 choice,choice 内每项必须有 choiceId 和 text。
|
||||
6. 关键剧情事实变化使用 flag,数值倾向变化使用 metric。
|
||||
7. 不要让前端从 raw_text 猜业务 step,不要输出未定义 step 类型。
|
||||
8. 不要输出旧 TXT 播放记录、分享播放包、屏幕记录、外部商业、运营、活动或独立保存元数据。
|
||||
"#;
|
||||
|
||||
pub(crate) const VISUAL_NOVEL_REPAIR_SYSTEM_PROMPT: &str = r#"你是视觉小说结构化输出修复器。
|
||||
|
||||
你的任务是把上一次模型输出修复为目标 JSON 契约。
|
||||
|
||||
硬约束:
|
||||
1. 只能输出目标 JSON,不要解释错误原因。
|
||||
2. 不能新增目标契约之外的字段。
|
||||
3. 不要把普通历史、运行事件或 raw_text 改写成旧 TXT 播放包、屏幕记录或分享片段。
|
||||
4. 如果原文缺失必要信息,只补最小可运行占位值,并保持中文内容。
|
||||
"#;
|
||||
|
||||
const VISUAL_NOVEL_CREATION_OUTPUT_CONTRACT: &str = r#"{
|
||||
"profileId": null,
|
||||
"workTitle": "",
|
||||
"workDescription": "",
|
||||
"workTags": [],
|
||||
"coverImageSrc": null,
|
||||
"sourceMode": "idea",
|
||||
"sourceAssetIds": [],
|
||||
"world": {
|
||||
"title": "",
|
||||
"summary": "",
|
||||
"background": "",
|
||||
"premise": "",
|
||||
"literaryStyle": "",
|
||||
"playerRole": "",
|
||||
"defaultTone": ""
|
||||
},
|
||||
"characters": [
|
||||
{
|
||||
"characterId": "char-main-1",
|
||||
"name": "",
|
||||
"gender": null,
|
||||
"role": "main",
|
||||
"appearance": "",
|
||||
"personality": "",
|
||||
"tone": "",
|
||||
"background": "",
|
||||
"relationshipToPlayer": "",
|
||||
"imageAssets": [],
|
||||
"defaultExpression": null,
|
||||
"isPlayerVisible": false
|
||||
}
|
||||
],
|
||||
"scenes": [
|
||||
{
|
||||
"sceneId": "scene-opening",
|
||||
"name": "",
|
||||
"description": "",
|
||||
"backgroundImageSrc": null,
|
||||
"musicSrc": null,
|
||||
"ambientSoundSrc": null,
|
||||
"availability": "opening",
|
||||
"phaseIds": []
|
||||
}
|
||||
],
|
||||
"storyPhases": [
|
||||
{
|
||||
"phaseId": "phase-opening",
|
||||
"title": "",
|
||||
"goal": "",
|
||||
"summary": "",
|
||||
"entryCondition": "",
|
||||
"exitCondition": "",
|
||||
"sceneIds": ["scene-opening"],
|
||||
"characterIds": ["char-main-1"],
|
||||
"suggestedChoices": []
|
||||
}
|
||||
],
|
||||
"opening": {
|
||||
"sceneId": "scene-opening",
|
||||
"narration": "",
|
||||
"speakerCharacterId": null,
|
||||
"firstDialogue": null,
|
||||
"initialChoices": [
|
||||
{ "choiceId": "choice-opening-1", "text": "", "actionHint": null },
|
||||
{ "choiceId": "choice-opening-2", "text": "", "actionHint": null }
|
||||
]
|
||||
},
|
||||
"runtimeConfig": {
|
||||
"textModeEnabled": true,
|
||||
"defaultTextMode": false,
|
||||
"maxHistoryEntries": 80,
|
||||
"maxAssistantStepCountPerTurn": 8,
|
||||
"allowFreeTextAction": true,
|
||||
"allowHistoryRegeneration": true,
|
||||
"attributePanelMode": "off",
|
||||
"saveArchiveEnabled": true
|
||||
},
|
||||
"publishReady": false,
|
||||
"validationIssues": [],
|
||||
"updatedAt": "ISO-8601"
|
||||
}"#;
|
||||
|
||||
const VISUAL_NOVEL_RUNTIME_OUTPUT_CONTRACT: &str = r#"[
|
||||
{ "type": "scene_change", "sceneId": "scene-opening", "backgroundImageSrc": null, "musicSrc": null },
|
||||
{ "type": "narration", "text": "" },
|
||||
{ "type": "dialogue", "characterId": "char-main-1", "characterName": "", "expression": null, "text": "" },
|
||||
{ "type": "transition", "transitionKind": "fade", "text": null },
|
||||
{ "type": "flag", "key": "", "value": true },
|
||||
{ "type": "metric", "key": "", "delta": 1 },
|
||||
{ "type": "choice", "choices": [{ "choiceId": "choice-next-1", "text": "", "actionHint": null }] }
|
||||
]"#;
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub(crate) struct VisualNovelCreationPromptParams<'a> {
|
||||
pub(crate) source_mode: &'a str,
|
||||
pub(crate) seed_text: Option<&'a str>,
|
||||
pub(crate) source_asset_ids: &'a [String],
|
||||
pub(crate) document_summary: Option<&'a str>,
|
||||
pub(crate) current_draft: Option<&'a JsonValue>,
|
||||
pub(crate) recent_messages: &'a [JsonValue],
|
||||
pub(crate) now_iso: &'a str,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub(crate) struct VisualNovelRuntimePromptParams<'a> {
|
||||
pub(crate) work_profile: &'a JsonValue,
|
||||
pub(crate) run_snapshot: &'a JsonValue,
|
||||
pub(crate) runtime_action: &'a JsonValue,
|
||||
pub(crate) recent_history: &'a [JsonValue],
|
||||
pub(crate) max_assistant_step_count_per_turn: u32,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub(crate) enum VisualNovelRepairTarget {
|
||||
ResultDraft,
|
||||
RuntimeSteps,
|
||||
}
|
||||
|
||||
impl VisualNovelRepairTarget {
|
||||
fn label(self) -> &'static str {
|
||||
match self {
|
||||
Self::ResultDraft => "VisualNovelResultDraft",
|
||||
Self::RuntimeSteps => "VisualNovelRuntimeStep[]",
|
||||
}
|
||||
}
|
||||
|
||||
fn contract(self) -> &'static str {
|
||||
match self {
|
||||
Self::ResultDraft => VISUAL_NOVEL_CREATION_OUTPUT_CONTRACT,
|
||||
Self::RuntimeSteps => VISUAL_NOVEL_RUNTIME_OUTPUT_CONTRACT,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub(crate) struct VisualNovelRepairPromptParams<'a> {
|
||||
pub(crate) target: VisualNovelRepairTarget,
|
||||
pub(crate) raw_text: &'a str,
|
||||
pub(crate) parse_error: &'a str,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub(crate) struct VisualNovelPromptParseFailure {
|
||||
pub(crate) target: VisualNovelRepairTarget,
|
||||
pub(crate) message: String,
|
||||
}
|
||||
|
||||
impl VisualNovelPromptParseFailure {
|
||||
pub(crate) fn retryable_message(&self) -> String {
|
||||
format!(
|
||||
"{} 输出结构不可解析,可重试或进入 repair:{}",
|
||||
self.target.label(),
|
||||
self.message
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub(crate) struct VisualNovelToolDescriptor {
|
||||
pub(crate) name: &'static str,
|
||||
pub(crate) description: &'static str,
|
||||
pub(crate) input_schema: JsonValue,
|
||||
}
|
||||
|
||||
pub(crate) fn build_visual_novel_creation_user_prompt(
|
||||
params: VisualNovelCreationPromptParams<'_>,
|
||||
) -> String {
|
||||
json!({
|
||||
"task": "generate_visual_novel_result_draft",
|
||||
"sourceMode": params.source_mode,
|
||||
"seedText": params.seed_text.unwrap_or("").trim(),
|
||||
"sourceAssetIds": params.source_asset_ids,
|
||||
"documentSummary": params.document_summary.unwrap_or("").trim(),
|
||||
"currentDraft": params.current_draft,
|
||||
"recentMessages": params.recent_messages,
|
||||
"nowIso": params.now_iso,
|
||||
"draftRequirements": {
|
||||
"mainCharacters": "3 到 6 个,至少 1 个非玩家主要角色",
|
||||
"scenes": "3 到 8 个,至少 1 个 opening 场景",
|
||||
"storyPhases": "3 到 6 个,第一阶段可从 opening 进入",
|
||||
"initialChoices": "2 到 4 个",
|
||||
"runtimeConfigDefaults": "沿用契约默认值,attributePanelMode 默认为 off"
|
||||
},
|
||||
"outputContract": VISUAL_NOVEL_CREATION_OUTPUT_CONTRACT
|
||||
})
|
||||
.to_string()
|
||||
}
|
||||
|
||||
pub(crate) fn build_visual_novel_runtime_user_prompt(
|
||||
params: VisualNovelRuntimePromptParams<'_>,
|
||||
) -> String {
|
||||
json!({
|
||||
"task": "generate_visual_novel_runtime_steps",
|
||||
"workProfile": params.work_profile,
|
||||
"runSnapshot": params.run_snapshot,
|
||||
"runtimeAction": params.runtime_action,
|
||||
"recentHistory": params.recent_history,
|
||||
"maxAssistantStepCountPerTurn": params.max_assistant_step_count_per_turn,
|
||||
"runtimeRules": [
|
||||
"只以 step 数组作为正式业务输出",
|
||||
"当前选择项必须来自 runSnapshot.availableChoices 或由本轮 choice step 重新给出",
|
||||
"如果玩家自由输入改变事实,必须用 flag 或 metric 表达可持久化变化",
|
||||
"不要在输出中夹带 raw_text、debug、prompt、historyPlayback 或平台运营字段"
|
||||
],
|
||||
"outputContract": VISUAL_NOVEL_RUNTIME_OUTPUT_CONTRACT
|
||||
})
|
||||
.to_string()
|
||||
}
|
||||
|
||||
pub(crate) fn build_visual_novel_repair_user_prompt(
|
||||
params: VisualNovelRepairPromptParams<'_>,
|
||||
) -> String {
|
||||
json!({
|
||||
"task": "repair_visual_novel_structured_output",
|
||||
"target": params.target.label(),
|
||||
"parseError": params.parse_error,
|
||||
"rawText": params.raw_text,
|
||||
"outputContract": params.target.contract()
|
||||
})
|
||||
.to_string()
|
||||
}
|
||||
|
||||
pub(crate) fn build_visual_novel_creation_llm_request(
|
||||
params: VisualNovelCreationPromptParams<'_>,
|
||||
enable_web_search: bool,
|
||||
) -> LlmTextRequest {
|
||||
LlmTextRequest::new(vec![
|
||||
LlmMessage::system(VISUAL_NOVEL_CREATION_SYSTEM_PROMPT),
|
||||
LlmMessage::user(build_visual_novel_creation_user_prompt(params)),
|
||||
])
|
||||
.with_model(CREATION_TEMPLATE_LLM_MODEL)
|
||||
.with_responses_api()
|
||||
.with_web_search(enable_web_search)
|
||||
}
|
||||
|
||||
pub(crate) fn build_visual_novel_runtime_llm_request(
|
||||
params: VisualNovelRuntimePromptParams<'_>,
|
||||
) -> LlmTextRequest {
|
||||
LlmTextRequest::new(vec![
|
||||
LlmMessage::system(VISUAL_NOVEL_RUNTIME_GM_SYSTEM_PROMPT),
|
||||
LlmMessage::user(build_visual_novel_runtime_user_prompt(params)),
|
||||
])
|
||||
.with_model(CREATION_TEMPLATE_LLM_MODEL)
|
||||
.with_responses_api()
|
||||
}
|
||||
|
||||
pub(crate) fn build_visual_novel_repair_llm_request(
|
||||
params: VisualNovelRepairPromptParams<'_>,
|
||||
) -> LlmTextRequest {
|
||||
LlmTextRequest::new(vec![
|
||||
LlmMessage::system(VISUAL_NOVEL_REPAIR_SYSTEM_PROMPT),
|
||||
LlmMessage::user(build_visual_novel_repair_user_prompt(params)),
|
||||
])
|
||||
.with_model(CREATION_TEMPLATE_LLM_MODEL)
|
||||
.with_responses_api()
|
||||
}
|
||||
|
||||
pub(crate) fn visual_novel_tool_descriptors() -> Vec<VisualNovelToolDescriptor> {
|
||||
vec![
|
||||
VisualNovelToolDescriptor {
|
||||
name: "visual_novel_apply_creation_action",
|
||||
description: "执行视觉小说创作 action,写回 VisualNovelResultDraft 或编译平台 work profile 草稿。",
|
||||
input_schema: json!({
|
||||
"type": "object",
|
||||
"required": ["kind"],
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"kind": {
|
||||
"type": "string",
|
||||
"enum": [
|
||||
"generate_draft",
|
||||
"patch_world",
|
||||
"patch_character",
|
||||
"patch_scene",
|
||||
"patch_story_phase",
|
||||
"compile_work_profile"
|
||||
]
|
||||
},
|
||||
"targetId": { "type": ["string", "null"] },
|
||||
"payload": { "type": "object", "additionalProperties": true }
|
||||
}
|
||||
}),
|
||||
},
|
||||
VisualNovelToolDescriptor {
|
||||
name: "visual_novel_generate_image_asset",
|
||||
description: "为视觉小说角色立绘或场景背景生成图片,并返回平台资产引用。",
|
||||
input_schema: json!({
|
||||
"type": "object",
|
||||
"required": ["kind", "targetId", "prompt"],
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"kind": {
|
||||
"type": "string",
|
||||
"enum": ["generate_scene_image", "generate_character_image"]
|
||||
},
|
||||
"targetId": { "type": "string", "minLength": 1 },
|
||||
"prompt": { "type": "string", "minLength": 1 },
|
||||
"styleHints": { "type": "array", "items": { "type": "string" } },
|
||||
"sourceImageAssetId": { "type": ["string", "null"] }
|
||||
}
|
||||
}),
|
||||
},
|
||||
]
|
||||
}
|
||||
|
||||
pub(crate) fn parse_visual_novel_result_draft_fixture(
|
||||
text: &str,
|
||||
) -> Result<VisualNovelResultDraft, VisualNovelPromptParseFailure> {
|
||||
let value = extract_json_root(
|
||||
text,
|
||||
JsonRootShape::Object,
|
||||
VisualNovelRepairTarget::ResultDraft,
|
||||
)?;
|
||||
serde_json::from_value(value).map_err(|error| VisualNovelPromptParseFailure {
|
||||
target: VisualNovelRepairTarget::ResultDraft,
|
||||
message: error.to_string(),
|
||||
})
|
||||
}
|
||||
|
||||
pub(crate) fn parse_visual_novel_runtime_steps_fixture(
|
||||
text: &str,
|
||||
) -> Result<Vec<VisualNovelRuntimeStep>, VisualNovelPromptParseFailure> {
|
||||
let value = extract_json_root(
|
||||
text,
|
||||
JsonRootShape::Array,
|
||||
VisualNovelRepairTarget::RuntimeSteps,
|
||||
)?;
|
||||
serde_json::from_value(value).map_err(|error| VisualNovelPromptParseFailure {
|
||||
target: VisualNovelRepairTarget::RuntimeSteps,
|
||||
message: error.to_string(),
|
||||
})
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy)]
|
||||
enum JsonRootShape {
|
||||
Object,
|
||||
Array,
|
||||
}
|
||||
|
||||
fn extract_json_root(
|
||||
text: &str,
|
||||
shape: JsonRootShape,
|
||||
target: VisualNovelRepairTarget,
|
||||
) -> Result<JsonValue, VisualNovelPromptParseFailure> {
|
||||
let trimmed = strip_json_code_fence(text.trim());
|
||||
if let Ok(value) = serde_json::from_str::<JsonValue>(trimmed) {
|
||||
return Ok(value);
|
||||
}
|
||||
|
||||
let (start_char, end_char) = match shape {
|
||||
JsonRootShape::Object => ('{', '}'),
|
||||
JsonRootShape::Array => ('[', ']'),
|
||||
};
|
||||
let start = trimmed.find(start_char);
|
||||
let end = trimmed.rfind(end_char);
|
||||
match (start, end) {
|
||||
(Some(start), Some(end)) if end > start => {
|
||||
serde_json::from_str::<JsonValue>(&trimmed[start..=end]).map_err(|error| {
|
||||
VisualNovelPromptParseFailure {
|
||||
target,
|
||||
message: error.to_string(),
|
||||
}
|
||||
})
|
||||
}
|
||||
_ => Err(VisualNovelPromptParseFailure {
|
||||
target,
|
||||
message: format!("未找到目标 JSON {}", target.label()),
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
||||
fn strip_json_code_fence(text: &str) -> &str {
|
||||
let trimmed = text.trim();
|
||||
if !trimmed.starts_with("```") {
|
||||
return trimmed;
|
||||
}
|
||||
|
||||
let without_start = trimmed
|
||||
.strip_prefix("```json")
|
||||
.or_else(|| trimmed.strip_prefix("```JSON"))
|
||||
.or_else(|| trimmed.strip_prefix("```"))
|
||||
.unwrap_or(trimmed)
|
||||
.trim();
|
||||
without_start
|
||||
.strip_suffix("```")
|
||||
.unwrap_or(without_start)
|
||||
.trim()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use platform_llm::LlmTextProtocol;
|
||||
use serde_json::json;
|
||||
|
||||
use super::*;
|
||||
|
||||
fn source_asset_ids() -> Vec<String> {
|
||||
vec!["asset-doc-1".to_string()]
|
||||
}
|
||||
|
||||
fn creation_params<'a>(source_asset_ids: &'a [String]) -> VisualNovelCreationPromptParams<'a> {
|
||||
VisualNovelCreationPromptParams {
|
||||
source_mode: "idea",
|
||||
seed_text: Some("雨夜里,只在午夜出现的书店会归还人们遗失的名字。"),
|
||||
source_asset_ids,
|
||||
document_summary: None,
|
||||
current_draft: None,
|
||||
recent_messages: &[],
|
||||
now_iso: "2026-05-05T12:00:00Z",
|
||||
}
|
||||
}
|
||||
|
||||
fn runtime_params<'a>(
|
||||
work_profile: &'a JsonValue,
|
||||
run_snapshot: &'a JsonValue,
|
||||
runtime_action: &'a JsonValue,
|
||||
) -> VisualNovelRuntimePromptParams<'a> {
|
||||
VisualNovelRuntimePromptParams {
|
||||
work_profile,
|
||||
run_snapshot,
|
||||
runtime_action,
|
||||
recent_history: &[],
|
||||
max_assistant_step_count_per_turn: 8,
|
||||
}
|
||||
}
|
||||
|
||||
fn sample_draft() -> JsonValue {
|
||||
json!({
|
||||
"profileId": null,
|
||||
"workTitle": "雨夜书店",
|
||||
"workDescription": "一名失去名字的读者在午夜书店寻找真相。",
|
||||
"workTags": ["悬疑", "治愈"],
|
||||
"coverImageSrc": null,
|
||||
"sourceMode": "idea",
|
||||
"sourceAssetIds": [],
|
||||
"world": {
|
||||
"title": "雨夜书店",
|
||||
"summary": "午夜书店会收留遗失名字的人。",
|
||||
"background": "旧城区尽头有一家只在雨夜开门的书店,书架保存着人们遗忘的片段。",
|
||||
"premise": "玩家要在天亮前找回自己的名字。",
|
||||
"literaryStyle": "细腻、克制、轻悬疑",
|
||||
"playerRole": "失去名字的读者",
|
||||
"defaultTone": "雨夜、温柔、隐秘"
|
||||
},
|
||||
"characters": [
|
||||
{
|
||||
"characterId": "char-keeper",
|
||||
"name": "林栖",
|
||||
"gender": "女",
|
||||
"role": "main",
|
||||
"appearance": "银灰短发,深绿围裙,手中常拿一盏铜灯,适合半身立绘。",
|
||||
"personality": "温和但不轻易透露真相",
|
||||
"tone": "低声、像在翻旧书",
|
||||
"background": "午夜书店的看守者。",
|
||||
"relationshipToPlayer": "知道玩家名字的一部分。",
|
||||
"imageAssets": [],
|
||||
"defaultExpression": "calm",
|
||||
"isPlayerVisible": false
|
||||
}
|
||||
],
|
||||
"scenes": [
|
||||
{
|
||||
"sceneId": "scene-bookstore",
|
||||
"name": "午夜书店",
|
||||
"description": "窄巷尽头的木门半开,暖黄灯光落在潮湿石板上,室内书架高而幽深。",
|
||||
"backgroundImageSrc": null,
|
||||
"musicSrc": null,
|
||||
"ambientSoundSrc": null,
|
||||
"availability": "opening",
|
||||
"phaseIds": ["phase-opening"]
|
||||
}
|
||||
],
|
||||
"storyPhases": [
|
||||
{
|
||||
"phaseId": "phase-opening",
|
||||
"title": "失名之夜",
|
||||
"goal": "确认玩家为何失去名字",
|
||||
"summary": "玩家进入书店,与林栖第一次交谈。",
|
||||
"entryCondition": "opening",
|
||||
"exitCondition": "找到第一张名字书签",
|
||||
"sceneIds": ["scene-bookstore"],
|
||||
"characterIds": ["char-keeper"],
|
||||
"suggestedChoices": ["询问书店来历", "查看柜台上的旧书"]
|
||||
}
|
||||
],
|
||||
"opening": {
|
||||
"sceneId": "scene-bookstore",
|
||||
"narration": "雨水顺着伞尖落下时,你发现门牌上的字正在一点点亮起。",
|
||||
"speakerCharacterId": "char-keeper",
|
||||
"firstDialogue": "你终于来了。名字丢失的人,总会先听见这场雨。",
|
||||
"initialChoices": [
|
||||
{ "choiceId": "choice-ask-name", "text": "询问自己的名字在哪里", "actionHint": "向林栖确认线索" },
|
||||
{ "choiceId": "choice-look-book", "text": "查看柜台上的旧书", "actionHint": "寻找名字书签" }
|
||||
]
|
||||
},
|
||||
"runtimeConfig": {
|
||||
"textModeEnabled": true,
|
||||
"defaultTextMode": false,
|
||||
"maxHistoryEntries": 80,
|
||||
"maxAssistantStepCountPerTurn": 8,
|
||||
"allowFreeTextAction": true,
|
||||
"allowHistoryRegeneration": true,
|
||||
"attributePanelMode": "off",
|
||||
"saveArchiveEnabled": true
|
||||
},
|
||||
"publishReady": true,
|
||||
"validationIssues": [],
|
||||
"updatedAt": "2026-05-05T12:00:00Z"
|
||||
})
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn creation_fixture_parses_as_visual_novel_result_draft() {
|
||||
let raw_text = format!("模型输出如下:\n{}", sample_draft());
|
||||
let draft = parse_visual_novel_result_draft_fixture(raw_text.as_str())
|
||||
.expect("draft fixture should parse");
|
||||
|
||||
assert_eq!(draft.work_title, "雨夜书店");
|
||||
assert_eq!(draft.characters[0].character_id, "char-keeper");
|
||||
assert_eq!(draft.opening.initial_choices.len(), 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn runtime_fixture_parses_as_typed_steps() {
|
||||
let raw_text = json!([
|
||||
{ "type": "scene_change", "sceneId": "scene-bookstore", "backgroundImageSrc": null, "musicSrc": null },
|
||||
{ "type": "narration", "text": "门铃轻响,雨声像被书页吸走。" },
|
||||
{ "type": "dialogue", "characterId": "char-keeper", "characterName": "林栖", "expression": "calm", "text": "先别急着找答案,先告诉我你还记得什么。" },
|
||||
{ "type": "flag", "key": "met_keeper", "value": true },
|
||||
{ "type": "metric", "key": "keeper_trust", "delta": 1 },
|
||||
{
|
||||
"type": "choice",
|
||||
"choices": [
|
||||
{ "choiceId": "choice-tell-memory", "text": "说出最后记得的街名", "actionHint": "提供线索" },
|
||||
{ "choiceId": "choice-stay-silent", "text": "保持沉默观察她", "actionHint": "观察林栖反应" }
|
||||
]
|
||||
}
|
||||
])
|
||||
.to_string();
|
||||
|
||||
let steps = parse_visual_novel_runtime_steps_fixture(raw_text.as_str())
|
||||
.expect("runtime fixture should parse");
|
||||
|
||||
assert_eq!(steps.len(), 6);
|
||||
assert!(matches!(
|
||||
steps[0],
|
||||
VisualNovelRuntimeStep::SceneChange { .. }
|
||||
));
|
||||
assert!(matches!(steps[5], VisualNovelRuntimeStep::Choice { .. }));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bad_runtime_output_can_enter_repair_prompt() {
|
||||
let failure = parse_visual_novel_runtime_steps_fixture("林栖说:欢迎来到书店。")
|
||||
.expect_err("bad output should fail");
|
||||
let retryable_message = failure.retryable_message();
|
||||
let repair_prompt = build_visual_novel_repair_user_prompt(VisualNovelRepairPromptParams {
|
||||
target: failure.target,
|
||||
raw_text: "林栖说:欢迎来到书店。",
|
||||
parse_error: failure.message.as_str(),
|
||||
});
|
||||
|
||||
assert!(retryable_message.contains("可重试"));
|
||||
assert!(repair_prompt.contains("VisualNovelRuntimeStep[]"));
|
||||
assert!(repair_prompt.contains("林栖说"));
|
||||
assert!(repair_prompt.contains("scene_change"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn llm_requests_use_responses_template_model() {
|
||||
let asset_ids = source_asset_ids();
|
||||
let creation_request =
|
||||
build_visual_novel_creation_llm_request(creation_params(asset_ids.as_slice()), true);
|
||||
|
||||
assert_eq!(
|
||||
creation_request.model.as_deref(),
|
||||
Some(CREATION_TEMPLATE_LLM_MODEL)
|
||||
);
|
||||
assert_eq!(creation_request.protocol, LlmTextProtocol::Responses);
|
||||
assert!(creation_request.enable_web_search);
|
||||
assert!(
|
||||
creation_request.messages[0]
|
||||
.content
|
||||
.contains("VisualNovelResultDraft")
|
||||
);
|
||||
assert!(
|
||||
creation_request.messages[1]
|
||||
.content
|
||||
.contains("sourceAssetIds")
|
||||
);
|
||||
|
||||
let work_profile = sample_draft();
|
||||
let run_snapshot = json!({ "runId": "run-1", "availableChoices": [] });
|
||||
let runtime_action = json!({ "actionKind": "continue", "clientEventId": "event-1" });
|
||||
let runtime_request = build_visual_novel_runtime_llm_request(runtime_params(
|
||||
&work_profile,
|
||||
&run_snapshot,
|
||||
&runtime_action,
|
||||
));
|
||||
|
||||
assert_eq!(
|
||||
runtime_request.model.as_deref(),
|
||||
Some(CREATION_TEMPLATE_LLM_MODEL)
|
||||
);
|
||||
assert_eq!(runtime_request.protocol, LlmTextProtocol::Responses);
|
||||
assert!(!runtime_request.enable_web_search);
|
||||
assert!(
|
||||
runtime_request.messages[0]
|
||||
.content
|
||||
.contains("VisualNovelRuntimeStep[]")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn prompts_and_tools_guard_against_external_platform_fields() {
|
||||
assert!(VISUAL_NOVEL_CREATION_SYSTEM_PROMPT.contains("外部商业"));
|
||||
assert!(VISUAL_NOVEL_CREATION_SYSTEM_PROMPT.contains("独立账号"));
|
||||
assert!(VISUAL_NOVEL_RUNTIME_GM_SYSTEM_PROMPT.contains("独立保存"));
|
||||
|
||||
let tools = visual_novel_tool_descriptors();
|
||||
let tool_payload = serde_json::to_string(&json!(
|
||||
tools
|
||||
.iter()
|
||||
.map(|tool| json!({
|
||||
"name": tool.name,
|
||||
"description": tool.description,
|
||||
"inputSchema": tool.input_schema,
|
||||
}))
|
||||
.collect::<Vec<_>>()
|
||||
))
|
||||
.expect("tools should serialize");
|
||||
|
||||
assert!(tool_payload.contains("generate_scene_image"));
|
||||
assert!(tool_payload.contains("generate_character_image"));
|
||||
assert!(tool_payload.contains("compile_work_profile"));
|
||||
let legacy_playback_marker = format!("{}{}", "re", "play");
|
||||
assert!(!tool_payload.contains(&legacy_playback_marker));
|
||||
assert!(!tool_payload.contains(&legacy_playback_marker.to_uppercase()));
|
||||
}
|
||||
}
|
||||
@@ -219,6 +219,7 @@ pub async fn generate_puzzle_onboarding_work(
|
||||
level_id: "onboarding-level-1".to_string(),
|
||||
level_name: level_name.clone(),
|
||||
picture_description: prompt_text.clone(),
|
||||
picture_reference: None,
|
||||
candidates,
|
||||
selected_candidate_id: Some(selected.candidate_id.clone()),
|
||||
cover_image_src: Some(selected.image_src.clone()),
|
||||
@@ -706,6 +707,7 @@ pub async fn execute_puzzle_agent_action(
|
||||
);
|
||||
let (operation_type, phase_label, phase_detail, session) = match action.as_str() {
|
||||
"compile_puzzle_draft" => {
|
||||
let ai_redraw = payload.ai_redraw.unwrap_or(true);
|
||||
let prompt_text = payload
|
||||
.picture_description
|
||||
.as_deref()
|
||||
@@ -725,33 +727,49 @@ pub async fn execute_puzzle_agent_action(
|
||||
Ok(next_session_id) => next_session_id,
|
||||
Err(response) => return Err(response),
|
||||
};
|
||||
let session = execute_billable_asset_operation_with_cost(
|
||||
&state,
|
||||
&owner_user_id,
|
||||
"puzzle_initial_image",
|
||||
&billing_asset_id,
|
||||
PUZZLE_IMAGE_GENERATION_POINTS_COST,
|
||||
async {
|
||||
compile_puzzle_draft_with_initial_cover(
|
||||
&state,
|
||||
compile_session_id.clone(),
|
||||
owner_user_id.clone(),
|
||||
prompt_text,
|
||||
payload.reference_image_src.as_deref(),
|
||||
payload.image_model.as_deref(),
|
||||
now,
|
||||
)
|
||||
.await
|
||||
},
|
||||
)
|
||||
.await
|
||||
let session = if ai_redraw {
|
||||
execute_billable_asset_operation_with_cost(
|
||||
&state,
|
||||
&owner_user_id,
|
||||
"puzzle_initial_image",
|
||||
&billing_asset_id,
|
||||
PUZZLE_IMAGE_GENERATION_POINTS_COST,
|
||||
async {
|
||||
compile_puzzle_draft_with_initial_cover(
|
||||
&state,
|
||||
compile_session_id.clone(),
|
||||
owner_user_id.clone(),
|
||||
prompt_text,
|
||||
payload.reference_image_src.as_deref(),
|
||||
payload.image_model.as_deref(),
|
||||
now,
|
||||
)
|
||||
.await
|
||||
},
|
||||
)
|
||||
.await
|
||||
} else {
|
||||
compile_puzzle_draft_with_uploaded_cover(
|
||||
&state,
|
||||
compile_session_id.clone(),
|
||||
owner_user_id.clone(),
|
||||
prompt_text,
|
||||
payload.reference_image_src.as_deref(),
|
||||
now,
|
||||
)
|
||||
.await
|
||||
}
|
||||
.map_err(|error| {
|
||||
puzzle_error_response(&request_context, PUZZLE_AGENT_API_BASE_PROVIDER, error)
|
||||
});
|
||||
(
|
||||
"compile_puzzle_draft",
|
||||
"首关拼图草稿",
|
||||
"已编译首关草稿、生成首关画面并写入正式草稿。",
|
||||
if ai_redraw {
|
||||
"已编译首关草稿、生成首关画面并写入正式草稿。"
|
||||
} else {
|
||||
"已编译首关草稿,并直接应用上传图片为第一关图片。"
|
||||
},
|
||||
session,
|
||||
)
|
||||
}
|
||||
@@ -1980,6 +1998,7 @@ fn map_puzzle_draft_level_response(level: PuzzleDraftLevelRecord) -> PuzzleDraft
|
||||
level_id: level.level_id,
|
||||
level_name: level.level_name,
|
||||
picture_description: level.picture_description,
|
||||
picture_reference: level.picture_reference,
|
||||
candidates: level
|
||||
.candidates
|
||||
.into_iter()
|
||||
@@ -2519,6 +2538,7 @@ fn parse_puzzle_level_records_from_module_json(
|
||||
level_id: level.level_id,
|
||||
level_name: level.level_name,
|
||||
picture_description: level.picture_description,
|
||||
picture_reference: level.picture_reference,
|
||||
candidates: level
|
||||
.candidates
|
||||
.into_iter()
|
||||
@@ -2685,6 +2705,7 @@ fn serialize_puzzle_levels_response(
|
||||
"level_id": level.level_id,
|
||||
"level_name": level.level_name,
|
||||
"picture_description": level.picture_description,
|
||||
"picture_reference": level.picture_reference,
|
||||
"candidates": level
|
||||
.candidates
|
||||
.iter()
|
||||
@@ -3076,6 +3097,163 @@ async fn compile_puzzle_draft_with_initial_cover(
|
||||
}
|
||||
}
|
||||
|
||||
async fn compile_puzzle_draft_with_uploaded_cover(
|
||||
state: &AppState,
|
||||
session_id: String,
|
||||
owner_user_id: String,
|
||||
prompt_text: Option<&str>,
|
||||
reference_image_src: Option<&str>,
|
||||
now: i64,
|
||||
) -> Result<PuzzleAgentSessionRecord, AppError> {
|
||||
let uploaded_image_src = reference_image_src
|
||||
.map(str::trim)
|
||||
.filter(|value| !value.is_empty())
|
||||
.ok_or_else(|| {
|
||||
AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({
|
||||
"provider": PUZZLE_AGENT_API_BASE_PROVIDER,
|
||||
"field": "referenceImageSrc",
|
||||
"message": "关闭 AI 重绘时必须上传拼图图片。",
|
||||
}))
|
||||
})?;
|
||||
let uploaded_image = parse_puzzle_image_data_url(uploaded_image_src).ok_or_else(|| {
|
||||
AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({
|
||||
"provider": PUZZLE_AGENT_API_BASE_PROVIDER,
|
||||
"field": "referenceImageSrc",
|
||||
"message": "关闭 AI 重绘时上传图必须是图片 Data URL。",
|
||||
}))
|
||||
})?;
|
||||
let compiled_session = state
|
||||
.spacetime_client()
|
||||
.compile_puzzle_agent_draft(session_id.clone(), owner_user_id.clone(), now)
|
||||
.await
|
||||
.map_err(map_puzzle_compile_error)?;
|
||||
let draft = compiled_session.draft.clone().ok_or_else(|| {
|
||||
AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({
|
||||
"provider": PUZZLE_AGENT_API_BASE_PROVIDER,
|
||||
"message": "拼图结果页草稿尚未生成",
|
||||
}))
|
||||
})?;
|
||||
let mut target_level = select_puzzle_level_for_api(&draft, None)?;
|
||||
let fallback_level_name = target_level.level_name.clone();
|
||||
let generated_level_name =
|
||||
generate_puzzle_first_level_name(state, &target_level.picture_description).await;
|
||||
target_level.level_name = generated_level_name.clone();
|
||||
let levels_json_with_generated_name = Some(serialize_puzzle_level_records_for_module(
|
||||
&build_puzzle_levels_with_primary_name(&draft, &target_level),
|
||||
)?);
|
||||
let image_prompt = resolve_puzzle_draft_cover_prompt(
|
||||
prompt_text,
|
||||
&target_level.picture_description,
|
||||
&draft.summary,
|
||||
);
|
||||
// 中文注释:关闭 AI 重绘时不请求 APIMart,也不进入光点扣费流程;上传图直接成为首关正式图候选。
|
||||
let candidate_id = format!(
|
||||
"{}-candidate-{}",
|
||||
compiled_session.session_id,
|
||||
target_level.candidates.len() + 1
|
||||
);
|
||||
let persisted_upload = persist_puzzle_generated_asset(
|
||||
state,
|
||||
owner_user_id.as_str(),
|
||||
&compiled_session.session_id,
|
||||
&target_level.level_name,
|
||||
candidate_id.as_str(),
|
||||
"uploaded-direct",
|
||||
PuzzleDownloadedImage {
|
||||
extension: puzzle_mime_to_extension(uploaded_image.mime_type.as_str()).to_string(),
|
||||
mime_type: normalize_puzzle_downloaded_image_mime_type(
|
||||
uploaded_image.mime_type.as_str(),
|
||||
),
|
||||
bytes: uploaded_image.bytes,
|
||||
},
|
||||
current_utc_micros(),
|
||||
)
|
||||
.await?;
|
||||
let candidate = PuzzleGeneratedImageCandidateRecord {
|
||||
candidate_id: candidate_id.clone(),
|
||||
image_src: persisted_upload.image_src,
|
||||
asset_id: persisted_upload.asset_id,
|
||||
prompt: image_prompt,
|
||||
actual_prompt: None,
|
||||
source_type: "uploaded".to_string(),
|
||||
selected: true,
|
||||
};
|
||||
let candidates_json = serde_json::to_string(&vec![to_puzzle_generated_image_candidate(
|
||||
&candidate,
|
||||
)])
|
||||
.map_err(|error| {
|
||||
AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({
|
||||
"provider": PUZZLE_AGENT_API_BASE_PROVIDER,
|
||||
"message": format!("拼图上传图候选序列化失败:{error}"),
|
||||
}))
|
||||
})?;
|
||||
let (saved_session, save_used_fallback) = state
|
||||
.spacetime_client()
|
||||
.save_puzzle_generated_images(PuzzleGeneratedImagesSaveRecordInput {
|
||||
session_id: compiled_session.session_id.clone(),
|
||||
owner_user_id: owner_user_id.clone(),
|
||||
level_id: Some(target_level.level_id.clone()),
|
||||
levels_json: levels_json_with_generated_name,
|
||||
candidates_json,
|
||||
saved_at_micros: current_utc_micros(),
|
||||
})
|
||||
.await
|
||||
.map_err(map_puzzle_client_error)
|
||||
.map(|session| (session, false))
|
||||
.or_else(|error| {
|
||||
if is_spacetimedb_connectivity_app_error(&error) {
|
||||
tracing::warn!(
|
||||
provider = PUZZLE_AGENT_API_BASE_PROVIDER,
|
||||
session_id = %compiled_session.session_id,
|
||||
owner_user_id = %owner_user_id,
|
||||
message = %error.body_text(),
|
||||
"拼图上传图草稿回写不可用,降级返回本地快照"
|
||||
);
|
||||
let session = apply_generated_puzzle_candidates_to_session_snapshot(
|
||||
apply_generated_puzzle_first_level_name_to_session_snapshot(
|
||||
compiled_session.clone(),
|
||||
target_level.level_id.as_str(),
|
||||
generated_level_name.as_str(),
|
||||
fallback_level_name.as_str(),
|
||||
now,
|
||||
),
|
||||
target_level.level_id.as_str(),
|
||||
vec![candidate.clone()],
|
||||
now,
|
||||
);
|
||||
Ok((session, true))
|
||||
} else {
|
||||
Err(error)
|
||||
}
|
||||
})?;
|
||||
if save_used_fallback {
|
||||
return Ok(saved_session);
|
||||
}
|
||||
match state
|
||||
.spacetime_client()
|
||||
.select_puzzle_cover_image(PuzzleSelectCoverImageRecordInput {
|
||||
session_id,
|
||||
owner_user_id,
|
||||
level_id: Some(target_level.level_id),
|
||||
candidate_id,
|
||||
selected_at_micros: current_utc_micros(),
|
||||
})
|
||||
.await
|
||||
{
|
||||
Ok(session) => Ok(session),
|
||||
Err(error) if should_skip_asset_operation_billing_for_connectivity(&error) => {
|
||||
tracing::warn!(
|
||||
provider = PUZZLE_AGENT_API_BASE_PROVIDER,
|
||||
session_id = %saved_session.session_id,
|
||||
error = %error,
|
||||
"拼图上传图选定回写因 SpacetimeDB 连接不可用而降级使用已保存快照"
|
||||
);
|
||||
Ok(saved_session)
|
||||
}
|
||||
Err(error) => Err(map_puzzle_client_error(error)),
|
||||
}
|
||||
}
|
||||
|
||||
fn apply_generated_puzzle_candidates_to_session_snapshot(
|
||||
mut session: PuzzleAgentSessionRecord,
|
||||
target_level_id: &str,
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
use std::{error::Error, fmt, sync::Arc};
|
||||
|
||||
#[cfg(test)]
|
||||
use std::{collections::HashMap, sync::Mutex};
|
||||
use std::{
|
||||
collections::HashMap,
|
||||
error::Error,
|
||||
fmt,
|
||||
sync::{Arc, Mutex},
|
||||
};
|
||||
|
||||
use module_ai::{AiTaskService, InMemoryAiTaskStore};
|
||||
use module_auth::{
|
||||
@@ -11,14 +13,16 @@ use module_auth::{
|
||||
use module_runtime::RuntimeSnapshotRecord;
|
||||
#[cfg(test)]
|
||||
use module_runtime::{SAVE_SNAPSHOT_VERSION, format_utc_micros};
|
||||
use platform_agent::MockLangChainRustAgentExecutor;
|
||||
use platform_auth::{
|
||||
AccessTokenClaims, AccessTokenClaimsInput, AuthProvider, BindingStatus, JwtConfig, JwtError,
|
||||
RefreshCookieConfig, RefreshCookieError, RefreshCookieSameSite, SmsAuthConfig, SmsAuthProvider,
|
||||
SmsAuthProviderKind, SmsProviderError, WechatProvider, sign_access_token, verify_access_token,
|
||||
};
|
||||
use platform_llm::{LlmClient, LlmConfig, LlmError};
|
||||
use platform_llm::{LlmClient, LlmConfig, LlmError, LlmProvider};
|
||||
use platform_oss::{OssClient, OssConfig, OssError};
|
||||
use serde_json::Value;
|
||||
use shared_contracts::creative_agent::CreativeAgentSessionSnapshot;
|
||||
use spacetime_client::{SpacetimeClient, SpacetimeClientConfig, SpacetimeClientError};
|
||||
use time::OffsetDateTime;
|
||||
use tracing::{info, warn};
|
||||
@@ -51,11 +55,22 @@ pub struct AppState {
|
||||
ai_task_service: AiTaskService,
|
||||
spacetime_client: SpacetimeClient,
|
||||
llm_client: Option<LlmClient>,
|
||||
creative_agent_gpt5_client: Option<LlmClient>,
|
||||
creative_agent_executor: Arc<MockLangChainRustAgentExecutor>,
|
||||
// Phase 1 任务 E 的 creative session facade 暂存在 api-server。
|
||||
// creative_agent_* 表由任务 D 收口后,这里只保留读写 facade。
|
||||
creative_agent_sessions: Arc<Mutex<HashMap<String, CreativeAgentSessionRuntimeRecord>>>,
|
||||
#[cfg(test)]
|
||||
// 测试环境允许在未启动 SpacetimeDB 时,用内存快照兜底当前 runtime story 回归链。
|
||||
test_runtime_snapshot_store: Arc<Mutex<HashMap<String, RuntimeSnapshotRecord>>>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
struct CreativeAgentSessionRuntimeRecord {
|
||||
owner_user_id: String,
|
||||
snapshot: CreativeAgentSessionSnapshot,
|
||||
}
|
||||
|
||||
// 后台管理员运行态独立于普通玩家登录体系,只从环境变量构造。
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct AdminRuntime {
|
||||
@@ -167,6 +182,7 @@ impl AppState {
|
||||
procedure_timeout: config.spacetime_procedure_timeout,
|
||||
});
|
||||
let llm_client = build_llm_client(&config)?;
|
||||
let creative_agent_gpt5_client = build_creative_agent_gpt5_client(&config)?;
|
||||
|
||||
Ok(Self {
|
||||
config,
|
||||
@@ -185,6 +201,9 @@ impl AppState {
|
||||
ai_task_service,
|
||||
spacetime_client,
|
||||
llm_client,
|
||||
creative_agent_gpt5_client,
|
||||
creative_agent_executor: Arc::new(MockLangChainRustAgentExecutor),
|
||||
creative_agent_sessions: Arc::new(Mutex::new(HashMap::new())),
|
||||
#[cfg(test)]
|
||||
test_runtime_snapshot_store: Arc::new(Mutex::new(HashMap::new())),
|
||||
})
|
||||
@@ -336,6 +355,44 @@ impl AppState {
|
||||
self.llm_client.as_ref()
|
||||
}
|
||||
|
||||
pub fn creative_agent_gpt5_client(&self) -> Option<&LlmClient> {
|
||||
self.creative_agent_gpt5_client.as_ref()
|
||||
}
|
||||
|
||||
pub fn creative_agent_executor(&self) -> Arc<MockLangChainRustAgentExecutor> {
|
||||
self.creative_agent_executor.clone()
|
||||
}
|
||||
|
||||
pub fn get_creative_agent_session(
|
||||
&self,
|
||||
session_id: &str,
|
||||
owner_user_id: &str,
|
||||
) -> Option<CreativeAgentSessionSnapshot> {
|
||||
self.creative_agent_sessions
|
||||
.lock()
|
||||
.expect("creative agent session store should lock")
|
||||
.get(session_id)
|
||||
.filter(|record| record.owner_user_id == owner_user_id)
|
||||
.map(|record| record.snapshot.clone())
|
||||
}
|
||||
|
||||
pub fn put_creative_agent_session(
|
||||
&self,
|
||||
owner_user_id: String,
|
||||
session: CreativeAgentSessionSnapshot,
|
||||
) {
|
||||
self.creative_agent_sessions
|
||||
.lock()
|
||||
.expect("creative agent session store should lock")
|
||||
.insert(
|
||||
session.session_id.clone(),
|
||||
CreativeAgentSessionRuntimeRecord {
|
||||
owner_user_id,
|
||||
snapshot: session,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
pub async fn get_runtime_snapshot_record(
|
||||
&self,
|
||||
user_id: String,
|
||||
@@ -710,6 +767,31 @@ fn build_llm_client(config: &AppConfig) -> Result<Option<LlmClient>, AppStateIni
|
||||
Ok(Some(LlmClient::new(llm_config)?))
|
||||
}
|
||||
|
||||
fn build_creative_agent_gpt5_client(
|
||||
config: &AppConfig,
|
||||
) -> Result<Option<LlmClient>, AppStateInitError> {
|
||||
let Some(api_key) = config
|
||||
.apimart_api_key
|
||||
.as_ref()
|
||||
.map(|value| value.trim())
|
||||
.filter(|value| !value.is_empty())
|
||||
else {
|
||||
return Ok(None);
|
||||
};
|
||||
|
||||
let llm_config = LlmConfig::new(
|
||||
LlmProvider::OpenAiCompatible,
|
||||
config.apimart_base_url.clone(),
|
||||
api_key.to_string(),
|
||||
platform_agent::CREATIVE_AGENT_GPT5_MODEL.to_string(),
|
||||
config.apimart_image_request_timeout_ms,
|
||||
0,
|
||||
config.llm_retry_backoff_ms,
|
||||
)?;
|
||||
|
||||
Ok(Some(LlmClient::new(llm_config)?))
|
||||
}
|
||||
|
||||
// 只有在用户名和密码都已配置时才启用后台,避免半配置状态暴露伪入口。
|
||||
fn build_admin_runtime(
|
||||
config: &AppConfig,
|
||||
@@ -783,5 +865,28 @@ mod tests {
|
||||
let state = AppState::new(AppConfig::default()).expect("state should build");
|
||||
|
||||
assert!(state.llm_client().is_none());
|
||||
assert!(state.creative_agent_gpt5_client().is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn app_state_builds_creative_agent_gpt5_client_from_apimart_settings() {
|
||||
let mut config = AppConfig::default();
|
||||
config.llm_api_key = None;
|
||||
config.apimart_base_url = "https://api.apimart.test/v1".to_string();
|
||||
config.apimart_api_key = Some("apimart-key".to_string());
|
||||
|
||||
let state = AppState::new(config).expect("state should build");
|
||||
let client = state
|
||||
.creative_agent_gpt5_client()
|
||||
.expect("creative agent gpt5 client should exist");
|
||||
|
||||
assert_eq!(
|
||||
client.config().model(),
|
||||
platform_agent::CREATIVE_AGENT_GPT5_MODEL
|
||||
);
|
||||
assert_eq!(
|
||||
client.config().responses_url(),
|
||||
"https://api.apimart.test/v1/responses"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
1767
server-rs/crates/api-server/src/visual_novel.rs
Normal file
1767
server-rs/crates/api-server/src/visual_novel.rs
Normal file
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user