合并最新 master 并解决画布共享冲突

同步 origin/master 的场景、音效、运行时与后端原子提交改动

保留共享画布包导出并补齐场景、生成配方和历史动作类型

融合清单刷新测试、幂等请求、生成合同校验与项目记忆文档
This commit is contained in:
2026-08-10 11:34:38 +08:00
358 changed files with 51496 additions and 9219 deletions
+63
View File
@@ -1588,6 +1588,15 @@ version = "1.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "34aa73646ffb006b8f5147f3dc182bd4bcb190227ce861fc4a4844bf8e3cb2c0"
[[package]]
name = "encoding_rs"
version = "0.8.35"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "75030f3c4f45dafd7586dd6780965a8c7e8e285a5ecb86713e63a79c5b2766f3"
dependencies = [
"cfg-if",
]
[[package]]
name = "enum-as-inner"
version = "0.6.1"
@@ -4065,9 +4074,13 @@ dependencies = [
name = "platform-audio"
version = "0.1.0"
dependencies = [
"base64 0.22.1",
"bytes",
"platform-oss",
"regex",
"reqwest",
"serde_json",
"symphonia",
"tokio",
"tracing",
"urlencoding",
@@ -5272,6 +5285,7 @@ version = "0.1.0"
dependencies = [
"serde",
"serde_json",
"sha2",
]
[[package]]
@@ -5850,6 +5864,55 @@ version = "2.6.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292"
[[package]]
name = "symphonia"
version = "0.5.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5773a4c030a19d9bfaa090f49746ff35c75dfddfa700df7a5939d5e076a57039"
dependencies = [
"lazy_static",
"symphonia-bundle-mp3",
"symphonia-core",
"symphonia-metadata",
]
[[package]]
name = "symphonia-bundle-mp3"
version = "0.5.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4872dd6bb56bf5eac799e3e957aa1981086c3e613b27e0ac23b176054f7c57ed"
dependencies = [
"lazy_static",
"log",
"symphonia-core",
"symphonia-metadata",
]
[[package]]
name = "symphonia-core"
version = "0.5.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ea00cc4f79b7f6bb7ff87eddc065a1066f3a43fe1875979056672c9ef948c2af"
dependencies = [
"arrayvec",
"bitflags 1.3.2",
"bytemuck",
"lazy_static",
"log",
]
[[package]]
name = "symphonia-metadata"
version = "0.5.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "36306ff42b9ffe6e5afc99d49e121e0bd62fe79b9db7b9681d48e29fa19e6b16"
dependencies = [
"encoding_rs",
"lazy_static",
"log",
"symphonia-core",
]
[[package]]
name = "syn"
version = "1.0.109"
+2
View File
@@ -112,6 +112,7 @@ pingora-http = { version = "0.8.1", default-features = false }
pingora-proxy = { version = "0.8.1", default-features = false }
rand_core = "0.6"
reqwest = { version = "0.12", default-features = false }
regex = "1"
rmcp = { version = "=2.2.0", default-features = false }
ring = "0.17"
serde = { version = "1", features = ["derive"] }
@@ -120,6 +121,7 @@ serde_urlencoded = "0.7"
sha1 = "0.10"
sha2 = "0.10"
socket2 = "0.6"
symphonia = { version = "0.5", default-features = false, features = ["mp3"] }
spacetimedb = "=2.7.0"
spacetimedb-sdk = "=2.7.0"
spacetimedb-lib = { version = "=2.7.0", default-features = false }
@@ -67,6 +67,10 @@
"unit": "perGeneration",
"price": 5
},
"eleven_text_to_sound_v2": {
"unit": "perGeneration",
"price": 5
},
"chirp-v5": {
"unit": "perGeneration",
"price": 12
+209
View File
@@ -1912,6 +1912,206 @@ mod tests {
);
}
#[tokio::test]
async fn editor_image_generation_rejects_scene_contract_bypasses() {
let state = AppState::new(AppConfig {
external_generation_mode: ExternalGenerationMode::Queue,
..AppConfig::default()
})
.expect("state should build");
let seed_user = seed_phone_user_with_password(&state, "13800138232", TEST_PASSWORD).await;
let token = sign_test_user_token(&state, &seed_user, "sess_editor_scene_bypass");
let app = build_router(state);
let requests = [
(
"scene kind",
serde_json::json!({
"prompt": "绕过后端场景 Prompt 组装",
"kind": "scene",
}),
),
(
"scene asset kind",
serde_json::json!({
"prompt": "把普通图片伪装成正式场景产物",
"assetKind": "scene",
}),
),
];
for (case_name, request_body) in requests {
let response = app
.clone()
.oneshot(
Request::builder()
.method("POST")
.uri("/api/editor/images/generations")
.header("authorization", format!("Bearer {token}"))
.header("content-type", "application/json")
.body(Body::from(request_body.to_string()))
.expect("request should build"),
)
.await
.expect("request should succeed");
assert_eq!(
response.status(),
StatusCode::BAD_REQUEST,
"{case_name} must not bypass the dedicated scene contract"
);
let body = response
.into_body()
.collect()
.await
.expect("response body should collect")
.to_bytes();
let body_text = String::from_utf8_lossy(&body);
assert!(
body_text.contains("/api/editor/scenes/generations"),
"{case_name} should point callers to the scene endpoint: {body_text}"
);
}
}
#[tokio::test]
async fn editor_scene_generation_requires_bearer_auth() {
let app = build_router(AppState::new(AppConfig::default()).expect("state should build"));
let request_body = serde_json::json!({
"sceneContent": "雨夜中的海边车站",
"stylePreset": "anime",
})
.to_string();
let response = app
.oneshot(
Request::builder()
.method("POST")
.uri("/api/editor/scenes/generations")
.header("content-type", "application/json")
.body(Body::from(request_body))
.expect("request should build"),
)
.await
.expect("request should succeed");
assert_eq!(response.status(), StatusCode::UNAUTHORIZED);
}
#[tokio::test]
async fn editor_scene_generation_rejects_invalid_required_inputs() {
let state = AppState::new(AppConfig {
external_generation_mode: ExternalGenerationMode::Queue,
..AppConfig::default()
})
.expect("state should build");
let seed_user = seed_phone_user_with_password(&state, "13800138230", TEST_PASSWORD).await;
let token = sign_test_user_token(&state, &seed_user, "sess_editor_scene_validation");
let app = build_router(state);
let requests = [
(
"empty sceneContent",
serde_json::json!({
"sceneContent": " ",
"stylePreset": "anime",
}),
"sceneContent",
),
(
"unknown stylePreset",
serde_json::json!({
"sceneContent": "雨夜中的海边车站",
"stylePreset": "unknown",
}),
"stylePreset",
),
(
"missing customStyle",
serde_json::json!({
"sceneContent": "雨夜中的海边车站",
"stylePreset": "custom",
}),
"customStyle",
),
];
for (case_name, request_body, expected_message) in requests {
let response = app
.clone()
.oneshot(
Request::builder()
.method("POST")
.uri("/api/editor/scenes/generations")
.header("authorization", format!("Bearer {token}"))
.header("content-type", "application/json")
.body(Body::from(request_body.to_string()))
.expect("request should build"),
)
.await
.expect("request should succeed");
assert_eq!(
response.status(),
StatusCode::BAD_REQUEST,
"{case_name} should return 400"
);
let body = response
.into_body()
.collect()
.await
.expect("response body should collect")
.to_bytes();
let body_text = String::from_utf8_lossy(&body);
assert!(
body_text.contains(expected_message),
"{case_name} should report {expected_message}: {body_text}"
);
}
}
#[tokio::test]
async fn editor_scene_generation_rejects_inline_data_url_before_queueing() {
let state = AppState::new(AppConfig {
external_generation_mode: ExternalGenerationMode::Queue,
..AppConfig::default()
})
.expect("state should build");
let seed_user = seed_phone_user_with_password(&state, "13800138231", TEST_PASSWORD).await;
let token = sign_test_user_token(&state, &seed_user, "sess_editor_scene_reference");
let app = build_router(state);
let request_body = serde_json::json!({
"sceneContent": "雨夜中的海边车站",
"stylePreset": "anime",
"referenceImageSrcs": ["data:image/png;base64,AAAA"],
})
.to_string();
let response = app
.oneshot(
Request::builder()
.method("POST")
.uri("/api/editor/scenes/generations")
.header("authorization", format!("Bearer {token}"))
.header("content-type", "application/json")
.body(Body::from(request_body))
.expect("request should build"),
)
.await
.expect("request should succeed");
assert_eq!(response.status(), StatusCode::BAD_REQUEST);
let body = response
.into_body()
.collect()
.await
.expect("response body should collect")
.to_bytes();
let body_text = String::from_utf8_lossy(&body);
assert!(
body_text.contains("先上传 OSS"),
"handler should reject inline editor scene references: {body_text}"
);
}
#[tokio::test]
async fn editor_pixel_art_style_wrong_types_return_bad_request() {
let state = AppState::new(AppConfig {
@@ -5194,6 +5394,10 @@ mod tests {
payload["models"]["audio1.0"]["price"],
Value::Number(5.into())
);
assert_eq!(
payload["models"]["eleven_text_to_sound_v2"]["price"],
Value::Number(5.into())
);
assert_eq!(
payload["models"]["chirp-v5"]["price"],
Value::Number(12.into())
@@ -5260,6 +5464,7 @@ mod tests {
"prices": { "480p": 11, "720p": 22, "1080p": 44 }
},
"audio1.0": { "unit": "perGeneration", "price": 15 },
"eleven_text_to_sound_v2": { "unit": "perGeneration", "price": 16 },
"chirp-v5": { "unit": "perGeneration", "price": 9 }
}
})
@@ -5298,6 +5503,10 @@ mod tests {
payload["models"]["audio1.0"]["unit"],
Value::String("perGeneration".to_string())
);
assert_eq!(
payload["models"]["eleven_text_to_sound_v2"]["price"],
Value::Number(16.into())
);
assert_eq!(
payload["models"]["seedance2.0"]["prices"]["720p"],
Value::Number(26.into())
@@ -1,4 +1,7 @@
use std::future::Future;
use std::{
cell::{Cell, RefCell},
future::Future,
};
use axum::http::StatusCode;
use serde_json::json;
@@ -35,6 +38,78 @@ struct AssetOperationBillingPlan {
tokio::task_local! {
static CURRENT_EXTERNAL_GENERATION_BILLING_CONTEXT: ExternalGenerationBillingContext;
static DEFERRED_ASSET_OPERATION_REFUNDS: RefCell<Vec<AssetOperationRefundOnDrop>>;
static EDITOR_GENERATION_COMMIT_DISPATCHED: Cell<bool>;
static EDITOR_GENERATION_COMMIT_OUTCOME_UNKNOWN: Cell<bool>;
}
pub(crate) async fn with_editor_generation_durable_billing_boundary<T, E, Fut>(
future: Fut,
) -> Result<T, E>
where
Fut: Future<Output = Result<T, E>>,
{
EDITOR_GENERATION_COMMIT_OUTCOME_UNKNOWN
.scope(Cell::new(false), async {
EDITOR_GENERATION_COMMIT_DISPATCHED
.scope(Cell::new(false), async {
DEFERRED_ASSET_OPERATION_REFUNDS
.scope(RefCell::new(Vec::new()), async {
let result = future.await;
let outcome_unknown =
EDITOR_GENERATION_COMMIT_OUTCOME_UNKNOWN.with(Cell::get);
let refunds = DEFERRED_ASSET_OPERATION_REFUNDS.with(RefCell::take);
match &result {
Ok(_) => {
for mut refund in refunds {
refund.disarm();
}
}
Err(_) if outcome_unknown => {
// 远端提交可能已经成功;不能把正式结果与扣费同时保留后再退款。
for mut refund in refunds {
refund.disarm();
}
}
Err(_) => {
for mut refund in refunds {
refund.refund_now().await;
}
}
}
result
})
.await
})
.await
})
.await
}
pub(crate) fn begin_editor_generation_commit_attempt() {
let _ = EDITOR_GENERATION_COMMIT_DISPATCHED.try_with(|dispatched| dispatched.set(false));
}
pub(crate) fn mark_editor_generation_commit_dispatched() {
let _ = EDITOR_GENERATION_COMMIT_DISPATCHED.try_with(|dispatched| dispatched.set(true));
let _ = EDITOR_GENERATION_COMMIT_OUTCOME_UNKNOWN.try_with(|unknown| unknown.set(true));
}
pub(crate) fn editor_generation_commit_attempt_was_dispatched() -> bool {
EDITOR_GENERATION_COMMIT_DISPATCHED
.try_with(Cell::get)
.unwrap_or(false)
}
pub(crate) fn mark_editor_generation_commit_outcome_confirmed() {
let _ = EDITOR_GENERATION_COMMIT_DISPATCHED.try_with(|dispatched| dispatched.set(false));
let _ = EDITOR_GENERATION_COMMIT_OUTCOME_UNKNOWN.try_with(|unknown| unknown.set(false));
}
fn editor_generation_commit_outcome_is_unknown() -> bool {
EDITOR_GENERATION_COMMIT_OUTCOME_UNKNOWN
.try_with(Cell::get)
.unwrap_or(false)
}
#[cfg(test)]
@@ -145,7 +220,20 @@ where
);
match operation.await {
Ok(value) => {
refund_on_drop.disarm();
let mut deferred_refund = Some(refund_on_drop);
let deferred = DEFERRED_ASSET_OPERATION_REFUNDS
.try_with(|refunds| {
refunds
.borrow_mut()
.push(deferred_refund.take().expect("deferred refund guard"));
})
.is_ok();
if !deferred {
deferred_refund
.as_mut()
.expect("immediate refund guard")
.disarm();
}
Ok(value)
}
Err(error) => {
@@ -225,6 +313,16 @@ impl Drop for AssetOperationRefundOnDrop {
if !self.active {
return;
}
if editor_generation_commit_outcome_is_unknown() {
tracing::warn!(
owner_user_id = self.owner_user_id,
asset_kind = self.asset_kind,
asset_id = self.asset_id,
"编辑器生成提交结果未知,取消请求时保留扣费并等待 durable receipt 对账"
);
self.active = false;
return;
}
let state = self.state.clone();
let owner_user_id = self.owner_user_id.clone();
let asset_kind = self.asset_kind.clone();
@@ -826,4 +924,87 @@ mod tests {
module_runtime::PROFILE_INVITE_CODE_METADATA_DEFAULT_JSON
);
}
#[test]
fn every_provider_inline_route_defers_billing_until_durable_completion() {
for (source, expected_calls) in [
(include_str!("editor_project.rs"), 4),
(include_str!("editor_project_icon.rs"), 2),
(include_str!("character_animation_assets.rs"), 2),
(
include_str!("vector_engine_audio_generation/generation.rs"),
2,
),
] {
let production_source = source
.rsplit_once("\n#[cfg(test)]\nmod tests")
.map(|(production_source, _)| production_source)
.unwrap_or(source);
assert_eq!(
production_source
.matches("with_editor_generation_durable_billing_boundary(")
.count(),
expected_calls,
);
}
let editor_source = include_str!("editor_project.rs");
let scene_route = editor_source
.split_once("pub async fn generate_editor_scene(")
.and_then(|(_, tail)| {
tail.split_once("pub async fn generate_editor_image(")
.map(|(body, _)| body)
})
.expect("scene route");
let inline_operation = scene_route
.find(".with_inline_operation(")
.expect("scene inline operation");
let billing_boundary = scene_route
.find("with_editor_generation_durable_billing_boundary(")
.expect("scene durable billing boundary");
let provider_execution = scene_route
.rfind("generate_editor_image_for_owner(")
.expect("scene provider execution");
assert!(inline_operation < billing_boundary);
assert!(billing_boundary < provider_execution);
}
#[test]
fn atomic_commit_dispatch_marks_unknown_before_the_cancellable_result_wait() {
let editor_source = include_str!("editor_project.rs");
let editor_body = editor_source
.split_once("async fn persist_editor_generation_prepared_commit(")
.and_then(|(_, tail)| {
tail.split_once("async fn call_with_editor_generation_unknown_result_replay")
.map(|(body, _)| body)
})
.expect("prepared commit helper");
assert!(editor_body.contains("persist_editor_generation_result_with_dispatch"));
assert!(editor_body.contains("mark_editor_generation_commit_dispatched"));
let client_source = include_str!("../../spacetime-client/src/editor_project.rs");
let client_body = client_source
.split_once("pub async fn persist_editor_generation_result_with_dispatch")
.and_then(|(_, tail)| {
tail.split_once("pub async fn create_editor_project")
.map(|(body, _)| body)
})
.expect("dispatch-aware client procedure");
let mark = client_body.find("on_dispatch();").expect("dispatch marker");
let procedure = client_body
.find(".persist_editor_generation_result_and_return_then(")
.expect("procedure dispatch");
assert!(mark < procedure);
let billing_source = include_str!("asset_billing.rs");
let drop_body = billing_source
.split_once("impl Drop for AssetOperationRefundOnDrop")
.and_then(|(_, tail)| {
tail.split_once("pub(crate) fn should_refund_asset_operation_error")
.map(|(body, _)| body)
})
.expect("refund guard drop");
assert!(drop_body.contains("editor_generation_commit_outcome_is_unknown()"));
}
}
File diff suppressed because it is too large Load Diff
+104 -3
View File
@@ -1,4 +1,4 @@
use std::{env, fs, net::SocketAddr, path::PathBuf, time::Duration};
use std::{env, fmt, fs, net::SocketAddr, path::PathBuf, time::Duration};
use platform_llm::{
DEFAULT_ARK_BASE_URL, DEFAULT_MAX_RETRIES, DEFAULT_REQUEST_TIMEOUT_MS,
@@ -17,6 +17,7 @@ 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;
pub(crate) const DEFAULT_ELEVENLABS_REQUEST_TIMEOUT_MS: u64 = 180_000;
const DEFAULT_EDITOR_BGFILTER_BASE_URL: &str = "http://58.87.105.82/bgfilter";
const DEFAULT_EDITOR_BGFILTER_SINGLE_IMAGE_ESTIMATE_MS: u64 = 5_000;
const BGFILTER_ATTEMPT_SAFETY_FACTOR: u64 = 2;
@@ -27,7 +28,7 @@ const DEFAULT_ALIYUN_MATTING_ENDPOINT: &str = "imageseg.cn-shanghai.aliyuncs.com
const DEFAULT_ALIYUN_MATTING_REQUEST_TIMEOUT_MS: u64 = 30_000;
// 集中管理 api-server 的启动配置,避免入口层直接散落环境变量解析逻辑。
#[derive(Clone, Debug)]
#[derive(Clone)]
pub struct AppConfig {
pub bind_host: String,
pub bind_port: u16,
@@ -189,6 +190,9 @@ pub struct AppConfig {
pub vector_engine_api_key: Option<String>,
pub vector_engine_image_request_timeout_ms: u64,
pub vector_engine_audio_request_timeout_ms: u64,
pub elevenlabs_base_url: String,
pub elevenlabs_api_key: Option<String>,
pub elevenlabs_request_timeout_ms: u64,
pub hyper3d_base_url: String,
pub hyper3d_api_key: Option<String>,
pub hyper3d_model_request_timeout_ms: u64,
@@ -212,6 +216,41 @@ pub struct AppConfig {
pub slow_request_threshold_ms: u64,
}
impl fmt::Debug for AppConfig {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
// 配置由环境变量和私密文件聚合而来。这里只输出排障所需的封闭运行摘要,
// 不递归格式化任一字符串配置,避免 URL 凭据、Token、私钥或未来新增字段进入日志。
f.debug_struct("AppConfig")
.field("bind_port", &self.bind_port)
.field("listen_backlog", &self.listen_backlog)
.field("worker_threads", &self.worker_threads)
.field("process_role", &self.process_role)
.field("external_generation_mode", &self.external_generation_mode)
.field(
"external_generation_worker_concurrency",
&self.external_generation_worker_concurrency,
)
.field("max_concurrent_requests", &self.max_concurrent_requests)
.field(
"admin_max_concurrent_requests",
&self.admin_max_concurrent_requests,
)
.field("spacetime_pool_size", &self.spacetime_pool_size)
.field("sms_auth_enabled", &self.sms_auth_enabled)
.field("wechat_auth_enabled", &self.wechat_auth_enabled)
.field("wechat_pay_enabled", &self.wechat_pay_enabled)
.field("aliyun_matting_enabled", &self.aliyun_matting_enabled)
.field("tracking_outbox_enabled", &self.tracking_outbox_enabled)
.field(
"wallet_refund_outbox_enabled",
&self.wallet_refund_outbox_enabled,
)
.field("otel_enabled", &self.otel_enabled)
.field("credentials", &"<redacted>")
.finish_non_exhaustive()
}
}
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub enum ProcessRole {
Api,
@@ -451,6 +490,9 @@ impl Default for AppConfig {
vector_engine_api_key: None,
vector_engine_image_request_timeout_ms: DEFAULT_VECTOR_ENGINE_IMAGE_REQUEST_TIMEOUT_MS,
vector_engine_audio_request_timeout_ms: 180_000,
elevenlabs_base_url: String::new(),
elevenlabs_api_key: None,
elevenlabs_request_timeout_ms: DEFAULT_ELEVENLABS_REQUEST_TIMEOUT_MS,
hyper3d_base_url: "https://api.hyper3d.com/api/v2".to_string(),
hyper3d_api_key: None,
hyper3d_model_request_timeout_ms: 180_000,
@@ -1169,6 +1211,16 @@ impl AppConfig {
config.vector_engine_audio_request_timeout_ms = vector_engine_audio_request_timeout_ms;
}
if let Some(elevenlabs_base_url) = read_first_non_empty_env(&["ELEVENLABS_BASE_URL"]) {
config.elevenlabs_base_url = elevenlabs_base_url;
}
config.elevenlabs_api_key = read_first_non_empty_env(&["ELEVENLABS_API_KEY"]);
if let Some(elevenlabs_request_timeout_ms) =
read_first_positive_u64_env(&["ELEVENLABS_REQUEST_TIMEOUT_MS"])
{
config.elevenlabs_request_timeout_ms = elevenlabs_request_timeout_ms;
}
if let Some(hyper3d_base_url) =
read_first_non_empty_env(&["HYPER3D_BASE_URL", "RODIN_BASE_URL"])
{
@@ -1596,7 +1648,7 @@ mod tests {
AppConfig, DEFAULT_EDITOR_BGFILTER_BASE_URL,
DEFAULT_EDITOR_BGFILTER_CIRCUIT_COOLDOWN_SECONDS,
DEFAULT_EDITOR_BGFILTER_CIRCUIT_FAILURE_THRESHOLD,
DEFAULT_EDITOR_BGFILTER_SINGLE_IMAGE_ESTIMATE_MS,
DEFAULT_EDITOR_BGFILTER_SINGLE_IMAGE_ESTIMATE_MS, DEFAULT_ELEVENLABS_REQUEST_TIMEOUT_MS,
DEFAULT_EXTERNAL_GENERATION_WORKER_JOB_TIMEOUT_SECONDS,
DEFAULT_EXTERNAL_GENERATION_WORKER_LEASE_SECONDS,
DEFAULT_EXTERNAL_GENERATION_WORKER_LONG_JOB_TIMEOUT_SECONDS, ExternalGenerationMode,
@@ -1806,6 +1858,55 @@ mod tests {
}
}
#[test]
fn default_elevenlabs_settings_fail_closed_with_a_bounded_timeout() {
let config = AppConfig::default();
assert!(config.elevenlabs_base_url.is_empty());
assert!(config.elevenlabs_api_key.is_none());
assert_eq!(
config.elevenlabs_request_timeout_ms,
DEFAULT_ELEVENLABS_REQUEST_TIMEOUT_MS
);
}
#[test]
fn from_env_reads_elevenlabs_settings() {
let _guard = ENV_LOCK
.get_or_init(|| Mutex::new(()))
.lock()
.expect("env lock should not poison");
unsafe {
std::env::remove_var("ELEVENLABS_BASE_URL");
std::env::remove_var("ELEVENLABS_API_KEY");
std::env::remove_var("ELEVENLABS_REQUEST_TIMEOUT_MS");
std::env::set_var(
"ELEVENLABS_BASE_URL",
"https://elevenlabs.internal.example/v1",
);
std::env::set_var("ELEVENLABS_API_KEY", "elevenlabs-test-key");
std::env::set_var("ELEVENLABS_REQUEST_TIMEOUT_MS", "190000");
}
let config = AppConfig::from_env();
assert_eq!(
config.elevenlabs_base_url,
"https://elevenlabs.internal.example/v1"
);
assert_eq!(
config.elevenlabs_api_key.as_deref(),
Some("elevenlabs-test-key")
);
assert_eq!(config.elevenlabs_request_timeout_ms, 190_000);
unsafe {
std::env::remove_var("ELEVENLABS_BASE_URL");
std::env::remove_var("ELEVENLABS_API_KEY");
std::env::remove_var("ELEVENLABS_REQUEST_TIMEOUT_MS");
}
}
#[test]
fn from_env_reads_non_public_models_and_urls() {
let _guard = ENV_LOCK
@@ -197,11 +197,12 @@ pub async fn editor_agent_message(
// Tool calls and attachment bookkeeping are separate system messages.
let previous_messages = build_prompt_memory(&document, history_end);
// Build tool context from document
let tool_context = context::build_tool_context(&document);
// Re-read authoritative resource and asset metadata for every planning turn. The message
// document only identifies attachments; it is not the source of truth for asset kind.
let tool_context = context::build_tool_context(&state, &conversation, &document).await?;
// Build and run agent
let Some(llm_client) = state.editor_agent_llm_client() else {
let Some(llm_client) = state.vector_engine_llm_client() else {
tracing::warn!(
conversation_id = %conversation.conversation_id,
"美术 Agent LLM 客户端未配置"
@@ -1046,7 +1047,7 @@ pub async fn confirm_editor_agent_tool_call(
}))
})?;
let project = load_editor_agent_project(&state, &conversation).await?;
let context = context::build_tool_context(&document);
let context = context::build_tool_context(&state, &conversation, &document).await?;
let tool = editor_agent_tool(tool_name.as_str(), &context)
.ok_or_else(|| editor_agent_bad_request(format!("unsupported tool: {tool_name}")))?;
let normalized_args = tool
@@ -1,10 +1,82 @@
use crate::editor_agent::utils::IntoDataKey;
use crate::editor_project::{current_utc_micros, map_editor_project_error};
use crate::http_error::AppError;
use crate::state::AppState;
use platform_editor_agent::agent::asset::{ImageId, ImageMetadata};
use platform_editor_agent::agent::tools::context::EditorToolContext;
use shared_contracts::editor_agent::EditorAgentConversationMessagesDocument;
use shared_contracts::editor_agent::{
EditorAgentAttachmentSource, EditorAgentConversationMessagesDocument,
};
use spacetime_client::{
EditorAgentConversationRecord, EditorAssetLibraryRecord, EditorProjectGetRecordInput,
EditorProjectRecord,
};
use std::collections::HashMap;
pub fn build_tool_context(document: &EditorAgentConversationMessagesDocument) -> EditorToolContext {
pub async fn build_tool_context(
state: &AppState,
conversation: &EditorAgentConversationRecord,
document: &EditorAgentConversationMessagesDocument,
) -> Result<EditorToolContext, AppError> {
let needs_project = document.messages.iter().any(|message| {
message
.attachments
.iter()
.any(|attachment| attachment.source == EditorAgentAttachmentSource::CanvasResource)
|| message.tool_call.as_ref().is_some_and(|tool_call| {
tool_call.images.iter().any(|image| {
image
.resource_id
.as_deref()
.is_some_and(|id| !id.trim().is_empty())
})
})
});
let needs_library = document.messages.iter().any(|message| {
message
.attachments
.iter()
.any(|attachment| attachment.source == EditorAgentAttachmentSource::LibraryAsset)
});
let project = if needs_project {
Some(
state
.spacetime_client()
.get_editor_project(EditorProjectGetRecordInput {
project_id: conversation.project_id.clone(),
owner_user_id: conversation.owner_user_id.clone(),
})
.await
.map_err(map_editor_project_error)?,
)
} else {
None
};
let library = if needs_library {
Some(
state
.spacetime_client()
.get_editor_asset_library(conversation.owner_user_id.clone(), current_utc_micros())
.await
.map_err(map_editor_project_error)?,
)
} else {
None
};
Ok(build_tool_context_from_authoritative_records(
document,
project.as_ref(),
library.as_ref(),
))
}
fn build_tool_context_from_authoritative_records(
document: &EditorAgentConversationMessagesDocument,
project: Option<&EditorProjectRecord>,
library: Option<&EditorAssetLibraryRecord>,
) -> EditorToolContext {
let mut images: HashMap<ImageId, ImageMetadata> = HashMap::new();
for msg in document.messages.iter().rev() {
@@ -13,6 +85,25 @@ pub fn build_tool_context(document: &EditorAgentConversationMessagesDocument) ->
let image_id = ImageId::from_data_key(&data_key);
let metadata = ImageMetadata {
data_key,
reference_id: Some(a.reference_id.clone()),
asset_kind: match a.source {
EditorAgentAttachmentSource::CanvasResource => project
.and_then(|project| {
project
.resources
.iter()
.find(|resource| resource.resource_id == a.reference_id)
})
.and_then(|resource| resource.asset_kind.clone()),
EditorAgentAttachmentSource::LibraryAsset => library
.and_then(|library| {
library
.assets
.iter()
.find(|asset| asset.asset_id == a.reference_id)
})
.and_then(|asset| asset.asset_kind.clone()),
},
image_src: a.image_src.clone(),
object_key: a.object_key.clone(),
thumbnail_src: a.thumbnail_src.clone(),
@@ -29,6 +120,17 @@ pub fn build_tool_context(document: &EditorAgentConversationMessagesDocument) ->
let image_id = ImageId::from_data_key(&data_key);
let metadata = ImageMetadata {
data_key,
reference_id: img.resource_id.clone(),
asset_kind: img.resource_id.as_deref().and_then(|resource_id| {
project
.and_then(|project| {
project
.resources
.iter()
.find(|resource| resource.resource_id == resource_id)
})
.and_then(|resource| resource.asset_kind.clone())
}),
image_src: img.image_src.clone(),
object_key: img.object_key.clone(),
thumbnail_src: img.thumbnail_src.clone(),
@@ -47,10 +149,17 @@ pub fn build_tool_context(document: &EditorAgentConversationMessagesDocument) ->
#[cfg(test)]
mod tests {
use super::*;
use crate::editor_agent::tool::editor_agent_tool;
use platform_editor_agent::agent::tools::generate_icon_spritesheet::GenerateIconSpritesheetTool;
use platform_editor_agent::framework::tool::Tool;
use serde_json::json;
use shared_contracts::editor_agent::{
EditorAgentAttachmentRef, EditorAgentAttachmentSource, EditorAgentMessage,
EditorAgentMessageRole,
};
use spacetime_client::{
EditorCanvasRecord, EditorCanvasViewportRecord, EditorProjectResourceRecord,
};
fn message(id: usize, label: &str, image_src: &str) -> EditorAgentMessage {
EditorAgentMessage {
@@ -73,6 +182,59 @@ mod tests {
}
}
fn project_with_resource_asset_kind(asset_kind: &str) -> EditorProjectRecord {
let viewport = EditorCanvasViewportRecord {
x: 0.0,
y: 0.0,
scale: 1.0,
};
EditorProjectRecord {
project_id: "project-1".to_string(),
owner_user_id: "user-1".to_string(),
title: "测试工程".to_string(),
canvas: EditorCanvasRecord {
canvas_id: "canvas-1".to_string(),
project_id: "project-1".to_string(),
title: "测试画布".to_string(),
viewport: viewport.clone(),
layers: json!([]),
revision: 0,
layout_storage_version: 2,
background_color: None,
created_at: "2026-08-08T00:00:00Z".to_string(),
updated_at: "2026-08-08T00:00:00Z".to_string(),
},
viewport,
layers: json!([]),
resources: vec![EditorProjectResourceRecord {
resource_id: "resource-1".to_string(),
project_id: "project-1".to_string(),
owner_user_id: "user-1".to_string(),
asset_object_id: Some("object-1".to_string()),
image_src: "/api/assets/read/current.png".to_string(),
object_key: Some("generated/reference.png".to_string()),
width: 640,
height: 480,
source_type: "generated".to_string(),
prompt: None,
actual_prompt: None,
model: None,
provider: None,
task_id: None,
source_resource_id: None,
asset_kind: Some(asset_kind.to_string()),
generation_inputs: None,
public_showcase_enabled: false,
created_at: "2026-08-08T00:00:00Z".to_string(),
updated_at: "2026-08-08T00:00:00Z".to_string(),
image_sequence_frames: None,
image_sequence_duration_ms: None,
}],
created_at: "2026-08-08T00:00:00Z".to_string(),
updated_at: "2026-08-08T00:00:00Z".to_string(),
}
}
#[test]
fn tool_context_keeps_complete_metadata_from_the_latest_image_reference() {
let document = EditorAgentConversationMessagesDocument {
@@ -84,7 +246,7 @@ mod tests {
],
};
let context = build_tool_context(&document);
let context = build_tool_context_from_authoritative_records(&document, None, None);
let image_id = ImageId::from_data_key("generated/reference.png");
let metadata = context
.image_metadata(&image_id)
@@ -96,4 +258,38 @@ mod tests {
assert_eq!(metadata.width, Some(640));
assert_eq!(metadata.height, Some(480));
}
#[test]
fn icon_spritesheet_validation_uses_authoritative_project_asset_kind() {
let document = EditorAgentConversationMessagesDocument {
version: 2,
conversation_id: "conversation-1".to_string(),
messages: vec![message(0, "主规范", "/api/assets/read/current.png")],
};
let args = json!({
"reference_image_id": ImageId::from_data_key("generated/reference.png").id,
"icon_descriptions": ["背包"]
});
let ordinary_project = project_with_resource_asset_kind("image");
let ordinary_context =
build_tool_context_from_authoritative_records(&document, Some(&ordinary_project), None);
let ordinary_tool = editor_agent_tool(GenerateIconSpritesheetTool::NAME, &ordinary_context)
.expect("icon spritesheet tool should resolve");
let error = ordinary_tool
.validate_args(&args)
.expect_err("ordinary registered image must be rejected before confirmation");
assert!(error.to_string().contains("icon-spec"));
let icon_spec_project = project_with_resource_asset_kind("icon-spec");
let icon_spec_context = build_tool_context_from_authoritative_records(
&document,
Some(&icon_spec_project),
None,
);
let icon_spec_tool =
editor_agent_tool(GenerateIconSpritesheetTool::NAME, &icon_spec_context)
.expect("icon spritesheet tool should resolve");
assert!(icon_spec_tool.validate_args(&args).is_ok());
}
}
@@ -11,3 +11,6 @@ pub use api::{
create_editor_agent_conversation, delete_editor_agent_conversation,
get_editor_agent_conversation, list_editor_agent_conversations,
};
#[cfg(test)]
pub(crate) use reconcile::reconcile_completed_editor_agent_tool_call_for_test;
@@ -1,5 +1,8 @@
use crate::http_error::AppError;
use crate::state::AppState;
use crate::{
editor_agent::tool::editor_agent_audio_generation_job_kind,
external_generation::user_visible_external_generation_error, http_error::AppError,
state::AppState,
};
use platform_editor_agent::agent::tools::context::EditorToolContext;
use serde_json::Value;
use shared_contracts::editor_agent::{
@@ -87,10 +90,18 @@ pub async fn reconcile_editor_agent_tool_calls(
}
}
"failed" => {
let tool_name = document.messages[message_index]
.tool_call
.as_ref()
.expect("reconcile candidate contains a tool call")
.tool_name
.clone();
mark_job_failed(
&mut document.messages[message_index],
job.last_error_message
.unwrap_or_else(|| "生成失败".to_string()),
editor_agent_job_failure_message(
tool_name.as_str(),
job.last_error_message,
),
);
last_reconcile_error = None;
reconciled.push(document.messages[message_index].clone());
@@ -124,6 +135,14 @@ fn should_retry_result_reconcile(retry_count: u8) -> bool {
retry_count < EDITOR_AGENT_RESULT_RECONCILE_MAX_RETRIES
}
fn editor_agent_job_failure_message(tool_name: &str, last_error_message: Option<String>) -> String {
let Some(job_kind) = editor_agent_audio_generation_job_kind(tool_name) else {
return last_error_message.unwrap_or_else(|| "生成失败".to_string());
};
user_visible_external_generation_error(job_kind, last_error_message)
.unwrap_or_else(|| "生成失败".to_string())
}
fn mark_job_failed(message: &mut EditorAgentMessage, error: String) {
let tool_call = message
.tool_call
@@ -207,9 +226,19 @@ fn reconcile_completed_editor_agent_tool_call(
Ok(())
}
#[cfg(test)]
pub(crate) fn reconcile_completed_editor_agent_tool_call_for_test(
message: &mut EditorAgentMessage,
result_payload_json: Option<&str>,
) -> Result<(), String> {
reconcile_completed_editor_agent_tool_call(message, result_payload_json)
.map_err(|error| format!("{error:?}"))
}
#[cfg(test)]
mod tests {
use super::*;
use platform_editor_agent::agent::tools::generate_background_music::GenerateBackgroundMusicTool;
use platform_editor_agent::agent::tools::generate_sound_effect::GenerateSoundEffectTool;
use platform_editor_agent::framework::tool::Tool;
use serde_json::json;
@@ -301,6 +330,46 @@ mod tests {
assert_eq!(tool_call.external_job_id.as_deref(), Some("job-1"));
}
#[test]
fn failed_sound_effect_jobs_hide_internal_reason_codes_in_agent_tool_calls() {
for internal_error in [
"translation_invalid",
"elevenlabs_http_failed",
"ELEVENLABS_BASE_URL 未配置",
] {
let mut message = pending_tool_message();
mark_job_failed(
&mut message,
editor_agent_job_failure_message(
GenerateSoundEffectTool::NAME,
Some(internal_error.to_string()),
),
);
let tool_call = message.tool_call.expect("tool call should remain present");
assert_eq!(tool_call.status, EditorAgentToolCallStatus::Failed);
assert_eq!(
tool_call.error.as_deref(),
Some("音效生成失败,请稍后重试。")
);
assert!(
!message.text.contains(internal_error),
"Agent 会话文本不应保留内部失败码:{internal_error}"
);
}
}
#[test]
fn failed_background_music_jobs_use_the_same_user_visible_error_policy() {
assert_eq!(
editor_agent_job_failure_message(
GenerateBackgroundMusicTool::NAME,
Some("请求 Vector Engine 背景音乐任务失败:connection refused".to_string()),
),
"背景音乐生成失败,请稍后重试。"
);
}
#[test]
fn completed_legacy_tool_call_with_null_defaults_still_reconciles() {
let mut message: EditorAgentMessage = serde_json::from_value(json!({
@@ -353,6 +422,8 @@ mod tests {
let tool_call = message.tool_call.expect("tool call should remain present");
assert_eq!(tool_call.status, EditorAgentToolCallStatus::Completed);
assert_eq!(tool_call.audios.len(), 1);
assert!(message.text.contains("\"duration\":5"));
// SFX V2 下 `duration: null` 是「自动时长」这一有效取值,规范化后必须保持 null,
// 不能像图片 / 视频那样被 null-as-missing 恢复成手动默认 5 秒。
assert!(message.text.contains("\"duration\":null"));
}
}
@@ -53,9 +53,8 @@ use crate::editor_generation_queue::{
EDITOR_IMAGE_EDIT_JOB_KIND, EDITOR_IMAGE_GENERATION_JOB_KIND,
EDITOR_SOUND_EFFECT_GENERATION_JOB_KIND, EDITOR_VIDEO_GENERATION_JOB_KIND,
};
use crate::editor_project::{
EditorIconSpritesheetGenerationRequest, EditorImageEditRequest, EditorImageGenerationRequest,
};
use crate::editor_project::{EditorImageEditRequest, EditorImageGenerationRequest};
use crate::editor_project_icon::EditorIconSpritesheetGenerationRequest;
#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) enum EditorAgentToolError {
@@ -86,6 +85,17 @@ impl EditorAgentToolError {
}
}
/// `get_external_generation_job_generated_artifacts` 的精简读取结果不带 `job_kind`;
/// Agent 会话回填只能从已持久化的 tool name 恢复音频任务类型,以复用普通任务读取侧的
/// 用户可见错误投影。
pub(crate) fn editor_agent_audio_generation_job_kind(tool_name: &str) -> Option<&'static str> {
match tool_name {
GenerateSoundEffectTool::NAME => Some(EDITOR_SOUND_EFFECT_GENERATION_JOB_KIND),
GenerateBackgroundMusicTool::NAME => Some(EDITOR_BACKGROUND_MUSIC_GENERATION_JOB_KIND),
_ => None,
}
}
impl Display for EditorAgentToolError {
fn fmt(&self, formatter: &mut Formatter<'_>) -> std::fmt::Result {
match self {
@@ -214,6 +224,31 @@ fn parse_internal<T: DeserializeOwned>(
})
}
/// SFX V2 的显式 `duration: null` 表示自动时长,必须原样保留,不能被通用的
/// “顶层 null 当缺省”兼容层恢复成手动 5 秒。但其余字段仍要走该兼容层:
/// `model` 是无 Option 的 String,LLM 传 `model: null` 时若不剥离会直接反序列化失败。
fn parse_sound_effect_args(
label: &str,
value: &Value,
invalid_args: bool,
) -> Result<GenerateSoundEffectToolArgs, EditorAgentToolError> {
let mut normalized = null_tool_args_as_missing(value.clone());
let has_explicit_null_duration = value
.get("duration")
.is_some_and(serde_json::Value::is_null);
if has_explicit_null_duration && let Some(fields) = normalized.as_object_mut() {
fields.insert("duration".to_string(), Value::Null);
}
serde_json::from_value(normalized).map_err(|error| {
let message = format!("failed to deserialize {label}: {error}");
if invalid_args {
EditorAgentToolError::invalid_args(message)
} else {
EditorAgentToolError::internal(message)
}
})
}
fn serialize_normalized_args<T: Serialize>(
tool_name: &str,
args: &T,
@@ -720,11 +755,37 @@ impl EditorAgentTool for EditImageTool {
}
}
fn resolve_icon_spec_reference_id<'a>(
context: &'a EditorToolContext,
image_id: &ImageId,
) -> Result<&'a str, EditorAgentToolError> {
let metadata = context.image_metadata(image_id).ok_or_else(|| {
EditorAgentToolError::invalid_args("reference image is not available in tool context")
})?;
let reference_id = metadata
.reference_id
.as_deref()
.map(str::trim)
.filter(|reference_id| !reference_id.is_empty())
.ok_or_else(|| {
EditorAgentToolError::invalid_args(
"reference image is not a registered resource or asset",
)
})?;
if metadata.asset_kind.as_deref().map(str::trim) != Some("icon-spec") {
return Err(EditorAgentToolError::invalid_args(
"reference image must be a registered icon-spec; select an icon specification image and try again",
));
}
Ok(reference_id)
}
impl EditorAgentTool for GenerateIconSpritesheetTool {
fn validate_args(&self, args: &Value) -> Result<Value, EditorAgentToolError> {
let args: GenerateIconSpritesheetToolArgs = parse_invalid_args(Self::NAME, args)?;
GenerateIconSpritesheetTool::validate_args(self, &args)
let args = GenerateIconSpritesheetTool::normalize_args(self, args)
.map_err(|error| EditorAgentToolError::invalid_args(error.to_string()))?;
resolve_icon_spec_reference_id(&self.context, &args.reference_image_id)?;
serialize_normalized_args(Self::NAME, &args)
}
@@ -792,14 +853,11 @@ impl EditorAgentTool for GenerateIconSpritesheetTool {
) -> Result<PreparedEditorAgentJob, EditorAgentToolError> {
let price_mud_points = self.pricing(context.pricing, args)?;
let args: GenerateIconSpritesheetToolArgs = parse_invalid_args(Self::NAME, args)?;
let reference_image_src = self
.context
.image_data_key(&args.reference_image_id)
.ok_or_else(|| EditorAgentToolError::invalid_args("reference image not found"))?
.to_string();
let reference_id =
resolve_icon_spec_reference_id(&self.context, &args.reference_image_id)?.to_string();
let reference_image_srcs = resolve_image_ids(&args.reference_image_ids, &self.context)?;
let payload = EditorIconSpritesheetGenerationRequest {
reference_image_src,
reference_id,
reference_image_srcs: Some(reference_image_srcs),
icon_descriptions: args.icon_descriptions,
style: None,
@@ -982,8 +1040,8 @@ impl EditorAgentTool for GenerateVideoTool {
impl EditorAgentTool for GenerateSoundEffectTool {
fn validate_args(&self, args: &Value) -> Result<Value, EditorAgentToolError> {
let args: GenerateSoundEffectToolArgs = parse_invalid_args(Self::NAME, args)?;
GenerateSoundEffectTool::validate_args(self, &args)
let args = parse_sound_effect_args(Self::NAME, args, true)?;
let args = GenerateSoundEffectTool::normalize_args(self, args)
.map_err(|error| EditorAgentToolError::invalid_args(error.to_string()))?;
serialize_normalized_args(Self::NAME, &args)
}
@@ -993,7 +1051,7 @@ impl EditorAgentTool for GenerateSoundEffectTool {
pricing: &EditorGenerationPricingConfig,
args: &Value,
) -> Result<u32, EditorAgentToolError> {
let args: GenerateSoundEffectToolArgs = parse_internal("generate sound effect args", args)?;
let args = parse_sound_effect_args("generate sound effect args", args, false)?;
Ok(pricing.sound_effect_model_mud_points(Some(args.model.as_str())))
}
@@ -1003,8 +1061,7 @@ impl EditorAgentTool for GenerateSoundEffectTool {
pricing: &EditorGenerationPricingConfig,
) -> Result<EditorAgentToolCallDisplayArgs, EditorAgentToolError> {
let price_mud_points = self.pricing(pricing, args)?;
let args: GenerateSoundEffectToolArgs =
parse_internal("generate sound effect display args", args)?;
let args = parse_sound_effect_args("generate sound effect display args", args, false)?;
let mut display_args = EditorAgentToolCallDisplayArgs::default();
push_string_display_arg(&mut display_args, "prompt", "音效描述", args.prompt);
push_string_display_arg(&mut display_args, "model", "模型", args.model);
@@ -1012,7 +1069,20 @@ impl EditorAgentTool for GenerateSoundEffectTool {
&mut display_args,
"duration",
"时长(秒)",
args.duration.to_string(),
args.duration
.map(|duration| format!("{duration}秒"))
.unwrap_or_else(|| "自动".to_string()),
);
push_string_display_arg(
&mut display_args,
"loop",
"Loop",
if args.loop_enabled {
"开启"
} else {
"关闭"
}
.to_string(),
);
Ok(finish_display_args(display_args, price_mud_points))
}
@@ -1023,12 +1093,13 @@ impl EditorAgentTool for GenerateSoundEffectTool {
context: &EditorAgentPrepareJobContext<'_>,
) -> Result<PreparedEditorAgentJob, EditorAgentToolError> {
let price_mud_points = self.pricing(context.pricing, args)?;
let args: GenerateSoundEffectToolArgs = parse_invalid_args(Self::NAME, args)?;
let args = parse_sound_effect_args(Self::NAME, args, true)?;
let title = args.prompt.clone();
let payload = EditorSoundEffectGenerateRequest {
prompt: args.prompt,
model: Some(args.model),
duration: args.duration,
loop_enabled: args.loop_enabled,
project_id: Some(context.conversation.project_id.clone()),
canvas_completion: Some(build_editor_agent_canvas_completion(
context.project,
@@ -1052,7 +1123,7 @@ impl EditorAgentTool for GenerateSoundEffectTool {
args: &Value,
result: &Value,
) -> Result<String, EditorAgentToolError> {
let args: GenerateSoundEffectToolArgs = parse_internal("generate sound effect args", args)?;
let args = parse_sound_effect_args("generate sound effect args", args, false)?;
let result: EditorAudioGenerateResponse =
parse_internal("generate sound effect result", result)?;
Ok(GenerateSoundEffectTool::format_execute_message(
@@ -1184,6 +1255,8 @@ mod tests {
},
ImageMetadata {
data_key: format!("generated/{image_id}.png"),
reference_id: Some(format!("resource-{image_id}")),
asset_kind: Some("icon-spec".to_string()),
image_src: format!("/api/assets/read/generated/{image_id}.png"),
object_key: Some(format!("generated/{image_id}.png")),
thumbnail_src: Some(format!("/api/assets/read/generated/{image_id}-thumb.png")),
@@ -1292,10 +1365,16 @@ mod tests {
let sound = editor_agent_tool(GenerateSoundEffectTool::NAME, &EditorToolContext::default())
.expect("sound tool should resolve");
// SFX V2 手动时长边界是 0.5-30 秒(旧 Vidu 契约是 10 秒上限),越界值随之上移。
assert!(
sound
.validate_args(&json!({ "prompt": "按钮点击声", "duration": 30.01 }))
.is_err()
);
assert!(
sound
.validate_args(&json!({ "prompt": "按钮点击声", "duration": 11 }))
.is_err()
.is_ok()
);
}
@@ -1345,7 +1424,9 @@ mod tests {
}))
.expect("null sound defaults should normalize");
assert_eq!(sound_args["model"], GenerateSoundEffectTool::DEFAULT_MODEL);
assert_eq!(sound_args["duration"], 5);
// SFX V2 与图片 / 视频不同:`duration: null` 是「自动时长」这一有效取值,
// 不能被 null-as-missing 兼容层恢复成手动默认 5 秒。
assert!(sound_args["duration"].is_null());
assert!(
sound
@@ -1369,6 +1450,40 @@ mod tests {
let missing = editor_agent_tool(EditImageTool::NAME, &EditorToolContext::default())
.expect("edit tool should resolve");
assert!(missing.validate_args(&normalized).is_err());
let registered_icon = editor_agent_tool(
GenerateIconSpritesheetTool::NAME,
&context_with_image("image-1"),
)
.expect("icon spritesheet tool should resolve");
assert!(
registered_icon
.validate_args(&json!({
"reference_image_id": "image-1",
"icon_descriptions": ["背包"]
}))
.is_ok()
);
let mut unregistered_context = context_with_image("image-1");
unregistered_context
.images
.get_mut(&ImageId {
id: "image-1".to_string(),
})
.expect("test image should exist")
.reference_id = None;
let unregistered_icon =
editor_agent_tool(GenerateIconSpritesheetTool::NAME, &unregistered_context)
.expect("icon spritesheet tool should resolve");
assert!(
unregistered_icon
.validate_args(&json!({
"reference_image_id": "image-1",
"icon_descriptions": ["背包"]
}))
.is_err()
);
}
#[test]
@@ -1464,7 +1579,8 @@ mod tests {
assert_eq!(display.extras.price_mud_points, 5);
assert_eq!(display.string_args[0].label, "音效描述");
assert_eq!(display.string_args[0].value, "按钮点击声");
assert_eq!(display.string_args[2].value, "5");
assert_eq!(display.string_args[2].value, "5秒");
assert_eq!(display.string_args[3].value, "关闭");
}
#[test]
@@ -1497,7 +1613,11 @@ mod tests {
let tool = editor_agent_tool(GenerateSoundEffectTool::NAME, &EditorToolContext::default())
.expect("sound tool should resolve");
let args = tool
.validate_args(&json!({ "prompt": "按钮点击声" }))
.validate_args(&json!({
"prompt": "\u{feff}按钮点击声\u{2003}",
"duration": null,
"loop": true
}))
.expect("sound args should validate");
let result = json!({
"ok": true,
@@ -1508,8 +1628,8 @@ mod tests {
"height": 0,
"sourceType": "generated",
"prompt": "按钮点击声",
"model": "audio1.0",
"provider": "vectorengine",
"actualPrompt": "A short button click",
"model": "eleven_text_to_sound_v2",
"taskId": "task-1",
"priceMudPoints": 5,
"audioKind": "sound-effect"
@@ -1535,7 +1655,11 @@ mod tests {
let tool = editor_agent_tool(GenerateSoundEffectTool::NAME, &EditorToolContext::default())
.expect("sound tool should resolve");
let args = tool
.validate_args(&json!({ "prompt": "按钮点击声" }))
.validate_args(&json!({
"prompt": "\u{feff}按钮点击声\u{2003}",
"duration": null,
"loop": true
}))
.expect("sound args should validate");
let job = tool
.prepare_job(
@@ -1551,10 +1675,13 @@ mod tests {
assert_eq!(job.job_kind, EDITOR_SOUND_EFFECT_GENERATION_JOB_KIND);
assert_eq!(job.request_label, "画布 Agent 生成音效");
assert_eq!(job.price_mud_points, 5);
assert_eq!(job.payload["duration"], Value::Null);
assert_eq!(job.payload["prompt"], "按钮点击声");
assert_eq!(
job.payload["duration"],
GenerateSoundEffectTool::DEFAULT_DURATION
job.payload["model"],
shared_contracts::assets::EDITOR_SOUND_EFFECT_MODEL
);
assert_eq!(job.payload["loop"], json!(true));
assert_eq!(job.payload["projectId"], "project-1");
assert_eq!(job.payload["generationInputs"]["toolCallMessageId"], 7);
}
@@ -26,7 +26,8 @@ use crate::{
state::AppState,
};
const BACKGROUND_MUSIC_PROMPT_ASSIST_MAX_OUTPUT_TOKENS: u32 = 1024;
const BACKGROUND_MUSIC_PROMPT_COMPLETION_MAX_OUTPUT_TOKENS: u32 = 2048;
const BACKGROUND_MUSIC_PROMPT_SIMPLIFICATION_MAX_OUTPUT_TOKENS: u32 = 8192;
const BACKGROUND_MUSIC_PROMPT_ASSIST_MODEL: &str = "gpt-5.6-luna";
const BACKGROUND_MUSIC_PROMPT_MAX_CHARS: usize =
platform_audio::SUNO_GPT_DESCRIPTION_PROMPT_MAX_CHARS;
@@ -135,7 +136,7 @@ fn background_music_prompt_bad_request(message: impl Into<String>) -> AppError {
}
fn require_background_music_prompt_llm_client(state: &AppState) -> Result<&LlmClient, AppError> {
state.editor_agent_llm_client().ok_or_else(|| {
state.vector_engine_llm_client().ok_or_else(|| {
AppError::from_status(StatusCode::SERVICE_UNAVAILABLE)
.with_message("服务端尚未配置可用的编辑器 LLM")
.with_details(json!({
@@ -153,6 +154,7 @@ async fn run_background_music_prompt_completion(
.run(build_background_music_prompt_llm_request(
background_music_completion_system_prompt(),
background_music_completion_user_prompt(current_prompt),
BACKGROUND_MUSIC_PROMPT_COMPLETION_MAX_OUTPUT_TOKENS,
))
.await
.map_err(map_background_music_prompt_llm_error)?;
@@ -173,6 +175,7 @@ async fn run_background_music_prompt_simplification(
original_prompt,
BACKGROUND_MUSIC_SIMPLIFICATION_FIRST_TARGET_CHARS,
),
BACKGROUND_MUSIC_PROMPT_SIMPLIFICATION_MAX_OUTPUT_TOKENS,
))
.await
.map_err(map_background_music_prompt_llm_error)?;
@@ -196,6 +199,7 @@ async fn run_background_music_prompt_simplification(
second_current_prompt,
BACKGROUND_MUSIC_SIMPLIFICATION_SECOND_TARGET_CHARS,
),
BACKGROUND_MUSIC_PROMPT_SIMPLIFICATION_MAX_OUTPUT_TOKENS,
))
.await
.map_err(map_background_music_prompt_llm_error)?;
@@ -208,6 +212,7 @@ async fn run_background_music_prompt_simplification(
fn build_background_music_prompt_llm_request(
system_prompt: String,
user_prompt: String,
max_output_tokens: u32,
) -> LlmRunRequest {
LlmRunRequest::new(vec![
LlmMessage::system(system_prompt),
@@ -216,7 +221,7 @@ fn build_background_music_prompt_llm_request(
.with_model(BACKGROUND_MUSIC_PROMPT_ASSIST_MODEL)
.with_openai_chat()
.with_response_reasoning_effort(LlmResponseReasoningEffort::Medium)
.with_max_output_tokens(BACKGROUND_MUSIC_PROMPT_ASSIST_MAX_OUTPUT_TOKENS)
.with_max_output_tokens(max_output_tokens)
}
fn inspect_background_music_prompt_candidate(
@@ -467,8 +472,11 @@ mod tests {
#[test]
fn llm_request_uses_editor_gpt_chat_without_tools() {
let request =
build_background_music_prompt_llm_request("系统".to_string(), "用户".to_string());
let request = build_background_music_prompt_llm_request(
"系统".to_string(),
"用户".to_string(),
BACKGROUND_MUSIC_PROMPT_COMPLETION_MAX_OUTPUT_TOKENS,
);
assert_eq!(request.model.as_deref(), Some("gpt-5.6-luna"));
assert_eq!(request.api_kind, LlmApiKind::OpenAiChat);
@@ -478,7 +486,7 @@ mod tests {
);
assert_eq!(
request.max_output_tokens,
Some(BACKGROUND_MUSIC_PROMPT_ASSIST_MAX_OUTPUT_TOKENS)
Some(BACKGROUND_MUSIC_PROMPT_COMPLETION_MAX_OUTPUT_TOKENS)
);
assert!(request.function_tools.is_empty());
assert_eq!(request.tool_choice, None);
@@ -889,7 +897,7 @@ mod tests {
);
assert_eq!(
requests[0].body["max_completion_tokens"],
BACKGROUND_MUSIC_PROMPT_ASSIST_MAX_OUTPUT_TOKENS
BACKGROUND_MUSIC_PROMPT_COMPLETION_MAX_OUTPUT_TOKENS
);
assert!(requests[0].body.get("max_tokens").is_none());
assert_eq!(requests[0].body["reasoning_effort"], "medium");
@@ -1033,6 +1041,12 @@ mod tests {
let requests = mock.finish();
assert_eq!(requests.len(), 2);
for request in &requests {
assert_eq!(
request.body["max_completion_tokens"],
BACKGROUND_MUSIC_PROMPT_SIMPLIFICATION_MAX_OUTPUT_TOKENS
);
}
let first_input = user_message_json(&requests[0].body);
assert_eq!(first_input["originalPrompt"], original_prompt);
assert_eq!(first_input["currentPrompt"], original_prompt);
@@ -28,6 +28,8 @@ const EDITOR_VIDEO_MODEL_KLING_3_OMNI: &str = "kling3.0-omni";
const EDITOR_VIDEO_MODEL_VEO_3_1: &str = "veo3.1";
const EDITOR_VIDEO_MODEL_VEO_3_1_FAST: &str = "veo3.1-fast";
pub(crate) const EDITOR_SOUND_EFFECT_MODEL_VIDU: &str = "audio1.0";
pub(crate) const EDITOR_SOUND_EFFECT_MODEL_ELEVENLABS: &str =
shared_contracts::assets::EDITOR_SOUND_EFFECT_MODEL;
pub(crate) const EDITOR_BACKGROUND_MUSIC_MODEL_SUNO: &str = "chirp-v5";
const IMAGE_PRICE_SIZE_0_5K: &str = "0.5K";
@@ -137,7 +139,7 @@ impl EditorGenerationPricingConfig {
}
match kind.map(str::trim) {
Some("spec") => self.spec_model_mud_points(model),
Some("character" | "icon" | "ui-design" | "publication-material") => {
Some("character" | "icon" | "ui-design" | "publication-material" | "scene") => {
self.image_model_mud_points(model, image_size)
}
_ => self.image_model_mud_points(model, image_size),
@@ -188,11 +190,12 @@ impl EditorGenerationPricingConfig {
if let Some(price_mud_points) = current_external_generation_billing_price_mud_points() {
return price_mud_points;
}
let normalized_model = normalize_non_empty_model(model, EDITOR_SOUND_EFFECT_MODEL_VIDU);
let normalized_model =
normalize_non_empty_model(model, EDITOR_SOUND_EFFECT_MODEL_ELEVENLABS);
read_flat_price(
&self.models,
normalized_model,
EDITOR_SOUND_EFFECT_MODEL_VIDU,
EDITOR_SOUND_EFFECT_MODEL_ELEVENLABS,
)
}
@@ -235,6 +238,11 @@ impl EditorGenerationPricingConfig {
EDITOR_SOUND_EFFECT_MODEL_VIDU,
EditorGenerationPricingUnit::PerGeneration,
)?;
validate_required_flat_price(
&self.models,
EDITOR_SOUND_EFFECT_MODEL_ELEVENLABS,
EditorGenerationPricingUnit::PerGeneration,
)?;
validate_required_flat_price(
&self.models,
EDITOR_BACKGROUND_MUSIC_MODEL_SUNO,
@@ -670,7 +678,8 @@ mod tests {
queued_price
);
assert_eq!(
config.sound_effect_model_mud_points(Some("audio1.0")),
config
.sound_effect_model_mud_points(Some(EDITOR_SOUND_EFFECT_MODEL_ELEVENLABS)),
queued_price
);
assert_eq!(
@@ -748,6 +757,14 @@ mod tests {
),
12
);
assert_eq!(
editor_image_generation_mud_points(
Some("scene"),
Some("gemini-3.1-flash-image-preview"),
Some("1K")
),
12
);
assert_eq!(
editor_image_generation_mud_points(Some("ui-design"), Some("gpt-image-2"), Some("1K")),
3
@@ -781,7 +798,13 @@ mod tests {
#[test]
fn editor_audio_generation_price_uses_configured_model_rates() {
assert_eq!(
editor_sound_effect_model_generation_mud_points(Some("audio1.0")),
editor_sound_effect_model_generation_mud_points(Some(
EDITOR_SOUND_EFFECT_MODEL_ELEVENLABS
)),
5
);
assert_eq!(
editor_sound_effect_model_generation_mud_points(Some(EDITOR_SOUND_EFFECT_MODEL_VIDU)),
5
);
assert_eq!(
@@ -832,6 +855,7 @@ mod tests {
"prices": { "480p": 11, "720p": 22, "1080p": 44 }
},
"audio1.0": { "unit": "perGeneration", "price": 15 },
"eleven_text_to_sound_v2": { "unit": "perGeneration", "price": 16 },
"chirp-v5": { "unit": "perGeneration", "price": 9 }
}
}"#,
@@ -854,6 +878,10 @@ mod tests {
config.character_animation_model_mud_points(Some("seedance2.0-fast"), "720p", 6),
132
);
assert_eq!(
config.sound_effect_model_mud_points(Some(EDITOR_SOUND_EFFECT_MODEL_ELEVENLABS)),
16
);
assert_eq!(config.sound_effect_model_mud_points(Some("audio1.0")), 15);
assert_eq!(
config.background_music_model_mud_points(Some("chirp-v5")),
@@ -901,6 +929,7 @@ mod tests {
"prices": { "480p": 10, "720p": 20, "1080p": 40 }
},
"audio1.0": { "unit": "perGeneration", "price": 10 },
"eleven_text_to_sound_v2": { "unit": "perGeneration", "price": 5 },
"chirp-v5": { "unit": "perGeneration", "price": 5 }
}
}"#,
@@ -11,6 +11,7 @@ use spacetime_client::{ExternalGenerationJobEnqueueRecordInput, ExternalGenerati
use crate::{http_error::AppError, request_context::RequestContext, state::AppState};
pub(crate) const EDITOR_IMAGE_GENERATION_JOB_KIND: &str = "editor_image_generation";
pub(crate) const EDITOR_ICON_SPEC_GENERATION_JOB_KIND: &str = "editor_icon_spec_generation";
pub(crate) const EDITOR_IMAGE_EDIT_JOB_KIND: &str = "editor_image_edit";
pub(crate) const EDITOR_BACKGROUND_REMOVAL_JOB_KIND: &str = "editor_background_removal";
pub(crate) const EDITOR_ICON_SPRITESHEET_GENERATION_JOB_KIND: &str =
@@ -205,14 +206,6 @@ fn ensure_editor_generation_job_matches_request(
Ok(job)
}
fn generation_input_references(value: &Value) -> Option<&Value> {
value
.as_object()
.and_then(|payload| payload.get("generationInputs"))
.and_then(Value::as_object)
.and_then(|generation_inputs| generation_inputs.get("references"))
}
fn strip_untrusted_generation_input_references_from_payload(value: &mut Value) -> bool {
let Some(generation_inputs) = value
.as_object_mut()
@@ -224,6 +217,10 @@ fn strip_untrusted_generation_input_references_from_payload(value: &mut Value) -
generation_inputs.remove("references").is_some()
}
fn generation_input_references(value: &Value) -> Option<&Value> {
value.pointer("/generationInputs/references")
}
fn job_kind_migrated_away_from_client_generation_references(job_kind: &str) -> bool {
matches!(
job_kind,
@@ -243,22 +240,26 @@ fn editor_generation_request_payloads_match(
if existing == requested {
return true;
}
let (Ok(mut existing), Ok(requested)) = (
let (Ok(mut existing), Ok(mut requested)) = (
serde_json::from_str::<Value>(existing),
serde_json::from_str::<Value>(requested),
) else {
return false;
};
// 只兼容部署前旧 payload 有 references、当前 sanitizer 已删除该字段的单向迁移。
// 音频、视频、角色动作等仍会保留 references;如果当前请求也带该字段,就必须完整
// 比较,不能把两个不同请求错误复用成同一任务。
if !job_kind_migrated_away_from_client_generation_references(job_kind)
|| generation_input_references(&existing).is_none()
|| generation_input_references(&requested).is_some()
|| !strip_untrusted_generation_input_references_from_payload(&mut existing)
// 这些站内 job 的真实来源始终由 sourceImageSrc / referenceImageSrcs 等请求字段表达,
// generationInputs.references 只携带随后由 owner-scoped 记录重建的展示槽位。滚动部署
// 期间旧任务可能没有 references、新任务可能只含安全 id;比较幂等请求时两边都移除
// 该冗余字段,实际媒体来源或其它参数的任何变化仍会冲突。视频等未迁移 job 保持严格比较。
if !job_kind_migrated_away_from_client_generation_references(job_kind) {
return false;
}
if generation_input_references(&existing).is_some()
== generation_input_references(&requested).is_some()
{
return false;
}
strip_untrusted_generation_input_references_from_payload(&mut existing);
strip_untrusted_generation_input_references_from_payload(&mut requested);
existing == requested
}
@@ -321,6 +322,15 @@ async fn enqueue_serialized_editor_generation_job_with_identity(
job_id: String,
dedupe_key: String,
) -> Result<ExternalGenerationJobRecord, AppError> {
#[cfg(test)]
if state.record_test_editor_generation_enqueue_attempt() {
return Err(
AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({
"provider": EDITOR_GENERATION_QUEUE_PROVIDER,
"message": "测试已在 SpacetimeDB 写入前截获编辑器生成入队",
})),
);
}
let now_micros = current_utc_micros();
state
.spacetime_client()
@@ -613,6 +623,84 @@ mod tests {
assert_eq!(error.status_code(), StatusCode::CONFLICT);
}
#[test]
fn replayed_migrated_job_accepts_new_safe_reference_slots() {
let mut job = queue_job_fixture("queued", None);
job.owner_user_id = "user-1".to_string();
job.job_kind = EDITOR_IMAGE_GENERATION_JOB_KIND.to_string();
job.request_payload_json = serde_json::to_string(&json!({
"prompt": "same",
"referenceImageSrcs": ["asset-1"],
"generationInputs": {
"version": 2,
"action": "image.generate",
"fields": [{"id": "prompt", "title": "提示词", "value": "same"}]
}
}))
.expect("existing payload should serialize");
let requested = serde_json::to_string(&json!({
"prompt": "same",
"referenceImageSrcs": ["asset-1"],
"generationInputs": {
"version": 2,
"action": "image.generate",
"fields": [{"id": "prompt", "title": "提示词", "value": "same"}],
"references": [{"id": "reference"}]
}
}))
.expect("requested payload should serialize");
assert!(
ensure_editor_generation_job_matches_request(
job,
"user-1",
EDITOR_IMAGE_GENERATION_JOB_KIND,
requested.as_str(),
"幂等冲突",
)
.is_ok()
);
}
#[test]
fn replayed_migrated_jobs_with_different_safe_slots_conflict() {
let mut job = queue_job_fixture("queued", None);
job.owner_user_id = "user-1".to_string();
job.job_kind = EDITOR_IMAGE_GENERATION_JOB_KIND.to_string();
job.request_payload_json = serde_json::to_string(&json!({
"prompt": "same",
"referenceImageSrcs": ["asset-1"],
"generationInputs": {
"version": 2,
"action": "image.generate",
"fields": [],
"references": [{"id": "source"}]
}
}))
.expect("existing payload should serialize");
let requested = serde_json::to_string(&json!({
"prompt": "same",
"referenceImageSrcs": ["asset-1"],
"generationInputs": {
"version": 2,
"action": "image.generate",
"fields": [],
"references": [{"id": "specReference"}]
}
}))
.expect("requested payload should serialize");
let error = ensure_editor_generation_job_matches_request(
job,
"user-1",
EDITOR_IMAGE_GENERATION_JOB_KIND,
requested.as_str(),
"幂等冲突",
)
.expect_err("different migrated safe slots must conflict");
assert_eq!(error.status_code(), StatusCode::CONFLICT);
}
#[test]
fn replayed_jobs_with_references_on_both_sides_compare_them_strictly() {
let mut job = queue_job_fixture("queued", None);
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
@@ -27,6 +27,15 @@ pub struct ExternalApiPrincipal {
}
impl ExternalApiPrincipal {
#[cfg(test)]
pub(crate) fn for_test(owner_user_id: &str, scopes: &[&str]) -> Self {
Self {
owner_user_id: owner_user_id.to_string(),
key_id: "external-api-key-test".to_string(),
scopes: scopes.iter().map(|scope| (*scope).to_string()).collect(),
}
}
pub fn owner_user_id(&self) -> &str {
self.owner_user_id.as_str()
}
File diff suppressed because it is too large Load Diff
@@ -20,7 +20,8 @@ use spacetime_client::{
};
use crate::editor_generation_queue::{
EDITOR_BACKGROUND_REMOVAL_JOB_KIND, EDITOR_CHARACTER_ANIMATION_GENERATION_JOB_KIND,
EDITOR_BACKGROUND_MUSIC_GENERATION_JOB_KIND, EDITOR_BACKGROUND_REMOVAL_JOB_KIND,
EDITOR_CHARACTER_ANIMATION_GENERATION_JOB_KIND, EDITOR_SOUND_EFFECT_GENERATION_JOB_KIND,
};
use crate::{
api_response::json_success_body, auth::AuthenticatedAccessToken, http_error::AppError,
@@ -211,17 +212,27 @@ fn map_external_generation_job_status(
}
}
/// 外部任务表保存的失败文本同时服务于 worker 诊断和用户通知。抠图及角色动作透明化任务的
/// 原始失败文本可能包含内部 provider、分割模型或实现细节,因此只能在普通用户读取边界
/// 替换成稳定文案。
/// 外部任务表保存的失败文本同时服务于 worker 诊断和用户通知。抠图、角色动作透明化和音效
/// 任务的原始失败文本可能包含内部 provider、分割模型、请求端点或 HTTP 传输层细节,因此
/// 只能在普通用户读取边界替换成稳定文案。
/// 原始值仍留在任务记录、tracing 和后台审计路径中。
fn user_visible_external_generation_error(job_kind: &str, error: Option<String>) -> Option<String> {
pub(crate) fn user_visible_external_generation_error(
job_kind: &str,
error: Option<String>,
) -> Option<String> {
if job_kind == EDITOR_BACKGROUND_REMOVAL_JOB_KIND && error.is_some() {
return Some("去除背景失败,请稍后重试。".to_string());
}
if job_kind == EDITOR_CHARACTER_ANIMATION_GENERATION_JOB_KIND && error.is_some() {
return Some("角色动作生成失败,请稍后重试。".to_string());
}
// 音效失败文本来自 ElevenLabs / reqwest,会带上请求端点、底层错误链和上游状态码。
if job_kind == EDITOR_SOUND_EFFECT_GENERATION_JOB_KIND && error.is_some() {
return Some("音效生成失败,请稍后重试。".to_string());
}
if job_kind == EDITOR_BACKGROUND_MUSIC_GENERATION_JOB_KIND && error.is_some() {
return Some("背景音乐生成失败,请稍后重试。".to_string());
}
error
}
@@ -500,6 +511,74 @@ mod tests {
assert!(!lower.contains("provider"));
}
#[test]
fn sound_effect_failure_hides_provider_and_transport_details_from_owner() {
let job = ExternalGenerationJobSummaryRecord {
job_id: "task-sound-effect-failed".to_string(),
job_kind: EDITOR_SOUND_EFFECT_GENERATION_JOB_KIND.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: "failed".to_string(),
last_error_message: Some(
"请求 ElevenLabs 音效生成失败:error sending request for url \
(https://api.elevenlabs.io/v1/sound-generation?output_format=mp3_44100_128): \
connection error: Connection refused (os error 111)"
.to_string(),
),
created_at: "2026-08-07T08:00:00Z".to_string(),
started_at: Some("2026-08-07T08:00:01Z".to_string()),
completed_at: Some("2026-08-07T08:00:10Z".to_string()),
updated_at: "2026-08-07T08:00:10Z".to_string(),
updated_at_micros: 1_000,
price_mud_points: 5,
refund_ledger_id: None,
notification_acknowledged_at: None,
notification_acknowledged_at_micros: None,
phase: None,
warning_message: None,
};
let status = map_external_generation_job_status(job.clone());
let task = map_external_generation_task_record(job);
assert_eq!(status.error.as_deref(), Some("音效生成失败,请稍后重试。"));
assert_eq!(task.error.as_deref(), Some("音效生成失败,请稍后重试。"));
for value in [status.error, task.error].into_iter().flatten() {
let lower = value.to_ascii_lowercase();
assert!(!lower.contains("elevenlabs"));
assert!(!lower.contains("api.elevenlabs.io"));
assert!(!lower.contains("sound-generation"));
assert!(!lower.contains("os error"));
}
}
#[test]
fn background_music_failure_hides_provider_and_transport_details_from_owner() {
let message = user_visible_external_generation_error(
EDITOR_BACKGROUND_MUSIC_GENERATION_JOB_KIND,
Some(
"提交 Vector Engine 背景音乐任务失败:error sending request for url \
(https://vector-engine.internal/v1/audio/generations): connection error: \
Connection refused (os error 111)"
.to_string(),
),
);
assert_eq!(message.as_deref(), Some("背景音乐生成失败,请稍后重试。"));
let lower = message
.expect("失败任务应返回稳定文案")
.to_ascii_lowercase();
for forbidden in ["vector engine", "vector-engine", "https://", "os error"] {
assert!(
!lower.contains(forbidden),
"普通用户文案不应包含 {forbidden}"
);
}
}
#[test]
fn unrelated_generation_failure_keeps_its_user_visible_message() {
let message = user_visible_external_generation_error(
File diff suppressed because it is too large Load Diff
+224 -23
View File
@@ -262,11 +262,16 @@ fn build_mcp_operations() -> Vec<McpOperation> {
.or_else(|| operation.get("summary"))
.and_then(Value::as_str)
.unwrap_or("调用陶泥儿外部编辑器 API");
let path_template = if operation_id == "listEditorProjects" {
format!("{path}?view=summary")
} else {
path.clone()
};
operations.push(McpOperation {
tool_name: camel_to_snake(operation_id),
operation_id: operation_id.to_string(),
method,
path_template: path.clone(),
path_template,
description: format!("{description}({} {path})", method_name.to_uppercase()),
input_schema: Arc::new(build_operation_input_schema(
&openapi,
@@ -289,6 +294,8 @@ fn build_operation_input_schema(
requires_idempotency_key: bool,
) -> Map<String, Value> {
let mut properties = Map::new();
let fixes_project_list_to_summary =
operation.get("operationId").and_then(Value::as_str) == Some("listEditorProjects");
let parameters = path_item
.get("parameters")
.and_then(Value::as_array)
@@ -314,6 +321,9 @@ fn build_operation_input_schema(
let Some(name) = parameter.get("name").and_then(Value::as_str) else {
continue;
};
if fixes_project_list_to_summary && location == "query" && name == "view" {
continue;
}
parameter_properties.insert(
name.to_string(),
parameter
@@ -447,6 +457,8 @@ async fn dispatch_operation(
arguments: Map<String, Value>,
context: &McpRequestContext<RoleServer>,
) -> Result<Value, Value> {
validate_required_body(operation, &arguments)?;
let parts = context
.extensions
.get::<axum::http::request::Parts>()
@@ -479,28 +491,7 @@ async fn dispatch_operation(
return Err(json!({"error": "缺少必填路径参数"}));
}
if let Some(query) = arguments.get("queryParameters").and_then(Value::as_object) {
let mut serializer = url::form_urlencoded::Serializer::new(String::new());
for (name, value) in query {
match value {
Value::Array(values) => {
for value in values {
if let Some(value) = json_scalar_string(value) {
serializer.append_pair(name, &value);
}
}
}
value => {
if let Some(value) = json_scalar_string(value) {
serializer.append_pair(name, &value);
}
}
}
}
let query = serializer.finish();
if !query.is_empty() {
path.push('?');
path.push_str(&query);
}
append_operation_query_parameters(operation, &mut path, query);
}
let body = arguments.get("body").cloned().unwrap_or(Value::Null);
@@ -566,6 +557,95 @@ async fn dispatch_operation(
}
}
fn validate_required_body(
operation: &McpOperation,
arguments: &Map<String, Value>,
) -> Result<(), Value> {
let body_is_required = operation
.input_schema
.get("required")
.and_then(Value::as_array)
.is_some_and(|required| required.iter().any(|name| name.as_str() == Some("body")));
if !body_is_required {
return Ok(());
}
let required_fields = operation.input_schema["properties"]["body"]
.get("required")
.and_then(Value::as_array)
.cloned()
.unwrap_or_default();
let Some(body) = arguments.get("body").filter(|body| !body.is_null()) else {
return Err(json!({
"error": "缺少请求体",
"requiredFields": required_fields,
}));
};
let Some(body) = body.as_object() else {
return Err(json!({
"error": "请求体必须是 JSON 对象",
"expectedType": "object",
}));
};
let missing_fields = required_fields
.iter()
.filter(|field| {
field
.as_str()
.is_some_and(|field| !body.contains_key(field))
})
.cloned()
.collect::<Vec<_>>();
if missing_fields.is_empty() {
Ok(())
} else {
Err(json!({
"error": "缺少必填字段",
"missingFields": missing_fields,
}))
}
}
fn append_query_parameters(path: &mut String, query: &Map<String, Value>) {
let mut serializer = url::form_urlencoded::Serializer::new(String::new());
for (name, value) in query {
match value {
Value::Array(values) => {
for value in values {
if let Some(value) = json_scalar_string(value) {
serializer.append_pair(name, &value);
}
}
}
value => {
if let Some(value) = json_scalar_string(value) {
serializer.append_pair(name, &value);
}
}
}
}
let query = serializer.finish();
if !query.is_empty() {
path.push(if path.contains('?') { '&' } else { '?' });
path.push_str(&query);
}
}
fn append_operation_query_parameters(
operation: &McpOperation,
path: &mut String,
query: &Map<String, Value>,
) {
if operation.operation_id == "listEditorProjects" {
let mut query = query.clone();
query.remove("view");
append_query_parameters(path, &query);
} else {
append_query_parameters(path, query);
}
}
fn unwrap_external_api_success_payload(payload: Value) -> Value {
payload
.get("data")
@@ -674,6 +754,127 @@ mod tests {
assert_eq!(create_project.method, Method::POST);
}
#[test]
fn project_list_tool_always_requests_summary_view() {
let operation = MCP_OPERATIONS
.iter()
.find(|operation| operation.tool_name == "list_editor_projects")
.expect("project list tool should exist");
assert_eq!(
operation.path_template,
"/api/external/v1/editor/projects?view=summary"
);
assert!(
operation.input_schema["properties"]
.get("queryParameters")
.is_none(),
"MCP 项目列表固定 summary 后不得再向 Agent 暴露 REST view 参数"
);
let mut path = operation.path_template.clone();
append_operation_query_parameters(
operation,
&mut path,
&Map::from_iter([
("limit".to_string(), json!(20)),
("view".to_string(), json!("full")),
]),
);
assert_eq!(
path,
"/api/external/v1/editor/projects?view=summary&limit=20"
);
let mut path = "/api/external/v1/editor/assets".to_string();
append_query_parameters(
&mut path,
&Map::from_iter([("folderId".to_string(), json!("folder-1"))]),
);
assert_eq!(path, "/api/external/v1/editor/assets?folderId=folder-1");
}
#[test]
fn required_request_body_errors_are_derived_from_tool_schema() {
for (tool_name, required_fields) in [
(
"confirm_external_asset_object",
vec![json!("objectKey"), json!("assetKind")],
),
(
"create_editor_asset",
vec![
json!("folderId"),
json!("label"),
json!("imageSrc"),
json!("width"),
json!("height"),
json!("sourceType"),
],
),
("create_editor_asset_folder", vec![json!("label")]),
(
"create_external_direct_upload_ticket",
vec![json!("legacyPrefix"), json!("fileName")],
),
] {
let operation = MCP_OPERATIONS
.iter()
.find(|operation| operation.tool_name == tool_name)
.unwrap_or_else(|| panic!("{tool_name} should exist"));
for arguments in [
Map::new(),
Map::from_iter([("body".to_string(), Value::Null)]),
] {
assert_eq!(
validate_required_body(operation, &arguments),
Err(json!({
"error": "缺少请求体",
"requiredFields": required_fields,
})),
"{tool_name}"
);
}
assert_eq!(
validate_required_body(
operation,
&Map::from_iter([("body".to_string(), json!({}))]),
),
Err(json!({
"error": "缺少必填字段",
"missingFields": required_fields,
})),
"{tool_name}"
);
assert_eq!(
validate_required_body(
operation,
&Map::from_iter([("body".to_string(), json!([]))]),
),
Err(json!({
"error": "请求体必须是 JSON 对象",
"expectedType": "object",
})),
"{tool_name}"
);
}
}
#[test]
fn optional_request_body_is_not_rejected() {
let operation = MCP_OPERATIONS
.iter()
.find(|operation| operation.tool_name == "create_editor_project")
.expect("project create tool should exist");
assert_eq!(validate_required_body(operation, &Map::new()), Ok(()));
assert_eq!(
validate_required_body(
operation,
&Map::from_iter([("body".to_string(), Value::Null)]),
),
Ok(())
);
}
#[test]
fn generation_tools_require_idempotency_key() {
let operation = MCP_OPERATIONS
@@ -0,0 +1,392 @@
use axum::{
Json,
extract::{Extension, State},
http::StatusCode,
};
use platform_llm::{EDITOR_AGENT_GPT5_MODEL, LlmMessage, LlmRunRequest};
use serde::Deserialize;
use serde_json::{Value, json};
use std::time::Duration;
use tokio::time::sleep;
use crate::{
api_response::json_success_body,
auth::AuthenticatedAccessToken,
http_error::AppError,
platform_errors::{is_retryable_llm_error, map_llm_error},
prompt::icon_spec::{
PROMPT_MAX_LEN, build_refine_art_style_prompt, build_refine_play_setting_prompt,
},
request_context::RequestContext,
state::AppState,
};
const ICON_SPEC_LLM_MAX_ATTEMPTS: u32 = 3;
const ICON_SPEC_LLM_MAX_OUTPUT_TOKENS: u32 = 1024;
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
pub(crate) struct RefineGamePlayRequest {
play_setting: String,
}
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
pub(crate) struct RefineArtStyleRequest {
art_style: String,
}
pub(crate) async fn refine_game_play(
State(state): State<AppState>,
Extension(request_context): Extension<RequestContext>,
Extension(_authenticated): Extension<AuthenticatedAccessToken>,
Json(payload): Json<RefineGamePlayRequest>,
) -> Result<Json<Value>, AppError> {
let input = require_refine_text(payload.play_setting, "playSetting", "玩法设定")?;
let prompt = build_refine_play_setting_prompt(input.as_str());
let refined = run_refine_text_llm(&state, prompt).await?;
Ok(json_success_body(
Some(&request_context),
json!({ "playSetting": refined }),
))
}
pub(crate) async fn refine_art_style(
State(state): State<AppState>,
Extension(request_context): Extension<RequestContext>,
Extension(_authenticated): Extension<AuthenticatedAccessToken>,
Json(payload): Json<RefineArtStyleRequest>,
) -> Result<Json<Value>, AppError> {
let input = require_refine_text(payload.art_style, "artStyle", "美术风格")?;
let prompt = build_refine_art_style_prompt(input.as_str());
let refined = run_refine_text_llm(&state, prompt).await?;
Ok(json_success_body(
Some(&request_context),
json!({ "artStyle": refined }),
))
}
fn require_refine_text(
value: String,
field: &'static str,
field_label: &'static str,
) -> Result<String, AppError> {
let value = value.trim().to_string();
if value.is_empty() {
return Err(
AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({
"provider": "editor-icon-spec",
"field": field,
"message": format!("{field_label}不能为空"),
})),
);
}
let actual_length = value.chars().count();
if actual_length > PROMPT_MAX_LEN {
return Err(
AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({
"provider": "editor-icon-spec",
"field": field,
"maxLength": PROMPT_MAX_LEN,
"actualLength": actual_length,
"message": format!("{field_label}不能超过 {PROMPT_MAX_LEN} 个字符"),
})),
);
}
Ok(value)
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
enum RefinedTextError {
Empty,
TooLong,
InvalidFormat,
Incomplete,
}
impl RefinedTextError {
fn message(self) -> String {
match self {
Self::Empty => "LLM 未返回可用文本".to_string(),
Self::TooLong => format!("LLM 返回文本超过 {PROMPT_MAX_LEN} 个字符"),
Self::InvalidFormat => "LLM 返回内容不是可直接使用的纯文本".to_string(),
Self::Incomplete => "LLM 返回内容未完整结束".to_string(),
}
}
fn repair_instruction(self) -> String {
match self {
Self::Empty => "上次输出为空。请重新输出一条可直接使用的纯文本。".to_string(),
Self::TooLong => format!(
"上次输出超过 {PROMPT_MAX_LEN} 个字符。请压缩到限制内,只保留可直接使用的正文。"
),
Self::InvalidFormat => {
"上次输出包含 Markdown、结构化数据或解释性前缀。请只输出可直接使用的纯文本正文。"
.to_string()
}
Self::Incomplete => {
"上次输出因长度、内容过滤或上游中止而未完整结束。请重新输出完整的纯文本正文。"
.to_string()
}
}
}
}
fn normalize_refined_text_response(
value: &str,
finish_reason: Option<&str>,
) -> Result<String, RefinedTextError> {
if finish_reason.is_some_and(|reason| {
platform_llm::is_incomplete_finish_reason(platform_llm::LlmApiKind::OpenAiChat, reason)
}) {
return Err(RefinedTextError::Incomplete);
}
normalize_refined_text(value)
}
fn normalize_refined_text(value: &str) -> Result<String, RefinedTextError> {
let value = value.trim();
if value.is_empty() {
return Err(RefinedTextError::Empty);
}
if value.chars().count() > PROMPT_MAX_LEN {
return Err(RefinedTextError::TooLong);
}
let disallowed_prefixes = [
"```",
"#",
"> ",
"- ",
"* ",
"+ ",
"说明:",
"说明:",
"解释:",
"解释:",
];
let has_disallowed_format = value.lines().any(|line| {
let line = line.trim_start();
disallowed_prefixes
.iter()
.any(|prefix| line.starts_with(prefix))
|| line.starts_with(['{', '['])
|| is_markdown_ordered_list_item(line)
|| ["**", "__", "~~"]
.iter()
.any(|marker| line.contains(marker))
|| line.contains('`')
});
if has_disallowed_format {
return Err(RefinedTextError::InvalidFormat);
}
Ok(value.to_string())
}
fn is_markdown_ordered_list_item(line: &str) -> bool {
let digit_count = line
.chars()
.take_while(|character| character.is_ascii_digit())
.count();
if digit_count == 0 {
return false;
}
let suffix = &line[digit_count..];
suffix.starts_with(". ") || suffix.starts_with(") ")
}
async fn run_refine_text_llm(state: &AppState, prompt: String) -> Result<String, AppError> {
let configured_llm_client = state.vector_engine_llm_client().ok_or_else(|| {
AppError::from_status(StatusCode::SERVICE_UNAVAILABLE).with_details(json!({
"provider": "editor-icon-spec-llm",
"message": "服务端尚未配置可用的 VectorEngine LLM API Key",
}))
})?;
let retry_backoff_ms = configured_llm_client.config().retry_backoff_ms();
let llm_client = configured_llm_client.clone().with_max_retries(0);
let mut request = build_refine_text_llm_request(prompt.clone());
for attempt in 1..=ICON_SPEC_LLM_MAX_ATTEMPTS {
match llm_client.run(request.clone()).await {
Ok(response) => match normalize_refined_text_response(
response.text.as_str(),
response.finish_reason.as_deref(),
) {
Ok(text) => return Ok(text),
Err(error) if attempt == ICON_SPEC_LLM_MAX_ATTEMPTS => {
return Err(AppError::from_status(StatusCode::BAD_GATEWAY).with_details(
json!({
"provider": "editor-icon-spec-llm",
"maxLength": PROMPT_MAX_LEN,
"message": error.message(),
}),
));
}
Err(error) => {
request =
build_refine_text_llm_repair_request(prompt.clone(), response.text, error);
}
},
Err(error) if is_retryable_llm_error(&error) => {
if attempt == ICON_SPEC_LLM_MAX_ATTEMPTS {
return Err(map_llm_error(error));
}
}
Err(error) => return Err(map_llm_error(error)),
}
sleep_before_icon_spec_llm_retry(retry_backoff_ms, attempt).await;
}
unreachable!("icon spec refine retry loop always returns")
}
fn build_refine_text_llm_request(prompt: String) -> LlmRunRequest {
build_refine_text_llm_request_with_messages(vec![LlmMessage::user(prompt)])
}
fn build_refine_text_llm_repair_request(
prompt: String,
invalid_response: String,
error: RefinedTextError,
) -> LlmRunRequest {
build_refine_text_llm_request_with_messages(vec![
LlmMessage::user(prompt),
LlmMessage::assistant(invalid_response),
LlmMessage::user(error.repair_instruction()),
])
}
fn build_refine_text_llm_request_with_messages(messages: Vec<LlmMessage>) -> LlmRunRequest {
LlmRunRequest::new(messages)
.with_model(EDITOR_AGENT_GPT5_MODEL)
.with_max_output_tokens(ICON_SPEC_LLM_MAX_OUTPUT_TOKENS)
.with_openai_chat()
}
async fn sleep_before_icon_spec_llm_retry(retry_backoff_ms: u64, attempt: u32) {
let delay_ms = retry_backoff_ms.saturating_mul(u64::from(attempt));
if delay_ms > 0 {
sleep(Duration::from_millis(delay_ms)).await;
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn refine_request_uses_play_setting_contract() {
let request: RefineGamePlayRequest = serde_json::from_value(json!({
"playSetting": "回合制占点"
}))
.expect("playSetting request should parse");
assert_eq!(request.play_setting, "回合制占点");
assert!(
serde_json::from_value::<RefineGamePlayRequest>(json!({
"gamePlay": "旧字段"
}))
.is_err()
);
}
#[test]
fn refine_text_enforces_prompt_max_length_by_unicode_characters() {
assert!(
require_refine_text("玩".repeat(PROMPT_MAX_LEN), "playSetting", "玩法设定").is_ok()
);
assert!(
require_refine_text("玩".repeat(PROMPT_MAX_LEN + 1), "playSetting", "玩法设定")
.is_err()
);
assert!(normalize_refined_text("画".repeat(PROMPT_MAX_LEN).as_str()).is_ok());
assert!(normalize_refined_text("画".repeat(PROMPT_MAX_LEN + 1).as_str()).is_err());
}
#[test]
fn refined_text_rejects_structured_or_explanatory_output() {
assert_eq!(
normalize_refined_text("```json\n{}\n```").unwrap_err(),
RefinedTextError::InvalidFormat
);
assert_eq!(
normalize_refined_text("说明:优化后的玩法").unwrap_err(),
RefinedTextError::InvalidFormat
);
assert_eq!(
normalize_refined_text(r#"{"playSetting":"回合制"}"#).unwrap_err(),
RefinedTextError::InvalidFormat
);
for invalid in [
"1. 回合制占点",
"2) 资源调度",
"##优化后的玩法",
"**回合制占点**",
"正文说明\n{\"playSetting\":\"回合制\"}",
"正文说明\n[\"回合制\"]",
] {
assert_eq!(
normalize_refined_text(invalid).unwrap_err(),
RefinedTextError::InvalidFormat,
"structured or Markdown output should fail: {invalid}",
);
}
assert_eq!(
normalize_refined_text("回合制占点,围绕资源调度形成攻防循环")
.expect("plain text should pass"),
"回合制占点,围绕资源调度形成攻防循环"
);
assert_eq!(
normalize_refined_text("核心循环围绕回合制占点。\n玩家通过资源调度形成攻防转换。")
.expect("multi-line plain text should pass"),
"核心循环围绕回合制占点。\n玩家通过资源调度形成攻防转换。"
);
}
#[test]
fn refined_text_rejects_incomplete_provider_completion() {
assert_eq!(
normalize_refined_text_response("看似完整但实际被截断", Some("length")).unwrap_err(),
RefinedTextError::Incomplete
);
assert_eq!(
normalize_refined_text_response("完整结果", Some("stop"))
.expect("stop completion should pass"),
"完整结果"
);
}
#[test]
fn refine_request_bounds_provider_output() {
let request = build_refine_text_llm_request("优化玩法".to_string());
assert_eq!(
request.max_output_tokens,
Some(ICON_SPEC_LLM_MAX_OUTPUT_TOKENS)
);
assert_eq!(request.api_kind, platform_llm::LlmApiKind::OpenAiChat);
}
#[test]
fn refine_repair_request_preserves_invalid_output_and_adds_targeted_correction() {
let request = build_refine_text_llm_repair_request(
"优化玩法".to_string(),
"```markdown\n说明:优化结果\n```".to_string(),
RefinedTextError::InvalidFormat,
);
assert_eq!(request.messages.len(), 3);
assert_eq!(request.messages[0].role, platform_llm::LlmMessageRole::User);
assert_eq!(
request.messages[1].role,
platform_llm::LlmMessageRole::Assistant
);
assert_eq!(
request.messages[1].content,
"```markdown\n说明:优化结果\n```"
);
assert_eq!(request.messages[2].role, platform_llm::LlmMessageRole::User);
assert!(
request.messages[2]
.content
.contains("只输出可直接使用的纯文本正文")
);
}
}
@@ -19,6 +19,8 @@ use crate::{
platform_errors::map_llm_error, request_context::RequestContext, state::AppState,
};
pub(crate) mod icon_specs;
pub async fn proxy_llm_chat_completions(
State(state): State<AppState>,
Extension(request_context): Extension<RequestContext>,

Some files were not shown because too many files have changed in this diff Show More