增加外部API的MCP与异步生成模式

新增托管式Streamable HTTP MCP端点并复用外部API Key鉴权
统一图片视频音频等生成请求为幂等异步提交和状态查询
提供可发现的使用说明、OpenAPI资源及完整Skill下载包
同步更新OpenAPI契约并规定接口变更必须连带维护
适配AI游戏创作Shell的异步提交轮询与安全重试
补齐MCP方法映射、异步Worker和客户端回归测试
This commit is contained in:
2026-07-31 19:47:36 +08:00
parent 33ec7b2861
commit e1d031b86f
35 changed files with 3684 additions and 497 deletions
@@ -2370,6 +2370,7 @@ function createDeterministicCanvasFixture(apiKey) {
const projectId = 'deterministic-canvas-project';
const folderId = 'deterministic-canvas-folder';
const images = new Map();
const generationOperations = new Map();
const imageCache = new Map();
const stats = {
canvasApiRequestCount: 0,
@@ -2476,6 +2477,16 @@ function createDeterministicCanvasFixture(apiKey) {
request.method === 'POST' &&
parsed.pathname === '/api/external/v1/editor/images/generations'
) {
const idempotencyKey = request.headers['idempotency-key'];
if (
typeof idempotencyKey !== 'string' ||
!/^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i.test(
idempotencyKey,
)
) {
request.resume();
return json(400, { error: { message: 'invalid idempotency key' } });
}
const body = await readJsonBody(request);
const image = imageForAspectRatio(body?.aspectRatio);
generationSequence += 1;
@@ -2489,8 +2500,22 @@ function createDeterministicCanvasFixture(apiKey) {
const assetKind =
typeof body?.assetKind === 'string' ? body.assetKind : 'game-art';
images.set(imageId, { ...image, objectKey });
return json(200, {
data: {
const operationId = `task-${imageId}`;
generationOperations.set(operationId, {
imageSrc: `/${objectKey}`,
objectKey,
assetObjectId,
width: image.width,
height: image.height,
sourceType: 'generated',
prompt: body?.prompt ?? 'deterministic canvas fixture',
actualPrompt: body?.prompt ?? 'deterministic canvas fixture',
model: 'deterministic-canvas-v1',
provider: 'deterministic-loopback',
taskId: `task-${imageId}`,
resource: {
resourceId,
projectId,
imageSrc: `/${objectKey}`,
objectKey,
assetObjectId,
@@ -2502,22 +2527,45 @@ function createDeterministicCanvasFixture(apiKey) {
model: 'deterministic-canvas-v1',
provider: 'deterministic-loopback',
taskId: `task-${imageId}`,
resource: {
resourceId,
projectId,
imageSrc: `/${objectKey}`,
objectKey,
assetObjectId,
width: image.width,
height: image.height,
sourceType: 'generated',
assetKind,
},
asset: {
assetId: `asset-${imageId}`,
assetObjectId,
assetKind,
},
assetKind,
},
asset: {
assetId: `asset-${imageId}`,
assetObjectId,
assetKind,
},
});
return json(202, {
data: {
operationId,
kind: 'editor_image_generation',
status: 'queued',
statusUrl: `/api/external/v1/generations/${operationId}`,
pollAfterMs: 1,
updatedAtMicros: generationSequence,
},
});
}
if (
request.method === 'GET' &&
parsed.pathname.startsWith('/api/external/v1/generations/')
) {
request.resume();
const operationId = parsed.pathname.slice(
'/api/external/v1/generations/'.length,
);
const result = generationOperations.get(operationId);
if (!result) return json(404, { error: { message: 'operation not found' } });
return json(200, {
data: {
operationId,
kind: 'editor_image_generation',
status: 'completed',
phaseLabel: '图片画布生成图片',
phaseDetail: '生成已完成。',
progress: 100,
result,
updatedAtMicros: generationSequence,
},
});
}
+1
View File
@@ -1507,6 +1507,7 @@ dependencies = [
"tokio",
"unicode-normalization",
"url",
"uuid",
"windows-sys 0.61.2",
"zip",
]
@@ -36,6 +36,7 @@ tempfile = "3"
tokio = { version = "1", features = ["io-util", "macros", "process", "rt-multi-thread", "signal", "sync", "time"] }
url = "2"
unicode-normalization = "0.1"
uuid = { version = "1", features = ["v4"] }
zip = { version = "2", default-features = false, features = ["deflate"] }
tauri-plugin-clipboard-manager = "2.3.2"
@@ -1,5 +1,11 @@
use super::*;
const EXTERNAL_GENERATION_POLL_TIMEOUT: Duration = Duration::from_secs(35 * 60);
const EXTERNAL_GENERATION_DEFAULT_POLL_AFTER_MS: u64 = 2_000;
const EXTERNAL_GENERATION_MAX_POLL_AFTER_MS: u64 = 5_000;
const EXTERNAL_GENERATION_SUBMIT_MAX_ATTEMPTS: usize = 3;
const EXTERNAL_GENERATION_SUBMIT_RETRY_BACKOFF_MS: u64 = 250;
pub(crate) fn project_canvas_asset_media_types(root: &Path) -> Vec<String> {
read_manifest_for_project(root)
.map(|manifest| {
@@ -270,6 +276,132 @@ async fn external_editor_json_request(
.map_err(|error| format!("解析{action}响应失败:{error}"))
}
fn external_generation_poll_after_ms(payload: &serde_json::Value) -> u64 {
external_editor_response_data(payload)
.get("pollAfterMs")
.and_then(serde_json::Value::as_u64)
.unwrap_or(EXTERNAL_GENERATION_DEFAULT_POLL_AFTER_MS)
.min(EXTERNAL_GENERATION_MAX_POLL_AFTER_MS)
}
async fn wait_for_external_generation_result(
client: &reqwest::Client,
api_base_url: &str,
api_key: &str,
submission_payload: &serde_json::Value,
) -> Result<serde_json::Value, String> {
let submission = external_editor_response_data(submission_payload);
let operation_id = json_string_field(submission, "operationId")
.ok_or_else(|| "外部图片生成提交响应缺少 operationId".to_string())?;
let operation_id_path =
url::form_urlencoded::byte_serialize(operation_id.as_bytes()).collect::<String>();
let status_url = format!("{api_base_url}/api/external/v1/generations/{operation_id_path}");
let started_at = tokio::time::Instant::now();
let mut poll_after_ms = external_generation_poll_after_ms(submission_payload);
loop {
if started_at.elapsed() >= EXTERNAL_GENERATION_POLL_TIMEOUT {
return Err(format!(
"平台图片生成任务仍在执行,已停止本地等待;operationId={operation_id}"
));
}
if poll_after_ms > 0 {
tokio::time::sleep(Duration::from_millis(poll_after_ms)).await;
}
let payload = match external_editor_json_request(
client.get(&status_url).bearer_auth(api_key),
"查询平台图片生成任务",
)
.await
{
Ok(payload) => payload,
Err(error)
if !error.contains("HTTP ")
|| [429, 502, 503, 504]
.iter()
.any(|status| error.contains(&format!("HTTP {status}"))) =>
{
poll_after_ms = EXTERNAL_GENERATION_DEFAULT_POLL_AFTER_MS;
continue;
}
Err(error) => return Err(format!("{error};operationId={operation_id}")),
};
let generation = external_editor_response_data(&payload);
match json_string_field(generation, "status").as_deref() {
Some("completed") => {
return generation
.get("result")
.filter(|result| !result.is_null())
.cloned()
.ok_or_else(|| {
format!(
"平台图片生成任务已完成但响应缺少 result;operationId={operation_id}"
)
});
}
Some("failed") => {
let error = json_string_field(generation, "error")
.or_else(|| json_string_field(generation, "phaseDetail"))
.unwrap_or_else(|| "生成任务失败".to_string());
return Err(format!(
"平台图片生成任务失败:{error};operationId={operation_id}"
));
}
Some("queued" | "running") => {
poll_after_ms = external_generation_poll_after_ms(&payload);
}
Some(status) => {
return Err(format!(
"平台图片生成任务返回未知状态 {status};operationId={operation_id}"
));
}
None => {
return Err(format!(
"平台图片生成任务状态响应缺少 status;operationId={operation_id}"
));
}
}
}
}
async fn submit_external_generation_request(
client: &reqwest::Client,
api_base_url: &str,
endpoint: &str,
api_key: &str,
idempotency_key: &str,
request_body: &serde_json::Value,
) -> Result<reqwest::Response, String> {
let mut last_error = None;
for attempt in 1..=EXTERNAL_GENERATION_SUBMIT_MAX_ATTEMPTS {
match client
.post(format!("{api_base_url}{endpoint}"))
.bearer_auth(api_key)
.header("Idempotency-Key", idempotency_key)
.json(request_body)
.send()
.await
{
Ok(response) => return Ok(response),
Err(error) => {
last_error = Some(error);
if attempt < EXTERNAL_GENERATION_SUBMIT_MAX_ATTEMPTS {
tokio::time::sleep(Duration::from_millis(
EXTERNAL_GENERATION_SUBMIT_RETRY_BACKOFF_MS * attempt as u64,
))
.await;
}
}
}
}
Err(format!(
"请求平台图片生成失败:{}",
last_error
.map(|error| error.to_string())
.unwrap_or_else(|| "未知传输错误".to_string())
))
}
async fn prepare_external_canvas_generation_context(
root: &Path,
client: &reqwest::Client,
@@ -519,7 +651,10 @@ pub(in crate::agent) async fn request_platform_art_asset_with_options_at(
prepared_output_path.and_then(|(_, _, replacement_fingerprint)| replacement_fingerprint);
let api_base_url = resolve_canvas_sync_api_base_url(None)?;
let api_key = resolve_canvas_sync_api_key(None)?;
let client = reqwest::Client::new();
let client = reqwest::Client::builder()
.timeout(Duration::from_secs(60))
.build()
.map_err(|error| format!("创建 External Editor HTTP 客户端失败:{error}"))?;
let canvas_context =
prepare_external_canvas_generation_context(root, &client, &api_base_url, &api_key).await?;
let generation_prompt = build_platform_art_asset_prompt(prompt, briefs, options);
@@ -582,22 +717,28 @@ pub(in crate::agent) async fn request_platform_art_asset_with_options_at(
}),
)
};
let response = client
.post(format!("{api_base_url}{endpoint}"))
.bearer_auth(&api_key)
.json(&request_body)
.send()
.await
.map_err(|error| format!("请求平台图片生成失败:{error}"))?;
let idempotency_key = uuid::Uuid::new_v4().to_string();
let response = submit_external_generation_request(
&client,
&api_base_url,
endpoint,
&api_key,
&idempotency_key,
&request_body,
)
.await?;
let status = response.status();
if !status.is_success() {
return Err(format!("请求平台图片生成失败:HTTP {}", status.as_u16()));
}
let payload = response
let submission_payload = response
.json::<serde_json::Value>()
.await
.map_err(|error| format!("解析平台图片生成响应失败:{error}"))?;
let generated = payload.get("data").unwrap_or(&payload);
.map_err(|error| format!("解析平台图片生成提交响应失败:{error}"))?;
let generated =
wait_for_external_generation_result(&client, &api_base_url, &api_key, &submission_payload)
.await?;
let generated = &generated;
if let Some(error) = platform_art_generation_postprocess_failure(generated) {
return Err(error);
}
@@ -643,7 +784,8 @@ pub(in crate::agent) async fn request_platform_art_asset_with_options_at(
let generated_prompt = json_string_field(generated, "actualPrompt")
.or_else(|| json_string_field(generated, "prompt"))
.or_else(|| json_string_field(resource, "actualPrompt"))
.or_else(|| json_string_field(resource, "prompt"));
.or_else(|| json_string_field(resource, "prompt"))
.or_else(|| Some(generation_prompt.clone()));
let model =
json_string_field(generated, "model").or_else(|| json_string_field(resource, "model"));
let provider = json_string_field(generated, "provider")
@@ -1023,6 +1165,49 @@ mod canvas_generation_tests {
use super::*;
use image::{codecs::png::PngEncoder, ColorType, ImageEncoder};
fn read_test_http_request(stream: &mut std::net::TcpStream) -> String {
stream
.set_read_timeout(Some(Duration::from_secs(2)))
.expect("set request read timeout");
let mut bytes = Vec::new();
let mut buffer = [0_u8; 4096];
loop {
let read = stream.read(&mut buffer).expect("read request bytes");
if read == 0 {
break;
}
bytes.extend_from_slice(&buffer[..read]);
let Some(header_end) = bytes.windows(4).position(|window| window == b"\r\n\r\n") else {
continue;
};
let headers = String::from_utf8_lossy(&bytes[..header_end]);
let content_length = headers
.lines()
.find_map(|line| {
let (name, value) = line.split_once(':')?;
name.eq_ignore_ascii_case("content-length")
.then(|| value.trim().parse::<usize>().ok())
.flatten()
})
.unwrap_or(0);
if bytes.len() >= header_end + 4 + content_length {
break;
}
}
String::from_utf8(bytes).expect("request must be UTF-8")
}
fn test_request_header<'a>(request: &'a str, expected_name: &str) -> &'a str {
request
.lines()
.find_map(|line| {
let (name, value) = line.split_once(':')?;
name.eq_ignore_ascii_case(expected_name)
.then_some(value.trim())
})
.expect("expected request header")
}
fn rgba_test_png(alpha: u8) -> CanvasResourceDownload {
let mut bytes = Vec::new();
PngEncoder::new(&mut bytes)
@@ -1034,6 +1219,76 @@ mod canvas_generation_tests {
}
}
#[tokio::test]
async fn generation_submit_transport_retry_reuses_body_and_idempotency_key() {
let listener = std::net::TcpListener::bind("127.0.0.1:0").expect("bind retry fixture");
let base_url = format!("http://{}", listener.local_addr().expect("fixture address"));
let (sender, receiver) = std::sync::mpsc::channel();
std::thread::spawn(move || {
for attempt in 0..2 {
let (mut stream, _) = listener.accept().expect("accept submit request");
let request = read_test_http_request(&mut stream);
sender.send(request).expect("capture submit request");
if attempt == 0 {
continue;
}
let body = serde_json::json!({
"data": {
"operationId": "task-retry-1",
"status": "queued",
"pollAfterMs": 1
}
})
.to_string();
let response = format!(
"HTTP/1.1 202 Accepted\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{}",
body.len(),
body,
);
stream
.write_all(response.as_bytes())
.expect("write accepted response");
}
});
let client = reqwest::Client::builder()
.timeout(Duration::from_secs(2))
.build()
.expect("build retry client");
let idempotency_key = uuid::Uuid::new_v4().to_string();
let body = serde_json::json!({ "prompt": "stable retry" });
let response = submit_external_generation_request(
&client,
&base_url,
"/generation",
"test-key",
&idempotency_key,
&body,
)
.await
.expect("transport retry should succeed");
assert_eq!(response.status(), reqwest::StatusCode::ACCEPTED);
let first = receiver
.recv_timeout(Duration::from_secs(2))
.expect("first request");
let second = receiver
.recv_timeout(Duration::from_secs(2))
.expect("retried request");
assert_eq!(
test_request_header(&first, "idempotency-key"),
&idempotency_key
);
assert_eq!(
test_request_header(&second, "idempotency-key"),
&idempotency_key
);
assert_eq!(
first.split_once("\r\n\r\n").map(|(_, body)| body),
second.split_once("\r\n\r\n").map(|(_, body)| body),
);
}
#[test]
fn canonical_art_spritesheet_requires_real_transparent_pixels() {
assert!(platform_art_spritesheet_has_transparent_pixels(
@@ -2651,6 +2651,17 @@ fn spawn_mock_external_canvas_api_server_with_capture_and_generation_gate(
}
})
.to_string();
let generation_accepted_body = serde_json::json!({
"data": {
"operationId": "task-external-fixture-1",
"kind": "editor_image_generation",
"status": "queued",
"statusUrl": "/api/external/v1/generations/task-external-fixture-1",
"pollAfterMs": 1,
"updatedAtMicros": 1
}
})
.to_string();
let read_body = serde_json::json!({
"read": {
"provider": "aliyun-oss",
@@ -2677,6 +2688,8 @@ fn spawn_mock_external_canvas_api_server_with_capture_and_generation_gate(
.to_string();
std::thread::spawn(move || {
let mut generation_response_gate = generation_response_gate;
let mut pending_generation_result: Option<String> = None;
let mut generation_poll_index = 0_u8;
for _ in 0..expected_requests {
let (mut stream, _) = listener.accept().expect("mock canvas api accept");
let mut request_buffer = [0_u8; 8192];
@@ -2686,61 +2699,119 @@ fn spawn_mock_external_canvas_api_server_with_capture_and_generation_gate(
let _ = sender.send(request.to_string());
}
let normalized_request = request.to_ascii_lowercase();
let (content_type, body) = if request
let (status, content_type, body) = if request
.starts_with("GET /api/external/v1/editor/projects ")
{
assert!(normalized_request.contains("authorization: bearer "));
("application/json", projects_body.as_bytes().to_vec())
("200 OK", "application/json", projects_body.as_bytes().to_vec())
} else if request.starts_with("GET /api/external/v1/editor/assets/library ") {
assert!(normalized_request.contains("authorization: bearer "));
("application/json", library_body.as_bytes().to_vec())
("200 OK", "application/json", library_body.as_bytes().to_vec())
} else if request.starts_with("GET /api/external/v1/editor/projects/canvas-project-1 ")
{
assert!(normalized_request.contains("authorization: bearer "));
("application/json", project_body.as_bytes().to_vec())
("200 OK", "application/json", project_body.as_bytes().to_vec())
} else if request.starts_with("POST /api/external/v1/editor/images/generations ") {
assert!(normalized_request.contains("authorization: bearer "));
if let Some(gate) = generation_response_gate.take() {
gate.recv_timeout(Duration::from_secs(5))
.expect("release mock canvas generation response");
}
("application/json", generation_body.as_bytes().to_vec())
let idempotency_key = request
.lines()
.find_map(|line| {
let (name, value) = line.split_once(':')?;
name.eq_ignore_ascii_case("idempotency-key")
.then_some(value.trim())
})
.expect("generation request idempotency key");
uuid::Uuid::parse_str(idempotency_key.trim())
.expect("generation idempotency key must be UUID");
pending_generation_result = Some(generation_body.clone());
generation_poll_index = 0;
(
"202 Accepted",
"application/json",
generation_accepted_body.as_bytes().to_vec(),
)
} else if request
.starts_with("POST /api/external/v1/editor/icon-spritesheets/generations ")
{
assert!(normalized_request.contains("authorization: bearer "));
let idempotency_key = request
.lines()
.find_map(|line| {
let (name, value) = line.split_once(':')?;
name.eq_ignore_ascii_case("idempotency-key")
.then_some(value.trim())
})
.expect("generation request idempotency key");
uuid::Uuid::parse_str(idempotency_key.trim())
.expect("generation idempotency key must be UUID");
pending_generation_result = Some(icon_spritesheet_body.clone());
generation_poll_index = 0;
(
"202 Accepted",
"application/json",
generation_accepted_body.as_bytes().to_vec(),
)
} else if request.starts_with(
"GET /api/external/v1/generations/task-external-fixture-1 ",
) {
assert!(normalized_request.contains("authorization: bearer "));
if let Some(gate) = generation_response_gate.take() {
gate.recv_timeout(Duration::from_secs(5))
.expect("release mock canvas generation response");
}
let status = match generation_poll_index {
0 => "queued",
1 => "running",
_ => "completed",
};
generation_poll_index = generation_poll_index.saturating_add(1);
let result = (status == "completed").then(|| {
serde_json::from_str::<serde_json::Value>(
pending_generation_result
.as_deref()
.expect("generation query follows one submission"),
)
.expect("fixture generation result JSON")
});
(
"200 OK",
"application/json",
icon_spritesheet_body.as_bytes().to_vec(),
serde_json::json!({
"data": {
"operationId": "task-external-fixture-1",
"kind": "editor_image_generation",
"status": status,
"phaseLabel": "图片画布生成图片",
"phaseDetail": if status == "completed" { "生成已完成。" } else { "正在生成。" },
"progress": if status == "completed" { 100 } else { 35 },
"result": result,
"pollAfterMs": 1,
"updatedAtMicros": 2
}
})
.to_string()
.into_bytes(),
)
} else if request.starts_with(
"GET /api/external/v1/assets/read-url?objectKey=generated%2Fcanvas%2Fhero.png ",
) {
assert!(normalized_request.contains("authorization: bearer "));
("application/json", read_body.as_bytes().to_vec())
("200 OK", "application/json", read_body.as_bytes().to_vec())
} else if request.starts_with(
"GET /api/external/v1/assets/read-url?objectKey=generated%2Fcanvas%2Fspritesheet.png ",
) {
assert!(normalized_request.contains("authorization: bearer "));
(
"200 OK",
"application/json",
spritesheet_read_body.as_bytes().to_vec(),
)
} else if request.starts_with("GET /signed/hero.png ") {
("image/png", valid_test_png_bytes())
("200 OK", "image/png", valid_test_png_bytes())
} else if request.starts_with("GET /signed/spritesheet.png ") {
("image/png", transparent_test_png_bytes())
("200 OK", "image/png", transparent_test_png_bytes())
} else {
("text/plain", b"not found".to_vec())
};
let status = if content_type == "text/plain" {
"404 Not Found"
} else {
"200 OK"
("404 Not Found", "text/plain", b"not found".to_vec())
};
let response = format!(
"HTTP/1.1 {status}\r\nContent-Type: {content_type}\r\nContent-Length: {}\r\nConnection: close\r\n\r\n",
@@ -2762,7 +2833,7 @@ fn spawn_mock_external_canvas_api_server() -> String {
fn spawn_mock_external_canvas_generation_api_server(
request_sender: Option<mpsc::Sender<String>>,
) -> String {
spawn_mock_external_canvas_api_server_with_capture(5, request_sender)
spawn_mock_external_canvas_api_server_with_capture(8, request_sender)
}
fn spawn_mock_external_canvas_generation_api_server_with_gate(
@@ -2770,7 +2841,7 @@ fn spawn_mock_external_canvas_generation_api_server_with_gate(
generation_response_gate: mpsc::Receiver<()>,
) -> String {
spawn_mock_external_canvas_api_server_with_capture_and_generation_gate(
5,
8,
Some(request_sender),
Some(generation_response_gate),
)
@@ -780,7 +780,7 @@ async fn background_agent_runtime_can_generate_platform_art_asset() {
assert!(agent_db.contains("\"agentId\":\"art-asset-plan\""));
assert!(agent_db.contains("测试图集保持整图,未生成独立切片。"));
assert!(!agent_db.contains("editor-runtime-key"));
let canvas_requests = (0..5)
let canvas_requests = (0..8)
.map(|_| {
canvas_receiver
.recv_timeout(Duration::from_secs(2))
@@ -793,6 +793,22 @@ async fn background_agent_runtime_can_generate_platform_art_asset() {
request.starts_with("POST /api/external/v1/editor/icon-spritesheets/generations ")
})
.expect("canvas generation request");
assert_eq!(
canvas_requests
.iter()
.filter(|request| request.starts_with("POST /api/external/v1/editor/"))
.count(),
1,
"queued/running polling must not submit generation again"
);
assert_eq!(
canvas_requests
.iter()
.filter(|request| request.starts_with("GET /api/external/v1/generations/"))
.count(),
3,
"fixture should exercise queued, running, and completed states"
);
for expected in [
r#""referenceImageSrc":"resource-icon-spec""#,
r#""iconDescriptions":"#,
@@ -839,7 +855,7 @@ async fn canonical_art_spec_and_ui_requests_use_the_shared_reference_chain() {
request_platform_art_asset_with_options_for_test(root, "原创贪吃蛇视觉", &options)
.await
.expect("prepare canonical visual request");
(0..5)
(0..8)
.map(|_| {
request_receiver
.recv_timeout(Duration::from_secs(2))