合并 feat/agc-resource-replace:把「替换素材」接进资源工作台
Project CI / Repository checks (pull_request) Failing after 10s
Project CI / Frontend tests (pull_request) Successful in 3m14s
Project CI / Backend tests (pull_request) Failing after 11s
Project CI / Native shell tests (pull_request) Successful in 20m52s

- 解冲突 project/manifest.rs:保留版本写入公共体 write_manifest_locked_with_version_guard(版本只追加 + 绑定改写放行),并把 schema 校验并入该公共体,两侧语义都不丢
- 解冲突 shared-contracts:取当前分支的读显示口径 game_creation_app_asset_effective_category(含与 TS 交叉钉住的 EFFECTIVE_CATEGORY_CONTRACT 矩阵),同步改写 version_resource_replacement 对旧函数名的调用
- 解冲突 index.tsx:两处均为纯 import 追加,两侧都保留
- 解冲突 decision-log 与技术方案文档:两侧条目都是追加,全部保留
- 随之进入本分支:版本绑定改写窄放行通道、直接替换命令与其兼容性判据、资源卡工具条「替换素材」入口
This commit is contained in:
2026-09-12 14:19:03 +08:00
20 changed files with 3308 additions and 44 deletions
@@ -1019,6 +1019,42 @@ pub(crate) fn delete_local_project_asset(
)
}
/// 读取某个版本引用的源素材可用的替换候选,并给出后端权威的三项兼容性结论。
///
/// 只读:不改 manifest、不推进 revision。候选渲染但禁用,不在前端重算判据。
#[tauri::command]
pub(crate) fn read_local_project_version_resource_replacement_candidates(
input: ReadLocalProjectVersionReplacementCandidatesInput,
) -> Result<ReadLocalProjectVersionReplacementCandidatesResult, String> {
let root = Path::new(input.project_path.trim());
enforce_project_permission_policy(root, "asset.list")?;
read_local_project_version_replacement_candidates_at(
root,
&input.source_version_id,
&input.source_resource_id,
)
}
/// 用另一个已登记素材替换某个版本引用的素材:改 manifest 绑定,并**追加下一迭代版本**。
///
/// 可运行版本不可变(既有版本记录一个字节都不改)、不动资源文件、不建文件副本;
/// 三项兼容性必须同时为 true,否则拒绝并说明哪一项不等。CAS 失败时 manifest 与 revision 都不变。
#[tauri::command]
pub(crate) fn replace_local_project_version_resource(
input: ReplaceLocalProjectVersionResourceInput,
) -> Result<ReplaceLocalProjectVersionResourceResult, String> {
let root = Path::new(input.project_path.trim());
enforce_project_permission_policy(root, "asset.register")?;
replace_local_project_version_resource_at(
root,
&input.expected_project_id,
input.expected_project_revision,
&input.source_version_id,
&input.source_resource_id,
&input.replacement_resource_id,
)
}
/// 重命名一个已登记素材:磁盘文件改名 + 更新 manifest 的 `localPath`,资产 `id` 不变。
///
/// 只允许在资产当前所在目录内改名,扩展名必须一致,同目录不得已有同名文件;manifest 写失败
@@ -2698,6 +2698,8 @@ fn main() {
delete_local_project_asset,
read_local_project_asset_references,
rename_local_project_asset,
read_local_project_version_resource_replacement_candidates,
replace_local_project_version_resource,
get_local_game_project_revision,
get_local_game_manifest,
download_agc_update,
@@ -17,6 +17,7 @@ mod resource_dependency_graph;
mod resource_editor;
mod resource_layout;
mod verification;
mod version_resource_replacement;
mod write_lock;
pub(crate) use agent_db::*;
@@ -33,4 +34,5 @@ pub(crate) use resource_dependency_graph::*;
pub(crate) use resource_editor::*;
pub(crate) use resource_layout::*;
pub(crate) use verification::*;
pub(crate) use version_resource_replacement::*;
pub(crate) use write_lock::*;
@@ -1391,6 +1391,91 @@ fn no_version_removals(_manifest: &GameCreationAppManifest) -> Vec<String> {
Vec::new()
}
/// 同 [`mutate_manifest_at`],但允许显式放行的既有版本**只改写自己的 `resourceBindings`**。
///
/// 这是"直接替换资源"的写入通道(PRD §3.2 的直接替换口径):把某个版本的绑定从素材 A 指向
/// 素材 B,既不新增版本、也不删除版本。放行面被刻意压到最小:
///
/// - **不增不删**:候选与磁盘的版本数量必须相等;
/// - **不重排**:版本 ID 序列必须逐项相同;
/// - **只有放行 ID 允许 `resourceBindings` 不同**,其余字段(`versionId` / `parentVersionId` /
/// `projectRevision` / `createdReason` / `createdAt` / `editPrompt`)逐字段相等;
/// - 未放行的版本必须整条相等;
/// - `allowed_binding_rewrites` 按**写入前的 manifest**求值(与删除放行口同口径);
/// - 与删除放行集合**互斥**:同一版本 ID 不得既被放行删除、又被放行改写绑定。
///
/// 既有 [`mutate_manifest_at_allowing_version_removals`] 与
/// [`validate_version_records_are_append_only`] 的语义不受影响:本通道是**另一条**独立、更窄的
/// 校验路径,默认路径仍然只有"只追加 + 显式放行删除"。
pub(crate) fn mutate_manifest_allowing_version_binding_rewrites<T>(
root: &Path,
allowed_binding_rewrites: &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());
}
if !root.is_absolute() {
return Err("项目目录必须是绝对路径".to_string());
}
let manifest_path = root.join(".agent/manifest.json");
if let Some(parent) = manifest_path.parent() {
ensure_game_creator_private_directory_tree(parent, "manifest 目录")?;
prepare_game_creator_private_path_for_read(parent, true, "manifest 目录")?;
}
let _write_lock = acquire_manifest_write_lock(&manifest_path)?;
let (_, mut manifest) = read_or_create_manifest(root)?;
let allowed_binding_rewrites = allowed_binding_rewrites(&manifest);
let result = mutate(&mut manifest)?;
write_manifest_locked_with_version_guard(&manifest_path, &manifest, &|existing, candidate| {
validate_version_records_allow_binding_rewrites(
&existing.versions,
&candidate.versions,
&allowed_binding_rewrites,
&[],
)
})?;
Ok(result)
}
/// 项目版本数组的**第二条**放行通道:只放行"显式列出的版本改写自己的 `resourceBindings`"。
///
/// 与 [`validate_version_records_are_append_only`] 并列存在、互不替代:本函数不做任何"允许追加/
/// 允许删除/允许重排"的放宽,只在一个字段上开一个显式白名单窗口。
fn validate_version_records_allow_binding_rewrites(
existing: &[GameIterationVersion],
candidate: &[GameIterationVersion],
allowed_binding_rewrites: &[String],
allowed_version_removals: &[String],
) -> Result<(), String> {
let violation = || "项目版本记录写入后不可修改、删除或重排".to_string();
// 两条放行通道必须互斥:既被放行删除、又被放行改写绑定,等于同时表达"这条消失"与
// "这条保留但改绑定",语义自相矛盾,直接失败关闭。
if allowed_binding_rewrites
.iter()
.any(|version_id| allowed_version_removals.contains(version_id))
{
return Err("版本绑定改写放行与版本删除放行必须互斥".to_string());
}
// 直接替换不产生新版本,也不允许任何版本消失。
if existing.len() != candidate.len() {
return Err(violation());
}
for (existing_version, candidate_version) in existing.iter().zip(candidate.iter()) {
if existing_version.version_id != candidate_version.version_id {
return Err(violation());
}
let mut normalized = candidate_version.clone();
if allowed_binding_rewrites.contains(&existing_version.version_id) {
normalized.resource_bindings = existing_version.resource_bindings.clone();
}
if normalized != *existing_version {
return Err(violation());
}
}
Ok(())
}
/// 项目版本数组的写入边界:既有版本只允许按显式放行的 ID 删除,其余必须原样保留且只能追加。
fn validate_version_records_are_append_only(
existing: &[GameIterationVersion],
@@ -1767,6 +1852,28 @@ fn write_manifest_locked(
path: &Path,
manifest: &GameCreationAppManifest,
allowed_version_removals: &dyn Fn(&GameCreationAppManifest) -> Vec<String>,
) -> Result<(), String> {
write_manifest_locked_with_version_guard(path, manifest, &|existing, candidate| {
validate_version_records_are_append_only(
&existing.versions,
&candidate.versions,
&allowed_version_removals(existing),
)
})
}
/// 版本数组写入的公共体:把"装盘 / 回读 / 原子替换"这套存储细节与版本校验分开。
///
/// 校验器由调用方注入,所以 [`write_manifest_locked`](只追加 + 显式放行删除)与
/// [`mutate_manifest_allowing_version_binding_rewrites`](只放行显式版本的绑定改写)共用同一份
/// 装盘实现,不会出现两份略有差异的原子替换路径。存储行为与改造前逐行等价。
fn write_manifest_locked_with_version_guard(
path: &Path,
manifest: &GameCreationAppManifest,
version_guard: &dyn Fn(
&GameCreationAppManifest,
&GameCreationAppManifest,
) -> Result<(), String>,
) -> Result<(), String> {
validate_manifest_schema_version(&manifest.schema_version)
.map_err(|error| format!("校验 manifest schema 版本失败:{error}"))?;
@@ -1776,11 +1883,7 @@ fn write_manifest_locked(
.map_err(|error| format!("序列化 manifest 失败:{error}"))?;
if manifest_storage_exists(path)? {
let existing = read_manifest(path)?;
validate_version_records_are_append_only(
&existing.versions,
&manifest.versions,
&allowed_version_removals(&existing),
)?;
version_guard(&existing, manifest)?;
}
match fs::symlink_metadata(path) {
Ok(metadata) if metadata.file_type().is_symlink() || !metadata.is_file() => {
@@ -1863,6 +1966,8 @@ mod classification_tests;
mod import_tests;
#[cfg(test)]
mod recovery_tests;
#[cfg(test)]
mod version_binding_rewrite_tests;
#[cfg(test)]
mod npm_scaffold_tests {
@@ -0,0 +1,343 @@
use super::*;
use shared_contracts::game_creation_app::{
GameIterationVersion, GameIterationVersionCreatedReason, GameIterationVersionResourceBinding,
};
/// 「直接替换」的写入通道用例:只放行显式版本的 `resourceBindings` 改写,其余一切照旧失败关闭。
///
/// 这些用例同时是"只追加保证没有被这条新通道打穿"的证据:不增、不删、不重排、不改别的字段。
fn unique_binding_rewrite_root(test_name: &str) -> PathBuf {
std::env::temp_dir().join(format!(
"genarrative-binding-rewrite-{test_name}-{}-{}",
std::process::id(),
SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap_or_default()
.as_nanos()
))
}
fn binding(resource_id: &str) -> GameIterationVersionResourceBinding {
GameIterationVersionResourceBinding {
slot_id: format!("asset:{resource_id}"),
resource_id: resource_id.to_string(),
}
}
fn binding_rewrite_version(
version_id: &str,
parent_version_id: Option<&str>,
project_revision: u64,
created_reason: GameIterationVersionCreatedReason,
resource_ids: &[&str],
) -> GameIterationVersion {
GameIterationVersion {
version_id: version_id.to_string(),
parent_version_id: parent_version_id.map(str::to_string),
project_revision,
resource_bindings: resource_ids.iter().copied().map(binding).collect(),
created_reason,
created_at: project_revision,
edit_prompt: None,
}
}
/// 三版本项目:root → child → grandchild,绑定集合各不同,供"只放行一条"的对照使用。
fn binding_rewrite_fixture(test_name: &str) -> (PathBuf, PathBuf, GameCreationAppManifest) {
let root = unique_binding_rewrite_root(test_name);
let manifest_path = root.join(".agent/manifest.json");
let mut manifest = new_game_creation_app_manifest("project-binding-rewrite", "绑定改写项目");
manifest.versions = vec![
binding_rewrite_version(
"version-root",
None,
1,
GameIterationVersionCreatedReason::Initial,
&["asset-a"],
),
binding_rewrite_version(
"version-child",
Some("version-root"),
2,
GameIterationVersionCreatedReason::AgentRevision,
&["asset-a", "asset-b"],
),
binding_rewrite_version(
"version-grandchild",
Some("version-child"),
3,
GameIterationVersionCreatedReason::AgentRevision,
&["asset-a", "asset-b", "asset-c"],
),
];
write_manifest(&manifest_path, &manifest).expect("write versioned manifest fixture");
(root, manifest_path, manifest)
}
/// 放行的版本:只改自己的 `resourceBindings` 成功,其余版本与其余字段逐字不动。
#[test]
fn binding_rewrite_allows_only_the_whitelisted_version_bindings() {
let (root, manifest_path, installed) = binding_rewrite_fixture("allowed");
let version_id = "version-child".to_string();
mutate_manifest_allowing_version_binding_rewrites(
&root,
&|_manifest| vec![version_id.clone()],
|manifest| {
let version = manifest
.versions
.iter_mut()
.find(|version| version.version_id == version_id)
.expect("child version exists");
version.resource_bindings = vec![binding("asset-a"), binding("asset-b2")];
Ok(())
},
)
.expect("放行版本改写自己的绑定必须成功");
let written = read_manifest(&manifest_path).expect("read rewritten manifest");
assert_eq!(written.versions.len(), installed.versions.len(), "不得增删版本");
assert_eq!(
written.versions[0], installed.versions[0],
"未放行版本必须整条相等"
);
assert_eq!(
written.versions[2], installed.versions[2],
"未放行版本必须整条相等"
);
// 放行版本:除绑定外逐字段相等。
let rewritten = &written.versions[1];
let original = &installed.versions[1];
assert_eq!(rewritten.version_id, original.version_id);
assert_eq!(rewritten.parent_version_id, original.parent_version_id);
assert_eq!(rewritten.project_revision, original.project_revision);
assert_eq!(rewritten.created_reason, original.created_reason);
assert_eq!(rewritten.created_at, original.created_at);
assert_eq!(rewritten.edit_prompt, original.edit_prompt);
assert_eq!(
rewritten.resource_bindings,
vec![binding("asset-a"), binding("asset-b2")]
);
fs::remove_dir_all(root).ok();
}
/// 未放行的版本改绑定 → 拒绝,manifest 字节不变。
#[test]
fn binding_rewrite_rejects_versions_outside_the_whitelist() {
let (root, manifest_path, _installed) = binding_rewrite_fixture("outside-whitelist");
let before = fs::read(&manifest_path).expect("read manifest bytes before rejection");
let error = mutate_manifest_allowing_version_binding_rewrites(
&root,
&|_manifest| vec!["version-child".to_string()],
|manifest| {
// 放行的是 child,改的却是 root。
manifest.versions[0].resource_bindings = vec![binding("asset-z")];
Ok(())
},
)
.expect_err("未放行版本改绑定必须被拒绝");
assert!(error.contains("不可修改、删除或重排"), "{error}");
assert_eq!(
fs::read(&manifest_path).expect("read manifest bytes after rejection"),
before,
"被拒绝的写入不得改动 manifest"
);
fs::remove_dir_all(root).ok();
}
/// 不增不删:追加新版本、删除版本都被这条通道拒绝(它不放行任何增删)。
#[test]
fn binding_rewrite_rejects_version_additions_and_removals() {
let (root, manifest_path, _installed) = binding_rewrite_fixture("no-add-no-remove");
let before = fs::read(&manifest_path).expect("read manifest bytes before rejection");
let version_id = "version-child".to_string();
let append_error = mutate_manifest_allowing_version_binding_rewrites(
&root,
&|_manifest| vec![version_id.clone()],
|manifest| {
manifest.versions.push(binding_rewrite_version(
"version-appended",
Some("version-grandchild"),
4,
GameIterationVersionCreatedReason::AgentRevision,
&["asset-d"],
));
Ok(())
},
)
.expect_err("绑定改写通道不得追加版本");
assert!(append_error.contains("不可修改、删除或重排"), "{append_error}");
let remove_error = mutate_manifest_allowing_version_binding_rewrites(
&root,
&|_manifest| vec![version_id.clone()],
|manifest| {
manifest.versions.remove(2);
Ok(())
},
)
.expect_err("绑定改写通道不得删除版本");
assert!(remove_error.contains("不可修改、删除或重排"), "{remove_error}");
assert_eq!(
fs::read(&manifest_path).expect("read manifest bytes after rejections"),
before
);
fs::remove_dir_all(root).ok();
}
/// 不重排:版本 ID 序列一变就必须拒绝。
///
/// 集成路径上"换序"会先被版本图校验拦下(父版本必须先于子版本存在),所以这里两条都测:
/// ① 直接打校验器,精确命中"不重排"分支;② 走真实通道,确认任何换序都失败关闭且字节不变。
#[test]
fn binding_rewrite_rejects_version_reordering() {
let (root, manifest_path, installed) = binding_rewrite_fixture("no-reorder");
let before = fs::read(&manifest_path).expect("read manifest bytes before rejection");
let mut reordered = installed.versions.clone();
reordered.swap(1, 2);
let direct_error = validate_version_records_allow_binding_rewrites(
&installed.versions,
&reordered,
&[],
&[],
)
.expect_err("版本 ID 序列变化必须被拒绝");
assert!(direct_error.contains("不可修改、删除或重排"), "{direct_error}");
let channel_error = mutate_manifest_allowing_version_binding_rewrites(
&root,
&|_manifest| vec!["version-child".to_string()],
|manifest| {
manifest.versions.swap(1, 2);
Ok(())
},
)
.expect_err("绑定改写通道不得重排版本");
assert!(
channel_error.contains("项目版本"),
"拒绝原因必须仍然来自版本校验:{channel_error}"
);
assert_eq!(
fs::read(&manifest_path).expect("read manifest bytes after rejection"),
before
);
fs::remove_dir_all(root).ok();
}
/// 放行版本改**其它字段**同样拒绝(每一条都先通过版本图校验,才能精确命中改写守卫)。
#[test]
fn binding_rewrite_rejects_other_field_changes_on_the_whitelisted_version() {
let (root, manifest_path, _installed) = binding_rewrite_fixture("other-fields");
let before = fs::read(&manifest_path).expect("read manifest bytes before rejection");
let version_id = "version-child".to_string();
let allowed = |_: &GameCreationAppManifest| vec![version_id.clone()];
let cases: [(&str, fn(&mut GameCreationAppManifest)); 4] = [
("createdAt", |manifest| {
manifest.versions[1].created_at = 1;
}),
("editPrompt", |manifest| {
manifest.versions[1].edit_prompt = Some("重新出图".to_string());
}),
("parentVersionId", |manifest| {
manifest.versions[2].parent_version_id = Some("version-root".to_string());
}),
("versionId", |manifest| {
manifest.versions[2].version_id = "version-renamed".to_string();
}),
];
for (label, mutate) in cases {
let error = mutate_manifest_allowing_version_binding_rewrites(&root, &allowed, |manifest| {
mutate(manifest);
Ok(())
})
.err()
.unwrap_or_else(|| panic!("{label} 必须被拒绝"));
assert!(
error.contains("不可修改、删除或重排"),
"{label} 的拒绝原因必须来自版本改写守卫:{error}"
);
assert_eq!(
fs::read(&manifest_path).expect("read manifest bytes after rejection"),
before,
"{label} 被拒绝后字节不得变化"
);
}
fs::remove_dir_all(root).ok();
}
/// 两组放行集合必须互斥:同一版本 ID 既被放行删除、又被放行改写绑定 → 失败关闭。
#[test]
fn binding_rewrite_whitelist_must_be_disjoint_from_version_removals() {
let (root, manifest_path, installed) = binding_rewrite_fixture("disjoint-whitelists");
let before = fs::read(&manifest_path).expect("read manifest bytes before rejection");
let error = validate_version_records_allow_binding_rewrites(
&installed.versions,
&installed.versions,
&["version-child".to_string()],
&["version-child".to_string()],
)
.expect_err("两组放行集合重叠必须被拒绝");
assert!(error.contains("必须互斥"), "{error}");
// 真实写入路径也拿不到重叠放行:该通道固定传空删除集合。
assert_eq!(
fs::read(&manifest_path).expect("read manifest bytes after rejection"),
before
);
fs::remove_dir_all(root).ok();
}
/// 只追加保证没有被打穿:默认路径与删除放行通道仍然拒绝绑定改写。
#[test]
fn binding_rewrite_channel_does_not_loosen_the_default_or_removal_paths() {
let (root, manifest_path, installed) = binding_rewrite_fixture("default-path-unchanged");
let before = fs::read(&manifest_path).expect("read manifest bytes before rejection");
let default_error = mutate_manifest_at(&root, |manifest| {
manifest.versions[1].resource_bindings = vec![binding("asset-b2")];
Ok(())
})
.expect_err("默认写入路径必须继续拒绝绑定改写");
assert!(
default_error.contains("不可修改、删除或重排"),
"{default_error}"
);
let removal_channel_error = mutate_manifest_at_allowing_version_removals(
&root,
&|_manifest| vec!["version-child".to_string()],
|manifest| {
// 即使用删除放行口把这条列进去,它也不能"保留下来顺便改绑定"。
manifest.versions[1].resource_bindings = vec![binding("asset-b2")];
Ok(())
},
)
.expect_err("删除放行通道不得用于绑定改写");
assert!(
removal_channel_error.contains("不可修改、删除或重排"),
"{removal_channel_error}"
);
assert_eq!(
fs::read(&manifest_path).expect("read manifest bytes after rejections"),
before
);
let unchanged = read_manifest(&manifest_path).expect("read unchanged manifest");
assert_eq!(unchanged.versions.len(), installed.versions.len());
fs::remove_dir_all(root).ok();
}
@@ -0,0 +1,484 @@
use super::*;
use shared_contracts::game_creation_app::{
canonical_game_creation_app_asset_kind, game_creation_app_asset_effective_category,
GameCreationAppAssetManifestEntry,
};
/// 版本级资源替换(**直接替换**口径)。
///
/// 落盘语义:**改 manifest 里该版本的绑定,指向另一个已登记资源** ——
/// 不追加新版本、不删除版本、不动资源文件、不建文件副本。
///
/// 写入通道是 `project/manifest.rs` 的 `mutate_manifest_allowing_version_binding_rewrites`
/// 它只放行"显式列出的版本改写自己的 `resourceBindings`",不增删、不重排、不改其它字段。
/// 因此本命令既表达了"这个版本现在用另一个素材",又没有把版本记录的只追加保证打穿。
///
/// 恒等绑定口径(`slotId` 恒为 `asset:{resourceId}`)下的绑定改写 = **源素材从这个版本的绑定
/// 集合里消失 + 替换素材出现在这个集合里**:
/// - 替换素材是源版本创建之后才登记时,按源素材原来的位置插回,保持顺序稳定;
/// - 替换素材早就登记(因此早就在集合里)时只做摘除 —— 不能"把源素材那条槽位改写成替换素材",
/// 那会撞「资源槽位重复」。
///
/// 准入与提示:
/// - **硬门禁**只有 `categoryEqual` 与 `subtypeEqual`(拦"把角色图换成背景音乐"这类明显错误);
/// - `sizeSpecEqual` 只作为**提示**、不拒绝:它的完整判据今天不存在(manifest 资产表没有
/// `width / height / durationMs`,实际只等于"媒体格式相等"),硬拦会误拒 `png ↔ webp` 这种
/// 直接替换里最常见的正常需求。
///
/// 已知代价:直接替换形态下**没有可回溯的替换历史**。替换前的绑定身份只存在于
/// ① `asset.version_binding.replace` 审计记录 ② manifest 的 `.previous` 恢复副本。
/// 需要"某个版本历史上被换过几次、换成过什么"时要另立切片(例如 PRD §3.2 的版本级替换)。
const VERSION_REPLACEMENT_MAX_ID_CHARS: usize = 512;
/// 直接替换的审计记录类型,与 `asset.register` / `asset.update` 同族。
const VERSION_BINDING_REPLACE_RECORD_TYPE: &str = "asset.version_binding.replace";
#[derive(Clone, Debug, Deserialize)]
#[serde(deny_unknown_fields, rename_all = "camelCase")]
pub(crate) struct ReadLocalProjectVersionReplacementCandidatesInput {
pub(crate) project_path: String,
pub(crate) source_version_id: String,
pub(crate) source_resource_id: String,
}
/// 三项兼容性。只有前两项是硬门禁;`size_spec_equal` 仅用于提示。
#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize)]
#[serde(rename_all = "camelCase")]
pub(crate) struct ProjectVersionResourceCompatibility {
pub(crate) category_equal: bool,
pub(crate) subtype_equal: bool,
pub(crate) size_spec_equal: bool,
}
impl ProjectVersionResourceCompatibility {
/// 硬门禁:分类与类型。任一不等即拒绝替换。
fn blocking_reason(self) -> Option<&'static str> {
if !self.category_equal {
Some("分类不同")
} else if !self.subtype_equal {
Some("类型不同")
} else {
None
}
}
/// 提示:通过硬门禁、但尺寸规格判据不等时给出(不拒绝)。
fn warning(self) -> Option<&'static str> {
if self.blocking_reason().is_none() && !self.size_spec_equal {
Some("格式与源素材不同")
} else {
None
}
}
}
#[derive(Clone, Debug, Eq, PartialEq, Serialize)]
#[serde(rename_all = "camelCase")]
pub(crate) struct LocalProjectVersionReplacementCandidate {
pub(crate) resource_id: String,
/// 能否选中:等价于"没有硬门禁原因"。
pub(crate) compatible: bool,
pub(crate) compatibility: ProjectVersionResourceCompatibility,
/// 硬门禁原因;通过时为 `null`。候选**渲染但禁用**,不用隐藏伪装成"素材不存在"。
pub(crate) blocked_reason: Option<&'static str>,
/// 非阻断提示(当前只有"格式与源素材不同")。
pub(crate) warning: Option<&'static str>,
}
#[derive(Clone, Debug, Eq, PartialEq, Serialize)]
#[serde(rename_all = "camelCase")]
pub(crate) struct ReadLocalProjectVersionReplacementCandidatesResult {
pub(crate) source_version_id: String,
pub(crate) source_resource_id: String,
pub(crate) candidates: Vec<LocalProjectVersionReplacementCandidate>,
}
#[derive(Clone, Debug, Deserialize)]
#[serde(deny_unknown_fields, rename_all = "camelCase")]
pub(crate) struct ReplaceLocalProjectVersionResourceInput {
pub(crate) project_path: String,
pub(crate) expected_project_id: String,
pub(crate) expected_project_revision: u64,
pub(crate) source_version_id: String,
pub(crate) source_resource_id: String,
pub(crate) replacement_resource_id: String,
}
/// 这一次替换的记录:哪个版本的哪个绑定指向改成了哪个素材。
///
/// 直接替换不产生新版本,所以没有 `parentVersionId`、也没有可推的前后版本对。
#[derive(Clone, Debug, Eq, PartialEq, Serialize)]
#[serde(rename_all = "camelCase")]
pub(crate) struct ProjectVersionResourceReplacement {
pub(crate) version_id: String,
pub(crate) source_resource_id: String,
pub(crate) replacement_resource_id: String,
pub(crate) compatibility: ProjectVersionResourceCompatibility,
pub(crate) warning: Option<&'static str>,
}
#[derive(Clone, Debug, Eq, PartialEq, Serialize)]
#[serde(rename_all = "camelCase")]
pub(crate) struct ReplaceLocalProjectVersionResourceResult {
pub(crate) version_id: String,
pub(crate) committed_project_revision: u64,
pub(crate) replacement: ProjectVersionResourceReplacement,
}
/// 槽位 ID 恒等于 `asset:{resourceId}`v3 恒等绑定口径),绑定改写时同步维护。
fn version_binding_slot_id(resource_id: &str) -> String {
format!("asset:{resource_id}")
}
/// 资源媒体格式身份。
///
/// 只做「声明值归一 + 缺声明时按扩展名回退」:去参数段(`; charset=...`)、去首尾空白、
/// 转小写,并把 `image/jpg` 归到 canonical 的 `image/jpeg`(与 `assets.rs` 的
/// `CanvasImageFormat::from_media_type` 同口径)。**不**做全量 MIME 规范化表。
fn canonical_asset_media_format(asset: &GameCreationAppAssetManifestEntry) -> String {
let declared = asset
.media_type
.split(';')
.next()
.unwrap_or_default()
.trim()
.to_ascii_lowercase();
if !declared.is_empty() {
return if declared == "image/jpg" {
"image/jpeg".to_string()
} else {
declared
};
}
Path::new(&asset.local_path)
.extension()
.and_then(|extension| extension.to_str())
.unwrap_or_default()
.trim()
.to_ascii_lowercase()
}
/// 已知尺寸事实:`imageSequenceFrames` 首帧的像素尺寸。没有帧事实时为 `None`。
fn asset_known_frame_size(asset: &GameCreationAppAssetManifestEntry) -> Option<(u32, u32)> {
asset
.image_sequence_frames
.as_ref()
.and_then(|frames| frames.first())
.map(|frame| (frame.width, frame.height))
}
/// 已知时长事实:仅序列资产有;其余类型为 `None`。
fn asset_known_duration_ms(asset: &GameCreationAppAssetManifestEntry) -> Option<u64> {
asset.image_sequence_duration_ms
}
/// `sizeSpecEqual` 判据(**只提示、不拒绝**)。
///
/// 判据 = 规范化媒体格式相等 **且**「任一方有事实的维度必须相等」(双方都无事实的维度不参与)。
///
/// **诚实标注(刻意接受的降级)**:manifest 资产表没有 `width / height / durationMs` 字段,
/// 且现役写入侧(上传、派生、画板回传、生成回流)几乎全部写 `imageSequenceFrames: None`
/// 所以这条判据在真实数据上**退化为"媒体格式相等"**。正因为它不完整,本轮**不用它拒绝**替换,
/// 只在弹窗里提示"格式与源素材不同";要变成硬判据,必须先给 manifest asset 增尺寸字段并在写入侧
/// 回填(跨端契约变更)。
fn version_resource_size_spec_equal(
source: &GameCreationAppAssetManifestEntry,
replacement: &GameCreationAppAssetManifestEntry,
) -> bool {
let format_equal =
canonical_asset_media_format(source) == canonical_asset_media_format(replacement);
let size_equal = match (
asset_known_frame_size(source),
asset_known_frame_size(replacement),
) {
(None, None) => true,
(source_size, replacement_size) => source_size == replacement_size,
};
let duration_equal = match (
asset_known_duration_ms(source),
asset_known_duration_ms(replacement),
) {
(None, None) => true,
(source_duration, replacement_duration) => source_duration == replacement_duration,
};
format_equal && size_equal && duration_equal
}
/// 三项兼容性判据(后端是权威判据,前端只做呈现)。
///
/// - `categoryEqual`:功能分类相等,用**读时自愈**口径(PRD §5.3「分类取值优先级」收口);
/// - `subtypeEqual`canonical `kind` 相等(别名表在 `shared-contracts`)——以上两项是硬门禁;
/// - `sizeSpecEqual`:见 [`version_resource_size_spec_equal`],只作提示。
fn version_resource_compatibility(
source: &GameCreationAppAssetManifestEntry,
replacement: &GameCreationAppAssetManifestEntry,
) -> ProjectVersionResourceCompatibility {
ProjectVersionResourceCompatibility {
category_equal: game_creation_app_asset_effective_category(
&source.kind,
source.category,
) == game_creation_app_asset_effective_category(
&replacement.kind,
replacement.category,
),
subtype_equal: canonical_game_creation_app_asset_kind(&source.kind)
== canonical_game_creation_app_asset_kind(&replacement.kind),
size_spec_equal: version_resource_size_spec_equal(source, replacement),
}
}
fn normalized_version_id<'a>(value: &'a str) -> Result<&'a str, String> {
let value = value.trim();
if value.is_empty() {
return Err("sourceVersionId 不能为空".to_string());
}
if value.chars().count() > VERSION_REPLACEMENT_MAX_ID_CHARS {
return Err("sourceVersionId 过长".to_string());
}
Ok(value)
}
fn normalized_resource_id<'a>(value: &'a str, label: &str) -> Result<&'a str, String> {
let value = value.trim();
if value.is_empty() {
return Err(format!("{label} 不能为空"));
}
if value.chars().count() > VERSION_REPLACEMENT_MAX_ID_CHARS {
return Err(format!("{label} 过长"));
}
Ok(value)
}
fn replacement_source_version<'a>(
manifest: &'a GameCreationAppManifest,
source_version_id: &str,
) -> Result<&'a GameIterationVersion, String> {
manifest
.versions
.iter()
.find(|version| version.version_id == source_version_id)
.ok_or_else(|| format!("源项目版本不存在:{source_version_id}"))
}
/// 源版本必须真的绑定着源素材(恒等绑定:`slotId` 与 `resourceId` 同时命中)。
fn require_source_binding(
version: &GameIterationVersion,
source_resource_id: &str,
) -> Result<(), String> {
let slot_id = version_binding_slot_id(source_resource_id);
if version
.resource_bindings
.iter()
.any(|binding| binding.slot_id == slot_id && binding.resource_id == source_resource_id)
{
return Ok(());
}
Err(format!(
"源版本未绑定该素材:{} · {}",
version.version_id, source_resource_id
))
}
fn manifest_asset<'a>(
manifest: &'a GameCreationAppManifest,
resource_id: &str,
) -> Result<&'a GameCreationAppAssetManifestEntry, String> {
manifest
.assets
.iter()
.find(|asset| asset.id == resource_id)
.ok_or_else(|| format!("项目资源不存在:{resource_id}"))
}
/// 读取源素材在当前 manifest 里可用的替换候选,并给出后端权威的兼容性结论与提示。
///
/// 只读:不改 manifest、不推进 revision。候选按 `manifest.assets` 顺序返回,源素材自身排除。
pub(crate) fn read_local_project_version_replacement_candidates_at(
root: &Path,
source_version_id: &str,
source_resource_id: &str,
) -> Result<ReadLocalProjectVersionReplacementCandidatesResult, String> {
let source_version_id = normalized_version_id(source_version_id)?;
let source_resource_id = normalized_resource_id(source_resource_id, "sourceResourceId")?;
let manifest = read_existing_manifest_for_project(root)?;
let source_version = replacement_source_version(&manifest, source_version_id)?;
require_source_binding(source_version, source_resource_id)?;
let source_asset = manifest_asset(&manifest, source_resource_id)?;
let candidates = manifest
.assets
.iter()
.filter(|asset| asset.id != source_resource_id)
.map(|asset| {
let compatibility = version_resource_compatibility(source_asset, asset);
LocalProjectVersionReplacementCandidate {
resource_id: asset.id.clone(),
compatible: compatibility.blocking_reason().is_none(),
compatibility,
blocked_reason: compatibility.blocking_reason(),
warning: compatibility.warning(),
}
})
.collect();
Ok(ReadLocalProjectVersionReplacementCandidatesResult {
source_version_id: source_version_id.to_string(),
source_resource_id: source_resource_id.to_string(),
candidates,
})
}
/// 把源版本引用的 `source_resource_id` **直接替换**成 `replacement_resource_id`。
///
/// 语义与拒绝路径:
/// - **不追加新版本**:只改该版本的绑定集合,其余版本整条不动、版本数量不变;
/// - 硬门禁:分类不同 / 类型不同 → 拒绝并说明哪一项不等,**不做假成功**;
/// - 源版本不存在 / 源版本未绑定该素材 / 替换素材未登记 / 替换素材与源素材相同 → 拒绝;
/// - CAS`expectedProjectId` 与 `expectedProjectRevision` 必须与锁内读到的事实一致,
/// 任何拒绝都保证 manifest 与 revision 不变;
/// - 成功后推进一次项目 revision(改绑定属于 `versions` 变化,跨面快照门禁要求 revision 前进),
/// 并追加一条 `asset.version_binding.replace` 审计。审计写失败会把错误报出,但 manifest 已落盘
/// (与 `asset.register` 同口径,不做回滚)。
pub(crate) fn replace_local_project_version_resource_at(
root: &Path,
expected_project_id: &str,
expected_project_revision: u64,
source_version_id: &str,
source_resource_id: &str,
replacement_resource_id: &str,
) -> Result<ReplaceLocalProjectVersionResourceResult, 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 source_version_id = normalized_version_id(source_version_id)?.to_string();
let source_resource_id =
normalized_resource_id(source_resource_id, "sourceResourceId")?.to_string();
let replacement_resource_id =
normalized_resource_id(replacement_resource_id, "replacementResourceId")?.to_string();
if source_resource_id == replacement_resource_id {
return Err("替换素材与源素材相同".to_string());
}
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 target_revision = expected_project_revision
.checked_add(1)
.ok_or_else(|| "项目 revision 已达到上限".to_string())?;
let mut applied: Option<ProjectVersionResourceCompatibility> = None;
mutate_manifest_allowing_version_binding_rewrites(
root,
&|_manifest| vec![source_version_id.clone()],
|manifest| {
// 锁内复核 CAS:项目写锁已持有,此处再读一次 durable revision,把"读 revision →
// 写 manifest"之间的窗口收干,任何漂移都在写入前失败关闭。
if read_game_creator_agent_runtime_project_revision(root)?.revision
!= expected_project_revision
{
return Err("project-revision-conflict".to_string());
}
if manifest.project_id != expected_project_id {
return Err("project-identity-conflict".to_string());
}
let version_index = manifest
.versions
.iter()
.position(|version| version.version_id == source_version_id)
.ok_or_else(|| format!("源项目版本不存在:{source_version_id}"))?;
require_source_binding(&manifest.versions[version_index], &source_resource_id)?;
let source_asset = manifest_asset(manifest, &source_resource_id)?.clone();
let replacement_asset = manifest_asset(manifest, &replacement_resource_id)?.clone();
let compatibility = version_resource_compatibility(&source_asset, &replacement_asset);
if let Some(reason) = compatibility.blocking_reason() {
return Err(format!("resource-replacement-incompatible{reason}"));
}
// 绑定改写 = 源素材从该版本的绑定集合里消失 + 保证替换素材在集合里。
let slot_id = version_binding_slot_id(&source_resource_id);
let version = &mut manifest.versions[version_index];
let source_index = version
.resource_bindings
.iter()
.position(|binding| {
binding.slot_id == slot_id && binding.resource_id == source_resource_id
})
.ok_or_else(|| {
format!("源版本未绑定该素材:{source_version_id} · {source_resource_id}")
})?;
let mut resource_bindings: Vec<GameIterationVersionResourceBinding> = version
.resource_bindings
.iter()
.filter(|binding| {
!(binding.slot_id == slot_id && binding.resource_id == source_resource_id)
})
.cloned()
.collect();
if !resource_bindings
.iter()
.any(|binding| binding.resource_id == replacement_resource_id)
{
resource_bindings.insert(
source_index.min(resource_bindings.len()),
GameIterationVersionResourceBinding {
slot_id: version_binding_slot_id(&replacement_resource_id),
resource_id: replacement_resource_id.clone(),
},
);
}
version.resource_bindings = resource_bindings;
applied = Some(compatibility);
Ok(())
},
)?;
let committed_project_revision = advance_agent_runtime_project_revision_locked(root)
.map_err(|error| format!("绑定已改写,但项目 revision 未能推进:{error}"))?;
let Some(compatibility) = applied else {
return Err("替换写入未产生结果".to_string());
};
if committed_project_revision != target_revision {
return Err(format!(
"绑定已改写,但项目 revision 推进结果与预期不一致:期望 {target_revision},实际 {committed_project_revision}"
));
}
append_agent_db_record(
root,
serde_json::json!({
"recordType": VERSION_BINDING_REPLACE_RECORD_TYPE,
"versionId": source_version_id,
"sourceResourceId": source_resource_id,
"replacementResourceId": replacement_resource_id,
"projectRevision": committed_project_revision,
}),
)?;
Ok(ReplaceLocalProjectVersionResourceResult {
version_id: source_version_id.clone(),
committed_project_revision,
replacement: ProjectVersionResourceReplacement {
version_id: source_version_id,
source_resource_id,
replacement_resource_id,
compatibility,
warning: compatibility.warning(),
},
})
}
@@ -6078,3 +6078,4 @@ mod response_stream;
mod runtime_actions;
mod runtime_state;
mod sessions;
mod version_resource_replacement;
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,199 @@
import type { EditorAsset } from '../../../../../src/components/image-editor/ImageCanvasEditorTypes';
import type { ProjectResource } from '../../view/project-development/resourceProjectionModel';
import {
resourceCanvasAssetKind,
resourceCanvasMediaType,
} from './resourceCanvasToolbarModel';
/**
* 「替换素材」的展示层口径:类型、禁用原因、提示与失败文案。
*
* 兼容性判据在 Rust`project/version_resource_replacement.rs`),前端只做呈现,不在这里重算
* 判据 —— 否则同一个规则会出现两份实现,迟早分叉。当前形态是**直接替换**:改 manifest 里该
* 版本的绑定,不产生新版本。
*/
/**
* 兼容性三项。只有前两项是硬门禁(不满足则候选不可选);`sizeSpecEqual` 只作提示。
*/
export type ProjectVersionResourceCompatibility = {
categoryEqual: boolean;
subtypeEqual: boolean;
sizeSpecEqual: boolean;
};
export type LocalProjectVersionReplacementCandidate = {
resourceId: string;
compatible: boolean;
compatibility: ProjectVersionResourceCompatibility;
blockedReason: string | null;
/** 非阻断提示(当前只有"格式与源素材不同")。 */
warning: string | null;
};
export type ReadLocalProjectVersionReplacementCandidatesResult = {
sourceVersionId: string;
sourceResourceId: string;
candidates: LocalProjectVersionReplacementCandidate[];
};
/** 这一次替换的记录:哪个版本的哪个绑定指向改成了哪个素材(没有新版本,也就没有 parentVersionId)。 */
export type ProjectVersionResourceReplacement = {
versionId: string;
sourceResourceId: string;
replacementResourceId: string;
compatibility: ProjectVersionResourceCompatibility;
warning: string | null;
};
export type ReplaceLocalProjectVersionResourceResult = {
versionId: string;
committedProjectRevision: number;
replacement: ProjectVersionResourceReplacement;
};
/**
* 候选不可选的原因:**只看硬门禁**(分类 / 类型)。
*
* 后端已经给出 `blockedReason`;这里只在它缺失(旧后端 / 手工构造的候选)时按同一顺序派生,
* 不让界面出现"不可选但不说原因"的条目。尺寸规格不参与该判据:它今天不完整,用不完整的判据
* 拒绝会误伤 `png ↔ webp` 这类正常替换。
*/
export function resourceReplacementBlockedReason(
candidate: LocalProjectVersionReplacementCandidate,
): string | null {
if (candidate.compatible) return null;
const reason = candidate.blockedReason?.trim();
if (reason) return reason;
if (!candidate.compatibility.categoryEqual) return '分类不同';
if (!candidate.compatibility.subtypeEqual) return '类型不同';
return '替换兼容性未通过';
}
/** 候选的非阻断提示:可选但仍值得说一声(当前只有尺寸规格/格式差异)。 */
export function resourceReplacementWarning(
candidate: LocalProjectVersionReplacementCandidate,
): string | null {
const warning = candidate.warning?.trim();
if (warning) return warning;
if (
candidate.compatible &&
candidate.compatibility.categoryEqual &&
candidate.compatibility.subtypeEqual &&
!candidate.compatibility.sizeSpecEqual
) {
return '格式与源素材不同';
}
return null;
}
/**
* 候选 → 禁用原因表,喂给弹窗的 `assetBlockedReasons`。
*
* 不兼容候选**渲染但禁用**:隐藏会让用户以为"素材不存在",而真实原因是它不能替换这个素材。
*/
export function resourceReplacementBlockedReasons(
candidates: readonly LocalProjectVersionReplacementCandidate[],
): Record<string, string> {
const reasons: Record<string, string> = {};
for (const candidate of candidates) {
const reason = resourceReplacementBlockedReason(candidate);
if (reason) reasons[candidate.resourceId] = reason;
}
return reasons;
}
/** 候选 → 提示表,喂给弹窗的 `assetHints`(默认不渲染,只有传了才显示)。 */
export function resourceReplacementAssetHints(
candidates: readonly LocalProjectVersionReplacementCandidate[],
): Record<string, string> {
const hints: Record<string, string> = {};
for (const candidate of candidates) {
const warning = resourceReplacementWarning(candidate);
if (warning) hints[candidate.resourceId] = warning;
}
return hints;
}
/**
* 候选 → 弹窗素材。
*
* `src` 固定为空字符串:AGC 的原生预览读取走带 scope 的调度器 + Blob URL,弹窗里拿不到同步
* `src`,所以缩略图由调用方通过弹窗的 `renderAssetMedia` opt-in 渲染,**不给 `<img>` 喂空串**
* (那会挂破图)。分类筛选复用语资源画布同一条媒体类型口径。
*/
export function resourceReplacementPickerAssets(
resources: readonly ProjectResource[],
candidates: readonly LocalProjectVersionReplacementCandidate[],
): EditorAsset[] {
const resourceByAssetId = new Map<string, ProjectResource>();
for (const resource of resources) {
const assetId = resource.manifestAssetId;
if (assetId && !resourceByAssetId.has(assetId)) {
resourceByAssetId.set(assetId, resource);
}
}
return candidates.flatMap((candidate) => {
const resource = resourceByAssetId.get(candidate.resourceId);
if (!resource) return [];
const assetKind = resourceCanvasAssetKind(resource);
return [
{
id: candidate.resourceId,
label: resource.label,
src: '',
mediaType: resourceCanvasMediaType(resource) ?? 'image',
width: 0,
height: 0,
folderId: 'project-assets',
sourceKind: 'uploaded',
sourceType: 'generated',
persisted: true,
...(assetKind ? { assetKind } : {}),
},
];
});
}
/** 入口判据复用现役口径:`isResourceUsedByCurrentVersion`manifest 身份 + 被当前版本绑定)。 */
/**
* 把 Rust 的结构化拒绝翻成用户可读中文。
*
* 不认识的错误原样透出:宁可显示后端原文,也不把原因吞掉换成"替换失败"这种无信息文案。
*/
export function resourceVersionReplacementErrorMessage(error: unknown): string {
const message =
error instanceof Error ? error.message : String(error ?? '').trim();
if (!message) return '替换素材失败';
if (message.includes('project-revision-conflict')) {
return '项目已被其它操作改动,请重试替换';
}
if (message.includes('project-identity-conflict')) {
return '项目身份不一致,请重新打开项目后再试';
}
if (message.includes('resource-replacement-incompatible')) {
const reason = message.split('').pop()?.trim();
return reason ? `替换素材不兼容:${reason}` : '替换素材不兼容';
}
if (message.includes('源项目版本不存在')) {
return '当前版本已不存在,请刷新项目后重试';
}
if (message.includes('源版本未绑定该素材')) {
return '该素材不在当前版本的绑定里,不能替换';
}
if (message.includes('项目资源不存在')) {
return '替换素材未登记或已被删除';
}
if (message.includes('替换素材与源素材相同')) {
return '替换素材与源素材相同';
}
if (message.includes('不可修改、删除或重排')) {
// 版本记录写入边界的拒绝:直接替换只允许改一个版本的绑定,走到这里说明写入被守门挡下了。
return '替换被项目版本写入边界拒绝,请刷新项目后重试';
}
if (message.includes('必须在客户端内执行')) {
return '替换素材需要在客户端内执行';
}
return message;
}
@@ -0,0 +1,49 @@
import type {
ReadLocalProjectVersionReplacementCandidatesResult,
ReplaceLocalProjectVersionResourceResult,
} from './resourceVersionReplacementModel';
/**
* 版本级资源替换的 IPC 边界。
*
* 两个命令都只吃单个 `input` 对象:Rust 侧入参是 `deny_unknown_fields` 的结构体,
* 多传字段会直接失败关闭。
*/
function replacementInvoke() {
const invoke = window.__TAURI__?.core?.invoke;
if (!invoke) {
// 与素材删除同口径:不在客户端内执行时明确报错,不做"看起来成功"的静默返回。
throw new Error('替换素材需要在客户端内执行');
}
return invoke;
}
/** 读取源素材可用的替换候选与后端权威的三项兼容性结论(只读)。 */
export async function readVersionResourceReplacementCandidates(input: {
projectPath: string;
sourceVersionId: string;
sourceResourceId: string;
}): Promise<ReadLocalProjectVersionReplacementCandidatesResult> {
const invoke = replacementInvoke();
return invoke<ReadLocalProjectVersionReplacementCandidatesResult>(
'read_local_project_version_resource_replacement_candidates',
{ input },
);
}
/** 改 manifest 绑定并追加下一迭代版本(不回滚:成功即已落盘)。 */
export async function replaceVersionResource(input: {
projectPath: string;
expectedProjectId: string;
expectedProjectRevision: number;
sourceVersionId: string;
sourceResourceId: string;
replacementResourceId: string;
}): Promise<ReplaceLocalProjectVersionResourceResult> {
const invoke = replacementInvoke();
return invoke<ReplaceLocalProjectVersionResourceResult>(
'replace_local_project_version_resource',
{ input },
);
}
+15
View File
@@ -6412,6 +6412,21 @@ iframe.preview-frame {
color: #9b5537;
}
/*
* 「替换素材」候选弹窗里的类型占位。
*
* AGC 的素材预览要经带 scope 的原生读取器拿 Blob URL,弹窗内没有同步 `src`
* 所以候选行只渲染稳定类型图标,不挂 `<img>`、不出现破图。
*/
.game-resource-replacement-media {
display: flex;
align-items: center;
justify-content: center;
width: 100%;
height: 100%;
color: #8d7a6b;
}
.game-attachment-errors {
display: grid;
gap: 4px;
@@ -35,6 +35,7 @@ import {
Play,
Plus,
Redo2,
Replace,
RotateCcw,
Search,
Settings2,
@@ -42,6 +43,7 @@ import {
Sparkles,
Undo2,
Users,
Video,
X,
ZoomOut,
} from 'lucide-react';
@@ -83,6 +85,7 @@ import {
CHARACTER_ANIMATION_DURATION_OPTIONS,
CHARACTER_ANIMATION_MODEL,
} from '../../../../../src/components/image-editor/ImageCanvasGenerationModel';
import { ImageCanvasProjectAssetPickerDialog } from '../../../../../src/components/image-editor/ImageCanvasProjectAssetPickerDialog';
import { ImageCanvasQuickEditPanelView } from '../../../../../src/components/image-editor/ImageCanvasQuickEditPanelView';
import { ImageCanvasSelectedLayerToolbarView } from '../../../../../src/components/image-editor/ImageCanvasSelectedLayerToolbarView';
import { useImageCanvasFloatingOptionDismiss } from '../../../../../src/components/image-editor/useImageCanvasFloatingOptionDismiss';
@@ -93,6 +96,7 @@ import {
} from '../../features/project-workspace/LocalGamePreviewFrame';
import {
dispatchResourceReferenceInsert,
resolveActiveIterationVersion,
resourceReferenceCategoryLabel,
} from '../../features/project-workspace/resourceReferences';
import { GameRunVersionPicker } from '../../features/resource-canvas/GameRunVersionPicker';
@@ -142,6 +146,17 @@ import {
isResourceUsedByCurrentVersion,
} from '../../features/resource-canvas/resourceCanvasVersionBindingModel';
import { ResourcePromptPolishSlot } from '../../features/resource-canvas/ResourcePromptPolishSlot';
import {
type LocalProjectVersionReplacementCandidate,
resourceReplacementAssetHints,
resourceReplacementBlockedReasons,
resourceReplacementPickerAssets,
resourceVersionReplacementErrorMessage,
} from '../../features/resource-canvas/resourceVersionReplacementModel';
import {
readVersionResourceReplacementCandidates,
replaceVersionResource,
} from '../../features/resource-canvas/resourceVersionReplacementTransport';
import { ensureUiDesignResourceForPrototype } from '../../features/ui-editor/uiDesignResourceBridge';
import {
currentPlatformSessionGeneration,
@@ -1431,6 +1446,24 @@ export default function ProjectDevelopmentView({
const [resourceRenameError, setResourceRenameError] = useState<string | null>(
null,
);
/**
* PRD §3.2 / §5.3 manifest "新版本 + 新绑定"
*
* `sourceVersionId + sourceResourceId`
* Rust
*/
const [resourceReplacementSource, setResourceReplacementSource] = useState<{
versionId: string;
assetId: string;
} | null>(null);
const [resourceReplacementCandidates, setResourceReplacementCandidates] =
useState<LocalProjectVersionReplacementCandidate[]>([]);
const [resourceReplacementOpen, setResourceReplacementOpen] = useState(false);
const [resourceReplacementLoading, setResourceReplacementLoading] =
useState(false);
const [resourceReplacementError, setResourceReplacementError] = useState<
string | null
>(null);
const [pendingResourceEditActionIds, setPendingResourceEditActionIds] =
useState<Set<string>>(() => new Set());
const [pendingResourceEditActionErrors, setPendingResourceEditActionErrors] =
@@ -5249,16 +5282,142 @@ export default function ProjectDevelopmentView({
*
*
*
*
* `useCallback`
* `confirmResourceVersionReplacement`
*/
function selectActiveVersion(versionId: string) {
if (versionId === activeVersionId) {
return;
}
onActiveVersionChange?.(versionId);
if (mode === 'run' && runAvailable) {
onPlay?.();
}
}
const selectActiveVersion = useCallback(
(versionId: string) => {
if (versionId === activeVersionId) {
return;
}
onActiveVersionChange?.(versionId);
if (mode === 'run' && runAvailable) {
onPlay?.();
}
},
[activeVersionId, mode, onActiveVersionChange, onPlay, runAvailable],
);
/**
*
*
* /
* "打开了但什么也选不了"
*
* `selectActiveVersion`
* `useCallback`
*/
const openResourceVersionReplacement = useCallback(
async (resource: ProjectResource) => {
const assetId = resource.manifestAssetId;
const version = resolveActiveIterationVersion(
projectVersions,
activeVersionId,
);
if (!assetId || !version) return;
const source = { versionId: version.versionId, assetId };
setResourceReplacementError(null);
setResourceReplacementCandidates([]);
setResourceReplacementSource(source);
setResourceReplacementLoading(true);
try {
const result = await readVersionResourceReplacementCandidates({
projectPath,
sourceVersionId: source.versionId,
sourceResourceId: source.assetId,
});
setResourceReplacementCandidates(result.candidates);
setResourceReplacementOpen(true);
} catch (error) {
setResourceReplacementSource(null);
setResourceWorkbenchNotice(
resourceVersionReplacementErrorMessage(error),
);
} finally {
setResourceReplacementLoading(false);
}
},
[activeVersionId, projectPath, projectVersions],
);
const closeResourceVersionReplacement = useCallback(() => {
setResourceReplacementOpen(false);
setResourceReplacementSource(null);
setResourceReplacementCandidates([]);
setResourceReplacementError(null);
}, []);
/**
* manifest
*
*
* ****使
*/
const confirmResourceVersionReplacement = useCallback(
async (assetIds: string[]) => {
const source = resourceReplacementSource;
const replacementResourceId = assetIds[0];
if (!source || !replacementResourceId) {
setResourceReplacementError('请选择一个替换素材');
return;
}
if (resourceReplacementLoading) return;
setResourceReplacementLoading(true);
setResourceReplacementError(null);
try {
const invoke = window.__TAURI__?.core?.invoke;
if (!invoke) {
throw new Error('替换素材需要在客户端内执行');
}
const status = await invoke<{ revision: number }>(
'get_local_game_project_revision',
{ projectPath },
);
if (!Number.isSafeInteger(status.revision) || status.revision < 0) {
throw new Error('项目 revision 无效');
}
const result = await replaceVersionResource({
projectPath,
expectedProjectId: manifest.projectId,
expectedProjectRevision: status.revision,
sourceVersionId: source.versionId,
sourceResourceId: source.assetId,
replacementResourceId,
});
setResourceReplacementOpen(false);
setResourceReplacementSource(null);
setResourceReplacementCandidates([]);
await reloadManifestAfterAssetCommand(
result.committedProjectRevision,
`version-resource-replacement:${result.versionId}`,
);
} catch (error) {
setResourceReplacementError(
resourceVersionReplacementErrorMessage(error),
);
} finally {
setResourceReplacementLoading(false);
}
},
[
manifest.projectId,
projectPath,
reloadManifestAfterAssetCommand,
resourceReplacementLoading,
resourceReplacementSource,
],
);
const resourceReplacementPickerEntries = useMemo(
() =>
resourceReplacementPickerAssets(resources, resourceReplacementCandidates),
[resourceReplacementCandidates, resources],
);
const resourceReplacementBlockedReasonMap = useMemo(
() => resourceReplacementBlockedReasons(resourceReplacementCandidates),
[resourceReplacementCandidates],
);
const resourceReplacementHintMap = useMemo(
() => resourceReplacementAssetHints(resourceReplacementCandidates),
[resourceReplacementCandidates],
);
function showRunView() {
if (!runAvailable || uiEditorRoute) {
@@ -6373,6 +6532,31 @@ export default function ProjectDevelopmentView({
<span></span>
</CanvasChromeButton>
) : null}
{/*
manifest
****`currentVersionBindingIds`
`resourceCanvasVersionBindingModel.ts`
"点了没反应"
*/}
{selectedResource &&
isResourceUsedByCurrentVersion(
selectedResource,
currentVersionBindingIds,
) ? (
<CanvasChromeButton
className="image-canvas-editor__floating-toolbar-text-button"
label="替换素材"
title="替换素材"
icon={<Replace className="h-4 w-4" />}
onClick={() =>
void openResourceVersionReplacement(
selectedResource,
)
}
>
<span></span>
</CanvasChromeButton>
) : null}
</>
}
onOpenQuickEditPanel={openResourceQuickEditPanel}
@@ -7093,6 +7277,36 @@ export default function ProjectDevelopmentView({
onConfirm={(newFileName) => void confirmResourceRename(newFileName)}
/>
) : null}
{/*
+ + +
`renderAssetMedia` AGC scope Blob URL
`src` `<img>`
*/}
<ImageCanvasProjectAssetPickerDialog
open={resourceReplacementOpen}
assets={resourceReplacementPickerEntries}
selectedAssetIds={[]}
singleSelect
selectionNoun="替换素材"
assetBlockedReasons={resourceReplacementBlockedReasonMap}
assetHints={resourceReplacementHintMap}
errorMessage={resourceReplacementError}
renderAssetMedia={(asset) => (
<span className="game-resource-replacement-media" aria-hidden="true">
{asset.mediaType === 'audio' ? (
<Music2 className="h-5 w-5" />
) : asset.mediaType === 'video' ? (
<Video className="h-5 w-5" />
) : (
<Image className="h-5 w-5" />
)}
</span>
)}
onCancel={closeResourceVersionReplacement}
onConfirm={(assetIds) =>
void confirmResourceVersionReplacement(assetIds)
}
/>
{resourceRecoveryPanelOpen ? (
<div
className="game-approval-backdrop"
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,290 @@
/** @vitest-environment jsdom */
import { describe, expect, it, vi } from 'vitest';
import type { GameCreationAppManifest } from '../../../packages/shared/src/contracts/gameCreationApp';
import type {
LocalProjectVersionReplacementCandidate,
ProjectVersionResourceCompatibility,
} from '../src/features/resource-canvas/resourceVersionReplacementModel';
import {
resourceReplacementAssetHints,
resourceReplacementBlockedReason,
resourceReplacementBlockedReasons,
resourceReplacementPickerAssets,
resourceReplacementWarning,
resourceVersionReplacementErrorMessage,
} from '../src/features/resource-canvas/resourceVersionReplacementModel';
import {
readVersionResourceReplacementCandidates,
replaceVersionResource,
} from '../src/features/resource-canvas/resourceVersionReplacementTransport';
import { projectResourcesFromReadModels } from '../src/view/project-development/resourceProjectionModel';
function candidate(
resourceId: string,
compatibility: Partial<ProjectVersionResourceCompatibility> = {},
overrides: Partial<LocalProjectVersionReplacementCandidate> = {},
): LocalProjectVersionReplacementCandidate {
const resolved: ProjectVersionResourceCompatibility = {
categoryEqual: true,
subtypeEqual: true,
sizeSpecEqual: true,
...compatibility,
};
return {
resourceId,
// 直接替换口径:只有分类与类型参与"能不能选"。
compatible: resolved.categoryEqual && resolved.subtypeEqual,
compatibility: resolved,
blockedReason: null,
warning: null,
...overrides,
};
}
function manifestWithAssets(
assets: Array<{
id: string;
kind: string;
mediaType: string;
localPath: string;
}>,
): GameCreationAppManifest {
return {
schemaVersion: 'game-creator-manifest.v1',
projectId: 'project-1',
name: '资源替换模型测试',
goal: null,
tasks: [],
assets: assets.map((asset) => ({
...asset,
source: { kind: 'generated' as const },
})),
};
}
describe('资源替换的展示层口径', () => {
it('可选时没有禁用原因,不可选时优先用后端给的原因', () => {
expect(resourceReplacementBlockedReason(candidate('asset-ok'))).toBeNull();
expect(
resourceReplacementBlockedReason(
candidate(
'asset-x',
{ categoryEqual: false },
{ blockedReason: '分类不同' },
),
),
).toBe('分类不同');
});
it('后端没给原因时按硬门禁顺序派生(分类 → 类型),尺寸规格不参与禁用', () => {
expect(
resourceReplacementBlockedReason(
candidate('asset-category', {
categoryEqual: false,
subtypeEqual: false,
sizeSpecEqual: false,
}),
),
).toBe('分类不同');
expect(
resourceReplacementBlockedReason(
candidate('asset-subtype', {
subtypeEqual: false,
sizeSpecEqual: false,
}),
),
).toBe('类型不同');
// 尺寸规格不同**不再**产生禁用原因:它是提示,不是门禁。
expect(
resourceReplacementBlockedReason(
candidate('asset-size', { sizeSpecEqual: false }),
),
).toBeNull();
});
it('被标记为不可选但硬门禁都通过时仍给出兜底原因,不静默放行', () => {
expect(
resourceReplacementBlockedReason(
candidate('asset-unknown', {}, { compatible: false }),
),
).toBe('替换兼容性未通过');
});
it('尺寸规格差异只做提示,且不覆盖真正的不兼容原因', () => {
expect(
resourceReplacementWarning(
candidate('asset-format', { sizeSpecEqual: false }),
),
).toBe('格式与源素材不同');
expect(resourceReplacementWarning(candidate('asset-ok'))).toBeNull();
expect(
resourceReplacementWarning(
candidate(
'asset-blocked',
{ categoryEqual: false },
{ compatible: false },
),
),
).toBeNull();
});
it('禁用原因表只收录不可选候选;提示表只收录可选但有差异的候选', () => {
const candidates = [
candidate('asset-ok'),
candidate('asset-format', { sizeSpecEqual: false }),
candidate('asset-scene', { categoryEqual: false }),
];
expect(resourceReplacementBlockedReasons(candidates)).toEqual({
'asset-scene': '分类不同',
});
expect(resourceReplacementAssetHints(candidates)).toEqual({
'asset-format': '格式与源素材不同',
});
});
it('候选映射成弹窗素材:复用资源投影口径、不给 img 喂空 src、缺投影的候选不合成条目', () => {
const manifest = manifestWithAssets([
{
id: 'asset-image',
kind: 'character',
mediaType: 'image/png',
localPath: 'assets/hero.png',
},
{
id: 'asset-audio',
kind: 'background-music',
mediaType: 'audio/mpeg',
localPath: 'assets/theme.mp3',
},
]);
const resources = projectResourcesFromReadModels(manifest, [], []);
const assets = resourceReplacementPickerAssets(resources, [
candidate('asset-image'),
candidate('asset-audio'),
candidate('asset-missing'),
]);
expect(assets.map((asset) => asset.id)).toEqual([
'asset-image',
'asset-audio',
]);
expect(assets.map((asset) => asset.label)).toEqual([
'hero.png',
'theme.mp3',
]);
expect(assets.map((asset) => asset.mediaType)).toEqual(['image', 'audio']);
// AGC 没有同步 src:交给弹窗的 renderAssetMedia 渲染类型占位,不能挂破图。
expect(assets.every((asset) => asset.src === '')).toBe(true);
});
it('失败文案逐条可读,未知错误原样透出', () => {
expect(
resourceVersionReplacementErrorMessage(
new Error('project-revision-conflict'),
),
).toBe('项目已被其它操作改动,请重试替换');
expect(
resourceVersionReplacementErrorMessage(
new Error('project-identity-conflict'),
),
).toBe('项目身份不一致,请重新打开项目后再试');
expect(
resourceVersionReplacementErrorMessage(
new Error('resource-replacement-incompatible:分类不同'),
),
).toBe('替换素材不兼容:分类不同');
expect(
resourceVersionReplacementErrorMessage(
new Error('源项目版本不存在:initial-1'),
),
).toBe('当前版本已不存在,请刷新项目后重试');
expect(
resourceVersionReplacementErrorMessage(
new Error('源版本未绑定该素材:initial-1 · asset-1'),
),
).toBe('该素材不在当前版本的绑定里,不能替换');
expect(
resourceVersionReplacementErrorMessage(
new Error('项目资源不存在:asset-x'),
),
).toBe('替换素材未登记或已被删除');
expect(
resourceVersionReplacementErrorMessage(new Error('替换素材与源素材相同')),
).toBe('替换素材与源素材相同');
expect(
resourceVersionReplacementErrorMessage(
new Error('项目版本记录写入后不可修改、删除或重排'),
),
).toBe('替换被项目版本写入边界拒绝,请刷新项目后重试');
expect(
resourceVersionReplacementErrorMessage(
new Error('替换素材需要在客户端内执行'),
),
).toBe('替换素材需要在客户端内执行');
expect(resourceVersionReplacementErrorMessage(new Error('磁盘满了'))).toBe(
'磁盘满了',
);
expect(resourceVersionReplacementErrorMessage(undefined)).toBe(
'替换素材失败',
);
});
});
describe('资源替换的 IPC 边界', () => {
it('两个命令都只传一个 input 对象,字段名与 Rust 结构体逐字一致', async () => {
const invoke = vi.fn(async () => ({ ok: true }));
window.__TAURI__ = { core: { invoke } };
await readVersionResourceReplacementCandidates({
projectPath: '/tmp/project',
sourceVersionId: 'initial-1',
sourceResourceId: 'asset-source',
});
expect(invoke).toHaveBeenNthCalledWith(
1,
'read_local_project_version_resource_replacement_candidates',
{
input: {
projectPath: '/tmp/project',
sourceVersionId: 'initial-1',
sourceResourceId: 'asset-source',
},
},
);
await replaceVersionResource({
projectPath: '/tmp/project',
expectedProjectId: 'project-1',
expectedProjectRevision: 7,
sourceVersionId: 'initial-1',
sourceResourceId: 'asset-source',
replacementResourceId: 'asset-target',
});
expect(invoke).toHaveBeenNthCalledWith(
2,
'replace_local_project_version_resource',
{
input: {
projectPath: '/tmp/project',
expectedProjectId: 'project-1',
expectedProjectRevision: 7,
sourceVersionId: 'initial-1',
sourceResourceId: 'asset-source',
replacementResourceId: 'asset-target',
},
},
);
});
it('不在客户端内执行时明确报错,不做静默成功', async () => {
(window as unknown as { __TAURI__?: unknown }).__TAURI__ = undefined;
await expect(
readVersionResourceReplacementCandidates({
projectPath: '/tmp/project',
sourceVersionId: 'initial-1',
sourceResourceId: 'asset-source',
}),
).rejects.toThrow('替换素材需要在客户端内执行');
});
});
@@ -40,6 +40,8 @@
- 新版本必须记录 `parentVersionId`、替换前后资源身份和创建原因。
- 当前运行中的版本不消费尚未生成的新版本变更。
> **实现状态(2026-09-11):本节与 §5.3 的 `ProjectVersionResourceReplacement` 作为未来合同保留,当前实现为「直接替换」**——改 manifest 里该版本的绑定指向另一个已登记资源,**不创建下一迭代版本**(用户当日 DDL 口径:「替换这块先做成直接替换」)。因此本节三条今天**暂不实现**:没有 `parentVersionId` 子版本、没有可回溯的替换历史(替换前身份只剩一条 `asset.version_binding.replace` 审计与 manifest 的 `.previous` 副本)。上面第二条的"可运行版本不可变"与最后一条"运行中的版本不消费尚未生成的新版本变更"仍然成立。直接替换的完整口径见 §5.3 与 §7.8;需要恢复版本级替换时,本节与 §5.3 的 DTO 就是合同。
### 3.3 资源布局
- “按依赖”和“按类型”分别保存画布位置。
@@ -187,10 +189,10 @@ idle -> focused(document|art|audio|version) -> idle
- 文档:合法 Agent 文本回执直接使用对话投影内容;项目文件只允许读取当前 manifest 已登记资产或已完成任务产物中的 Markdown、文本、JSON、YAML、TOML,必须经过 `file.read` auto 权限、相对路径、项目边界、普通文件、符号链接 / 硬链接、读取漂移、2 MiB、UTF-8 与扩展名白名单校验。正文使用不执行 HTML、不加载远程图片、不产生可点击外链的安全 Markdown 渲染,并在中央画布内独立滚动;读取失败显示错误空态。聊天侧 `/read` 回执中的文件正文必须作为代码块渲染为 `<pre><code>`,以便用户审阅源码字面量但不执行其中的 HTML;Markdown 渲染使用 `react-markdown``skipHtml`,依赖库对代码 span / fenced code 的文本转义;不得在整段 Markdown 上预转义 HTML,否则会把代码中的 `<tag>` 双重转义为字面量 `&lt;tag&gt;`
- 美术:PNG、JPEG、WEBP、GIF、SVG、AVIF、BMP、MP4、WebM、MOV 只在资源卡本体中按既有受控读取、文件签名与解码门禁展示;中央详情不重复加载或放大图片 / 视频本体。SVG 继续拒绝脚本、事件处理器、外部资源引用和实体声明。
- 音频:只读取 manifest 已登记音频或已成功导入且登记到 manifest 的附件,按文件签名接受 MP3、WAV、OGG / Opus、M4A、AAC、FLAC;聚焦态展示实际格式、浏览器解码后的时长以及带播放进度和暂停能力的内置播放器。音频任务声明中的未登记路径继续不得读取或播放。
- 版本:只展示 manifest 中正式、不可变的迭代版本记录;版本卡展示项目修订、创建原因与父版本,聚焦态同时展示直接子版本和资源绑定。点击版本卡后高亮仍存在于当前资源投影中的引用资源;缺失历史资源只保留绑定身份,不生成幽灵资源卡。资源替换仍留给后续切片
- 版本:只展示 manifest 中正式、不可变的迭代版本记录;版本卡展示项目修订、创建原因与父版本,聚焦态同时展示直接子版本和资源绑定。点击版本卡后高亮仍存在于当前资源投影中的引用资源;缺失历史资源只保留绑定身份,不生成幽灵资源卡。资源替换2026-09-11 实施)不在版本聚焦态提供,入口在资源卡选中工具条:选中被**当前版本**绑定的素材后替换成另一已登记素材,落盘为「改该版本的绑定、不建新版本」,详见 §5.3 与 §7.8
- mentor 最新决定:资源聚焦不提供工具栏,也不提供工具侧边栏。
- 音频:只读取 manifest 已登记音频或已成功导入且登记到 manifest 的附件,按文件签名接受 MP3、WAV、OGG / Opus、M4A、AAC、FLAC;聚焦态展示实际格式、浏览器解码后的时长以及带播放进度和暂停能力的内置播放器。音频任务声明中的未登记路径继续不得读取或播放。
- 版本:只展示 manifest 中正式、不可变的迭代版本记录;版本卡展示项目修订、创建原因与父版本,聚焦态同时展示直接子版本和资源绑定。点击版本卡后高亮仍存在于当前资源投影中的引用资源;缺失历史资源只保留绑定身份,不生成幽灵资源卡。资源编辑只允许追加继承源绑定并记录提示词的子版本,不允许原地替换或修改源版本。
- 版本:只展示 manifest 中正式、不可变的迭代版本记录;版本卡展示项目修订、创建原因与父版本,聚焦态同时展示直接子版本和资源绑定。点击版本卡后高亮仍存在于当前资源投影中的引用资源;缺失历史资源只保留绑定身份,不生成幽灵资源卡。资源编辑只允许追加继承源绑定并记录提示词的子版本,不允许原地替换或修改源版本;资源替换同样不改既有版本,而是追加一条 `resource-replacement` 子版本(§5.3
- 资源聚焦不提供通用工具栏或工具侧边栏;图片聚焦态允许一个明确的“精修资源”业务动作进入素材创作无限画布,该动作不是在聚焦容器中内嵌编辑器或恢复通用工具栏。
- 点击资源后,中央主视窗从 `resource-overview.list` 切换为 `resource-overview.focused.document / art / audio / version`,左侧平台导航、右侧 Supervisor 对话和底部 Agent 状态栏保持原位;聚焦容器以路径、类型、来源任务、依赖层级、同类型上下游和版本字段为首屏主体,不使用页面级浮层或可拖动标题栏。文档正文与按意图加载的音频控制位于元数据之后。
- 焦点转换以稳定资源 ID 为准。只有从资源列表进入详情或从一个资源 ID 切换到另一个 ID 时聚焦详情 region;同一资源 ID 因 manifest 更新而重新投影时,不得抢走详情内音频 / 视频控件、文档链接或收起按钮的当前焦点。
@@ -394,11 +396,26 @@ type ProjectVersionResourceReplacement = {
};
```
> `ProjectResourceDescriptor.category` 是**历史形状**(旧四分类轴),已被本节上方 2026-09-10 / 2026-09-11 的六分类收口取代;实现以 manifest `assets[].category` 的六分类与读时自愈口径为准,本 DTO 只保留作名词参照。`width / height / durationMs` 也只在该历史形状里存在,manifest 资产表今天并没有这些字段(见下方 `sizeSpecEqual` 的降级声明)。
三项兼容性必须同时为 true 才能创建下一版本。
> **实现状态(2026-09-11):上面这两段(`ProjectVersionResourceReplacement` 与"三项必须同时为 true 才能创建下一版本")作为未来合同保留;当前实现是「直接替换」**——改 manifest 里该版本的绑定指向另一个已登记资源,**不创建下一版本**,因此"才能创建下一版本"这一门槛今天不成立。今天的准入与提示见下方。
替换实现口径(2026-09-11**直接替换**):
- **落盘 = 改该版本的绑定**:不创建新版本、不删除版本、不动资源文件、不建文件副本;被改的是源版本自己的 `resourceBindings`,其余字段与其余版本整条不动,版本数量不变。写入走 §5.4 存储边界里的"版本绑定改写放行"(见该节的第二个例外)。
- **绑定改写 = 源素材从该版本的绑定集合里消失 + 保证替换素材在集合里**:恒等绑定口径下 `resourceBindings` 是"该版本使用的素材集合"而不是槽位表,所以替换素材是源版本创建之后才登记时按源素材原来的位置插回(顺序稳定),替换素材早已登记时只做摘除 —— 不能把源素材那条槽位改写成替换素材,那会撞「资源槽位重复」。
- **准入与提示**(后端权威,前端只呈现,不重算):
- **硬门禁**只有 `categoryEqual``subtypeEqual`:不满足则拒绝并说明哪一项不等,界面不得出现成功态。`categoryEqual` 用**读时自愈**口径(见本节 2026-09-11 收口),Rust 侧与 `packages/shared` 逐分支一致的实现是 `game_creation_app_asset_category_with_read_time_healing``subtypeEqual` 比较 canonical `kind`
- `sizeSpecEqual` **只作提示、不再拒绝**(提示文案「格式与源素材不同」):它的完整判据今天不存在 —— manifest 资产表没有 `width / height / durationMs`,现役写入侧(上传、派生、画板回传、生成回流)几乎全部写 `imageSequenceFrames: None`,实际只等于"媒体格式相等";用不完整到会误拒 `png ↔ webp` 的判据去挡替换是错的。要把它变成硬判据,必须先给 manifest asset 增尺寸字段并在写入侧回填(跨端契约变更),并把「缺失即未知」的口径写进契约。
- **失败即拒绝**:硬门禁任一项不等、源版本不存在、源版本未绑定该素材、替换素材未登记、替换素材与源素材相同、`expectedProjectId` / `expectedProjectRevision` CAS 冲突,都必须在写入前拒绝并说明原因;拒绝时 manifest、`versions` 与项目 revision 都不变,投影里不得出现新的版本卡或新的"当前使用"高亮。
- **落盘与留痕**:改绑定属于 `versions` 变化,写入后推进一次 `projectRevision`;并追加一条 `asset.version_binding.replace` 审计(`versionId / sourceResourceId / replacementResourceId / projectRevision`)。**没有可回溯的替换历史**:替换前的身份只有这条审计与 manifest 的 `.previous` 恢复副本。
- **入口与可见效果**:入口在资源卡选中工具条,只对「manifest 资产 + 被当前版本绑定」的素材放行;候选弹窗列出全部候选,命中硬门禁的项渲染但禁用并给出原因,只有尺寸规格差异的项仍可选但带提示。成功后**不切换版本**(没有新版本可切),只重读 manifest;可见变化是资源卡"当前使用"高亮移到替换素材、`@` 面板"当前版本素材"更新。按 §7.4 的口径,改绑定不做运行时资源重映射,运行画面本身不会因改绑定而变化;也不自动重载 / 重启运行中的预览(§3.2 末条)。
### 5.4 游戏迭代版本(P1
阶段六实现状态(2026-08-13 更新):正式版本业务真相扩展在本地项目 `.agent/manifest.json` 的可选 `versions` 字段中;旧项目字段缺失时等价于空列表,不根据 checkpoint、布局 sidecar、静态检查、失败试玩或单独的 `game-creator-project-revision.v1` 自动伪造版本。版本数组只允许追加,已有记录不得删除、重排或修改;首轮没有版本创建按钮。自主首板只有在当前 revision 的 `preview.validate` 已成功形成持久试玩回执后,才幂等追加首条 `initial` 版本,并绑定当时 manifest 中全部已登记资源;同一完成态恢复不得重复创建。已有正式版本时,后续试玩通过不自动追加版本,仍由明确的资源派生事务创建子版本。
阶段六实现状态(2026-08-13 更新):正式版本业务真相扩展在本地项目 `.agent/manifest.json` 的可选 `versions` 字段中;旧项目字段缺失时等价于空列表,不根据 checkpoint、布局 sidecar、静态检查、失败试玩或单独的 `game-creator-project-revision.v1` 自动伪造版本。版本数组只允许追加,已有记录不得删除、重排或修改**两个例外见下方存储边界两条**;首轮没有版本创建按钮。自主首板只有在当前 revision 的 `preview.validate` 已成功形成持久试玩回执后,才幂等追加首条 `initial` 版本,并绑定当时 manifest 中全部已登记资源;同一完成态恢复不得重复创建。已有正式版本时,后续试玩通过不自动追加版本,仍由明确的资源派生事务创建子版本。
```ts
type GameIterationVersion = {
@@ -421,6 +438,7 @@ type GameCreationAppManifest = {
- 同一版本内 `slotId` 唯一;`resourceId` 固定保存 manifest asset ID,不保存资源卡显示名称、External Editor resource ID、路径或布局 ID。历史资源已不在当前 manifest 时仍保留原绑定,但界面不为其合成资源卡。
- Tauri manifest 存储边界在每次写入前校验完整版本图,并与磁盘中的旧 `versions` 前缀逐项比较;只允许追加新记录,已有记录被修改、删除或重排时写入失败且原文件保持不变。
- 2026-09-10 补充)上述"只允许追加"有且只有一个例外:**用户在删除素材时显式确认"把相关游戏版本一并删除"**。该路径下,只有引用被删素材的那些版本允许消失,其余既有版本仍必须原样、原顺序保留,新增版本仍只能追加在末尾;放行集合按**写入前的 manifest** 求值,且被放行的版本不得出现在追加段里(防止"删除后重排"绕过校验)。除该路径外,任何版本删除、修改、重排仍然失败关闭。
- 2026-09-11 补充,当前状态)"只允许追加"现在有**两个**例外,第二个比第一个更窄:**直接替换资源时,显式放行列出的版本允许改写自己的 `resourceBindings`**`mutate_manifest_allowing_version_binding_rewrites`)。它的不变式是:不增不删(候选与磁盘的版本数量必须相等)、不重排(版本 ID 序列逐项相同)、只有放行清单里的版本允许 `resourceBindings` 不同、其余字段(`versionId / parentVersionId / projectRevision / createdReason / createdAt / editPrompt`)逐字段相等、未放行版本整条相等、放行集合按**写入前的 manifest** 求值、与"放行删除"集合**互斥**(同一版本 ID 不得既被放行删除又被放行改写绑定)。这条通道只服务"改绑定",任何版本的新增、删除、重排仍然失败关闭,删除放行通道也不因此获得改写能力。
- 版本卡标题由稳定追加序号生成,卡片与聚焦态展示 `versionId / projectRevision / createdReason / parentVersionId`;聚焦态额外展示直接子版本和全部 slot 绑定。点击版本卡只高亮当前投影中唯一匹配 `asset:<resourceId>` 的资源卡,不修改版本或资源。
### 5.5 测试切片与数值参数(P2)
@@ -483,7 +501,7 @@ type ProjectAgentMudPointAttribution = {
- 已实施依赖/类型两套坐标持久化、首次默认不重叠布局、历史坐标跨重启恢复、自动协调 CAS 冲突处理与资源卡手动拖动。
- 资源关系线在布局持久化验收通过后单独实施,不与本切片捆绑伪造完成。
- 已实施正式版本不可变模型、版本卡、父子关系与引用资源高亮;“编辑资源”可追加继承源绑定并记录提示词的子版本资源直接替换、运行版本切换和兼容性迁移仍待后续切片。
- 已实施正式版本不可变模型、版本卡、父子关系与引用资源高亮;“编辑资源”可追加继承源绑定并记录提示词的子版本资源替换(2026-09-11)已按**直接替换**口径实施为「改该版本的绑定、不建新版本」(§5.3 / §7.8),入口在资源卡选中工具条;运行版本切换(C7)已完成;§3.2 的版本级替换与兼容性迁移仍待后续切片。
- 素材创作无限画布阶段一按权威专题一次交付图片导入、编辑、生成、导出、草稿恢复、正式本地回写、即时投影和焦点竞态闭环。
- 高级抠图、图集、角色动画、视频时间线编辑和音频波形级编辑按后续切片实施;当前视频走源引用派生,音频只做语义重制。
@@ -540,7 +558,7 @@ type ProjectAgentMudPointAttribution = {
1. manifest 缺少 `versions` 时旧项目正常打开且不显示伪造版本;存在合法记录时,固定“项目版本”分区按追加顺序显示稳定版本卡。
2. 根版本、父版本和直接子版本关系在卡片或聚焦态可见;悬空父版本、自引用、重复 ID、非递增修订、倒退时间、重复 slot 和超限数字均失败关闭。
3. 点击版本卡后,当前 manifest 中仍存在的绑定资产卡被高亮;历史已删除资产只在版本详情保留 ID,不创建幽灵卡,也不把 External Editor resource ID 猜成 manifest asset ID。
4. 版本聚焦态展示身份、修订、创建原因、父子关系、创建时间和 slot 绑定;“编辑资源”只追加继承源绑定并记录提示词的子版本,不提供原地替换、切换、回滚或运行按钮。
4. 版本聚焦态展示身份、修订、创建原因、父子关系、创建时间和 slot 绑定;“编辑资源”只追加继承源绑定并记录提示词的子版本,不提供原地替换、切换、回滚或运行按钮。资源替换**也不在版本聚焦态提供**:它的入口是资源卡选中工具条(§5.3 / §7.8),本条限制的是版本聚焦态这个容器,不限制资源卡入口。
5. 任意现有 manifest 写入只能保留磁盘版本前缀并追加新记录;存储边界以跨进程专用锁串行覆盖旧状态读取、前缀校验、安装和回读,修改、删除、重排或并发旧快照覆盖已有版本时写入失败。
6. 版本选择和高亮不写 manifest、布局 sidecar 或 project revisiondependency / type 两种布局都可显示绑定高亮,既有依赖关系 SVG 语义不变。
@@ -592,6 +610,16 @@ type ProjectAgentMudPointAttribution = {
8. 右侧正式钱包入口在普通工作台和 UI Editor 子路由都始终可见、键盘可达并能打开余额、充值与使用详情;路由切换不得使入口消失或失去交互。
9. “资源依赖 / 资源类型”以连通分段按钮呈现,点击与键盘操作均只保留一个选中项;使用 Tab 定位和键盘切换时焦点指示清晰、完整,不被容器边界或 `overflow` 裁切。
### 7.8 P1 资源替换验收(直接替换)
1. 入口只对「manifest 资产 + 被当前版本绑定」的素材渲染;未绑定素材(版本创建后才登记、或不属于当前版本绑定集合)一律不出现入口,也不出现"点了没反应"的假按钮。
2. 候选弹窗列出后端返回的全部候选:命中硬门禁(分类 / 类型)的项**渲染但禁用**并显示不等维度,只有尺寸规格差异的项**仍可选**并带「格式与源素材不同」的提示;不得隐藏不可选候选,不得合成非候选素材,不得在前端重算判据。
3. 写入后的 manifest:**版本数量不变**(不新增、不删除),被替换的那个版本除 `resourceBindings` 外逐字段不变(`versionId` / `parentVersionId` / `projectRevision` / `createdReason` / `createdAt` / `editPrompt`),其余版本整条不变;项目 revision 推进一格。
4. 绑定改写 = 源素材从该版本的绑定集合里消失 + 保证替换素材在集合里;不得出现重复 `slotId`。替换素材在源版本创建后才登记时,差异恰好"一减一增"且顺序稳定;替换素材早已登记时只少掉源素材那一条。
5. 硬门禁任一为 false、源版本不存在、源版本未绑定该素材、替换素材未登记、替换素材与源素材相同、`expectedProjectId` / `expectedProjectRevision` 冲突,都必须在写入前拒绝;拒绝时 manifest、`versions` 与项目 revision 都不变,投影里不得出现新的"当前使用"高亮或版本卡变化。
6. 成功后重读 manifest 并**不切换版本**(没有新版本可切);**不**自动重载或重启运行中的预览(§3.2 末条),也不做运行时资源重映射。可见变化只有:资源卡"当前使用"高亮移到替换素材、`@` 面板"当前版本素材"更新。
7. 替换成功后追加一条 `asset.version_binding.replace` 审计(`versionId / sourceResourceId / replacementResourceId / projectRevision`);**替换历史不可回溯**——替换前身份只有这条审计与 manifest 的 `.previous` 副本,不得声称能查到"某版本历史上换过什么"。
## 8. 非目标
- 当前不开放“新增资源”产品入口。图片精修候选保持私有且不污染资源总览;“设为最终图”只允许保持原 asset ID、以新正式文件和事务方式切换 manifest 指针,不原地覆写旧文件。其他资源编辑继续追加派生 asset 或子版本。
@@ -8494,3 +8494,28 @@ CI 上 `background_agent_runtime_recovers_stale_running_before_pending_task` 在
- 原因:预览起停不是源码变更,不能靠"给它也推一次 revision"来消除冲突——那会让运行时的验证凭证(`expected_revision` / `verified_revision` / `failed_playtest_revision`)凭空漂移,把自主构建流程拖进无谓的重新验证。正确的修法是把判据面收回到契约所说的对象,而不是让簿记写入承担源码语义。
- 验证:`projectResourceLiveUpdateModel.test.ts` 新增「同 revision 只有 `preview` 变化必须被接受」与「同 revision 资产变化必须仍被拒收」两条;变异回整份 JSON 指纹后前者以 `expected 'revision-conflict' to be 'accepted'` 失败。定向:模型 17 passed、workspaceLauncherManifestMerge 5 passed、appSurface 413 passed、typecheck exit 0、check:encoding 4399 files passed。
- 影响范围:`apps/ai-game-creator-shell/src/view/project-development/projectResourceLiveUpdateModel.ts``apps/ai-game-creator-shell/tests/projectResourceLiveUpdateModel.test.ts``pitfalls.md` 本条。
## 2026-09-11 AGC 资源替换按 PRD 恢复为「版本级替换」:改绑定落在追加的新版本上
- 背景:PRD §3.2`docs/prd/【AI游戏创作】项目开发工作台PRD-2026-07-20.md:36-41`)要求「替换版本引用资源时创建下一迭代版本,不原地修改既有版本」,并要求新版本记录 `parentVersionId`、替换前后资源身份与创建原因;§5.3(:356-387)给出 `ProjectVersionResourceReplacement` 与「三项兼容性必须同时为 true 才能创建下一版本」。本文件 2026-09-10 那条(`decision-log.md:8263`)与 Issue #309 的 C1 决策 / 贯穿性决策 6 / 验收总纲当时写的是相反口径:「改 manifest 绑定……**不创建新版本**」「替换功能(候选素材 / 替换关系 / 替换队列 / Agent 审核 / 单点替换)整条取消,不实现」。用户裁决:**按 PRD 来**,实际机制仍是「改 manifest 绑定」。
- 决策:资源替换 = **改绑定 + 追加下一迭代版本**。新命令 `replace_local_project_version_resource`(写,`asset.register`)在持项目写锁与 `expectedProjectId + expectedProjectRevision` CAS 下,一次 `mutate_manifest_at` 写入里追加 `createdReason='resource-replacement'` 的子版本(`parentVersionId` 指向被替换的源版本),子版本绑定 = 源版本绑定**去掉源素材**并保证**替换素材在集合里**;只读命令 `read_local_project_version_replacement_candidates``asset.list`)返回候选与后端权威的三项兼容性。既有版本记录一个字节不改,**继续走默认空放行集合**,不新增/放宽 `mutate_manifest_at_allowing_version_removals`
- 恒等绑定口径的硬约束(实测得出):每个版本的 `resourceBindings` 是「该版本使用的素材集合」而不是槽位表,替换素材若在源版本创建时就已登记,它本来就在集合里,因此**不能**把源素材那条槽位改写成替换素材(会撞「资源槽位重复」);正确落盘是「源素材从集合里消失 + 替换素材在集合里」,替换素材是后来才登记时按源素材原位置插回。
- 三项兼容性判据:`categoryEqual` 用 PRD §5.3 的**读时自愈**口径(为此在 `server-rs/crates/shared-contracts` 新增 `game_creation_app_asset_category_with_read_time_healing`,与 `packages/shared``gameCreationAppAssetCategory` 逐分支一致并配用例锁定——此前 Rust 反序列化只有「缺失/非法 → 按 kind 派生」,没有自愈,两侧口径并不一致);`subtypeEqual` 比较 canonical `kind``sizeSpecEqual` 比较规范化媒体格式 + 已知帧宽高 + 已知时长,任一方有事实的维度必须相等。
- 已知降级(刻意接受,不得当成完整实现):manifest 资产表没有 `width / height / durationMs`,现役写入侧几乎全写 `imageSequenceFrames: None`,所以 `sizeSpecEqual` 在真实数据上退化为**媒体格式相等**(`png ↔ webp` 会被拒)。要支持跨图片格式替换,必须先给 manifest asset 增尺寸字段并在写入侧回填(跨端契约变更)。
- 替换前后资源身份:按 PRD §5.4 的版本字段表口径**用推导记录**,不新增 manifest / 跨端契约字段(`父 子 = {源素材}``子 父 = {替换素材}`,配对由 `parentVersionId` + `createdReason` 确定)。已知限制:替换素材在源版本创建时就已登记时 `子 父` 为空集,版本记录无法单独反推配对;要无歧义持久化配对须先给 `GameIterationVersion` 增字段。
- 边界:C6 的候选素材 / 替换关系图 / 替换队列 / Agent 审核 / 批量提交**仍然不做**(#309 该部分不变);§3.2 末条「当前运行中的版本不消费尚未生成的新版本变更」保持——成功后只切记录层当前版本,不自动重载/重启预览,也不做运行时资源重映射。入口只在资源卡选中工具条、且只对「manifest 资产 + 被当前版本绑定」的素材渲染;候选弹窗复用美术画布的 `ImageCanvasProjectAssetPickerDialog`(AGC 首次使用),以可选 prop 扩展且默认值保持网页端行为逐字不变。
- 影响范围:`server-rs/crates/shared-contracts/src/game_creation_app.rs``src-tauri/src/project/version_resource_replacement.rs`(新增)、`commands.rs``main.rs``tests/version_resource_replacement.rs`(新增)、`src/features/resource-canvas/resourceVersionReplacement{Model,Transport}.ts`(新增)、`view/project-development/index.tsx``src/components/image-editor/ImageCanvasProjectAssetPickerDialog.tsx``apps/ai-game-creator-shell/src/styles.css`
- 验证方式:Rust 定向 8 条 + `shared-contracts` 20 条;变异验证三条(去掉媒体格式维度 → 2 条转红;绕开读时自愈 → 自愈用例转红;改成原地改既有版本 → 4 条转红并被「项目版本记录写入后不可修改、删除或重排」拦下,证明只追加守卫真的在挡)。前端新增 12 条,变异验证两条(放宽入口判据 → 「不给假按钮」转红;失败路径静默关弹窗 → 「保留弹窗显示原因」转红)。AGC 全量 1231 passed / 4 skipped / 0 failed;共享美术画布组件 1385 passedtypecheck / `cargo check --locked --all-targets` / `check:encoding` / `git diff --check` 全绿。跨端契约、SpacetimeDB schema、manifest 结构与布局 sidecar schema 均未改动。
- 关联文档:`docs/prd/【AI游戏创作】项目开发工作台PRD-2026-07-20.md`(§3.2 保持,§5.3 补实现口径与降级声明,§7.8 新增替换验收)、`docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md`(新增同章节)、`docs/technical/【测试用例】AGC资源工作台V3端到端验收-2026-09-11.md`(§7.3 第 1 条已按本次口径改写)、Issue #309
## 2026-09-11 AGC 资源替换改成「直接替换」:只改该版本的绑定,不建新版本
- 背景:同日上午按 PRD §3.2 / §5.3 落地的「版本级替换(改绑定 + 追加下一迭代版本)」,当天下午被用户改口径:「**替换这块先做成直接替换**」(同日 DDL:替换先做,运行画面点选、切图集像素规整先不做)。直接替换 = 改 manifest 里该版本的绑定指向另一个已登记资源,**不追加新版本**,也就是 #309 原先那条「更换资源 = 改 manifest 绑定」的轻形态。
- 决策(实现):删除版本级路径(按「四不写」:不建 `replace-{revision}` 版本、不写 `parentVersionId`、不写 `createdReason=resource-replacement`、不留兼容分支)。直接替换走**新增的第二条、更窄的版本放行通道** `mutate_manifest_allowing_version_binding_rewrites`:不增不删(版本数量必须相等)、不重排(版本 ID 序列逐项相同)、只有显式放行列出的版本允许 `resourceBindings` 不同、其余字段(`versionId / parentVersionId / projectRevision / createdReason / createdAt / editPrompt`)逐字段相等、未放行版本整条相等、放行集合取自**写入前** manifest、与删除放行集合**互斥**。既有 `validate_version_records_are_append_only` 与既有删除放行通道的语义一行未改;唯一结构性改动是把写盘公共体抽成带校验器参数的内部函数(`write_manifest_locked` 变成一行委托),装盘 / 回读 / 原子替换仍只有一份实现。
- 恒等绑定口径的硬约束(实测得出,两个形态共用):每个版本的 `resourceBindings` 是「该版本使用的素材集合」而不是槽位表,所以绑定改写 = **源素材从集合里消失 + 保证替换素材在集合里**;不能把源素材那条槽位改写成替换素材(替换素材若早已登记就会撞「资源槽位重复」)。
- 准入与提示:硬门禁只有 `categoryEqual``subtypeEqual`(拒绝并说明哪一项不等);`sizeSpecEqual` **降级为提示**(「格式与源素材不同」)不再拒绝。理由:它的完整判据今天不存在(manifest 资产表没有 `width / height / durationMs`,现役写入侧几乎全写 `imageSequenceFrames: None`,实际只等于"媒体格式相等"),硬拦会误拒 `png ↔ webp` 这类直接替换里最常见的需求。要变成硬判据须先给 manifest asset 增尺寸字段并在写入侧回填(跨端契约变更)。
- 存储与留痕:改绑定属于 `versions` 变化,因此写入后**推进一次 project revision**(跨面快照门禁要求 revision 前进),并追加一条 `asset.version_binding.replace` 审计(复用既有 `append_agent_db_record`,字段 `versionId / sourceResourceId / replacementResourceId / projectRevision`)。审计写失败会报错但不回滚,与 `asset.register` 同口径。
- **代价(用户已确认接受,记账备查):直接替换没有可回溯的替换历史。** 上一轮版本级形态里"父版本绑定"就是那份历史;现在替换前的身份只存在于 ① 那条审计记录 ② manifest 的 `.previous` 恢复副本(只保留上一次写入)。需要"某个版本历史上被换过几次、换成过什么"时必须另立切片(PRD §3.2 / §5.3 保留为未来合同正是为此)。
- 文档口径:PRD §3.2 的两条与 §5.3 的 `ProjectVersionResourceReplacement` / "三项兼容性必须同时为 true 才能创建下一版本"**保留为未来合同**并就地标注「当前实现为直接替换(2026-09-11),版本级替换暂缓」;PRD 的**存储边界**("只允许追加…有且只有一个例外")改写成当前状态——现在是**两个例外**(删除素材时连带删版本 / 显式放行版本的绑定改写),因为它是"现在写入校验行为"的描述,留未来式会误导。
- 影响范围:`src-tauri/src/project/manifest.rs``src-tauri/src/project/manifest/version_binding_rewrite_tests.rs`(新增)、`src-tauri/src/project/version_resource_replacement.rs``src-tauri/src/tests/version_resource_replacement.rs``src/features/resource-canvas/resourceVersionReplacement{Model,Transport}.ts``view/project-development/index.tsx``src/components/image-editor/ImageCanvasProjectAssetPickerDialog.tsx``apps/ai-game-creator-shell/src/styles.css`
- 验证方式:放行通道定向 7 条 + 替换定向 8 条 + `shared-contracts` 20 条;**变异验证四条**(去掉"未放行版本整条相等"→两周转红;放行集合改成整个版本数组→"未放行版本"转红;去掉长度检查→"不增不删"转红;准入删掉 category→硬门禁与候选两条转红,均已实测并还原)。前端 13 条(模型 9 + 真链路 4)。门禁:AGC 全量 1231 passed / 4 skipped / 0 failed、共享美术画布组件 1385 passed、`ai-game-creator-shell:typecheck`(含 check-config)、`cargo check --locked --all-targets``check:encoding``git diff --check` 全绿。
- 关联文档:`docs/prd/【AI游戏创作】项目开发工作台PRD-2026-07-20.md`(§3.2 / §5.3 / §5.4 存储边界 / §7.8 验收)、`docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md`(同章节)、`docs/technical/【技术方案】AGC资源派生与非破坏性编辑合同-2026-09-09.md``docs/technical/【测试用例】AGC资源工作台V3端到端验收-2026-09-11.md`、Issue #309
@@ -25,7 +25,7 @@
- 文本、SVG 与 Agent 回执在 Provider 调用前必须先持久化 request-issued;成功响应必须先原子安装到与原 operation、请求指纹和内容摘要绑定的私有 durable handoff,再做 envelope 解析、格式校验和 staging。issued 后缺少可信 handoff 只能对账;handoff 已存在且校验通过时恢复只消费该正文,两种情况都禁止再次调用 Provider。
- 普通客户端视频使用 `POST /api/editor/videos/generations`。有稳定远端引用时直接作为 `referenceVideoSrcs`,只有本地文件时先走 `/api/assets/direct-upload-tickets`、OSS 表单上传和 `/api/assets/objects/confirm`,再提交同一逻辑生成;结果必须下载到新的本地文件并登记远端稳定身份。
- 普通客户端音效和背景音乐分别使用 `POST /api/editor/audios/sound-effects/generations``POST /api/editor/audios/background-music/generations`。当前接口没有源音频引用字段,因此产品语义固定为"基于原资源语义的派生重制",界面不得描述为对源波形的裁剪、变声或局部修改;结果仍必须引用源资源身份并保留原音频。
- 项目版本编辑固定追加 `parentVersionId` 指向源版本的子版本,继承源版本资源绑定并记录本轮编辑提示;已有版本数组元素不可修改、删除或重排。
- 项目版本编辑固定追加 `parentVersionId` 指向源版本的子版本,继承源版本资源绑定并记录本轮编辑提示;已有版本数组元素不可修改、删除或重排(两个例外见 PRD §5.4 的存储边界两条:删除素材时连带删版本、资源直接替换时改写该版本的 `resourceBindings`
- 角色动画派生结果的 manifest `kind` 固定写 `character-animation``source.generationKind` 同步保留 `character-animation`,媒体仍以预览视频加正式序列帧登记。图片编辑等普通派生继续继承源资源语义 kind;后续如需改写为 canonical kind,只能在读取投影或显式迁移中完成,不重写历史 manifest。
- 图片编辑、视频、音效和背景音乐请求统一在 `generationInputs.source` 写入专用消费身份 `game-creator-resource-editor`;普通内部路由和高级 External 路由都必须实际读取并传递同一稳定 `Idempotency-Key`。队列完成态只向该消费身份返回经过裁剪的稳定 `objectKey / resource / asset` 引用和必要媒体元数据,不暴露 provider、worker、队列内部字段或临时签名 URL。
@@ -638,7 +638,7 @@ game-project/
- 五栏目页签的未读红点由 `ProjectDevelopmentView` 在会话内比较各栏目稳定资源 ID 派生:首次快照只建立基线,只有非当前栏目后续出现新 ID 才加入未读集合;所有栏目切换路径统一以当前栏目变化清除对应未读,项目 scope 变化时重建基线并清空。该状态以 `projectPath + projectId` 隔离,不进入 manifest、布局 sidecar、localStorage、sessionStorage 或后端契约。
- type 默认布局固定按 `subtype -> mediaType -> label -> id` 排序。manifest 资产的 subtype 使用 `asset.kind`,任务产物、导入附件和 Agent 文本成果使用稳定的来源 fallback;subtype 必须进入资源协调签名,不能因 MIME 相同而退化成按名称混排。
- 自动协调保存失败时保留当前会话布局;CAS 冲突载入对方最新布局,需要继续协调时最多追加两次重试,持续跨窗口竞争时停止自旋。用户提示只说明“布局已在其他窗口更新”,不要求重新拖动。损坏、未知 schema、身份冲突、超限与链接文件失败关闭,不能用空布局覆盖原文件。
- 本布局持久化切片不包含资源关系线、资源替换、聚焦态持久化、分区高度、分区内容倍率、整个画布平移、搜索 / 筛选条件、当前 mode,也不修改 `api-server` 或 SpacetimeDB。资源关系线、当前会话内中央聚焦、分区独立高度与内容倍率已在后续独立前端切片接入,都不改变本段 sidecar 合同;其余 P1 能力继续独立实施。
- 本布局持久化切片不包含资源关系线、资源替换、聚焦态持久化、分区高度、分区内容倍率、整个画布平移、搜索 / 筛选条件、当前 mode,也不修改 `api-server` 或 SpacetimeDB。资源关系线、当前会话内中央聚焦、分区独立高度与内容倍率已在后续独立前端切片接入,都不改变本段 sidecar 合同;其余 P1 能力继续独立实施。**资源替换已在 2026-09-11 按「直接替换」口径独立实施**(见本文末「资源替换」一节),同样不改布局 sidecar 合同。
- 本资源总览布局 sidecar 不包含资源关系线、资源替换、聚焦态持久化、资源总览缩放 / 平移、搜索 / 筛选条件、当前 mode,也不修改 `api-server` 或 SpacetimeDB。资源关系线与当前会话内中央聚焦已在后续独立前端切片接入;素材创作 viewport 和图层使用独立草稿 schema,不能写入 `game-creator-resource-layout.v1`
历史实施顺序已完成 TypeScript / Rust DTO、Tauri sidecar/CAS、前端纯模型与持久 Hook。二维手动拖动接线现已暂缓;重新开放前必须先更新 PRD 与验收合同。任何后续步骤不得用 `localStorage`、manifest 字段或只在当前 React 会话有效的状态冒充项目持久化。
@@ -666,7 +666,7 @@ game-project/
2026-08-03 阶段六:正式迭代版本直接扩展本地 `.agent/manifest.json`,不新增 checkpoint / layout sidecar / SpacetimeDB 平行业务真相。共享 Rust / TypeScript 合同新增可选 `versions: GameIterationVersion[]`;旧项目缺失字段时只读为空,不回填。Rust 在 manifest 读写边界校验版本唯一性、父先于子、根/原因一致、父子修订与时间单调、slot 唯一和 JavaScript 安全整数,并在覆盖已有 manifest 前要求磁盘版本数组是新数组的逐项相等前缀,从存储边界保证历史记录不可修改、删除或重排。2026-08-13 起,自主首板在当前 revision 的 `preview.validate` 成功结果和持久试玩回执均落盘后,幂等追加唯一首条 `initial` 版本,并以稳定 `asset:<manifest asset id>` 槽位绑定当时全部已登记资源;失败试玩、静态 smoke、checkpoint 和普通预览状态不得触发版本创建,恢复重放和已有版本项目也不得重复追加。
工作台资源投影只从 `manifest.versions` 构建版本卡,按数组追加顺序生成稳定“版本 N”标题;不再接收前端独立 `projectVersions` 注入。`resourceBindings.resourceId` 只解释为 manifest asset ID,并映射到现有 `asset:<id>` 卡片。选中版本后在 dependency / type 两种布局中高亮当前仍存在的绑定资产;缺失历史资产只留在版本聚焦详情,不能合成幽灵卡或猜测 External Editor resource ID。版本聚焦复用中央只读容器,展示身份、修订、原因、父版本、直接子版本、创建时间与 slot 绑定。本阶段不提供版本创建、替换、切换、回滚、测试切片或运行态消费入口。
工作台资源投影只从 `manifest.versions` 构建版本卡,按数组追加顺序生成稳定“版本 N”标题;不再接收前端独立 `projectVersions` 注入。`resourceBindings.resourceId` 只解释为 manifest asset ID,并映射到现有 `asset:<id>` 卡片。选中版本后在 dependency / type 两种布局中高亮当前仍存在的绑定资产;缺失历史资产只留在版本聚焦详情,不能合成幽灵卡或猜测 External Editor resource ID。版本聚焦复用中央只读容器,展示身份、修订、原因、父版本、直接子版本、创建时间与 slot 绑定。**本阶段**不提供版本创建、替换、切换、回滚、测试切片或运行态消费入口;其中**运行版本切换、资源创建与资源替换**已由 2026-09-10 / 2026-09-11 的后续切片补齐(替换按「直接替换」口径,见本文末一节),版本聚焦态本身仍不提供创建、替换、切换、回滚按钮
历史命令式 drag preview 句柄与局部连接索引可以保留,但项目工作台不再向资源卡传入该入口。拖动热路径、4096 张真实卡片拖动重渲染和 Chromium p95 门槛统一暂缓;当前回归只要求 Pointer Move 不改变卡片坐标、SVG path 或布局 revision。`ResizeObserver` 仍保持单图层单实例,任何实时 DOM 几何都不得通过 Tauri IPC 往返 Rust。
@@ -1340,3 +1340,15 @@ DirectProject 使用 `approvalPolicy=never`,避免每次原生调用再经过
- AGC 前端请求客户端配套后端的鉴权 API(包括 `/api/llm/models`)收到 `401` 时,共享进行中的 refresh 请求;确认当前用户并安装 Rust / Runner 会话后,用新 access token 最多重试原请求一次。`403` 权限拒绝不触发续期;续期失败保留原鉴权错误,账号切换或登出后不重发旧请求。
- DirectProject 的 Rust/app-server 对话调用返回鉴权失效时,前端先刷新客户端平台会话并重新提交同一 `clientTurnId`;平台会话代次变化后由 app-server pool 使用新 access token 建立连接,避免长时间运行后必须重新登录。
## 2026-09-11 AGC 资源工作台 V3:资源替换(**直接替换**:只改该版本的绑定,不建新版本)
- **口径**:用户当日 DDL 口径「替换这块先做成直接替换」→ 改 `.agent/manifest.json` 里该版本的绑定指向另一个已登记资源:**不创建新版本、不删除版本、不动资源文件、不建文件副本**。PRD §3.2 的「创建下一迭代版本」与 §5.3 的 `ProjectVersionResourceReplacement` /「三项兼容性必须同时为 true 才能创建下一版本」**保留为未来合同**,今天不实现;Issue #309 的 C6(候选素材 / 替换关系 / 替换队列 / Agent 审核 / 批量提交)仍然不做。
- **写入通道(新增的第二条、也是更窄的版本放行口)**`project/manifest.rs``mutate_manifest_allowing_version_binding_rewrites` + `validate_version_records_allow_binding_rewrites`。不变式:**不增不删**(候选与磁盘版本数量必须相等)、**不重排**(版本 ID 序列逐项相同)、**只有放行清单里的版本**允许 `resourceBindings` 不同、其余字段(`versionId / parentVersionId / projectRevision / createdReason / createdAt / editPrompt`)逐字段相等、**未放行版本整条相等**、放行集合取自**写入前** manifest、与删除放行集合**互斥**。既有 `validate_version_records_are_append_only` 与既有 `mutate_manifest_at_allowing_version_removals` 语义一行未改;唯一结构性改动是把写盘公共体抽成 `write_manifest_locked_with_version_guard`(校验器由调用方注入,`write_manifest_locked` 变成一行委托),装盘 / 回读 / 原子替换仍只有一份实现。
- **命令**`read_local_project_version_resource_replacement_candidates`(只读,`asset.list`)与 `replace_local_project_version_resource`(写,`asset.register`),实现在 `src-tauri/src/project/version_resource_replacement.rs`。入参都是单个 `input` 对象、`deny_unknown_fields`:读 `{projectPath, sourceVersionId, sourceResourceId}`,写额外要求 `expectedProjectId + expectedProjectRevision + replacementResourceId`;出参 `{versionId, committedProjectRevision, replacement:{versionId, sourceResourceId, replacementResourceId, compatibility, warning}}`**没有** `parentVersionId`,因为没有新版本)。这两个 DTO 是 Tauri 本地 DTO**不进跨端契约**。
- **写入语义**:持项目写锁(与删除 / 重命名 / 标签同一把)→ 锁外与锁内各复核一次 `projectId`,锁内复核 durable revisionCAS 失败报 `project-identity-conflict` / `project-revision-conflict` 且零写入)→ 走上面的绑定改写通道,放行集合固定为 `[sourceVersionId]` → 成功后推进一次项目 revision(改绑定属于 `versions` 变化,跨面快照门禁要求 revision 前进)→ 追加一条 `asset.version_binding.replace` 审计(复用既有 `append_agent_db_record`)。审计写失败会报错但不回滚,与 `asset.register` 同口径。
- **绑定改写语义**(恒等绑定口径的硬约束):`resourceBindings` 是"该版本使用的素材集合"而不是槽位表,因此改写 = **源素材从集合里消失 + 保证替换素材在集合里**;替换素材是源版本创建之后才登记时按源素材原来的位置插回(顺序稳定),早已登记时只做摘除 —— 不能把源素材那条槽位改写成替换素材,那会撞「资源槽位重复」。
- **准入与提示(后端权威,前端只呈现)****硬门禁**只有 `categoryEqual`(用 PRD §5.3 的**读时自愈**口径,Rust 侧 `game_creation_app_asset_category_with_read_time_healing``packages/shared``gameCreationAppAssetCategory` 逐分支一致并配用例锁定)与 `subtypeEqual`canonical `kind`);`sizeSpecEqual` **降级为提示**(「格式与源素材不同」)不再拒绝 —— 它的完整判据今天不存在(manifest 没有 `width / height / durationMs`,现役写入侧几乎全写 `imageSequenceFrames: None`,实际只等于"媒体格式相等"),硬拦会误拒 `png ↔ webp` 这类最常见需求;要变成硬判据须先给 manifest asset 增尺寸字段并在写入侧回填(跨端契约变更)。
- **入口**:资源卡选中工具条的宿主 `extraActions` 新增「替换素材」,判据复用现役 `isResourceUsedByCurrentVersion`manifest 身份 + 被当前版本绑定),未绑定素材不渲染入口;不动 `ImageCanvasSelectedLayerToolbarAction` 共享 union、不改 `resourceCanvasToolbarModel``supportedActions`。候选弹窗复用 `ImageCanvasProjectAssetPickerDialog`(该弹窗在 AGC 侧首次使用),以可选 prop 扩展:`singleSelect` / `assetBlockedReasons` / `assetHints` / `renderAssetMedia` / `selectionNoun` / `errorMessage`,**默认值保持网页端美术画布行为逐字不变**。候选行渲染类型占位而不挂 `<img>`(AGC 的预览要经带 scope 的原生调度器拿 Blob URL,弹窗内没有同步 `src`)。
- **成功后行为**:重读 manifest,**不切换版本**(没有新版本可切),**不自动重载 / 重启运行中的预览**(PRD §3.2 末条),不做运行时资源重映射。可见变化只有资源卡「当前使用」高亮移到替换素材、`@` 面板「当前版本素材」更新。
- **已知代价(用户已确认接受)**:**替换历史不可回溯**——替换前身份只剩那条审计与 manifest 的 `.previous` 副本;需要"某版本历史上换过什么"时要另立切片(PRD §3.2 / §5.3 保留为未来合同正是为此)。
- **验证**:绑定改写通道定向 7 条(`project/manifest/version_binding_rewrite_tests.rs`+ 替换定向 8 条 + `shared-contracts` 20 条。**变异验证**:① 去掉「未放行版本整条相等」→ 两周转红;② 放行集合改成整个版本数组 → 「未放行版本」转红;③ 去掉长度检查 → 「不增不删」转红;④ 准入删掉 category → 硬门禁与候选两条转红;另有 ⑤ 改回"原地改既有版本但绕过放行口"→ 被「项目版本记录写入后不可修改、删除或重排」拦下。前端 13 条(模型 9 + 真链路 4)。AGC 全量 1231 passed / 4 skipped / 0 failed;共享美术画布组件 1385 passed`npm run ai-game-creator-shell:typecheck`(含 check-config)、`cargo check --locked --all-targets``npm run check:encoding``git diff --check` 全绿。`/api/external/v1`、SpacetimeDB schema、`packages/shared``shared-contracts` 的 wire DTO、manifest 结构、布局 sidecar schema 均未改动。
@@ -231,15 +231,19 @@ api-server 是否本次重启:□ 是 □ 否
### 7.3 已知未做 / 已取消(不要报成缺陷)
1. **C6 替换关系替换队列整条取消**#309)。PRD L387`ProjectVersionResourceReplacement` 兼容性三项属该取消范围
1. **C6 的候选素材 / 替换关系 / 替换队列 / Agent 审核 / 批量提交整条取消**#309)。PRD §5.3`ProjectVersionResourceReplacement` 与 §3.2 的「创建下一迭代版本」**当前不实现**(2026-09-11 用户 DDL 口径「替换这块先做成直接替换」),但**保留为未来合同**:今天的实现是「改该版本的绑定,不建新版本」,验收见 PRD §7.8
2. **画布生成入口只覆盖 3 类**(视频 / 音效 / 背景音乐);图片等类型会直接报"当前资源类型不支持无源生成",这是设计而非缺陷。
3. **工具条 7 个动作按 opt-in 不渲染**:重绘 / 裁剪扩图 / 去背景 / 像素完美 / 切图集 / 提取 UI 素材 / 角色动画;只有 `快速编辑``下载` 真渲染(宿主另附加 `UI 编辑器` / `编辑标签` / `重命名`)。
4. **C7 只做记录层 + UI 层**:切换版本即重载当前预览;版本化资源解析机制未实现。
3. **工具条 7 个动作按 opt-in 不渲染**:重绘 / 裁剪扩图 / 去背景 / 像素完美 / 切图集 / 提取 UI 素材 / 角色动画;只有 `快速编辑``下载` 真渲染(宿主另附加 `UI 编辑器` / `编辑标签` / `重命名`,以及**只在素材被当前版本绑定时**出现的 `替换素材`)。
4. **C7 只做记录层 + UI 层**:切换版本即重载当前预览;版本化资源解析机制未实现。资源替换同样不做运行时资源重映射:改绑定只改「这个版本用哪些素材」的记录,运行画面要按游戏自身引用的资源路径渲染。
5. **「首轮进度投影」无编码级判据**,可见性由底部 Agent 状态栏承载。
6. **浮出工具条的视觉位置未做真机核验**jsdom 不执行 Web Animations,只覆盖结构与 class)。
7. **重命名不改游戏源码中的旧 `assets/<name>` 引用**
8. **C9 参考图弹窗在 AGC 侧入口未确认**:全仓检索 `添加参考图` 只命中共享 `src/components/image-editor/*`AGC 的 `ResourceCanvasGenerationPanelView` 未引用它,可能处于"组件已实现、AGC 入口未接"状态。建议真机点一次生成 / 编辑面板确认;若无入口,C9 不计入本轮主线。
8. **C9 参考图弹窗在 AGC 侧入口未确认**:全仓检索 `添加参考图` 只命中共享 `src/components/image-editor/*`AGC 的 `ResourceCanvasGenerationPanelView` 未引用它,可能处于"组件已实现、AGC 入口未接"状态。建议真机点一次生成 / 编辑面板确认;若无入口,C9 不计入本轮主线。**注**:资源替换复用同一个 `ImageCanvasProjectAssetPickerDialog`,它在 AGC 侧是首次使用(同一个弹窗、不同的 opt-in 参数)。
9. **运行视图存在「点选素材」按钮**,但 #309 的"不做"清单包含运行画面点选,口径冲突待定。
10. **替换成功后运行画面不会立刻变**:按 C7 与本轮口径只做记录层 + UI 层,可见变化是资源卡"当前使用"高亮移到替换素材与 `@` 面板"当前版本素材"更新;**不会新增版本卡、也不切换版本**,这不等于替换失败。
11. **替换候选弹窗不加载缩略图**:AGC 的素材预览要经带 scope 的原生读取器拿 Blob URL,弹窗内没有同步 `src`,因此候选行只渲染类型占位(不给 `<img>` 喂空串、不挂破图)。
12. **尺寸规格差异只提示、不拒绝**`sizeSpecEqual` 的完整判据今天不存在(manifest 无 `width / height / durationMs`,实际只等于"媒体格式相等"),所以同分类同类型的 `png ↔ webp` 替换是**允许**的,弹窗里只给「格式与源素材不同」的提示。
13. **替换历史不可回溯**:直接替换不产生版本记录,替换前身份只剩一条 `asset.version_binding.replace` 审计与 manifest 的 `.previous` 副本。这是当前口径的已知代价,不要报成"缺少替换历史功能"的缺陷(版本级替换属未来合同)。
### 7.4 文档与代码的偏差(需明确按哪个判)
@@ -253,5 +257,5 @@ api-server 是否本次重启:□ 是 □ 否
## 8. 验收边界
- 本用例覆盖:资源总览 / 栏目分页画布、资源卡预览与本地受控读取、资源卡选择与多选、快速编辑派生、画布生成入口(3 类)、编辑标签、重命名、下载、删除三分支、版本切换与当前使用高亮、聊天 @ 引用与原子 chip、AI 润色与发送前提醒、本地预览启动与退出收尾、两份布局 sidecar 台账。
- 本用例不覆盖(另走专项或定向测试):双窗口 CAS 冲突、大规模 fixture 性能、素材创作无限画布阶段一至五的草稿 / 事务 / 恢复矩阵(见 `【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md` 与配套专题)、UI 编辑器子路由、主站图片编辑器回归(见 PRD §7.7)。
- 本用例覆盖:资源总览 / 栏目分页画布、资源卡预览与本地受控读取、资源卡选择与多选、快速编辑派生、画布生成入口(3 类)、编辑标签、重命名、下载、删除三分支、版本切换与当前使用高亮、**资源替换(入口放行判据 / 候选禁用与原因 / 格式提示 / 写入载荷 / 拒绝零副作用 / 成功后不切版本不重载预览)**、聊天 @ 引用与原子 chip、AI 润色与发送前提醒、本地预览启动与退出收尾、两份布局 sidecar 台账。
- 本用例不覆盖(另走专项或定向测试):双窗口 CAS 冲突、大规模 fixture 性能、素材创作无限画布阶段一至五的草稿 / 事务 / 恢复矩阵(见 `【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md` 与配套专题)、UI 编辑器子路由、主站图片编辑器回归(见 PRD §7.7)。资源替换的后端矩阵(版本绑定改写放行的六条不变式与两组互斥、绑定改写两条路径、硬门禁与提示、CAS、四条拒绝路径、读时自愈口径、审计留痕)见 `apps/ai-game-creator-shell/src-tauri/src/project/manifest/version_binding_rewrite_tests.rs``apps/ai-game-creator-shell/src-tauri/src/tests/version_resource_replacement.rs`,前端矩阵见 `apps/ai-game-creator-shell/tests/resourceVersionReplacement*.test.ts(x)`
@@ -1,8 +1,9 @@
import { Check, ImageIcon, Music, Search, Video } from 'lucide-react';
import { useEffect, useMemo, useState } from 'react';
import { type ReactNode, useEffect, useMemo, useState } from 'react';
import { PlatformActionButton } from '../../../packages/shared/src/components/PlatformActionButton';
import { PlatformResourceFilterBar } from '../../../packages/shared/src/components/PlatformResourceFilterBar';
import { PlatformStatusMessage } from '../../../packages/shared/src/components/PlatformStatusMessage';
import { UnifiedModal } from '../common/UnifiedModal';
import type { EditorAsset } from './ImageCanvasEditorTypes';
import {
@@ -20,6 +21,41 @@ type ImageCanvasProjectAssetPickerDialogProps = {
selectedAssetIds: readonly string[];
onCancel: () => void;
onConfirm: (assetIds: string[]) => void;
/**
* 单选模式:点击即整组替换当前选择,不再渲染「已选」chip 行。
* 默认 `false`,网页端美术画布的参考图多选行为逐字不变。
*/
singleSelect?: boolean;
/**
* 被禁用的素材 id → 用户可见的禁用原因。默认空,即所有素材都可选。
*
* 禁用项仍然渲染(不隐藏):隐藏会让用户以为"素材不存在",而真实原因是它不可替换。
*/
assetBlockedReasons?: Readonly<Record<string, string>>;
/**
* 可选素材 id → 非阻断提示(例如"格式与源素材不同")。默认空,即不显示任何提示。
*
* 与 `assetBlockedReasons` 的区别:提示不改变可点性,只把差异说清楚。
*/
assetHints?: Readonly<Record<string, string>>;
/**
* 素材缩略图渲染器。默认 `undefined` → 沿用 `<img src={thumbnailSrc || src}>`。
*
* 宿主(如 AGC 资源工作台)没有同步 `src` 时必须传它:AGC 的预览读取走带 scope 的
* 原生调度器 + Blob URL,弹窗内取不到,直接给空 `src` 会挂破图。
*/
renderAssetMedia?: (asset: EditorAsset) => ReactNode;
/**
* 选择对象的中文名词,用于拼弹窗标题与可访问名称。默认「参考图」,
* 即网页端美术画布的现有文案逐字不变。
*/
selectionNoun?: string;
/**
* 宿主的失败原因(例如后端拒绝了这次替换)。默认 `undefined` → 不渲染。
*
* 用于在弹窗内说明"为什么这次操作没成功",而不是静默关闭弹窗让用户以为成功了。
*/
errorMessage?: string | null;
};
function assetIcon(asset: EditorAsset) {
@@ -46,6 +82,12 @@ export function ImageCanvasProjectAssetPickerDialog({
selectedAssetIds,
onCancel,
onConfirm,
singleSelect = false,
assetBlockedReasons,
assetHints,
renderAssetMedia,
selectionNoun = '参考图',
errorMessage,
}: ImageCanvasProjectAssetPickerDialogProps) {
const [query, setQuery] = useState('');
const [category, setCategory] = useState<ProjectAssetPickerCategory>('all');
@@ -76,20 +118,24 @@ export function ImageCanvasProjectAssetPickerDialog({
});
function toggleAsset(assetId: string) {
setSelection((current) =>
current.includes(assetId)
if (assetBlockedReasons?.[assetId]) return;
setSelection((current) => {
if (singleSelect) {
return current.includes(assetId) ? [] : [assetId];
}
return current.includes(assetId)
? current.filter((item) => item !== assetId)
: [...current, assetId],
);
: [...current, assetId];
});
}
return (
<UnifiedModal
open={open}
title="选择参考图"
title={`选择${selectionNoun}`}
size="lg"
portalTheme="light"
closeLabel="关闭选择参考图"
closeLabel={`关闭选择${selectionNoun}`}
onClose={onCancel}
panelClassName="image-canvas-editor__project-asset-picker"
bodyClassName="image-canvas-editor__project-asset-picker-body"
@@ -118,7 +164,7 @@ export function ImageCanvasProjectAssetPickerDialog({
<PlatformActionButton
type="button"
size="sm"
aria-label="确认选择参考图"
aria-label={`确认选择${selectionNoun}`}
onClick={() => onConfirm(selection)}
>
@@ -126,13 +172,26 @@ export function ImageCanvasProjectAssetPickerDialog({
</>
}
>
{selectedAssets.length > 0 ? (
<div className="mb-3 flex flex-wrap gap-1.5" aria-label="已选参考图">
{errorMessage ? (
<PlatformStatusMessage
tone="error"
size="xs"
className="mb-3"
role="alert"
>
{errorMessage}
</PlatformStatusMessage>
) : null}
{selectedAssets.length > 0 && !singleSelect ? (
<div
className="mb-3 flex flex-wrap gap-1.5"
aria-label={`已选${selectionNoun}`}
>
{selectedAssets.map((asset) => (
<button
key={asset.id}
type="button"
aria-label={`取消参考图${asset.label}`}
aria-label={`取消${selectionNoun}${asset.label}`}
className="platform-category-chip gap-1 px-2 text-xs"
onClick={() => toggleAsset(asset.id)}
>
@@ -163,28 +222,39 @@ export function ImageCanvasProjectAssetPickerDialog({
<div
className="grid grid-cols-2 gap-2 sm:grid-cols-3 lg:grid-cols-4"
role="listbox"
aria-label="参考图素材"
aria-multiselectable
aria-label={`${selectionNoun}素材`}
aria-multiselectable={!singleSelect}
>
{visibleAssets.map((asset) => {
const selected = selection.includes(asset.id);
const blockedReason = assetBlockedReasons?.[asset.id] ?? null;
const hint = blockedReason
? null
: (assetHints?.[asset.id] ?? null);
return (
<button
key={asset.id}
type="button"
role="option"
aria-selected={selected}
aria-label={`选择参考图${asset.label}`}
aria-label={`选择${selectionNoun}${asset.label}`}
disabled={blockedReason !== null}
title={blockedReason ?? hint ?? undefined}
className={[
'relative flex min-h-[7.5rem] flex-col overflow-hidden rounded-[0.9rem] border text-left transition',
selected
? 'border-[var(--platform-accent)] bg-white shadow-sm'
: 'border-[var(--platform-subpanel-border)] bg-white/70 hover:bg-white',
].join(' ')}
blockedReason !== null ? 'cursor-not-allowed opacity-60' : '',
]
.filter(Boolean)
.join(' ')}
onClick={() => toggleAsset(asset.id)}
>
<span className="flex h-20 w-full items-center justify-center overflow-hidden bg-white/60">
{projectAssetPickerCategory(asset) === 'image' ? (
{renderAssetMedia ? (
renderAssetMedia(asset)
) : projectAssetPickerCategory(asset) === 'image' ? (
<img
src={asset.thumbnailSrc || asset.src}
alt=""
@@ -205,6 +275,15 @@ export function ImageCanvasProjectAssetPickerDialog({
/>
) : null}
</span>
{blockedReason !== null ? (
<span className="block px-2 pb-1.5 text-[0.6875rem] leading-snug text-[var(--platform-text-base)]">
{blockedReason}
</span>
) : hint !== null ? (
<span className="block px-2 pb-1.5 text-[0.6875rem] leading-snug text-[var(--platform-text-base)]">
{hint}
</span>
) : null}
</button>
);
})}