合并最新 master 并解决画布共享冲突
同步 origin/master 的场景、音效、运行时与后端原子提交改动 保留共享画布包导出并补齐场景、生成配方和历史动作类型 融合清单刷新测试、幂等请求、生成合同校验与项目记忆文档
This commit is contained in:
Generated
+63
@@ -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"
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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
@@ -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
Reference in New Issue
Block a user