Merge remote-tracking branch 'origin/master' into editor-agent-refactored

# Conflicts:
#	docs/technical/【前端架构】图片画布编辑器MVP接入方案-2026-06-11.md
#	docs/technical/【后端架构】外部生成Worker化方案-2026-06-03.md
#	docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md
#	server-rs/crates/api-server/src/editor_agent.rs
#	server-rs/crates/api-server/src/external_generation_worker.rs
#	server-rs/crates/spacetime-client/src/lib.rs
#	server-rs/crates/spacetime-client/src/mapper.rs
#	server-rs/crates/spacetime-client/src/mapper/external_generation.rs
This commit is contained in:
2026-07-16 19:32:14 +08:00
57 changed files with 6201 additions and 853 deletions
File diff suppressed because it is too large Load Diff
+1 -73
View File
@@ -17,8 +17,6 @@ const DEFAULT_EXTERNAL_GENERATION_WORKER_LEASE_SECONDS: u64 = 600;
const DEFAULT_EXTERNAL_GENERATION_WORKER_JOB_TIMEOUT_SECONDS: u64 = 900;
const DEFAULT_EXTERNAL_GENERATION_WORKER_LONG_JOB_TIMEOUT_SECONDS: u64 = 1_800;
pub(crate) const DEFAULT_VECTOR_ENGINE_IMAGE_REQUEST_TIMEOUT_MS: u64 = 1_000_000;
const DEFAULT_EDITOR_BACKGROUND_REMOVAL_BASE_URL: &str = "http://58.87.105.82";
const DEFAULT_EDITOR_BACKGROUND_REMOVAL_REQUEST_TIMEOUT_MS: u64 = 120_000;
const DEFAULT_EDITOR_BGFILTER_BASE_URL: &str = "http://58.87.105.82/bgfilter";
const DEFAULT_EDITOR_BGFILTER_REQUEST_TIMEOUT_MS: u64 = 180_000;
const DEFAULT_EDITOR_BGFILTER_CIRCUIT_FAILURE_THRESHOLD: u32 = 3;
@@ -64,9 +62,6 @@ pub struct AppConfig {
pub wallet_refund_outbox_flush_interval: Duration,
pub wallet_refund_outbox_max_bytes: u64,
pub editor_generation_pricing_override_path: PathBuf,
pub editor_background_removal_base_url: String,
pub editor_background_removal_token: Option<String>,
pub editor_background_removal_request_timeout_ms: u64,
pub editor_bgfilter_base_url: String,
pub editor_bgfilter_token: Option<String>,
pub editor_bgfilter_request_timeout_ms: u64,
@@ -312,11 +307,6 @@ impl Default for AppConfig {
wallet_refund_outbox_max_bytes: 64 * 1024 * 1024,
editor_generation_pricing_override_path:
crate::editor_generation_config::default_editor_generation_pricing_override_path(),
editor_background_removal_base_url: DEFAULT_EDITOR_BACKGROUND_REMOVAL_BASE_URL
.to_string(),
editor_background_removal_token: None,
editor_background_removal_request_timeout_ms:
DEFAULT_EDITOR_BACKGROUND_REMOVAL_REQUEST_TIMEOUT_MS,
editor_bgfilter_base_url: DEFAULT_EDITOR_BGFILTER_BASE_URL.to_string(),
editor_bgfilter_token: None,
editor_bgfilter_request_timeout_ms: DEFAULT_EDITOR_BGFILTER_REQUEST_TIMEOUT_MS,
@@ -515,18 +505,6 @@ impl AppConfig {
{
config.editor_generation_pricing_override_path = PathBuf::from(pricing_override_path);
}
if let Some(base_url) =
read_first_non_empty_env(&["GENARRATIVE_EDITOR_BACKGROUND_REMOVAL_BASE_URL"])
{
config.editor_background_removal_base_url = base_url;
}
config.editor_background_removal_token =
read_first_non_empty_env(&["GENARRATIVE_EDITOR_BACKGROUND_REMOVAL_TOKEN"]);
if let Some(timeout_ms) = read_first_positive_u64_env(&[
"GENARRATIVE_EDITOR_BACKGROUND_REMOVAL_REQUEST_TIMEOUT_MS",
]) {
config.editor_background_removal_request_timeout_ms = timeout_ms;
}
if let Some(base_url) = read_first_non_empty_env(&["GENARRATIVE_EDITOR_BGFILTER_BASE_URL"])
{
config.editor_bgfilter_base_url = base_url;
@@ -1612,8 +1590,7 @@ fn parse_positive_u16(raw: &str) -> Option<u16> {
#[cfg(test)]
mod tests {
use super::{
AppConfig, DEFAULT_EDITOR_BACKGROUND_REMOVAL_BASE_URL,
DEFAULT_EDITOR_BACKGROUND_REMOVAL_REQUEST_TIMEOUT_MS, DEFAULT_EDITOR_BGFILTER_BASE_URL,
AppConfig, DEFAULT_EDITOR_BGFILTER_BASE_URL,
DEFAULT_EDITOR_BGFILTER_CIRCUIT_COOLDOWN_SECONDS,
DEFAULT_EDITOR_BGFILTER_CIRCUIT_FAILURE_THRESHOLD,
DEFAULT_EDITOR_BGFILTER_REQUEST_TIMEOUT_MS,
@@ -1638,15 +1615,6 @@ mod tests {
assert!(config.llm_base_url.is_empty());
// assert!(config.apimart_base_url.is_empty());
assert!(config.vector_engine_base_url.is_empty());
assert_eq!(
config.editor_background_removal_base_url,
DEFAULT_EDITOR_BACKGROUND_REMOVAL_BASE_URL
);
assert_eq!(
config.editor_background_removal_request_timeout_ms,
DEFAULT_EDITOR_BACKGROUND_REMOVAL_REQUEST_TIMEOUT_MS
);
assert!(config.editor_background_removal_token.is_none());
assert_eq!(
config.editor_bgfilter_base_url,
DEFAULT_EDITOR_BGFILTER_BASE_URL
@@ -2387,46 +2355,6 @@ mod tests {
}
}
#[test]
fn from_env_reads_editor_background_removal_settings() {
let _guard = ENV_LOCK
.get_or_init(|| Mutex::new(()))
.lock()
.expect("env lock should not poison");
unsafe {
std::env::remove_var("GENARRATIVE_EDITOR_BACKGROUND_REMOVAL_BASE_URL");
std::env::remove_var("GENARRATIVE_EDITOR_BACKGROUND_REMOVAL_TOKEN");
std::env::remove_var("GENARRATIVE_EDITOR_BACKGROUND_REMOVAL_REQUEST_TIMEOUT_MS");
std::env::set_var(
"GENARRATIVE_EDITOR_BACKGROUND_REMOVAL_BASE_URL",
"http://10.0.0.12:8090",
);
std::env::set_var("GENARRATIVE_EDITOR_BACKGROUND_REMOVAL_TOKEN", "token-1");
std::env::set_var(
"GENARRATIVE_EDITOR_BACKGROUND_REMOVAL_REQUEST_TIMEOUT_MS",
"90000",
);
}
let config = AppConfig::from_env();
assert_eq!(
config.editor_background_removal_base_url,
"http://10.0.0.12:8090"
);
assert_eq!(
config.editor_background_removal_token.as_deref(),
Some("token-1")
);
assert_eq!(config.editor_background_removal_request_timeout_ms, 90_000);
unsafe {
std::env::remove_var("GENARRATIVE_EDITOR_BACKGROUND_REMOVAL_BASE_URL");
std::env::remove_var("GENARRATIVE_EDITOR_BACKGROUND_REMOVAL_TOKEN");
std::env::remove_var("GENARRATIVE_EDITOR_BACKGROUND_REMOVAL_REQUEST_TIMEOUT_MS");
}
}
#[test]
fn from_env_reads_editor_bgfilter_settings_and_reuses_background_token() {
let _guard = ENV_LOCK
File diff suppressed because it is too large Load Diff
@@ -684,6 +684,7 @@ pub async fn generate_external_editor_character_animation(
request_context,
principal.owner_user_id().to_string(),
payload,
None,
)
.await
}
@@ -750,6 +751,7 @@ fn editor_generation_caller(
owner_user_id: principal.owner_user_id().to_string(),
audit_subject_user_id: Some(principal.owner_user_id().to_string()),
audit_project_id: normalize_optional_string(project_id),
phase_reporter: None,
}
}
@@ -882,6 +884,20 @@ mod tests {
["sliceWarning"]["anyOf"][0]["$ref"],
"#/components/schemas/EditorIconSpritesheetSliceWarning"
);
assert_eq!(
parsed["components"]["schemas"]["EditorImageGenerationResponse"]["properties"]
["warning"]["anyOf"][0]["$ref"],
"#/components/schemas/EditorGenerationWarning"
);
assert_eq!(
parsed["components"]["schemas"]["EditorIconSpritesheetGenerationResponse"]["properties"]
["warning"]["anyOf"][0]["$ref"],
"#/components/schemas/EditorGenerationWarning"
);
assert_eq!(
parsed["components"]["schemas"]["EditorGenerationWarning"]["required"],
json!(["code", "reason"])
);
assert!(
parsed["paths"]
.get("/api/external/v1/editor/ui-designs/assets/extractions")
@@ -172,6 +172,9 @@ fn map_external_generation_job_status(
100,
None,
),
"running" if job.phase.as_deref() == Some("processing") => {
(ExternalGenerationJobStatus::Running, "正在处理。", 70, None)
}
"running" => (ExternalGenerationJobStatus::Running, "正在生成。", 35, None),
"failed" => (
ExternalGenerationJobStatus::Failed,
@@ -314,6 +317,7 @@ mod tests {
refund_ledger_id: None,
notification_acknowledged_at: None,
notification_acknowledged_at_micros: None,
phase: None,
warning_message: Some("连通域数量不足".to_string()),
};
let status = map_external_generation_job_status_detail(summary.clone());
@@ -326,4 +330,66 @@ mod tests {
assert_eq!(task.warning.as_deref(), Some("连通域数量不足"));
assert!(task.error.is_none());
}
#[test]
fn maps_running_processing_phase_from_backend_projection() {
let status = map_external_generation_job_status(ExternalGenerationJobSummaryRecord {
job_id: "task-processing".to_string(),
job_kind: "editor_character_animation_generation".to_string(),
owner_user_id: "user-1".to_string(),
source_module: "editor-canvas".to_string(),
source_entity_id: "project-1".to_string(),
request_label: "角色动作生成".to_string(),
request_prompt: None,
status: "running".to_string(),
last_error_message: None,
created_at: "2026-07-13T08:00:00Z".to_string(),
started_at: Some("2026-07-13T08:00:01Z".to_string()),
completed_at: None,
updated_at: "2026-07-13T08:00:10Z".to_string(),
updated_at_micros: 1_000,
price_mud_points: 4,
refund_ledger_id: None,
notification_acknowledged_at: None,
notification_acknowledged_at_micros: None,
phase: Some("processing".to_string()),
warning_message: None,
});
assert_eq!(status.status, ExternalGenerationJobStatus::Running);
assert_eq!(status.phase_detail, "正在处理。");
assert_eq!(status.progress, 70);
}
#[test]
fn maps_legacy_running_job_without_phase_as_generating() {
let mut job = ExternalGenerationJobSummaryRecord {
job_id: "task-legacy".to_string(),
job_kind: "editor_image_generation".to_string(),
owner_user_id: "user-1".to_string(),
source_module: "editor-canvas".to_string(),
source_entity_id: "project-1".to_string(),
request_label: "图片生成".to_string(),
request_prompt: None,
status: "running".to_string(),
last_error_message: None,
created_at: "2026-07-13T08:00:00Z".to_string(),
started_at: Some("2026-07-13T08:00:01Z".to_string()),
completed_at: None,
updated_at: "2026-07-13T08:00:10Z".to_string(),
updated_at_micros: 1_000,
price_mud_points: 4,
refund_ledger_id: None,
notification_acknowledged_at: None,
notification_acknowledged_at_micros: None,
phase: None,
warning_message: None,
};
let legacy = map_external_generation_job_status(job.clone());
assert_eq!(legacy.phase_detail, "正在生成。");
job.phase = Some("generating".to_string());
let generating = map_external_generation_job_status(job);
assert_eq!(generating.phase_detail, "正在生成。");
}
}
File diff suppressed because it is too large Load Diff
+26
View File
@@ -276,6 +276,7 @@ pub struct AppStateInner {
llm_client: Option<LlmClient>,
creative_agent_gpt5_client: Option<LlmClient>,
matting_client: Option<MattingClient>,
editor_bgfilter_http_client: reqwest::Client,
creative_agent_executor: Arc<MockLangChainRustAgentExecutor>,
// Phase 1 任务 E 的 creative session facade 暂存在 api-server。
// creative_agent_* 表由任务 D 收口后,这里只保留读写 facade。
@@ -515,6 +516,7 @@ impl AppState {
let llm_client = build_llm_client(&config)?;
let creative_agent_gpt5_client = build_creative_agent_gpt5_client(&config)?;
let matting_client = build_matting_client(&config)?;
let editor_bgfilter_http_client = build_editor_bgfilter_http_client(&config)?;
let http_request_permit_pools = HttpRequestPermitPools::from_config(&config);
let (profile_recharge_order_updates, _) = broadcast::channel(128);
@@ -555,6 +557,7 @@ impl AppState {
llm_client,
creative_agent_gpt5_client,
matting_client,
editor_bgfilter_http_client,
creative_agent_executor: Arc::new(MockLangChainRustAgentExecutor),
creative_agent_sessions: Arc::new(Mutex::new(HashMap::new())),
profile_recharge_order_updates,
@@ -1255,6 +1258,10 @@ impl AppState {
self.matting_client.as_ref()
}
pub fn editor_bgfilter_http_client(&self) -> &reqwest::Client {
&self.editor_bgfilter_http_client
}
pub fn creative_agent_executor(&self) -> Arc<MockLangChainRustAgentExecutor> {
self.creative_agent_executor.clone()
}
@@ -1954,6 +1961,25 @@ fn build_matting_client(config: &AppConfig) -> Result<Option<MattingClient>, App
.map_err(|error| AppStateInitError::DependencyUnavailable(error.to_string()))
}
fn build_editor_bgfilter_http_client(
config: &AppConfig,
) -> Result<reqwest::Client, AppStateInitError> {
reqwest::Client::builder()
.timeout(std::time::Duration::from_millis(
config.editor_bgfilter_request_timeout_ms.max(1),
))
.connect_timeout(std::time::Duration::from_secs(30))
.pool_idle_timeout(std::time::Duration::from_secs(300))
.pool_max_idle_per_host(64)
.tcp_keepalive(std::time::Duration::from_secs(60))
.build()
.map_err(|error| {
AppStateInitError::DependencyUnavailable(format!(
"构建共享 BgFilter HTTP 客户端失败:{error}"
))
})
}
fn build_wechat_client(config: &AppConfig) -> WechatClient {
WechatClient::new(WechatConfig {
app_id: config.wechat_mini_program_app_id.clone(),