Merge remote-tracking branch 'origin/master' into fix/copy-pasted-on-canva
Project CI / Repository checks (pull_request) Failing after 11s
Project CI / Backend tests (pull_request) Failing after 11s
Project CI / Frontend tests (pull_request) Successful in 4m2s
Project CI / Native shell tests (pull_request) Successful in 12m34s

# Conflicts:
#	docs/project-memory/shared-memory/decision-log.md
This commit is contained in:
2026-08-03 17:20:50 +08:00
72 changed files with 7633 additions and 1456 deletions
+103
View File
@@ -231,6 +231,7 @@ dependencies = [
"platform-wechat",
"reqwest",
"ring",
"rmcp",
"serde",
"serde_json",
"sha1",
@@ -827,6 +828,17 @@ dependencies = [
"libc",
]
[[package]]
name = "chacha20"
version = "0.10.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d524456ba66e72eb8b115ff89e01e497f8e6d11d78b70b1aa13c0fbd97540a81"
dependencies = [
"cfg-if",
"cpufeatures 0.3.0",
"rand_core 0.10.1",
]
[[package]]
name = "chrono"
version = "0.4.45"
@@ -1932,6 +1944,7 @@ dependencies = [
"cfg-if",
"libc",
"r-efi 6.0.0",
"rand_core 0.10.1",
"wasip2",
"wasip3",
]
@@ -3679,6 +3692,12 @@ version = "1.0.15"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "57c0d7b74b563b49d38dae00a0c37d4d6de9b432382b2892f0574ddcae73fd0a"
[[package]]
name = "pastey"
version = "0.2.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2ee67f1008b1ba2321834326597b8e186293b049a023cdef258527550b9935b4"
[[package]]
name = "pem"
version = "3.0.6"
@@ -4516,6 +4535,17 @@ dependencies = [
"rand_core 0.9.5",
]
[[package]]
name = "rand"
version = "0.10.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c7f5fa3a058cd35567ef9bfa5e75732bee0f9e4c55fa90477bef2dfcdbc4be80"
dependencies = [
"chacha20",
"getrandom 0.4.2",
"rand_core 0.10.1",
]
[[package]]
name = "rand_chacha"
version = "0.3.1"
@@ -4554,6 +4584,12 @@ dependencies = [
"getrandom 0.3.4",
]
[[package]]
name = "rand_core"
version = "0.10.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "63b8176103e19a2643978565ca18b50549f6101881c443590420e4dc998a3c69"
[[package]]
name = "raw-window-handle"
version = "0.6.2"
@@ -4720,6 +4756,35 @@ dependencies = [
"windows-sys 0.52.0",
]
[[package]]
name = "rmcp"
version = "2.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "14db48ee17a9ba61810ab1a9c1beb7d06d8136ae39ac25a1137f10d357af01af"
dependencies = [
"async-trait",
"bytes",
"chrono",
"futures",
"http",
"http-body",
"http-body-util",
"pastey",
"pin-project-lite",
"rand 0.10.2",
"schemars 1.2.1",
"serde",
"serde_json",
"sse-stream",
"thiserror 2.0.18",
"tokio",
"tokio-stream",
"tokio-util",
"tower-service",
"tracing",
"uuid",
]
[[package]]
name = "rmp"
version = "0.8.15"
@@ -4931,12 +4996,26 @@ version = "1.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a2b42f36aa1cd011945615b92222f6bf73c599a102a300334cd7f8dbeec726cc"
dependencies = [
"chrono",
"dyn-clone",
"ref-cast",
"schemars_derive",
"serde",
"serde_json",
]
[[package]]
name = "schemars_derive"
version = "1.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7d115b50f4aaeea07e79c1912f645c7513d81715d0420f8bc77a18c6260b307f"
dependencies = [
"proc-macro2",
"quote",
"serde_derive_internals",
"syn 2.0.118",
]
[[package]]
name = "scoped-tls"
version = "1.0.1"
@@ -5027,6 +5106,17 @@ dependencies = [
"syn 2.0.118",
]
[[package]]
name = "serde_derive_internals"
version = "0.29.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "18d26a20a969b9e3fdf2fc2d9f21eda6c40e2de84c9408bb5d3b05d499aae711"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.118",
]
[[package]]
name = "serde_json"
version = "1.0.150"
@@ -5670,6 +5760,19 @@ dependencies = [
"log",
]
[[package]]
name = "sse-stream"
version = "0.2.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c123f296ade4ec4b8b0f6162116e6629f5146922ca5ab40ca9d3c2e73ab4761e"
dependencies = [
"bytes",
"futures-util",
"http-body",
"http-body-util",
"pin-project-lite",
]
[[package]]
name = "stable_deref_trait"
version = "1.2.1"
+1
View File
@@ -112,6 +112,7 @@ pingora-http = { version = "0.8.1", default-features = false }
pingora-proxy = { version = "0.8.1", default-features = false }
rand_core = "0.6"
reqwest = { version = "0.12", default-features = false }
rmcp = { version = "=2.2.0", default-features = false }
ring = "0.17"
serde = { version = "1", features = ["derive"] }
serde_json = "1"
+2 -1
View File
@@ -16,6 +16,7 @@ hex = { workspace = true }
image = { workspace = true, features = ["jpeg", "png", "webp"] }
http-body-util = { workspace = true }
reqwest = { workspace = true, features = ["json", "multipart", "rustls-tls"] }
rmcp = { workspace = true, features = ["server", "transport-streamable-http-server"] }
webp = { workspace = true }
module-ai = { workspace = true }
module-assets = { workspace = true, features = ["server-service"] }
@@ -48,6 +49,7 @@ tokio-stream = { workspace = true }
futures-util = { workspace = true }
time = { workspace = true, features = ["formatting"] }
tower-http = { workspace = true, features = ["trace"] }
tower = { workspace = true, features = ["util"] }
tracing = { workspace = true }
opentelemetry = { workspace = true }
url = { workspace = true }
@@ -62,4 +64,3 @@ windows-sys = { workspace = true, features = ["Win32_Foundation", "Win32_System_
base64 = { workspace = true }
http-body-util = { workspace = true }
reqwest = { workspace = true, features = ["json", "multipart", "rustls-tls"] }
tower = { workspace = true, features = ["util"] }
@@ -48,7 +48,7 @@ use shared_contracts::assets::{
use shared_contracts::assets::{
CharacterRoleAssetWorkflowResolveRequest, CharacterRoleAssetWorkflowResponse,
};
use spacetime_client::SpacetimeClientError;
use spacetime_client::{ExternalGenerationJobRecord, SpacetimeClientError};
use crate::{
api_response::json_success_body,
@@ -61,7 +61,7 @@ use crate::{
editor_generation_queue::{
EDITOR_CHARACTER_ANIMATION_GENERATION_JOB_KIND, EDITOR_VIDEO_GENERATION_JOB_KIND,
EditorGenerationQueuedResponse, editor_generation_queue_state,
editor_generation_source_entity_id, enqueue_editor_generation_job,
editor_generation_source_entity_id, enqueue_editor_generation_job_for_caller,
},
editor_green_screen::{
EditorScreenBackgroundColor, editor_green_screen_character_prompt_clause,
@@ -578,38 +578,14 @@ pub async fn generate_editor_character_animation(
));
}
if !state.config.external_generation_mode.is_inline() {
let pricing = state.editor_generation_pricing().await.map_err(|error| {
character_animation_error_response(
&request_context,
AppError::from_status(StatusCode::INTERNAL_SERVER_ERROR).with_details(json!({
"provider": "editor-generation-pricing",
"message": error.to_string(),
})),
)
})?;
// 队列路径只取定价,背景色决策留到实际执行时再做,这里用默认色占位。
let normalized = normalize_editor_character_animation_request_with_pricing(
payload.clone(),
&pricing,
crate::editor_green_screen::default_editor_screen_background_color(),
)
.map_err(|error| character_animation_error_response(&request_context, error))?;
let source_entity_id = editor_generation_source_entity_id(
payload.project_id.as_deref(),
payload.source_layer_id.as_str(),
);
let queue_job = enqueue_editor_generation_job(
let queue_job = enqueue_editor_character_animation_for_owner(
&state,
&request_context,
owner_user_id.as_str(),
EDITOR_CHARACTER_ANIMATION_GENERATION_JOB_KIND,
source_entity_id,
"图片画布生成角色动作",
u64::from(normalized.price_mud_points),
&payload,
payload,
None,
)
.await
.map_err(|error| error.into_response_with_context(Some(&request_context)))?;
.await?;
return Ok(json_success_body(
Some(&request_context),
EditorGenerationQueuedResponse {
@@ -627,6 +603,56 @@ pub async fn generate_editor_character_animation(
.await
}
pub(crate) async fn enqueue_editor_character_animation_for_owner(
state: &AppState,
request_context: &RequestContext,
owner_user_id: &str,
payload: EditorCharacterAnimationGenerateRequest,
external_idempotency_key: Option<&str>,
) -> Result<ExternalGenerationJobRecord, Response> {
if matches_inline_media_source(payload.source_image_src.as_str()) {
return Err(character_animation_error_response(
request_context,
editor_character_animation_bad_request(
"sourceImageSrc 必须先上传 OSS,并使用 objectKey 或画板资源引用。",
),
));
}
let pricing = state.editor_generation_pricing().await.map_err(|error| {
character_animation_error_response(
request_context,
AppError::from_status(StatusCode::INTERNAL_SERVER_ERROR).with_details(json!({
"provider": "editor-generation-pricing",
"message": error.to_string(),
})),
)
})?;
// 队列路径只取定价,背景色决策留到实际执行时再做,这里用默认色占位。
let normalized = normalize_editor_character_animation_request_with_pricing(
payload.clone(),
&pricing,
crate::editor_green_screen::default_editor_screen_background_color(),
)
.map_err(|error| character_animation_error_response(request_context, error))?;
let source_entity_id = editor_generation_source_entity_id(
payload.project_id.as_deref(),
payload.source_layer_id.as_str(),
);
enqueue_editor_generation_job_for_caller(
state,
request_context,
owner_user_id,
EDITOR_CHARACTER_ANIMATION_GENERATION_JOB_KIND,
source_entity_id,
"图片画布生成角色动作",
u64::from(normalized.price_mud_points),
&payload,
external_idempotency_key,
)
.await
.map_err(|error| error.into_response_with_context(Some(request_context)))
}
pub(crate) async fn generate_editor_character_animation_for_owner(
state: AppState,
request_context: RequestContext,
@@ -970,31 +996,14 @@ pub async fn generate_editor_video(
})?;
let owner_user_id = authenticated.claims().user_id().to_string();
if !state.config.external_generation_mode.is_inline() {
let pricing = state.editor_generation_pricing().await.map_err(|error| {
editor_video_error_response(
&request_context,
AppError::from_status(StatusCode::INTERNAL_SERVER_ERROR).with_details(json!({
"provider": "editor-generation-pricing",
"message": error.to_string(),
})),
)
})?;
let normalized = normalize_editor_video_request_with_pricing(payload.clone(), &pricing)
.map_err(|error| editor_video_error_response(&request_context, error))?;
let source_entity_id =
editor_generation_source_entity_id(payload.project_id.as_deref(), "editor-video");
let queue_job = enqueue_editor_generation_job(
let queue_job = enqueue_editor_video_generation_for_owner(
&state,
&request_context,
owner_user_id.as_str(),
EDITOR_VIDEO_GENERATION_JOB_KIND,
source_entity_id,
"图片画布生成视频",
u64::from(normalized.price_mud_points),
&payload,
payload,
None,
)
.await
.map_err(|error| error.into_response_with_context(Some(&request_context)))?;
.await?;
return Ok(json_success_body(
Some(&request_context),
EditorGenerationQueuedResponse {
@@ -1005,6 +1014,41 @@ pub async fn generate_editor_video(
generate_editor_video_for_owner(state, request_context, owner_user_id, Ok(Json(payload))).await
}
pub(crate) async fn enqueue_editor_video_generation_for_owner(
state: &AppState,
request_context: &RequestContext,
owner_user_id: &str,
payload: EditorVideoGenerateRequest,
external_idempotency_key: Option<&str>,
) -> Result<ExternalGenerationJobRecord, Response> {
let pricing = state.editor_generation_pricing().await.map_err(|error| {
editor_video_error_response(
request_context,
AppError::from_status(StatusCode::INTERNAL_SERVER_ERROR).with_details(json!({
"provider": "editor-generation-pricing",
"message": error.to_string(),
})),
)
})?;
let normalized = normalize_editor_video_request_with_pricing(payload.clone(), &pricing)
.map_err(|error| editor_video_error_response(request_context, error))?;
let source_entity_id =
editor_generation_source_entity_id(payload.project_id.as_deref(), "editor-video");
enqueue_editor_generation_job_for_caller(
state,
request_context,
owner_user_id,
EDITOR_VIDEO_GENERATION_JOB_KIND,
source_entity_id,
"图片画布生成视频",
u64::from(normalized.price_mud_points),
&payload,
external_idempotency_key,
)
.await
.map_err(|error| error.into_response_with_context(Some(request_context)))
}
pub(crate) async fn generate_editor_video_for_owner(
state: AppState,
request_context: RequestContext,
@@ -1,6 +1,7 @@
use axum::http::StatusCode;
use serde::Serialize;
use serde_json::{Value, json};
use sha2::{Digest, Sha256};
use shared_contracts::external_generation::{
ExternalGenerationJobStatus, ExternalGenerationJobStatusRecord,
};
@@ -26,6 +27,7 @@ pub(crate) const EDITOR_BACKGROUND_MUSIC_GENERATION_JOB_KIND: &str =
pub(crate) const EDITOR_GENERATION_QUEUE_SOURCE_MODULE: &str = "editor-canvas";
const EDITOR_GENERATION_QUEUE_PROVIDER: &str = "editor-generation-worker";
const MAX_EDITOR_GENERATION_JOB_PAYLOAD_BYTES: usize = 512 * 1024;
const EXTERNAL_API_GENERATION_DEDUPE_PREFIX: &str = "external-api-generation";
#[derive(Debug, Serialize)]
#[serde(rename_all = "camelCase")]
@@ -77,6 +79,132 @@ where
T: Serialize,
{
let request_payload_json = serialize_editor_generation_job_payload(payload)?;
enqueue_serialized_editor_generation_job_with_identity(
state,
owner_user_id,
job_kind,
source_entity_id,
request_label,
price_mud_points,
request_payload_json,
job_id,
dedupe_key,
)
.await
}
#[allow(clippy::too_many_arguments)]
pub(crate) async fn enqueue_external_api_editor_generation_job<T>(
state: &AppState,
owner_user_id: &str,
job_kind: &str,
source_entity_id: impl Into<String>,
request_label: impl Into<String>,
price_mud_points: u64,
payload: &T,
idempotency_key: &str,
) -> Result<ExternalGenerationJobRecord, AppError>
where
T: Serialize,
{
let request_payload_json = serialize_editor_generation_job_payload(payload)?;
let mut hasher = Sha256::new();
hasher.update(owner_user_id.trim().as_bytes());
hasher.update(b"\0");
hasher.update(job_kind.trim().as_bytes());
hasher.update(b"\0");
hasher.update(idempotency_key.as_bytes());
let dedupe_key = format!(
"{EXTERNAL_API_GENERATION_DEDUPE_PREFIX}:{job_kind}:{:x}",
hasher.finalize()
);
let requested_job_id = build_prefixed_uuid_id("task-");
let job = enqueue_serialized_editor_generation_job_with_identity(
state,
owner_user_id,
job_kind,
source_entity_id,
request_label,
price_mud_points,
request_payload_json.clone(),
requested_job_id.clone(),
dedupe_key,
)
.await?;
if job.job_kind != job_kind
|| job.owner_user_id != owner_user_id
|| job.request_payload_json != request_payload_json
{
return Err(
AppError::from_status(StatusCode::CONFLICT).with_details(json!({
"provider": EDITOR_GENERATION_QUEUE_PROVIDER,
"message": "Idempotency-Key 已用于不同的生成请求,请复用原请求参数或更换幂等键。",
})),
);
}
Ok(job)
}
#[allow(clippy::too_many_arguments)]
pub(crate) async fn enqueue_editor_generation_job_for_caller<T>(
state: &AppState,
request_context: &RequestContext,
owner_user_id: &str,
job_kind: &str,
source_entity_id: impl Into<String>,
request_label: impl Into<String>,
price_mud_points: u64,
payload: &T,
external_idempotency_key: Option<&str>,
) -> Result<ExternalGenerationJobRecord, AppError>
where
T: Serialize,
{
let source_entity_id = source_entity_id.into();
let request_label = request_label.into();
match external_idempotency_key {
Some(idempotency_key) => {
enqueue_external_api_editor_generation_job(
state,
owner_user_id,
job_kind,
source_entity_id,
request_label,
price_mud_points,
payload,
idempotency_key,
)
.await
}
None => {
enqueue_editor_generation_job(
state,
request_context,
owner_user_id,
job_kind,
source_entity_id,
request_label,
price_mud_points,
payload,
)
.await
}
}
}
#[allow(clippy::too_many_arguments)]
async fn enqueue_serialized_editor_generation_job_with_identity(
state: &AppState,
owner_user_id: &str,
job_kind: &str,
source_entity_id: impl Into<String>,
request_label: impl Into<String>,
price_mud_points: u64,
request_payload_json: String,
job_id: String,
dedupe_key: String,
) -> Result<ExternalGenerationJobRecord, AppError> {
let now_micros = current_utc_micros();
state
.spacetime_client()
@@ -164,12 +292,21 @@ fn is_inline_media_reference(value: &str) -> bool {
pub(crate) fn editor_generation_queue_state(
job: ExternalGenerationJobRecord,
) -> ExternalGenerationJobStatusRecord {
let (status, phase_detail, progress) = match job.status.as_str() {
"completed" => (ExternalGenerationJobStatus::Completed, "生成已完成。", 100),
"running" if job.phase.as_deref() == Some("processing") => {
(ExternalGenerationJobStatus::Running, "正在处理。", 70)
}
"running" => (ExternalGenerationJobStatus::Running, "正在生成。", 35),
"failed" | "cancelled" => (ExternalGenerationJobStatus::Failed, "生成失败。", 0),
_ => (ExternalGenerationJobStatus::Queued, "排队中。", 8),
};
ExternalGenerationJobStatusRecord {
operation_id: job.job_id,
status: ExternalGenerationJobStatus::Queued,
status,
phase_label: job.request_label,
phase_detail: "排队中。".to_string(),
progress: 8,
phase_detail: phase_detail.to_string(),
progress,
error: job.last_error_message,
updated_at_micros: job.updated_at_micros,
}
@@ -194,6 +331,38 @@ fn current_utc_micros() -> i64 {
mod tests {
use super::*;
fn queue_job_fixture(status: &str, phase: Option<&str>) -> ExternalGenerationJobRecord {
ExternalGenerationJobRecord {
job_id: "task-queue-test".to_string(),
dedupe_key: "editor-canvas:test:task-queue-test".to_string(),
job_kind: EDITOR_IMAGE_GENERATION_JOB_KIND.to_string(),
owner_user_id: "user-1".to_string(),
source_module: EDITOR_GENERATION_QUEUE_SOURCE_MODULE.to_string(),
source_entity_id: "project-1".to_string(),
request_label: "图片画布生成图片".to_string(),
request_payload_json: "{}".to_string(),
status: status.to_string(),
attempt: 0,
max_attempts: 1,
last_error_message: None,
worker_id: None,
lease_expires_at: None,
available_at: "2026-07-31T00:00:00Z".to_string(),
result_payload_json: None,
created_at: "2026-07-31T00:00:00Z".to_string(),
started_at: None,
completed_at: None,
updated_at: "2026-07-31T00:00:00Z".to_string(),
updated_at_micros: 1_785_456_000_000_000,
lease_token: None,
price_mud_points: 2,
refund_ledger_id: None,
notification_acknowledged_at: None,
notification_acknowledged_at_micros: None,
phase: phase.map(ToOwned::to_owned),
}
}
#[test]
fn serialize_payload_accepts_persistable_media_references() {
let payload = json!({
@@ -253,4 +422,72 @@ mod tests {
assert_eq!(error.status_code().as_u16(), 413);
assert!(error.body_text().contains("超过持久化上限"));
}
#[test]
fn queue_state_maps_idempotent_replays_to_the_persisted_status() {
let cases = [
(
"pending",
None,
ExternalGenerationJobStatus::Queued,
"排队中。",
8,
),
(
"running",
Some("generating"),
ExternalGenerationJobStatus::Running,
"正在生成。",
35,
),
(
"running",
Some("processing"),
ExternalGenerationJobStatus::Running,
"正在处理。",
70,
),
(
"completed",
None,
ExternalGenerationJobStatus::Completed,
"生成已完成。",
100,
),
(
"failed",
None,
ExternalGenerationJobStatus::Failed,
"生成失败。",
0,
),
];
for (persisted_status, phase, expected_status, expected_detail, expected_progress) in cases
{
let state = editor_generation_queue_state(queue_job_fixture(persisted_status, phase));
assert_eq!(state.operation_id, "task-queue-test");
assert_eq!(state.status, expected_status, "status={persisted_status}");
assert_eq!(
state.phase_detail, expected_detail,
"status={persisted_status}"
);
assert_eq!(
state.progress, expected_progress,
"status={persisted_status}"
);
}
}
#[test]
fn queue_state_keeps_failed_error_for_idempotent_replay() {
let mut job = queue_job_fixture("failed", None);
job.last_error_message = Some("生成失败摘要".to_string());
let state = editor_generation_queue_state(job);
assert_eq!(state.status, ExternalGenerationJobStatus::Failed);
assert_eq!(state.error.as_deref(), Some("生成失败摘要"));
}
}
File diff suppressed because it is too large Load Diff
@@ -1,9 +1,13 @@
use axum::{
extract::{Request, State},
http::{HeaderMap, StatusCode, header::AUTHORIZATION},
http::{
HeaderMap, HeaderValue, StatusCode,
header::{AUTHORIZATION, WWW_AUTHENTICATE},
},
middleware::Next,
response::Response,
};
use serde_json::json;
use spacetime_client::ExternalApiKeyAuthenticateRecordInput;
use tracing::warn;
@@ -74,6 +78,71 @@ pub async fn require_external_api_key(
Ok(response)
}
pub async fn require_external_mcp_api_key(
State(state): State<AppState>,
request: Request,
next: Next,
) -> Result<Response, AppError> {
let request_context = request.extensions().get::<RequestContext>().cloned();
match require_external_api_key(State(state), request, next).await {
Ok(response) => Ok(response),
Err(error) if error.status_code() == StatusCode::UNAUTHORIZED => {
Ok(map_external_mcp_authentication_error(error)
.into_response_with_context(request_context.as_ref()))
}
Err(error) => Err(error),
}
}
fn map_external_mcp_authentication_error(error: AppError) -> AppError {
debug_assert_eq!(error.status_code(), StatusCode::UNAUTHORIZED);
external_mcp_authentication_guide_error()
}
fn external_mcp_authentication_guide_error() -> AppError {
AppError::from_status(StatusCode::UNAUTHORIZED)
.with_message("连接陶泥儿托管 MCP 需要开发者 API Key")
.with_details(json!({
"guide": {
"reason": "MCP_AUTHENTICATION_REQUIRED",
"action": "CONFIGURE_BEARER_API_KEY",
"authentication": {
"scheme": "Bearer",
"header": "Authorization",
"valueFormat": "Bearer <tnr_sk_...>"
},
"keyManagement": {
"navigationLabel": "开发者 API Key",
"rawKeyShownOnce": true
},
"retry": {
"method": "POST",
"path": "/api/external/v1/mcp",
"rpcMethod": "initialize"
},
"steps": [
"登录陶泥儿,在「开发者 API Key」中创建密钥;原始密钥只显示一次",
"把密钥配置为 MCP 连接的 Bearer token;不要粘贴到聊天或写入仓库",
"使用相同 MCP URL 重新发送 initialize"
],
"credentialSafety": {
"rawKeyShownOnce": true,
"neverPasteIntoChat": true,
"neverStoreInRepository": true
},
"publicDiscovery": {
"manifest": "/api/external/v1/agent-integration.json",
"skill": "/api/external/v1/skill/SKILL.md",
"openapi": "/api/external/v1/openapi.json"
}
}
}))
.with_header(
WWW_AUTHENTICATE.as_str(),
HeaderValue::from_static("Bearer realm=\"genarrative-external-editor\""),
)
}
fn extract_external_api_bearer(headers: &HeaderMap) -> Result<String, AppError> {
let authorization = headers
.get(AUTHORIZATION)
@@ -89,3 +158,32 @@ fn extract_external_api_bearer(headers: &HeaderMap) -> Result<String, AppError>
.map(ToOwned::to_owned)
.ok_or_else(|| AppError::from_status(StatusCode::UNAUTHORIZED))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn mcp_authentication_guide_replaces_sensitive_key_diagnostics() {
let error = AppError::from_status(StatusCode::UNAUTHORIZED).with_details(json!({
"provider": "external-api-key",
"message": "SENSITIVE_KEY_LURE 不存在或已失效"
}));
let mapped = map_external_mcp_authentication_error(error);
let serialized = serde_json::to_string(
mapped
.details()
.expect("mapped authentication error should contain guide details"),
)
.expect("guide should serialize");
assert_eq!(mapped.status_code(), StatusCode::UNAUTHORIZED);
assert_eq!(mapped.message(), "连接陶泥儿托管 MCP 需要开发者 API Key");
assert!(serialized.contains("MCP_AUTHENTICATION_REQUIRED"));
assert!(serialized.contains("CONFIGURE_BEARER_API_KEY"));
assert!(!serialized.contains("SENSITIVE_KEY_LURE"));
assert!(!serialized.contains("provider"));
assert!(!serialized.contains("不存在"));
assert!(!serialized.contains("已失效"));
}
}
File diff suppressed because it is too large Load Diff
@@ -217,7 +217,7 @@ fn user_visible_external_generation_error(job_kind: &str, error: Option<String>)
error
}
fn map_external_generation_job_status_detail(
pub(crate) fn map_external_generation_job_status_detail(
job: ExternalGenerationJobSummaryRecord,
) -> ExternalGenerationJobStatusDetailRecord {
let warning = job.warning_message.clone();
@@ -1213,6 +1213,14 @@ fn editor_generation_result_payload_json(
compact_editor_generation_result(response.clone()),
);
}
if is_external_api_generation_job(job)
&& let Some(object) = payload.as_object_mut()
{
object.insert(
"result".to_string(),
compact_external_api_generation_result(response.clone()),
);
}
if let Some(warning) = extract_editor_generation_warning(response)
&& let Some(object) = payload.as_object_mut()
{
@@ -1221,6 +1229,12 @@ fn editor_generation_result_payload_json(
payload.to_string()
}
fn is_external_api_generation_job(job: &ExternalGenerationJobRecord) -> bool {
job.dedupe_key
.trim()
.starts_with("external-api-generation:")
}
fn is_editor_agent_generation_job(job: &ExternalGenerationJobRecord) -> bool {
serde_json::from_str::<Value>(job.request_payload_json.as_str())
.ok()
@@ -1289,6 +1303,177 @@ fn compact_editor_generation_result(mut result: Value) -> Value {
result
}
fn compact_external_api_generation_result(result: Value) -> Value {
let mut result = result.get("data").cloned().unwrap_or(result);
let Some(object) = result.as_object_mut() else {
return Value::Null;
};
object.retain(|key, _| {
matches!(
key.as_str(),
"ok" | "imageSrc"
| "videoSrc"
| "audioSrc"
| "previewVideoPath"
| "thumbnailSrc"
| "objectKey"
| "assetObjectId"
| "width"
| "height"
| "sourceType"
| "model"
| "taskId"
| "durationSeconds"
| "resolution"
| "priceMudPoints"
| "audioKind"
| "spritesheetImageSrc"
| "spritesheetWidth"
| "spritesheetHeight"
| "iconImageSrcs"
| "frames"
| "frameCount"
| "frameWidth"
| "frameHeight"
| "fps"
| "resource"
| "asset"
| "spritesheetResource"
| "spritesheetAsset"
| "warning"
| "sliceWarning"
)
});
for field in ["resource", "spritesheetResource"] {
if let Some(resource) = object.get_mut(field).and_then(Value::as_object_mut) {
compact_external_generation_resource(resource);
}
}
for field in ["asset", "spritesheetAsset"] {
if let Some(asset) = object.get_mut(field).and_then(Value::as_object_mut) {
compact_external_generation_asset(asset);
}
}
if let Some(icons) = object
.get_mut("iconImageSrcs")
.and_then(Value::as_array_mut)
{
for icon in icons {
let Some(icon) = icon.as_object_mut() else {
continue;
};
icon.retain(|key, _| {
matches!(
key.as_str(),
"name" | "imageSrc" | "objectKey" | "width" | "height" | "resource" | "asset"
)
});
if let Some(resource) = icon.get_mut("resource").and_then(Value::as_object_mut) {
compact_external_generation_resource(resource);
}
if let Some(asset) = icon.get_mut("asset").and_then(Value::as_object_mut) {
compact_external_generation_asset(asset);
}
remove_unstable_external_generation_media_fields(icon);
}
}
if let Some(frames) = object.get_mut("frames").and_then(Value::as_array_mut) {
for frame in frames {
let Some(frame) = frame.as_object_mut() else {
continue;
};
frame.retain(|key, _| {
matches!(
key.as_str(),
"frameIndex" | "imageSrc" | "objectKey" | "width" | "height"
)
});
remove_unstable_external_generation_media_fields(frame);
}
}
for field in ["warning", "sliceWarning"] {
if let Some(warning) = object.get_mut(field).and_then(Value::as_object_mut)
&& let Some(reason) = warning.get_mut("reason")
&& let Some(value) = reason.as_str()
{
*reason = Value::String(normalize_editor_generation_warning_reason(value));
}
}
remove_unstable_external_generation_media_fields(object);
result
}
fn compact_external_generation_resource(resource: &mut serde_json::Map<String, Value>) {
resource.retain(|key, _| {
matches!(
key.as_str(),
"resourceId"
| "projectId"
| "objectKey"
| "assetObjectId"
| "imageSrc"
| "width"
| "height"
| "sourceType"
| "assetKind"
| "taskId"
)
});
remove_unstable_external_generation_media_fields(resource);
}
fn compact_external_generation_asset(asset: &mut serde_json::Map<String, Value>) {
asset.retain(|key, _| {
matches!(
key.as_str(),
"assetId"
| "folderId"
| "objectKey"
| "assetObjectId"
| "imageSrc"
| "thumbnailSrc"
| "width"
| "height"
| "sourceType"
| "assetKind"
| "taskId"
)
});
remove_unstable_external_generation_media_fields(asset);
}
fn remove_unstable_external_generation_media_fields(object: &mut serde_json::Map<String, Value>) {
object.retain(|key, value| {
if !matches!(
key.as_str(),
"imageSrc"
| "videoSrc"
| "audioSrc"
| "previewVideoPath"
| "thumbnailSrc"
| "spritesheetImageSrc"
) {
return true;
}
value
.as_str()
.is_some_and(is_stable_external_generation_media_reference)
});
}
fn is_stable_external_generation_media_reference(value: &str) -> bool {
let value = value.trim();
!value.is_empty()
&& value.starts_with('/')
&& !value.starts_with("//")
&& !value.contains('?')
&& !value.contains('#')
&& !value.to_ascii_lowercase().starts_with("data:")
&& !value.to_ascii_lowercase().starts_with("blob:")
&& !value.to_ascii_lowercase().starts_with("http://")
&& !value.to_ascii_lowercase().starts_with("https://")
}
fn is_editor_internal_processing_model(model: &str) -> bool {
matches!(
model.trim().to_ascii_lowercase().as_str(),
@@ -1348,7 +1533,13 @@ fn extract_editor_generation_warning(response: &Value) -> Option<Value> {
fn normalize_editor_generation_warning_reason(reason: &str) -> String {
let normalized = reason.to_ascii_lowercase();
if normalized.contains("data:") || normalized.contains("blob:") {
if normalized.contains("data:")
|| normalized.contains("blob:")
|| normalized.contains("http://")
|| normalized.contains("https://")
|| normalized.contains("x-amz-")
|| normalized.contains("signature=")
{
return EDITOR_GENERATION_WARNING_REDACTED_MESSAGE.to_string();
}
let mut chars = reason.chars();
@@ -2051,6 +2242,135 @@ mod tests {
assert!(!payload.to_string().contains("data:image"));
}
#[test]
fn external_api_result_keeps_stable_artifacts_and_removes_unstable_media() {
let mut job = external_generation_job_record_fixture(Some("lease-1"));
job.dedupe_key = "external-api-generation:editor_image_generation:fingerprint".to_string();
let response = json!({
"data": {
"imageSrc": "data:image/png;base64,SHOULD_NOT_PERSIST",
"videoSrc": "blob:https://example.test/video",
"audioSrc": "https://cdn.example.test/audio.mp3?X-Amz-Signature=secret",
"previewVideoPath": "https://cdn.example.test/stable-looking-but-external.mp4",
"thumbnailSrc": "/api/assets/object/thumbnail.png?expires=1&signature=secret",
"objectKey": "users/user-1/generated/main.png",
"assetObjectId": "asset-object-main",
"width": 1024,
"height": 1024,
"provider": "internal-provider-must-not-persist",
"resource": {
"resourceId": "resource-main",
"projectId": "project-1",
"objectKey": "users/user-1/generated/main.png",
"assetObjectId": "asset-object-main",
"imageSrc": "https://cdn.example.test/main.png?signature=secret",
"width": 1024,
"height": 1024,
"prompt": "不应复制完整资源元数据"
},
"asset": {
"assetId": "asset-main",
"folderId": "folder-1",
"objectKey": "users/user-1/generated/main.png",
"assetObjectId": "asset-object-main",
"imageSrc": "/api/assets/object/main.png",
"thumbnailSrc": "https://cdn.example.test/thumb.png?signature=secret",
"width": 1024,
"height": 1024,
"generationInputs": {"private": true}
},
"project": {
"projectId": "project-1",
"canvas": {"layers": ["large-layout-must-not-persist"]}
},
"warning": {
"code": "dimension-restore-fallback",
"reason": "已保留 provider 实际输出尺寸。"
}
},
"meta": {
"requestId": "worker-envelope-must-not-persist"
}
});
let payload: Value =
serde_json::from_str(&editor_generation_result_payload_json(&job, &response))
.expect("外部生成结果应是合法 JSON");
let result = &payload["result"];
assert!(result.get("project").is_none());
assert!(result.get("provider").is_none());
for unstable_field in [
"imageSrc",
"videoSrc",
"audioSrc",
"previewVideoPath",
"thumbnailSrc",
] {
assert!(
result.get(unstable_field).is_none(),
"不稳定媒体字段 {unstable_field} 不得持久化"
);
}
assert_eq!(
result["objectKey"],
json!("users/user-1/generated/main.png")
);
assert_eq!(result["assetObjectId"], json!("asset-object-main"));
assert_eq!(result["resource"]["resourceId"], json!("resource-main"));
assert_eq!(
result["resource"]["objectKey"],
json!("users/user-1/generated/main.png")
);
assert!(result["resource"].get("imageSrc").is_none());
assert!(result["resource"].get("prompt").is_none());
assert_eq!(result["asset"]["assetId"], json!("asset-main"));
assert_eq!(
result["asset"]["imageSrc"],
json!("/api/assets/object/main.png")
);
assert!(result["asset"].get("thumbnailSrc").is_none());
assert!(result["asset"].get("generationInputs").is_none());
assert_eq!(
result["warning"],
json!({
"code": "dimension-restore-fallback",
"reason": "已保留 provider 实际输出尺寸。"
})
);
assert_eq!(payload["warning"], result["warning"]);
assert!(result.get("prompt").is_none());
assert!(result.get("actualPrompt").is_none());
let serialized = payload.to_string().to_ascii_lowercase();
for forbidden in [
"data:",
"blob:",
"x-amz-signature",
"?signature=",
"large-layout",
] {
assert!(
!serialized.contains(forbidden),
"compact result 不应包含 {forbidden}"
);
}
}
#[test]
fn non_external_job_does_not_publish_query_result() {
let job = external_generation_job_record_fixture(Some("lease-1"));
let payload: Value = serde_json::from_str(&editor_generation_result_payload_json(
&job,
&json!({
"objectKey": "users/user-1/generated/main.png",
"resource": {"resourceId": "resource-main"}
}),
))
.expect("普通编辑器任务结果应为合法 JSON");
assert!(payload.get("result").is_none());
}
#[test]
fn worker_job_timeout_uses_long_budget_for_image_and_video_jobs() {
let config = AppConfig {
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,180 @@
use std::io::{Cursor, Write};
use axum::{
Json,
body::Body,
http::{
HeaderValue, StatusCode,
header::{CONTENT_DISPOSITION, CONTENT_TYPE},
},
response::{IntoResponse, Response},
};
use serde_json::{Value, json};
use sha2::{Digest, Sha256};
use zip::{ZipWriter, write::SimpleFileOptions};
use crate::http_error::AppError;
const SKILL_ROOT: &str = "genarrative-external-editor-api";
const SKILL_FILES: [(&str, &str); 7] = [
(
"SKILL.md",
include_str!("../../../../.codex/skills/genarrative-external-editor-api/SKILL.md"),
),
(
"references/capability-routing.md",
include_str!(
"../../../../.codex/skills/genarrative-external-editor-api/references/capability-routing.md"
),
),
(
"references/api-operations.md",
include_str!(
"../../../../.codex/skills/genarrative-external-editor-api/references/api-operations.md"
),
),
(
"references/authentication-and-safety.md",
include_str!(
"../../../../.codex/skills/genarrative-external-editor-api/references/authentication-and-safety.md"
),
),
(
"references/requests-and-outputs.md",
include_str!(
"../../../../.codex/skills/genarrative-external-editor-api/references/requests-and-outputs.md"
),
),
(
"scripts/genarrative_external_api.py",
include_str!(
"../../../../.codex/skills/genarrative-external-editor-api/scripts/genarrative_external_api.py"
),
),
(
"agents/openai.yaml",
include_str!(
"../../../../.codex/skills/genarrative-external-editor-api/agents/openai.yaml"
),
),
];
pub async fn get_external_skill_entry() -> Response {
let mut response = Body::from(SKILL_FILES[0].1).into_response();
response.headers_mut().insert(
CONTENT_TYPE,
HeaderValue::from_static("text/markdown; charset=utf-8"),
);
response
}
pub async fn download_external_skill_archive() -> Result<Response, AppError> {
let bytes = build_external_skill_archive()?;
let mut response = Body::from(bytes).into_response();
response
.headers_mut()
.insert(CONTENT_TYPE, HeaderValue::from_static("application/zip"));
response.headers_mut().insert(
CONTENT_DISPOSITION,
HeaderValue::from_static(
"attachment; filename=\"genarrative-external-editor-api.skill.zip\"",
),
);
Ok(response)
}
pub async fn get_external_agent_integration_manifest() -> Result<Json<Value>, AppError> {
let archive = build_external_skill_archive()?;
let sha256 = format!("{:x}", Sha256::digest(&archive));
Ok(Json(json!({
"name": SKILL_ROOT,
"version": env!("CARGO_PKG_VERSION"),
"mcp": {
"transport": "streamable-http",
"url": "/api/external/v1/mcp",
"authentication": "bearer-api-key",
"credentialSetup": {
"action": "CONFIGURE_BEARER_API_KEY",
"authorizationValueFormat": "Bearer <tnr_sk_...>",
"navigationLabel": "开发者 API Key",
"guide": "/api/external/v1/skill/SKILL.md"
}
},
"openapi": "/api/external/v1/openapi.json",
"skill": {
"entry": "/api/external/v1/skill/SKILL.md",
"archive": "/api/external/v1/skill.zip",
"archiveSha256": sha256,
"files": SKILL_FILES.map(|(path, _)| format!("{SKILL_ROOT}/{path}")),
}
})))
}
fn build_external_skill_archive() -> Result<Vec<u8>, AppError> {
let cursor = Cursor::new(Vec::new());
let mut archive = ZipWriter::new(cursor);
let options = SimpleFileOptions::default().unix_permissions(0o644);
for (path, contents) in SKILL_FILES {
archive
.start_file(format!("{SKILL_ROOT}/{path}"), options)
.map_err(skill_archive_error)?;
archive
.write_all(contents.as_bytes())
.map_err(skill_archive_error)?;
}
archive
.finish()
.map(Cursor::into_inner)
.map_err(skill_archive_error)
}
fn skill_archive_error(error: impl std::fmt::Display) -> AppError {
AppError::from_status(StatusCode::INTERNAL_SERVER_ERROR).with_details(json!({
"provider": "external-skill-archive",
"message": format!("构建外部 Skill 包失败:{error}"),
}))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn archive_contains_complete_skill_bundle() {
let bytes = build_external_skill_archive().expect("skill archive should build");
let mut archive = zip::ZipArchive::new(Cursor::new(bytes)).expect("archive should parse");
assert_eq!(archive.len(), SKILL_FILES.len());
for (path, contents) in SKILL_FILES {
let name = format!("{SKILL_ROOT}/{path}");
let mut file = archive.by_name(&name).expect("skill file should exist");
let mut actual = String::new();
std::io::Read::read_to_string(&mut file, &mut actual).expect("skill file should read");
assert_eq!(actual, contents);
}
}
#[tokio::test]
async fn integration_manifest_matches_complete_skill_archive() {
let bytes = build_external_skill_archive().expect("skill archive should build");
let Json(manifest) = get_external_agent_integration_manifest()
.await
.expect("integration manifest should build");
let expected_files = SKILL_FILES
.map(|(path, _)| format!("{SKILL_ROOT}/{path}"))
.to_vec();
assert_eq!(manifest["skill"]["files"], json!(expected_files));
assert_eq!(
manifest["mcp"]["credentialSetup"],
json!({
"action": "CONFIGURE_BEARER_API_KEY",
"authorizationValueFormat": "Bearer <tnr_sk_...>",
"navigationLabel": "开发者 API Key",
"guide": "/api/external/v1/skill/SKILL.md"
})
);
assert_eq!(
manifest["skill"]["archiveSha256"],
json!(format!("{:x}", Sha256::digest(&bytes)))
);
}
}
+2
View File
@@ -37,6 +37,8 @@ mod external_editor_api;
mod external_generation;
mod external_generation_worker;
mod external_generation_worker_controller;
mod external_mcp;
mod external_skill_api;
mod frontend_runtime_config;
mod generated_image_assets;
mod health;
@@ -1,5 +1,5 @@
use axum::{
Router,
Extension, Router,
extract::DefaultBodyLimit,
middleware,
routing::{get, patch, post},
@@ -7,7 +7,7 @@ use axum::{
use crate::{
editor_project::EDITOR_LAYOUT_REQUEST_BODY_MAX_BYTES,
external_api_auth::require_external_api_key,
external_api_auth::{require_external_api_key, require_external_mcp_api_key},
external_assets_api::{
confirm_external_asset_object, create_external_direct_upload_ticket,
get_external_asset_read_url,
@@ -21,17 +21,43 @@ use crate::{
generate_external_editor_character_animation, generate_external_editor_icon_spritesheet,
generate_external_editor_image, generate_external_editor_sound_effect,
generate_external_editor_video, get_external_editor_asset_library,
get_external_editor_project, list_external_editor_projects,
load_recent_external_editor_project, openapi_json, rename_external_editor_project,
save_external_editor_canvas, update_external_editor_asset,
get_external_editor_generation_job, get_external_editor_project,
list_external_editor_projects, load_recent_external_editor_project, openapi_json,
rename_external_editor_project, save_external_editor_canvas, update_external_editor_asset,
update_external_editor_asset_folder,
},
external_mcp,
external_skill_api::{
download_external_skill_archive, get_external_agent_integration_manifest,
get_external_skill_entry,
},
state::AppState,
};
pub fn router(state: AppState) -> Router<AppState> {
let mcp_router = Router::new()
.nest_service("/api/external/v1/mcp", external_mcp::service())
.layer(Extension(state.clone()))
.route_layer(middleware::from_fn_with_state(
state.clone(),
require_external_mcp_api_key,
));
Router::new()
.merge(mcp_router)
.route("/api/external/v1/openapi.json", get(openapi_json))
.route(
"/api/external/v1/agent-integration.json",
get(get_external_agent_integration_manifest),
)
.route(
"/api/external/v1/skill/SKILL.md",
get(get_external_skill_entry),
)
.route(
"/api/external/v1/skill.zip",
get(download_external_skill_archive),
)
.route(
"/api/external/v1/assets/direct-upload-tickets",
post(create_external_direct_upload_ticket).route_layer(middleware::from_fn_with_state(
@@ -139,6 +165,13 @@ pub fn router(state: AppState) -> Router<AppState> {
require_external_api_key,
)),
)
.route(
"/api/external/v1/generations/{operation_id}",
get(get_external_editor_generation_job).route_layer(middleware::from_fn_with_state(
state.clone(),
require_external_api_key,
)),
)
.route(
"/api/external/v1/editor/images/generations",
post(generate_external_editor_image).route_layer(middleware::from_fn_with_state(
@@ -6,7 +6,9 @@ mod publish;
mod settings;
mod types;
pub use generation::{generate_editor_background_music, generate_editor_sound_effect};
pub(crate) use generation::{
generate_editor_background_music_for_owner, generate_editor_sound_effect_for_owner,
enqueue_editor_background_music_generation_for_owner,
enqueue_editor_sound_effect_generation_for_owner, generate_editor_background_music_for_owner,
generate_editor_sound_effect_for_owner,
};
pub use generation::{generate_editor_background_music, generate_editor_sound_effect};
@@ -9,6 +9,7 @@ use platform_oss::LegacyAssetPrefix;
use serde_json::Value;
use serde_json::json;
use shared_contracts::assets;
use spacetime_client::ExternalGenerationJobRecord;
use crate::{
api_response::json_success_body,
@@ -17,7 +18,7 @@ use crate::{
editor_generation_queue::{
EDITOR_BACKGROUND_MUSIC_GENERATION_JOB_KIND, EDITOR_SOUND_EFFECT_GENERATION_JOB_KIND,
EditorGenerationQueuedResponse, editor_generation_queue_state,
editor_generation_source_entity_id, enqueue_editor_generation_job,
editor_generation_source_entity_id, enqueue_editor_generation_job_for_caller,
},
editor_project::{
EditorCanvasGeneratedLayerInput, PersistEditorGeneratedAssetRequest,
@@ -142,33 +143,14 @@ pub async fn generate_editor_sound_effect(
let Json(payload) = parse_json_payload(&request_context, payload)?;
let owner_user_id = authenticated.claims().user_id().to_string();
if !state.config.external_generation_mode.is_inline() {
let pricing = state.editor_generation_pricing().await.map_err(|error| {
AppError::from_status(StatusCode::INTERNAL_SERVER_ERROR)
.with_details(json!({
"provider": "editor-generation-pricing",
"message": error.to_string(),
}))
.into_response_with_context(Some(&request_context))
})?;
let normalized =
normalize_editor_sound_effect_request_with_pricing(payload.clone(), &pricing)
.map_err(|error| error.into_response_with_context(Some(&request_context)))?;
let source_entity_id = editor_generation_source_entity_id(
payload.project_id.as_deref(),
"editor-sound-effect",
);
let queue_job = enqueue_editor_generation_job(
let queue_job = enqueue_editor_sound_effect_generation_for_owner(
&state,
&request_context,
owner_user_id.as_str(),
EDITOR_SOUND_EFFECT_GENERATION_JOB_KIND,
source_entity_id,
"图片画布生成音效",
u64::from(normalized.price_mud_points),
&payload,
payload,
None,
)
.await
.map_err(|error| error.into_response_with_context(Some(&request_context)))?;
.await?;
return Ok(json_success_body(
Some(&request_context),
EditorGenerationQueuedResponse {
@@ -180,6 +162,40 @@ pub async fn generate_editor_sound_effect(
.await
}
pub(crate) async fn enqueue_editor_sound_effect_generation_for_owner(
state: &AppState,
request_context: &RequestContext,
owner_user_id: &str,
payload: assets::EditorSoundEffectGenerateRequest,
external_idempotency_key: Option<&str>,
) -> Result<ExternalGenerationJobRecord, Response> {
let pricing = state.editor_generation_pricing().await.map_err(|error| {
AppError::from_status(StatusCode::INTERNAL_SERVER_ERROR)
.with_details(json!({
"provider": "editor-generation-pricing",
"message": error.to_string(),
}))
.into_response_with_context(Some(request_context))
})?;
let normalized = normalize_editor_sound_effect_request_with_pricing(payload.clone(), &pricing)
.map_err(|error| error.into_response_with_context(Some(request_context)))?;
let source_entity_id =
editor_generation_source_entity_id(payload.project_id.as_deref(), "editor-sound-effect");
enqueue_editor_generation_job_for_caller(
state,
request_context,
owner_user_id,
EDITOR_SOUND_EFFECT_GENERATION_JOB_KIND,
source_entity_id,
"图片画布生成音效",
u64::from(normalized.price_mud_points),
&payload,
external_idempotency_key,
)
.await
.map_err(|error| error.into_response_with_context(Some(request_context)))
}
pub(crate) async fn generate_editor_sound_effect_for_owner(
state: AppState,
request_context: RequestContext,
@@ -358,33 +374,14 @@ pub async fn generate_editor_background_music(
let Json(payload) = parse_json_payload(&request_context, payload)?;
let owner_user_id = authenticated.claims().user_id().to_string();
if !state.config.external_generation_mode.is_inline() {
let pricing = state.editor_generation_pricing().await.map_err(|error| {
AppError::from_status(StatusCode::INTERNAL_SERVER_ERROR)
.with_details(json!({
"provider": "editor-generation-pricing",
"message": error.to_string(),
}))
.into_response_with_context(Some(&request_context))
})?;
let normalized =
normalize_editor_background_music_request_with_pricing(payload.clone(), &pricing)
.map_err(|error| error.into_response_with_context(Some(&request_context)))?;
let source_entity_id = editor_generation_source_entity_id(
payload.project_id.as_deref(),
"editor-background-music",
);
let queue_job = enqueue_editor_generation_job(
let queue_job = enqueue_editor_background_music_generation_for_owner(
&state,
&request_context,
owner_user_id.as_str(),
EDITOR_BACKGROUND_MUSIC_GENERATION_JOB_KIND,
source_entity_id,
"图片画布生成背景音乐",
u64::from(normalized.price_mud_points),
&payload,
payload,
None,
)
.await
.map_err(|error| error.into_response_with_context(Some(&request_context)))?;
.await?;
return Ok(json_success_body(
Some(&request_context),
EditorGenerationQueuedResponse {
@@ -401,6 +398,43 @@ pub async fn generate_editor_background_music(
.await
}
pub(crate) async fn enqueue_editor_background_music_generation_for_owner(
state: &AppState,
request_context: &RequestContext,
owner_user_id: &str,
payload: assets::EditorBackgroundMusicGenerateRequest,
external_idempotency_key: Option<&str>,
) -> Result<ExternalGenerationJobRecord, Response> {
let pricing = state.editor_generation_pricing().await.map_err(|error| {
AppError::from_status(StatusCode::INTERNAL_SERVER_ERROR)
.with_details(json!({
"provider": "editor-generation-pricing",
"message": error.to_string(),
}))
.into_response_with_context(Some(request_context))
})?;
let normalized =
normalize_editor_background_music_request_with_pricing(payload.clone(), &pricing)
.map_err(|error| error.into_response_with_context(Some(request_context)))?;
let source_entity_id = editor_generation_source_entity_id(
payload.project_id.as_deref(),
"editor-background-music",
);
enqueue_editor_generation_job_for_caller(
state,
request_context,
owner_user_id,
EDITOR_BACKGROUND_MUSIC_GENERATION_JOB_KIND,
source_entity_id,
"图片画布生成背景音乐",
u64::from(normalized.price_mud_points),
&payload,
external_idempotency_key,
)
.await
.map_err(|error| error.into_response_with_context(Some(request_context)))
}
pub(crate) async fn generate_editor_background_music_for_owner(
state: AppState,
request_context: RequestContext,
+1 -1
View File
@@ -30,7 +30,7 @@
| --- | --- | --- | --- | --- |
| `OpenAiChat` | `tools[].type=function`,函数内为 `name` / `description` / `parameters` / `strict` | `"auto"` / `"required"` | `choices[0].message.tool_calls` | `delta.tool_calls[].index`;首片提供 id/name,后续拼接 arguments |
| `OpenAiResponses` | `tools[].type=function`,函数内为 `name` / `description` / `parameters` / `strict` | `"auto"` / `"required"` | `output[].type=function_call` | `output_index`;`output_item.added` 提供身份,`function_call_arguments.delta` 拼接,`.done` 覆盖完整参数 |
| `Anthropic` | 顶层 `tools[]` 为 `name` / `description` / `input_schema`,没有 `function` 包装层和 `strict` | `{ "type": "auto" }` / `{ "type": "any" }`;`Required` 映射为 `any` | `content[].type=tool_use`,`input` 序列化为 `arguments` | content block `index`;`content_block_start` 提供身份,`input_json_delta` 拼接参数 |
| `Anthropic` | 顶层 `tools[]` 为 `name` / `description` / `input_schema`,没有 `function` 包装层;只有 endpoint/model 配置显式声明支持且 schema / 请求复杂度满足 Anthropic 当前边界时才发送 `strict: true`。strict 传输 schema 会剥离不支持的约束,调用方原 schema 保持不变;最后一项带 ephemeral cache breakpoint | `{ "type": "auto" }` / `{ "type": "any" }`;`Required` 映射为 `any` | `content[].type=tool_use`,`input` 序列化为 `arguments` | content block `index`;`content_block_start` 提供身份,`input_json_delta` 拼接参数;`message_start/message_delta` 合并 cache/input/output usage |
Responses 如果只发送 `response.completed` 或 `response.incomplete`,解析器会从其中的 `response.output[]` 恢复 `function_call`;恢复时使用 output 数组下标作为 slot。`response.incomplete` 表示上游没有完成本轮生成:其中的工具调用即使参数是完整 JSON 也返回 `Deserialize`,纯正文则保留为可用的降级结果。三种协议的并行工具调用只在平台层做 slot 聚合,不代表工具会在平台层并发执行。
File diff suppressed because it is too large Load Diff
@@ -3,10 +3,10 @@
//! 仓库根目录没有 Cargo.toml,必须显式指定 workspace manifest:
//!
//! ```powershell
//! $env:PLATFORM_LLM_LIVE_BASE_URL = 'https://api.minimaxi.com/anthropic'
//! $env:PLATFORM_LLM_LIVE_BASE_URL = 'https://api.anthropic.com'
//! $env:PLATFORM_LLM_LIVE_API_KEY = '...'
//! $env:PLATFORM_LLM_LIVE_MODEL = 'MiniMax-M3'
//! $env:PLATFORM_LLM_LIVE_API_KIND = 'anthropic' # 或 openai_chat / openai_responses
//! $env:PLATFORM_LLM_LIVE_MODEL = '<当前支持 strict tool use 的 Claude 模型>'
//! $env:PLATFORM_LLM_LIVE_API_KIND = 'anthropic'
//! cargo test -p platform-llm --manifest-path server-rs/Cargo.toml --test live_stream_tool_calls -- --ignored --nocapture
//! ```
//!
@@ -107,7 +107,8 @@ async fn live_stream_run_returns_native_tool_calls() {
0,
1_000,
)
.expect("live config should be valid");
.expect("live config should be valid")
.with_anthropic_strict_tool_support(api_kind == LlmApiKind::Anthropic);
let client = LlmClient::new(config).expect("live client should be created");
let request = LlmRunRequest::new(vec![
@@ -116,15 +117,27 @@ async fn live_stream_run_returns_native_tool_calls() {
])
.with_api_kind(api_kind)
.with_max_output_tokens(512)
.with_function_tools(vec![LlmFunctionTool::new(
"get_weather",
"查询指定城市的当前天气。",
serde_json::json!({
"type": "object",
"properties": { "city": { "type": "string" } },
"required": ["city"]
}),
)])
.with_function_tools(vec![
LlmFunctionTool::new(
"get_weather",
"查询指定城市的当前天气。",
serde_json::json!({
"type": "object",
"$defs": {
"WeatherRequest": {
"type": "object",
"properties": { "city": { "type": "string" } },
"required": ["city"],
"additionalProperties": false
}
},
"properties": { "request": { "$ref": "#/$defs/WeatherRequest" } },
"required": ["request"],
"additionalProperties": false
}),
)
.with_strict(true),
])
.with_tool_choice(LlmToolChoice::Required);
let mut streamed_chars = 0usize;
@@ -157,7 +170,10 @@ async fn live_stream_run_returns_native_tool_calls() {
let arguments: serde_json::Value =
serde_json::from_str(&call.arguments).expect("参数必须是完整 JSON");
assert!(
arguments.get("city").is_some(),
"参数应包含 city,实际为 {arguments}"
arguments
.get("request")
.and_then(|request| request.get("city"))
.is_some(),
"参数应包含 request.city,实际为 {arguments}"
);
}
@@ -1,4 +1,5 @@
use serde::{Deserialize, Serialize};
use serde_json::Value;
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "kebab-case")]
@@ -51,6 +52,37 @@ pub struct ExternalGenerationJobStatusResponse {
pub job: ExternalGenerationJobStatusDetailRecord,
}
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct ExternalEditorGenerationSubmissionResponse {
pub operation_id: String,
pub kind: String,
pub status: ExternalGenerationJobStatus,
pub status_url: String,
pub poll_after_ms: u64,
pub updated_at_micros: i64,
}
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct ExternalEditorGenerationJobResponse {
pub operation_id: String,
pub kind: String,
pub status: ExternalGenerationJobStatus,
pub phase_label: String,
pub phase_detail: String,
pub progress: u8,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub error: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub warning: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub result: Option<Value>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub poll_after_ms: Option<u64>,
pub updated_at_micros: i64,
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct ExternalGenerationTaskRecord {