拆分大文件
This commit is contained in:
@@ -1,4 +1,7 @@
|
||||
use std::collections::BTreeSet;
|
||||
use std::{
|
||||
collections::BTreeSet,
|
||||
net::{IpAddr, Ipv4Addr, Ipv6Addr},
|
||||
};
|
||||
|
||||
use axum::{
|
||||
Json,
|
||||
@@ -21,10 +24,13 @@ use shared_contracts::admin::{
|
||||
use time::{OffsetDateTime, format_description::well_known::Rfc3339};
|
||||
|
||||
use crate::{
|
||||
api_response::json_success_body, http_error::AppError, request_context::RequestContext,
|
||||
api_response::json_success_body,
|
||||
http_error::AppError,
|
||||
request_context::RequestContext,
|
||||
state::{AdminRuntime, AppState},
|
||||
};
|
||||
|
||||
// 首版调试台只允许有限大小的请求体,避免把后台当作通用代理大包转发器。
|
||||
const MAX_DEBUG_BODY_BYTES: usize = 128 * 1024;
|
||||
const BLOCKED_DEBUG_HEADERS: &[&str] = &[
|
||||
"host",
|
||||
@@ -33,6 +39,7 @@ const BLOCKED_DEBUG_HEADERS: &[&str] = &[
|
||||
"transfer-encoding",
|
||||
"expect",
|
||||
];
|
||||
// 数据库概览首版只统计受控白名单表,禁止后台页面直接输入任意 SQL。
|
||||
const DATABASE_OVERVIEW_TABLES: &[&str] = &[
|
||||
"runtime_setting",
|
||||
"runtime_snapshot",
|
||||
@@ -124,9 +131,9 @@ pub async fn admin_login(
|
||||
Extension(request_context): Extension<RequestContext>,
|
||||
Json(payload): Json<AdminLoginRequest>,
|
||||
) -> Result<Json<Value>, AppError> {
|
||||
let runtime = state
|
||||
.admin_runtime()
|
||||
.ok_or_else(|| AppError::from_status(StatusCode::SERVICE_UNAVAILABLE).with_message("后台管理未启用"))?;
|
||||
let runtime = state.admin_runtime().ok_or_else(|| {
|
||||
AppError::from_status(StatusCode::SERVICE_UNAVAILABLE).with_message("后台管理未启用")
|
||||
})?;
|
||||
|
||||
let expected_username = runtime.username().trim();
|
||||
let expected_password = runtime.password().trim();
|
||||
@@ -139,16 +146,18 @@ pub async fn admin_login(
|
||||
}
|
||||
|
||||
if submitted_username != expected_username || submitted_password != expected_password {
|
||||
return Err(AppError::from_status(StatusCode::UNAUTHORIZED).with_message("管理员用户名或密码错误"));
|
||||
return Err(
|
||||
AppError::from_status(StatusCode::UNAUTHORIZED).with_message("管理员用户名或密码错误")
|
||||
);
|
||||
}
|
||||
|
||||
let now = OffsetDateTime::now_utc();
|
||||
let claims = runtime
|
||||
.build_claims(now)
|
||||
.map_err(|error| AppError::from_status(StatusCode::INTERNAL_SERVER_ERROR).with_message(error))?;
|
||||
let token = runtime
|
||||
.sign_token(&claims)
|
||||
.map_err(|error| AppError::from_status(StatusCode::INTERNAL_SERVER_ERROR).with_message(error))?;
|
||||
let claims = runtime.build_claims(now).map_err(|error| {
|
||||
AppError::from_status(StatusCode::INTERNAL_SERVER_ERROR).with_message(error)
|
||||
})?;
|
||||
let token = runtime.sign_token(&claims).map_err(|error| {
|
||||
AppError::from_status(StatusCode::INTERNAL_SERVER_ERROR).with_message(error)
|
||||
})?;
|
||||
|
||||
Ok(json_success_body(
|
||||
Some(&request_context),
|
||||
@@ -176,9 +185,9 @@ pub async fn admin_overview(
|
||||
Extension(request_context): Extension<RequestContext>,
|
||||
Extension(_admin): Extension<AuthenticatedAdmin>,
|
||||
) -> Result<Json<Value>, AppError> {
|
||||
let runtime = state
|
||||
.admin_runtime()
|
||||
.ok_or_else(|| AppError::from_status(StatusCode::SERVICE_UNAVAILABLE).with_message("后台管理未启用"))?;
|
||||
let runtime = state.admin_runtime().ok_or_else(|| {
|
||||
AppError::from_status(StatusCode::SERVICE_UNAVAILABLE).with_message("后台管理未启用")
|
||||
})?;
|
||||
|
||||
let overview = build_admin_overview(&state, runtime).await?;
|
||||
Ok(json_success_body(Some(&request_context), overview))
|
||||
@@ -199,9 +208,10 @@ pub async fn require_admin_auth(
|
||||
mut request: Request,
|
||||
next: Next,
|
||||
) -> Result<Response, AppError> {
|
||||
let runtime = state
|
||||
.admin_runtime()
|
||||
.ok_or_else(|| AppError::from_status(StatusCode::SERVICE_UNAVAILABLE).with_message("后台管理未启用"))?;
|
||||
// 后台鉴权必须同时满足:令牌验签通过、主体匹配当前管理员、roles 含 admin。
|
||||
let runtime = state.admin_runtime().ok_or_else(|| {
|
||||
AppError::from_status(StatusCode::SERVICE_UNAVAILABLE).with_message("后台管理未启用")
|
||||
})?;
|
||||
let bearer_token = extract_bearer_token(request.headers())?;
|
||||
let claims = runtime
|
||||
.verify_token(&bearer_token)
|
||||
@@ -213,7 +223,9 @@ pub async fn require_admin_auth(
|
||||
|
||||
request
|
||||
.extensions_mut()
|
||||
.insert(AuthenticatedAdmin::new(build_admin_session_payload(admin_session)));
|
||||
.insert(AuthenticatedAdmin::new(build_admin_session_payload(
|
||||
admin_session,
|
||||
)));
|
||||
Ok(next.run(request).await)
|
||||
}
|
||||
|
||||
@@ -252,10 +264,16 @@ async fn build_admin_overview(
|
||||
}
|
||||
|
||||
async fn fetch_database_overview(state: &AppState) -> AdminDatabaseOverviewPayload {
|
||||
// 概览直接读取 SpacetimeDB HTTP API,保证后台看到的是真实数据库元信息而不是本地缓存。
|
||||
let client = Client::new();
|
||||
let server_root = state.config.spacetime_server_url.trim_end_matches('/');
|
||||
let database = state.config.spacetime_database.trim();
|
||||
let token = state.config.spacetime_token.as_deref().map(str::trim).filter(|value| !value.is_empty());
|
||||
let token = state
|
||||
.config
|
||||
.spacetime_token
|
||||
.as_deref()
|
||||
.map(str::trim)
|
||||
.filter(|value| !value.is_empty());
|
||||
let mut fetch_errors = Vec::new();
|
||||
|
||||
let database_info = fetch_spacetime_json::<SpacetimeDatabaseInfoResponse>(
|
||||
@@ -321,9 +339,15 @@ async fn fetch_database_overview(state: &AppState) -> AdminDatabaseOverviewPaylo
|
||||
schema_table_names.sort();
|
||||
|
||||
AdminDatabaseOverviewPayload {
|
||||
database_identity: database_info.as_ref().and_then(|value| value.database_identity.clone()),
|
||||
owner_identity: database_info.as_ref().and_then(|value| value.owner_identity.clone()),
|
||||
host_type: database_info.as_ref().and_then(|value| value.host_type.clone()),
|
||||
database_identity: database_info
|
||||
.as_ref()
|
||||
.and_then(|value| value.database_identity.clone()),
|
||||
owner_identity: database_info
|
||||
.as_ref()
|
||||
.and_then(|value| value.owner_identity.clone()),
|
||||
host_type: database_info
|
||||
.as_ref()
|
||||
.and_then(|value| value.host_type.clone()),
|
||||
schema_table_names,
|
||||
table_stats,
|
||||
fetch_errors,
|
||||
@@ -426,15 +450,12 @@ async fn execute_admin_debug_http(
|
||||
state: &AppState,
|
||||
payload: AdminDebugHttpRequest,
|
||||
) -> Result<AdminDebugHttpResponse, AppError> {
|
||||
// 调试请求始终回打当前 api-server,同源受控,不允许作为外部代理使用。
|
||||
let method = Method::from_bytes(payload.method.trim().as_bytes()).map_err(|_| {
|
||||
AppError::from_status(StatusCode::BAD_REQUEST).with_message("HTTP 方法不合法")
|
||||
})?;
|
||||
let path = normalize_debug_path(&payload.path)?;
|
||||
let base_url = format!(
|
||||
"http://{}:{}",
|
||||
state.config.bind_host.trim(),
|
||||
state.config.bind_port
|
||||
);
|
||||
let base_url = build_debug_base_url(&state.config.bind_host, state.config.bind_port);
|
||||
let target_url = format!("{base_url}{path}");
|
||||
let body_text = payload.body.unwrap_or_default();
|
||||
if body_text.len() > MAX_DEBUG_BODY_BYTES {
|
||||
@@ -451,7 +472,10 @@ async fn execute_admin_debug_http(
|
||||
|
||||
for header in payload.headers.unwrap_or_default() {
|
||||
let header_name = header.name.trim().to_ascii_lowercase();
|
||||
if BLOCKED_DEBUG_HEADERS.iter().any(|blocked| *blocked == header_name) {
|
||||
if BLOCKED_DEBUG_HEADERS
|
||||
.iter()
|
||||
.any(|blocked| *blocked == header_name)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
let name = HeaderName::from_bytes(header_name.as_bytes()).map_err(|_| {
|
||||
@@ -464,7 +488,8 @@ async fn execute_admin_debug_http(
|
||||
}
|
||||
|
||||
let response = request.send().await.map_err(|error| {
|
||||
AppError::from_status(StatusCode::BAD_GATEWAY).with_message(format!("调试请求失败:{error}"))
|
||||
AppError::from_status(StatusCode::BAD_GATEWAY)
|
||||
.with_message(format!("调试请求失败:{error}"))
|
||||
})?;
|
||||
let status = response.status();
|
||||
let headers = response
|
||||
@@ -476,7 +501,8 @@ async fn execute_admin_debug_http(
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
let response_body = response.bytes().await.map_err(|error| {
|
||||
AppError::from_status(StatusCode::BAD_GATEWAY).with_message(format!("调试响应读取失败:{error}"))
|
||||
AppError::from_status(StatusCode::BAD_GATEWAY)
|
||||
.with_message(format!("调试响应读取失败:{error}"))
|
||||
})?;
|
||||
let body_preview = build_body_preview(&response_body);
|
||||
let body_json = serde_json::from_slice::<Value>(&response_body).ok();
|
||||
@@ -490,19 +516,56 @@ async fn execute_admin_debug_http(
|
||||
})
|
||||
}
|
||||
|
||||
fn build_debug_base_url(bind_host: &str, bind_port: u16) -> String {
|
||||
let debug_host = resolve_debug_host(bind_host);
|
||||
let authority_host = format_http_authority_host(&debug_host);
|
||||
format!("http://{authority_host}:{bind_port}")
|
||||
}
|
||||
|
||||
fn resolve_debug_host(bind_host: &str) -> String {
|
||||
let trimmed = bind_host.trim();
|
||||
if trimmed.is_empty() {
|
||||
return Ipv4Addr::LOCALHOST.to_string();
|
||||
}
|
||||
|
||||
match trimmed.parse::<IpAddr>() {
|
||||
Ok(IpAddr::V4(ip)) if ip.is_unspecified() => Ipv4Addr::LOCALHOST.to_string(),
|
||||
Ok(IpAddr::V6(ip)) if ip.is_unspecified() => Ipv6Addr::LOCALHOST.to_string(),
|
||||
Ok(ip) => ip.to_string(),
|
||||
Err(_) => trimmed.to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
fn format_http_authority_host(host: &str) -> String {
|
||||
if host.starts_with('[') && host.ends_with(']') {
|
||||
return host.to_string();
|
||||
}
|
||||
if host.parse::<Ipv6Addr>().is_ok() {
|
||||
return format!("[{host}]");
|
||||
}
|
||||
host.to_string()
|
||||
}
|
||||
|
||||
fn normalize_debug_path(path: &str) -> Result<String, AppError> {
|
||||
// 只允许 `/xxx` 形式的同源相对路径,明确拒绝绝对 URL 与后台登录接口。
|
||||
let trimmed = path.trim();
|
||||
if trimmed.is_empty() {
|
||||
return Err(AppError::from_status(StatusCode::BAD_REQUEST).with_message("调试路径不能为空"));
|
||||
}
|
||||
if trimmed.starts_with("http://") || trimmed.starts_with("https://") {
|
||||
return Err(AppError::from_status(StatusCode::BAD_REQUEST).with_message("只允许调试同源相对路径"));
|
||||
return Err(
|
||||
AppError::from_status(StatusCode::BAD_REQUEST).with_message("只允许调试同源相对路径")
|
||||
);
|
||||
}
|
||||
if !trimmed.starts_with('/') {
|
||||
return Err(AppError::from_status(StatusCode::BAD_REQUEST).with_message("调试路径必须以 / 开头"));
|
||||
return Err(
|
||||
AppError::from_status(StatusCode::BAD_REQUEST).with_message("调试路径必须以 / 开头")
|
||||
);
|
||||
}
|
||||
if trimmed == "/admin/api/login" {
|
||||
return Err(AppError::from_status(StatusCode::BAD_REQUEST).with_message("禁止调试后台登录接口"));
|
||||
return Err(
|
||||
AppError::from_status(StatusCode::BAD_REQUEST).with_message("禁止调试后台登录接口")
|
||||
);
|
||||
}
|
||||
Ok(trimmed.to_string())
|
||||
}
|
||||
@@ -540,6 +603,7 @@ fn build_admin_session_payload(session: crate::state::AdminSession) -> AdminSess
|
||||
}
|
||||
}
|
||||
|
||||
// 首版后台页面内嵌在 api-server,避免新增独立前端工程与静态资源发布链。
|
||||
static ADMIN_CONSOLE_HTML: &str = r#"<!doctype html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
@@ -1051,23 +1115,46 @@ static ADMIN_CONSOLE_HTML: &str = r#"<!doctype html>
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::{build_body_preview, normalize_debug_path, trim_preview};
|
||||
use axum::http::StatusCode;
|
||||
use super::{build_body_preview, build_debug_base_url, normalize_debug_path, trim_preview};
|
||||
use axum::{http::StatusCode, response::IntoResponse};
|
||||
|
||||
#[test]
|
||||
fn normalize_debug_path_rejects_absolute_url() {
|
||||
let error = normalize_debug_path("https://example.com/api").expect_err("absolute url should fail");
|
||||
let error =
|
||||
normalize_debug_path("https://example.com/api").expect_err("absolute url should fail");
|
||||
|
||||
assert_eq!(error.into_response().status(), StatusCode::BAD_REQUEST);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn normalize_debug_path_rejects_admin_login_route() {
|
||||
let error = normalize_debug_path("/admin/api/login").expect_err("admin login route should fail");
|
||||
let error =
|
||||
normalize_debug_path("/admin/api/login").expect_err("admin login route should fail");
|
||||
|
||||
assert_eq!(error.into_response().status(), StatusCode::BAD_REQUEST);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn normalize_debug_path_accepts_healthz() {
|
||||
let path = normalize_debug_path("/healthz").expect("healthz path should pass validation");
|
||||
|
||||
assert_eq!(path, "/healthz");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn build_debug_base_url_rewrites_wildcard_ipv4_to_loopback() {
|
||||
let url = build_debug_base_url("0.0.0.0", 3200);
|
||||
|
||||
assert_eq!(url, "http://127.0.0.1:3200");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn build_debug_base_url_wraps_ipv6_host() {
|
||||
let url = build_debug_base_url("::1", 3200);
|
||||
|
||||
assert_eq!(url, "http://[::1]:3200");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn trim_preview_limits_length() {
|
||||
let text = "a".repeat(5000);
|
||||
|
||||
@@ -30,7 +30,7 @@ use crate::{
|
||||
require_bearer_auth,
|
||||
},
|
||||
auth_me::auth_me,
|
||||
auth_public_user::get_public_user_by_code,
|
||||
auth_public_user::{get_public_user_by_code, get_public_user_by_id},
|
||||
auth_sessions::auth_sessions,
|
||||
big_fish::{
|
||||
create_big_fish_session, execute_big_fish_action, get_big_fish_run, get_big_fish_session,
|
||||
@@ -159,6 +159,10 @@ pub fn build_router(state: AppState) -> Router {
|
||||
"/api/auth/public-users/by-code/{code}",
|
||||
get(get_public_user_by_code),
|
||||
)
|
||||
.route(
|
||||
"/api/auth/public-users/by-id/{user_id}",
|
||||
get(get_public_user_by_id),
|
||||
)
|
||||
.route(
|
||||
"/generated-character-drafts/{*path}",
|
||||
get(proxy_generated_character_drafts),
|
||||
@@ -959,8 +963,10 @@ mod tests {
|
||||
use platform_auth::{
|
||||
AccessTokenClaims, AccessTokenClaimsInput, AuthProvider, BindingStatus, sign_access_token,
|
||||
};
|
||||
use reqwest::Client;
|
||||
use serde_json::Value;
|
||||
use time::OffsetDateTime;
|
||||
use tokio::net::TcpListener;
|
||||
use tower::ServiceExt;
|
||||
|
||||
use crate::{config::AppConfig, state::AppState};
|
||||
@@ -1018,7 +1024,7 @@ mod tests {
|
||||
assert_eq!(payload["ok"], Value::Bool(true));
|
||||
assert_eq!(
|
||||
payload["service"],
|
||||
Value::String("genarrative-node-server".to_string())
|
||||
Value::String("genarrative-api-server".to_string())
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1052,7 +1058,7 @@ mod tests {
|
||||
assert_eq!(payload["ok"], Value::Bool(true));
|
||||
assert_eq!(
|
||||
payload["data"]["service"],
|
||||
Value::String("genarrative-node-server".to_string())
|
||||
Value::String("genarrative-api-server".to_string())
|
||||
);
|
||||
assert_eq!(
|
||||
payload["meta"]["requestId"],
|
||||
@@ -2986,7 +2992,10 @@ mod tests {
|
||||
serde_json::from_slice(&body).expect("response body should be valid json");
|
||||
|
||||
assert!(payload["token"].as_str().is_some());
|
||||
assert_eq!(payload["admin"]["username"], Value::String("root".to_string()));
|
||||
assert_eq!(
|
||||
payload["admin"]["username"],
|
||||
Value::String("root".to_string())
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
@@ -3043,49 +3052,78 @@ mod tests {
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn admin_debug_http_can_probe_healthz() {
|
||||
async fn admin_debug_http_can_probe_healthz_when_authenticated() {
|
||||
let mut config = AppConfig::default();
|
||||
config.admin_username = Some("root".to_string());
|
||||
config.admin_password = Some("secret123".to_string());
|
||||
let listener = TcpListener::bind("127.0.0.1:0")
|
||||
.await
|
||||
.expect("listener should bind");
|
||||
let local_addr = listener
|
||||
.local_addr()
|
||||
.expect("listener should expose local addr");
|
||||
config.bind_host = "127.0.0.1".to_string();
|
||||
config.bind_port = local_addr.port();
|
||||
let app = build_router(AppState::new(config).expect("state should build"));
|
||||
let server = tokio::spawn(async move {
|
||||
axum::serve(listener, app)
|
||||
.await
|
||||
.expect("test admin server should serve");
|
||||
});
|
||||
let http_client = Client::new();
|
||||
let base_url = format!("http://{}", local_addr);
|
||||
|
||||
let login_response = app
|
||||
.clone()
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.method("POST")
|
||||
.uri("/admin/api/login")
|
||||
.header("content-type", "application/json")
|
||||
.body(Body::from(
|
||||
serde_json::json!({
|
||||
"username": "root",
|
||||
"password": "secret123"
|
||||
})
|
||||
.to_string(),
|
||||
))
|
||||
.expect("login request should build"),
|
||||
)
|
||||
let login_payload: Value = http_client
|
||||
.post(format!("{base_url}/admin/api/login"))
|
||||
.json(&serde_json::json!({
|
||||
"username": "root",
|
||||
"password": "secret123"
|
||||
}))
|
||||
.send()
|
||||
.await
|
||||
.expect("login should succeed");
|
||||
let login_body = login_response
|
||||
.into_body()
|
||||
.collect()
|
||||
.expect("login request should succeed")
|
||||
.json()
|
||||
.await
|
||||
.expect("login body should collect")
|
||||
.to_bytes();
|
||||
let login_payload: Value =
|
||||
serde_json::from_slice(&login_body).expect("login payload should be json");
|
||||
.expect("login payload should be json");
|
||||
let access_token = login_payload["token"]
|
||||
.as_str()
|
||||
.expect("token should exist")
|
||||
.to_string();
|
||||
|
||||
let payload: Value = http_client
|
||||
.post(format!("{base_url}/admin/api/debug/http"))
|
||||
.bearer_auth(access_token)
|
||||
.json(&serde_json::json!({
|
||||
"method": "GET",
|
||||
"path": "/healthz",
|
||||
"headers": [],
|
||||
"body": ""
|
||||
}))
|
||||
.send()
|
||||
.await
|
||||
.expect("debug request should succeed")
|
||||
.json()
|
||||
.await
|
||||
.expect("debug payload should be json");
|
||||
|
||||
server.abort();
|
||||
let _ = server.await;
|
||||
|
||||
assert_eq!(payload["status"], Value::Number(200.into()));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn admin_debug_http_requires_authentication() {
|
||||
let mut config = AppConfig::default();
|
||||
config.admin_username = Some("root".to_string());
|
||||
config.admin_password = Some("secret123".to_string());
|
||||
let app = build_router(AppState::new(config).expect("state should build"));
|
||||
|
||||
let debug_response = app
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.method("POST")
|
||||
.uri("/admin/api/debug/http")
|
||||
.header("authorization", format!("Bearer {access_token}"))
|
||||
.header("content-type", "application/json")
|
||||
.body(Body::from(
|
||||
serde_json::json!({
|
||||
@@ -3101,16 +3139,6 @@ mod tests {
|
||||
.await
|
||||
.expect("debug request should succeed");
|
||||
|
||||
assert_eq!(debug_response.status(), StatusCode::OK);
|
||||
let body = debug_response
|
||||
.into_body()
|
||||
.collect()
|
||||
.await
|
||||
.expect("debug body should collect")
|
||||
.to_bytes();
|
||||
let payload: Value =
|
||||
serde_json::from_slice(&body).expect("debug payload should be json");
|
||||
|
||||
assert_eq!(payload["status"], Value::Number(200.into()));
|
||||
assert_eq!(debug_response.status(), StatusCode::UNAUTHORIZED);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,11 +6,8 @@ use axum::{
|
||||
use shared_contracts::auth::PublicUserSearchResponse;
|
||||
|
||||
use crate::{
|
||||
api_response::json_success_body,
|
||||
auth_payload::map_public_user_summary_payload,
|
||||
http_error::AppError,
|
||||
request_context::RequestContext,
|
||||
state::AppState,
|
||||
api_response::json_success_body, auth_payload::map_public_user_summary_payload,
|
||||
http_error::AppError, request_context::RequestContext, state::AppState,
|
||||
};
|
||||
|
||||
pub async fn get_public_user_by_code(
|
||||
@@ -34,6 +31,32 @@ pub async fn get_public_user_by_code(
|
||||
))
|
||||
}
|
||||
|
||||
pub async fn get_public_user_by_id(
|
||||
State(state): State<AppState>,
|
||||
Extension(request_context): Extension<RequestContext>,
|
||||
Path(user_id): Path<String>,
|
||||
) -> Result<Json<serde_json::Value>, AppError> {
|
||||
let user_id = user_id.trim();
|
||||
if user_id.is_empty() {
|
||||
return Err(AppError::from_status(StatusCode::BAD_REQUEST).with_message("用户 ID 不能为空"));
|
||||
}
|
||||
|
||||
let user = state
|
||||
.auth_user_service()
|
||||
.get_user_by_id(user_id)
|
||||
.map_err(map_public_user_id_search_error)?
|
||||
.ok_or_else(|| {
|
||||
AppError::from_status(StatusCode::NOT_FOUND).with_message("未找到对应用户")
|
||||
})?;
|
||||
|
||||
Ok(json_success_body(
|
||||
Some(&request_context),
|
||||
PublicUserSearchResponse {
|
||||
user: map_public_user_summary_payload(user),
|
||||
},
|
||||
))
|
||||
}
|
||||
|
||||
fn map_public_user_search_error(error: module_auth::PasswordEntryError) -> AppError {
|
||||
match error {
|
||||
module_auth::PasswordEntryError::InvalidPublicUserCode => {
|
||||
@@ -48,3 +71,14 @@ fn map_public_user_search_error(error: module_auth::PasswordEntryError) -> AppEr
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn map_public_user_id_search_error(error: module_auth::LogoutError) -> AppError {
|
||||
match error {
|
||||
module_auth::LogoutError::UserNotFound => {
|
||||
AppError::from_status(StatusCode::NOT_FOUND).with_message("未找到对应用户")
|
||||
}
|
||||
module_auth::LogoutError::Store(_) => {
|
||||
AppError::from_status(StatusCode::INTERNAL_SERVER_ERROR).with_message(error.to_string())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -13,9 +13,7 @@ use module_assets::{
|
||||
AssetObjectAccessPolicy, AssetObjectFieldError, build_asset_entity_binding_input,
|
||||
build_asset_object_upsert_input, generate_asset_binding_id, generate_asset_object_id,
|
||||
};
|
||||
use platform_oss::{
|
||||
LegacyAssetPrefix, OssHeadObjectRequest, OssObjectAccess, OssPutObjectRequest,
|
||||
};
|
||||
use platform_oss::{LegacyAssetPrefix, OssHeadObjectRequest, OssObjectAccess, OssPutObjectRequest};
|
||||
use serde_json::{Map, Value, json};
|
||||
use shared_contracts::big_fish::{
|
||||
BigFishActionResponse, BigFishAgentMessageResponse, BigFishAnchorItemResponse,
|
||||
@@ -711,8 +709,7 @@ struct BigFishFormalAssetContext {
|
||||
|
||||
const BIG_FISH_TEXT_TO_IMAGE_MODEL: &str = "wan2.2-t2i-flash";
|
||||
const BIG_FISH_ENTITY_KIND: &str = "big_fish_session";
|
||||
const BIG_FISH_DEFAULT_NEGATIVE_PROMPT: &str =
|
||||
"文字,水印,logo,UI界面,对话框,边框,多余肢体,畸形鱼体,低清晰度,模糊,压缩噪点,现代摄影棚,写实照片背景";
|
||||
const BIG_FISH_DEFAULT_NEGATIVE_PROMPT: &str = "文字,水印,logo,UI界面,对话框,边框,多余肢体,畸形鱼体,低清晰度,模糊,压缩噪点,现代摄影棚,写实照片背景";
|
||||
|
||||
async fn generate_big_fish_formal_asset(
|
||||
state: &AppState,
|
||||
@@ -839,10 +836,12 @@ fn build_big_fish_formal_asset_context(
|
||||
asset_id,
|
||||
],
|
||||
}),
|
||||
_ => Err(AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({
|
||||
"provider": "big-fish",
|
||||
"message": format!("assetKind `{asset_kind}` 不支持正式图片生成。"),
|
||||
}))),
|
||||
_ => Err(
|
||||
AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({
|
||||
"provider": "big-fish",
|
||||
"message": format!("assetKind `{asset_kind}` 不支持正式图片生成。"),
|
||||
})),
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1028,9 +1027,9 @@ async fn create_big_fish_text_to_image_generation(
|
||||
}))
|
||||
.send()
|
||||
.await
|
||||
.map_err(|error| map_big_fish_dashscope_request_error(format!(
|
||||
"创建 Big Fish 图片生成任务失败:{error}"
|
||||
)))?;
|
||||
.map_err(|error| {
|
||||
map_big_fish_dashscope_request_error(format!("创建 Big Fish 图片生成任务失败:{error}"))
|
||||
})?;
|
||||
let status = response.status();
|
||||
let response_text = response.text().await.map_err(|error| {
|
||||
map_big_fish_dashscope_request_error(format!("读取 Big Fish 图片生成响应失败:{error}"))
|
||||
@@ -1041,7 +1040,8 @@ async fn create_big_fish_text_to_image_generation(
|
||||
"创建 Big Fish 图片生成任务失败",
|
||||
));
|
||||
}
|
||||
let payload = parse_big_fish_json_payload(response_text.as_str(), "解析 Big Fish 图片生成响应失败")?;
|
||||
let payload =
|
||||
parse_big_fish_json_payload(response_text.as_str(), "解析 Big Fish 图片生成响应失败")?;
|
||||
let task_id = extract_big_fish_task_id(&payload).ok_or_else(|| {
|
||||
AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({
|
||||
"provider": "dashscope",
|
||||
@@ -1059,9 +1059,11 @@ async fn create_big_fish_text_to_image_generation(
|
||||
)
|
||||
.send()
|
||||
.await
|
||||
.map_err(|error| map_big_fish_dashscope_request_error(format!(
|
||||
"查询 Big Fish 图片生成任务失败:{error}"
|
||||
)))?;
|
||||
.map_err(|error| {
|
||||
map_big_fish_dashscope_request_error(format!(
|
||||
"查询 Big Fish 图片生成任务失败:{error}"
|
||||
))
|
||||
})?;
|
||||
let poll_status = poll_response.status();
|
||||
let poll_text = poll_response.text().await.map_err(|error| {
|
||||
map_big_fish_dashscope_request_error(format!(
|
||||
@@ -1115,11 +1117,9 @@ async fn download_big_fish_remote_image(
|
||||
image_url: &str,
|
||||
fallback_message: &str,
|
||||
) -> Result<BigFishDownloadedImage, AppError> {
|
||||
let response = http_client
|
||||
.get(image_url)
|
||||
.send()
|
||||
.await
|
||||
.map_err(|error| map_big_fish_dashscope_request_error(format!("{fallback_message}:{error}")))?;
|
||||
let response = http_client.get(image_url).send().await.map_err(|error| {
|
||||
map_big_fish_dashscope_request_error(format!("{fallback_message}:{error}"))
|
||||
})?;
|
||||
let status = response.status();
|
||||
let content_type = response
|
||||
.headers()
|
||||
@@ -1127,10 +1127,9 @@ async fn download_big_fish_remote_image(
|
||||
.and_then(|value| value.to_str().ok())
|
||||
.unwrap_or("image/jpeg")
|
||||
.to_string();
|
||||
let bytes = response
|
||||
.bytes()
|
||||
.await
|
||||
.map_err(|error| map_big_fish_dashscope_request_error(format!("{fallback_message}:{error}")))?;
|
||||
let bytes = response.bytes().await.map_err(|error| {
|
||||
map_big_fish_dashscope_request_error(format!("{fallback_message}:{error}"))
|
||||
})?;
|
||||
if !status.is_success() {
|
||||
return Err(
|
||||
AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({
|
||||
|
||||
@@ -735,7 +735,7 @@ async fn persist_animation_preview_video(
|
||||
"provider": "character-animation",
|
||||
"message": "当前策略需要真实生成视频结果,不再支持回退到仓库占位预览视频。",
|
||||
})),
|
||||
)
|
||||
);
|
||||
}
|
||||
};
|
||||
let put_result = put_character_animation_object(
|
||||
@@ -1005,7 +1005,9 @@ async fn send_ark_image_to_video_request(
|
||||
}))
|
||||
.send()
|
||||
.await
|
||||
.map_err(|error| map_character_animation_upstream_error(format!("请求 Ark 视频服务失败:{error}")))
|
||||
.map_err(|error| {
|
||||
map_character_animation_upstream_error(format!("请求 Ark 视频服务失败:{error}"))
|
||||
})
|
||||
}
|
||||
|
||||
async fn wait_for_ark_content_generation_task(
|
||||
@@ -1026,7 +1028,9 @@ async fn wait_for_ark_content_generation_task(
|
||||
)
|
||||
.send()
|
||||
.await
|
||||
.map_err(|error| map_character_animation_upstream_error(format!("查询 Ark 视频任务失败:{error}")))?;
|
||||
.map_err(|error| {
|
||||
map_character_animation_upstream_error(format!("查询 Ark 视频任务失败:{error}"))
|
||||
})?;
|
||||
let status = response.status();
|
||||
let text = response.text().await.map_err(|error| {
|
||||
map_character_animation_upstream_error(format!("读取 Ark 视频任务响应失败:{error}"))
|
||||
@@ -1062,11 +1066,13 @@ async fn wait_for_ark_content_generation_task(
|
||||
sleep(Duration::from_millis(ARK_VIDEO_TASK_POLL_INTERVAL_MS)).await;
|
||||
}
|
||||
|
||||
Err(AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({
|
||||
"provider": "ark",
|
||||
"message": "视频生成任务执行超时,请稍后重试。",
|
||||
"taskId": task_id,
|
||||
})))
|
||||
Err(
|
||||
AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({
|
||||
"provider": "ark",
|
||||
"message": "视频生成任务执行超时,请稍后重试。",
|
||||
"taskId": task_id,
|
||||
})),
|
||||
)
|
||||
}
|
||||
|
||||
async fn download_generated_video(
|
||||
@@ -1074,11 +1080,9 @@ async fn download_generated_video(
|
||||
video_url: &str,
|
||||
fallback_message: &str,
|
||||
) -> Result<MediaPayload, AppError> {
|
||||
let response = http_client
|
||||
.get(video_url)
|
||||
.send()
|
||||
.await
|
||||
.map_err(|error| map_character_animation_upstream_error(format!("{fallback_message}:{error}")))?;
|
||||
let response = http_client.get(video_url).send().await.map_err(|error| {
|
||||
map_character_animation_upstream_error(format!("{fallback_message}:{error}"))
|
||||
})?;
|
||||
let status = response.status();
|
||||
let content_type = response
|
||||
.headers()
|
||||
@@ -1090,11 +1094,13 @@ async fn download_generated_video(
|
||||
map_character_animation_upstream_error(format!("{fallback_message}:{error}"))
|
||||
})?;
|
||||
if !status.is_success() {
|
||||
return Err(AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({
|
||||
"provider": "character-animation",
|
||||
"message": fallback_message,
|
||||
"status": status.as_u16(),
|
||||
})));
|
||||
return Err(
|
||||
AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({
|
||||
"provider": "character-animation",
|
||||
"message": fallback_message,
|
||||
"status": status.as_u16(),
|
||||
})),
|
||||
);
|
||||
}
|
||||
Ok(MediaPayload {
|
||||
mime_type: content_type.clone(),
|
||||
@@ -1728,12 +1734,9 @@ fn build_character_animation_prompt(
|
||||
loop_,
|
||||
use_chroma_key,
|
||||
),
|
||||
CharacterAnimationStrategy::ImageSequence => build_image_sequence_prompt(
|
||||
animation,
|
||||
prompt_text,
|
||||
frame_count,
|
||||
use_chroma_key,
|
||||
),
|
||||
CharacterAnimationStrategy::ImageSequence => {
|
||||
build_image_sequence_prompt(animation, prompt_text, frame_count, use_chroma_key)
|
||||
}
|
||||
CharacterAnimationStrategy::MotionTransfer
|
||||
| CharacterAnimationStrategy::ReferenceToVideo => build_npc_animation_prompt(
|
||||
animation,
|
||||
@@ -1755,7 +1758,10 @@ fn build_image_sequence_prompt(
|
||||
use_chroma_key: bool,
|
||||
) -> String {
|
||||
[
|
||||
format!("同一角色连续 {} 帧动作序列,动作主题是 {}。", frame_count, animation),
|
||||
format!(
|
||||
"同一角色连续 {} 帧动作序列,动作主题是 {}。",
|
||||
frame_count, animation
|
||||
),
|
||||
"固定机位,单人,全身,侧身朝右,保持同一套服装、发型、武器和体型。".to_string(),
|
||||
"帧间动作连续,姿态逐步推进,不要换人,不要跳变,不要多余物体。".to_string(),
|
||||
if use_chroma_key {
|
||||
@@ -1784,16 +1790,16 @@ fn build_npc_animation_prompt(
|
||||
let character_brief = build_compact_animation_character_brief(character_brief_text);
|
||||
let action_detail_text = sanitize_animation_prompt_text(prompt_text, 140);
|
||||
let loop_rule = if loop_ {
|
||||
"这是循环动作,直接进入动作循环中段,不要开场静止站桩,不要把主参考图原样作为第一帧。".to_string()
|
||||
"这是循环动作,直接进入动作循环中段,不要开场静止站桩,不要把主参考图原样作为第一帧。"
|
||||
.to_string()
|
||||
} else if animation == "die" {
|
||||
"这是死亡终结动作,首帧参考主图角色形象即可,尾帧停在死亡结束姿态,不要回到主图形象。".to_string()
|
||||
"这是死亡终结动作,首帧参考主图角色形象即可,尾帧停在死亡结束姿态,不要回到主图形象。"
|
||||
.to_string()
|
||||
} else {
|
||||
"这是非循环动作,首帧和尾帧都要回到参考主图角色形象,中段完成动作变化。".to_string()
|
||||
};
|
||||
|
||||
if let Some(template) = action_template_id
|
||||
.and_then(|id| find_motion_template(id))
|
||||
{
|
||||
if let Some(template) = action_template_id.and_then(|id| find_motion_template(id)) {
|
||||
return [
|
||||
format!(
|
||||
"单人 NPC 全身动作视频,动作主题是 {}。角色固定为同一人,右向斜侧身,镜头稳定,轮廓清晰,武器不可丢失。",
|
||||
@@ -1843,7 +1849,11 @@ fn build_npc_animation_prompt(
|
||||
} else {
|
||||
action_detail_text
|
||||
},
|
||||
format!("目标帧率 {} fps,时长约 {} 秒。", fps.clamp(1, 60), duration_seconds.clamp(1, 8)),
|
||||
format!(
|
||||
"目标帧率 {} fps,时长约 {} 秒。",
|
||||
fps.clamp(1, 60),
|
||||
duration_seconds.clamp(1, 8)
|
||||
),
|
||||
loop_rule,
|
||||
]
|
||||
.into_iter()
|
||||
@@ -1906,8 +1916,12 @@ fn build_ark_character_animation_prompt(
|
||||
}
|
||||
|
||||
[
|
||||
format!("单人 NPC 全身动作视频,动作英文名是 {}。", normalized_animation_name),
|
||||
"角色固定为图片1和图片2中的同一人,侧身朝右,镜头稳定,轮廓清晰,武器不可丢失。".to_string(),
|
||||
format!(
|
||||
"单人 NPC 全身动作视频,动作英文名是 {}。",
|
||||
normalized_animation_name
|
||||
),
|
||||
"角色固定为图片1和图片2中的同一人,侧身朝右,镜头稳定,轮廓清晰,武器不可丢失。"
|
||||
.to_string(),
|
||||
"动作连贯,避免服装、发型、面部、武器随机漂移,不要多角色,不要镜头切换。".to_string(),
|
||||
if use_chroma_key {
|
||||
"背景为纯绿色绿幕,无其他人物和场景元素,方便后期抠像。".to_string()
|
||||
@@ -2341,11 +2355,7 @@ fn finalize_animation_frame_payload(
|
||||
);
|
||||
}
|
||||
|
||||
let normalized = contain_rgba_image(
|
||||
&image,
|
||||
frame_width.max(1),
|
||||
frame_height.max(1),
|
||||
);
|
||||
let normalized = contain_rgba_image(&image, frame_width.max(1), frame_height.max(1));
|
||||
let mut encoded = Vec::new();
|
||||
let encoder = PngEncoder::new(&mut encoded);
|
||||
encoder
|
||||
@@ -2665,7 +2675,14 @@ fn is_completed_generation_task_status(status: &str) -> bool {
|
||||
fn is_failed_generation_task_status(status: &str) -> bool {
|
||||
matches!(
|
||||
status,
|
||||
"failed" | "canceled" | "cancelled" | "error" | "aborted" | "rejected" | "expired" | "unknown"
|
||||
"failed"
|
||||
| "canceled"
|
||||
| "cancelled"
|
||||
| "error"
|
||||
| "aborted"
|
||||
| "rejected"
|
||||
| "expired"
|
||||
| "unknown"
|
||||
)
|
||||
}
|
||||
|
||||
@@ -3110,9 +3127,8 @@ fn remove_background_from_rgba(pixels: &mut [u8], width: usize, height: usize) -
|
||||
}
|
||||
let offset = pixel_index * 4;
|
||||
let alpha = pixels[offset + 3];
|
||||
let strong_candidate = alpha < 40
|
||||
|| green_scores[pixel_index] > 0.12
|
||||
|| white_scores[pixel_index] > 0.32;
|
||||
let strong_candidate =
|
||||
alpha < 40 || green_scores[pixel_index] > 0.12 || white_scores[pixel_index] > 0.32;
|
||||
if !strong_candidate {
|
||||
return;
|
||||
}
|
||||
@@ -3137,9 +3153,21 @@ fn remove_background_from_rgba(pixels: &mut [u8], width: usize, height: usize) -
|
||||
let y = pixel_index / width;
|
||||
let neighbor_indexes = [
|
||||
if x > 0 { Some(pixel_index - 1) } else { None },
|
||||
if x + 1 < width { Some(pixel_index + 1) } else { None },
|
||||
if y > 0 { Some(pixel_index - width) } else { None },
|
||||
if y + 1 < height { Some(pixel_index + width) } else { None },
|
||||
if x + 1 < width {
|
||||
Some(pixel_index + 1)
|
||||
} else {
|
||||
None
|
||||
},
|
||||
if y > 0 {
|
||||
Some(pixel_index - width)
|
||||
} else {
|
||||
None
|
||||
},
|
||||
if y + 1 < height {
|
||||
Some(pixel_index + width)
|
||||
} else {
|
||||
None
|
||||
},
|
||||
];
|
||||
|
||||
for next_pixel_index in neighbor_indexes.into_iter().flatten() {
|
||||
@@ -3153,9 +3181,7 @@ fn remove_background_from_rgba(pixels: &mut [u8], width: usize, height: usize) -
|
||||
let next_hint = background_hints[next_pixel_index];
|
||||
let reachable_soft_edge = next_hint > 0.08
|
||||
&& next_alpha < SOFT_EDGE_ALPHA_THRESHOLD
|
||||
&& (next_green_score > 0.04
|
||||
|| next_white_score > 0.08
|
||||
|| next_alpha < 180);
|
||||
&& (next_green_score > 0.04 || next_white_score > 0.08 || next_alpha < 180);
|
||||
|
||||
if next_alpha < 40
|
||||
|| next_green_score > 0.12
|
||||
@@ -3203,8 +3229,7 @@ fn remove_background_from_rgba(pixels: &mut [u8], width: usize, height: usize) -
|
||||
}
|
||||
}
|
||||
|
||||
if adjacent_background_count >= 2
|
||||
|| (adjacent_background_count >= 1 && hint > 0.18)
|
||||
if adjacent_background_count >= 2 || (adjacent_background_count >= 1 && hint > 0.18)
|
||||
{
|
||||
expanded_mask[pixel_index] = 1;
|
||||
}
|
||||
@@ -3237,10 +3262,7 @@ fn remove_background_from_rgba(pixels: &mut [u8], width: usize, height: usize) -
|
||||
}
|
||||
let next_x = x as i32 + offset_x;
|
||||
let next_y = y as i32 + offset_y;
|
||||
if next_x < 0
|
||||
|| next_x >= width as i32
|
||||
|| next_y < 0
|
||||
|| next_y >= height as i32
|
||||
if next_x < 0 || next_x >= width as i32 || next_y < 0 || next_y >= height as i32
|
||||
{
|
||||
continue;
|
||||
}
|
||||
@@ -3295,10 +3317,7 @@ fn remove_background_from_rgba(pixels: &mut [u8], width: usize, height: usize) -
|
||||
}
|
||||
let next_x = x as i32 + offset_x;
|
||||
let next_y = y as i32 + offset_y;
|
||||
if next_x < 0
|
||||
|| next_x >= width as i32
|
||||
|| next_y < 0
|
||||
|| next_y >= height as i32
|
||||
if next_x < 0 || next_x >= width as i32 || next_y < 0 || next_y >= height as i32
|
||||
{
|
||||
touches_transparent_edge = true;
|
||||
continue;
|
||||
@@ -3320,7 +3339,11 @@ fn remove_background_from_rgba(pixels: &mut [u8], width: usize, height: usize) -
|
||||
let white_score = white_scores[pixel_index];
|
||||
let contamination = green_score
|
||||
.max(white_score)
|
||||
.max(if background_mask[pixel_index] != 0 { 0.35 } else { 0.0 })
|
||||
.max(if background_mask[pixel_index] != 0 {
|
||||
0.35
|
||||
} else {
|
||||
0.0
|
||||
})
|
||||
.max(if alpha < 220 {
|
||||
((220 - alpha) as f32 / 220.0) * 0.25
|
||||
} else {
|
||||
@@ -3343,7 +3366,8 @@ fn remove_background_from_rgba(pixels: &mut [u8], width: usize, height: usize) -
|
||||
&background_mask,
|
||||
&background_hints,
|
||||
);
|
||||
let blend = clamp01(contamination.max(if touches_transparent_edge { 0.22 } else { 0.0 }));
|
||||
let blend =
|
||||
clamp01(contamination.max(if touches_transparent_edge { 0.22 } else { 0.0 }));
|
||||
|
||||
if let Some((sample_red, sample_green, sample_blue)) = sample {
|
||||
red = lerp(red, sample_red as f32, blend);
|
||||
@@ -3360,7 +3384,8 @@ fn remove_background_from_rgba(pixels: &mut [u8], width: usize, height: usize) -
|
||||
}
|
||||
} else {
|
||||
if green_score > 0.04 {
|
||||
green = green.max(red.max(blue))
|
||||
green = green
|
||||
.max(red.max(blue))
|
||||
.max((green - (green - red.max(blue)) * 0.78).round());
|
||||
}
|
||||
|
||||
|
||||
@@ -104,11 +104,11 @@ pub async fn generate_character_visual(
|
||||
text_output: Some(prompt.clone()),
|
||||
structured_payload_json: Some(
|
||||
json!({
|
||||
"characterId": character_id,
|
||||
"sourceMode": payload.source_mode,
|
||||
"size": size,
|
||||
"referenceImageCount": payload.reference_image_data_urls.len(),
|
||||
})
|
||||
"characterId": character_id,
|
||||
"sourceMode": payload.source_mode,
|
||||
"size": size,
|
||||
"referenceImageCount": payload.reference_image_data_urls.len(),
|
||||
})
|
||||
.to_string(),
|
||||
),
|
||||
warning_messages: Vec::new(),
|
||||
@@ -832,12 +832,9 @@ async fn resolve_reference_image_as_data_url(
|
||||
.and_then(|value| value.to_str().ok())
|
||||
.unwrap_or("image/png")
|
||||
.to_string();
|
||||
let body = response
|
||||
.bytes()
|
||||
.await
|
||||
.map_err(|error| {
|
||||
map_dashscope_request_error(format!("读取角色主形象参考图内容失败:{error}"))
|
||||
})?;
|
||||
let body = response.bytes().await.map_err(|error| {
|
||||
map_dashscope_request_error(format!("读取角色主形象参考图内容失败:{error}"))
|
||||
})?;
|
||||
if !status.is_success() {
|
||||
return Err(
|
||||
AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({
|
||||
@@ -911,9 +908,7 @@ async fn create_character_visual_generation(
|
||||
}))
|
||||
.send()
|
||||
.await
|
||||
.map_err(|error| {
|
||||
map_dashscope_request_error(format!("创建角色主形象任务失败:{error}"))
|
||||
})?;
|
||||
.map_err(|error| map_dashscope_request_error(format!("创建角色主形象任务失败:{error}")))?;
|
||||
let response_status = response.status();
|
||||
let response_text = response.text().await.map_err(|error| {
|
||||
map_dashscope_request_error(format!("读取角色主形象任务响应失败:{error}"))
|
||||
@@ -963,19 +958,23 @@ async fn create_character_visual_generation(
|
||||
if task_status == "SUCCEEDED" {
|
||||
let image_urls = extract_image_urls(&poll_json.payload);
|
||||
if image_urls.is_empty() {
|
||||
return Err(AppError::from_status(StatusCode::BAD_GATEWAY).with_details(
|
||||
json!({
|
||||
return Err(
|
||||
AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({
|
||||
"provider": "dashscope",
|
||||
"message": "角色主形象生成成功,但没有返回可下载图片。",
|
||||
}),
|
||||
));
|
||||
})),
|
||||
);
|
||||
}
|
||||
|
||||
let mut images = Vec::with_capacity(image_urls.len());
|
||||
for image_url in image_urls {
|
||||
images.push(
|
||||
download_generated_image(http_client, image_url.as_str(), "下载角色主形象候选图失败。")
|
||||
.await?,
|
||||
download_generated_image(
|
||||
http_client,
|
||||
image_url.as_str(),
|
||||
"下载角色主形象候选图失败。",
|
||||
)
|
||||
.await?,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -992,13 +991,18 @@ async fn create_character_visual_generation(
|
||||
));
|
||||
}
|
||||
|
||||
sleep(Duration::from_millis(CHARACTER_VISUAL_TASK_POLL_INTERVAL_MS)).await;
|
||||
sleep(Duration::from_millis(
|
||||
CHARACTER_VISUAL_TASK_POLL_INTERVAL_MS,
|
||||
))
|
||||
.await;
|
||||
}
|
||||
|
||||
Err(AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({
|
||||
"provider": "dashscope",
|
||||
"message": "角色主形象任务执行超时,请稍后重试。",
|
||||
})))
|
||||
Err(
|
||||
AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({
|
||||
"provider": "dashscope",
|
||||
"message": "角色主形象任务执行超时,请稍后重试。",
|
||||
})),
|
||||
)
|
||||
}
|
||||
|
||||
async fn download_generated_image(
|
||||
@@ -1023,11 +1027,13 @@ async fn download_generated_image(
|
||||
.await
|
||||
.map_err(|error| map_dashscope_request_error(format!("{fallback_message}:{error}")))?;
|
||||
if !status.is_success() {
|
||||
return Err(AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({
|
||||
"provider": "dashscope",
|
||||
"message": fallback_message,
|
||||
"status": status.as_u16(),
|
||||
})));
|
||||
return Err(
|
||||
AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({
|
||||
"provider": "dashscope",
|
||||
"message": fallback_message,
|
||||
"status": status.as_u16(),
|
||||
})),
|
||||
);
|
||||
}
|
||||
|
||||
let normalized_mime_type = normalize_downloaded_image_mime_type(content_type.as_str());
|
||||
@@ -1244,7 +1250,10 @@ fn map_character_visual_oss_error(error: platform_oss::OssError) -> AppError {
|
||||
}))
|
||||
}
|
||||
|
||||
fn parse_json_payload(raw_text: &str, fallback_message: &str) -> Result<ParsedJsonPayload, AppError> {
|
||||
fn parse_json_payload(
|
||||
raw_text: &str,
|
||||
fallback_message: &str,
|
||||
) -> Result<ParsedJsonPayload, AppError> {
|
||||
serde_json::from_str::<Value>(raw_text)
|
||||
.map(|payload| ParsedJsonPayload { payload })
|
||||
.map_err(|error| {
|
||||
@@ -1541,11 +1550,7 @@ fn collect_foreground_neighbor_color(
|
||||
))
|
||||
}
|
||||
|
||||
pub(crate) fn remove_background_from_rgba(
|
||||
pixels: &mut [u8],
|
||||
width: usize,
|
||||
height: usize,
|
||||
) -> bool {
|
||||
pub(crate) fn remove_background_from_rgba(pixels: &mut [u8], width: usize, height: usize) -> bool {
|
||||
const SOFT_EDGE_ALPHA_THRESHOLD: u8 = 224;
|
||||
const FOREGROUND_NEIGHBOR_ALPHA_THRESHOLD: u8 = 96;
|
||||
|
||||
@@ -1574,26 +1579,24 @@ pub(crate) fn remove_background_from_rgba(
|
||||
|
||||
green_scores[pixel_index] = green_score;
|
||||
white_scores[pixel_index] = white_score;
|
||||
background_hints[pixel_index] =
|
||||
green_score.max(white_score).max(transparency_hint);
|
||||
background_hints[pixel_index] = green_score.max(white_score).max(transparency_hint);
|
||||
}
|
||||
|
||||
let try_seed_background =
|
||||
|pixel_index: usize, background_mask: &mut [u8], queue: &mut Vec<usize>| {
|
||||
if background_mask[pixel_index] != 0 {
|
||||
return;
|
||||
}
|
||||
let offset = pixel_index * 4;
|
||||
let alpha = pixels[offset + 3];
|
||||
let strong_candidate = alpha < 40
|
||||
|| green_scores[pixel_index] > 0.12
|
||||
|| white_scores[pixel_index] > 0.32;
|
||||
if !strong_candidate {
|
||||
return;
|
||||
}
|
||||
background_mask[pixel_index] = 1;
|
||||
queue.push(pixel_index);
|
||||
};
|
||||
if background_mask[pixel_index] != 0 {
|
||||
return;
|
||||
}
|
||||
let offset = pixel_index * 4;
|
||||
let alpha = pixels[offset + 3];
|
||||
let strong_candidate =
|
||||
alpha < 40 || green_scores[pixel_index] > 0.12 || white_scores[pixel_index] > 0.32;
|
||||
if !strong_candidate {
|
||||
return;
|
||||
}
|
||||
background_mask[pixel_index] = 1;
|
||||
queue.push(pixel_index);
|
||||
};
|
||||
|
||||
for x in 0..width {
|
||||
try_seed_background(x, &mut background_mask, &mut queue);
|
||||
@@ -1612,9 +1615,21 @@ pub(crate) fn remove_background_from_rgba(
|
||||
let y = pixel_index / width;
|
||||
let neighbor_indexes = [
|
||||
if x > 0 { Some(pixel_index - 1) } else { None },
|
||||
if x + 1 < width { Some(pixel_index + 1) } else { None },
|
||||
if y > 0 { Some(pixel_index - width) } else { None },
|
||||
if y + 1 < height { Some(pixel_index + width) } else { None },
|
||||
if x + 1 < width {
|
||||
Some(pixel_index + 1)
|
||||
} else {
|
||||
None
|
||||
},
|
||||
if y > 0 {
|
||||
Some(pixel_index - width)
|
||||
} else {
|
||||
None
|
||||
},
|
||||
if y + 1 < height {
|
||||
Some(pixel_index + width)
|
||||
} else {
|
||||
None
|
||||
},
|
||||
];
|
||||
|
||||
for next_pixel_index in neighbor_indexes.into_iter().flatten() {
|
||||
@@ -1628,9 +1643,7 @@ pub(crate) fn remove_background_from_rgba(
|
||||
let next_hint = background_hints[next_pixel_index];
|
||||
let reachable_soft_edge = next_hint > 0.08
|
||||
&& next_alpha < SOFT_EDGE_ALPHA_THRESHOLD
|
||||
&& (next_green_score > 0.04
|
||||
|| next_white_score > 0.08
|
||||
|| next_alpha < 180);
|
||||
&& (next_green_score > 0.04 || next_white_score > 0.08 || next_alpha < 180);
|
||||
|
||||
if next_alpha < 40
|
||||
|| next_green_score > 0.12
|
||||
@@ -1678,8 +1691,7 @@ pub(crate) fn remove_background_from_rgba(
|
||||
}
|
||||
}
|
||||
|
||||
if adjacent_background_count >= 2
|
||||
|| (adjacent_background_count >= 1 && hint > 0.18)
|
||||
if adjacent_background_count >= 2 || (adjacent_background_count >= 1 && hint > 0.18)
|
||||
{
|
||||
expanded_mask[pixel_index] = 1;
|
||||
}
|
||||
@@ -1712,10 +1724,7 @@ pub(crate) fn remove_background_from_rgba(
|
||||
}
|
||||
let next_x = x as i32 + offset_x;
|
||||
let next_y = y as i32 + offset_y;
|
||||
if next_x < 0
|
||||
|| next_x >= width as i32
|
||||
|| next_y < 0
|
||||
|| next_y >= height as i32
|
||||
if next_x < 0 || next_x >= width as i32 || next_y < 0 || next_y >= height as i32
|
||||
{
|
||||
continue;
|
||||
}
|
||||
@@ -1770,10 +1779,7 @@ pub(crate) fn remove_background_from_rgba(
|
||||
}
|
||||
let next_x = x as i32 + offset_x;
|
||||
let next_y = y as i32 + offset_y;
|
||||
if next_x < 0
|
||||
|| next_x >= width as i32
|
||||
|| next_y < 0
|
||||
|| next_y >= height as i32
|
||||
if next_x < 0 || next_x >= width as i32 || next_y < 0 || next_y >= height as i32
|
||||
{
|
||||
touches_transparent_edge = true;
|
||||
continue;
|
||||
@@ -1795,7 +1801,11 @@ pub(crate) fn remove_background_from_rgba(
|
||||
let white_score = white_scores[pixel_index];
|
||||
let contamination = green_score
|
||||
.max(white_score)
|
||||
.max(if background_mask[pixel_index] != 0 { 0.35 } else { 0.0 })
|
||||
.max(if background_mask[pixel_index] != 0 {
|
||||
0.35
|
||||
} else {
|
||||
0.0
|
||||
})
|
||||
.max(if alpha < 220 {
|
||||
((220 - alpha) as f32 / 220.0) * 0.25
|
||||
} else {
|
||||
@@ -1818,7 +1828,8 @@ pub(crate) fn remove_background_from_rgba(
|
||||
&background_mask,
|
||||
&background_hints,
|
||||
);
|
||||
let blend = clamp01(contamination.max(if touches_transparent_edge { 0.22 } else { 0.0 }));
|
||||
let blend =
|
||||
clamp01(contamination.max(if touches_transparent_edge { 0.22 } else { 0.0 }));
|
||||
|
||||
if let Some((sample_red, sample_green, sample_blue)) = sample {
|
||||
red = lerp(red, sample_red as f32, blend);
|
||||
@@ -1835,7 +1846,8 @@ pub(crate) fn remove_background_from_rgba(
|
||||
}
|
||||
} else {
|
||||
if green_score > 0.04 {
|
||||
green = green.max(red.max(blue))
|
||||
green = green
|
||||
.max(red.max(blue))
|
||||
.max((green - (green - red.max(blue)) * 0.78).round());
|
||||
}
|
||||
|
||||
|
||||
@@ -478,8 +478,7 @@ impl AppConfig {
|
||||
"ARK_CHARACTER_VIDEO_REQUEST_TIMEOUT_MS",
|
||||
"DASHSCOPE_CHARACTER_VIDEO_REQUEST_TIMEOUT_MS",
|
||||
]) {
|
||||
config.ark_character_video_request_timeout_ms =
|
||||
ark_character_video_request_timeout_ms;
|
||||
config.ark_character_video_request_timeout_ms = ark_character_video_request_timeout_ms;
|
||||
}
|
||||
|
||||
if let Some(ark_character_video_model) = read_first_non_empty_env(&[
|
||||
@@ -501,9 +500,9 @@ impl AppConfig {
|
||||
config.character_animation_ffprobe_path = character_animation_ffprobe_path;
|
||||
}
|
||||
|
||||
if let Some(character_animation_frame_extract_timeout_ms) = read_first_positive_u64_env(&[
|
||||
"CHARACTER_ANIMATION_FRAME_EXTRACT_TIMEOUT_MS",
|
||||
]) {
|
||||
if let Some(character_animation_frame_extract_timeout_ms) =
|
||||
read_first_positive_u64_env(&["CHARACTER_ANIMATION_FRAME_EXTRACT_TIMEOUT_MS"])
|
||||
{
|
||||
config.character_animation_frame_extract_timeout_ms =
|
||||
character_animation_frame_extract_timeout_ms;
|
||||
}
|
||||
|
||||
@@ -611,7 +611,10 @@ where
|
||||
empty_json_array()
|
||||
};
|
||||
let asset_coverage_json = if should_stay_in_draft_stage {
|
||||
serialize_json(&request.session.asset_coverage, &empty_agent_asset_coverage_json())
|
||||
serialize_json(
|
||||
&request.session.asset_coverage,
|
||||
&empty_agent_asset_coverage_json(),
|
||||
)
|
||||
} else {
|
||||
empty_agent_asset_coverage_json()
|
||||
};
|
||||
@@ -732,7 +735,10 @@ pub(crate) fn build_failed_finalize_record_input(
|
||||
stage: session.stage.clone(),
|
||||
progress_percent: session.progress_percent,
|
||||
focus_card_id: session.focus_card_id.clone(),
|
||||
anchor_content_json: serialize_json(&session.anchor_content, &empty_agent_anchor_content_json()),
|
||||
anchor_content_json: serialize_json(
|
||||
&session.anchor_content,
|
||||
&empty_agent_anchor_content_json(),
|
||||
),
|
||||
creator_intent_json: serialize_optional_json_object(&session.creator_intent),
|
||||
creator_intent_readiness_json: serialize_json(
|
||||
&session.creator_intent_readiness,
|
||||
@@ -753,7 +759,10 @@ pub(crate) fn build_failed_finalize_record_input(
|
||||
&JsonValue::Array(session.quality_findings.clone()),
|
||||
&empty_json_array(),
|
||||
),
|
||||
asset_coverage_json: serialize_json(&session.asset_coverage, &empty_agent_asset_coverage_json()),
|
||||
asset_coverage_json: serialize_json(
|
||||
&session.asset_coverage,
|
||||
&empty_agent_asset_coverage_json(),
|
||||
),
|
||||
error_message: Some(error_message),
|
||||
updated_at_micros,
|
||||
}
|
||||
@@ -771,13 +780,18 @@ async fn stream_single_turn<F>(
|
||||
where
|
||||
F: FnMut(&str),
|
||||
{
|
||||
let llm_client = llm_client.ok_or_else(|| {
|
||||
CustomWorldTurnError::new("当前模型不可用,请稍后重试。")
|
||||
})?;
|
||||
let llm_client =
|
||||
llm_client.ok_or_else(|| CustomWorldTurnError::new("当前模型不可用,请稍后重试。"))?;
|
||||
let chat_history = build_chat_history(messages);
|
||||
let dynamic_state =
|
||||
resolve_dynamic_state(llm_client, current_turn, progress_percent, quick_fill_requested, current_anchor_content, &chat_history)
|
||||
.await;
|
||||
let dynamic_state = resolve_dynamic_state(
|
||||
llm_client,
|
||||
current_turn,
|
||||
progress_percent,
|
||||
quick_fill_requested,
|
||||
current_anchor_content,
|
||||
&chat_history,
|
||||
)
|
||||
.await;
|
||||
let prompt = build_eight_anchor_single_turn_prompt(
|
||||
current_turn,
|
||||
progress_percent,
|
||||
@@ -806,27 +820,21 @@ where
|
||||
)
|
||||
.await;
|
||||
|
||||
let response = response.map_err(|_| {
|
||||
CustomWorldTurnError::new("这一轮设定生成失败,请稍后重试。")
|
||||
})?;
|
||||
let response =
|
||||
response.map_err(|_| CustomWorldTurnError::new("这一轮设定生成失败,请稍后重试。"))?;
|
||||
|
||||
let parsed = parse_json_response_text(response.content.as_str()).map_err(|_| {
|
||||
CustomWorldTurnError::new("模型返回结果解析失败,请稍后重试。")
|
||||
})?;
|
||||
let parsed = parse_json_response_text(response.content.as_str())
|
||||
.map_err(|_| CustomWorldTurnError::new("模型返回结果解析失败,请稍后重试。"))?;
|
||||
|
||||
let next_anchor_content = normalize_eight_anchor_content(
|
||||
parsed
|
||||
.get("nextAnchorContent")
|
||||
.unwrap_or(&JsonValue::Null),
|
||||
);
|
||||
let next_anchor_content =
|
||||
normalize_eight_anchor_content(parsed.get("nextAnchorContent").unwrap_or(&JsonValue::Null));
|
||||
let progress_percent = if quick_fill_requested {
|
||||
100
|
||||
} else {
|
||||
clamp_progress_percent(parsed.get("progressPercent"))
|
||||
};
|
||||
let reply_text = to_text(parsed.get("replyText")).ok_or_else(|| {
|
||||
CustomWorldTurnError::new("模型返回结果缺少有效回复,请稍后重试。")
|
||||
})?;
|
||||
let reply_text = to_text(parsed.get("replyText"))
|
||||
.ok_or_else(|| CustomWorldTurnError::new("模型返回结果缺少有效回复,请稍后重试。"))?;
|
||||
if reply_text != latest_reply_text {
|
||||
on_reply_update(reply_text.as_str());
|
||||
}
|
||||
@@ -907,13 +915,19 @@ fn build_prompt_dynamic_state(
|
||||
let Some(inference) = inference else {
|
||||
return fallback;
|
||||
};
|
||||
let user_input_signal = inference.user_input_signal.unwrap_or(fallback.user_input_signal);
|
||||
let user_input_signal = inference
|
||||
.user_input_signal
|
||||
.unwrap_or(fallback.user_input_signal);
|
||||
let drift_risk = inference.drift_risk.unwrap_or(fallback.drift_risk);
|
||||
let conversation_mode = inference.conversation_mode.unwrap_or(fallback.conversation_mode);
|
||||
let conversation_mode = inference
|
||||
.conversation_mode
|
||||
.unwrap_or(fallback.conversation_mode);
|
||||
let judgement_summary = inference
|
||||
.judgement_summary
|
||||
.filter(|value| !value.trim().is_empty())
|
||||
.unwrap_or_else(|| summarize_dynamic_state(user_input_signal, drift_risk, conversation_mode));
|
||||
.unwrap_or_else(|| {
|
||||
summarize_dynamic_state(user_input_signal, drift_risk, conversation_mode)
|
||||
});
|
||||
|
||||
PromptDynamicState {
|
||||
current_turn,
|
||||
@@ -966,7 +980,11 @@ fn build_prompt_dynamic_state_inference_prompt(
|
||||
chat_history: &[JsonValue],
|
||||
) -> (String, String) {
|
||||
(
|
||||
[STATE_INFERENCE_SYSTEM_PROMPT, STATE_INFERENCE_OUTPUT_CONTRACT].join("\n\n"),
|
||||
[
|
||||
STATE_INFERENCE_SYSTEM_PROMPT,
|
||||
STATE_INFERENCE_OUTPUT_CONTRACT,
|
||||
]
|
||||
.join("\n\n"),
|
||||
[
|
||||
format!("当前轮次:{current_turn}"),
|
||||
format!("当前完成度:{progress_percent}"),
|
||||
@@ -1010,7 +1028,8 @@ fn build_chat_history(messages: &[CustomWorldAgentMessageRecord]) -> Vec<JsonVal
|
||||
messages
|
||||
.iter()
|
||||
.filter(|message| {
|
||||
(message.role == "user" || message.role == "assistant") && !message.text.trim().is_empty()
|
||||
(message.role == "user" || message.role == "assistant")
|
||||
&& !message.text.trim().is_empty()
|
||||
})
|
||||
.map(|message| {
|
||||
json!({
|
||||
@@ -1059,8 +1078,7 @@ fn build_creator_intent_from_eight_anchor_content(
|
||||
.iter()
|
||||
.cloned()
|
||||
.chain(
|
||||
(!value.hidden_crisis.trim().is_empty())
|
||||
.then_some(value.hidden_crisis.clone()),
|
||||
(!value.hidden_crisis.trim().is_empty()).then_some(value.hidden_crisis.clone()),
|
||||
)
|
||||
.collect::<Vec<_>>()
|
||||
})
|
||||
@@ -1205,7 +1223,10 @@ fn evaluate_creator_intent_readiness(intent: &CreatorIntentRecord) -> CreatorInt
|
||||
}
|
||||
}
|
||||
|
||||
fn resolve_creator_intent_stage(has_user_input: bool, readiness: &CreatorIntentReadiness) -> &'static str {
|
||||
fn resolve_creator_intent_stage(
|
||||
has_user_input: bool,
|
||||
readiness: &CreatorIntentReadiness,
|
||||
) -> &'static str {
|
||||
if readiness.is_ready {
|
||||
"foundation_review"
|
||||
} else if has_user_input {
|
||||
@@ -1509,11 +1530,16 @@ fn detect_user_input_signal(chat_history: &[JsonValue]) -> PromptUserInputSignal
|
||||
if latest_user_text.is_empty() {
|
||||
return PromptUserInputSignal::Sparse;
|
||||
}
|
||||
if contains_any(&latest_user_text, &["不是", "改成", "改为", "换成", "重来", "推翻", "修正"])
|
||||
{
|
||||
if contains_any(
|
||||
&latest_user_text,
|
||||
&["不是", "改成", "改为", "换成", "重来", "推翻", "修正"],
|
||||
) {
|
||||
return PromptUserInputSignal::Correction;
|
||||
}
|
||||
if contains_any(&latest_user_text, &["你帮我想", "你来定", "你决定", "你补完"]) {
|
||||
if contains_any(
|
||||
&latest_user_text,
|
||||
&["你帮我想", "你来定", "你决定", "你补完"],
|
||||
) {
|
||||
return PromptUserInputSignal::Delegate;
|
||||
}
|
||||
let segments = split_sentences(&latest_user_text);
|
||||
@@ -1535,8 +1561,14 @@ fn detect_drift_risk(
|
||||
let recent_user_messages = chat_history
|
||||
.iter()
|
||||
.filter_map(|entry| {
|
||||
(entry.get("role").and_then(JsonValue::as_str) == Some("user"))
|
||||
.then(|| entry.get("content").and_then(JsonValue::as_str).unwrap_or("").trim().to_string())
|
||||
(entry.get("role").and_then(JsonValue::as_str) == Some("user")).then(|| {
|
||||
entry
|
||||
.get("content")
|
||||
.and_then(JsonValue::as_str)
|
||||
.unwrap_or("")
|
||||
.trim()
|
||||
.to_string()
|
||||
})
|
||||
})
|
||||
.filter(|value| !value.is_empty())
|
||||
.rev()
|
||||
@@ -1545,11 +1577,19 @@ fn detect_drift_risk(
|
||||
|
||||
let correction_count = recent_user_messages
|
||||
.iter()
|
||||
.filter(|entry| contains_any(entry, &["不是", "改成", "改为", "换成", "推翻", "重来", "修正"]))
|
||||
.filter(|entry| {
|
||||
contains_any(
|
||||
entry,
|
||||
&["不是", "改成", "改为", "换成", "推翻", "重来", "修正"],
|
||||
)
|
||||
})
|
||||
.count();
|
||||
if correction_count >= 2
|
||||
|| (progress_percent >= 65
|
||||
&& contains_any(&latest_user_text, &["不是", "改成", "改为", "换成", "重来", "推翻"]))
|
||||
&& contains_any(
|
||||
&latest_user_text,
|
||||
&["不是", "改成", "改为", "换成", "重来", "推翻"],
|
||||
))
|
||||
{
|
||||
return PromptDriftRisk::High;
|
||||
}
|
||||
@@ -1652,7 +1692,8 @@ fn render_dynamic_state_context(dynamic_state: &PromptDynamicState) -> String {
|
||||
fn render_current_anchor_context(anchor_content: &EightAnchorContent) -> String {
|
||||
format!(
|
||||
"当前完整设定结构如下。\n你必须把它视为上一版有效世界底子。\n\n如果用户没有否定其中某部分内容,且该部分仍然成立,可以继续保留。\n如果用户明确修正了某部分内容,新的完整设定结构必须体现修正后的版本。\n\n当前完整设定结构:\n{}",
|
||||
serde_json::to_string_pretty(anchor_content).unwrap_or_else(|_| empty_agent_anchor_content_json())
|
||||
serde_json::to_string_pretty(anchor_content)
|
||||
.unwrap_or_else(|_| empty_agent_anchor_content_json())
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1757,7 +1798,8 @@ fn parse_conversation_mode(value: Option<&JsonValue>) -> Option<PromptConversati
|
||||
|
||||
fn mode_rules(mode: PromptConversationMode) -> &'static str {
|
||||
match mode {
|
||||
PromptConversationMode::Bootstrap => r#"当前模式:bootstrap
|
||||
PromptConversationMode::Bootstrap => {
|
||||
r#"当前模式:bootstrap
|
||||
|
||||
目标:
|
||||
1. 先把世界的基本方向抓住
|
||||
@@ -1777,8 +1819,10 @@ fn mode_rules(mode: PromptConversationMode) -> &'static str {
|
||||
1. 让用户觉得“现在很容易继续往下说”
|
||||
2. 不要制造被考试、被拷问、被策划问卷追着跑的感觉
|
||||
3. replyText 最好短、稳、可接话
|
||||
4. 如果用户信息很少,也不要显得冷淡或机械"#,
|
||||
PromptConversationMode::Expand => r#"当前模式:expand
|
||||
4. 如果用户信息很少,也不要显得冷淡或机械"#
|
||||
}
|
||||
PromptConversationMode::Expand => {
|
||||
r#"当前模式:expand
|
||||
|
||||
目标:
|
||||
1. 在保持现有方向的前提下,把设定结构逐步补全
|
||||
@@ -1797,8 +1841,10 @@ fn mode_rules(mode: PromptConversationMode) -> &'static str {
|
||||
1. 让用户感到“我刚说的内容都被接住了”
|
||||
2. 回复里可以带一点顺势整理感,但不要太像会议纪要
|
||||
3. 不要无视用户刚提供的高价值细节
|
||||
4. 不要让用户觉得系统在自顾自重写世界"#,
|
||||
PromptConversationMode::Compress => r#"当前模式:compress
|
||||
4. 不要让用户觉得系统在自顾自重写世界"#
|
||||
}
|
||||
PromptConversationMode::Compress => {
|
||||
r#"当前模式:compress
|
||||
|
||||
目标:
|
||||
1. 开始收束当前设定
|
||||
@@ -1818,8 +1864,10 @@ fn mode_rules(mode: PromptConversationMode) -> &'static str {
|
||||
1. 让用户感觉世界正在变得更稳,而不是越来越散
|
||||
2. 让推进感更明确,但不要显得催促
|
||||
3. 回复语气应更笃定一些,减少反复横跳
|
||||
4. 不要把用户刚补进来的细节又冲淡掉"#,
|
||||
PromptConversationMode::RepairDirection => r#"当前模式:repair_direction
|
||||
4. 不要把用户刚补进来的细节又冲淡掉"#
|
||||
}
|
||||
PromptConversationMode::RepairDirection => {
|
||||
r#"当前模式:repair_direction
|
||||
|
||||
目标:
|
||||
1. 处理用户对既有设定的修正
|
||||
@@ -1838,8 +1886,10 @@ fn mode_rules(mode: PromptConversationMode) -> &'static str {
|
||||
1. 让用户感到“我刚刚的纠偏真的生效了”
|
||||
2. 不要和用户辩论旧方案为什么也行
|
||||
3. 不要表现出对修正的不情愿
|
||||
4. 回复要体现重心已经切到新方向,而不是停留在旧世界观惯性里"#,
|
||||
PromptConversationMode::ForceComplete => r#"当前模式:force_complete
|
||||
4. 回复要体现重心已经切到新方向,而不是停留在旧世界观惯性里"#
|
||||
}
|
||||
PromptConversationMode::ForceComplete => {
|
||||
r#"当前模式:force_complete
|
||||
|
||||
目标:
|
||||
1. 基于当前方向直接补齐剩余设定
|
||||
@@ -1860,8 +1910,10 @@ fn mode_rules(mode: PromptConversationMode) -> &'static str {
|
||||
1. 让用户感到“系统已经帮我把能补的补好了”
|
||||
2. 不要在这一步突然冒出很多陌生设定把用户吓出戏
|
||||
3. 回复要有完成感,但不要太官话
|
||||
4. 清楚告诉用户下一步可以做什么"#,
|
||||
PromptConversationMode::Closing => r#"当前模式:closing
|
||||
4. 清楚告诉用户下一步可以做什么"#
|
||||
}
|
||||
PromptConversationMode::Closing => {
|
||||
r#"当前模式:closing
|
||||
|
||||
目标:
|
||||
1. 尽量形成一版可用的设定底子
|
||||
@@ -1880,26 +1932,37 @@ fn mode_rules(mode: PromptConversationMode) -> &'static str {
|
||||
1. 让用户感觉作品已经快成了,而不是还在无穷试探
|
||||
2. 回复可以更像确认和轻推,不要继续像前期那样频繁试探
|
||||
3. 保持留白感,不要把所有东西都一次说死
|
||||
4. 让用户自然过渡到下一阶段,而不是突然被切断对话"#,
|
||||
4. 让用户自然过渡到下一阶段,而不是突然被切断对话"#
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn user_signal_rules(signal: PromptUserInputSignal) -> &'static str {
|
||||
match signal {
|
||||
PromptUserInputSignal::Rich => r#"本轮用户输入信息密度高。
|
||||
PromptUserInputSignal::Rich => {
|
||||
r#"本轮用户输入信息密度高。
|
||||
请尽量从这一轮里提取多个锚点,不要只更新单一方向。
|
||||
如果一条输入同时影响世界方向、冲突和关系,请在新的完整设定结构中一起体现。"#,
|
||||
PromptUserInputSignal::Normal => r#"本轮用户输入为正常补充。
|
||||
请优先顺着当前方向稳定更新,不要主动扩写太多新设定。"#,
|
||||
PromptUserInputSignal::Sparse => r#"本轮用户输入较少或较虚。
|
||||
如果一条输入同时影响世界方向、冲突和关系,请在新的完整设定结构中一起体现。"#
|
||||
}
|
||||
PromptUserInputSignal::Normal => {
|
||||
r#"本轮用户输入为正常补充。
|
||||
请优先顺着当前方向稳定更新,不要主动扩写太多新设定。"#
|
||||
}
|
||||
PromptUserInputSignal::Sparse => {
|
||||
r#"本轮用户输入较少或较虚。
|
||||
请保留上一版中仍然成立的内容,不要为了凑完整度而强行发明过多新设定。
|
||||
replyText 要让用户容易继续往下说。"#,
|
||||
PromptUserInputSignal::Correction => r#"本轮用户在修正或推翻旧设定。
|
||||
replyText 要让用户容易继续往下说。"#
|
||||
}
|
||||
PromptUserInputSignal::Correction => {
|
||||
r#"本轮用户在修正或推翻旧设定。
|
||||
请优先吸收修正,不要机械复读旧版本。
|
||||
新的完整设定结构必须以修正后的方向为准。"#,
|
||||
PromptUserInputSignal::Delegate => r#"本轮用户把部分决定权交给你。
|
||||
新的完整设定结构必须以修正后的方向为准。"#
|
||||
}
|
||||
PromptUserInputSignal::Delegate => {
|
||||
r#"本轮用户把部分决定权交给你。
|
||||
你可以在 replyText 中给出有限度的建议,但不要突然补满整套设定。
|
||||
新的完整设定结构仍应尽量建立在已有世界方向上,而不是完全重做。"#,
|
||||
新的完整设定结构仍应尽量建立在已有世界方向上,而不是完全重做。"#
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1985,7 +2048,11 @@ fn clamp_text(value: &str, max_length: usize) -> String {
|
||||
if normalized.chars().count() <= max_length {
|
||||
return normalized;
|
||||
}
|
||||
normalized.chars().take(max_length.saturating_sub(1)).collect::<String>() + "…"
|
||||
normalized
|
||||
.chars()
|
||||
.take(max_length.saturating_sub(1))
|
||||
.collect::<String>()
|
||||
+ "…"
|
||||
}
|
||||
|
||||
fn clamp_progress_percent(value: Option<&JsonValue>) -> u32 {
|
||||
@@ -1996,7 +2063,8 @@ fn clamp_progress_percent(value: Option<&JsonValue>) -> u32 {
|
||||
}
|
||||
|
||||
fn to_text(value: Option<&JsonValue>) -> Option<String> {
|
||||
value.and_then(JsonValue::as_str)
|
||||
value
|
||||
.and_then(JsonValue::as_str)
|
||||
.map(str::trim)
|
||||
.filter(|value| !value.is_empty())
|
||||
.map(str::to_string)
|
||||
|
||||
@@ -16,10 +16,10 @@ use tracing::{info, warn};
|
||||
|
||||
use crate::{
|
||||
api_response::json_success_body,
|
||||
auth_payload::map_auth_user_payload,
|
||||
auth_session::{
|
||||
attach_set_cookie_header, build_refresh_session_cookie_header, create_auth_session,
|
||||
},
|
||||
auth_payload::map_auth_user_payload,
|
||||
http_error::AppError,
|
||||
request_context::RequestContext,
|
||||
session_client::resolve_session_client_context,
|
||||
|
||||
@@ -52,12 +52,15 @@ use spacetime_client::{
|
||||
use std::convert::Infallible;
|
||||
|
||||
use crate::{
|
||||
api_response::json_success_body, auth::AuthenticatedAccessToken, http_error::AppError,
|
||||
api_response::json_success_body,
|
||||
auth::AuthenticatedAccessToken,
|
||||
http_error::AppError,
|
||||
puzzle_agent_turn::{
|
||||
PuzzleAgentTurnRequest, build_failed_finalize_record_input, build_finalize_record_input,
|
||||
run_puzzle_agent_turn,
|
||||
},
|
||||
request_context::RequestContext, state::AppState,
|
||||
request_context::RequestContext,
|
||||
state::AppState,
|
||||
};
|
||||
|
||||
const PUZZLE_AGENT_API_BASE_PROVIDER: &str = "puzzle-agent";
|
||||
@@ -1269,7 +1272,9 @@ fn build_puzzle_welcome_text(seed_text: &str) -> String {
|
||||
}
|
||||
|
||||
fn build_stable_puzzle_work_ids(session_id: &str) -> (String, String) {
|
||||
let stable_suffix = session_id.strip_prefix("puzzle-session-").unwrap_or(session_id);
|
||||
let stable_suffix = session_id
|
||||
.strip_prefix("puzzle-session-")
|
||||
.unwrap_or(session_id);
|
||||
(
|
||||
format!("puzzle-work-{stable_suffix}"),
|
||||
format!("puzzle-profile-{stable_suffix}"),
|
||||
|
||||
@@ -1,6 +1,4 @@
|
||||
use module_puzzle::{
|
||||
PuzzleAgentStage, PuzzleAnchorPack, PuzzleAnchorStatus, empty_anchor_pack,
|
||||
};
|
||||
use module_puzzle::{PuzzleAgentStage, PuzzleAnchorPack, PuzzleAnchorStatus, empty_anchor_pack};
|
||||
use platform_llm::{LlmClient, LlmMessage, LlmStreamDelta, LlmTextRequest};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::{Value as JsonValue, json};
|
||||
@@ -157,10 +155,13 @@ where
|
||||
|
||||
Ok(PuzzleAgentTurnResult {
|
||||
assistant_reply_text: output.reply_text,
|
||||
stage: resolve_puzzle_agent_stage(output.progress_percent).as_str().to_string(),
|
||||
stage: resolve_puzzle_agent_stage(output.progress_percent)
|
||||
.as_str()
|
||||
.to_string(),
|
||||
progress_percent: output.progress_percent,
|
||||
anchor_pack_json: serde_json::to_string(&output.next_anchor_pack)
|
||||
.unwrap_or_else(|_| serde_json::to_string(&empty_anchor_pack()).unwrap_or_else(|_| "{}".to_string())),
|
||||
anchor_pack_json: serde_json::to_string(&output.next_anchor_pack).unwrap_or_else(|_| {
|
||||
serde_json::to_string(&empty_anchor_pack()).unwrap_or_else(|_| "{}".to_string())
|
||||
}),
|
||||
error_message: None,
|
||||
})
|
||||
}
|
||||
@@ -193,7 +194,9 @@ pub(crate) fn build_failed_finalize_record_input(
|
||||
updated_at_micros: i64,
|
||||
) -> PuzzleAgentMessageFinalizeRecordInput {
|
||||
let anchor_pack_json = serde_json::to_string(&map_record_anchor_pack(&session.anchor_pack))
|
||||
.unwrap_or_else(|_| serde_json::to_string(&empty_anchor_pack()).unwrap_or_else(|_| "{}".to_string()));
|
||||
.unwrap_or_else(|_| {
|
||||
serde_json::to_string(&empty_anchor_pack()).unwrap_or_else(|_| "{}".to_string())
|
||||
});
|
||||
PuzzleAgentMessageFinalizeRecordInput {
|
||||
session_id,
|
||||
owner_user_id,
|
||||
@@ -214,8 +217,9 @@ fn build_puzzle_agent_prompt(session: &PuzzleAgentSessionRecord) -> String {
|
||||
progress = session.progress_percent,
|
||||
anchor_pack = serde_json::to_string_pretty(&map_record_anchor_pack(&session.anchor_pack))
|
||||
.unwrap_or_else(|_| "{}".to_string()),
|
||||
chat_history = serde_json::to_string_pretty(&build_chat_history(session.messages.as_slice()))
|
||||
.unwrap_or_else(|_| "[]".to_string()),
|
||||
chat_history =
|
||||
serde_json::to_string_pretty(&build_chat_history(session.messages.as_slice()))
|
||||
.unwrap_or_else(|_| "[]".to_string()),
|
||||
contract = PUZZLE_AGENT_OUTPUT_CONTRACT,
|
||||
)
|
||||
}
|
||||
@@ -279,7 +283,9 @@ fn map_record_anchor_pack(record: &spacetime_client::PuzzleAnchorPackRecord) ->
|
||||
}
|
||||
}
|
||||
|
||||
fn map_record_anchor_item(record: &spacetime_client::PuzzleAnchorItemRecord) -> module_puzzle::PuzzleAnchorItem {
|
||||
fn map_record_anchor_item(
|
||||
record: &spacetime_client::PuzzleAnchorItemRecord,
|
||||
) -> module_puzzle::PuzzleAnchorItem {
|
||||
module_puzzle::PuzzleAnchorItem {
|
||||
key: record.key.clone(),
|
||||
label: record.label.clone(),
|
||||
|
||||
@@ -1,10 +1,7 @@
|
||||
use std::{error::Error, fmt, sync::Arc};
|
||||
|
||||
#[cfg(test)]
|
||||
use std::{
|
||||
collections::HashMap,
|
||||
sync::{Arc, Mutex},
|
||||
};
|
||||
use std::{collections::HashMap, sync::Mutex};
|
||||
|
||||
use module_ai::{AiTaskService, InMemoryAiTaskStore};
|
||||
use module_auth::{
|
||||
@@ -16,9 +13,8 @@ use module_runtime::RuntimeSnapshotRecord;
|
||||
use module_runtime::{SAVE_SNAPSHOT_VERSION, format_utc_micros};
|
||||
use platform_auth::{
|
||||
AccessTokenClaims, AccessTokenClaimsInput, AuthProvider, BindingStatus, JwtConfig, JwtError,
|
||||
RefreshCookieConfig, RefreshCookieError, RefreshCookieSameSite,
|
||||
sign_access_token, verify_access_token,
|
||||
SmsAuthConfig, SmsAuthProvider, SmsAuthProviderKind, SmsProviderError,
|
||||
RefreshCookieConfig, RefreshCookieError, RefreshCookieSameSite, SmsAuthConfig, SmsAuthProvider,
|
||||
SmsAuthProviderKind, SmsProviderError, sign_access_token, verify_access_token,
|
||||
};
|
||||
use platform_llm::{LlmClient, LlmConfig, LlmError};
|
||||
use platform_oss::{OssClient, OssConfig, OssError};
|
||||
@@ -57,6 +53,7 @@ pub struct AppState {
|
||||
test_runtime_snapshot_store: Arc<Mutex<HashMap<String, RuntimeSnapshotRecord>>>,
|
||||
}
|
||||
|
||||
// 后台管理员运行态独立于普通玩家登录体系,只从环境变量构造。
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct AdminRuntime {
|
||||
username: Arc<str>,
|
||||
@@ -565,6 +562,7 @@ fn build_llm_client(config: &AppConfig) -> Result<Option<LlmClient>, AppStateIni
|
||||
Ok(Some(LlmClient::new(llm_config)?))
|
||||
}
|
||||
|
||||
// 只有在用户名和密码都已配置时才启用后台,避免半配置状态暴露伪入口。
|
||||
fn build_admin_runtime(
|
||||
config: &AppConfig,
|
||||
base_jwt_config: &JwtConfig,
|
||||
|
||||
Reference in New Issue
Block a user