WIP: AGC 资源画布与替换改造 V3.0 #316

Draft
suzmii wants to merge 131 commits from feat/agc-canvas-resource-workbench-v3 into master
137 changed files with 21605 additions and 32622 deletions
@@ -1,7 +1,7 @@
{
"$schema": "../gen/schemas/desktop-schema.json",
"identifier": "main",
"description": "AI 游戏创作主窗口允许读取系统剪贴板图片,用于粘贴素材附件。",
"description": "AI 游戏创作主窗口允许读取系统剪贴板图片,用于粘贴素材附件;允许弹出原生打开/保存对话框用于素材上传与导出。",
"windows": ["client"],
"permissions": [
"clipboard-manager:allow-read-image",
@@ -21,6 +21,7 @@
]
},
"opener:default",
"dialog:allow-open"
"dialog:allow-open",
"dialog:allow-save"
]
}
@@ -0,0 +1 @@
你是游戏创作需求的润色助手。把用户给创作 Agent 的一段需求改写成更清晰、可执行的中文需求。要求:保留用户的原始意图、玩法、美术方向、数值与限制条件,不得新增或删除需求点,不得替用户做决定,不得写成方案书或任务清单。只输出润色后的需求正文,不要解释、不要引言、不要 Markdown 标记、不要引号包裹、不要重复用户原文;无法润色时原样输出用户输入。
@@ -16,6 +16,7 @@ mod direct_codex_attachments;
mod direct_codex_audit;
mod direct_project_history;
mod direct_project_turn_history;
mod direct_codex_references;
mod direct_runtime;
mod direct_tool_bridge;
mod direct_tools_mcp;
@@ -42,6 +43,7 @@ pub(crate) use direct_codex_attachments::*;
pub(crate) use direct_codex_audit::*;
pub(crate) use direct_project_history::*;
pub(crate) use direct_project_turn_history::*;
pub(crate) use direct_codex_references::*;
pub(crate) use direct_runtime::*;
pub(crate) use direct_tool_bridge::*;
pub(crate) use direct_tools_mcp::*;
@@ -0,0 +1,328 @@
use super::*;
pub(crate) const MAX_DIRECT_CODEX_REFERENCES: usize = 32;
const MAX_DIRECT_CODEX_REFERENCE_ID_CHARS: usize = 200;
const MAX_DIRECT_CODEX_REFERENCE_LABEL_CHARS: usize = 160;
const MAX_DIRECT_CODEX_REFERENCE_SOURCE_CHARS: usize = 32;
const MAX_DIRECT_CODEX_REFERENCE_ELEMENT_CHARS: usize = 80;
const MAX_DIRECT_CODEX_REFERENCE_TEXT_CHARS: usize = 240;
const DIRECT_CODEX_REFERENCE_HEADER: &str =
"[本轮用户引用素材:以下均为当前项目已确认的安全引用。请使用稳定资源 ID 和项目相对路径读取,不要读取或输出其它路径。]";
#[derive(Clone, Debug, serde::Deserialize)]
#[serde(rename_all = "camelCase", tag = "type")]
pub(crate) enum DirectCodexTurnReference {
#[serde(rename = "resource")]
Resource(DirectCodexResourceReference),
#[serde(rename = "runtime-region")]
RuntimeRegion(DirectCodexRuntimeRegionReference),
}
#[derive(Clone, Debug, serde::Deserialize)]
#[serde(rename_all = "camelCase")]
pub(crate) struct DirectCodexResourceReference {
pub(crate) resource_id: String,
#[serde(default)]
pub(crate) label: Option<String>,
#[serde(default)]
pub(crate) source: Option<String>,
}
#[derive(Clone, Debug, serde::Deserialize)]
#[serde(rename_all = "camelCase")]
pub(crate) struct DirectCodexRuntimeRegionReference {
#[serde(default)]
pub(crate) label: Option<String>,
#[serde(default)]
pub(crate) run_id: Option<String>,
#[serde(default)]
pub(crate) version_id: Option<String>,
#[serde(default)]
pub(crate) element_tag: Option<String>,
#[serde(default)]
pub(crate) element_role: Option<String>,
#[serde(default)]
pub(crate) text: Option<String>,
#[serde(default)]
pub(crate) width: Option<f64>,
#[serde(default)]
pub(crate) height: Option<f64>,
#[serde(default)]
pub(crate) resource_ids: Vec<String>,
}
fn sanitize_reference_text(value: &str, max_chars: usize) -> Option<String> {
let sanitized = value
.trim()
.chars()
.filter(|character| !character.is_control())
.take(max_chars)
.collect::<String>();
if sanitized.is_empty() {
None
} else {
Some(sanitized)
}
}
fn sanitize_reference_label(value: Option<&str>) -> Option<String> {
value.and_then(|value| sanitize_reference_text(value, MAX_DIRECT_CODEX_REFERENCE_LABEL_CHARS))
}
fn sanitize_reference_source(value: Option<&str>) -> Option<String> {
let value = sanitize_reference_text(value?, MAX_DIRECT_CODEX_REFERENCE_SOURCE_CHARS)?;
if value
.chars()
.all(|character| character.is_ascii_alphanumeric() || matches!(character, '-' | '_' | '.'))
{
Some(value)
} else {
None
}
}
fn sanitize_reference_element(value: Option<&str>) -> Option<String> {
let value = sanitize_reference_text(value?, MAX_DIRECT_CODEX_REFERENCE_ELEMENT_CHARS)?;
if value
.chars()
.all(|character| character.is_ascii_alphanumeric() || matches!(character, '-' | '_' | '.'))
{
Some(value)
} else {
None
}
}
fn sanitize_reference_dimension(value: Option<f64>) -> Option<u32> {
value
.filter(|value| value.is_finite() && *value > 0.0)
.map(|value| value.round().clamp(1.0, 100_000.0) as u32)
}
fn asset_display_label(asset: &GameCreationAppAssetManifestEntry) -> String {
let basename = asset
.local_path
.rsplit(['/', '\\'])
.next()
.unwrap_or(&asset.id);
let without_extension = basename
.rsplit_once('.')
.map(|(name, _)| name)
.unwrap_or(basename)
.trim();
if without_extension.is_empty() {
asset.id.clone()
} else {
without_extension
.chars()
.filter(|character| !character.is_control())
.take(MAX_DIRECT_CODEX_REFERENCE_LABEL_CHARS)
.collect()
}
}
fn validate_resource_reference_id(value: &str) -> Result<String, String> {
let resource_id = value.trim();
if resource_id.is_empty()
|| resource_id.chars().count() > MAX_DIRECT_CODEX_REFERENCE_ID_CHARS
|| resource_id.chars().any(char::is_control)
{
return Err("引用的素材 ID 无效,请移除后重新选择".to_string());
}
Ok(resource_id.to_string())
}
fn render_resource_reference_line(
manifest: &GameCreationAppManifest,
reference: &DirectCodexResourceReference,
) -> Result<String, String> {
let resource_id = validate_resource_reference_id(&reference.resource_id)?;
let asset = manifest
.assets
.iter()
.find(|asset| asset.id == resource_id)
.ok_or_else(|| "引用的素材已不存在,请移除后重新选择".to_string())?;
let local_path = sanitize_attachment_local_path(&asset.local_path)
.ok_or_else(|| "引用的素材路径无效,请移除后重新选择".to_string())?;
let label = sanitize_reference_label(reference.label.as_deref())
.unwrap_or_else(|| asset_display_label(asset));
let source = sanitize_reference_source(reference.source.as_deref())
.unwrap_or_else(|| "unknown".to_string());
Ok(format!(
"- 素材 ID{resource_id};名称:{label};类型:{};媒体类型:{};项目路径:{local_path};来源:{source}",
asset.kind, asset.media_type
))
}
fn render_runtime_region_reference_line(
manifest: &GameCreationAppManifest,
reference: &DirectCodexRuntimeRegionReference,
) -> Result<String, String> {
let label = sanitize_reference_label(reference.label.as_deref())
.unwrap_or_else(|| "运行画面区域".to_string());
let run_id = sanitize_reference_source(reference.run_id.as_deref());
let version_id = sanitize_reference_source(reference.version_id.as_deref());
let element_tag = sanitize_reference_element(reference.element_tag.as_deref());
let element_role = sanitize_reference_element(reference.element_role.as_deref());
let text = reference
.text
.as_deref()
.and_then(|value| sanitize_reference_text(value, MAX_DIRECT_CODEX_REFERENCE_TEXT_CHARS));
let width = sanitize_reference_dimension(reference.width);
let height = sanitize_reference_dimension(reference.height);
let mut related_resource_ids = Vec::new();
for resource_id in &reference.resource_ids {
let resource_id = validate_resource_reference_id(resource_id)?;
if !manifest.assets.iter().any(|asset| asset.id == resource_id) {
return Err("运行画面引用的素材已变化,请重新点选".to_string());
}
related_resource_ids.push(resource_id);
}
let mut parts = vec![format!("名称:{label}")];
if let Some(run_id) = run_id {
parts.push(format!("运行标识:{run_id}"));
}
if let Some(version_id) = version_id {
parts.push(format!("版本标识:{version_id}"));
}
if let Some(element_tag) = element_tag {
parts.push(format!("元素:{element_tag}"));
}
if let Some(element_role) = element_role {
parts.push(format!("角色:{element_role}"));
}
if let Some(text) = text {
parts.push(format!("文本摘要:{text}"));
}
if let (Some(width), Some(height)) = (width, height) {
parts.push(format!("尺寸:{width}x{height}"));
}
if !related_resource_ids.is_empty() {
parts.push(format!("关联素材 ID{}", related_resource_ids.join(",")));
}
Ok(format!("- 运行画面区域:{}", parts.join("")))
}
pub(crate) fn render_direct_codex_references_section(
root: &Path,
references: &[DirectCodexTurnReference],
) -> Result<Option<String>, String> {
if references.is_empty() {
return Ok(None);
}
if references.len() > MAX_DIRECT_CODEX_REFERENCES {
return Err(format!("一次最多引用 {MAX_DIRECT_CODEX_REFERENCES} 个素材"));
}
let manifest = read_manifest_for_project(root)?;
let mut lines = Vec::with_capacity(references.len());
for reference in references {
lines.push(match reference {
DirectCodexTurnReference::Resource(reference) => {
render_resource_reference_line(&manifest, reference)?
}
DirectCodexTurnReference::RuntimeRegion(reference) => {
render_runtime_region_reference_line(&manifest, reference)?
}
});
}
Ok(Some(
std::iter::once(DIRECT_CODEX_REFERENCE_HEADER.to_string())
.chain(lines)
.collect::<Vec<_>>()
.join("\n"),
))
}
#[cfg(test)]
mod tests {
use super::*;
fn fixture_project() -> tempfile::TempDir {
let directory = tempfile::tempdir().expect("temp project");
let root = directory.path();
std::fs::create_dir_all(root.join(".agent")).expect("create agent dir");
let mut manifest = new_game_creation_app_manifest("project-1", "测试项目");
manifest.assets.push(GameCreationAppAssetManifestEntry {
id: "asset-hero".to_string(),
kind: "character".to_string(),
media_type: "image/png".to_string(),
local_path: "assets/hero.png".to_string(),
source: GameCreationAppAssetSource {
kind: GameCreationAppAssetSourceKind::Uploaded,
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(),
},
image_sequence_frames: None,
image_sequence_duration_ms: None,
category: game_creation_app_asset_category_for_kind("character"),
tags: Vec::new(),
});
write_manifest(&root.join(".agent/manifest.json"), &manifest).expect("write manifest");
directory
}
#[test]
fn resource_reference_uses_manifest_identity_and_never_accepts_client_paths() {
let project = fixture_project();
let reference: DirectCodexTurnReference = serde_json::from_str(
r#"{"type":"resource","resourceId":"asset-hero","label":"主角","source":"asset-picker","localPath":"C:\\secret.png"}"#,
)
.expect("reference json");
let section = render_direct_codex_references_section(
project.path(),
std::slice::from_ref(&reference),
)
.expect("render")
.expect("section");
assert!(section.contains("素材 IDasset-hero"));
assert!(section.contains("名称:主角"));
assert!(section.contains("项目路径:assets/hero.png"));
assert!(!section.contains("C:\\secret.png"));
}
#[test]
fn deleted_resource_fails_closed() {
let project = fixture_project();
let reference: DirectCodexTurnReference = serde_json::from_str(
r#"{"type":"resource","resourceId":"asset-missing","label":"不存在"}"#,
)
.expect("reference json");
let error = render_direct_codex_references_section(
project.path(),
std::slice::from_ref(&reference),
)
.expect_err("missing resource");
assert_eq!(error, "引用的素材已不存在,请移除后重新选择");
}
#[test]
fn runtime_region_keeps_only_safe_summary_and_existing_resource_ids() {
let project = fixture_project();
let reference: DirectCodexTurnReference = serde_json::from_str(
r#"{"type":"runtime-region","label":"开始按钮","runId":"run-1","elementTag":"button","elementRole":"button","text":"开始游戏","width":120.4,"height":40.2,"resourceIds":["asset-hero"],"html":"<button onclick=secret>"}"#,
)
.expect("reference json");
let section = render_direct_codex_references_section(
project.path(),
std::slice::from_ref(&reference),
)
.expect("render")
.expect("section");
assert!(section.contains("运行画面区域:名称:开始按钮"));
assert!(section.contains("文本摘要:开始游戏"));
assert!(section.contains("尺寸:120x40"));
assert!(section.contains("关联素材 IDasset-hero"));
assert!(!section.contains("onclick"));
assert!(!section.contains("secret"));
}
}
@@ -4414,6 +4414,7 @@ pub(crate) async fn chat_with_game_creator_direct_codex(
creation_type: Option<String>,
client_turn_id: Option<String>,
attachments: Option<Vec<DirectCodexTurnAttachment>>,
references: Option<Vec<DirectCodexTurnReference>>,
) -> Result<String, String> {
let root = Path::new(project_path.trim());
let turn_id = normalize_direct_client_turn_id(client_turn_id.as_deref())?;
@@ -4428,16 +4429,33 @@ pub(crate) async fn chat_with_game_creator_direct_codex(
&prompt,
attachments.as_deref().unwrap_or_default(),
);
let user_prompt = match render_direct_codex_user_prompt(
&prompt,
attachments.as_deref().unwrap_or_default(),
) {
let attachments = attachments.unwrap_or_default();
let references = references.unwrap_or_default();
let mut user_prompt = match render_direct_codex_user_prompt(&prompt, &attachments) {
Ok(prompt) => prompt,
Err(_) if !references.is_empty() && prompt.trim().is_empty() => String::new(),
Err(error) => {
audit.finish(false);
return Err(error);
}
};
if let Some(reference_section) = match render_direct_codex_references_section(root, &references)
{
Ok(section) => section,
Err(error) => {
audit.finish(false);
return Err(error);
}
} {
if !user_prompt.trim().is_empty() {
user_prompt.push_str("\n\n");
}
user_prompt.push_str(&reference_section);
}
if user_prompt.trim().is_empty() {
audit.finish(false);
return Err("聊天内容不能为空".to_string());
}
let reply = match run_direct_game_creator_turn_at_with_creation_type_and_emitter(
root,
&user_prompt,
@@ -1161,6 +1161,8 @@ fn bridge_registered_resource(
"localPath": asset.local_path,
"kind": asset.kind,
"mediaType": asset.media_type,
"category": asset.category,
"tags": asset.tags,
"canvasProjectId": asset.source.canvas_project_id,
"resourceId": asset.source.resource_id,
"assetObjectId": asset.source.asset_object_id,
@@ -2781,6 +2783,8 @@ mod tests {
local_path: "assets/art-spritesheet-slices/player.png".to_string(),
image_sequence_frames: None,
image_sequence_duration_ms: None,
category: game_creation_app_asset_category_for_kind("art-spritesheet-slice"),
tags: Vec::new(),
source: GameCreationAppAssetSource {
kind: GameCreationAppAssetSourceKind::Canvas,
canvas_project_id: Some("canvas-1".to_string()),
@@ -2844,6 +2848,8 @@ mod tests {
},
]),
image_sequence_duration_ms: Some(4_000),
category: game_creation_app_asset_category_for_kind("character-animation"),
tags: Vec::new(),
source: GameCreationAppAssetSource {
kind: GameCreationAppAssetSourceKind::Canvas,
canvas_project_id: Some("canvas-1".to_string()),
@@ -2863,6 +2869,8 @@ mod tests {
projection["imageSequenceFrames"][0]["objectKey"],
"animations/frame-01.png"
);
assert_eq!(projection["category"], "character");
assert_eq!(projection["tags"], json!([]));
let serialized = projection.to_string();
assert!(!serialized.contains("signed.invalid"));
assert!(!serialized.contains("private prompt"));
@@ -2870,6 +2878,42 @@ mod tests {
assert!(!serialized.contains("private.invalid"));
}
#[test]
fn bridge_registered_resource_projects_manifest_classification_verbatim() {
let asset = GameCreationAppAssetManifestEntry {
id: "spec-1".to_string(),
kind: "spec".to_string(),
media_type: "application/json".to_string(),
local_path: "assets/specs/hero.json".to_string(),
image_sequence_frames: None,
image_sequence_duration_ms: None,
category:
shared_contracts::game_creation_app::GameCreationAppAssetCategory::UiInteraction,
tags: vec!["主角".to_string(), "第一版".to_string()],
source: GameCreationAppAssetSource {
kind: GameCreationAppAssetSourceKind::Generated,
canvas_project_id: None,
resource_id: None,
asset_object_id: None,
task_id: None,
prompt: Some("private prompt".to_string()),
model: Some("private model".to_string()),
generation_route: None,
generation_kind: None,
reference_resource_ids: Vec::new(),
},
};
let projection = bridge_registered_resource(&asset, false);
assert_eq!(projection["kind"], "spec");
assert_eq!(projection["category"], "ui-interaction");
assert_eq!(projection["tags"], json!(["主角", "第一版"]));
assert_eq!(projection["imageSequenceFrames"], Value::Null);
let serialized = projection.to_string();
assert!(!serialized.contains("private prompt"));
assert!(!serialized.contains("private model"));
}
#[test]
fn bridge_success_warnings_are_redacted_before_serialization() {
let root = tempfile::tempdir().expect("warning redaction root");
@@ -256,7 +256,7 @@ fn direct_tools_mcp_specs_for(controlled_web_search: bool) -> Value {
}),
json!({
"name": "agc_list_registered_assets",
"description": "查询当前项目由客户端权威 manifest 登记的资源与未完成资源 operation。结果有界且只包含项目相对路径、稳定资源身份、序列帧身份和恢复状态,不返回 prompt、模型、签名 URL、宿主路径或凭据。",
"description": "查询当前项目由客户端权威 manifest 登记的资源与未完成资源 operation。结果有界且只包含项目相对路径、稳定资源身份、功能分类与自定义标签、序列帧身份和恢复状态,不返回 prompt、模型、签名 URL、宿主路径或凭据。",
"inputSchema": {
"type": "object",
"properties": {
@@ -6786,6 +6786,8 @@ fn register_platform_art_slice_manifest_entries_at(
local_path: registration.local_path.clone(),
image_sequence_frames: None,
image_sequence_duration_ms: None,
category: game_creation_app_asset_category_for_kind("art-spritesheet-slice"),
tags: Vec::new(),
source,
});
ids.push(id);
@@ -1768,6 +1768,8 @@ pub(crate) fn register_local_asset_entry(
local_path: normalized_path.clone(),
image_sequence_frames: None,
image_sequence_duration_ms: None,
category: game_creation_app_asset_category_for_kind(kind),
tags: Vec::new(),
source,
});
Ok((id, "asset.register"))
@@ -1793,6 +1795,181 @@ pub(crate) fn register_local_asset_entry(
})
}
#[derive(Clone, Debug, Deserialize)]
#[serde(deny_unknown_fields, rename_all = "camelCase")]
pub(crate) struct ReadLocalProjectAssetReferencesInput {
pub project_path: String,
pub asset_id: String,
}
/// 引用了某个素材的项目版本摘要。删除弹窗据此列出「被哪些版本使用」。
#[derive(Clone, Debug, Eq, PartialEq, Serialize)]
#[serde(rename_all = "camelCase")]
pub(crate) struct LocalProjectAssetReferenceVersion {
pub version_id: String,
pub project_revision: u64,
pub created_at: u64,
}
#[derive(Clone, Debug, Eq, PartialEq, Serialize)]
#[serde(rename_all = "camelCase")]
pub(crate) struct ReadLocalProjectAssetReferencesResult {
pub asset_id: String,
pub versions: Vec<LocalProjectAssetReferenceVersion>,
}
/// 版本数组里 `resourceBindings` 指向该素材的版本,顺序与版本数组一致。
///
/// 版本绑定即资源记录:`resourceId` 指向 manifest 资产 ID,命中即表示该版本用了这个素材。
fn asset_referencing_versions(
manifest: &GameCreationAppManifest,
asset_id: &str,
) -> Vec<LocalProjectAssetReferenceVersion> {
manifest
.versions
.iter()
.filter(|version| {
version
.resource_bindings
.iter()
.any(|binding| binding.resource_id == asset_id)
})
.map(|version| LocalProjectAssetReferenceVersion {
version_id: version.version_id.clone(),
project_revision: version.project_revision,
created_at: version.created_at,
})
.collect()
}
fn normalized_asset_id(asset_id: &str) -> Result<&str, String> {
let asset_id = asset_id.trim();
if asset_id.is_empty() {
return Err("素材 assetId 不能为空".to_string());
}
Ok(asset_id)
}
pub(crate) fn read_manifest_asset_references_at(
root: &Path,
asset_id: &str,
) -> Result<ReadLocalProjectAssetReferencesResult, String> {
let asset_id = normalized_asset_id(asset_id)?;
let manifest = read_existing_manifest_for_project(root)?;
Ok(ReadLocalProjectAssetReferencesResult {
asset_id: asset_id.to_string(),
versions: asset_referencing_versions(&manifest, asset_id),
})
}
#[derive(Clone, Debug, Deserialize)]
#[serde(deny_unknown_fields, rename_all = "camelCase")]
pub(crate) struct DeleteLocalProjectAssetInput {
pub project_path: String,
pub expected_project_id: String,
pub expected_project_revision: u64,
pub asset_id: String,
/// 用户是否勾选「把相关游戏版本一并删除」;默认不勾,只删素材登记。
pub delete_referenced_versions: bool,
}
#[derive(Clone, Debug, Eq, PartialEq, Serialize)]
#[serde(rename_all = "camelCase")]
pub(crate) struct DeleteLocalProjectAssetResult {
pub asset_id: String,
pub local_path: String,
pub committed_project_revision: u64,
/// 只删除 manifest 登记,磁盘文件保留;调用方据此提示用户。
pub file_retained: bool,
}
/// 删除一个素材登记,并按用户确认决定是否连带删除引用它的游戏版本。
///
/// 语义:
/// - 未被任何版本引用 → 只摘掉登记;
/// - 被引用且 `delete_referenced_versions` 为 `false` → 只摘掉登记,引用它的版本原样保留为
/// 悬空绑定,不做二次拦截;界面不为这些版本合成资源卡;
/// - 被引用且为 `true` → 素材与所有引用它的版本在**同一次 manifest 写入**里一起删除;
/// - 素材不可变,**不删除磁盘文件**,只摘掉 manifest 登记;
/// - 与资源分类更新同口径:持项目写锁后按 `expectedProjectId` / 当前 revision 做 CAS
/// 失败时 manifest 与 revision 都不变。
pub(crate) fn delete_manifest_asset_at(
root: &Path,
expected_project_id: &str,
expected_project_revision: u64,
asset_id: &str,
delete_referenced_versions: bool,
) -> Result<DeleteLocalProjectAssetResult, 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_id = normalized_asset_id(asset_id)?;
if read_existing_manifest_for_project(root)?.project_id != expected_project_id {
return Err("project-identity-conflict".to_string());
}
let _lock = acquire_project_write_lock(root, "asset.register")?;
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());
}
let mut local_path = String::new();
mutate_manifest_at_allowing_version_removals(
root,
&|manifest| {
if !delete_referenced_versions {
return Vec::new();
}
asset_referencing_versions(manifest, asset_id)
.into_iter()
.map(|version| version.version_id)
.collect()
},
|manifest| {
let Some(index) = manifest
.assets
.iter()
.position(|asset| asset.id == asset_id)
else {
return Err(format!("项目资源不存在:{asset_id}"));
};
local_path = manifest.assets[index].local_path.clone();
manifest.assets.remove(index);
if delete_referenced_versions {
let referenced_version_ids: Vec<String> =
asset_referencing_versions(manifest, asset_id)
.into_iter()
.map(|version| version.version_id)
.collect();
manifest
.versions
.retain(|version| !referenced_version_ids.contains(&version.version_id));
}
Ok(())
},
)?;
let committed_project_revision = advance_agent_runtime_project_revision_locked(root)
.map_err(|error| format!("素材登记已删除,但项目 revision 未能推进:{error}"))?;
Ok(DeleteLocalProjectAssetResult {
asset_id: asset_id.to_string(),
local_path,
committed_project_revision,
file_retained: true,
})
}
#[cfg(test)]
mod tests {
use super::*;
@@ -18,6 +18,13 @@ const AUTOMATIC_PROJECT_NAME_MAX_PROMPT_CHARS: usize = 8_000;
const AUTOMATIC_PROJECT_NAME_MAX_OUTPUT_TOKENS: u32 = 64;
const AUTOMATIC_PROJECT_NAME_SYSTEM_PROMPT: &str =
include_str!("../prompts/automatic-project-name.md");
// 聊天输入区的 AI 润色复用同一条短文本生成通道:只做一次单轮改写,
// 计费由平台 LLM 路由(/api/llm/chat/completions、/api/llm/responses)侧完成。
const LOCAL_PROJECT_PROMPT_POLISH_MAX_PROMPT_CHARS: usize = 4_000;
const LOCAL_PROJECT_PROMPT_POLISH_MAX_CONTEXT_CHARS: usize = 1_000;
const LOCAL_PROJECT_PROMPT_POLISH_MAX_OUTPUT_TOKENS: u32 = 2_048;
const LOCAL_PROJECT_PROMPT_POLISH_SYSTEM_PROMPT: &str =
include_str!("../prompts/local-project-prompt-polish.md");
fn is_chinese_project_name_character(value: char) -> bool {
matches!(
@@ -79,6 +86,122 @@ async fn request_automatic_project_name(prompt: &str) -> Result<Option<String>,
Ok(normalize_suggested_project_name(response.text.trim()))
}
pub(crate) fn build_local_project_prompt_polish_prompt(
prompt: &str,
context: Option<&str>,
) -> Result<String, String> {
let prompt = prompt.trim();
if prompt.is_empty() {
return Err("待润色的内容为空,无法润色".to_string());
}
let mut user_prompt: String = prompt
.chars()
.take(LOCAL_PROJECT_PROMPT_POLISH_MAX_PROMPT_CHARS)
.collect();
if let Some(context) = context.map(str::trim).filter(|value| !value.is_empty()) {
let context: String = context
.chars()
.take(LOCAL_PROJECT_PROMPT_POLISH_MAX_CONTEXT_CHARS)
.collect();
user_prompt.push_str("\n\n当前项目上下文:\n");
user_prompt.push_str(&context);
}
Ok(user_prompt)
}
/// 只接受非空文本:空回复或纯空白一律视为润色失败,由调用方保留原文。
fn normalize_polished_prompt(value: &str) -> Option<String> {
let value = value.trim();
if value.is_empty() {
None
} else {
Some(value.to_string())
}
}
async fn request_local_project_prompt_polish(
prompt: &str,
context: Option<&str>,
) -> Result<String, String> {
let user_prompt = build_local_project_prompt_polish_prompt(prompt, context)?;
let app_config = load_game_creator_app_config()?;
if app_config.agent_mode == GAME_CREATOR_AGENT_MODE_CODEX_APP_SERVER {
let reply = crate::agent::direct_game_creator_home_codex_chat(
LOCAL_PROJECT_PROMPT_POLISH_SYSTEM_PROMPT.trim().to_string(),
user_prompt,
)
.await?;
return normalize_polished_prompt(&reply)
.ok_or_else(|| "AI 润色没有返回可用文本".to_string());
}
let mut llm = app_config.llm.clone();
llm.max_retries = 0;
let client = build_game_creator_llm_client_from_llm_config(&llm, "llm")?;
let request = LlmRunRequest::single_turn(
LOCAL_PROJECT_PROMPT_POLISH_SYSTEM_PROMPT.trim(),
user_prompt,
)
.with_api_kind(parse_game_creator_llm_api_kind(&llm.api_kind)?)
.with_model(llm.model.clone())
.with_request_timeout_ms(llm.request_timeout_ms)
.with_max_output_tokens(LOCAL_PROJECT_PROMPT_POLISH_MAX_OUTPUT_TOKENS)
.with_web_search(false);
let response = client
.run(request)
.await
.map_err(|error| format!("AI 润色失败:{error}"))?;
normalize_polished_prompt(&response.text).ok_or_else(|| "AI 润色没有返回可用文本".to_string())
}
#[cfg(test)]
mod local_project_prompt_polish_tests {
use super::*;
#[test]
fn rejects_empty_prompt() {
assert!(build_local_project_prompt_polish_prompt(" ", None).is_err());
}
#[test]
fn appends_optional_project_context() {
let prompt =
build_local_project_prompt_polish_prompt("做个跳跃游戏", Some(" 像素风 ")).unwrap();
assert_eq!(prompt, "做个跳跃游戏\n\n当前项目上下文:\n像素风");
let without_context =
build_local_project_prompt_polish_prompt("做个跳跃游戏", None).unwrap();
assert_eq!(without_context, "做个跳跃游戏");
let blank_context =
build_local_project_prompt_polish_prompt("做个跳跃游戏", Some(" ")).unwrap();
assert_eq!(blank_context, "做个跳跃游戏");
}
#[test]
fn truncates_prompt_and_context_to_their_limits() {
let long_prompt = "".repeat(LOCAL_PROJECT_PROMPT_POLISH_MAX_PROMPT_CHARS + 32);
let long_context = "".repeat(LOCAL_PROJECT_PROMPT_POLISH_MAX_CONTEXT_CHARS + 32);
let prompt =
build_local_project_prompt_polish_prompt(&long_prompt, Some(&long_context)).unwrap();
let (body, context) = prompt.split_once("\n\n当前项目上下文:\n").unwrap();
assert_eq!(
body.chars().count(),
LOCAL_PROJECT_PROMPT_POLISH_MAX_PROMPT_CHARS
);
assert_eq!(
context.chars().count(),
LOCAL_PROJECT_PROMPT_POLISH_MAX_CONTEXT_CHARS
);
}
#[test]
fn empty_model_reply_is_not_accepted_as_polished_text() {
assert_eq!(normalize_polished_prompt(" \n "), None);
assert_eq!(
normalize_polished_prompt(" 做个像素风跳跃游戏 \n").as_deref(),
Some("做个像素风跳跃游戏")
);
}
}
#[derive(Clone, Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
pub(crate) struct AssetImportRequirements {
@@ -848,242 +971,45 @@ pub(crate) fn update_local_project_resource_canvas_layout(
)
}
/// 读取引用了某个素材的项目版本,供删除弹窗提示「被哪些版本使用」。
#[tauri::command]
pub(crate) fn create_local_project_asset_canvas_draft(
input: CreateAssetCanvasDraftInput,
) -> Result<CreateAssetCanvasDraftResult, String> {
let root = validated_local_project_directory_path(input.project_path.trim())?;
create_asset_canvas_draft_at(&root, &input)
pub(crate) fn read_local_project_asset_references(
input: ReadLocalProjectAssetReferencesInput,
) -> Result<ReadLocalProjectAssetReferencesResult, String> {
let root = Path::new(input.project_path.trim());
enforce_project_permission_policy(root, "asset.list")?;
read_manifest_asset_references_at(root, &input.asset_id)
}
/// 删除一个素材登记,并按用户确认决定是否连带删除引用它的游戏版本。
///
/// 素材不可变:只摘掉 manifest 登记,磁盘文件保留。
#[tauri::command]
pub(crate) fn read_local_project_asset_canvas_draft(
input: ReadAssetCanvasDraftInput,
) -> Result<ReadAssetCanvasDraftResult, String> {
let root = validated_local_project_directory_path(input.project_path.trim())?;
read_asset_canvas_draft_at(&root, &input)
pub(crate) fn delete_local_project_asset(
input: DeleteLocalProjectAssetInput,
) -> Result<DeleteLocalProjectAssetResult, String> {
let root = Path::new(input.project_path.trim());
enforce_project_permission_policy(root, "asset.register")?;
delete_manifest_asset_at(
root,
&input.expected_project_id,
input.expected_project_revision,
&input.asset_id,
input.delete_referenced_versions,
)
}
/// 重命名一个已登记素材:磁盘文件改名 + 更新 manifest 的 `localPath`,资产 `id` 不变。
///
/// 只允许在资产当前所在目录内改名,扩展名必须一致,同目录不得已有同名文件;manifest 写失败
/// 时把文件改回原名,不留半成品。
#[tauri::command]
pub(crate) fn discover_local_project_asset_canvas_draft(
input: DiscoverAssetCanvasDraftInput,
) -> Result<DiscoverAssetCanvasDraftResult, String> {
let root = validated_local_project_directory_path(input.project_path.trim())?;
discover_asset_canvas_draft_at(&root, &input)
}
#[tauri::command]
pub(crate) fn update_local_project_asset_canvas_draft(
input: UpdateAssetCanvasDraftInput,
) -> Result<UpdateAssetCanvasDraftResult, String> {
let root = validated_local_project_directory_path(input.project_path.trim())?;
update_asset_canvas_draft_at(&root, &input)
}
#[tauri::command]
pub(crate) fn acknowledge_local_project_asset_canvas_candidate_layers(
input: AcknowledgeAssetCanvasCandidateLayersInput,
) -> Result<AcknowledgeAssetCanvasCandidateLayersResult, String> {
let root = validated_local_project_directory_path(input.project_path.trim())?;
acknowledge_candidate_layers_at(&root, &input)
}
#[tauri::command]
pub(crate) async fn import_local_project_asset_canvas_images(
app: tauri::AppHandle,
input: ImportAssetCanvasImagesInput,
) -> Result<ImportAssetCanvasImagesResult, String> {
let root = validated_local_project_directory_path(input.project_path.trim())?;
let (sender, receiver) = tokio::sync::oneshot::channel();
let mut dialog = app
.dialog()
.file()
.set_title("导入图片")
.add_filter("图片", &["png", "jpg", "jpeg", "webp"]);
if let Some(window) = app.get_webview_window("client") {
dialog = dialog.set_parent(&window);
}
dialog.pick_files(move |paths| {
let _ = sender.send(paths);
});
let Some(paths) = receiver
.await
.map_err(|_| "图片文件选择器意外关闭".to_string())?
else {
return Ok(ImportAssetCanvasImagesResult::cancelled());
};
if paths.is_empty() {
return Ok(ImportAssetCanvasImagesResult::cancelled());
}
let paths = paths
.into_iter()
.map(|path| {
path.into_path()
.map_err(|error| format!("读取导入图片路径失败:{error}"))
})
.collect::<Result<Vec<_>, _>>()?;
tokio::task::spawn_blocking(move || import_asset_canvas_images_at(&root, &input, &paths))
.await
.map_err(|error| format!("图片导入任务异常结束:{error}"))?
}
#[tauri::command]
pub(crate) fn store_local_project_asset_canvas_media(
input: StoreAssetCanvasMediaInput,
) -> Result<StoreAssetCanvasMediaResult, String> {
let root = validated_local_project_directory_path(input.project_path.trim())?;
store_asset_canvas_media_at(&root, &input)
}
#[tauri::command]
pub(crate) fn stage_local_project_asset_canvas_image(
input: StageAssetCanvasImageInput,
) -> Result<StageAssetCanvasImageResult, String> {
let root = validated_local_project_directory_path(input.project_path.trim())?;
stage_asset_canvas_image_at(&root, &input)
}
#[tauri::command]
pub(crate) async fn archive_failed_local_project_asset_canvas_generation(
input: ArchiveAssetCanvasGenerationInput,
) -> Result<ArchiveAssetCanvasGenerationResult, String> {
let root = validated_local_project_directory_path(input.project_path.trim())?;
archive_failed_asset_canvas_generation_at(&root, &input).await
}
#[tauri::command]
pub(crate) async fn generate_local_project_asset_canvas_image(
app: tauri::AppHandle,
input: GenerateAssetCanvasImageInput,
) -> Result<GenerateAssetCanvasImageResult, String> {
let root = validated_local_project_directory_path(input.project_path.trim())?;
let progress_app = app.clone();
let execution = generate_asset_canvas_image_at(&root, &input, move |payload| {
let _ = progress_app.emit(ASSET_CANVAS_GENERATION_PROGRESS_EVENT, payload);
})
.await?;
if let Some(event) = execution.event.as_ref() {
publish_asset_canvas_event_after_commit_at(&root, event, |payload| {
app.emit(
"game-creator-local-asset-committed",
asset_canvas_committed_public_event(payload),
)
.map_err(|error| error.to_string())
});
}
Ok(execution.result)
}
#[tauri::command]
pub(crate) async fn finalize_local_project_asset_canvas_generation_failure(
input: FinalizeAssetCanvasGenerationFailureInput,
) -> Result<FinalizeAssetCanvasGenerationFailureResult, String> {
let root = validated_local_project_directory_path(input.project_path.trim())?;
finalize_asset_canvas_generation_failure_at(&root, &input).await
}
#[tauri::command]
pub(crate) async fn recover_local_project_asset_canvas_generations(
app: tauri::AppHandle,
input: RecoverAssetCanvasGenerationsInput,
) -> Result<RecoverAssetCanvasGenerationsResult, String> {
let root = validated_local_project_directory_path(input.project_path.trim())?;
let progress_app = app.clone();
let execution = recover_asset_canvas_generations_at(&root, &input, move |payload| {
let _ = progress_app.emit(ASSET_CANVAS_GENERATION_PROGRESS_EVENT, payload);
})
.await?;
for event in &execution.events {
publish_asset_canvas_event_after_commit_at(&root, event, |payload| {
app.emit(
"game-creator-local-asset-committed",
asset_canvas_committed_public_event(payload),
)
.map_err(|error| error.to_string())
});
}
Ok(execution.result)
}
#[tauri::command]
pub(crate) async fn confirm_local_project_asset_canvas_generation_service_identity(
input: ConfirmAssetCanvasGenerationServiceIdentityInput,
) -> Result<ConfirmAssetCanvasGenerationServiceIdentityResult, String> {
let root = validated_local_project_directory_path(input.project_path.trim())?;
confirm_asset_canvas_generation_service_identity_at(&root, &input).await
}
#[tauri::command]
pub(crate) fn read_local_project_asset_canvas_media(
input: ReadAssetCanvasMediaInput,
) -> Result<ReadAssetCanvasMediaResult, String> {
let root = validated_local_project_directory_path(input.project_path.trim())?;
read_asset_canvas_media_at(&root, &input)
}
#[tauri::command]
pub(crate) fn discard_local_project_asset_canvas_draft(
input: DiscardAssetCanvasDraftInput,
) -> Result<DiscardAssetCanvasDraftResult, String> {
let root = validated_local_project_directory_path(input.project_path.trim())?;
discard_asset_canvas_draft_at(&root, &input)
}
#[tauri::command]
pub(crate) fn commit_local_project_asset(
app: tauri::AppHandle,
input: CommitAssetCanvasInput,
) -> Result<CommitAssetCanvasResult, String> {
let root = validated_local_project_directory_path(input.project_path.trim())?;
let execution = commit_asset_canvas_at(&root, &input)?;
if let Some(event) = execution.event.as_ref() {
publish_asset_canvas_event_after_commit_at(&root, event, |payload| {
app.emit(
"game-creator-local-asset-committed",
asset_canvas_committed_public_event(payload),
)
.map_err(|error| error.to_string())
});
}
Ok(execution.result)
}
#[tauri::command]
pub(crate) fn commit_local_project_asset_canvas_candidate(
app: tauri::AppHandle,
input: CommitAssetCanvasCandidateInput,
) -> Result<CommitAssetCanvasResult, String> {
let root = validated_local_project_directory_path(input.project_path.trim())?;
let execution = commit_asset_canvas_candidate_at(&root, &input)?;
if let Some(event) = execution.event.as_ref() {
publish_asset_canvas_event_after_commit_at(&root, event, |payload| {
app.emit(
"game-creator-local-asset-committed",
asset_canvas_committed_public_event(payload),
)
.map_err(|error| error.to_string())
});
}
Ok(execution.result)
}
#[tauri::command]
pub(crate) fn recover_local_project_asset_canvas_transactions(
app: tauri::AppHandle,
input: RecoverAssetCanvasTransactionsInput,
) -> Result<RecoverAssetCanvasTransactionsResult, String> {
let root = validated_local_project_directory_path(input.project_path.trim())?;
let execution = recover_asset_canvas_transactions_at(&root, &input.expected_project_id)?;
for event in &execution.events {
publish_asset_canvas_event_after_commit_at(&root, event, |payload| {
app.emit(
"game-creator-local-asset-committed",
asset_canvas_committed_public_event(payload),
)
.map_err(|error| error.to_string())
});
}
Ok(execution.result)
pub(crate) fn rename_local_project_asset(
input: RenameLocalProjectAssetInput,
) -> Result<RenameLocalProjectAssetResult, String> {
let root = Path::new(input.project_path.trim());
enforce_project_permission_policy(root, "asset.register")?;
rename_local_project_asset_at(root, &input.asset_id, &input.new_file_name, None)
}
#[tauri::command]
@@ -1865,6 +1791,16 @@ pub(crate) async fn suggest_automatic_project_name(
.map_err(|error| redact_agent_runtime_error(Path::new("."), &error, 320))
}
#[tauri::command]
pub(crate) async fn polish_local_project_prompt(
prompt: String,
context: Option<String>,
) -> Result<String, String> {
request_local_project_prompt_polish(prompt.trim(), context.as_deref())
.await
.map_err(|error| redact_agent_runtime_error(Path::new("."), &error, 320))
}
#[tauri::command]
pub(crate) fn read_platform_account_session_generation() -> u64 {
current_platform_session_generation()
@@ -2166,6 +2102,22 @@ pub(crate) fn create_ui_design_resource(
})
}
#[tauri::command]
pub(crate) fn update_local_project_resource_classification(
input: UpdateLocalProjectResourceClassificationInput,
) -> Result<UpdateLocalProjectResourceClassificationResult, String> {
let root = Path::new(input.project_path.trim());
enforce_project_permission_policy(root, "asset.register")?;
update_manifest_asset_classification_at(
root,
&input.expected_project_id,
input.expected_project_revision,
&input.asset_id,
&input.category,
input.tags,
)
}
#[tauri::command]
pub(crate) async fn derive_local_project_resource(
input: DeriveLocalProjectResourceInput,
@@ -2791,6 +2743,8 @@ mod ui_editor_font_tests {
local_path: relative_path.to_string(),
image_sequence_frames: None,
image_sequence_duration_ms: None,
category: game_creation_app_asset_category_for_kind("font"),
tags: Vec::new(),
source: GameCreationAppAssetSource {
kind: GameCreationAppAssetSourceKind::Uploaded,
canvas_project_id: None,
@@ -4378,6 +4332,12 @@ pub(crate) fn read_local_project_file(
read_local_project_file_at(root, &normalized_path)
}
#[tauri::command]
pub(crate) fn save_local_project_asset_file(
input: SaveLocalProjectAssetFileInput,
) -> Result<SaveLocalProjectAssetFileResult, String> {
save_local_project_asset_file_at(input)
}
#[tauri::command]
pub(crate) async fn read_local_project_image_preview(
preview_manager: tauri::State<'_, ProjectResourcePreviewReadManager>,
@@ -26,10 +26,11 @@ use serde::{Deserialize, Serialize};
use sha2::Digest;
use shared_contracts::error_reports::ErrorReportLogInput;
use shared_contracts::game_creation_app::{
game_creation_app_asset_category_for_kind, game_creation_app_asset_category_from_str,
new_game_creation_app_manifest, new_game_creation_app_seed_tasks,
validate_game_iteration_versions, GameCreationAgentArtifactTrace,
GameCreationAgentCapabilityDescriptor, GameCreationAgentPassPlanTrace,
GameCreationAgentRepairRouteTrace, GameCreationAgentRunStep,
normalize_game_creation_app_asset_tags, validate_game_iteration_versions,
GameCreationAgentArtifactTrace, GameCreationAgentCapabilityDescriptor,
GameCreationAgentPassPlanTrace, GameCreationAgentRepairRouteTrace, GameCreationAgentRunStep,
GameCreationAgentRunTaskGraphTrace, GameCreationAgentRunTrace, GameCreationAgentToolCallTrace,
GameCreationAppAgentGroup, GameCreationAppAssetManifestEntry, GameCreationAppAssetSource,
GameCreationAppAssetSourceKind, GameCreationAppCommandRunState,
@@ -2562,6 +2563,7 @@ fn main() {
pick_local_project_directory,
rename_local_game_project,
suggest_automatic_project_name,
polish_local_project_prompt,
pick_local_file,
pick_client_extension_file,
pick_client_extension_directory,
@@ -2613,6 +2615,7 @@ fn main() {
upload_local_asset,
register_local_asset,
create_ui_design_resource,
update_local_project_resource_classification,
derive_local_project_resource,
list_pending_local_project_resource_edits,
resume_local_project_resource_edit,
@@ -2645,6 +2648,7 @@ fn main() {
import_local_project_image_assets,
read_local_project_file,
read_local_project_image_preview,
save_local_project_asset_file,
read_local_project_text_preview,
read_local_project_media_preview,
cancel_local_project_resource_preview_scope,
@@ -2683,24 +2687,9 @@ fn main() {
read_local_project_resource_canvas_layout,
read_local_project_resource_graph,
update_local_project_resource_canvas_layout,
create_local_project_asset_canvas_draft,
read_local_project_asset_canvas_draft,
discover_local_project_asset_canvas_draft,
update_local_project_asset_canvas_draft,
acknowledge_local_project_asset_canvas_candidate_layers,
import_local_project_asset_canvas_images,
store_local_project_asset_canvas_media,
stage_local_project_asset_canvas_image,
finalize_local_project_asset_canvas_generation_failure,
archive_failed_local_project_asset_canvas_generation,
generate_local_project_asset_canvas_image,
recover_local_project_asset_canvas_generations,
confirm_local_project_asset_canvas_generation_service_identity,
read_local_project_asset_canvas_media,
discard_local_project_asset_canvas_draft,
recover_local_project_asset_canvas_transactions,
commit_local_project_asset,
commit_local_project_asset_canvas_candidate,
delete_local_project_asset,
read_local_project_asset_references,
rename_local_project_asset,
get_local_game_project_revision,
get_local_game_manifest,
download_agc_update,
@@ -488,6 +488,113 @@ const PREVIEW_FIT_BRIDGE_SCRIPT: &str = r#"(() => {
if (!document.hidden) schedule();
}, 500);
};
const inspectMessageType = 'genarrative.local-preview-inspect.v1';
const inspectState = { enabled: false, overlay: null, current: null };
const inspectOverlay = () => {
if (inspectState.overlay) return inspectState.overlay;
const overlay = document.createElement('div');
overlay.dataset.genarrativePreviewInspect = 'true';
overlay.style.cssText = 'position:fixed;z-index:2147483647;pointer-events:none;border:2px solid #f97316;background:rgba(249,115,22,0.14);box-shadow:inset 0 0 0 1px rgba(255,255,255,0.85);';
(document.body || document.documentElement).appendChild(overlay);
inspectState.overlay = overlay;
return overlay;
};
const clearInspectOverlay = () => {
inspectState.overlay?.remove();
inspectState.overlay = null;
inspectState.current = null;
};
const inspectElementSummary = (element) => {
const rect = element.getBoundingClientRect();
const tag = element.tagName ? element.tagName.toLowerCase() : 'div';
const role = (element.getAttribute && element.getAttribute('role')) || '';
const text = (element.innerText || element.textContent || '')
.replace(/\s+/g, ' ')
.trim()
.slice(0, 120);
const resourceIds = [];
if (element.getAttribute) {
for (const name of ['data-resource-id', 'data-asset-id', 'data-genarrative-resource-id']) {
const value = element.getAttribute(name);
if (value && !resourceIds.includes(value)) resourceIds.push(value);
}
}
const media = element.querySelector ? element.querySelector('img,video,audio') : null;
const rawSource =
(element.getAttribute && element.getAttribute('src')) ||
(media && media.getAttribute && media.getAttribute('src')) ||
'';
let sourcePath = '';
if (rawSource) {
try {
sourcePath = new URL(rawSource, window.location.href).pathname;
} catch {
sourcePath = '';
}
}
return {
label: text || tag,
elementTag: tag,
elementRole: role.slice(0, 80),
text,
width: Math.max(1, Math.round(rect.width)),
height: Math.max(1, Math.round(rect.height)),
resourceIds,
sourcePath,
};
};
const postInspect = (action, selection) => {
window.parent.postMessage({ type: inspectMessageType, action, selection }, '*');
};
const stopInspect = (cancelled) => {
if (!inspectState.enabled) return;
inspectState.enabled = false;
document.removeEventListener('mousemove', handleInspectMove, true);
document.removeEventListener('click', handleInspectClick, true);
document.removeEventListener('keydown', handleInspectKeyDown, true);
clearInspectOverlay();
postInspect(cancelled ? 'cancelled' : 'disabled');
};
const handleInspectMove = (event) => {
if (!inspectState.enabled) return;
const target = event.target;
if (!(target instanceof Element)) return;
inspectState.current = target;
const rect = target.getBoundingClientRect();
const overlay = inspectOverlay();
overlay.style.left = `${rect.left}px`;
overlay.style.top = `${rect.top}px`;
overlay.style.width = `${rect.width}px`;
overlay.style.height = `${rect.height}px`;
};
const handleInspectClick = (event) => {
if (!inspectState.enabled) return;
event.preventDefault();
event.stopPropagation();
const target = event.target;
if (target instanceof Element) postInspect('selected', inspectElementSummary(target));
stopInspect(false);
};
const handleInspectKeyDown = (event) => {
if (!inspectState.enabled || event.key !== 'Escape') return;
event.preventDefault();
stopInspect(true);
};
const startInspect = () => {
if (inspectState.enabled) return;
inspectState.enabled = true;
document.addEventListener('mousemove', handleInspectMove, true);
document.addEventListener('click', handleInspectClick, true);
document.addEventListener('keydown', handleInspectKeyDown, true);
postInspect('enabled');
};
window.addEventListener('message', (event) => {
if (event.source !== window.parent) return;
const data = event.data;
if (!data || data.type !== inspectMessageType) return;
if (data.action === 'enable') startInspect();
else if (data.action === 'disable') stopInspect(false);
});
if (ready) activate();
else window.addEventListener('load', activate, { once: true });
})();"#;
@@ -4,7 +4,8 @@ use similar::TextDiff;
use std::io::{Seek, SeekFrom};
mod agent_db;
mod asset_canvas;
mod asset_export;
mod asset_rename;
mod checkpoint;
mod conversation;
mod export;
@@ -18,7 +19,8 @@ mod resource_layout;
mod verification;
pub(crate) use agent_db::*;
pub(crate) use asset_canvas::*;
pub(crate) use asset_export::*;
pub(crate) use asset_rename::*;
pub(crate) use checkpoint::*;
pub(crate) use conversation::*;
pub(crate) use export::*;
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,255 @@
use super::*;
use std::fs::File;
use std::io::{Read, Write};
/// 显式保存:把项目内已登记的素材文件复制到用户选定的目标路径。
///
/// 这里刻意不做"浏览器下载"——AGC 是 Tauri/WebView2 宿主,未注册 `on_download`
/// 时 `<a download>` 能否落盘不可靠,所以保存路径由原生对话框给出,复制由 Rust 完成。
const ASSET_EXPORT_COPY_CHUNK_BYTES: usize = 64 * 1024;
#[derive(Clone, Debug, Deserialize)]
#[serde(deny_unknown_fields, rename_all = "camelCase")]
pub(crate) struct SaveLocalProjectAssetFileInput {
pub(crate) project_path: String,
pub(crate) relative_path: String,
pub(crate) destination_path: String,
}
#[derive(Clone, Debug, PartialEq, Serialize)]
#[serde(rename_all = "camelCase")]
pub(crate) struct SaveLocalProjectAssetFileResult {
pub(crate) destination_path: String,
pub(crate) byte_len: u64,
}
fn resolve_export_source_file(
root: &Path,
relative_path: &str,
) -> Result<PathBuf, String> {
let normalized = normalize_relative_path(relative_path.trim())?;
if normalized.is_empty() {
return Err("待保存的素材路径不能为空".to_string());
}
let source = root.join(&normalized);
// 只允许保存项目根内真实存在的普通文件:符号链接与目录都要拒绝。
let metadata = std::fs::symlink_metadata(&source)
.map_err(|error| format!("素材文件不存在或不可读:{error}"))?;
if metadata.file_type().is_symlink() {
return Err("素材路径不能是符号链接".to_string());
}
if !metadata.is_file() {
return Err("素材路径必须是普通文件".to_string());
}
Ok(source)
}
fn resolve_export_destination_file(destination_path: &str) -> Result<PathBuf, String> {
let trimmed = destination_path.trim();
if trimmed.is_empty() {
return Err("保存目标路径不能为空".to_string());
}
let destination = PathBuf::from(trimmed);
if destination.file_name().is_none() {
return Err("保存目标必须是文件路径".to_string());
}
let parent = destination
.parent()
.filter(|parent| !parent.as_os_str().is_empty())
.ok_or_else(|| "保存目标缺少父目录".to_string())?;
if !parent.is_dir() {
return Err("保存目标的父目录不存在".to_string());
}
if destination.is_dir() {
return Err("保存目标不能是目录".to_string());
}
Ok(destination)
}
pub(crate) fn save_local_project_asset_file_at(
input: SaveLocalProjectAssetFileInput,
) -> Result<SaveLocalProjectAssetFileResult, String> {
let root = Path::new(input.project_path.trim());
validate_project_root(root)?;
enforce_project_auto_permission_policy(root, "file.read")?;
let source = resolve_export_source_file(root, &input.relative_path)?;
let destination = resolve_export_destination_file(&input.destination_path)?;
let mut reader =
File::open(&source).map_err(|error| format!("打开素材文件失败:{error}"))?;
let mut writer = File::create(&destination)
.map_err(|error| format!("创建保存目标失败:{error}"))?;
let mut buffer = vec![0_u8; ASSET_EXPORT_COPY_CHUNK_BYTES];
let mut byte_len = 0_u64;
loop {
let read = reader
.read(&mut buffer)
.map_err(|error| format!("读取素材文件失败:{error}"))?;
if read == 0 {
break;
}
writer
.write_all(&buffer[..read])
.map_err(|error| format!("写入保存目标失败:{error}"))?;
byte_len += read as u64;
}
writer
.flush()
.map_err(|error| format!("刷新保存目标失败:{error}"))?;
Ok(SaveLocalProjectAssetFileResult {
destination_path: destination.to_string_lossy().into_owned(),
byte_len,
})
}
#[cfg(test)]
mod tests {
use super::*;
use std::sync::atomic::{AtomicU64, Ordering};
static NEXT_ASSET_EXPORT_TEST_ID: AtomicU64 = AtomicU64::new(0);
fn unique_asset_export_directory(prefix: &str) -> PathBuf {
std::env::temp_dir().join(format!(
"genarrative-asset-export-{prefix}-{}-{}",
std::process::id(),
NEXT_ASSET_EXPORT_TEST_ID.fetch_add(1, Ordering::Relaxed)
))
}
fn create_export_project() -> (PathBuf, PathBuf) {
let root = unique_asset_export_directory("project");
let assets = root.join("assets");
std::fs::create_dir_all(&assets).expect("create project assets directory");
(root, assets)
}
#[test]
fn copies_registered_asset_to_chosen_destination() {
let (root, assets) = create_export_project();
let source_bytes = b"genarrative-asset-export-payload".to_vec();
std::fs::write(assets.join("hero.png"), &source_bytes).expect("write source asset");
let destination_directory = unique_asset_export_directory("destination");
std::fs::create_dir_all(&destination_directory).expect("create destination directory");
let destination = destination_directory.join("hero-copy.png");
let result = save_local_project_asset_file_at(SaveLocalProjectAssetFileInput {
project_path: root.to_string_lossy().into_owned(),
relative_path: "assets/hero.png".to_string(),
destination_path: destination.to_string_lossy().into_owned(),
})
.expect("save asset file");
assert_eq!(result.byte_len, source_bytes.len() as u64);
assert_eq!(result.destination_path, destination.to_string_lossy());
assert_eq!(
std::fs::read(&destination).expect("read copied asset"),
source_bytes
);
// 源素材保持不变:保存只是复制,不改动项目内文件。
assert_eq!(
std::fs::read(assets.join("hero.png")).expect("read source asset"),
source_bytes
);
let _ = std::fs::remove_dir_all(&root);
let _ = std::fs::remove_dir_all(&destination_directory);
}
#[test]
fn rejects_missing_source_asset() {
let (root, _assets) = create_export_project();
let destination_directory = unique_asset_export_directory("destination");
std::fs::create_dir_all(&destination_directory).expect("create destination directory");
let error = save_local_project_asset_file_at(SaveLocalProjectAssetFileInput {
project_path: root.to_string_lossy().into_owned(),
relative_path: "assets/missing.png".to_string(),
destination_path: destination_directory
.join("missing-copy.png")
.to_string_lossy()
.into_owned(),
})
.expect_err("missing source must fail");
assert!(error.contains("素材文件不存在或不可读"), "{error}");
let _ = std::fs::remove_dir_all(&root);
let _ = std::fs::remove_dir_all(&destination_directory);
}
#[test]
fn rejects_directory_as_source_asset() {
let (root, assets) = create_export_project();
std::fs::create_dir_all(assets.join("nested")).expect("create nested directory");
let destination_directory = unique_asset_export_directory("destination");
std::fs::create_dir_all(&destination_directory).expect("create destination directory");
let error = save_local_project_asset_file_at(SaveLocalProjectAssetFileInput {
project_path: root.to_string_lossy().into_owned(),
relative_path: "assets/nested".to_string(),
destination_path: destination_directory
.join("nested-copy")
.to_string_lossy()
.into_owned(),
})
.expect_err("directory source must fail");
assert!(error.contains("必须是普通文件"), "{error}");
let _ = std::fs::remove_dir_all(&root);
let _ = std::fs::remove_dir_all(&destination_directory);
}
#[test]
fn rejects_blank_destination_and_missing_parent_directory() {
let (root, assets) = create_export_project();
std::fs::write(assets.join("hero.png"), b"payload").expect("write source asset");
let blank = save_local_project_asset_file_at(SaveLocalProjectAssetFileInput {
project_path: root.to_string_lossy().into_owned(),
relative_path: "assets/hero.png".to_string(),
destination_path: " ".to_string(),
})
.expect_err("blank destination must fail");
assert!(blank.contains("保存目标路径不能为空"), "{blank}");
let missing_parent_directory = unique_asset_export_directory("absent");
let missing_parent = save_local_project_asset_file_at(
SaveLocalProjectAssetFileInput {
project_path: root.to_string_lossy().into_owned(),
relative_path: "assets/hero.png".to_string(),
destination_path: missing_parent_directory
.join("hero-copy.png")
.to_string_lossy()
.into_owned(),
},
)
.expect_err("missing parent directory must fail");
assert!(
missing_parent.contains("保存目标的父目录不存在"),
"{missing_parent}"
);
let _ = std::fs::remove_dir_all(&root);
}
#[test]
fn rejects_destination_that_is_an_existing_directory() {
let (root, assets) = create_export_project();
std::fs::write(assets.join("hero.png"), b"payload").expect("write source asset");
let destination_directory = unique_asset_export_directory("destination");
std::fs::create_dir_all(&destination_directory).expect("create destination directory");
let error = save_local_project_asset_file_at(SaveLocalProjectAssetFileInput {
project_path: root.to_string_lossy().into_owned(),
relative_path: "assets/hero.png".to_string(),
destination_path: destination_directory.to_string_lossy().into_owned(),
})
.expect_err("directory destination must fail");
assert!(error.contains("保存目标不能是目录"), "{error}");
let _ = std::fs::remove_dir_all(&root);
let _ = std::fs::remove_dir_all(&destination_directory);
}
}
@@ -0,0 +1,256 @@
use super::*;
use super::filesystem::validate_portable_project_path_component;
/// 素材重命名的入参。
///
/// 只收"新文件名"而不是新旧两个全路径:改名被限制在资产当前所在目录内,目录由 manifest
/// 里的 `localPath` 决定,调用方无法指定目标目录。
#[derive(Clone, Debug, Deserialize)]
#[serde(deny_unknown_fields, rename_all = "camelCase")]
pub(crate) struct RenameLocalProjectAssetInput {
pub(crate) project_path: String,
pub(crate) asset_id: String,
pub(crate) new_file_name: String,
}
#[derive(Clone, Debug, Eq, PartialEq, Serialize)]
#[serde(rename_all = "camelCase")]
pub(crate) struct RenameLocalProjectAssetResult {
pub(crate) asset: GameCreationAppAssetManifestEntry,
pub(crate) previous_local_path: String,
pub(crate) committed_project_revision: u64,
}
/// 故障注入点:让测试在不依赖只读路径的前提下验证"manifest 写失败必须回滚文件改名"。
/// 生产路径始终传 `None`。
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub(crate) enum RenameLocalProjectAssetFaultStage {
/// 文件已改名、manifest 尚未写入时失败。
ManifestWrite,
}
/// 校验调用方给出的新文件名,返回 trim 后的名字。
///
/// 判据(按顺序):
/// - 非空;
/// - 不含路径分隔符(`/`、`\`)——这同时保证"不跨目录",改名只落在资产当前所在目录内;
/// - 不含 `..`——分隔符已被拒绝后,`..` 仍可能以"整个名字就是 `..`"的形式改变层级,一律拒绝;
/// - 通过项目路径组件的可移植性校验(控制字符、Windows 保留字符、结尾点或空格、保留设备名),
/// 与项目内其它写入路径保持同一套路径安全口径。
fn normalize_asset_file_name(value: &str) -> Result<String, String> {
let name = value.trim();
if name.is_empty() {
return Err("新文件名不能为空".to_string());
}
if name.contains('/') || name.contains('\\') {
return Err("新文件名不能包含路径分隔符".to_string());
}
if name.contains("..") {
return Err("新文件名不能包含 ..".to_string());
}
validate_portable_project_path_component(name)
.map_err(|error| format!("新文件名非法:{error}"))?;
Ok(name.to_string())
}
/// 取小写扩展名。`.gitignore` 这类隐藏文件按"无扩展名"处理。
fn asset_file_extension(file_name: &str) -> Option<String> {
Path::new(file_name)
.extension()
.and_then(std::ffi::OsStr::to_str)
.map(str::to_ascii_lowercase)
}
/// 一个帧的 `imageSrc` 是否就是被改名的那份本地文件。
///
/// 判据:既不是绝对路径、也不是带 scheme 的远程地址、也不是反斜杠路径;去掉目录后文件名与旧名
/// 一致(大小写不敏感,和扩展名比较同口径)。只认真正指向该文件的帧,同目录但指向其它文件的帧
/// 保持原样,避免改名连带改坏别的引用。
fn asset_local_frame_matches(image_src: &str, directory: &str, previous_file_name: &str) -> bool {
let image_src = image_src.trim();
if image_src.is_empty()
|| image_src.starts_with('/')
|| image_src.contains('\\')
|| image_src.contains("://")
|| Path::new(image_src).is_absolute()
{
return false;
}
let (frame_directory, frame_file_name) = match image_src.rsplit_once('/') {
Some((directory, file_name)) => (directory, file_name),
None => ("", image_src),
};
frame_directory == directory && frame_file_name.eq_ignore_ascii_case(previous_file_name)
}
/// 把 manifest 条目里指向被改名文件的序列帧对齐到新 `localPath`。
fn align_image_sequence_frames(
asset: &mut GameCreationAppAssetManifestEntry,
directory: &str,
previous_file_name: &str,
next_local_path: &str,
) {
let Some(frames) = asset.image_sequence_frames.as_mut() else {
return;
};
for frame in frames.iter_mut() {
if !asset_local_frame_matches(&frame.image_src, directory, previous_file_name) {
continue;
}
frame.image_src = next_local_path.to_string();
}
}
/// manifest 写失败后的回滚:把文件改回原名,并把回滚失败单独报出来。
fn rollback_asset_file_rename(
current_absolute: &Path,
next_absolute: &Path,
error: String,
) -> String {
match fs::rename(next_absolute, current_absolute) {
Ok(()) => error,
Err(rollback_error) => format!(
"{error}reconciliation-required: 素材文件未能改回原名:{} -> {}: {rollback_error}",
next_absolute.display(),
current_absolute.display()
),
}
}
/// 重命名一个已登记素材:**文件改名 + 更新 manifest 的 `localPath`,资产 `id` 不变**。
///
/// manifest 资产条目没有 `name` 字段,资源显示名来自 `fileName(localPath)`,因此重命名的语义
/// 只能是"改文件名 + 改 `localPath`"。
///
/// 事务顺序(全程持既有项目写锁,期间不会出现第二个项目写者;manifest 写入仍走既有边界:
/// 版本数组不可变校验 + 安装后回读一致性校验):
/// 1. 读 manifest 定位资产,取旧 `localPath`
/// 2. 校验新文件名、扩展名一致、目标不冲突,然后 `rename` 磁盘文件;
/// 3. 更新 manifest 的 `localPath``id` / `kind` / `mediaType` / `source` / `category` / `tags`
/// 全部不变),并把 `imageSequenceFrames` 里指向该文件的帧对齐到新路径;
/// 4. 写 manifest
/// 5. 第 4 步失败时把文件改回原名,不留"文件已改名但 manifest 还是旧路径"的半成品;
/// 改回原名也失败时,两个错误都报出来并标记 reconciliation-required。
pub(crate) fn rename_local_project_asset_at(
root: &Path,
asset_id: &str,
new_file_name: &str,
fault: Option<RenameLocalProjectAssetFaultStage>,
) -> Result<RenameLocalProjectAssetResult, String> {
let asset_id = asset_id.trim();
if asset_id.is_empty() {
return Err("素材重命名 assetId 不能为空".to_string());
}
let new_file_name = normalize_asset_file_name(new_file_name)?;
let _lock = acquire_project_write_lock(root, "asset.register")?;
let manifest_path = root.join(".agent/manifest.json");
let mut manifest = read_existing_manifest_for_project(root)?;
let index = manifest
.assets
.iter()
.position(|asset| asset.id == asset_id)
.ok_or_else(|| format!("项目资源不存在:{asset_id}"))?;
let previous_local_path = manifest.assets[index].local_path.clone();
let (directory, current_file_name) = match previous_local_path.rsplit_once('/') {
Some((directory, file_name)) => (directory.to_string(), file_name.to_string()),
None => (String::new(), previous_local_path.clone()),
};
// 同名重命名是空操作:磁盘与 manifest 都不动,直接回报当前状态。
if current_file_name == new_file_name {
return Ok(RenameLocalProjectAssetResult {
asset: manifest.assets[index].clone(),
previous_local_path,
committed_project_revision: read_game_creator_agent_runtime_project_revision(root)?
.revision,
});
}
let current_extension = asset_file_extension(&current_file_name);
let next_extension = asset_file_extension(&new_file_name);
if current_extension != next_extension {
return Err(format!(
"新文件名扩展名必须与原文件一致,原扩展名:{}",
current_extension
.map(|extension| format!(".{extension}"))
.unwrap_or_else(|| "".to_string())
));
}
let next_local_path = normalize_relative_path(&if directory.is_empty() {
new_file_name.clone()
} else {
format!("{directory}/{new_file_name}")
})?;
let current_absolute = resolve_local_project_path(root, &previous_local_path)?;
let next_absolute = resolve_local_project_path(root, &next_local_path)?;
// 被改名的必须是真的普通文件:登记与磁盘不一致时先报错,不写出指向"新名字"的悬空登记。
match fs::symlink_metadata(&current_absolute) {
Ok(metadata) if metadata.is_file() && !metadata.file_type().is_symlink() => {}
Ok(_) => return Err(format!("素材路径必须是普通文件:{previous_local_path}")),
Err(error) if error.kind() == std::io::ErrorKind::NotFound => {
return Err(format!("素材文件不存在:{previous_local_path}"));
}
Err(error) => {
return Err(format!(
"读取素材元数据失败:{}: {error}",
current_absolute.display()
));
}
}
// 同目录内不得已存在同名文件:先检查再改名,避免 `rename` 覆盖既有文件。
// 只差文件名大小写的改名在大小写不敏感的文件系统上会命中同一条检查,按"同名冲突"拒绝。
match fs::symlink_metadata(&next_absolute) {
Ok(_) => return Err(format!("同目录已存在同名文件:{new_file_name}")),
Err(error) if error.kind() == std::io::ErrorKind::NotFound => {}
Err(error) => {
return Err(format!(
"读取目标素材元数据失败:{}: {error}",
next_absolute.display()
));
}
}
fs::rename(&current_absolute, &next_absolute).map_err(|error| {
format!(
"素材文件改名失败:{} -> {}: {error}",
current_absolute.display(),
next_absolute.display()
)
})?;
manifest.assets[index].local_path = next_local_path.clone();
align_image_sequence_frames(
&mut manifest.assets[index],
&directory,
&current_file_name,
&next_local_path,
);
let asset = manifest.assets[index].clone();
// 文件已改名:从这里开始的任何失败都必须把文件改回原名。
let write_error = match fault {
Some(RenameLocalProjectAssetFaultStage::ManifestWrite) => {
Some("fault-injected:rename-asset-manifest-write".to_string())
}
None => write_manifest(&manifest_path, &manifest).err(),
};
if let Some(error) = write_error {
return Err(rollback_asset_file_rename(
&current_absolute,
&next_absolute,
error,
));
}
let committed_project_revision = advance_agent_runtime_project_revision_locked(root)
.map_err(|error| format!("素材已改名,但项目 revision 未能推进:{error}"))?;
Ok(RenameLocalProjectAssetResult {
asset,
previous_local_path,
committed_project_revision,
})
}
@@ -718,6 +718,23 @@ pub(crate) fn read_existing_manifest_for_project(
Ok(manifest)
}
/// 构造正式版本的资源绑定。
///
/// 绑定即资源记录:`slotId` 恒为恒等映射 `asset:{assetId}``resourceId` 指向 manifest 资产 ID
/// 顺序与 `manifest.assets` 一致。要更换某个版本使用的资源,就改这里写进该版本的绑定。
pub(crate) fn game_iteration_resource_bindings(
manifest: &GameCreationAppManifest,
) -> Vec<GameIterationVersionResourceBinding> {
manifest
.assets
.iter()
.map(|asset| GameIterationVersionResourceBinding {
slot_id: format!("asset:{}", asset.id),
resource_id: asset.id.clone(),
})
.collect()
}
/// Registers the first formally playable project version after the current
/// revision has produced a durable successful browser-playtest receipt.
/// Replays are idempotent: once any formal version exists, validation never
@@ -733,14 +750,7 @@ pub(crate) fn ensure_initial_game_iteration_version_at(
if !manifest.versions.is_empty() {
return Ok(false);
}
let resource_bindings = manifest
.assets
.iter()
.map(|asset| GameIterationVersionResourceBinding {
slot_id: format!("asset:{}", asset.id),
resource_id: asset.id.clone(),
})
.collect();
let resource_bindings = game_iteration_resource_bindings(&manifest);
manifest.versions.push(GameIterationVersion {
version_id: format!("initial-{project_revision}"),
parent_version_id: None,
@@ -779,14 +789,7 @@ pub(crate) fn append_agent_game_iteration_version_at(
return Err("Agent 项目版本必须绑定大于 0 的项目 revision".to_string());
}
mutate_manifest_at(root, |manifest| {
let resource_bindings = manifest
.assets
.iter()
.map(|asset| GameIterationVersionResourceBinding {
slot_id: format!("asset:{}", asset.id),
resource_id: asset.id.clone(),
})
.collect::<Vec<_>>();
let resource_bindings = game_iteration_resource_bindings(manifest);
if let Some(existing) = manifest
.versions
.iter()
@@ -1082,6 +1085,85 @@ pub(crate) fn update_manifest_task_status_at(
})
}
#[derive(Clone, Debug, Deserialize)]
#[serde(deny_unknown_fields, rename_all = "camelCase")]
pub(crate) struct UpdateLocalProjectResourceClassificationInput {
pub(crate) project_path: String,
pub(crate) expected_project_id: String,
pub(crate) expected_project_revision: u64,
pub(crate) asset_id: String,
pub(crate) category: String,
#[serde(default)]
pub(crate) tags: Vec<String>,
}
#[derive(Clone, Debug, PartialEq, Serialize)]
#[serde(rename_all = "camelCase")]
pub(crate) struct UpdateLocalProjectResourceClassificationResult {
pub(crate) asset: GameCreationAppAssetManifestEntry,
pub(crate) committed_project_revision: u64,
}
/// 只更新单个 manifest 资产条目的功能分类与自定义标签,保持条目字段构成与顺序不变。
/// `category` 必须是 6 个合法值之一,非法值直接失败、不回退到 `kind` 派生;
/// `tags` 按既有归一化语义 trim、去空、去重。
pub(crate) fn update_manifest_asset_classification_at(
root: &Path,
expected_project_id: &str,
expected_project_revision: u64,
asset_id: &str,
category: &str,
tags: Vec<String>,
) -> Result<UpdateLocalProjectResourceClassificationResult, 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_id = asset_id.trim();
if asset_id.is_empty() {
return Err("资源分类 assetId 不能为空".to_string());
}
let category = game_creation_app_asset_category_from_str(category)
.ok_or_else(|| format!("非法资源分类:{}", category.trim()))?;
let tags = normalize_game_creation_app_asset_tags(&tags);
if read_existing_manifest_for_project(root)?.project_id != expected_project_id {
return Err("project-identity-conflict".to_string());
}
let _lock = acquire_project_write_lock(root, "asset.register")?;
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());
}
let asset = mutate_manifest_at(root, |manifest| {
let Some(asset) = manifest
.assets
.iter_mut()
.find(|asset| asset.id == asset_id)
else {
return Err(format!("项目资源不存在:{asset_id}"));
};
asset.category = category;
asset.tags = tags;
Ok(asset.clone())
})?;
let committed_project_revision = advance_agent_runtime_project_revision_locked(root)
.map_err(|error| format!("资源分类已写入,但项目 revision 未能推进:{error}"))?;
Ok(UpdateLocalProjectResourceClassificationResult {
asset,
committed_project_revision,
})
}
pub(crate) fn create_manifest_task_at(
root: &Path,
task_id: &str,
@@ -1252,6 +1334,19 @@ pub(crate) fn read_or_create_manifest(
pub(crate) fn mutate_manifest_at<T>(
root: &Path,
mutate: impl FnOnce(&mut GameCreationAppManifest) -> Result<T, String>,
) -> Result<T, String> {
mutate_manifest_at_allowing_version_removals(root, &no_version_removals, mutate)
}
/// 同 [`mutate_manifest_at`],但允许显式放行的既有项目版本从版本数组里消失。
///
/// `allowed_version_removals` 按**写入前的 manifest** 求值,只有它返回的版本 ID 才允许被删除;
/// 其余既有版本仍必须原样、原顺序保留,新增版本只能追加在末尾。这是"项目版本记录写入后不可
/// 修改、删除或重排"校验唯一被放开的入口。
pub(crate) fn mutate_manifest_at_allowing_version_removals<T>(
root: &Path,
allowed_version_removals: &dyn Fn(&GameCreationAppManifest) -> Vec<String>,
mutate: impl FnOnce(&mut GameCreationAppManifest) -> Result<T, String>,
) -> Result<T, String> {
if root.as_os_str().is_empty() {
return Err("项目目录不能为空".to_string());
@@ -1267,10 +1362,37 @@ pub(crate) fn mutate_manifest_at<T>(
let _write_lock = acquire_manifest_write_lock(&manifest_path)?;
let (_, mut manifest) = read_or_create_manifest(root)?;
let result = mutate(&mut manifest)?;
write_manifest_locked(&manifest_path, &manifest)?;
write_manifest_locked(&manifest_path, &manifest, allowed_version_removals)?;
Ok(result)
}
fn no_version_removals(_manifest: &GameCreationAppManifest) -> Vec<String> {
Vec::new()
}
/// 项目版本数组的写入边界:既有版本只允许按显式放行的 ID 删除,其余必须原样保留且只能追加。
fn validate_version_records_are_append_only(
existing: &[GameIterationVersion],
candidate: &[GameIterationVersion],
allowed_version_removals: &[String],
) -> Result<(), String> {
let violation = || "项目版本记录写入后不可修改、删除或重排".to_string();
let mut candidate_versions = candidate.iter();
for existing_version in existing
.iter()
.filter(|version| !allowed_version_removals.contains(&version.version_id))
{
if candidate_versions.next() != Some(existing_version) {
return Err(violation());
}
}
// 放行删除的版本只能真的消失:不得出现在追加段里被改写成"删除后重排"。
if candidate_versions.any(|version| allowed_version_removals.contains(&version.version_id)) {
return Err(violation());
}
Ok(())
}
fn manifest_backup_path(path: &Path) -> PathBuf {
path.with_file_name(format!(
".{}.previous",
@@ -1453,12 +1575,13 @@ pub(crate) fn write_manifest(
path: &Path,
manifest: &GameCreationAppManifest,
) -> Result<(), String> {
write_manifest_with_lock_hook(path, manifest, || {})
write_manifest_with_lock_hook(path, manifest, &no_version_removals, || {})
}
fn write_manifest_with_lock_hook<F>(
path: &Path,
manifest: &GameCreationAppManifest,
allowed_version_removals: &dyn Fn(&GameCreationAppManifest) -> Vec<String>,
after_lock: F,
) -> Result<(), String>
where
@@ -1472,25 +1595,25 @@ where
}
let _write_lock = acquire_manifest_write_lock(path)?;
after_lock();
write_manifest_locked(path, manifest)
write_manifest_locked(path, manifest, allowed_version_removals)
}
fn write_manifest_locked(path: &Path, manifest: &GameCreationAppManifest) -> Result<(), String> {
fn write_manifest_locked(
path: &Path,
manifest: &GameCreationAppManifest,
allowed_version_removals: &dyn Fn(&GameCreationAppManifest) -> Vec<String>,
) -> Result<(), String> {
validate_game_iteration_versions(&manifest.versions)
.map_err(|error| format!("校验 manifest 项目版本失败:{error}"))?;
let payload = serde_json::to_string_pretty(manifest)
.map_err(|error| format!("序列化 manifest 失败:{error}"))?;
if manifest_storage_exists(path)? {
let existing = read_manifest(path)?;
if existing.versions.len() > manifest.versions.len()
|| existing
.versions
.iter()
.zip(&manifest.versions)
.any(|(existing, candidate)| existing != candidate)
{
return Err("项目版本记录写入后不可修改、删除或重排".to_string());
}
validate_version_records_are_append_only(
&existing.versions,
&manifest.versions,
&allowed_version_removals(&existing),
)?;
}
match fs::symlink_metadata(path) {
Ok(metadata) if metadata.file_type().is_symlink() || !metadata.is_file() => {
@@ -1567,6 +1690,8 @@ pub(crate) fn trim_optional_string(value: Option<String>) -> Option<String> {
.filter(|value| !value.is_empty())
}
#[cfg(test)]
mod classification_tests;
#[cfg(test)]
mod import_tests;
#[cfg(test)]
@@ -0,0 +1,279 @@
use super::*;
use shared_contracts::game_creation_app::GameCreationAppAssetCategory;
fn classification_test_root(test_name: &str) -> PathBuf {
std::env::temp_dir().join(format!(
"genarrative-manifest-classification-{test_name}-{}-{}",
std::process::id(),
SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap_or_default()
.as_nanos()
))
}
fn project_with_two_assets(test_name: &str) -> (PathBuf, String) {
let root = classification_test_root(test_name);
init_local_game_project_at(&root, "classification-project", "资源分类测试")
.expect("initialize classification project");
let first = register_local_asset_entry(
&root,
"assets/hero.png",
"character",
"image/png",
"asset",
GameCreationAppAssetSource {
kind: GameCreationAppAssetSourceKind::Uploaded,
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(),
},
)
.expect("register first asset");
register_local_asset_entry(
&root,
"assets/theme.mp3",
"background-music",
"audio/mpeg",
"asset",
GameCreationAppAssetSource {
kind: GameCreationAppAssetSourceKind::Uploaded,
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(),
},
)
.expect("register second asset");
(root, first.id)
}
#[test]
fn asset_classification_update_writes_category_and_normalized_tags() {
let (root, asset_id) = project_with_two_assets("update-success");
let project_id = read_existing_manifest_for_project(&root)
.expect("read manifest")
.project_id;
let revision_before = read_game_creator_agent_runtime_project_revision(&root)
.expect("read revision before")
.revision;
let result = update_manifest_asset_classification_at(
&root,
&project_id,
revision_before,
&asset_id,
"scene",
vec![
" 场景 ".to_string(),
String::new(),
" ".to_string(),
"场景".to_string(),
"主舞台".to_string(),
],
)
.expect("update classification");
assert_eq!(result.asset.id, asset_id);
assert_eq!(result.asset.category, GameCreationAppAssetCategory::Scene);
assert_eq!(result.asset.tags, vec!["场景", "主舞台"]);
assert_eq!(result.committed_project_revision, revision_before + 1);
assert_eq!(
read_game_creator_agent_runtime_project_revision(&root)
.expect("read revision after")
.revision,
revision_before + 1
);
let manifest = read_existing_manifest_for_project(&root).expect("read manifest after");
let persisted = manifest
.assets
.iter()
.find(|asset| asset.id == asset_id)
.expect("persisted asset");
assert_eq!(persisted.category, GameCreationAppAssetCategory::Scene);
assert_eq!(persisted.tags, vec!["场景", "主舞台"]);
assert_eq!(persisted.kind, "character");
assert_eq!(persisted.local_path, "assets/hero.png");
}
#[test]
fn asset_classification_rejects_invalid_category_without_falling_back() {
let (root, asset_id) = project_with_two_assets("invalid-category");
let project_id = read_existing_manifest_for_project(&root)
.expect("read manifest")
.project_id;
let revision_before = read_game_creator_agent_runtime_project_revision(&root)
.expect("read revision before")
.revision;
let before = read_existing_manifest_for_project(&root).expect("read manifest before");
for invalid in ["", " props ", "image", "UI-INTERACTION", "future-category"] {
let error = update_manifest_asset_classification_at(
&root,
&project_id,
revision_before,
&asset_id,
invalid,
Vec::new(),
)
.expect_err("invalid category must fail");
assert!(
error.contains("非法资源分类"),
"unexpected error for {invalid:?}: {error}"
);
}
assert_eq!(
read_existing_manifest_for_project(&root).expect("read manifest after"),
before
);
assert_eq!(
read_game_creator_agent_runtime_project_revision(&root)
.expect("read revision after")
.revision,
revision_before
);
}
#[test]
fn asset_classification_update_leaves_other_assets_and_fields_untouched() {
let (root, asset_id) = project_with_two_assets("isolated-write");
let project_id = read_existing_manifest_for_project(&root)
.expect("read manifest")
.project_id;
let revision_before = read_game_creator_agent_runtime_project_revision(&root)
.expect("read revision before")
.revision;
let before = read_existing_manifest_for_project(&root).expect("read manifest before");
update_manifest_asset_classification_at(
&root,
&project_id,
revision_before,
&asset_id,
"ui-interaction",
vec!["入口".to_string()],
)
.expect("update classification");
let after = read_existing_manifest_for_project(&root).expect("read manifest after");
let mut expected = before.clone();
let mut expected_assets = expected.assets.clone();
let target = expected_assets
.iter_mut()
.find(|asset| asset.id == asset_id)
.expect("expected target asset");
target.category = GameCreationAppAssetCategory::UiInteraction;
target.tags = vec!["入口".to_string()];
expected.assets = expected_assets;
assert_eq!(after, expected);
assert_eq!(after.versions, before.versions);
assert_eq!(after.tasks, before.tasks);
assert_eq!(after.name, before.name);
assert_eq!(after.project_id, before.project_id);
}
#[test]
fn asset_classification_update_keeps_revision_cas_semantics() {
let (root, asset_id) = project_with_two_assets("revision-cas");
let project_id = read_existing_manifest_for_project(&root)
.expect("read manifest")
.project_id;
let revision_before = read_game_creator_agent_runtime_project_revision(&root)
.expect("read revision before")
.revision;
let stale = update_manifest_asset_classification_at(
&root,
&project_id,
revision_before + 1,
&asset_id,
"scene",
Vec::new(),
)
.expect_err("stale revision must fail");
assert_eq!(stale, "project-revision-conflict");
let identity = update_manifest_asset_classification_at(
&root,
"other-project",
revision_before,
&asset_id,
"scene",
Vec::new(),
)
.expect_err("foreign project identity must fail");
assert_eq!(identity, "project-identity-conflict");
let missing = update_manifest_asset_classification_at(
&root,
&project_id,
revision_before,
"missing-asset",
"scene",
Vec::new(),
)
.expect_err("missing asset must fail");
assert!(missing.contains("项目资源不存在"), "unexpected: {missing}");
let before_failed = read_existing_manifest_for_project(&root).expect("read manifest");
assert_eq!(
read_game_creator_agent_runtime_project_revision(&root)
.expect("read revision")
.revision,
revision_before
);
update_manifest_asset_classification_at(
&root,
&project_id,
revision_before,
&asset_id,
"document",
vec!["说明".to_string()],
)
.expect("valid update after rejected attempts");
assert_eq!(
read_game_creator_agent_runtime_project_revision(&root)
.expect("read revision after")
.revision,
revision_before + 1
);
assert_eq!(
before_failed
.assets
.iter()
.find(|asset| asset.id == asset_id)
.expect("asset before")
.category,
GameCreationAppAssetCategory::Character
);
}
#[test]
fn asset_classification_input_rejects_unknown_fields() {
let input = serde_json::from_value::<UpdateLocalProjectResourceClassificationInput>(
serde_json::json!({
"projectPath": "C:/project",
"expectedProjectId": "project",
"expectedProjectRevision": 1,
"assetId": "asset-1",
"category": "scene",
"unexpected": true,
}),
);
assert!(input.is_err());
}
@@ -185,6 +185,8 @@ fn successful_first_playable_registration_creates_one_initial_version_with_asset
local_path: "assets/player.png".to_string(),
image_sequence_frames: None,
image_sequence_duration_ms: None,
category: game_creation_app_asset_category_for_kind("character"),
tags: Vec::new(),
source: GameCreationAppAssetSource {
kind: GameCreationAppAssetSourceKind::Generated,
canvas_project_id: None,
@@ -285,7 +287,7 @@ fn concurrent_manifest_write_cannot_overwrite_an_installed_version_with_a_stale_
let (release_newer_tx, release_newer_rx) = mpsc::channel();
let newer_path = manifest_path.clone();
let newer_writer = std::thread::spawn(move || {
write_manifest_with_lock_hook(&newer_path, &newer_manifest, || {
write_manifest_with_lock_hook(&newer_path, &newer_manifest, &no_version_removals, || {
newer_locked_tx
.send(())
.expect("signal newer lock acquired");
@@ -400,6 +400,12 @@ pub(crate) fn build_project_resource_graph(
.push(resource_ids[0].clone());
}
let resources_by_manifest_asset_id = resource_ids_by_manifest_asset
.iter()
.filter(|(_, resource_ids)| resource_ids.len() == 1)
.map(|(asset_id, resource_ids)| (asset_id.clone(), resource_ids[0].clone()))
.collect::<BTreeMap<_, _>>();
let mut unresolved_reference_resource_ids = BTreeSet::new();
let mut reference_edge_by_id = BTreeMap::<String, ProjectResourceReferenceEdge>::new();
for (asset_id, target_resource_ids) in &resource_ids_by_manifest_asset {
@@ -418,16 +424,32 @@ pub(crate) fn build_project_resource_graph(
.filter(|value| !value.is_empty())
.collect::<BTreeSet<_>>()
{
let source_candidates = resources_by_external_id
let mut source_candidates = resources_by_external_id
.get(external_reference_id)
.map(Vec::as_slice)
.unwrap_or(&[]);
.unwrap_or(&[])
.iter()
.collect::<BTreeSet<_>>();
if let Some(referenced_asset_id) = external_reference_id
.strip_prefix("local-asset:")
.map(str::trim)
.filter(|value| !value.is_empty())
{
if let Some(source_resource_id) =
resources_by_manifest_asset_id.get(referenced_asset_id)
{
source_candidates.insert(source_resource_id);
}
}
if source_candidates.len() != 1 {
unresolved_reference_resource_ids.insert(external_reference_id.to_string());
continue;
}
let source_resource_id = &source_candidates[0];
if !resource_by_id.contains_key(source_resource_id)
let source_resource_id = source_candidates
.iter()
.next()
.expect("a non-empty candidate set must have one resource");
if !resource_by_id.contains_key(source_resource_id.as_str())
|| !resource_by_id.contains_key(target_resource_id)
{
continue;
@@ -438,7 +460,7 @@ pub(crate) fn build_project_resource_graph(
ProjectResourceReferenceEdge {
id,
kind: "asset-reference".to_string(),
source_resource_id: source_resource_id.clone(),
source_resource_id: source_resource_id.to_string(),
target_resource_id: target_resource_id.clone(),
cyclic: false,
},
@@ -635,6 +657,8 @@ mod tests {
local_path: format!("assets/{id}.png"),
image_sequence_frames: None,
image_sequence_duration_ms: None,
category: game_creation_app_asset_category_for_kind("test"),
tags: Vec::new(),
source: GameCreationAppAssetSource {
kind: GameCreationAppAssetSourceKind::Canvas,
canvas_project_id: None,
@@ -835,6 +859,75 @@ mod tests {
);
}
#[test]
fn graph_resolves_local_asset_reference_identities() {
let manifest = manifest(
Vec::new(),
vec![
asset("source-1", None, &[], None),
asset(
"derivative-1",
Some("local-asset:derivative-1"),
&["local-asset:source-1"],
None,
),
],
);
let graph = build_project_resource_graph(
&manifest,
vec![
resource("asset:source-1", Some("source-1"), None),
resource("asset:derivative-1", Some("derivative-1"), None),
],
&[],
false,
);
assert_eq!(graph.reference_edges.len(), 1);
assert_eq!(
graph.reference_edges[0].source_resource_id,
"asset:source-1"
);
assert_eq!(
graph.reference_edges[0].target_resource_id,
"asset:derivative-1"
);
assert!(graph.unresolved_reference_resource_ids.is_empty());
assert!(graph
.connection_index
.iter()
.any(|index| index.resource_id == "asset:derivative-1"
&& index.upstream_reference_resource_ids == vec!["asset:source-1"]));
}
#[test]
fn graph_resolves_local_asset_identity_without_ambiguous_remote_duplicate() {
let manifest = manifest(
Vec::new(),
vec![
asset("source-1", Some("external-source"), &[], None),
asset(
"derivative-1",
Some("local-asset:derivative-1"),
&["local-asset:source-1"],
None,
),
],
);
let graph = build_project_resource_graph(
&manifest,
vec![
resource("asset:source-1", Some("source-1"), None),
resource("asset:derivative-1", Some("derivative-1"), None),
],
&[],
false,
);
assert_eq!(graph.reference_edges.len(), 1);
assert!(graph.unresolved_reference_resource_ids.is_empty());
}
#[test]
fn graph_aggregates_flows_filters_missing_resources_and_detects_cycles_iteratively() {
let manifest = manifest(
@@ -2457,6 +2457,75 @@ fn is_external_resource_edit_endpoint(endpoint: &str) -> bool {
)
}
/// 从服务端 4xx 响应体里取出**可读的失败原因**,让用户看到的不是笼统的 HTTP 状态码。
///
/// 平台错误体在不同路由上可能是 `{error:{message,details:{message}}}`、`{error:"文本"}`、
/// `{details:{message}}` 或顶层 `{message}`。**`details.message` 优先**:平台把所有 4xx 都
/// 兜底成同一条通用文案(`http_error.rs` 的 `resolve_http_error`),真实原因写在
/// `details.message` 里(例如「当前素材类型不支持图片快速编辑」),只读 `error.message`
/// 会让用户永远看到「请求参数不合法」。
async fn editor_api_rejection_reason(response: reqwest::Response) -> Option<String> {
let body = response.text().await.ok()?;
editor_api_rejection_reason_from_body(body.as_str())
}
fn editor_api_rejection_reason_from_body(body: &str) -> Option<String> {
let value: serde_json::Value = serde_json::from_str(body.trim()).ok()?;
let error = value.get("error");
let details = value.get("details");
let error_details = details.or_else(|| error.and_then(|error| error.get("details")));
// 取值顺序:`error.details.message` → `details.message` → `error.message` → `error` 文本
// → 顶层 `message`。前两者才是平台写进去的具体原因,后两者只是兜底。
let candidate = error_details
.and_then(|error_details| error_details.get("message"))
.and_then(serde_json::Value::as_str)
.or_else(|| {
details
.and_then(|details| details.get("message"))
.and_then(serde_json::Value::as_str)
})
.or_else(|| {
error
.and_then(|error| error.get("message"))
.and_then(serde_json::Value::as_str)
})
.or_else(|| error.and_then(serde_json::Value::as_str))
.or_else(|| value.get("message").and_then(serde_json::Value::as_str))?
.trim();
if candidate.is_empty() {
return None;
}
// 平台把所有 4xx 都兜底成同一条通用文案(`http_error.rs`),稳定码和 `details` 里的
// provider / assetKind / mediaType 必须一起透出,否则排障者只能看到"请求参数不合法"。
let detail_text = |key: &str| {
error_details
.and_then(|error_details| error_details.get(key))
.and_then(serde_json::Value::as_str)
.map(str::trim)
.filter(|detail| !detail.is_empty())
};
let code = value
.get("error")
.and_then(|error| error.get("code"))
.and_then(|code| code.as_str())
.map(str::trim)
.filter(|code| !code.is_empty() && *code != "BAD_REQUEST");
let mut reason: String = candidate.chars().take(200).collect();
if let Some(provider) = detail_text("provider") {
reason.push_str(format!("provider {provider}").as_str());
}
if let Some(asset_kind) = detail_text("assetKind") {
reason.push_str(format!("|素材类型 {asset_kind}").as_str());
}
if let Some(media_type) = detail_text("mediaType") {
reason.push_str(format!("|媒体类型 {media_type}").as_str());
}
if let Some(code) = code {
reason.push_str(format!("|错误码 {code}").as_str());
}
Some(reason)
}
async fn submit_resource_edit_remote(
root: &Path,
client: &reqwest::Client,
@@ -2527,7 +2596,10 @@ async fn submit_resource_edit_remote(
if let Some(error) = post_response_session_error.as_ref() {
return Err(error.clone());
}
return Err("remote-terminal-failed: 资源编辑请求被拒绝:HTTP 400".to_string());
return Err(match editor_api_rejection_reason(response).await {
Some(reason) => format!("remote-terminal-failed: 资源编辑请求被拒绝:HTTP 400({reason}"),
None => "remote-terminal-failed: 资源编辑请求被拒绝:HTTP 400".to_string(),
});
}
if !matches!(
status,
@@ -3712,6 +3784,8 @@ pub(crate) fn normalize_local_project_raster_resource_at(
local_path: source_path,
image_sequence_frames: None,
image_sequence_duration_ms: None,
category: game_creation_app_asset_category_for_kind(source_subtype),
tags: Vec::new(),
source: GameCreationAppAssetSource {
kind: GameCreationAppAssetSourceKind::Generated,
canvas_project_id: None,
@@ -3950,13 +4024,21 @@ fn commit_resource_edit_asset_internal(
})
.transpose()?;
let image_sequence_duration_ms = ledger.remote_sequence_duration_ms;
let asset_kind = if input.edit_kind == LocalProjectResourceEditKind::CharacterAnimation {
"character-animation".to_string()
} else {
source.asset_kind.clone()
};
let category = game_creation_app_asset_category_for_kind(&asset_kind);
let asset = GameCreationAppAssetManifestEntry {
id: asset_id.clone(),
kind: source.asset_kind.clone(),
kind: asset_kind,
media_type: staged_media_type.to_string(),
local_path: relative_path.clone(),
image_sequence_frames,
image_sequence_duration_ms,
category,
tags: Vec::new(),
source: GameCreationAppAssetSource {
kind: if remote {
GameCreationAppAssetSourceKind::Canvas
@@ -5421,6 +5503,102 @@ mod tests {
);
}
/// 平台把 4xx 兜底成 `请求参数不合法`,具体原因写在 `details.message`。
/// 只读 `error.message` 会让用户永远看不到真实原因,因此这里把取值顺序钉在测试里。
#[test]
fn editor_api_rejection_reason_prefers_details_message_and_keeps_context() {
assert_eq!(
editor_api_rejection_reason_from_body(
serde_json::json!({
"error": {
"code": "BAD_REQUEST",
"message": "请求参数不合法",
"details": {
"provider": "editor-image-edit",
"message": "当前素材类型不支持图片快速编辑",
"assetKind": "art-spritesheet",
"mediaType": "image",
},
}
})
.to_string()
.as_str(),
),
Some(
"当前素材类型不支持图片快速编辑|provider editor-image-edit|素材类型 art-spritesheet|媒体类型 image"
.to_string()
),
"details.message 必须优先于通用 error.message,并附上 provider 与类型上下文"
);
assert_eq!(
editor_api_rejection_reason_from_body(
serde_json::json!({
"error": {
"code": "UPSTREAM_FAILED",
"message": "上游服务请求失败",
"details": { "message": "Provider 返回 429", "provider": "editor-image-edit" },
}
})
.to_string()
.as_str(),
),
Some("Provider 返回 429provider editor-image-edit|错误码 UPSTREAM_FAILED".to_string()),
"非 BAD_REQUEST 的稳定码照旧透出"
);
// 顶层 details(没有 error.details 的错误体)同样优先。
assert_eq!(
editor_api_rejection_reason_from_body(
serde_json::json!({ "details": { "message": "具体原因" }, "message": "通用文案" })
.to_string()
.as_str(),
),
Some("具体原因".to_string())
);
// 只有 error.message 时退回它;error 是字符串时按文本兜底。
assert_eq!(
editor_api_rejection_reason_from_body(
serde_json::json!({ "error": { "code": "BAD_REQUEST", "message": "请求参数不合法" } })
.to_string()
.as_str(),
),
Some("请求参数不合法".to_string()),
"BAD_REQUEST 不重复透出错误码"
);
assert_eq!(
editor_api_rejection_reason_from_body(serde_json::json!({ "error": "直白文本" }).to_string().as_str()),
Some("直白文本".to_string())
);
assert_eq!(
editor_api_rejection_reason_from_body(
serde_json::json!({ "message": "顶层文案" }).to_string().as_str()
),
Some("顶层文案".to_string())
);
// 空值与非 JSON 正文不给用户编造原因。
assert_eq!(
editor_api_rejection_reason_from_body(
serde_json::json!({ "error": { "message": " " } }).to_string().as_str()
),
None
);
assert_eq!(editor_api_rejection_reason_from_body("<html>502</html>"), None);
assert_eq!(editor_api_rejection_reason_from_body(""), None);
// 超长正文仍按 200 字符截断,避免把整段响应体塞进用户可见文案。
let long = "".repeat(400);
let reason = editor_api_rejection_reason_from_body(
serde_json::json!({ "error": { "details": { "message": long.clone() } } })
.to_string()
.as_str(),
)
.expect("超长正文仍应给出原因");
assert_eq!(reason, "".repeat(200));
}
async fn with_test_external_editor_credentials<T>(
api_base_url: &str,
api_key: &str,
@@ -8967,6 +9145,72 @@ mod tests {
);
}
#[test]
fn character_animation_commit_uses_animation_asset_kind() {
let directory = tempfile::tempdir().expect("create animation commit fixture");
let root = directory.path();
init_local_game_project_at(root, PROJECT_ID, "角色动画提交类型测试")
.expect("initialize project");
let uploaded = upload_local_asset_at(
root,
"character-source.png",
"image/png",
&resource_editor_test_png(),
)
.expect("upload source image");
let manifest = read_existing_manifest_for_project(root).expect("read source manifest");
let source_asset = manifest
.assets
.iter()
.find(|asset| asset.id == uploaded.id)
.cloned()
.expect("find source image");
let mut request = input(
root,
Uuid::new_v4().to_string(),
LocalProjectResourceEditKind::CharacterAnimation,
format!("asset:{}", source_asset.id),
);
request.source_asset_id = Some(source_asset.id.clone());
request.source_path = Some(source_asset.local_path.clone());
request.source_media_type = Some(source_asset.media_type.clone());
request.source_subtype = Some(source_asset.kind.clone());
let source = resolve_resource_edit_source(
root,
&read_existing_manifest_for_project(root).expect("reread manifest"),
&request,
)
.expect("resolve animation source");
let mut ledger = ledger_for(&request, &source, ResourceEditLedgerPhase::MediaDownloaded);
ledger.staged_media_type = Some("video/mp4".to_string());
ledger.staged_extension = Some("mp4".to_string());
ledger.remote_sequence_duration_ms = Some(4_000);
write_resource_edit_staging(
root,
&request.operation_id,
b"\0\0\0\x18ftypisom\0\0\0\0isomiso2",
)
.expect("stage animation preview");
let result = commit_resource_edit_asset(
root,
&request,
&source,
&request.prompt,
&request.asset_name,
&mut ledger,
)
.expect("commit animation derivative");
let derivative = result.asset.expect("animation derivative");
assert_eq!(derivative.kind, "character-animation");
assert_eq!(
derivative.source.generation_kind.as_deref(),
Some("character-animation")
);
assert_eq!(derivative.image_sequence_duration_ms, Some(4_000));
}
#[test]
fn platform_session_switch_waits_until_local_asset_commit_finishes() {
let directory = tempfile::tempdir().expect("create commit account switch fixture");
@@ -0,0 +1,386 @@
use super::*;
fn asset_delete_project_fixture() -> PathBuf {
let root = unique_project_path();
init_local_game_project_at(&root, "project-1", "素材删除口径测试").expect("init project");
root
}
fn write_asset_delete_file(root: &Path, relative_path: &str, contents: &[u8]) {
let path = root.join(relative_path);
if let Some(parent) = path.parent() {
fs::create_dir_all(parent).expect("create asset delete fixture parent");
}
fs::write(&path, contents).expect("write asset delete fixture asset");
}
fn generated_asset_source(task_id: &str) -> GameCreationAppAssetSource {
GameCreationAppAssetSource {
kind: GameCreationAppAssetSourceKind::Generated,
canvas_project_id: None,
resource_id: None,
asset_object_id: None,
task_id: Some(task_id.to_string()),
prompt: None,
model: None,
generation_route: None,
generation_kind: None,
reference_resource_ids: Vec::new(),
}
}
/// 登记一个素材并返回它的 manifest 资产 ID。
fn register_asset_delete_fixture_asset(root: &Path, relative_path: &str, task_id: &str) -> String {
write_asset_delete_file(root, relative_path, relative_path.as_bytes());
register_local_asset_at(
root,
relative_path,
"character",
"image/png",
"generated",
generated_asset_source(task_id),
)
.expect("register asset delete fixture asset");
read_manifest_for_project(root)
.expect("read manifest after registration")
.assets
.iter()
.find(|asset| asset.local_path == relative_path)
.expect("asset delete fixture asset registered")
.id
.clone()
}
fn project_revision(root: &Path) -> u64 {
read_game_creator_agent_runtime_project_revision(root)
.expect("read project revision before asset delete")
.revision
}
/// 删除走 CAS:调用方拿当前 revision 发起,模拟前端的正常调用。
fn delete_asset_at_current_revision(
root: &Path,
asset_id: &str,
delete_referenced_versions: bool,
) -> Result<DeleteLocalProjectAssetResult, String> {
delete_manifest_asset_at(
root,
"project-1",
project_revision(root),
asset_id,
delete_referenced_versions,
)
}
/// 让当前 revision 成为一个正式版本,绑定当时 manifest 里的全部素材。
fn register_initial_version(root: &Path) {
let revision =
advance_agent_runtime_project_revision_locked(root).expect("advance project revision");
assert!(
ensure_initial_game_iteration_version_at(root, revision).expect("create initial version"),
"initial version should be appended"
);
}
/// 推进 durable revision 后追加一个 Agent 正式版本。
fn register_agent_version(root: &Path) {
let revision =
advance_agent_runtime_project_revision_locked(root).expect("advance project revision");
assert!(
append_agent_game_iteration_version_at(root, revision).expect("append agent version"),
"agent version should be appended"
);
}
fn version_references_asset(version: &GameIterationVersion, asset_id: &str) -> bool {
version
.resource_bindings
.iter()
.any(|binding| binding.resource_id == asset_id)
}
/// 恒等绑定就是资源记录:每个已登记素材一条 `asset:{assetId} → assetId`,顺序与资产表一致。
#[test]
fn game_iteration_bindings_are_identity_records_for_every_registered_asset() {
let root = asset_delete_project_fixture();
let first = register_asset_delete_fixture_asset(&root, "assets/hero.png", "art-asset-plan");
let second = register_asset_delete_fixture_asset(&root, "assets/scene.png", "art-scene-plan");
let manifest = read_manifest_for_project(&root).expect("read manifest with two assets");
let bindings = game_iteration_resource_bindings(&manifest);
let pairs: Vec<(String, String)> = bindings
.iter()
.map(|binding| (binding.slot_id.clone(), binding.resource_id.clone()))
.collect();
assert_eq!(
pairs,
vec![
(format!("asset:{first}"), first.clone()),
(format!("asset:{second}"), second.clone()),
]
);
register_initial_version(&root);
let versioned = read_manifest_for_project(&root).expect("read manifest with initial version");
assert_eq!(versioned.versions[0].resource_bindings, bindings);
fs::remove_dir_all(root).ok();
}
/// 未被任何版本引用的素材:直接删除登记,磁盘文件保留,revision 推进一格。
#[test]
fn asset_delete_removes_unreferenced_asset_registration() {
let root = asset_delete_project_fixture();
let asset_id = register_asset_delete_fixture_asset(&root, "assets/unused.png", "art-unused");
let revision_before = project_revision(&root);
let result = delete_asset_at_current_revision(&root, &asset_id, false)
.expect("delete unreferenced asset");
assert_eq!(result.asset_id, asset_id);
assert_eq!(result.local_path, "assets/unused.png");
assert!(result.file_retained, "素材不可变,磁盘文件必须保留");
assert_eq!(
result.committed_project_revision,
revision_before + 1,
"删除成功后项目 revision 必须推进一格"
);
assert!(
root.join("assets/unused.png").is_file(),
"删除登记不得删除磁盘文件"
);
let manifest = read_manifest_for_project(&root).expect("read manifest after delete");
assert!(
manifest.assets.iter().all(|asset| asset.id != asset_id),
"素材登记应已摘掉"
);
assert!(manifest.versions.is_empty());
fs::remove_dir_all(root).ok();
}
/// 有引用但不勾选:只删素材,引用它的版本原样保留悬空绑定,不报错、不做二次拦截。
#[test]
fn asset_delete_keeps_referencing_versions_when_not_confirmed() {
let root = asset_delete_project_fixture();
let asset_id = register_asset_delete_fixture_asset(&root, "assets/hero.png", "art-asset-plan");
register_initial_version(&root);
register_agent_version(&root);
let result = delete_asset_at_current_revision(&root, &asset_id, false)
.expect("delete referenced asset without deleting versions");
assert!(root.join(&result.local_path).is_file());
let manifest = read_manifest_for_project(&root).expect("read manifest after delete");
assert!(
manifest.assets.iter().all(|asset| asset.id != asset_id),
"素材登记应已摘掉"
);
assert_eq!(manifest.versions.len(), 2, "未勾选时两个版本都必须原样保留");
assert!(
manifest
.versions
.iter()
.all(|version| version_references_asset(version, &asset_id)),
"版本保留指向已删素材的悬空绑定"
);
// 悬空绑定不阻塞后续版本追加。
register_agent_version(&root);
assert_eq!(
read_manifest_for_project(&root)
.expect("read manifest after later version")
.versions
.len(),
3
);
fs::remove_dir_all(root).ok();
}
/// 有引用且勾选:素材与所有引用它的版本在同一次 manifest 写入里一起删除。
#[test]
fn asset_delete_removes_referencing_versions_when_confirmed() {
let root = asset_delete_project_fixture();
let kept_asset_id =
register_asset_delete_fixture_asset(&root, "assets/base.png", "art-base-plan");
register_initial_version(&root);
let removed_asset_id =
register_asset_delete_fixture_asset(&root, "assets/hero.png", "art-asset-plan");
register_agent_version(&root);
register_agent_version(&root);
let before = read_manifest_for_project(&root).expect("read manifest before delete");
assert_eq!(before.versions.len(), 3);
assert!(
!version_references_asset(&before.versions[0], &removed_asset_id),
"首个版本早于该素材,不应引用它"
);
assert_eq!(
before
.versions
.iter()
.filter(|version| version_references_asset(version, &removed_asset_id))
.count(),
2
);
let result = delete_asset_at_current_revision(&root, &removed_asset_id, true)
.expect("delete referencing asset together with its versions");
assert!(root.join(&result.local_path).is_file());
let manifest = read_manifest_for_project(&root).expect("read manifest after delete");
assert!(manifest.assets.iter().all(|asset| asset.id != removed_asset_id));
assert!(
manifest.assets.iter().any(|asset| asset.id == kept_asset_id),
"未被删除的素材登记必须保留"
);
assert_eq!(
manifest.versions.len(),
1,
"引用被删素材的版本必须一并删除:{:?}",
manifest
.versions
.iter()
.map(|version| version.version_id.clone())
.collect::<Vec<_>>()
);
assert!(
manifest
.versions
.iter()
.all(|version| !version_references_asset(version, &removed_asset_id)),
"剩余版本不得再引用被删素材"
);
assert_eq!(manifest.versions[0].version_id, before.versions[0].version_id);
fs::remove_dir_all(root).ok();
}
/// 只读投影:列出引用了该素材的版本,顺序与版本数组一致。
#[test]
fn asset_reference_projection_lists_only_versions_using_the_asset() {
let root = asset_delete_project_fixture();
let early_asset_id = register_asset_delete_fixture_asset(&root, "assets/base.png", "art-base");
register_initial_version(&root);
let late_asset_id = register_asset_delete_fixture_asset(&root, "assets/hero.png", "art-hero");
register_agent_version(&root);
let manifest = read_manifest_for_project(&root).expect("read manifest");
let late_references =
read_manifest_asset_references_at(&root, &late_asset_id).expect("read late asset references");
assert_eq!(late_references.asset_id, late_asset_id);
assert_eq!(late_references.versions.len(), 1);
assert_eq!(
late_references.versions[0].version_id,
manifest.versions[1].version_id
);
assert_eq!(
late_references.versions[0].project_revision,
manifest.versions[1].project_revision
);
assert_eq!(
late_references.versions[0].created_at,
manifest.versions[1].created_at
);
let early_references = read_manifest_asset_references_at(&root, &early_asset_id)
.expect("read early asset references");
assert_eq!(
early_references
.versions
.iter()
.map(|version| version.version_id.clone())
.collect::<Vec<_>>(),
manifest
.versions
.iter()
.map(|version| version.version_id.clone())
.collect::<Vec<_>>()
);
let absent_references =
read_manifest_asset_references_at(&root, "absent-asset").expect("read absent references");
assert!(absent_references.versions.is_empty());
let error = read_manifest_asset_references_at(&root, " ")
.expect_err("blank asset id must be rejected");
assert!(error.contains("assetId"), "unexpected error: {error}");
fs::remove_dir_all(root).ok();
}
/// 版本数组"只允许追加"校验只为显式确认的连带删除放开,其它写入路径仍然必须拒绝。
#[test]
fn version_removal_stays_rejected_outside_the_confirmed_asset_delete() {
let root = asset_delete_project_fixture();
register_asset_delete_fixture_asset(&root, "assets/base.png", "art-base");
register_initial_version(&root);
register_agent_version(&root);
let installed = fs::read(root.join(".agent/manifest.json")).expect("read installed manifest");
let error = mutate_manifest_at(&root, |manifest| {
manifest.versions.pop();
Ok(())
})
.expect_err("a plain manifest mutation must not delete versions");
assert!(error.contains("不可修改、删除或重排"), "unexpected error: {error}");
let reordered = mutate_manifest_at(&root, |manifest| {
manifest.versions.swap(0, 1);
Ok(())
})
.expect_err("a plain manifest mutation must not reorder versions");
assert!(!reordered.is_empty(), "reorder must surface a rejection");
assert_eq!(
fs::read(root.join(".agent/manifest.json")).expect("read manifest bytes after rejection"),
installed,
"被拒绝的写入不得改动 manifest"
);
fs::remove_dir_all(root).ok();
}
/// 删除同样走 CAS:陈旧 revision、跨项目身份与不存在的素材都被拒绝,manifest 与 revision 不变。
#[test]
fn asset_delete_enforces_revision_and_identity_cas() {
let root = asset_delete_project_fixture();
let asset_id = register_asset_delete_fixture_asset(&root, "assets/hero.png", "art-asset-plan");
let revision_before = project_revision(&root);
let revision_error = delete_manifest_asset_at(
&root,
"project-1",
revision_before + 1,
&asset_id,
true,
)
.expect_err("stale revision must be rejected");
assert_eq!(revision_error, "project-revision-conflict");
let identity_error =
delete_manifest_asset_at(&root, "project-2", revision_before, &asset_id, true)
.expect_err("cross-project identity must be rejected");
assert_eq!(identity_error, "project-identity-conflict");
let missing_error = delete_manifest_asset_at(&root, "project-1", revision_before, "absent", true)
.expect_err("missing asset must be rejected");
assert!(
missing_error.contains("项目资源不存在"),
"unexpected error: {missing_error}"
);
let manifest = read_manifest_for_project(&root).expect("read manifest after rejected deletes");
assert!(
manifest.assets.iter().any(|asset| asset.id == asset_id),
"失败的删除不得改动 manifest"
);
assert_eq!(
project_revision(&root),
revision_before,
"失败的删除不得推进 revision"
);
fs::remove_dir_all(root).ok();
}
@@ -0,0 +1,324 @@
use super::*;
use shared_contracts::game_creation_app::GameCreationAppImageSequenceFrame;
/// 一个已初始化项目 + 一份已登记的 `assets/hero.png`。
fn asset_rename_project_fixture() -> (PathBuf, String) {
let root = unique_project_path();
init_local_game_project_at(&root, "project-1", "像素动作原型").expect("project init");
write_local_project_file_at(&root, "assets/hero.png", "hero-bytes").expect("asset file");
let asset = register_local_asset_at(
&root,
"assets/hero.png",
"character",
"image/png",
"canvas",
asset_rename_source(),
)
.expect("asset register");
(root, asset.id)
}
fn asset_rename_source() -> GameCreationAppAssetSource {
GameCreationAppAssetSource {
kind: GameCreationAppAssetSourceKind::Canvas,
canvas_project_id: Some("canvas-project-1".to_string()),
resource_id: Some("resource-1".to_string()),
asset_object_id: Some("asset-object-1".to_string()),
task_id: Some("task-1".to_string()),
prompt: Some("像素主角".to_string()),
model: Some("image-model".to_string()),
generation_route: None,
generation_kind: None,
reference_resource_ids: Vec::new(),
}
}
fn asset_rename_entry(root: &Path, asset_id: &str) -> GameCreationAppAssetManifestEntry {
read_existing_manifest_for_project(root)
.expect("read manifest")
.assets
.into_iter()
.find(|asset| asset.id == asset_id)
.expect("asset entry")
}
fn asset_rename_revision(root: &Path) -> u64 {
read_game_creator_agent_runtime_project_revision(root)
.expect("read project revision")
.revision
}
/// 成功改名:磁盘文件真的换了名字,manifest 只改 `localPath`,资产 `id` 与其余字段不变,
/// 项目 revision 前进一格。
#[test]
fn rename_local_project_asset_renames_file_and_keeps_asset_identity() {
let (root, asset_id) = asset_rename_project_fixture();
let before = asset_rename_entry(&root, &asset_id);
let revision_before = asset_rename_revision(&root);
let result = rename_local_project_asset_at(&root, &asset_id, "hero-idle.png", None)
.expect("rename asset");
assert_eq!(result.previous_local_path, "assets/hero.png");
assert_eq!(result.asset.local_path, "assets/hero-idle.png");
assert_eq!(result.committed_project_revision, revision_before + 1);
assert_eq!(
fs::read(root.join("assets/hero-idle.png")).expect("renamed file"),
b"hero-bytes"
);
assert!(!root.join("assets/hero.png").exists());
// 只改 localPathid / kind / mediaType / source / category / tags 全部原样。
let mut expected = before.clone();
expected.local_path = "assets/hero-idle.png".to_string();
assert_eq!(result.asset, expected);
assert_eq!(result.asset.id, asset_id);
assert_eq!(asset_rename_entry(&root, &asset_id), expected);
assert_eq!(
read_existing_manifest_for_project(&root)
.expect("read manifest")
.assets
.len(),
1
);
fs::remove_dir_all(root).ok();
}
/// 同名重命名是空操作:磁盘、manifest、revision 都不动。
#[test]
fn rename_local_project_asset_is_a_noop_for_the_same_file_name() {
let (root, asset_id) = asset_rename_project_fixture();
let manifest_before = fs::read(root.join(".agent/manifest.json")).expect("manifest bytes");
let revision_before = asset_rename_revision(&root);
let result = rename_local_project_asset_at(&root, &asset_id, "hero.png", None)
.expect("no-op rename");
assert_eq!(result.previous_local_path, "assets/hero.png");
assert_eq!(result.asset.local_path, "assets/hero.png");
assert_eq!(result.committed_project_revision, revision_before);
assert_eq!(
fs::read(root.join("assets/hero.png")).expect("asset file"),
b"hero-bytes"
);
assert_eq!(
fs::read(root.join(".agent/manifest.json")).expect("manifest bytes"),
manifest_before
);
fs::remove_dir_all(root).ok();
}
/// 空名、带路径分隔符、带 `..` 一律拒绝,且磁盘与 manifest 都不动。
#[test]
fn rename_local_project_asset_rejects_unsafe_file_names() {
let (root, asset_id) = asset_rename_project_fixture();
let manifest_before = fs::read(root.join(".agent/manifest.json")).expect("manifest bytes");
for name in [
"",
" ",
"hero/idle.png",
"hero\\idle.png",
"..",
"..png",
"assets/../hero.png",
] {
let error = rename_local_project_asset_at(&root, &asset_id, name, None)
.expect_err("unsafe file name must be rejected");
assert!(
error.contains("不能为空")
|| error.contains("路径分隔符")
|| error.contains(".."),
"意外错误:{error}"
);
}
assert!(root.join("assets/hero.png").is_file());
assert_eq!(
fs::read(root.join(".agent/manifest.json")).expect("manifest bytes"),
manifest_before
);
fs::remove_dir_all(root).ok();
}
/// 跨目录改名拒绝:新名带分层目录时不可能落到资产当前目录之外,也就在原目录里找不到目标。
#[test]
fn rename_local_project_asset_rejects_cross_directory_targets() {
let (root, asset_id) = asset_rename_project_fixture();
fs::create_dir_all(root.join("assets/nested")).expect("nested dir");
let error = rename_local_project_asset_at(&root, &asset_id, "nested/hero.png", None)
.expect_err("cross directory rename must be rejected");
assert!(error.contains("路径分隔符"), "意外错误:{error}");
assert!(root.join("assets/hero.png").is_file());
assert!(!root.join("assets/nested/hero.png").exists());
assert_eq!(
asset_rename_entry(&root, &asset_id).local_path,
"assets/hero.png"
);
fs::remove_dir_all(root).ok();
}
/// 扩展名必须与原文件一致:换扩展名、去掉扩展名、给无扩展名文件加扩展名都拒绝。
#[test]
fn rename_local_project_asset_rejects_extension_changes() {
let (root, asset_id) = asset_rename_project_fixture();
for name in ["hero.jpg", "hero", "hero.png.bak"] {
let error = rename_local_project_asset_at(&root, &asset_id, name, None)
.expect_err("extension change must be rejected");
assert!(error.contains("扩展名"), "意外错误:{error}");
}
write_local_project_file_at(&root, "assets/notes", "notes-bytes").expect("extensionless file");
let extensionless = register_local_asset_at(
&root,
"assets/notes",
"asset",
"text/plain",
"canvas",
asset_rename_source(),
)
.expect("register extensionless asset");
let error = rename_local_project_asset_at(&root, &extensionless.id, "notes.txt", None)
.expect_err("adding an extension must be rejected");
assert!(error.contains("扩展名"), "意外错误:{error}");
assert!(root.join("assets/hero.png").is_file());
assert!(root.join("assets/notes").is_file());
assert_eq!(
asset_rename_entry(&root, &asset_id).local_path,
"assets/hero.png"
);
fs::remove_dir_all(root).ok();
}
/// 同目录已有同名文件时拒绝,且两个文件的内容都不受影响。
#[test]
fn rename_local_project_asset_rejects_existing_target_name() {
let (root, asset_id) = asset_rename_project_fixture();
write_local_project_file_at(&root, "assets/other.png", "other-bytes").expect("other asset file");
let manifest_before = fs::read(root.join(".agent/manifest.json")).expect("manifest bytes");
let error = rename_local_project_asset_at(&root, &asset_id, "other.png", None)
.expect_err("existing target must be rejected");
assert!(error.contains("同名文件"), "意外错误:{error}");
assert_eq!(
fs::read(root.join("assets/hero.png")).expect("hero file"),
b"hero-bytes"
);
assert_eq!(
fs::read(root.join("assets/other.png")).expect("other file"),
b"other-bytes"
);
assert_eq!(
fs::read(root.join(".agent/manifest.json")).expect("manifest bytes"),
manifest_before
);
fs::remove_dir_all(root).ok();
}
/// 目标资产必须存在:assetId 不存在、或 manifest 登记的文件在磁盘上已经没了,都拒绝。
#[test]
fn rename_local_project_asset_requires_an_existing_asset_and_file() {
let (root, asset_id) = asset_rename_project_fixture();
let error = rename_local_project_asset_at(&root, "missing-asset", "hero-idle.png", None)
.expect_err("unknown asset must be rejected");
assert!(error.contains("项目资源不存在"), "意外错误:{error}");
fs::remove_file(root.join("assets/hero.png")).expect("remove asset file");
let error = rename_local_project_asset_at(&root, &asset_id, "hero-idle.png", None)
.expect_err("missing file must be rejected");
assert!(error.contains("素材文件不存在"), "意外错误:{error}");
assert!(!root.join("assets/hero-idle.png").exists());
assert_eq!(
asset_rename_entry(&root, &asset_id).local_path,
"assets/hero.png"
);
fs::remove_dir_all(root).ok();
}
/// manifest 写失败必须把文件改回原名:磁盘、manifest、revision 三处都不留半成品。
#[test]
fn rename_local_project_asset_rolls_back_the_file_when_manifest_write_fails() {
let (root, asset_id) = asset_rename_project_fixture();
let manifest_before = fs::read(root.join(".agent/manifest.json")).expect("manifest bytes");
let revision_before = asset_rename_revision(&root);
let error = rename_local_project_asset_at(
&root,
&asset_id,
"hero-idle.png",
Some(RenameLocalProjectAssetFaultStage::ManifestWrite),
)
.expect_err("injected manifest write failure must surface");
assert!(error.contains("fault-injected"), "意外错误:{error}");
assert_eq!(
fs::read(root.join("assets/hero.png")).expect("original file restored"),
b"hero-bytes"
);
assert!(!root.join("assets/hero-idle.png").exists());
assert_eq!(
fs::read(root.join(".agent/manifest.json")).expect("manifest bytes"),
manifest_before
);
assert_eq!(asset_rename_revision(&root), revision_before);
fs::remove_dir_all(root).ok();
}
/// `imageSequenceFrames` 里指向被改名文件的本地帧对齐到新路径,指向别的文件或远程地址的帧不动。
#[test]
fn rename_local_project_asset_aligns_image_sequence_frames_of_the_renamed_file() {
let (root, asset_id) = asset_rename_project_fixture();
mutate_manifest_at(&root, |manifest| {
let asset = manifest
.assets
.iter_mut()
.find(|asset| asset.id == asset_id)
.expect("asset entry");
asset.image_sequence_frames = Some(
[
("assets/hero.png", 32u32, 32u32),
("assets/other.png", 64, 64),
("https://signed.invalid/frame-1.png", 16, 16),
]
.into_iter()
.map(
|(image_src, width, height)| GameCreationAppImageSequenceFrame {
image_src: image_src.to_string(),
object_key: None,
asset_object_id: None,
width,
height,
},
)
.collect(),
);
Ok(())
})
.expect("seed image sequence frames");
rename_local_project_asset_at(&root, &asset_id, "hero-idle.png", None).expect("rename asset");
let frames = asset_rename_entry(&root, &asset_id)
.image_sequence_frames
.expect("image sequence frames");
assert_eq!(frames[0].image_src, "assets/hero-idle.png");
assert_eq!(frames[0].width, 32);
assert_eq!(frames[1].image_src, "assets/other.png");
assert_eq!(frames[2].image_src, "https://signed.invalid/frame-1.png");
fs::remove_dir_all(root).ok();
}

Some files were not shown because too many files have changed in this diff Show More