Compare commits
35 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| f5a333b839 | |||
| 2e15a70b14 | |||
| 20b1fd63de | |||
| 105591bac5 | |||
| 195b7dd5dd | |||
| da44d66dc8 | |||
| 0a85c4f87e | |||
| efce7b102f | |||
| db5edc948c | |||
| 45c3780bf5 | |||
| 6fcf42e4ac | |||
| e0f9f811b7 | |||
| 03b5c1c9f4 | |||
| 58992330aa | |||
| 2c687b01a4 | |||
| c0e377f479 | |||
| 6001b87215 | |||
| b08ab6ee66 | |||
| 7f80012d7f | |||
| fbe95591d5 | |||
| d222aad2ec | |||
| 13b28ebbc7 | |||
| 30648e6b93 | |||
| 9dd1052374 | |||
| aec9568c39 | |||
| 0a1f0e0b26 | |||
| 5c31ae91ca | |||
| 3ba6168c6a | |||
| 2628b83d4b | |||
| a7b2b0e23b | |||
| ef982fdb78 | |||
| 91b65f94ca | |||
| 70513cf049 | |||
| 87aed0b765 | |||
| 733a7015af |
+1
-1
@@ -14,7 +14,7 @@ Let the client derive projections from real disk changes and trusted tool result
|
||||
3. Keep read scopes separate: `asset.list` is the current project manifest, `asset.library.list` is the signed-in account library, and the web project's canvas resource read model is the authoritative canvas list. The account library is not the complete canvas list.
|
||||
4. Use `canvas.asset_import` for safe account/canvas asset IDs or project-relative local paths. The client rechecks ownership and validates bytes; host absolute paths require native UI file-picker authorization.
|
||||
5. When the user explicitly asks to create or derive video, character animation, sound effect, or background music, call `agc_create_or_derive_resource`. Use `create` only for video/audio without a source and `derive` with a registered `sourceLocalAssetId`; character animation is always derived from an image. Keep `prompt` inside the per-kind limit that the client really enforces: background music at most 140 characters, sound effect at most 1900, video and character animation at most 4000. A longer prompt is rejected before submission, so write the short version first instead of retrying the same text.
|
||||
6. When the user explicitly asks to remove an image background, call `agc_remove_background` with a registered image `sourceLocalAssetId` and `assetName`. Optional `backgroundMode` is `complex` (semantic foreground segmentation; default) or `flat` (solid-colour background removal). Prefer `flat` when the background is known to be solid. Only `flat` accepts optional `screenColor`: `auto`, `#RRGGBB`, or omitted for automatic detection by the service. Do not select a colour on behalf of `auto`. The client requires the signed-in account, owns canvas/folder context and task identity, and returns only bounded queue state.
|
||||
6. When the user explicitly asks to remove an image background, call `agc_remove_background` with a registered image `sourceLocalAssetId` and `assetName`. Optional `backgroundMode` is `complex` (semantic foreground segmentation; default) or `flat` (solid-colour background removal). Prefer `flat` when the background is known to be solid. Only `flat` accepts optional `screenColor`: `auto`, `#RRGGBB`, or omitted for automatic detection by the service. Do not select a colour on behalf of `auto`. The client requires the signed-in account, owns canvas/folder context and task identity, waits for the accepted operation, downloads and registers the completed local asset, and preserves the operation for recovery when the remote result is not yet known.
|
||||
7. Preserve existing relative paths when a small edit is sufficient so client resource identities remain stable.
|
||||
8. Do not edit `.agent/manifest.json`, revision counters, version records, resource IDs, canvas identities, source provenance, generation ledgers, or browser receipts by hand.
|
||||
9. Do not create a version when no game file changed. The client compares content fingerprints and advances revision only after an actual source change.
|
||||
|
||||
+3
-1
@@ -16,4 +16,6 @@ Read scopes remain separate: `asset.list` is the current project's local manifes
|
||||
|
||||
`prompt` limits are per kind and are enforced before any paid submission: background music accepts 1-140 characters, sound effect 1-1900, video and character animation 1-4000, and image editing (`agc_edit_image`) 1-32000. The client composes the submitted request from a fixed prefix plus your prompt, so an over-limit prompt fails locally with the exact limit; shorten the text rather than resubmitting the same value. `agc_edit_image` remains the image path; this tool never generates or edits still images.
|
||||
|
||||
`agc_remove_background` accepts a registered image `sourceLocalAssetId`, `assetName`, and optional `backgroundMode` and `screenColor`. `complex` uses semantic segmentation to identify the foreground; `flat` removes a solid-colour background. Prefer `flat` when the background is known to be solid; omitting the mode selects `complex`. Only `flat` accepts a colour: `auto`, `#RRGGBB`, or omitted for automatic service detection. Never infer a concrete colour for `auto`. Empty or invalid values and colour without `flat` are rejected. The client resolves the formal source resource, canvas/folder context, stable operation identity, idempotency key, and authenticated External v1 `/api/external/v1/editor/images/background-removals` call. Mode and colour are part of request identity. Its result is bounded queue state; Codex must not poll internal workers, construct source URLs, or retry with a new identity after an uncertain response.
|
||||
`agc_remove_background` accepts a registered image `sourceLocalAssetId`, `assetName`, and optional `backgroundMode` and `screenColor`. `complex` uses semantic segmentation to identify the foreground; `flat` removes a solid-colour background. Prefer `flat` when the background is known to be solid; omitting the mode selects `complex`. Only `flat` accepts a colour: `auto`, `#RRGGBB`, or omitted for automatic service detection. Never infer a concrete colour for `auto`. Empty or invalid values and colour without `flat` are rejected. The client resolves the formal source resource, canvas/folder context, stable operation identity, idempotency key, and authenticated request. Ordinary account mode maps the External v1 shaped route to `/api/editor/images/background-removals`; ExternalDeveloper mode uses `/api/external/v1/editor/images/background-removals`. Mode and colour are part of request identity. After acceptance, the client polls the authenticated generation status route, downloads the completed media, and commits it to the local manifest. If completion is unknown, it retains the same local operation for recovery; it never retries with a new identity or exposes internal worker details.
|
||||
|
||||
After an interrupted call, inspect `agc_list_registered_assets.pendingOperations`. Calling `agc_remove_background` again with the same source, name, mode, and colour resumes the matching pending operation. A submission marked `reconciliation-required` needs client-side reconciliation and cannot be automatically resumed. Do not change parameters to bypass a pending task. A queued receipt, fixed progress value, or absent local file does not establish that the background-removal provider is waiting in a queue; report only the observed state.
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"schemaVersion": "agc-skill-pack.v1",
|
||||
"version": "2026-08-26.20",
|
||||
"version": "2026-08-26.23",
|
||||
"skills": [
|
||||
{
|
||||
"name": "agc-game-production-workflow",
|
||||
@@ -123,7 +123,7 @@
|
||||
"agents/openai.yaml",
|
||||
"references/projection-contract.md"
|
||||
],
|
||||
"sha256": "93210c0eeb73b279d35aa85c201c226139b0bdf041f3300ac2c6e2c1bdd63afe"
|
||||
"sha256": "247787975944ce8b21d7c879c39c60ec13608056cff9426ac374c9299937d475"
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -2566,11 +2566,18 @@ fn direct_taonier_art_asset_identity(
|
||||
.to_string(),
|
||||
reference_resource_ids: asset.source.reference_resource_ids.clone(),
|
||||
};
|
||||
// 参考集合先按该 kind 的请求合同收口:根素材(规范图)允许用户参考(icon-spec 没有
|
||||
// 规范前置,参考只是风格输入),派生素材仍必须按合同携带规范前置。
|
||||
let references_match_contract =
|
||||
crate::agent::platform_art_runtime_references_match_request_contract(
|
||||
&identity.reference_resource_ids,
|
||||
expected_kind,
|
||||
);
|
||||
let lineage_matches = match expected_reference_source {
|
||||
Some(source) => direct_taonier_reference_matches_local_source(root, source, &identity),
|
||||
None => identity.reference_resource_ids.is_empty(),
|
||||
None => true,
|
||||
};
|
||||
lineage_matches.then_some(identity)
|
||||
(references_match_contract && lineage_matches).then_some(identity)
|
||||
})
|
||||
}
|
||||
|
||||
@@ -2579,7 +2586,9 @@ fn direct_taonier_reference_matches_local_source(
|
||||
source: &DirectTaonierArtAssetIdentity,
|
||||
derived: &DirectTaonierArtAssetIdentity,
|
||||
) -> bool {
|
||||
let [remote_reference_id] = derived.reference_resource_ids.as_slice() else {
|
||||
// 派生素材的规范身份只由参考序列首项承担:用户参考按顺序追加在规范图之后,
|
||||
// 不能让它们顶替或淹没规范引用,也不能因为多出用户参考就判定派生关系不成立。
|
||||
let Some(remote_reference_id) = derived.reference_resource_ids.first() else {
|
||||
return false;
|
||||
};
|
||||
if derived.canvas_project_id == source.canvas_project_id
|
||||
@@ -3390,6 +3399,8 @@ async fn generate_direct_taonier_art_asset_at(
|
||||
slice_mode: (asset_kind == "art-spritesheet").then(|| "connected-components".to_string()),
|
||||
grid_x: None,
|
||||
grid_y: None,
|
||||
reference_asset_ids: Vec::new(),
|
||||
target_category: None,
|
||||
};
|
||||
let runtime_context =
|
||||
direct_taonier_art_generation_runtime_context(root, output_path, asset_kind)?;
|
||||
@@ -9802,6 +9813,78 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn direct_taonier_art_package_accepts_manifest_user_references() {
|
||||
let root = tempfile::tempdir().expect("temp dir");
|
||||
init_local_game_project_at(root.path(), "direct-art-references", "直连美术参考")
|
||||
.expect("init project");
|
||||
register_direct_taonier_art_package_fixture(root.path());
|
||||
assert!(direct_taonier_art_package_is_valid(root.path()));
|
||||
|
||||
// 规范图与背景图带用户参考:参考只是风格输入,规范身份仍由参考序列首项承担。
|
||||
mutate_manifest_at(root.path(), |manifest| {
|
||||
let art_spec = manifest
|
||||
.assets
|
||||
.iter_mut()
|
||||
.find(|asset| asset.local_path == DIRECT_CODEX_ART_SPEC_ASSET_PATH)
|
||||
.expect("art spec asset");
|
||||
art_spec.source.reference_resource_ids = vec![
|
||||
"user-reference-1".to_string(),
|
||||
"user-reference-2".to_string(),
|
||||
];
|
||||
let background = manifest
|
||||
.assets
|
||||
.iter_mut()
|
||||
.find(|asset| asset.local_path == DIRECT_CODEX_BACKGROUND_ASSET_PATH)
|
||||
.expect("background asset");
|
||||
background
|
||||
.source
|
||||
.reference_resource_ids
|
||||
.push("user-reference-1".to_string());
|
||||
Ok(())
|
||||
})
|
||||
.expect("apply user references to the art base");
|
||||
assert!(
|
||||
direct_taonier_art_package_is_valid(root.path()),
|
||||
"user references must not invalidate the art package"
|
||||
);
|
||||
|
||||
// 图集仍只接受唯一规范引用:多一项用户参考必须失败关闭。
|
||||
mutate_manifest_at(root.path(), |manifest| {
|
||||
let spritesheet = manifest
|
||||
.assets
|
||||
.iter_mut()
|
||||
.find(|asset| asset.local_path == DIRECT_CODEX_SPRITESHEET_ASSET_PATH)
|
||||
.expect("spritesheet asset");
|
||||
spritesheet
|
||||
.source
|
||||
.reference_resource_ids
|
||||
.push("user-reference-1".to_string());
|
||||
Ok(())
|
||||
})
|
||||
.expect("add an extra spritesheet reference");
|
||||
assert!(
|
||||
!direct_taonier_art_package_is_valid(root.path()),
|
||||
"art spritesheet must reject extra user references"
|
||||
);
|
||||
|
||||
// 用户参考不能顶替图集的规范前置。
|
||||
mutate_manifest_at(root.path(), |manifest| {
|
||||
let spritesheet = manifest
|
||||
.assets
|
||||
.iter_mut()
|
||||
.find(|asset| asset.local_path == DIRECT_CODEX_SPRITESHEET_ASSET_PATH)
|
||||
.expect("spritesheet asset");
|
||||
spritesheet.source.reference_resource_ids = vec!["user-reference-1".to_string()];
|
||||
Ok(())
|
||||
})
|
||||
.expect("replace the spritesheet canonical reference");
|
||||
assert!(
|
||||
!direct_taonier_art_package_is_valid(root.path()),
|
||||
"a user reference must not replace the art spritesheet canonical spec"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn direct_output_sync_accepts_a_complete_spritesheet_without_slices() {
|
||||
let root = tempfile::tempdir().expect("temp dir");
|
||||
|
||||
@@ -1294,7 +1294,10 @@ fn bridge_list_registered_assets(root: &Path, arguments: &Value) -> Value {
|
||||
.map(|asset| bridge_registered_resource(asset, include_sequence_frames))
|
||||
.collect::<Vec<_>>();
|
||||
let next_offset = (offset + resources.len() < total).then_some(offset + resources.len());
|
||||
let pending = list_pending_local_project_resource_edits_at(
|
||||
let platform_session = (editor_api_mode() == EditorApiMode::PlatformAccount)
|
||||
.then(current_platform_session)
|
||||
.flatten();
|
||||
let pending = list_pending_local_project_resource_edits_for_session_at(
|
||||
ListPendingLocalProjectResourceEditsInput {
|
||||
project_path: root
|
||||
.to_str()
|
||||
@@ -1302,6 +1305,7 @@ fn bridge_list_registered_assets(root: &Path, arguments: &Value) -> Value {
|
||||
.to_string(),
|
||||
expected_project_id: manifest.project_id,
|
||||
},
|
||||
platform_session.as_ref(),
|
||||
)?
|
||||
.into_iter()
|
||||
.map(|edit| {
|
||||
@@ -1311,6 +1315,8 @@ fn bridge_list_registered_assets(root: &Path, arguments: &Value) -> Value {
|
||||
"mode": edit.generation_mode,
|
||||
"sourceResourceId": edit.source_resource_id,
|
||||
"assetName": edit.asset_name,
|
||||
"backgroundMode": edit.background_mode,
|
||||
"screenColor": edit.screen_color,
|
||||
"phase": edit.phase,
|
||||
"createdAt": edit.created_at,
|
||||
})
|
||||
@@ -1801,8 +1807,8 @@ async fn bridge_import_account_assets(state: &DirectToolBridgeState, arguments:
|
||||
|
||||
fn bridge_completed_resource_result(
|
||||
root: &Path,
|
||||
kind: DirectResourceGenerationKind,
|
||||
mode: DirectResourceGenerationMode,
|
||||
kind: &str,
|
||||
mode: &str,
|
||||
result: DeriveLocalProjectResourceResult,
|
||||
) -> Result<Value, String> {
|
||||
let asset = result
|
||||
@@ -1814,8 +1820,8 @@ fn bridge_completed_resource_result(
|
||||
Ok(json!({
|
||||
"status": "completed",
|
||||
"operationId": result.operation_id,
|
||||
"kind": kind.as_str(),
|
||||
"mode": mode.as_str(),
|
||||
"kind": kind,
|
||||
"mode": mode,
|
||||
"sourceResourceId": result.source_resource_id,
|
||||
"committedProjectRevision": result.committed_project_revision,
|
||||
"resource": bridge_registered_resource(asset, true),
|
||||
@@ -1910,10 +1916,17 @@ async fn bridge_create_or_derive_resource(
|
||||
source_version_id: None,
|
||||
prompt: input.prompt.clone(),
|
||||
asset_name: input.asset_name.clone(),
|
||||
background_mode: None,
|
||||
screen_color: None,
|
||||
};
|
||||
with_direct_editor_api_credentials(derive_local_project_resource_at(request)).await?
|
||||
};
|
||||
bridge_completed_resource_result(&state.root, input.kind, input.mode, completed)
|
||||
bridge_completed_resource_result(
|
||||
&state.root,
|
||||
input.kind.as_str(),
|
||||
input.mode.as_str(),
|
||||
completed,
|
||||
)
|
||||
}
|
||||
.await;
|
||||
match result {
|
||||
@@ -1927,7 +1940,8 @@ async fn bridge_create_or_derive_resource(
|
||||
}
|
||||
|
||||
async fn bridge_remove_background(state: &DirectToolBridgeState, arguments: &Value) -> Value {
|
||||
let result = async {
|
||||
let _generation_guard = state.resource_generation_gate.lock().await;
|
||||
let result = with_direct_editor_api_credentials(async {
|
||||
super::direct_tools_mcp::validate_remove_background_arguments(arguments)?;
|
||||
enforce_project_permission_policy(&state.root, "canvas.asset_generate")?;
|
||||
enforce_project_permission_policy(&state.root, "asset.register")?;
|
||||
@@ -1948,74 +1962,72 @@ async fn bridge_remove_background(state: &DirectToolBridgeState, arguments: &Val
|
||||
if !source_asset.media_type.starts_with("image/") {
|
||||
return Err("抠图工具只接受当前项目已登记的图片资源".to_string());
|
||||
}
|
||||
let source_resource_id = source_asset
|
||||
.source
|
||||
.resource_id
|
||||
.as_deref()
|
||||
.map(str::trim)
|
||||
.filter(|value| !value.is_empty() && !value.starts_with("local-asset:"))
|
||||
.ok_or_else(|| "图片资源缺少可供抠图服务使用的正式 resourceId".to_string())?
|
||||
.to_string();
|
||||
let (api_base_url, api_key, session) = resolve_canvas_sync_api_credentials(None, None)?;
|
||||
let access = ExternalEditorBindingAccess::new(&api_base_url, &api_key, session.as_ref())?;
|
||||
let client = crate::http_client::agc_main_site_client_builder()
|
||||
.build()
|
||||
.map_err(|_| "创建抠图服务连接失败".to_string())?;
|
||||
let context =
|
||||
prepare_external_canvas_generation_context(&state.root, &client, &access).await?;
|
||||
let background_mode = background_mode.unwrap_or("complex").to_string();
|
||||
let source_resource_id = bridge_asset_canonical_resource_id(source_asset);
|
||||
let fingerprint = background_removal_request_fingerprint(
|
||||
&source_asset_id,
|
||||
&asset_name,
|
||||
background_mode,
|
||||
Some(background_mode.as_str()),
|
||||
screen_color,
|
||||
);
|
||||
let (_operation_id, idempotency_key) = state.resource_request_ids(&fingerprint)?;
|
||||
let route = "/api/external/v1/editor/images/background-removals";
|
||||
let mut request_body = json!({
|
||||
"sourceImageSrc": source_resource_id,
|
||||
"projectId": manifest.project_id,
|
||||
"assetKind": source_asset.kind,
|
||||
"assetFolderId": context.asset_folder_id,
|
||||
"assetLabel": asset_name,
|
||||
"sourceResourceId": source_resource_id,
|
||||
});
|
||||
if background_mode == Some("flat") {
|
||||
request_body["backgroundMode"] = json!("flat");
|
||||
let (_, _, platform_session) = resolve_canvas_sync_api_credentials(None, None)?;
|
||||
let pending = list_pending_local_project_resource_edits_for_session_at(
|
||||
ListPendingLocalProjectResourceEditsInput {
|
||||
project_path: state.root.to_string_lossy().into_owned(),
|
||||
expected_project_id: manifest.project_id.clone(),
|
||||
},
|
||||
platform_session.as_ref(),
|
||||
)?;
|
||||
let matching_pending = pending
|
||||
.into_iter()
|
||||
.filter(|pending| {
|
||||
pending.edit_kind == LocalProjectResourceEditKind::BackgroundRemoval
|
||||
&& (pending.source_asset_id.as_deref() == Some(source_asset_id.as_str())
|
||||
|| pending.source_resource_id == format!("local-asset:{source_asset_id}"))
|
||||
&& pending.asset_name == asset_name
|
||||
&& pending.background_mode.as_deref().unwrap_or("complex")
|
||||
== background_mode.as_str()
|
||||
&& pending.screen_color.as_deref() == screen_color
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
if matching_pending.len() > 1 {
|
||||
return Err("存在多个相同抠图 operation,必须先在客户端完成对账".to_string());
|
||||
}
|
||||
if let Some(color) = screen_color {
|
||||
request_body["screenColor"] = json!(color);
|
||||
}
|
||||
let response = crate::http_client::with_agc_main_site_marker(
|
||||
client
|
||||
.post(format!("{}{}", api_base_url, route))
|
||||
.bearer_auth(api_key)
|
||||
.header("Idempotency-Key", idempotency_key)
|
||||
.json(&request_body),
|
||||
)
|
||||
.send()
|
||||
.await
|
||||
.map_err(|error| format!("抠图服务提交失败:{error}"))?;
|
||||
let status = response.status();
|
||||
let payload = response
|
||||
.json::<Value>()
|
||||
.await
|
||||
.map_err(|error| format!("抠图服务响应无法解析:{error}"))?;
|
||||
if !status.is_success() {
|
||||
if status == reqwest::StatusCode::UNAUTHORIZED {
|
||||
return Err("authentication-required: 抠图服务提交失败:HTTP 401".to_string());
|
||||
}
|
||||
return Err(format!("抠图服务提交失败:HTTP {}", status.as_u16()));
|
||||
}
|
||||
let queue_state = external_editor_response_data(&payload).clone();
|
||||
Ok::<_, String>(json!({
|
||||
"status": "queued",
|
||||
"sourceLocalAssetId": source_asset_id,
|
||||
"assetName": asset_name,
|
||||
"projectId": manifest.project_id,
|
||||
"assetFolderId": context.asset_folder_id,
|
||||
"queueState": bridge_safe_queue_state(queue_state),
|
||||
}))
|
||||
}
|
||||
let completed = if let Some(pending) = matching_pending.into_iter().next() {
|
||||
resume_local_project_resource_edit_at(ResumeLocalProjectResourceEditInput {
|
||||
project_path: state.root.to_string_lossy().into_owned(),
|
||||
expected_project_id: manifest.project_id.clone(),
|
||||
operation_id: pending.operation_id,
|
||||
})
|
||||
.await?
|
||||
} else {
|
||||
let (operation_id, idempotency_key) = state.resource_request_ids(&fingerprint)?;
|
||||
let revision = read_game_creator_agent_runtime_project_revision(&state.root)?.revision;
|
||||
let request = DeriveLocalProjectResourceInput {
|
||||
project_path: state.root.to_string_lossy().into_owned(),
|
||||
expected_project_id: manifest.project_id.clone(),
|
||||
expected_project_revision: revision,
|
||||
operation_id,
|
||||
idempotency_key,
|
||||
edit_kind: LocalProjectResourceEditKind::BackgroundRemoval,
|
||||
generation_mode: LocalProjectResourceGenerationMode::Derive,
|
||||
source_resource_id,
|
||||
source_asset_id: Some(source_asset_id.clone()),
|
||||
source_path: Some(source_asset.local_path.clone()),
|
||||
source_media_type: Some(source_asset.media_type.clone()),
|
||||
source_subtype: Some(source_asset.kind.clone()),
|
||||
producer_task_id: source_asset.source.task_id.clone(),
|
||||
source_version_id: None,
|
||||
prompt: "去除背景".to_string(),
|
||||
asset_name: asset_name.clone(),
|
||||
background_mode: Some(background_mode),
|
||||
screen_color: screen_color.map(str::to_string),
|
||||
};
|
||||
derive_local_project_resource_at(request).await?
|
||||
};
|
||||
emit_game_creator_manifest_invalidated(&state.root, "direct-background-removal");
|
||||
bridge_completed_resource_result(&state.root, "background-removal", "derive", completed)
|
||||
})
|
||||
.await;
|
||||
match result {
|
||||
Ok(value) => bridge_tool_result(value.to_string(), Vec::new(), false),
|
||||
@@ -2041,17 +2053,6 @@ fn background_removal_request_fingerprint(
|
||||
}
|
||||
}
|
||||
|
||||
fn bridge_safe_queue_state(value: Value) -> Value {
|
||||
let object = value.as_object();
|
||||
json!({
|
||||
"operationId": object.and_then(|value| value.get("operationId")).and_then(Value::as_str),
|
||||
"status": object.and_then(|value| value.get("status")).and_then(Value::as_str),
|
||||
"phaseLabel": object.and_then(|value| value.get("phaseLabel")).and_then(Value::as_str),
|
||||
"progress": object.and_then(|value| value.get("progress")).and_then(Value::as_u64),
|
||||
"updatedAtMicros": object.and_then(|value| value.get("updatedAtMicros")).and_then(Value::as_u64),
|
||||
})
|
||||
}
|
||||
|
||||
fn bridge_art_resources(
|
||||
root: &Path,
|
||||
asset_paths: &[String],
|
||||
@@ -2340,6 +2341,8 @@ async fn bridge_generate_image(state: &DirectToolBridgeState, arguments: &Value)
|
||||
slice_mode,
|
||||
grid_x,
|
||||
grid_y,
|
||||
reference_asset_ids: Vec::new(),
|
||||
target_category: None,
|
||||
};
|
||||
let _generation_guard = state.image_generation_gate.lock().await;
|
||||
let generated = with_direct_editor_api_credentials(
|
||||
@@ -3844,20 +3847,4 @@ mod tests {
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bridge_background_removal_queue_projection_is_bounded() {
|
||||
let projection = bridge_safe_queue_state(json!({
|
||||
"operationId": "background-removal-1",
|
||||
"status": "queued",
|
||||
"phaseLabel": "排队中",
|
||||
"progress": 0,
|
||||
"updatedAtMicros": 1,
|
||||
"error": "private provider detail",
|
||||
"signedUrl": "https://private.invalid/result"
|
||||
}));
|
||||
assert_eq!(projection["operationId"], "background-removal-1");
|
||||
assert!(projection.get("error").is_none());
|
||||
assert!(projection.get("signedUrl").is_none());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -67,10 +67,11 @@ pub(crate) use canvas_generation::{
|
||||
generate_platform_art_asset_with_options_at,
|
||||
generate_platform_art_asset_with_required_slices_at, maybe_generate_platform_art_asset_step,
|
||||
needs_platform_art_asset_generation, normalize_platform_art_asset_generation_kind,
|
||||
normalize_platform_art_reference_asset_ids, normalize_platform_art_target_category,
|
||||
platform_art_asset_art_spec, platform_art_asset_output_extension_matches,
|
||||
prepare_platform_art_asset_output_path, project_canvas_asset_media_types,
|
||||
role_has_canvas_assets, suggested_canvas_tool_call, PlatformArtAssetGenerationOptions,
|
||||
PLATFORM_ART_ASSET_GENERATION_KINDS,
|
||||
platform_art_runtime_references_match_request_contract, prepare_platform_art_asset_output_path,
|
||||
project_canvas_asset_media_types, role_has_canvas_assets, suggested_canvas_tool_call,
|
||||
PlatformArtAssetGenerationOptions, PLATFORM_ART_ASSET_GENERATION_KINDS,
|
||||
};
|
||||
#[allow(unused_imports)]
|
||||
pub(crate) use draft_validation::{
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -416,10 +416,6 @@ pub(super) const AGENT_RUNTIME_REAL_E2E_TOOL_PLAN_CHECKPOINT_POLL_MS: u64 = 50;
|
||||
pub(super) const AGENT_RUNTIME_REAL_E2E_TOOL_PLAN_CHECKPOINT_MAX_TTL_MS: u64 = 10 * 60 * 1_000;
|
||||
pub(super) const AGENT_RUNTIME_REAL_E2E_TOOL_PLAN_CHECKPOINT_ERROR: &str =
|
||||
"agent-runtime-real-e2e-tool-plan-handoff-checkpoint-needs-reconciliation";
|
||||
pub(super) const AGENT_RUNTIME_PROVIDER_TRANSIENT_RETRY_LIMIT: u32 = 3;
|
||||
pub(super) const AGENT_RUNTIME_AUTONOMOUS_PROVIDER_TRANSIENT_RETRY_FLOOR: u32 = 12;
|
||||
pub(super) const AGENT_RUNTIME_AUTONOMOUS_PROVIDER_TRANSIENT_RETRY_LIMIT: u32 = 16;
|
||||
pub(crate) const AGENT_RUNTIME_AUTONOMOUS_PROVIDER_UPSTREAM_400_RETRY_LIMIT: u32 = 2;
|
||||
pub(super) const AGENT_RUNTIME_AUTONOMOUS_TOOL_PLAN_FORMAT_REPAIR_ATTEMPTS: usize = 4;
|
||||
pub(super) const AGENT_RUNTIME_AUTONOMOUS_FORCED_ACTION_MAX_OUTPUT_TOKENS: u32 = 2_000;
|
||||
pub(crate) const AGENT_RUNTIME_AUTONOMOUS_SCAFFOLD_MAX_OUTPUT_TOKENS: u32 = 2_600;
|
||||
|
||||
@@ -84,7 +84,7 @@ pub(crate) use response_stream::{
|
||||
pub(crate) use run_configuration::{
|
||||
agent_runtime_run_profile_identity_at, bind_game_creator_agent_runtime_run_profile_at,
|
||||
game_creator_agent_runtime_project_revision_path,
|
||||
game_creator_agent_runtime_provider_transient_max_retries_at,
|
||||
game_creator_agent_runtime_provider_transient_retry_policy_at,
|
||||
game_creator_agent_runtime_run_profile_binding_path,
|
||||
read_game_creator_agent_runtime_run_profile_binding,
|
||||
};
|
||||
|
||||
@@ -717,14 +717,14 @@ where
|
||||
Fut: std::future::Future<Output = Result<platform_llm::LlmRunResponse, platform_llm::LlmError>>,
|
||||
H: FnOnce(&platform_llm::LlmRunResponse) -> platform_llm::LlmRunResponse,
|
||||
{
|
||||
let max_retries = game_creator_agent_runtime_provider_transient_max_retries_at(
|
||||
let retry_policy = game_creator_agent_runtime_provider_transient_retry_policy_at(
|
||||
root,
|
||||
&provider_snapshot.agent_id,
|
||||
&provider_snapshot.run_id,
|
||||
llm.max_retries,
|
||||
)?;
|
||||
let retry_autonomous_upstream_400 =
|
||||
max_retries >= AGENT_RUNTIME_AUTONOMOUS_PROVIDER_TRANSIENT_RETRY_FLOOR;
|
||||
let max_retries = retry_policy.max_retries;
|
||||
let retry_autonomous_upstream_400 = retry_policy.retry_upstream_400;
|
||||
let identity = game_creator_agent_runtime_provider_retry_identity_for_mode(
|
||||
provider_snapshot,
|
||||
llm,
|
||||
@@ -1365,17 +1365,9 @@ where
|
||||
)?;
|
||||
return Err("Provider 瞬态错误编码损坏".to_string());
|
||||
};
|
||||
let error_max_retries = if error_kind == "upstream-400" {
|
||||
effective_max_retries
|
||||
.min(AGENT_RUNTIME_AUTONOMOUS_PROVIDER_UPSTREAM_400_RETRY_LIMIT)
|
||||
} else {
|
||||
effective_max_retries
|
||||
};
|
||||
if existing
|
||||
.as_ref()
|
||||
.is_some_and(|record| record.max_retries != error_max_retries)
|
||||
|| attempt >= error_max_retries
|
||||
{
|
||||
// 所有瞬态错误共用设置里的重试预算,上游 400 不再单独收窄上限。
|
||||
let error_max_retries = effective_max_retries;
|
||||
if attempt >= error_max_retries {
|
||||
crate::provider_retry::remove_at(
|
||||
root,
|
||||
&provider_snapshot.agent_id,
|
||||
@@ -1517,14 +1509,14 @@ pub(in crate::agent) async fn request_game_creator_agent_runtime_llm_with_transi
|
||||
operation: &str,
|
||||
request: &LlmRunRequest,
|
||||
) -> Result<Option<platform_llm::LlmRunResponse>, String> {
|
||||
let max_retries = game_creator_agent_runtime_provider_transient_max_retries_at(
|
||||
let retry_policy = game_creator_agent_runtime_provider_transient_retry_policy_at(
|
||||
root,
|
||||
&provider_snapshot.agent_id,
|
||||
&provider_snapshot.run_id,
|
||||
llm.max_retries,
|
||||
)?;
|
||||
let retry_autonomous_upstream_400 =
|
||||
max_retries >= AGENT_RUNTIME_AUTONOMOUS_PROVIDER_TRANSIENT_RETRY_FLOOR;
|
||||
let max_retries = retry_policy.max_retries;
|
||||
let retry_autonomous_upstream_400 = retry_policy.retry_upstream_400;
|
||||
for attempt in 0..=max_retries {
|
||||
let request_slot = if attempt == 0 {
|
||||
provider_snapshot.request_slot.clone()
|
||||
@@ -1585,11 +1577,8 @@ pub(in crate::agent) async fn request_game_creator_agent_runtime_llm_with_transi
|
||||
let Some((error_kind, public_error)) = encoded.split_once('\n') else {
|
||||
return Err("Provider 瞬态错误编码损坏".to_string());
|
||||
};
|
||||
let error_max_retries = if error_kind == "upstream-400" {
|
||||
max_retries.min(AGENT_RUNTIME_AUTONOMOUS_PROVIDER_UPSTREAM_400_RETRY_LIMIT)
|
||||
} else {
|
||||
max_retries
|
||||
};
|
||||
// 所有瞬态错误共用设置里的重试预算,上游 400 不再单独收窄上限。
|
||||
let error_max_retries = max_retries;
|
||||
if attempt >= error_max_retries {
|
||||
return Err(game_creator_agent_runtime_provider_retry_exhausted_error(
|
||||
public_error,
|
||||
|
||||
+16
-8
@@ -395,12 +395,22 @@ pub(crate) fn agent_runtime_run_profile_identity_at(
|
||||
Ok((profile, String::new()))
|
||||
}
|
||||
|
||||
pub(crate) fn game_creator_agent_runtime_provider_transient_max_retries_at(
|
||||
/// 当前持久 run 的 Provider 瞬态重试策略。
|
||||
///
|
||||
/// 重试次数严格使用设置值:运行档位不再把 `maxRetries` 收进固定区间,
|
||||
/// 只决定上游 400 是否算瞬态错误。
|
||||
#[derive(Debug)]
|
||||
pub(crate) struct AgentRuntimeProviderTransientRetryPolicy {
|
||||
pub(crate) max_retries: u32,
|
||||
pub(crate) retry_upstream_400: bool,
|
||||
}
|
||||
|
||||
pub(crate) fn game_creator_agent_runtime_provider_transient_retry_policy_at(
|
||||
root: &Path,
|
||||
agent_id: &str,
|
||||
run_id: &str,
|
||||
configured_max_retries: u32,
|
||||
) -> Result<u32, String> {
|
||||
) -> Result<AgentRuntimeProviderTransientRetryPolicy, String> {
|
||||
let agent_id = normalize_game_creator_runtime_agent_id(agent_id)?;
|
||||
let stored_identity =
|
||||
read_latest_game_creator_agent_runtime_task_by_run_id(root, &agent_id, run_id)?
|
||||
@@ -416,10 +426,8 @@ pub(crate) fn game_creator_agent_runtime_provider_transient_max_retries_at(
|
||||
stored_profile,
|
||||
stored_binding_fingerprint,
|
||||
)?;
|
||||
if profile == AGENT_RUNTIME_RUN_PROFILE_AUTONOMOUS_GAME_BUILD {
|
||||
return Ok(configured_max_retries
|
||||
.max(AGENT_RUNTIME_AUTONOMOUS_PROVIDER_TRANSIENT_RETRY_FLOOR)
|
||||
.min(AGENT_RUNTIME_AUTONOMOUS_PROVIDER_TRANSIENT_RETRY_LIMIT));
|
||||
}
|
||||
Ok(configured_max_retries.min(AGENT_RUNTIME_PROVIDER_TRANSIENT_RETRY_LIMIT))
|
||||
Ok(AgentRuntimeProviderTransientRetryPolicy {
|
||||
max_retries: configured_max_retries,
|
||||
retry_upstream_400: profile == AGENT_RUNTIME_RUN_PROFILE_AUTONOMOUS_GAME_BUILD,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -577,6 +577,8 @@ pub(in crate::agent) async fn observe_agent_runtime_platform_art_asset_generatio
|
||||
slice_mode: (!slice_mode.trim().is_empty()).then_some(slice_mode.clone()),
|
||||
grid_x,
|
||||
grid_y,
|
||||
reference_asset_ids: Vec::new(),
|
||||
target_category: None,
|
||||
};
|
||||
if let Some(pending) = pending_action {
|
||||
match recover_persisted_visual_generation_options(
|
||||
@@ -628,6 +630,9 @@ pub(in crate::agent) async fn observe_agent_runtime_platform_art_asset_generatio
|
||||
.or_else(|| (!slice_mode.trim().is_empty()).then_some(slice_mode)),
|
||||
grid_x,
|
||||
grid_y,
|
||||
reference_asset_ids: requested_options.reference_asset_ids,
|
||||
// Agent 运行时不会指定完成登记的目标栏目,保持调用方给的值(默认 `None`)。
|
||||
target_category: requested_options.target_category,
|
||||
}
|
||||
};
|
||||
options.replace_existing = replace_existing;
|
||||
|
||||
@@ -390,6 +390,12 @@ pub(crate) async fn start_local_project_asset_generation(
|
||||
image_size: Option<String>,
|
||||
asset_name: Option<String>,
|
||||
output_path: Option<String>,
|
||||
// 前端 IPC 字段 `referenceAssetIds`:当前项目 manifest 里的图片素材 id,只做参考输入,
|
||||
// 不进任务账本(重试由调用方继续用同一份引用提交,账本本身不新增字段)。
|
||||
reference_asset_ids: Option<Vec<String>>,
|
||||
// 前端 IPC 字段 `targetCategory`:完成登记时要落盘的正式栏目分类。同样不进任务账本:
|
||||
// 它与引用一样属于「同一次提交的本地落点」,重试由调用方继续用同一个栏目提交。
|
||||
target_category: Option<String>,
|
||||
) -> Result<AssetGenerationTaskRecord, String> {
|
||||
let task_id = asset_generation_task_id(&task_id)?;
|
||||
let request = prepare_local_project_asset_generation(
|
||||
@@ -400,6 +406,8 @@ pub(crate) async fn start_local_project_asset_generation(
|
||||
image_size.as_deref(),
|
||||
asset_name.as_deref(),
|
||||
output_path.as_deref(),
|
||||
reference_asset_ids.as_deref().unwrap_or_default(),
|
||||
target_category.as_deref(),
|
||||
)?;
|
||||
enforce_project_permission_policy(&request.root, "canvas.asset_generate")?;
|
||||
enforce_project_permission_policy(&request.root, "asset.register")?;
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
use super::*;
|
||||
use sha2::{Digest as _, Sha256};
|
||||
use shared_contracts::game_creation_app::GameCreationAppAssetCategory;
|
||||
use std::future::Future;
|
||||
|
||||
const PRIVATE_EXTERNAL_EDITOR_API_KEY_FILE_PREFIX: &str = "external-editor-api-";
|
||||
@@ -662,6 +663,7 @@ pub(crate) fn register_design_artifacts_at(root: &Path) -> Result<bool, String>
|
||||
generation_kind: None,
|
||||
reference_resource_ids: Vec::new(),
|
||||
},
|
||||
None,
|
||||
)?;
|
||||
changed |= asset_changed;
|
||||
}
|
||||
@@ -1876,8 +1878,56 @@ pub(crate) fn register_local_asset_entry(
|
||||
id_prefix: &str,
|
||||
source: GameCreationAppAssetSource,
|
||||
) -> Result<UploadLocalAssetResult, String> {
|
||||
register_local_asset_entry_with_change(root, local_path, kind, media_type, id_prefix, source)
|
||||
.map(|(result, _)| result)
|
||||
register_local_asset_entry_with_change(
|
||||
root, local_path, kind, media_type, id_prefix, source, None,
|
||||
)
|
||||
.map(|(result, _)| result)
|
||||
}
|
||||
|
||||
/// 带**显式目标分类**的登记入口:只给 GUI 生成完成路径用(前端 `targetCategory`)。
|
||||
///
|
||||
/// 入口栏目与生成 kind 不是同一套词汇(栏目 `character` / `scene` / `ui-interaction`,
|
||||
/// 生成 kind 的派生分类会把图片落到 `unclassified`、规范图落到 `document`),所以要落回
|
||||
/// 入口栏目只能由调用方把目标分类显式交进来。取值必须先过
|
||||
/// [`shared_contracts::game_creation_app::game_creation_app_asset_category_from_str`],
|
||||
/// 非法值失败关闭,绝不回退到 kind 派生;其它调用方继续走
|
||||
/// [`register_local_asset_entry`],行为不变。
|
||||
pub(crate) fn register_local_asset_entry_with_category(
|
||||
root: &Path,
|
||||
local_path: &str,
|
||||
kind: &str,
|
||||
media_type: &str,
|
||||
id_prefix: &str,
|
||||
source: GameCreationAppAssetSource,
|
||||
target_category: Option<&str>,
|
||||
) -> Result<UploadLocalAssetResult, String> {
|
||||
let target_category = normalize_asset_category_override(target_category)?;
|
||||
register_local_asset_entry_with_change(
|
||||
root,
|
||||
local_path,
|
||||
kind,
|
||||
media_type,
|
||||
id_prefix,
|
||||
source,
|
||||
target_category,
|
||||
)
|
||||
.map(|(result, _)| result)
|
||||
}
|
||||
|
||||
/// 归一显式目标分类:只接受合法枚举值,返回落盘字符串。
|
||||
fn normalize_asset_category_override(
|
||||
target_category: Option<&str>,
|
||||
) -> Result<Option<GameCreationAppAssetCategory>, String> {
|
||||
let Some(target_category) = target_category else {
|
||||
return Ok(None);
|
||||
};
|
||||
let target_category = target_category.trim();
|
||||
if target_category.is_empty() {
|
||||
return Ok(None);
|
||||
}
|
||||
game_creation_app_asset_category_from_str(target_category)
|
||||
.map(Some)
|
||||
.ok_or_else(|| format!("非法资源分类:{target_category}"))
|
||||
}
|
||||
|
||||
fn register_local_asset_entry_with_change(
|
||||
@@ -1887,6 +1937,7 @@ fn register_local_asset_entry_with_change(
|
||||
media_type: &str,
|
||||
id_prefix: &str,
|
||||
source: GameCreationAppAssetSource,
|
||||
target_category: Option<GameCreationAppAssetCategory>,
|
||||
) -> Result<(UploadLocalAssetResult, bool), String> {
|
||||
let normalized_path = normalize_relative_path(local_path)?;
|
||||
let absolute_path = resolve_local_project_path(root, &normalized_path)?;
|
||||
@@ -1912,11 +1963,17 @@ fn register_local_asset_entry_with_change(
|
||||
// kind 没变时刻意不动 category——落盘分类是权威值,同 kind 重登记不得抹掉它。
|
||||
let changed = existing.kind != kind
|
||||
|| existing.media_type != media_type
|
||||
|| existing.source != source;
|
||||
|| existing.source != source
|
||||
|| target_category.is_some_and(|category| existing.category != category);
|
||||
if existing.kind != kind {
|
||||
existing.kind = kind.to_string();
|
||||
existing.category = game_creation_app_asset_category_for_kind(kind);
|
||||
}
|
||||
// 调用方显式给出目标分类时它就是权威值:GUI 完成登记必须能落回入口栏目,
|
||||
// 这也是同路径重新生成时把资产从旧栏目(或 unclassified)原位接管过来的唯一入口。
|
||||
if let Some(category) = target_category {
|
||||
existing.category = category;
|
||||
}
|
||||
existing.media_type = media_type.to_string();
|
||||
existing.source = source;
|
||||
Ok((existing.id.clone(), "asset.update", changed))
|
||||
@@ -1933,7 +1990,8 @@ fn register_local_asset_entry_with_change(
|
||||
local_path: normalized_path.clone(),
|
||||
image_sequence_frames: None,
|
||||
image_sequence_duration_ms: None,
|
||||
category: game_creation_app_asset_category_for_kind(kind),
|
||||
category: target_category
|
||||
.unwrap_or_else(|| game_creation_app_asset_category_for_kind(kind)),
|
||||
tags: Vec::new(),
|
||||
source,
|
||||
});
|
||||
@@ -2153,6 +2211,7 @@ pub(crate) fn delete_manifest_asset_at(
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use shared_contracts::game_creation_app::GameCreationAppAssetCategory;
|
||||
use std::io::{Read, Write};
|
||||
|
||||
#[test]
|
||||
@@ -2176,6 +2235,121 @@ mod tests {
|
||||
assert!(!register_design_artifacts_at(root).expect("register idempotently"));
|
||||
}
|
||||
|
||||
/// GUI 完成登记可以显式指定目标栏目:新建条目与已登记条目都按显式值落盘。
|
||||
///
|
||||
/// 入口栏目(character / scene / ui-interaction)与生成 kind 不是同一套词汇,按 kind 派生
|
||||
/// 会把图片落到 unclassified,占位拿不回原位;非法值必须失败关闭,不传时保持 kind 派生。
|
||||
#[test]
|
||||
fn explicit_target_category_overrides_the_kind_derived_category() {
|
||||
fn canvas_source() -> GameCreationAppAssetSource {
|
||||
GameCreationAppAssetSource {
|
||||
kind: GameCreationAppAssetSourceKind::Canvas,
|
||||
canvas_project_id: None,
|
||||
resource_id: None,
|
||||
asset_object_id: None,
|
||||
task_id: None,
|
||||
prompt: None,
|
||||
model: None,
|
||||
generation_route: None,
|
||||
generation_kind: None,
|
||||
reference_resource_ids: Vec::new(),
|
||||
}
|
||||
}
|
||||
fn category_of(root: &Path, asset_id: &str) -> GameCreationAppAssetCategory {
|
||||
read_existing_manifest_for_project(root)
|
||||
.expect("read manifest")
|
||||
.assets
|
||||
.into_iter()
|
||||
.find(|asset| asset.id == asset_id)
|
||||
.expect("registered asset is present")
|
||||
.category
|
||||
}
|
||||
|
||||
let temporary = tempfile::tempdir().expect("tempdir");
|
||||
let root = temporary.path();
|
||||
crate::project::init_local_game_project_at(root, "target-category-test", "目标栏目登记")
|
||||
.expect("init project");
|
||||
fs::create_dir_all(root.join("assets")).expect("create assets dir");
|
||||
fs::write(root.join("assets/hero.png"), b"png-bytes").expect("write asset");
|
||||
|
||||
let registered = register_local_asset_entry_with_category(
|
||||
root,
|
||||
"assets/hero.png",
|
||||
"image",
|
||||
"image/png",
|
||||
"platform-art",
|
||||
canvas_source(),
|
||||
Some("character"),
|
||||
)
|
||||
.expect("register with a target category");
|
||||
assert_eq!(
|
||||
category_of(root, ®istered.id),
|
||||
GameCreationAppAssetCategory::Character
|
||||
);
|
||||
|
||||
// 同 kind 重新生成时显式目标分类仍是权威值:资产要能换栏目原位接管。
|
||||
register_local_asset_entry_with_category(
|
||||
root,
|
||||
"assets/hero.png",
|
||||
"image",
|
||||
"image/png",
|
||||
"platform-art",
|
||||
canvas_source(),
|
||||
Some("ui-interaction"),
|
||||
)
|
||||
.expect("re-register with another target category");
|
||||
assert_eq!(
|
||||
category_of(root, ®istered.id),
|
||||
GameCreationAppAssetCategory::UiInteraction
|
||||
);
|
||||
|
||||
// 非法值失败关闭,且不动已落盘的分类。
|
||||
assert!(register_local_asset_entry_with_category(
|
||||
root,
|
||||
"assets/hero.png",
|
||||
"image",
|
||||
"image/png",
|
||||
"platform-art",
|
||||
canvas_source(),
|
||||
Some("version"),
|
||||
)
|
||||
.is_err());
|
||||
assert_eq!(
|
||||
category_of(root, ®istered.id),
|
||||
GameCreationAppAssetCategory::UiInteraction
|
||||
);
|
||||
|
||||
// 不传目标分类时保持原有行为:新条目按 kind 派生(image → unclassified)。
|
||||
fs::write(root.join("assets/plain.png"), b"png-bytes").expect("write plain asset");
|
||||
let plain = register_local_asset_entry(
|
||||
root,
|
||||
"assets/plain.png",
|
||||
"image",
|
||||
"image/png",
|
||||
"platform-art",
|
||||
canvas_source(),
|
||||
)
|
||||
.expect("register without a target category");
|
||||
assert_eq!(
|
||||
category_of(root, &plain.id),
|
||||
GameCreationAppAssetCategory::Unclassified
|
||||
);
|
||||
// 已落盘的显式分类在 kind 未变时仍然是权威值:同 kind 重登记不得把它抹掉。
|
||||
register_local_asset_entry(
|
||||
root,
|
||||
"assets/hero.png",
|
||||
"image",
|
||||
"image/png",
|
||||
"platform-art",
|
||||
canvas_source(),
|
||||
)
|
||||
.expect("re-register without a target category");
|
||||
assert_eq!(
|
||||
category_of(root, ®istered.id),
|
||||
GameCreationAppAssetCategory::UiInteraction
|
||||
);
|
||||
}
|
||||
|
||||
/// 画板导出推断出的 kind 必须已经是 canonical 值。
|
||||
///
|
||||
/// 这个值会被原样写进 manifest 并据以派生落盘 `category`;一旦写出非 canonical 值
|
||||
|
||||
@@ -2320,6 +2320,27 @@ pub(crate) fn update_local_project_resource_classification(
|
||||
)
|
||||
}
|
||||
|
||||
/// 为一批已登记素材追加标签:整批一次校验、一次 manifest 写入、一次 revision 推进。
|
||||
///
|
||||
/// 权限位与单素材分类更新同口径取 `asset.register`(命令包装层只做权限门面,
|
||||
/// 身份 / 写锁 / CAS / 原子写与审计都在 `project/manifest.rs` 内完成)。
|
||||
/// 这里刻意**不**循环调用单素材命令:逐项调用会写出多份 manifest、推进多次 revision,
|
||||
/// 中途失败还会留下"前几个素材改了、后面的没改"的部分写入。
|
||||
#[tauri::command]
|
||||
pub(crate) fn add_local_project_resource_tags(
|
||||
input: AddLocalProjectResourceTagsInput,
|
||||
) -> Result<AddLocalProjectResourceTagsResult, String> {
|
||||
let root = Path::new(input.project_path.trim());
|
||||
enforce_project_permission_policy(root, "asset.register")?;
|
||||
add_manifest_asset_tags_at(
|
||||
root,
|
||||
&input.expected_project_id,
|
||||
input.expected_project_revision,
|
||||
input.asset_ids,
|
||||
input.tags,
|
||||
)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub(crate) async fn derive_local_project_resource(
|
||||
input: DeriveLocalProjectResourceInput,
|
||||
@@ -4871,6 +4892,8 @@ pub(crate) fn prepare_local_project_asset_generation(
|
||||
image_size: Option<&str>,
|
||||
asset_name: Option<&str>,
|
||||
output_path: Option<&str>,
|
||||
reference_asset_ids: &[String],
|
||||
target_category: Option<&str>,
|
||||
) -> Result<LocalProjectAssetGenerationRequest, String> {
|
||||
let project_path = project_path.trim();
|
||||
if project_path.is_empty() {
|
||||
@@ -4878,6 +4901,13 @@ pub(crate) fn prepare_local_project_asset_generation(
|
||||
}
|
||||
let asset_kind = normalize_platform_art_asset_generation_kind(kind)
|
||||
.ok_or_else(|| format!("素材类型不受支持:{}", kind.trim()))?;
|
||||
// 参考入参只接受当前项目 manifest 素材 id:路径、远端 resourceId 与超限在这里就被拒绝,
|
||||
// 不把校验推迟到远端(远端只该收到当前账号绑定下的 resource ID)。
|
||||
let reference_asset_ids =
|
||||
normalize_platform_art_reference_asset_ids(asset_kind, reference_asset_ids)?;
|
||||
// GUI 完成登记层参数:入口栏目与生成 kind 不是同一套词汇,只有调用方显式给出目标分类
|
||||
// 才能把产物原位落回入口栏目。非法值(含 `version` / `all` 这类栏目伪值)直接失败关闭。
|
||||
let target_category = normalize_platform_art_target_category(target_category)?;
|
||||
Ok(LocalProjectAssetGenerationRequest {
|
||||
root: PathBuf::from(project_path),
|
||||
prompt: local_project_asset_prompt(prompt)?,
|
||||
@@ -4914,6 +4944,8 @@ pub(crate) fn prepare_local_project_asset_generation(
|
||||
.then(|| "connected-components".to_string()),
|
||||
grid_x: None,
|
||||
grid_y: None,
|
||||
reference_asset_ids,
|
||||
target_category,
|
||||
},
|
||||
})
|
||||
}
|
||||
@@ -4935,6 +4967,10 @@ pub(crate) async fn generate_local_project_asset(
|
||||
image_size: Option<String>,
|
||||
asset_name: Option<String>,
|
||||
output_path: Option<String>,
|
||||
reference_asset_ids: Option<Vec<String>>,
|
||||
// 前端 IPC 字段 `targetCategory`:本次生成完成登记时要落盘的正式栏目分类,
|
||||
// 只走 GUI 命令,取值必须是合法素材分类,Agent / Direct 路径不传。
|
||||
target_category: Option<String>,
|
||||
) -> Result<UploadLocalAssetResult, String> {
|
||||
let request = prepare_local_project_asset_generation(
|
||||
&project_path,
|
||||
@@ -4944,6 +4980,8 @@ pub(crate) async fn generate_local_project_asset(
|
||||
image_size.as_deref(),
|
||||
asset_name.as_deref(),
|
||||
output_path.as_deref(),
|
||||
reference_asset_ids.as_deref().unwrap_or_default(),
|
||||
target_category.as_deref(),
|
||||
)?;
|
||||
enforce_project_permission_policy(&request.root, "canvas.asset_generate")?;
|
||||
enforce_project_permission_policy(&request.root, "asset.register")?;
|
||||
@@ -4962,7 +5000,17 @@ mod local_project_asset_generation_tests {
|
||||
use super::*;
|
||||
|
||||
fn prepare(kind: &str, prompt: &str) -> Result<LocalProjectAssetGenerationRequest, String> {
|
||||
prepare_local_project_asset_generation("/tmp/project", kind, prompt, None, None, None, None)
|
||||
prepare_local_project_asset_generation(
|
||||
"/tmp/project",
|
||||
kind,
|
||||
prompt,
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
&[],
|
||||
None,
|
||||
)
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -5002,6 +5050,8 @@ mod local_project_asset_generation_tests {
|
||||
Some("2K"),
|
||||
Some(" 主角图集 "),
|
||||
Some(" assets/hero.png "),
|
||||
&[],
|
||||
None,
|
||||
)
|
||||
.expect("explicit options");
|
||||
assert_eq!(explicit.root, PathBuf::from("/tmp/project"));
|
||||
@@ -5029,8 +5079,18 @@ mod local_project_asset_generation_tests {
|
||||
#[test]
|
||||
fn invalid_toolbar_arguments_are_rejected_before_any_generation() {
|
||||
assert_eq!(
|
||||
prepare_local_project_asset_generation("", "image", "要求", None, None, None, None)
|
||||
.expect_err("empty project path"),
|
||||
prepare_local_project_asset_generation(
|
||||
"",
|
||||
"image",
|
||||
"要求",
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
&[],
|
||||
None,
|
||||
)
|
||||
.expect_err("empty project path"),
|
||||
"项目路径不能为空"
|
||||
);
|
||||
assert_eq!(
|
||||
@@ -5041,6 +5101,60 @@ mod local_project_asset_generation_tests {
|
||||
prepare("game-art", "要求").expect_err("unverified kind"),
|
||||
"素材类型不受支持:game-art"
|
||||
);
|
||||
// 目标分类只接受合法素材分类枚举:栏目侧伪值 `version` / `all` 与任意其它值都失败关闭。
|
||||
for rejected in ["version", "all", "bogus", "UI"] {
|
||||
assert_eq!(
|
||||
prepare_local_project_asset_generation(
|
||||
"/tmp/project",
|
||||
"image",
|
||||
"要求",
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
&[],
|
||||
Some(rejected),
|
||||
)
|
||||
.expect_err("illegal target category"),
|
||||
format!("目标分类不是合法素材分类:{rejected}")
|
||||
);
|
||||
}
|
||||
// 合法值归一成落盘字符串(trim + kebab-case),供 manifest `category` 直接使用。
|
||||
assert_eq!(
|
||||
prepare_local_project_asset_generation(
|
||||
"/tmp/project",
|
||||
"image",
|
||||
"要求",
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
&[],
|
||||
Some(" ui-interaction "),
|
||||
)
|
||||
.expect("legal target category")
|
||||
.options
|
||||
.target_category
|
||||
.as_deref(),
|
||||
Some("ui-interaction")
|
||||
);
|
||||
assert_eq!(
|
||||
prepare_local_project_asset_generation(
|
||||
"/tmp/project",
|
||||
"image",
|
||||
"要求",
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
&[],
|
||||
None,
|
||||
)
|
||||
.expect("omitted target category")
|
||||
.options
|
||||
.target_category,
|
||||
None
|
||||
);
|
||||
assert_eq!(
|
||||
prepare(
|
||||
"spec",
|
||||
@@ -5057,7 +5171,9 @@ mod local_project_asset_generation_tests {
|
||||
Some("4:3"),
|
||||
None,
|
||||
None,
|
||||
None
|
||||
None,
|
||||
&[],
|
||||
None,
|
||||
)
|
||||
.expect_err("unsupported ratio"),
|
||||
"图片比例不受支持:4:3"
|
||||
@@ -5070,7 +5186,9 @@ mod local_project_asset_generation_tests {
|
||||
None,
|
||||
Some("4K"),
|
||||
None,
|
||||
None
|
||||
None,
|
||||
&[],
|
||||
None,
|
||||
)
|
||||
.expect_err("unsupported size"),
|
||||
"图片尺寸不受支持:4K"
|
||||
@@ -5083,7 +5201,9 @@ mod local_project_asset_generation_tests {
|
||||
None,
|
||||
None,
|
||||
Some("坏\u{7}名字"),
|
||||
None
|
||||
None,
|
||||
&[],
|
||||
None,
|
||||
)
|
||||
.expect_err("control character in asset name"),
|
||||
"素材名称超出安全边界"
|
||||
@@ -5096,7 +5216,9 @@ mod local_project_asset_generation_tests {
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
Some(&"a".repeat(LOCAL_PROJECT_ASSET_MAX_OUTPUT_PATH_CHARS + 1))
|
||||
Some(&"a".repeat(LOCAL_PROJECT_ASSET_MAX_OUTPUT_PATH_CHARS + 1)),
|
||||
&[],
|
||||
None,
|
||||
)
|
||||
.expect_err("oversized output path"),
|
||||
"输出路径超出安全边界"
|
||||
|
||||
@@ -2589,6 +2589,7 @@ fn main() {
|
||||
register_local_asset,
|
||||
create_ui_design_resource,
|
||||
update_local_project_resource_classification,
|
||||
add_local_project_resource_tags,
|
||||
derive_local_project_resource,
|
||||
list_pending_local_project_resource_edits,
|
||||
resume_local_project_resource_edit,
|
||||
|
||||
@@ -1131,8 +1131,17 @@ pub(crate) fn validate_manifest_required_visual_asset(
|
||||
}
|
||||
|
||||
if task_id == "art-director" {
|
||||
if !asset.source.reference_resource_ids.is_empty() {
|
||||
return Err("统一视觉规范图不得声明派生资源引用".to_string());
|
||||
// 规范图是视觉来源链的根:它自身不派生任何视觉资产,但 icon-spec 生成允许用户参考
|
||||
// (没有规范前置,最多总上限),这些参考只是风格输入,不构成派生关系。这里改为验证
|
||||
// 参考集合仍符合 icon-spec 请求合同;route / generation kind / canvasProjectId /
|
||||
// resourceId / PNG 解码等身份判据全部保持不变。
|
||||
if !crate::agent::platform_art_runtime_references_match_request_contract(
|
||||
&asset.source.reference_resource_ids,
|
||||
expected_kind,
|
||||
) {
|
||||
return Err(format!(
|
||||
"统一视觉规范图的参考集合不符合请求合同:{expected_path}"
|
||||
));
|
||||
}
|
||||
return Ok(());
|
||||
}
|
||||
@@ -1157,11 +1166,17 @@ pub(crate) fn validate_manifest_required_visual_asset(
|
||||
.map(str::trim)
|
||||
.filter(|value| !value.is_empty())
|
||||
.ok_or_else(|| "统一视觉规范图缺少 resourceId".to_string())?;
|
||||
let [reference_resource_id] = asset.source.reference_resource_ids.as_slice() else {
|
||||
// 派生素材的参考合同是「规范图前置在最前,用户参考按顺序追加在后」,图集不接受用户参考:
|
||||
// 规范身份仍只由首项承担,用户参考不能顶替也不能冒充规范引用。
|
||||
if !crate::agent::platform_art_runtime_references_match_request_contract(
|
||||
&asset.source.reference_resource_ids,
|
||||
expected_kind,
|
||||
) {
|
||||
return Err(format!(
|
||||
"派生视觉资产未精确引用当前统一视觉规范图:{expected_path}"
|
||||
));
|
||||
};
|
||||
}
|
||||
let reference_resource_id = asset.source.reference_resource_ids[0].as_str();
|
||||
let original_provenance_matches =
|
||||
canvas_project_id == art_spec_project_id && reference_resource_id == art_spec_resource_id;
|
||||
let rebound_local_source_matches = if original_provenance_matches {
|
||||
@@ -1328,6 +1343,240 @@ pub(crate) fn update_manifest_asset_classification_at(
|
||||
})
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Deserialize)]
|
||||
#[serde(deny_unknown_fields, rename_all = "camelCase")]
|
||||
pub(crate) struct AddLocalProjectResourceTagsInput {
|
||||
pub(crate) project_path: String,
|
||||
pub(crate) expected_project_id: String,
|
||||
pub(crate) expected_project_revision: u64,
|
||||
pub(crate) asset_ids: Vec<String>,
|
||||
#[serde(default)]
|
||||
pub(crate) tags: Vec<String>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub(crate) struct AddLocalProjectResourceTagsResult {
|
||||
pub(crate) assets: Vec<GameCreationAppAssetManifestEntry>,
|
||||
pub(crate) committed_project_revision: u64,
|
||||
}
|
||||
|
||||
/// 一次批量追加的素材上限:与主规范「每批最多 200 个不同素材」一致,按**去重后**数量计算。
|
||||
/// 批次越大,锁内要重算的合并结果越多,manifest 也越大;无界批次会把成本摊到之后每一次读写上。
|
||||
pub(crate) const ASSET_BATCH_TAG_MAX_ASSETS: usize = 200;
|
||||
|
||||
/// 批量追加标签的素材 ID 归一化:trim、按**首次出现顺序**去重,再在此处收口批次上下界。
|
||||
///
|
||||
/// 这里是"整句拒绝"的失败关闭口径,不做任何静默容忍:
|
||||
///
|
||||
/// - 空白 `assetId` 直接失败,不 `continue` 跳过。静默跳过会让"请求了 N 个素材"和"实际写了
|
||||
/// N-1 个"分叉,而调用方拿到的仍是成功——这正是本合同要排除的静默部分写;
|
||||
/// - 空批次失败;
|
||||
/// - 去重后超限立即失败(在扫描到第 201 个不同 ID 时就返回,不对剩余 ID 继续做去重扫描),
|
||||
/// 更不做"截断到 200 个":截断会让用户以为 250 个素材都加上了标签。
|
||||
fn normalize_manifest_batch_asset_ids(asset_ids: &[String]) -> Result<Vec<String>, String> {
|
||||
let mut normalized: Vec<String> = Vec::new();
|
||||
for asset_id in asset_ids {
|
||||
let asset_id = asset_id.trim();
|
||||
if asset_id.is_empty() {
|
||||
return Err("批量标签 assetId 不能为空".to_string());
|
||||
}
|
||||
if !normalized.iter().any(|existing| existing == asset_id) {
|
||||
normalized.push(asset_id.to_string());
|
||||
if normalized.len() > ASSET_BATCH_TAG_MAX_ASSETS {
|
||||
return Err(format!(
|
||||
"批量标签最多支持 {ASSET_BATCH_TAG_MAX_ASSETS} 个素材"
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
if normalized.is_empty() {
|
||||
return Err("批量标签至少需要一个素材".to_string());
|
||||
}
|
||||
Ok(normalized)
|
||||
}
|
||||
|
||||
/// 批量追加的标签归一化:沿用主规范的 trim / 去空 / 去重口径(复用
|
||||
/// [`normalize_manifest_asset_tags`],其中已含数量与单标签长度收口)。
|
||||
///
|
||||
/// 只有**归一后为空**才拒绝:请求里全是空白标签时,用户填的东西一个字都不会落盘,
|
||||
/// 此时若当成"成功且无变化"返回,界面会显示保存成功而素材上什么都没有。
|
||||
fn normalize_manifest_batch_tags(tags: &[String]) -> Result<Vec<String>, String> {
|
||||
let normalized = normalize_manifest_asset_tags(tags)?;
|
||||
if normalized.is_empty() {
|
||||
return Err("批量标签不能为空".to_string());
|
||||
}
|
||||
Ok(normalized)
|
||||
}
|
||||
|
||||
/// 追加语义:只把请求里**尚不存在**的标签按请求顺序补到原有标签之后。
|
||||
/// 原有标签的顺序、分类、类型、路径与来源都不参与改写——本命令没有删除或替换语义。
|
||||
fn merge_manifest_asset_tags(existing: &[String], incoming: &[String]) -> Vec<String> {
|
||||
let mut merged = existing.to_vec();
|
||||
for tag in incoming {
|
||||
if !merged.iter().any(|current| current == tag) {
|
||||
merged.push(tag.clone());
|
||||
}
|
||||
}
|
||||
merged
|
||||
}
|
||||
|
||||
/// 锁内先算完的整批计划:任何一项缺失或超限都在这里失败,此时 manifest 一个字节都没动。
|
||||
struct ManifestAssetTagAppendPlan {
|
||||
/// 按请求顺序(去重后)返回的素材条目,标签为合并后的完整列表。
|
||||
assets: Vec<GameCreationAppAssetManifestEntry>,
|
||||
/// 真正需要落值的目标:`(assets 下标, 合并后的标签)`。
|
||||
updates: Vec<(usize, Vec<String>)>,
|
||||
/// 确实发生变化的素材 ID,供审计记录使用;空表示整批无变化。
|
||||
changed_asset_ids: Vec<String>,
|
||||
}
|
||||
|
||||
/// 先校验**全部**目标与**全部**合并结果,再决定是否写值。
|
||||
///
|
||||
/// 顺序是刻意的:第一阶段只读,任一目标不存在、任一合并结果超过标签上界都在写之前返回错误;
|
||||
/// 只有全部通过,第二阶段才逐项落值。这样"缺任一资产 / 超限"都不可能留下部分写入。
|
||||
fn plan_manifest_asset_tag_append(
|
||||
manifest: &GameCreationAppManifest,
|
||||
asset_ids: &[String],
|
||||
tags: &[String],
|
||||
) -> Result<ManifestAssetTagAppendPlan, String> {
|
||||
let mut assets = Vec::with_capacity(asset_ids.len());
|
||||
let mut updates: Vec<(usize, Vec<String>)> = Vec::with_capacity(asset_ids.len());
|
||||
let mut changed_asset_ids = Vec::new();
|
||||
for asset_id in asset_ids {
|
||||
let index = manifest
|
||||
.assets
|
||||
.iter()
|
||||
.position(|asset| &asset.id == asset_id)
|
||||
.ok_or_else(|| format!("项目资源不存在:{asset_id}"))?;
|
||||
let asset = &manifest.assets[index];
|
||||
// 合并结果复用同一个上界函数:已有标签已归一化,这里等价于对整份新列表再收口一次。
|
||||
// 上界函数只报"16 个"这种通用口径,200 个素材的批次里看不出是哪一项超了,所以在**调用点**
|
||||
// 补上目标身份(ID + 可读 localPath)并说明整批未写:用户要能直接定位到那一张素材。
|
||||
let merged = normalize_manifest_asset_tags(&merge_manifest_asset_tags(&asset.tags, tags))
|
||||
.map_err(|error| {
|
||||
format!(
|
||||
"素材 {}({})的标签合并结果不合法:{error};本次未写入任何素材",
|
||||
asset.id, asset.local_path
|
||||
)
|
||||
})?;
|
||||
if merged != asset.tags {
|
||||
changed_asset_ids.push(asset.id.clone());
|
||||
}
|
||||
updates.push((index, merged.clone()));
|
||||
assets.push(GameCreationAppAssetManifestEntry {
|
||||
tags: merged,
|
||||
..asset.clone()
|
||||
});
|
||||
}
|
||||
Ok(ManifestAssetTagAppendPlan {
|
||||
assets,
|
||||
updates,
|
||||
changed_asset_ids,
|
||||
})
|
||||
}
|
||||
|
||||
/// 为一批已登记素材追加标签:一次校验、一次 manifest 写入、一次 revision 推进。
|
||||
///
|
||||
/// 语义与 [`update_manifest_asset_classification_at`] 同源(`asset.register` 权限位、项目身份、
|
||||
/// 项目写锁、revision CAS、manifest 原子写、审计在 manifest 落盘之后 / revision 推进之前),
|
||||
/// 但作用域是**整批**:
|
||||
///
|
||||
/// - 项目身份校验两次(进入前与持锁后各一次),锁内按 `expectedProjectRevision` 做一次 CAS;
|
||||
/// - 锁内先算完整批计划,任一目标缺失或任一合并结果超限都**不写任何一项**;
|
||||
/// - 整批无变化时**不写盘、不审计、不推进 revision**,直接返回当前条目与当前 revision;
|
||||
/// - 真正有变化时才写一次 manifest、追加一条审计、推进一次 revision。
|
||||
///
|
||||
/// 已落盘之后的审计或 revision 失败照实报"整批已写入",不回滚、也不谎称回滚:manifest 是权威
|
||||
/// 真相且已经改变,把错误说成"没写"只会让用户拿错状态去重试。
|
||||
pub(crate) fn add_manifest_asset_tags_at(
|
||||
root: &Path,
|
||||
expected_project_id: &str,
|
||||
expected_project_revision: u64,
|
||||
asset_ids: Vec<String>,
|
||||
tags: Vec<String>,
|
||||
) -> Result<AddLocalProjectResourceTagsResult, String> {
|
||||
if expected_project_revision
|
||||
> shared_contracts::game_creation_app::GAME_CREATION_RESOURCE_LAYOUT_MAX_SAFE_REVISION
|
||||
{
|
||||
return Err("expectedProjectRevision 超出 JavaScript 安全整数范围".to_string());
|
||||
}
|
||||
let expected_project_id = expected_project_id.trim();
|
||||
if expected_project_id.is_empty() {
|
||||
return Err("批量标签 expectedProjectId 不能为空".to_string());
|
||||
}
|
||||
let asset_ids = normalize_manifest_batch_asset_ids(&asset_ids)?;
|
||||
let tags = normalize_manifest_batch_tags(&tags)?;
|
||||
|
||||
if read_existing_manifest_for_project(root)?.project_id != expected_project_id {
|
||||
return Err("project-identity-conflict".to_string());
|
||||
}
|
||||
// 锁的 commandId 用本命令自己的动作名(审计/排障时能区分是批量追加还是别的写路径);
|
||||
// 权限门面仍然是 `asset.register`,见 `commands.rs` 的命令包装层。
|
||||
let _lock = acquire_project_write_lock(root, ASSET_BATCH_TAG_AUDIT_RECORD_TYPE)?;
|
||||
if read_existing_manifest_for_project(root)?.project_id != expected_project_id {
|
||||
return Err("project-identity-conflict".to_string());
|
||||
}
|
||||
if read_game_creator_agent_runtime_project_revision(root)?.revision != expected_project_revision
|
||||
{
|
||||
return Err("project-revision-conflict".to_string());
|
||||
}
|
||||
|
||||
// no-op 判定发生在锁内、写盘之前:整批标签都已经存在时,连 manifest 都不必重写一次。
|
||||
// 这不是优化洁癖——重写会换掉文件 mtime 与内容字节,让"什么都没做"看起来像一次真实改动。
|
||||
let plan = plan_manifest_asset_tag_append(
|
||||
&read_existing_manifest_for_project(root)?,
|
||||
&asset_ids,
|
||||
&tags,
|
||||
)?;
|
||||
if plan.changed_asset_ids.is_empty() {
|
||||
return Ok(AddLocalProjectResourceTagsResult {
|
||||
assets: plan.assets,
|
||||
committed_project_revision: expected_project_revision,
|
||||
});
|
||||
}
|
||||
|
||||
let plan = mutate_manifest_at(root, |manifest| {
|
||||
// 锁内复核:`mutate_manifest_at` 自己重新读盘,所以这里按同一套规则重算一遍再落值。
|
||||
// 复核失败会在 `write_manifest_locked` 之前返回错误,仍然零写入;重算也保证不会拿
|
||||
// 锁外算出的绝对标签列表去覆盖这份 manifest 上刚出现的新标签。
|
||||
let plan = plan_manifest_asset_tag_append(manifest, &asset_ids, &tags)?;
|
||||
for (index, merged) in &plan.updates {
|
||||
manifest.assets[*index].tags = merged.clone();
|
||||
}
|
||||
Ok(plan)
|
||||
})?;
|
||||
|
||||
// 复核阶段才发现"锁外以为有变化、锁内其实已无变化"的极端竞态:这一次写盘写出的就是原内容,
|
||||
// 不能凭空补一条审计或推进 revision。正常路径不会走到这里——整批目标在此之前已经通过锁内 no-op 判定。
|
||||
if plan.changed_asset_ids.is_empty() {
|
||||
return Ok(AddLocalProjectResourceTagsResult {
|
||||
assets: plan.assets,
|
||||
committed_project_revision: expected_project_revision,
|
||||
});
|
||||
}
|
||||
|
||||
append_agent_db_record(
|
||||
root,
|
||||
serde_json::json!({
|
||||
"recordType": ASSET_BATCH_TAG_AUDIT_RECORD_TYPE,
|
||||
"assetIds": plan.changed_asset_ids,
|
||||
"expectedProjectRevision": expected_project_revision,
|
||||
"appendedTags": tags,
|
||||
}),
|
||||
)
|
||||
.map_err(|error| format!("批量标签已写入,但审计记录失败:{error}"))?;
|
||||
let committed_project_revision = advance_agent_runtime_project_revision_locked(root)
|
||||
.map_err(|error| format!("批量标签已写入,但项目 revision 未能推进:{error}"))?;
|
||||
Ok(AddLocalProjectResourceTagsResult {
|
||||
assets: plan.assets,
|
||||
committed_project_revision,
|
||||
})
|
||||
}
|
||||
|
||||
/// 批量标签写入的审计类型:一次批量追加只留一条记录,装的是"谁被追加了什么"。
|
||||
pub(crate) const ASSET_BATCH_TAG_AUDIT_RECORD_TYPE: &str = "asset.tags.append";
|
||||
|
||||
pub(crate) fn create_manifest_task_at(
|
||||
root: &Path,
|
||||
task_id: &str,
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
+725
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -662,7 +662,7 @@ async fn chat_with_game_creator_role_agent_stream_does_not_fallback_on_upstream_
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn autonomous_game_build_profile_uses_durable_provider_retry_floor_and_cap() {
|
||||
fn provider_transient_retry_uses_configured_max_retries_for_every_run_profile() {
|
||||
let root = unique_project_path();
|
||||
init_local_game_project_at(
|
||||
&root,
|
||||
@@ -671,16 +671,16 @@ fn autonomous_game_build_profile_uses_durable_provider_retry_floor_and_cap() {
|
||||
)
|
||||
.expect("project init");
|
||||
|
||||
assert_eq!(
|
||||
game_creator_agent_runtime_provider_transient_max_retries_at(
|
||||
&root,
|
||||
"design-director",
|
||||
"legacy-standard-run",
|
||||
99,
|
||||
)
|
||||
.expect("legacy standard retry policy"),
|
||||
3
|
||||
);
|
||||
// 历史 standard run 仍走同一身份校验,但重试次数不再被收进区间。
|
||||
let legacy_standard = game_creator_agent_runtime_provider_transient_retry_policy_at(
|
||||
&root,
|
||||
"design-director",
|
||||
"legacy-standard-run",
|
||||
99,
|
||||
)
|
||||
.expect("legacy standard retry policy");
|
||||
assert_eq!(legacy_standard.max_retries, 99);
|
||||
assert!(!legacy_standard.retry_upstream_400);
|
||||
let standard = bind_game_creator_agent_runtime_run_profile_at(
|
||||
&root,
|
||||
"design-director",
|
||||
@@ -691,25 +691,25 @@ fn autonomous_game_build_profile_uses_durable_provider_retry_floor_and_cap() {
|
||||
)
|
||||
.expect("bind standard profile");
|
||||
assert_eq!(
|
||||
game_creator_agent_runtime_provider_transient_max_retries_at(
|
||||
game_creator_agent_runtime_provider_transient_retry_policy_at(
|
||||
&root,
|
||||
&standard.agent_id,
|
||||
&standard.run_id,
|
||||
0,
|
||||
)
|
||||
.expect("standard zero retry policy"),
|
||||
.expect("standard zero retry policy")
|
||||
.max_retries,
|
||||
0
|
||||
);
|
||||
assert_eq!(
|
||||
game_creator_agent_runtime_provider_transient_max_retries_at(
|
||||
&root,
|
||||
&standard.agent_id,
|
||||
&standard.run_id,
|
||||
99,
|
||||
)
|
||||
.expect("standard capped retry policy"),
|
||||
3
|
||||
);
|
||||
let standard_configured = game_creator_agent_runtime_provider_transient_retry_policy_at(
|
||||
&root,
|
||||
&standard.agent_id,
|
||||
&standard.run_id,
|
||||
99,
|
||||
)
|
||||
.expect("standard configured retry policy");
|
||||
assert_eq!(standard_configured.max_retries, 99);
|
||||
assert!(!standard_configured.retry_upstream_400);
|
||||
|
||||
let parent = bind_game_creator_agent_runtime_run_profile_at(
|
||||
&root,
|
||||
@@ -720,17 +720,16 @@ fn autonomous_game_build_profile_uses_durable_provider_retry_floor_and_cap() {
|
||||
None,
|
||||
)
|
||||
.expect("bind autonomous parent profile");
|
||||
for (configured, expected) in [(0, 12), (14, 14), (99, 16)] {
|
||||
assert_eq!(
|
||||
game_creator_agent_runtime_provider_transient_max_retries_at(
|
||||
&root,
|
||||
&parent.agent_id,
|
||||
&parent.run_id,
|
||||
configured,
|
||||
)
|
||||
.expect("autonomous parent retry policy"),
|
||||
expected
|
||||
);
|
||||
for (configured, expected) in [(0, 0), (5, 5), (99, 99)] {
|
||||
let policy = game_creator_agent_runtime_provider_transient_retry_policy_at(
|
||||
&root,
|
||||
&parent.agent_id,
|
||||
&parent.run_id,
|
||||
configured,
|
||||
)
|
||||
.expect("autonomous parent retry policy");
|
||||
assert_eq!(policy.max_retries, expected);
|
||||
assert!(policy.retry_upstream_400);
|
||||
}
|
||||
|
||||
let child_link = AgentRuntimeTaskLink {
|
||||
@@ -758,14 +757,25 @@ fn autonomous_game_build_profile_uses_durable_provider_retry_floor_and_cap() {
|
||||
append_game_creator_agent_runtime_task(&root, &child_state)
|
||||
.expect("append autonomous child task projection");
|
||||
assert_eq!(
|
||||
game_creator_agent_runtime_provider_transient_max_retries_at(
|
||||
game_creator_agent_runtime_provider_transient_retry_policy_at(
|
||||
&root,
|
||||
&child.agent_id,
|
||||
&child.run_id,
|
||||
0,
|
||||
)
|
||||
.expect("autonomous child retry policy"),
|
||||
12
|
||||
.expect("autonomous child retry policy")
|
||||
.max_retries,
|
||||
0
|
||||
);
|
||||
assert!(
|
||||
game_creator_agent_runtime_provider_transient_retry_policy_at(
|
||||
&root,
|
||||
&child.agent_id,
|
||||
&child.run_id,
|
||||
0,
|
||||
)
|
||||
.expect("autonomous child retry policy")
|
||||
.retry_upstream_400
|
||||
);
|
||||
|
||||
fs::remove_file(game_creator_agent_runtime_run_profile_binding_path(
|
||||
@@ -775,7 +785,7 @@ fn autonomous_game_build_profile_uses_durable_provider_retry_floor_and_cap() {
|
||||
))
|
||||
.expect("remove autonomous child binding");
|
||||
assert!(
|
||||
game_creator_agent_runtime_provider_transient_max_retries_at(
|
||||
game_creator_agent_runtime_provider_transient_retry_policy_at(
|
||||
&root,
|
||||
&child.agent_id,
|
||||
&child.run_id,
|
||||
@@ -4558,7 +4568,7 @@ async fn provider_transient_retry_provider_error_is_not_retried() {
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn provider_transient_retry_autonomous_upstream_400_retries_with_bounded_budget() {
|
||||
async fn provider_transient_retry_autonomous_upstream_400_uses_configured_budget() {
|
||||
let root = unique_project_path();
|
||||
init_local_game_project_at(&root, "project-1", "自主构建 Provider 400 重试测试")
|
||||
.expect("project init");
|
||||
@@ -4631,7 +4641,7 @@ async fn provider_transient_retry_autonomous_upstream_400_retries_with_bounded_b
|
||||
"model": "supervisor-autonomous-upstream-400-model",
|
||||
"apiKind": "openai_chat",
|
||||
"stream": false,
|
||||
"maxRetries": 0,
|
||||
"maxRetries": 2,
|
||||
"retryBackoffMs": 1
|
||||
}}
|
||||
}}
|
||||
@@ -4676,10 +4686,7 @@ async fn provider_transient_retry_autonomous_upstream_400_retries_with_bounded_b
|
||||
.expect("initial autonomous upstream 400 request");
|
||||
assert_eq!(waiting.error_kind, "upstream-400");
|
||||
assert_eq!(waiting.next_attempt, 1);
|
||||
assert_eq!(
|
||||
waiting.max_retries,
|
||||
AGENT_RUNTIME_AUTONOMOUS_PROVIDER_UPSTREAM_400_RETRY_LIMIT
|
||||
);
|
||||
assert_eq!(waiting.max_retries, 2);
|
||||
provider_retry::force_provider_retry_due_for_test_at(&root, &waiting.identity)
|
||||
.expect("force autonomous upstream 400 retry due");
|
||||
|
||||
@@ -4725,10 +4732,7 @@ async fn provider_transient_retry_autonomous_upstream_400_retries_with_bounded_b
|
||||
.collect::<Vec<_>>();
|
||||
assert_eq!(retry_audits.len(), 1);
|
||||
assert_eq!(retry_audits[0]["errorKind"], "upstream-400");
|
||||
assert_eq!(
|
||||
retry_audits[0]["maxRetries"],
|
||||
AGENT_RUNTIME_AUTONOMOUS_PROVIDER_UPSTREAM_400_RETRY_LIMIT
|
||||
);
|
||||
assert_eq!(retry_audits[0]["maxRetries"], 2);
|
||||
let lifecycle = records
|
||||
.iter()
|
||||
.filter(|record| {
|
||||
@@ -6923,6 +6927,22 @@ fn agent_native_function_catalog_exposes_each_runtime_tool_with_core_schemas() {
|
||||
"sliceCount"
|
||||
])
|
||||
);
|
||||
let canvas_properties = &canvas_asset.parameters["properties"]["input"]["properties"];
|
||||
assert_eq!(
|
||||
canvas_properties["sliceMode"]["enum"],
|
||||
serde_json::json!(["connected-components", "grid", null])
|
||||
);
|
||||
for field in ["gridX", "gridY", "sliceCount"] {
|
||||
assert_eq!(
|
||||
canvas_properties[field]["type"],
|
||||
serde_json::json!(["integer", "null"])
|
||||
);
|
||||
assert_eq!(canvas_properties[field]["minimum"], 1);
|
||||
assert_eq!(
|
||||
canvas_properties[field]["maximum"],
|
||||
if field == "sliceCount" { 256 } else { 32 }
|
||||
);
|
||||
}
|
||||
assert_eq!(
|
||||
canvas_asset.parameters["properties"]["input"]["properties"]["aspectRatio"]["enum"],
|
||||
serde_json::json!(["1:1", "2:3", "3:2", "9:16", "16:9", null])
|
||||
|
||||
+40
-6
@@ -495,8 +495,9 @@ function ResourceReferenceEditor({
|
||||
useState<ResourceReferenceScope | null>(null);
|
||||
const [pickerPosition, setPickerPosition] = useState<{
|
||||
left: number;
|
||||
bottom: number;
|
||||
top: number;
|
||||
width: number;
|
||||
maxHeight: number;
|
||||
} | null>(null);
|
||||
const rootRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
@@ -960,18 +961,46 @@ function ResourceReferenceEditor({
|
||||
const updatePickerPosition = useCallback(() => {
|
||||
const rect = rootRef.current?.getBoundingClientRect();
|
||||
if (!rect) return;
|
||||
const viewportPadding = 12;
|
||||
const gap = 8;
|
||||
const width = Math.min(
|
||||
Math.max(rect.width, 360),
|
||||
Math.max(280, window.innerWidth - 24),
|
||||
Math.max(280, window.innerWidth - viewportPadding * 2),
|
||||
);
|
||||
const left = Math.min(
|
||||
Math.max(12, rect.left),
|
||||
Math.max(12, window.innerWidth - width - 12),
|
||||
Math.max(viewportPadding, rect.left),
|
||||
Math.max(viewportPadding, window.innerWidth - width - viewportPadding),
|
||||
);
|
||||
/**
|
||||
* 上边界钳制:面板高度**必须**由输入框上下实际可用的空间决定。
|
||||
*
|
||||
* 之前只把底边钉在输入框上方(`bottom: 视口高 - rect.top + 8`)却让高度自由取到 480px,
|
||||
* 输入框靠上时(居中弹层里的提示词输入、窄屏)整块面板的顶边会被顶出视口——顶部那一排
|
||||
* 搜索与筛选既看不见也点不到。这里与 `@` 候选菜单同一套口径:先算上下各有多少空间,
|
||||
* 空间不足就翻到下方,并把高度收在该侧可用空间内,再对 `top` 兜一次底。
|
||||
*/
|
||||
const availableAbove = Math.max(0, rect.top - viewportPadding - gap);
|
||||
const availableBelow = Math.max(
|
||||
0,
|
||||
window.innerHeight - rect.bottom - viewportPadding - gap,
|
||||
);
|
||||
const openAbove =
|
||||
availableAbove >= 200 || availableAbove >= availableBelow;
|
||||
const maxHeight = Math.max(
|
||||
160,
|
||||
Math.min(480, openAbove ? availableAbove : availableBelow),
|
||||
);
|
||||
const top = openAbove
|
||||
? Math.max(viewportPadding, rect.top - gap - maxHeight)
|
||||
: Math.min(
|
||||
Math.max(viewportPadding, window.innerHeight - viewportPadding - maxHeight),
|
||||
rect.bottom + gap,
|
||||
);
|
||||
setPickerPosition({
|
||||
left,
|
||||
bottom: Math.max(12, window.innerHeight - rect.top + 8),
|
||||
top,
|
||||
width,
|
||||
maxHeight,
|
||||
});
|
||||
}, []);
|
||||
|
||||
@@ -1096,10 +1125,15 @@ function ResourceReferenceEditor({
|
||||
aria-modal="false"
|
||||
aria-label="选择素材"
|
||||
style={{
|
||||
// 与 `@` 候选菜单同一坐标系(fixed + top + 高度钳制):底边锚点在
|
||||
// 输入框上方会被顶出视口,只有钉住顶边并收紧高度才能保证整块面板可见。
|
||||
position: 'fixed',
|
||||
top: `${pickerPosition.top}px`,
|
||||
left: `${pickerPosition.left}px`,
|
||||
bottom: `${pickerPosition.bottom}px`,
|
||||
right: 'auto',
|
||||
bottom: 'auto',
|
||||
width: `${pickerPosition.width}px`,
|
||||
maxHeight: `${pickerPosition.maxHeight}px`,
|
||||
}}
|
||||
>
|
||||
<header>
|
||||
|
||||
+208
-25
@@ -1,12 +1,31 @@
|
||||
import './resourceCanvasGenerationPanel.css';
|
||||
|
||||
import { Sparkles, X } from 'lucide-react';
|
||||
import { type FormEvent, useState } from 'react';
|
||||
import { type CSSProperties, type FormEvent, useState } from 'react';
|
||||
|
||||
import { PlatformActionButton } from '../../../../../packages/shared/src/components/PlatformActionButton';
|
||||
import { PlatformSegmentedTabs } from '../../../../../packages/shared/src/components/PlatformSegmentedTabs';
|
||||
import { PlatformTextField } from '../../../../../packages/shared/src/components/PlatformTextField';
|
||||
import type {
|
||||
GameCreationAppAssetManifestEntry,
|
||||
GameIterationVersion,
|
||||
} from '../../../../../packages/shared/src/contracts/gameCreationApp';
|
||||
import { resolveEditorImageSizeLabel } from '../../../../../src/components/image-editor/ImageCanvasGenerationModel';
|
||||
import { ThemedModal } from '../../components/modal/ThemedModal';
|
||||
import { resourceEditPromptMaxLength } from '../../view/project-development/resourceEditModel';
|
||||
import { ResourceReferenceInput } from '../project-workspace/ResourceReferenceInput';
|
||||
import type {
|
||||
ChatComposerDraft,
|
||||
ChatReference,
|
||||
} from '../project-workspace/resourceReferences';
|
||||
import {
|
||||
resourceCanvasAssetGenerationAcceptsReferences,
|
||||
resourceCanvasAssetGenerationReferenceAssets,
|
||||
resourceCanvasAssetGenerationReferenceError,
|
||||
resourceCanvasAssetGenerationReferenceIds,
|
||||
resourceCanvasAssetGenerationReferenceIssue,
|
||||
resourceCanvasAssetGenerationUserReferenceLimit,
|
||||
} from './resourceCanvasAssetGenerationReferenceModel';
|
||||
import {
|
||||
RESOURCE_CANVAS_ASSET_ASPECT_RATIOS,
|
||||
RESOURCE_CANVAS_ASSET_IMAGE_SIZES,
|
||||
@@ -20,6 +39,14 @@ export type ResourceCanvasAssetGenerationSubmitInput = {
|
||||
assetName: string;
|
||||
aspectRatio: string;
|
||||
imageSize: string;
|
||||
/**
|
||||
* 本次生成的参考图引用(面板草稿与重开草稿的同一种形状)。
|
||||
*
|
||||
* 宿主从这里取 `resourceId`(**当前项目 manifest 的资产 ID**,按选择顺序去重)交给原生侧;
|
||||
* 原生据此读本地正式文件并按当前账号重新建立远端绑定,不接受本地路径,也不复用 manifest 里
|
||||
* 历史账号的远端 ID。
|
||||
*/
|
||||
references: ChatReference[];
|
||||
};
|
||||
|
||||
/** 提交面板的草稿:点击即关闭之后,只有「即时失败」重开时才需要把这份草稿带回来。 */
|
||||
@@ -28,6 +55,8 @@ export type ResourceCanvasAssetGenerationPanelDraft = {
|
||||
assetName: string;
|
||||
aspectRatio: string;
|
||||
imageSize: string;
|
||||
/** 提示词里的 `@显示名` 引用节点;参考选择器的候选项与它们同源。 */
|
||||
references: ChatReference[];
|
||||
};
|
||||
|
||||
export type ResourceCanvasAssetGenerationPanelViewProps = {
|
||||
@@ -40,6 +69,25 @@ export type ResourceCanvasAssetGenerationPanelViewProps = {
|
||||
draft?: ResourceCanvasAssetGenerationPanelDraft;
|
||||
/** 上一次即时失败的原因;重开时直接以 `role="alert"` 呈现。 */
|
||||
error?: string | null;
|
||||
/** 当前项目的 manifest 资产:参考选择的候选集由它收口到本项目的已登记图片。 */
|
||||
assets?: readonly GameCreationAppAssetManifestEntry[];
|
||||
/** `@` 引用选择器需要项目路径来登记资源预览,与快速编辑走同一条链路。 */
|
||||
projectPath?: string;
|
||||
versions?: GameIterationVersion[];
|
||||
activeVersionId?: string | null;
|
||||
/**
|
||||
* 呈现形态。
|
||||
*
|
||||
* `modal`:既有居中弹层(`ThemedModal` + 焦点陷阱)。`floating`:挂在画布占位卡下沿的
|
||||
* **独立浮层**——工具点击先建占位,浮层只是它旁边的一块 UI。
|
||||
*
|
||||
* 生成浮层必须走 `floating`:`ThemedModal` 的焦点陷阱会把 `@` 引用选择器(portal 到 body 的
|
||||
* `resource-reference-picker`)挡在陷阱之外,候选项点了不生效。浮层不是模态,因此不受这条限制;
|
||||
* 「关闭浮层不等于取消后台任务」的语义也由浮层形态直接成立。
|
||||
*/
|
||||
variant?: 'modal' | 'floating';
|
||||
/** 浮层形态的定位样式(贴着占位卡下沿,与快速编辑 / 信息浮层同一条锚点口径)。 */
|
||||
style?: CSSProperties | null;
|
||||
/**
|
||||
* 提交回调:**同步返回**,面板不等它的结果。
|
||||
*
|
||||
@@ -47,7 +95,13 @@ export type ResourceCanvasAssetGenerationPanelViewProps = {
|
||||
* 面板自己不持有任何在途状态。
|
||||
*/
|
||||
onSubmit: (input: ResourceCanvasAssetGenerationSubmitInput) => void;
|
||||
onClose: () => void;
|
||||
/**
|
||||
* 收起浮层。
|
||||
*
|
||||
* 参数是当前草稿:宿主保存它,用户再点开占位卡时接着编辑(关闭 ≠ 丢弃输入,不是空表单)。
|
||||
* 提交后的关闭不带草稿——这次输入已经被任务接走,重试身份也在宿主的提交上下文里。
|
||||
*/
|
||||
onClose: (draft?: ResourceCanvasAssetGenerationPanelDraft) => void;
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -73,10 +127,19 @@ export function ResourceCanvasAssetGenerationPanelView({
|
||||
action,
|
||||
draft,
|
||||
error: initialError,
|
||||
assets,
|
||||
projectPath,
|
||||
versions,
|
||||
activeVersionId,
|
||||
variant = 'modal',
|
||||
style,
|
||||
onSubmit,
|
||||
onClose,
|
||||
}: ResourceCanvasAssetGenerationPanelViewProps) {
|
||||
const [prompt, setPrompt] = useState(draft?.prompt ?? '');
|
||||
const [references, setReferences] = useState<ChatReference[]>(
|
||||
draft?.references ?? [],
|
||||
);
|
||||
const [assetName, setAssetName] = useState(
|
||||
draft?.assetName ?? action.assetName,
|
||||
);
|
||||
@@ -90,13 +153,71 @@ export function ResourceCanvasAssetGenerationPanelView({
|
||||
// 提示词上限复用资源编辑模型的同一份口径:图片类入口默认 32000,与 Rust
|
||||
// `LOCAL_PROJECT_ASSET_MAX_PROMPT_CHARS` 一致,不在面板里另抄常量。
|
||||
const promptMaxLength = resourceEditPromptMaxLength('image-reference');
|
||||
const canSubmit = prompt.trim().length > 0 && assetName.trim().length > 0;
|
||||
/**
|
||||
* 参考选择只对有真实参考能力的入口呈现。
|
||||
*
|
||||
* 图集只接受单张规范引用、图标规范本身就是权威规范图产出方:这两类入口不给选择器,
|
||||
* 原生侧同样拒绝额外参考(不是静默丢弃)。
|
||||
*/
|
||||
const referenceEnabled = resourceCanvasAssetGenerationAcceptsReferences(action);
|
||||
const referenceLimit =
|
||||
resourceCanvasAssetGenerationUserReferenceLimit(action);
|
||||
const referenceAssets = resourceCanvasAssetGenerationReferenceAssets(
|
||||
assets ?? [],
|
||||
);
|
||||
const referenceAssetIds =
|
||||
resourceCanvasAssetGenerationReferenceIds(references);
|
||||
const referenceError = resourceCanvasAssetGenerationReferenceError({
|
||||
action,
|
||||
referenceCount: referenceAssetIds.length,
|
||||
});
|
||||
/*
|
||||
陈旧引用(素材被删 / 改了类型 / 没有本地文件)必须在提交前报出来:过滤掉再提交等于
|
||||
把「带参考」变成「无参考」的付费生成,用户还以为参考生效了。
|
||||
*/
|
||||
const referenceIssue = referenceEnabled
|
||||
? resourceCanvasAssetGenerationReferenceIssue({
|
||||
references,
|
||||
assets: assets ?? [],
|
||||
})
|
||||
: null;
|
||||
const promptTooLong = prompt.trim().length > promptMaxLength;
|
||||
const promptTooLongError = promptTooLong
|
||||
? `生成提示词最多 ${promptMaxLength} 个字符,当前 ${prompt.trim().length} 个`
|
||||
: null;
|
||||
const canSubmit =
|
||||
prompt.trim().length > 0 &&
|
||||
assetName.trim().length > 0 &&
|
||||
!referenceError &&
|
||||
!referenceIssue &&
|
||||
!promptTooLong;
|
||||
const shownError = error ?? referenceIssue ?? promptTooLongError ?? referenceError;
|
||||
const applyDraft = (next: ChatComposerDraft) => {
|
||||
setPrompt(next.text);
|
||||
setReferences(next.references);
|
||||
};
|
||||
/** 收起浮层:把当前草稿交给宿主保存,用户再点开占位卡时接着编辑。 */
|
||||
const closeWithDraft = () =>
|
||||
onClose({
|
||||
prompt,
|
||||
assetName,
|
||||
aspectRatio,
|
||||
imageSize,
|
||||
references,
|
||||
});
|
||||
|
||||
function submit(event: FormEvent<HTMLFormElement>) {
|
||||
event.preventDefault();
|
||||
const normalizedPrompt = prompt.trim();
|
||||
const normalizedAssetName = assetName.trim();
|
||||
if (!normalizedPrompt || !normalizedAssetName) {
|
||||
// 超限与超长在提交入口再挡一次:按钮禁用只是表现,不能当唯一防线。
|
||||
if (
|
||||
!normalizedPrompt ||
|
||||
!normalizedAssetName ||
|
||||
referenceError ||
|
||||
referenceIssue ||
|
||||
promptTooLong
|
||||
) {
|
||||
return;
|
||||
}
|
||||
setError(null);
|
||||
@@ -108,17 +229,14 @@ export function ResourceCanvasAssetGenerationPanelView({
|
||||
assetName: normalizedAssetName,
|
||||
aspectRatio,
|
||||
imageSize,
|
||||
references,
|
||||
});
|
||||
// 提交后的关闭不带草稿:这次输入已经被任务接走,重试身份在宿主的提交上下文里。
|
||||
onClose();
|
||||
}
|
||||
|
||||
return (
|
||||
<ThemedModal
|
||||
open
|
||||
ariaLabel={action.label}
|
||||
onClose={onClose}
|
||||
panelClassName="game-approval-dialog game-resource-generation-dialog"
|
||||
>
|
||||
const panelBody = (
|
||||
<>
|
||||
<header>
|
||||
<div>
|
||||
<h2>{action.label}</h2>
|
||||
@@ -126,7 +244,7 @@ export function ResourceCanvasAssetGenerationPanelView({
|
||||
<button
|
||||
type="button"
|
||||
aria-label={`关闭${action.label}`}
|
||||
onClick={onClose}
|
||||
onClick={closeWithDraft}
|
||||
>
|
||||
<X size={16} aria-hidden="true" />
|
||||
</button>
|
||||
@@ -143,16 +261,40 @@ export function ResourceCanvasAssetGenerationPanelView({
|
||||
</label>
|
||||
<label>
|
||||
<span>生成提示词</span>
|
||||
<PlatformTextField
|
||||
variant="textarea"
|
||||
aria-label="生成提示词"
|
||||
rows={6}
|
||||
autoFocus
|
||||
maxLength={promptMaxLength}
|
||||
placeholder={action.promptPlaceholder}
|
||||
value={prompt}
|
||||
onChange={(event) => setPrompt(event.currentTarget.value)}
|
||||
/>
|
||||
{referenceEnabled ? (
|
||||
/*
|
||||
与聊天输入区、资源快速编辑同一个 `@` 引用输入区:候选、`@显示名` 文本与引用模型都
|
||||
复用那一份,所以参考图带的是**稳定资源 ID**(进而出站到当前账号绑定下的远端资源),
|
||||
不是只有名字的纯提示词。候选集只放当前项目的已登记图片。
|
||||
*/
|
||||
<div className="resource-canvas-asset-generation-prompt-input">
|
||||
<ResourceReferenceInput
|
||||
ariaLabel="生成提示词"
|
||||
value={prompt}
|
||||
references={references}
|
||||
onChange={applyDraft}
|
||||
assets={referenceAssets}
|
||||
projectPath={projectPath ?? ''}
|
||||
versions={versions}
|
||||
activeVersionId={activeVersionId}
|
||||
multiline
|
||||
rows={6}
|
||||
placeholder={`${action.promptPlaceholder}(可用 @ 选择参考图)`}
|
||||
showPolishAction={false}
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<PlatformTextField
|
||||
variant="textarea"
|
||||
aria-label="生成提示词"
|
||||
rows={6}
|
||||
autoFocus
|
||||
maxLength={promptMaxLength}
|
||||
placeholder={action.promptPlaceholder}
|
||||
value={prompt}
|
||||
onChange={(event) => setPrompt(event.currentTarget.value)}
|
||||
/>
|
||||
)}
|
||||
</label>
|
||||
{action.adjustableDimensions ? (
|
||||
<div className="resource-canvas-asset-generation-dimensions">
|
||||
@@ -197,16 +339,26 @@ export function ResourceCanvasAssetGenerationPanelView({
|
||||
prompt={prompt}
|
||||
applyPrompt={setPrompt}
|
||||
/>
|
||||
{error ? (
|
||||
{referenceEnabled && referenceLimit > 0 ? (
|
||||
<p
|
||||
className="resource-canvas-asset-generation-reference-hint"
|
||||
data-resource-canvas-generation-reference-count={
|
||||
referenceAssetIds.length
|
||||
}
|
||||
>
|
||||
{`参考图 ${referenceAssetIds.length}/${referenceLimit}`}
|
||||
</p>
|
||||
) : null}
|
||||
{shownError ? (
|
||||
<p className="game-resource-generation-error" role="alert">
|
||||
{error}
|
||||
{shownError}
|
||||
</p>
|
||||
) : null}
|
||||
<div className="game-resource-generation-actions">
|
||||
<PlatformActionButton
|
||||
type="button"
|
||||
tone="secondary"
|
||||
onClick={onClose}
|
||||
onClick={closeWithDraft}
|
||||
>
|
||||
取消
|
||||
</PlatformActionButton>
|
||||
@@ -216,6 +368,37 @@ export function ResourceCanvasAssetGenerationPanelView({
|
||||
</PlatformActionButton>
|
||||
</div>
|
||||
</form>
|
||||
</>
|
||||
);
|
||||
|
||||
if (variant === 'floating') {
|
||||
return (
|
||||
<section
|
||||
/*
|
||||
与模态共用同一套面板 chrome:`game-approval-dialog` 提供边框/圆角/底色/内边距与
|
||||
`> header` 排布,`game-resource-generation-dialog` 提供表单宽度口径。少任何一个,
|
||||
浮层就会退化成没有背景边框、标题挤在一起的一块裸容器(真实浏览器复现过)。
|
||||
*/
|
||||
className="game-approval-dialog game-resource-generation-dialog resource-canvas-generation-floating-panel"
|
||||
role="dialog"
|
||||
aria-label={action.label}
|
||||
data-resource-canvas-generation-floating-panel=""
|
||||
style={style ?? undefined}
|
||||
onPointerDown={(event) => event.stopPropagation()}
|
||||
>
|
||||
{panelBody}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<ThemedModal
|
||||
open
|
||||
ariaLabel={action.label}
|
||||
onClose={closeWithDraft}
|
||||
panelClassName="game-approval-dialog game-resource-generation-dialog"
|
||||
>
|
||||
{panelBody}
|
||||
</ThemedModal>
|
||||
);
|
||||
}
|
||||
|
||||
+84
-14
@@ -1,5 +1,5 @@
|
||||
import { Sparkles, X } from 'lucide-react';
|
||||
import { type FormEvent, useRef, useState } from 'react';
|
||||
import { type CSSProperties, type FormEvent, useRef, useState } from 'react';
|
||||
|
||||
import { PlatformActionButton } from '../../../../../packages/shared/src/components/PlatformActionButton';
|
||||
import { PlatformSegmentedTabs } from '../../../../../packages/shared/src/components/PlatformSegmentedTabs';
|
||||
@@ -36,8 +36,45 @@ export type ResourceCanvasGenerationPanelViewProps = {
|
||||
*/
|
||||
kinds?: readonly ResourceCanvasGenerationKind[];
|
||||
initialKind?: ResourceCanvasGenerationKind;
|
||||
/**
|
||||
* 呈现形态。
|
||||
*
|
||||
* 面板底部栏目的音频入口(音效 / 背景音乐)与「生成素材」入口一样:工具点击先在当前栏目
|
||||
* 建占位卡,浮层挂在占位卡下沿。占位与浮层的归属由宿主按 `draftId` 维护,面板只负责这一份
|
||||
* 草稿与失败重试。
|
||||
*/
|
||||
variant?: 'modal' | 'floating';
|
||||
style?: CSSProperties | null;
|
||||
/**
|
||||
* 初始草稿(音频 / 背景音乐入口用)。
|
||||
*
|
||||
* 用户收起浮层后草稿由宿主保存,再点开占位卡时从这里灌回来——**不是**空表单,
|
||||
* 用户不必重打一遍提示词。
|
||||
*/
|
||||
initialDraft?: {
|
||||
kind: ResourceCanvasGenerationKind;
|
||||
prompt: string;
|
||||
assetName: string;
|
||||
} | null;
|
||||
/**
|
||||
* 已绑定的提交身份。
|
||||
*
|
||||
* 同一次草稿的重试必须复用同一对 `operationId` / 幂等键:原生按 operation 记账,换一对就是
|
||||
* 一次**新的**付费生成。宿主把首次提交铸造的身份记在占位上,收起来再点开时灌回来。
|
||||
*/
|
||||
request?: ResourceEditRequestIdentity | null;
|
||||
onSubmit: (input: ResourceCanvasGenerationSubmitInput) => Promise<void>;
|
||||
onClose: () => void;
|
||||
/**
|
||||
* 收起浮层。
|
||||
*
|
||||
* 参数是当前草稿:宿主把它存起来,用户再点开占位卡时能接着编辑(关闭 ≠ 丢弃输入)。
|
||||
* 提交成功后的关闭不带草稿(这次输入已经被任务接走)。
|
||||
*/
|
||||
onClose: (draft?: {
|
||||
kind: ResourceCanvasGenerationKind;
|
||||
prompt: string;
|
||||
assetName: string;
|
||||
}) => void;
|
||||
};
|
||||
|
||||
const RESOURCE_GENERATION_ALL_KIND_ITEMS =
|
||||
@@ -66,6 +103,10 @@ function resourceGenerationErrorMessage(error: unknown) {
|
||||
export function ResourceCanvasGenerationPanelView({
|
||||
kinds = RESOURCE_CANVAS_GENERATION_OPTIONS.map((option) => option.kind),
|
||||
initialKind,
|
||||
variant = 'modal',
|
||||
style,
|
||||
initialDraft,
|
||||
request: boundRequest,
|
||||
onSubmit,
|
||||
onClose,
|
||||
}: ResourceCanvasGenerationPanelViewProps) {
|
||||
@@ -83,16 +124,23 @@ export function ResourceCanvasGenerationPanelView({
|
||||
const option = resourceCanvasGenerationOption(kind);
|
||||
const panelTitle =
|
||||
allowedOptions.length === 1 ? option.generationLabel : '生成素材';
|
||||
const [prompt, setPrompt] = useState('');
|
||||
const [assetName, setAssetName] = useState(option.assetName);
|
||||
const [prompt, setPrompt] = useState(initialDraft?.prompt ?? '');
|
||||
const [assetName, setAssetName] = useState(
|
||||
initialDraft?.assetName ?? option.assetName,
|
||||
);
|
||||
const [attempted, setAttempted] = useState(false);
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
// 请求身份绑定到铸造时的那句提示词:失败重试命中同一 operation 账本,提示词变了就重铸
|
||||
// (Rust 的 request_fingerprint 含 prompt,复用旧身份会被拒)。面板在首次提交后锁定
|
||||
// 输入,正常路径下提示词不会漂移;这里按同一口径收口,不依赖「锁」这层间接保证。
|
||||
const requestRef = useRef<ResourceEditRequestIdentity | null>(null);
|
||||
const requestRef = useRef<ResourceEditRequestIdentity | null>(
|
||||
boundRequest ?? null,
|
||||
);
|
||||
const inputLocked = attempted || submitting;
|
||||
/** 收起浮层:把当前草稿交给宿主保存,用户再点开占位卡时接着编辑。 */
|
||||
const closeWithDraft = () =>
|
||||
onClose({ kind, prompt, assetName: assetName.trim() || option.assetName });
|
||||
|
||||
async function submit(event: FormEvent<HTMLFormElement>) {
|
||||
event.preventDefault();
|
||||
@@ -125,13 +173,8 @@ export function ResourceCanvasGenerationPanelView({
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<ThemedModal
|
||||
open
|
||||
ariaLabel={panelTitle}
|
||||
onClose={onClose}
|
||||
panelClassName="game-approval-dialog game-resource-generation-dialog"
|
||||
>
|
||||
const panelBody = (
|
||||
<>
|
||||
<header>
|
||||
<div>
|
||||
<h2>{panelTitle}</h2>
|
||||
@@ -139,7 +182,7 @@ export function ResourceCanvasGenerationPanelView({
|
||||
<button
|
||||
type="button"
|
||||
aria-label={`关闭${panelTitle}`}
|
||||
onClick={onClose}
|
||||
onClick={closeWithDraft}
|
||||
>
|
||||
<X size={16} aria-hidden="true" />
|
||||
</button>
|
||||
@@ -200,7 +243,7 @@ export function ResourceCanvasGenerationPanelView({
|
||||
<PlatformActionButton
|
||||
type="button"
|
||||
tone="secondary"
|
||||
onClick={onClose}
|
||||
onClick={closeWithDraft}
|
||||
>
|
||||
{submitting ? '后台运行并关闭' : '取消'}
|
||||
</PlatformActionButton>
|
||||
@@ -222,6 +265,33 @@ export function ResourceCanvasGenerationPanelView({
|
||||
)}
|
||||
</div>
|
||||
</form>
|
||||
</>
|
||||
);
|
||||
|
||||
if (variant === 'floating') {
|
||||
return (
|
||||
<section
|
||||
// 与模态共用面板 chrome(边框/圆角/底色/内边距 + `> header` 排布),浮层不另造外观。
|
||||
className="game-approval-dialog game-resource-generation-dialog resource-canvas-generation-floating-panel"
|
||||
role="dialog"
|
||||
aria-label={panelTitle}
|
||||
data-resource-canvas-generation-floating-panel=""
|
||||
style={style ?? undefined}
|
||||
onPointerDown={(event) => event.stopPropagation()}
|
||||
>
|
||||
{panelBody}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<ThemedModal
|
||||
open
|
||||
ariaLabel={panelTitle}
|
||||
onClose={closeWithDraft}
|
||||
panelClassName="game-approval-dialog game-resource-generation-dialog"
|
||||
>
|
||||
{panelBody}
|
||||
</ThemedModal>
|
||||
);
|
||||
}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user