合并最新 master 分支

保留 master 的画布交互、Agent、VectorEngine 与原生 CI 更新。
解决角色动画测试和项目决策日志冲突,不恢复按帧数放大 BgFilter 超时的旧逻辑。
同步修正融合文档中的 BgFilter worker、N/Q、重试与父侧降级边界。
This commit is contained in:
2026-07-21 14:37:15 +00:00
49 changed files with 2411 additions and 366 deletions
@@ -1,3 +1,6 @@
use std::future::IntoFuture;
use std::time::Duration;
use axum::extract::{Path, State};
use axum::{Extension, Json};
use module_editor_agent::{
@@ -75,8 +78,13 @@ use platform_editor_agent::agent::tools::generate_video::{
GenerateVideoTool, GenerateVideoToolArgs,
};
use shared_kernel::{build_prefixed_uuid_id, normalize_optional_string, normalize_required_string};
use tokio::time::{Instant, timeout};
const EDITOR_AGENT_CLIENT_MESSAGE_ID_MAX_CHARS: usize = 128;
const EDITOR_AGENT_PROMPT_TIMEOUT_MS: u64 = 18 * 60_000;
const EDITOR_AGENT_PROMPT_TIMEOUT_MESSAGE: &str = "规划总时长已达到 18 分钟安全上限";
const EDITOR_AGENT_LLM_UNAVAILABLE_MESSAGE: &str = "美术 Agent 服务暂不可用,请稍后重试";
const EDITOR_AGENT_PRICING_UNAVAILABLE_MESSAGE: &str = "美术 Agent 生成定价暂不可用,请稍后重试";
pub async fn editor_agent_message(
State(state): State<AppState>,
@@ -85,6 +93,7 @@ pub async fn editor_agent_message(
Extension(authenticated): Extension<AuthenticatedAccessToken>,
Json(payload): Json<EditorAgentMessageRequest>,
) -> Result<Json<EditorAgentMessageResponse>, AppError> {
let message_started_at = Instant::now();
let owner_user_id = authenticated.claims().user_id().to_string();
require_editor_agent_sidebar_enabled(&state, owner_user_id.as_str()).await?;
let client_message_id = validate_editor_agent_message_request(&payload)?;
@@ -211,12 +220,16 @@ pub async fn editor_agent_message(
// Build and run agent
let Some(llm_client) = state.editor_agent_llm_client() else {
tracing::warn!(
conversation_id = %conversation.conversation_id,
"美术 Agent LLM 客户端未配置"
);
return persist_editor_agent_planning_error(
&state,
&conversation,
&mut document,
conversation_summary,
"Editor Agent LLM client not configured",
EDITOR_AGENT_LLM_UNAVAILABLE_MESSAGE,
)
.await;
};
@@ -224,12 +237,17 @@ pub async fn editor_agent_message(
let pricing = match state.editor_generation_pricing().await {
Ok(pricing) => pricing,
Err(error) => {
tracing::warn!(
conversation_id = %conversation.conversation_id,
error = %error,
"读取美术 Agent 生成定价失败"
);
return persist_editor_agent_planning_error(
&state,
&conversation,
&mut document,
conversation_summary,
format!("failed to load editor generation pricing: {error}"),
EDITOR_AGENT_PRICING_UNAVAILABLE_MESSAGE,
)
.await;
}
@@ -264,9 +282,13 @@ pub async fn editor_agent_message(
.memory(memory)
.build();
let agent_result = agent
.prompt(LlmMessage::user(user_message.text.clone()))
.await;
let remaining_prompt_duration =
remaining_editor_agent_prompt_duration(message_started_at.elapsed());
let agent_result = run_editor_agent_prompt_with_timeout(
agent.prompt(LlmMessage::user(user_message.text.clone())),
remaining_prompt_duration,
)
.await;
let assistant_now = now_rfc3339();
@@ -303,6 +325,26 @@ pub async fn editor_agent_message(
}
}
fn remaining_editor_agent_prompt_duration(elapsed: Duration) -> Duration {
Duration::from_millis(EDITOR_AGENT_PROMPT_TIMEOUT_MS).saturating_sub(elapsed)
}
async fn run_editor_agent_prompt_with_timeout<F>(
future: F,
duration: Duration,
) -> Result<Vec<PromptOutput>, PromptError>
where
F: IntoFuture<Output = Result<Vec<PromptOutput>, PromptError>>,
{
timeout(duration, future.into_future())
.await
.unwrap_or_else(|_| {
Err(PromptError::CompletionError(
EDITOR_AGENT_PROMPT_TIMEOUT_MESSAGE.to_string(),
))
})
}
fn build_editor_agent_error_message(
message_id: usize,
error: impl std::fmt::Display,
@@ -512,6 +554,42 @@ mod tests {
assert_eq!(message.text, "ERROR planning failed");
assert!(message.tool_call.is_none());
}
#[test]
fn direct_planning_failures_use_user_facing_chinese_copy() {
assert_eq!(
build_editor_agent_error_message(1, EDITOR_AGENT_LLM_UNAVAILABLE_MESSAGE).text,
"ERROR 美术 Agent 服务暂不可用,请稍后重试"
);
assert_eq!(
build_editor_agent_error_message(2, EDITOR_AGENT_PRICING_UNAVAILABLE_MESSAGE).text,
"ERROR 美术 Agent 生成定价暂不可用,请稍后重试"
);
}
#[tokio::test]
async fn prompt_timeout_applies_to_the_whole_agent_run() {
let error = run_editor_agent_prompt_with_timeout(
std::future::pending::<Result<Vec<PromptOutput>, PromptError>>(),
Duration::from_millis(1),
)
.await
.expect_err("pending agent run should hit the prompt deadline");
assert_eq!(EDITOR_AGENT_PROMPT_TIMEOUT_MS, 1_080_000);
assert_eq!(
remaining_editor_agent_prompt_duration(Duration::from_secs(17 * 60)),
Duration::from_secs(60)
);
assert_eq!(
remaining_editor_agent_prompt_duration(Duration::from_secs(18 * 60)),
Duration::ZERO
);
assert_eq!(
error.to_string(),
"美术 Agent 规划失败:规划总时长已达到 18 分钟安全上限"
);
}
}
fn editor_agent_system_prompt() -> &'static str {
r#"
@@ -1702,8 +1702,8 @@ mod tests {
serde_json::from_str(&editor_generation_result_payload_json(&job, &response))
.expect("worker 结果应是合法 JSON");
assert_eq!(payload["sourceModule"], json!("puzzle"));
assert_eq!(payload["sourceEntityId"], json!("session-1:puzzle-level-1"));
assert_eq!(payload["sourceModule"], json!("editor"));
assert_eq!(payload["sourceEntityId"], json!("project-1"));
assert_eq!(
payload["warning"],
json!({
@@ -434,6 +434,9 @@ async fn map_platform_image_result(
) -> Result<OpenAiGeneratedImages, AppError> {
match result {
Ok(value) => {
for audit in &value.recovered_failure_audits {
record_openai_image_failure_audit_if_configured(settings, audit).await;
}
if let Some(state) = settings.external_api_audit_state.as_ref() {
record_external_generation_run_after_success(
state,
@@ -448,6 +451,7 @@ async fn map_platform_image_result(
Some(json!({
"imageCount": value.images.len(),
"actualPromptChars": value.actual_prompt.as_ref().map(|prompt| prompt.chars().count()),
"recoveredFailureCount": value.recovered_failure_audits.len(),
})),
)
.await;
@@ -455,6 +459,9 @@ async fn map_platform_image_result(
Ok(value)
}
Err(error) => {
for audit in error.recovered_failure_audits() {
record_openai_image_failure_audit_if_configured(settings, audit).await;
}
if let Some(state) = settings.external_api_audit_state.as_ref() {
record_external_generation_run_after_success(
state,
@@ -478,14 +485,21 @@ async fn map_platform_image_result(
pub(crate) async fn record_openai_image_failure_if_configured(
settings: &OpenAiImageSettings,
error: &PlatformImageError,
) {
let Some(audit) = error.audit() else {
return;
};
record_openai_image_failure_audit_if_configured(settings, audit).await;
}
async fn record_openai_image_failure_audit_if_configured(
settings: &OpenAiImageSettings,
audit: &platform_image::PlatformImageFailureAudit,
) {
let Some(state) = settings.external_api_audit_state.as_ref() else {
return;
};
let Some(draft) = build_openai_image_failure_audit_draft(error) else {
return;
};
let draft = draft
let draft = build_external_api_failure_draft_from_platform_image_audit(audit)
.with_user_id(settings.external_api_audit_user_id.clone())
.with_profile_id(settings.external_api_audit_profile_id.clone())
.with_request_id(settings.external_api_audit_request_id.clone());
@@ -501,6 +515,7 @@ pub(crate) fn build_openai_image_failure_audit_draft(
}
pub(crate) fn map_platform_image_error(error: PlatformImageError) -> AppError {
let error = error.into_final_error();
let status = match error.status_hint() {
PlatformImageStatusHint::BadRequest => StatusCode::BAD_REQUEST,
PlatformImageStatusHint::ServiceUnavailable => StatusCode::SERVICE_UNAVAILABLE,
@@ -545,6 +560,9 @@ pub(crate) fn map_platform_image_error(error: PlatformImageError) -> AppError {
details["rawExcerpt"] = json!(raw_excerpt);
}
PlatformImageError::MissingImage { .. } => {}
PlatformImageError::FallbackFailed { .. } => {
unreachable!("fallback wrapper should be removed before HTTP error mapping")
}
}
if let Some(audit) = error.audit() {
@@ -2682,7 +2682,7 @@ mod tests {
}
#[tokio::test]
async fn profile_play_stats_requires_authentication() {
async fn retired_profile_play_stats_route_is_not_mounted() {
let app = build_router(AppState::new(AppConfig::default()).expect("state should build"));
let response = app
@@ -2696,7 +2696,7 @@ mod tests {
.await
.expect("request should succeed");
assert_eq!(response.status(), StatusCode::UNAUTHORIZED);
assert_eq!(response.status(), StatusCode::NOT_FOUND);
}
#[tokio::test]
+10 -2
View File
@@ -48,6 +48,8 @@ use crate::work_author::{
};
const ADMIN_ROLE: &str = "admin";
const EDITOR_AGENT_LLM_MAX_RETRIES: u32 = 1;
const EDITOR_AGENT_LLM_MAX_RETRY_BACKOFF_MS: u64 = 60_000;
pub(crate) const CHARACTER_ANIMATION_OSS_MAX_CONCURRENCY: usize = 8;
pub(crate) const BGFILTER_IMAGE_VALIDATION_MAX_CONCURRENCY: usize = 4;
@@ -2128,8 +2130,10 @@ fn build_editor_agent_llm_client(
api_key.to_string(),
platform_llm::EDITOR_AGENT_GPT5_MODEL.to_string(),
config.llm_request_timeout_ms,
0,
config.llm_retry_backoff_ms,
config.llm_max_retries.min(EDITOR_AGENT_LLM_MAX_RETRIES),
config
.llm_retry_backoff_ms
.min(EDITOR_AGENT_LLM_MAX_RETRY_BACKOFF_MS),
)?;
Ok(Some(LlmClient::new(llm_config)?))
@@ -2422,6 +2426,8 @@ mod tests {
fn app_state_builds_editor_agent_llm_client_from_vector_engine_settings() {
let mut config = AppConfig::default();
config.llm_api_key = None;
config.llm_max_retries = 2;
config.llm_retry_backoff_ms = 120_000;
config.vector_engine_base_url = "https://api.vectorengine.test".to_string();
config.vector_engine_api_key = Some("ve-key".to_string());
@@ -2439,6 +2445,8 @@ mod tests {
"https://api.vectorengine.test/v1/chat/completions"
);
assert!(!client.config().official_fallback());
assert_eq!(client.config().max_retries(), 1);
assert_eq!(client.config().retry_backoff_ms(), 60_000);
}
fn test_feature_gate(gate_key: &str) -> module_runtime::FeatureGateConfigSnapshot {