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}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user