合并最新 master 分支
保留 master 的画布交互、Agent、VectorEngine 与原生 CI 更新。 解决角色动画测试和项目决策日志冲突,不恢复按帧数放大 BgFilter 超时的旧逻辑。 同步修正融合文档中的 BgFilter worker、N/Q、重试与父侧降级边界。
This commit is contained in:
@@ -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]
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user