增加外部API的MCP与异步生成模式
新增托管式Streamable HTTP MCP端点并复用外部API Key鉴权 统一图片视频音频等生成请求为幂等异步提交和状态查询 提供可发现的使用说明、OpenAPI资源及完整Skill下载包 同步更新OpenAPI契约并规定接口变更必须连带维护 适配AI游戏创作Shell的异步提交轮询与安全重试 补齐MCP方法映射、异步Worker和客户端回归测试
This commit is contained in:
@@ -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
@@ -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))
|
||||
|
||||
Reference in New Issue
Block a user