Compare commits

...

17 Commits

Author SHA1 Message Date
k88936 4d0dd2d721 收紧 UI 资源桥接的 manifest kind
UI 原型与文档链接改用生成枚举边界
删除 ui-prototype 的主动 alias 使用并更新测试清单
2026-09-15 18:06:27 +08:00
k88936 2befaaef9d 接入 AGC shell TS 资源 kind 边界
新增消费 Rust 生成绑定的未知值解析模块
迁移资源引用与画布引用入口并补充日志测试
更新逐项迁移清单与实施计划
2026-09-15 18:00:34 +08:00
k88936 20b0bf4dd7 补充 AGC manifest 未知 kind 验收
验证旧值读取为 Unknown 且序列化为 unknown
明确不执行 alias 迁移
2026-09-15 17:50:33 +08:00
k88936 b4fd8d9b8b 收紧 AGC manifest 资源 kind 字段
将 manifest 条目 kind 改为 Rust 枚举并把未知值收口为 Unknown
修正字体上传与图集切片的正式资源 kind
保持 source.kind 与 generationKind 等其他字符串字段不变
2026-09-15 17:37:28 +08:00
k88936 fe85fa2a62 建立 AGC 资源 kind Rust 枚举与 TypeScript 绑定
新增 GameCreationAppAssetKind 及 Unknown 解析边界
通过 ts-rs 生成 shell 专属 TypeScript 联合类型
补充未知值上下文日志与 Rust/绑定生成测试
2026-09-15 16:20:52 +08:00
k88936 b14a533b17 新增AGC资源kind绑定实施计划
限定首个代码切片为 Rust enum 与 ts-rs 生成边界。

明确不修改 manifest 字段和其他资源字符串领域。

列出绑定、serde round-trip 与定向验证门禁。
2026-09-15 15:29:37 +08:00
k88936 42969b2218 细化AGC资源kind构造器审计
补充 Rust manifest 构造器逐处迁移清单。

补充 shell TypeScript 裸 assetKind 消费方清单。

区分图集、工作流和 manifest kind 的处理边界。
2026-09-15 15:24:46 +08:00
k88936 c4ee75a1af 补充AGC资源kind词汇审计清单
记录 manifest kind 的 Rust 写入边界与 TypeScript 消费方。

区分资源 kind 与 source/generation/workflow 等字符串领域。

标注 font、audio、sound-effect、background-music 及旧值的生产状态。

通过文档索引、编码与 diff 检查。
2026-09-15 15:19:58 +08:00
k88936 6f2137cbb0 补充AGC资源kind枚举化规范
新增资源 kind 枚举化主规范与边界。

新增词汇审计、Rust 绑定、写入清理三个里程碑。

新增 shell-only 资源 kind 扫描 todo 清单。

通过文档索引、编码与 diff 检查。
2026-09-15 15:12:24 +08:00
k88936 70a2246e8b 移除 UI 文档引用新增测试
Project CI / AI game creator shell Rust shard 2/4 (pull_request) Successful in 4m30s
Project CI / AI game creator shell Rust shard 1/4 (pull_request) Successful in 4m59s
Project CI / AI game creator shell Rust shard 3/4 (pull_request) Successful in 4m24s
Project CI / Backend tests (pull_request) Failing after 11s
Project CI / AI game creator shell Rust smoke (pull_request) Successful in 1m50s
Project CI / AI game creator shell Rust shard 4/4 (pull_request) Failing after 4m28s
Project CI / Repository checks (pull_request) Failing after 13s
Project CI / AI game creator shell Rust crates (pull_request) Successful in 2m7s
Project CI / AI game creator shell web tests (pull_request) Failing after 3m22s
Project CI / Frontend tests (pull_request) Failing after 3m51s
Project CI / Native shell tests (pull_request) Successful in 7m4s
删除 Direct Codex UI 文档 fixture 与专门测试用例

保留生产引用逻辑和现有业务测试
2026-09-14 21:41:53 +08:00
k88936 b7e3dac661 统一引用 UI 文档资产类型常量
前端入口、资源桥接与引用预览统一使用共享常量

Rust 持久化与测试复用共享契约常量

补齐 UI 编辑器入口 fixture 的新资产类型
2026-09-14 21:36:43 +08:00
k88936 2e4a1996c8 同步 UI 编辑器入口测试资产类型
将项目开发 UI 编辑器回程测试 fixture 更新为 ui-design-doc
2026-09-14 21:12:03 +08:00
k88936 fe4e952853 更新 UI 文档资源编辑器入口
将 ui-design-doc 资源纳入选中资源的 UI 编辑器按钮判定

保留 ui-prototype 图片资源的编辑器桥接入口
2026-09-14 21:04:08 +08:00
k88936 18654b6806 同步 UI 文档资产共享契约与前端识别
共享契约新增 ui-design-doc 分类及中英文映射

前端编辑器与资源桥接严格识别文档资产

更新技术方案、实施计划和长期决策记录
2026-09-14 20:45:04 +08:00
k88936 ee7d00c0b1 统一 UI 编辑器文档资产类型
新增 ui-design-doc 与 application/json 常量

创建、桥接、工作流和持久化统一使用严格资产类型
2026-09-14 20:44:49 +08:00
k88936 9cd1a94369 接入 UI 文档引用代码上下文生成
Direct Codex 引用 ui-design-doc 时生成带文档的 JS 文件

生成成功或失败信息追加到当前 prompt

补充成功、失败与 ui-prototype 隔离测试
2026-09-14 20:44:49 +08:00
k88936 470e85c0ff Add UI-specific prompt rendering for design assets in resource references 2026-09-14 20:44:49 +08:00
43 changed files with 1282 additions and 163 deletions
+2
View File
@@ -4900,6 +4900,8 @@ dependencies = [
"serde",
"serde_json",
"sha2",
"tracing",
"ts-rs",
]
[[package]]
@@ -14,7 +14,7 @@ cocos-editor-injection = ["cocos-editor-execute", "cocos-editor-bridge/windows-i
serde = { version = "1", features = ["derive"] }
serde_json = "1"
sha2 = "0.10"
shared-contracts = { path = "../../../server-rs/crates/shared-contracts", default-features = false }
shared-contracts = { path = "../../../server-rs/crates/shared-contracts", default-features = false, features = ["ts-bindings"] }
tauri-build = { version = "2.6.2", features = [] }
[dependencies]
@@ -50,7 +50,7 @@ portable-pty = "0.9"
percent-encoding = "2"
reqwest = { version = "0.12", default-features = false, features = ["json", "multipart", "native-tls", "stream"] }
regex = "1"
shared-contracts = { path = "../../../server-rs/crates/shared-contracts", default-features = false }
shared-contracts = { path = "../../../server-rs/crates/shared-contracts", default-features = false, features = ["ts-bindings"] }
tauri = { version = "2.11.2", features = [] }
tauri-plugin-dialog = "2.7.1"
tauri-plugin-http = { version = "2.5.9", default-features = false, features = ["charset", "cookies", "http2", "rustls-tls"] }
@@ -1,4 +1,8 @@
use super::*;
use crate::ui_editor::persistence::{
generate_ui_design_code_at, GenerateUiDesignCodeInput, UI_DESIGN_DOC_ASSET_KIND,
UI_DESIGN_DOC_MEDIA_TYPE,
};
pub(crate) const MAX_DIRECT_CODEX_REFERENCES: usize = 32;
const MAX_DIRECT_CODEX_REFERENCE_ID_CHARS: usize = 200;
@@ -133,7 +137,35 @@ fn validate_resource_reference_id(value: &str) -> Result<String, String> {
Ok(resource_id.to_string())
}
/// Render the prompt context for a UI design asset.
///
/// Keep this separate from the generic resource renderer so UI-specific
/// instructions/metadata can evolve without changing other asset kinds.
fn render_ui_design_reference_line(
root: &Path,
manifest: &GameCreationAppManifest,
asset: &GameCreationAppAssetManifestEntry,
resource_id: &str,
label: &str,
local_path: &str,
source: &str,
) -> String {
let context = match generate_ui_design_code_at(GenerateUiDesignCodeInput {
project_path: root.to_string_lossy().into_owned(),
expected_project_id: manifest.project_id.clone(),
asset_id: resource_id.to_string(),
}) {
Ok(result) => format!("请先阅读生成的带有文档的代码片段: {}", result.relative_path),
Err(error) => format!("生成代码遇到错误{error}"),
};
format!(
"- 素材 ID{resource_id};名称:{label};类型:{};媒体类型:{};项目路径:{local_path};来源:{source}",
asset.kind, asset.media_type
) + "\n" + &context
}
fn render_resource_reference_line(
root: &Path,
manifest: &GameCreationAppManifest,
reference: &DirectCodexResourceReference,
) -> Result<String, String> {
@@ -149,6 +181,19 @@ fn render_resource_reference_line(
.unwrap_or_else(|| asset_display_label(asset));
let source = sanitize_reference_source(reference.source.as_deref())
.unwrap_or_else(|| "unknown".to_string());
let is_ui_design =
asset.kind == UI_DESIGN_DOC_ASSET_KIND && asset.media_type == UI_DESIGN_DOC_MEDIA_TYPE;
if is_ui_design {
return Ok(render_ui_design_reference_line(
root,
manifest,
asset,
&resource_id,
&label,
&local_path,
&source,
));
}
Ok(format!(
"- 素材 ID{resource_id};名称:{label};类型:{};媒体类型:{};项目路径:{local_path};来源:{source}",
asset.kind, asset.media_type
@@ -234,7 +279,7 @@ pub(crate) fn render_direct_codex_references_section(
for reference in references {
lines.push(match reference {
DirectCodexTurnReference::Resource(reference) => {
render_resource_reference_line(&manifest, reference)?
render_resource_reference_line(root, &manifest, reference)?
}
DirectCodexTurnReference::RuntimeRegion(reference) => {
render_runtime_region_reference_line(&manifest, reference)?
@@ -2020,7 +2020,7 @@ fn direct_taonier_art_asset_identity(
local_asset_id: asset.id.clone(),
source_sha256: validated.content_sha256,
media_type: asset.media_type.clone(),
canonical_asset_kind: asset.kind.clone(),
canonical_asset_kind: asset.kind.to_string(),
resource_id: asset
.source
.resource_id
@@ -2546,7 +2546,7 @@ async fn recover_direct_taonier_spritesheet_read_only_at(
&source_asset.id,
&format!("{:x}", Sha256::digest(&source_bytes)),
&source_asset.media_type,
&source_asset.kind,
source_asset.kind.as_str(),
)?;
let principal = external_editor_binding_principal(&access)?;
let Some(project_binding) =
@@ -1169,7 +1169,7 @@ fn bridge_registered_resource(
// (落盘值 + 按 kind 派生 + 读时自愈),这里走 Rust 的同构实现。
// 直接透传落盘 `asset.category` 会让 `kind:"ui"` 的资产在 UI 显示「UI 交互」、
// 在 Agent 侧读到 `unclassified`(真机 55 条分歧)。
"category": game_creation_app_asset_effective_category(&asset.kind, asset.category),
"category": game_creation_app_asset_effective_category(asset.kind.as_str(), asset.category),
"tags": asset.tags,
"canvasProjectId": asset.source.canvas_project_id,
"resourceId": asset.source.resource_id,
@@ -1828,7 +1828,7 @@ async fn bridge_create_or_derive_resource(
source_asset_id: source_asset.as_ref().map(|asset| asset.id.clone()),
source_path: source_asset.as_ref().map(|asset| asset.local_path.clone()),
source_media_type: source_asset.as_ref().map(|asset| asset.media_type.clone()),
source_subtype: source_asset.as_ref().map(|asset| asset.kind.clone()),
source_subtype: source_asset.as_ref().map(|asset| asset.kind.to_string()),
producer_task_id: source_asset
.as_ref()
.and_then(|asset| asset.source.task_id.clone()),
@@ -1649,7 +1649,7 @@ async fn canonical_art_spec_reference_at(
&source.id,
&format!("{:x}", Sha256::digest(&bytes)),
&source.media_type,
&source.kind,
source.kind.as_str(),
)?;
if let Some(binding) = read_external_editor_resource_binding_at(
root,
@@ -6801,7 +6801,7 @@ fn register_platform_art_slice_manifest_entries_at(
.iter_mut()
.find(|asset| asset.local_path == registration.local_path)
{
existing.kind = "art-spritesheet-slice".to_string();
existing.kind = GameCreationAppAssetKind::Icon;
existing.media_type = registration.media_type.clone();
existing.image_sequence_frames = None;
existing.image_sequence_duration_ms = None;
@@ -6815,7 +6815,7 @@ fn register_platform_art_slice_manifest_entries_at(
);
manifest.assets.push(GameCreationAppAssetManifestEntry {
id: id.clone(),
kind: "art-spritesheet-slice".to_string(),
kind: GameCreationAppAssetKind::Icon,
media_type: registration.media_type.clone(),
local_path: registration.local_path.clone(),
image_sequence_frames: None,
@@ -98,7 +98,7 @@ pub(crate) fn render_local_asset_prompt_context(root: &Path) -> Result<String, S
output.push_str("- ");
output.push_str(&asset.id);
output.push_str(": ");
output.push_str(&asset.kind);
output.push_str(asset.kind.as_str());
output.push_str(" / ");
output.push_str(&asset.media_type);
output.push_str(" / ");
@@ -478,7 +478,7 @@ pub(crate) fn external_editor_api_credentials_override_is_active() -> bool {
/// 分类时才生效,`kind` 本身错时自愈只会把错值放大。
///
/// 判不出内容类型时返回中性的 `asset`(派生 `unclassified` → 「待归类」),**不猜具体类型**。
fn uploaded_asset_kind(file_name: &str, media_type: &str) -> &'static str {
fn uploaded_asset_kind(file_name: &str, media_type: &str) -> GameCreationAppAssetKind {
let media_type = media_type.trim().to_ascii_lowercase();
let extension = Path::new(file_name)
.extension()
@@ -492,20 +492,20 @@ fn uploaded_asset_kind(file_name: &str, media_type: &str) -> &'static str {
"mp3" | "wav" | "ogg" | "m4a" | "aac" | "flac" | "opus"
)
{
"audio"
GameCreationAppAssetKind::Audio
} else if media_type.starts_with("video/") || matches!(extension, "mp4" | "webm" | "mov") {
"video"
GameCreationAppAssetKind::Video
} else if media_type.starts_with("font/")
|| matches!(extension, "ttf" | "otf" | "woff" | "woff2")
{
"document"
GameCreationAppAssetKind::Font
} else if media_type.starts_with("image/")
|| matches!(
extension,
"png" | "jpg" | "jpeg" | "webp" | "gif" | "svg" | "avif" | "bmp"
)
{
"image"
GameCreationAppAssetKind::Image
} else if matches!(media_type.as_str(), "text/html" | "text/css")
|| media_type.contains("javascript")
|| media_type.contains("typescript")
@@ -514,7 +514,7 @@ fn uploaded_asset_kind(file_name: &str, media_type: &str) -> &'static str {
"html" | "htm" | "css" | "js" | "mjs" | "cjs" | "jsx" | "ts" | "tsx"
)
{
"code"
GameCreationAppAssetKind::Code
} else if media_type.starts_with("text/")
|| matches!(media_type.as_str(), "application/json" | "application/xml")
|| matches!(
@@ -532,9 +532,9 @@ fn uploaded_asset_kind(file_name: &str, media_type: &str) -> &'static str {
| "xml"
)
{
"document"
GameCreationAppAssetKind::Document
} else {
"asset"
GameCreationAppAssetKind::Unknown
}
}
@@ -563,7 +563,7 @@ pub(crate) fn upload_local_asset_at(
register_local_asset_entry(
root,
&relative_path,
uploaded_asset_kind(file_name, media_type),
uploaded_asset_kind(file_name, media_type).as_str(),
media_type,
"upload",
GameCreationAppAssetSource {
@@ -1879,7 +1879,10 @@ pub(crate) fn register_local_asset_entry(
let normalized_path = normalize_relative_path(local_path)?;
let absolute_path = resolve_local_project_path(root, &normalized_path)?;
let manifest_path = root.join(".agent/manifest.json");
let kind = if kind.is_empty() { "asset" } else { kind };
let kind = GameCreationAppAssetKind::parse_with_context(
if kind.is_empty() { "unknown" } else { kind },
"register_local_asset_entry",
);
let media_type = if media_type.is_empty() {
"application/octet-stream"
} else {
@@ -1899,8 +1902,8 @@ pub(crate) fn register_local_asset_entry(
// 时才触发),于是这个资产永远停在错误栏目。
// kind 没变时刻意不动 category——落盘分类是权威值,同 kind 重登记不得抹掉它。
if existing.kind != kind {
existing.kind = kind.to_string();
existing.category = game_creation_app_asset_category_for_kind(kind);
existing.kind = kind;
existing.category = game_creation_app_asset_category_for_kind(kind.as_str());
}
existing.media_type = media_type.to_string();
existing.source = source;
@@ -1913,12 +1916,12 @@ pub(crate) fn register_local_asset_entry(
);
manifest.assets.push(GameCreationAppAssetManifestEntry {
id: id.clone(),
kind: kind.to_string(),
kind,
media_type: media_type.to_string(),
local_path: normalized_path.clone(),
image_sequence_frames: None,
image_sequence_duration_ms: None,
category: game_creation_app_asset_category_for_kind(kind),
category: game_creation_app_asset_category_for_kind(kind.as_str()),
tags: Vec::new(),
source,
});
@@ -1931,7 +1934,7 @@ pub(crate) fn register_local_asset_entry(
"recordType": record_type,
"assetId": id.clone(),
"localPath": normalized_path.clone(),
"kind": kind,
"kind": kind.as_str(),
"mediaType": media_type,
"source": source_for_record,
}),
@@ -2144,7 +2144,10 @@ pub(crate) fn create_ui_design_resource(
let next_index = manifest
.assets
.iter()
.filter(|asset| asset.kind == "UI")
.filter(|asset| {
asset.kind == crate::ui_editor::persistence::UI_DESIGN_DOC_ASSET_KIND
&& asset.media_type == crate::ui_editor::persistence::UI_DESIGN_DOC_MEDIA_TYPE
})
.count()
+ 1;
let resource_name = format!("UI 设计 {next_index}");
@@ -2178,8 +2181,8 @@ pub(crate) fn create_ui_design_resource(
let asset = match register_local_asset_at(
root,
&relative_path,
"UI",
"application/json",
crate::ui_editor::persistence::UI_DESIGN_DOC_ASSET_KIND,
crate::ui_editor::persistence::UI_DESIGN_DOC_MEDIA_TYPE,
"generated",
GameCreationAppAssetSource {
kind: GameCreationAppAssetSourceKind::Generated,
@@ -4033,7 +4036,7 @@ pub(crate) fn import_local_project_assets_for_agent(
imported.push(ImportedAsset {
id: existing.id.clone(),
local_path: existing.local_path.clone(),
asset_kind: Some(existing.kind.clone()),
asset_kind: Some(existing.kind.to_string()),
});
continue;
}
@@ -4231,7 +4234,7 @@ pub(crate) async fn import_account_editor_assets_for_agent(
imported.push(ImportedAsset {
id: existing.id.clone(),
local_path: existing.local_path.clone(),
asset_kind: Some(existing.kind.clone()),
asset_kind: Some(existing.kind.to_string()),
});
continue;
}
@@ -33,8 +33,8 @@ use shared_contracts::game_creation_app::{
GameCreationAgentCapabilityDescriptor, GameCreationAgentPassPlanTrace,
GameCreationAgentRepairRouteTrace, GameCreationAgentRunStep,
GameCreationAgentRunTaskGraphTrace, GameCreationAgentRunTrace, GameCreationAgentToolCallTrace,
GameCreationAppAgentGroup, GameCreationAppAssetManifestEntry, GameCreationAppAssetSource,
GameCreationAppAssetSourceKind, GameCreationAppCommandRunState,
GameCreationAppAgentGroup, GameCreationAppAssetKind, GameCreationAppAssetManifestEntry,
GameCreationAppAssetSource, GameCreationAppAssetSourceKind, GameCreationAppCommandRunState,
GameCreationAppCommandRunStatus, GameCreationAppLimitedRunCommandDescriptor,
GameCreationAppManifest, GameCreationAppPermission, GameCreationAppPreviewState,
GameCreationAppPreviewStatus, GameCreationAppTaskState, GameCreationAppTaskStatus,
@@ -1303,8 +1303,13 @@ fn resolve_resource_edit_source(
let asset_kind = source_asset
.as_ref()
.map(|asset| asset.kind.clone())
.or_else(|| input.source_subtype.clone())
.unwrap_or_else(|| "asset".to_string());
.or_else(|| {
input
.source_subtype
.as_deref()
.map(|value| GameCreationAppAssetKind::parse_with_context(value, "resource-edit"))
})
.unwrap_or(GameCreationAppAssetKind::Unknown);
let canonical_resource_id = source_asset
.as_ref()
.map(source_asset_canonical_resource_id)
@@ -1332,7 +1337,7 @@ fn resolve_resource_edit_source(
canonical_resource_id,
source_path: Some(path),
media_type,
asset_kind,
asset_kind: asset_kind.to_string(),
source_sha256: sha256_hex(text.as_bytes()),
bytes: None,
generation_mode: input.generation_mode,
@@ -1363,7 +1368,7 @@ fn resolve_resource_edit_source(
if matches!(
input.edit_kind,
LocalProjectResourceEditKind::SoundEffect | LocalProjectResourceEditKind::BackgroundMusic
) && resource_edit_audio_kind(&asset_kind, &path) != input.edit_kind
) && resource_edit_audio_kind(asset_kind.as_str(), &path) != input.edit_kind
{
return Err("音频资源的音效/BGM 编辑类型与源资源用途不一致".to_string());
}
@@ -1385,7 +1390,7 @@ fn resolve_resource_edit_source(
canonical_resource_id,
source_path: Some(path),
media_type,
asset_kind,
asset_kind: asset_kind.to_string(),
source_sha256: sha256_hex(&bytes),
bytes: Some(bytes),
generation_mode: input.generation_mode,
@@ -3805,7 +3810,10 @@ pub(crate) fn normalize_local_project_raster_resource_at(
.unwrap_or("art-image");
let asset = GameCreationAppAssetManifestEntry {
id: asset_id.clone(),
kind: source_subtype.to_string(),
kind: GameCreationAppAssetKind::parse_with_context(
source_subtype,
"resource-edit.derivative",
),
media_type: verified_media_type,
local_path: source_path,
image_sequence_frames: None,
@@ -4058,7 +4066,7 @@ fn commit_resource_edit_asset_internal(
let category = game_creation_app_asset_category_for_kind(&asset_kind);
let asset = GameCreationAppAssetManifestEntry {
id: asset_id.clone(),
kind: asset_kind,
kind: GameCreationAppAssetKind::parse_with_context(&asset_kind, "resource-edit.derivative"),
media_type: staged_media_type.to_string(),
local_path: relative_path.clone(),
image_sequence_frames,
@@ -5165,7 +5173,7 @@ pub(crate) async fn resume_local_project_resource_edit_at(
let source_subtype = ledger
.source_asset_kind
.clone()
.or_else(|| source_asset.as_ref().map(|asset| asset.kind.clone()))
.or_else(|| source_asset.as_ref().map(|asset| asset.kind.to_string()))
.or_else(|| Some(infer_resource_edit_source_asset_kind(&ledger.edit_kind)));
derive_local_project_resource_at(DeriveLocalProjectResourceInput {
project_path: input.project_path,
@@ -2031,8 +2031,8 @@ fn register_local_asset_keeps_explicit_category_when_kind_is_unchanged() {
let registered = register_local_asset_at(
&root,
"ui/UI 设计 1.json",
"UI",
"application/json",
shared_contracts::game_creation_app::GAME_CREATION_APP_UI_DESIGN_DOC_ASSET_KIND,
shared_contracts::game_creation_app::GAME_CREATION_APP_UI_DESIGN_DOC_MEDIA_TYPE,
"ui-workflow",
source(),
)
@@ -2053,8 +2053,8 @@ fn register_local_asset_keeps_explicit_category_when_kind_is_unchanged() {
register_local_asset_at(
&root,
"ui/UI 设计 1.json",
"UI",
"application/json",
shared_contracts::game_creation_app::GAME_CREATION_APP_UI_DESIGN_DOC_ASSET_KIND,
shared_contracts::game_creation_app::GAME_CREATION_APP_UI_DESIGN_DOC_MEDIA_TYPE,
"ui-workflow",
source(),
)
@@ -2063,7 +2063,10 @@ fn register_local_asset_keeps_explicit_category_when_kind_is_unchanged() {
let manifest: Value =
serde_json::from_str(&fs::read_to_string(root.join(".agent/manifest.json")).unwrap())
.expect("manifest json");
assert_eq!(manifest["assets"][0]["kind"], "UI");
assert_eq!(
manifest["assets"][0]["kind"],
shared_contracts::game_creation_app::GAME_CREATION_APP_UI_DESIGN_DOC_ASSET_KIND
);
assert_eq!(manifest["assets"][0]["category"], "audio");
assert_eq!(manifest["assets"][0]["tags"], serde_json::json!(["界面"]));
@@ -2074,7 +2077,7 @@ fn register_local_asset_keeps_explicit_category_when_kind_is_unchanged() {
///
/// 这里的字面量与写入侧逐字一致:UI 设计资产是
/// `ui_editor/resource_bridge.rs` / `workflow.rs` / `persistence.rs` 的
/// `register_local_asset_at(root, path, "UI", "application/json", ...)`
/// `register_local_asset_at` 使用 UI 文档 kind/media 常量
/// 字体是 `commands.rs` 字体上传的 `register_local_asset_entry(root, path, "font", ...)`。
/// 只要别名表漏掉它们,真机资产就会永远停在「待归类」且读时自愈也救不回来。
#[test]
@@ -2099,8 +2102,8 @@ fn register_local_asset_derives_category_from_real_write_side_kinds() {
register_local_asset_at(
&root,
"ui/UI 设计 1.json",
"UI",
"application/json",
shared_contracts::game_creation_app::GAME_CREATION_APP_UI_DESIGN_DOC_ASSET_KIND,
shared_contracts::game_creation_app::GAME_CREATION_APP_UI_DESIGN_DOC_MEDIA_TYPE,
"ui-workflow",
source(),
)
@@ -2133,7 +2136,11 @@ fn register_local_asset_derives_category_from_real_write_side_kinds() {
assert_eq!(
categories,
vec![
("UI".to_string(), "ui-interaction".to_string()),
(
shared_contracts::game_creation_app::GAME_CREATION_APP_UI_DESIGN_DOC_ASSET_KIND
.to_string(),
"ui-interaction".to_string(),
),
("font".to_string(), "document".to_string()),
]
);
@@ -15,6 +15,7 @@ use platform_llm::{
};
use schemars::JsonSchema;
use serde::{Deserialize, Serialize};
use shared_contracts::game_creation_app::GameCreationAppAssetKind;
use std::collections::HashSet;
use std::path::Path;
use ts_rs::TS;
@@ -645,5 +646,30 @@ mod tests {
Node::export_all(&config).expect("Node TypeScript export succeeds");
TextComponent::export_all(&config).expect("TextComponent TypeScript export succeeds");
FontSource::export_all(&config).expect("FontSource TypeScript export succeeds");
let game_creation_config = Config::new().with_out_dir(concat!(
env!("CARGO_MANIFEST_DIR"),
"/../src/contracts/generated/"
));
GameCreationAppAssetKind::export_all(&game_creation_config)
.expect("GameCreationAppAssetKind TypeScript export succeeds");
let generated = std::fs::read_to_string(concat!(
env!("CARGO_MANIFEST_DIR"),
"/../src/contracts/generated/GameCreationAppAssetKind.ts"
))
.expect("generated GameCreationAppAssetKind binding exists");
for value in [
"\"image\"",
"\"audio\"",
"\"sound-effect\"",
"\"background-music\"",
"\"font\"",
"\"unknown\"",
] {
assert!(
generated.contains(value),
"generated binding misses {value}"
);
}
}
}
@@ -19,6 +19,10 @@ use std::time::{SystemTime, UNIX_EPOCH};
use typed_floats::tf32::StrictlyPositiveFinite;
const UI_DESIGN_STATE_SCHEMA_VERSION: &str = "game-creator-ui-design-state.v1";
pub(crate) use shared_contracts::game_creation_app::{
GAME_CREATION_APP_UI_DESIGN_DOC_ASSET_KIND as UI_DESIGN_DOC_ASSET_KIND,
GAME_CREATION_APP_UI_DESIGN_DOC_MEDIA_TYPE as UI_DESIGN_DOC_MEDIA_TYPE,
};
const UI_DESIGN_STATE_MAX_BYTES: usize = 2 * 1024 * 1024;
const UI_DESIGN_CODE_MAX_BYTES: usize = UI_DESIGN_STATE_MAX_BYTES * 8;
const UI_DESIGN_STATE_MAX_IMAGES: usize = 4;
@@ -321,7 +325,7 @@ fn ui_design_asset(
.into_iter()
.find(|asset| asset.id == asset_id)
.ok_or_else(|| "UI 设计资源不存在".to_string())?;
if asset.kind != "UI" || asset.media_type != "application/json" {
if asset.kind != UI_DESIGN_DOC_ASSET_KIND || asset.media_type != UI_DESIGN_DOC_MEDIA_TYPE {
return Err("目标资源不是 UI 设计 JSON 资产".to_string());
}
normalize_relative_path(&asset.local_path)?;
@@ -815,8 +819,8 @@ mod tests {
let asset = register_local_asset_at(
directory.path(),
relative_path,
"UI",
"application/json",
UI_DESIGN_DOC_ASSET_KIND,
UI_DESIGN_DOC_MEDIA_TYPE,
"test",
GameCreationAppAssetSource {
kind: GameCreationAppAssetSourceKind::Generated,
@@ -1,4 +1,7 @@
use crate::ui_editor::persistence::initialize_ui_design_state_with_source_image_at;
use crate::ui_editor::persistence::{
initialize_ui_design_state_with_source_image_at, UI_DESIGN_DOC_ASSET_KIND,
UI_DESIGN_DOC_MEDIA_TYPE,
};
use crate::{
acquire_project_write_lock, advance_agent_runtime_project_revision_locked,
enforce_project_permission_policy, read_existing_manifest_for_project,
@@ -89,8 +92,8 @@ pub(crate) fn ensure_ui_design_resource_for_prototype(
}
if let Some(asset) = manifest.assets.iter().find(|asset| {
asset.kind == "UI"
&& asset.media_type == "application/json"
asset.kind == UI_DESIGN_DOC_ASSET_KIND
&& asset.media_type == UI_DESIGN_DOC_MEDIA_TYPE
&& asset.source.reference_resource_ids.iter().any(|reference| {
source_reference_ids
.iter()
@@ -118,8 +121,8 @@ pub(crate) fn ensure_ui_design_resource_for_prototype(
let asset = match register_local_asset_at(
root,
&relative_path,
"UI",
"application/json",
UI_DESIGN_DOC_ASSET_KIND,
UI_DESIGN_DOC_MEDIA_TYPE,
"generated",
GameCreationAppAssetSource {
kind: GameCreationAppAssetSourceKind::Generated,
@@ -193,7 +196,9 @@ fn next_ui_design_path(
let mut index = manifest
.assets
.iter()
.filter(|asset| asset.kind == "UI")
.filter(|asset| {
asset.kind == UI_DESIGN_DOC_ASSET_KIND && asset.media_type == UI_DESIGN_DOC_MEDIA_TYPE
})
.count()
+ 1;
loop {
@@ -281,11 +286,9 @@ mod tests {
};
let first = ensure_ui_design_resource_for_prototype(input.clone()).expect("bridge");
assert!(first.created);
assert_eq!(first.asset.kind, "UI");
assert_eq!(first.asset.kind, UI_DESIGN_DOC_ASSET_KIND);
// 写侧 → 分类的端到端断言:这条路径走的是与
// `workflow.rs` / `persistence.rs` 完全相同的 `register_local_asset_at(..., "UI", ...)`
// 别名表漏掉大写 `UI` 时,这里会落 unclassified(真机 8 条 UI 资产的表现),
// 且派生值本身就是 unclassified,读时自愈也救不回来。
// 写侧与 persistence/workflow 共用 UI 文档 kind/media 常量
assert_eq!(
first.asset.category,
GameCreationAppAssetCategory::UiInteraction
@@ -322,7 +325,10 @@ mod tests {
.manifest
.assets
.iter()
.filter(|asset| asset.kind == "UI")
.filter(|asset| {
asset.kind == UI_DESIGN_DOC_ASSET_KIND
&& asset.media_type == UI_DESIGN_DOC_MEDIA_TYPE
})
.count(),
1
);
@@ -7,6 +7,7 @@ use crate::ui_editor::layout::node::{Node, StageStatus};
use crate::ui_editor::persistence::{
initialize_ui_design_state_at, load_ui_design_state_at, save_ui_design_state_at,
LoadUiDesignStateInput, SaveUiDesignStateInput, SaveUiDesignStateResult,
UI_DESIGN_DOC_ASSET_KIND, UI_DESIGN_DOC_MEDIA_TYPE,
};
use crate::ui_editor::resource::font::FontAsset;
use crate::ui_editor::resource::sprite::{SpriteAsset, SpriteAssetMetadata, SpriteBorder};
@@ -683,8 +684,8 @@ fn find_page_ui_resource(
let Some(asset) = matches.into_iter().next() else {
return Ok(None);
};
if asset.kind != "UI"
|| asset.media_type != "application/json"
if asset.kind != UI_DESIGN_DOC_ASSET_KIND
|| asset.media_type != UI_DESIGN_DOC_MEDIA_TYPE
|| asset.local_path != workflow_relative_path(source, &page.page_id)
{
return Err(format!("页面 {} 的 UI workflow 资源身份冲突", page.page_id));
@@ -748,8 +749,8 @@ fn ensure_page_ui_resource(
let registered = register_local_asset_at(
root,
&relative_path,
"UI",
"application/json",
UI_DESIGN_DOC_ASSET_KIND,
UI_DESIGN_DOC_MEDIA_TYPE,
"ui-workflow",
GameCreationAppAssetSource {
kind: GameCreationAppAssetSourceKind::Generated,
@@ -879,7 +880,7 @@ fn install_page_component_assets(
.and_then(|name| name.to_str())
.unwrap_or("UI 素材")
.to_string(),
asset_type: asset.kind.clone(),
asset_type: asset.kind.to_string(),
};
sprite.path = asset.local_path.clone();
match state.sprite_assets.get(&asset_id) {
@@ -1179,7 +1180,7 @@ fn update_page_manifest_stage(root: &Path, asset_id: &str, stage: &str) -> Resul
.iter_mut()
.find(|asset| asset.id == asset_id)
.ok_or_else(|| format!("UI workflow 资源 {} 未登记", asset_id))?;
if asset.kind != "UI" || asset.media_type != "application/json" {
if asset.kind != UI_DESIGN_DOC_ASSET_KIND || asset.media_type != UI_DESIGN_DOC_MEDIA_TYPE {
return Err(format!("UI workflow 资源 {} 类型不匹配", asset_id));
}
let next_kind = format!("ui-workflow.{stage}");
@@ -0,0 +1,45 @@
import type { GameCreationAppAssetKind } from './generated/GameCreationAppAssetKind';
export type { GameCreationAppAssetKind } from './generated/GameCreationAppAssetKind';
export const GAME_CREATION_APP_ASSET_KINDS = [
'image',
'scene',
'character',
'character-animation',
'icon',
'icon-spritesheet',
'icon-spec',
'ui-design',
'ui-design-doc',
'publication-material',
'spec',
'video',
'audio',
'sound-effect',
'background-music',
'font',
'document',
'code',
'unknown',
] as const satisfies readonly GameCreationAppAssetKind[];
const KNOWN_ASSET_KINDS = new Set<string>(GAME_CREATION_APP_ASSET_KINDS);
export function parseGameCreationAppAssetKind(
raw: unknown,
context: string,
): GameCreationAppAssetKind {
if (typeof raw === 'string' && KNOWN_ASSET_KINDS.has(raw)) {
return raw as GameCreationAppAssetKind;
}
const rawValue = typeof raw === 'string' ? raw : String(raw);
if (rawValue !== 'unknown') {
console.warn('未知的 GameCreationApp 资源 kind,已收口为 unknown', {
rawKind: rawValue,
sourceContext: context,
});
}
return 'unknown';
}
@@ -0,0 +1,28 @@
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
/**
* GameCreationApp manifest kind Rust
*
* JSON 使 kebab-case`Unknown`
*
*/
export type GameCreationAppAssetKind =
| 'image'
| 'scene'
| 'character'
| 'character-animation'
| 'icon'
| 'icon-spritesheet'
| 'icon-spec'
| 'ui-design'
| 'ui-design-doc'
| 'publication-material'
| 'spec'
| 'video'
| 'audio'
| 'sound-effect'
| 'background-music'
| 'font'
| 'document'
| 'code'
| 'unknown';
@@ -53,6 +53,7 @@ import { createPortal, flushSync } from 'react-dom';
import { PlatformResourceFilterBar } from '../../../../../packages/shared/src/components/PlatformResourceFilterBar';
import { PlatformSegmentedTabs } from '../../../../../packages/shared/src/components/PlatformSegmentedTabs';
import {
GAME_CREATION_APP_UI_DESIGN_DOC_MEDIA_TYPE,
type GameCreationAppAssetManifestEntry,
gameCreationAppAssetTags,
type GameIterationVersion,
@@ -1253,7 +1254,10 @@ function ResourcePickerThumbnail({
if (mediaType.startsWith('image/')) {
return <Loader2 size={18} className="animate-spin" aria-hidden="true" />;
}
if (kind === 'ui' || mediaType === 'application/json') {
if (
kind === 'ui' ||
mediaType === GAME_CREATION_APP_UI_DESIGN_DOC_MEDIA_TYPE
) {
return <FileText size={18} aria-hidden="true" />;
}
return <ImageIcon size={18} aria-hidden="true" />;
@@ -10,6 +10,10 @@ import {
buildGameCreationAppAssetTagLibrary,
type GameCreationAppAssetTagLibraryEntry,
} from '../../../../../packages/shared/src/contracts/gameCreationAppAssetTagLibrary';
import {
type GameCreationAppAssetKind,
parseGameCreationAppAssetKind,
} from '../../contracts/assetKind';
export type ResourceReferenceSource =
| 'asset-picker'
@@ -20,7 +24,7 @@ export type ResourceReferenceSource =
export type ResourceReference = {
type: 'resource';
resourceId: string;
kind: string;
kind: GameCreationAppAssetKind;
mediaType: string;
label: string;
category: GameCreationAppAssetCategory;
@@ -98,7 +102,7 @@ export function resourceReferenceFromAsset(
return {
type: 'resource',
resourceId: asset.id,
kind: asset.kind,
kind: parseGameCreationAppAssetKind(asset.kind, 'resource-reference'),
mediaType: asset.mediaType,
label: resourceDisplayName(asset),
category: gameCreationAppAssetCategory(asset),
@@ -2,6 +2,11 @@ import type {
GameCreationAppAssetManifestEntry,
GameCreationAppManifest,
} from '../../../../../packages/shared/src/contracts/gameCreationApp';
import {
GAME_CREATION_APP_UI_DESIGN_DOC_ASSET_KIND,
GAME_CREATION_APP_UI_DESIGN_DOC_MEDIA_TYPE,
} from '../../../../../packages/shared/src/contracts/gameCreationApp';
import { parseGameCreationAppAssetKind } from '../../contracts/assetKind';
export type UiDesignResourceBridgeResult = {
asset: GameCreationAppAssetManifestEntry;
@@ -48,8 +53,11 @@ export function findLinkedUiDesignResource(
manifest.assets
.filter(
(asset) =>
asset.kind === 'UI' &&
asset.mediaType === 'application/json' &&
parseGameCreationAppAssetKind(
asset.kind,
'ui-design-resource-bridge.linked-document',
) === GAME_CREATION_APP_UI_DESIGN_DOC_ASSET_KIND &&
asset.mediaType === GAME_CREATION_APP_UI_DESIGN_DOC_MEDIA_TYPE &&
asset.source.referenceResourceIds?.some((reference) =>
referenceIds.has(reference),
),
@@ -80,7 +88,13 @@ export async function ensureUiDesignResourceForPrototype({
const prototype = manifest.assets.find(
(asset) => asset.id === normalizedPrototypeAssetId,
);
if (!prototype || prototype.kind !== 'ui-prototype') {
if (
!prototype ||
parseGameCreationAppAssetKind(
prototype.kind,
'ui-design-resource-bridge.prototype',
) !== 'ui-design'
) {
throw new Error('目标资源不是 UI 原型图片');
}
if (!prototype.mediaType.toLowerCase().startsWith('image/')) {
@@ -73,6 +73,10 @@ import type {
GameIterationVersion,
ProjectResourceCanvasLayoutMode,
} from '../../../../../packages/shared/src/contracts/gameCreationApp';
import {
GAME_CREATION_APP_UI_DESIGN_DOC_ASSET_KIND,
GAME_CREATION_APP_UI_DESIGN_DOC_MEDIA_TYPE,
} from '../../../../../packages/shared/src/contracts/gameCreationApp';
import { ImageCanvasCharacterAnimationPanelView } from '../../../../../src/components/image-editor/ImageCanvasCharacterAnimationPanelView';
import type {
CanvasLayer,
@@ -92,6 +96,7 @@ import {
isFloatingOverlayWheelEvent,
useImageCanvasFloatingOptionDismiss,
} from '../../../../../src/components/image-editor/useImageCanvasFloatingOptionDismiss';
import { parseGameCreationAppAssetKind } from '../../contracts/assetKind';
import { DesignWorkspacePanel } from '../../features/project-workspace/DesignWorkspacePanel';
import {
LocalGamePreviewFrame,
@@ -5040,8 +5045,8 @@ export default function ProjectDevelopmentView({
if (canvasOpenEpochRef.current !== openEpoch) return;
if (
result.manifest.projectId !== manifest.projectId ||
result.asset.kind !== 'UI' ||
result.asset.mediaType !== 'application/json'
result.asset.kind !== GAME_CREATION_APP_UI_DESIGN_DOC_ASSET_KIND ||
result.asset.mediaType !== GAME_CREATION_APP_UI_DESIGN_DOC_MEDIA_TYPE
) {
throw new Error('UI 编辑资源结果与当前项目不一致');
}
@@ -5112,8 +5117,8 @@ export default function ProjectDevelopmentView({
if (uiEditorRoute) return;
const completed = manifest.assets.find(
(asset) =>
asset.kind === 'UI' &&
asset.mediaType === 'application/json' &&
asset.kind === GAME_CREATION_APP_UI_DESIGN_DOC_ASSET_KIND &&
asset.mediaType === GAME_CREATION_APP_UI_DESIGN_DOC_MEDIA_TYPE &&
asset.source.generationKind === 'ui-workflow.completed',
);
if (
@@ -6812,7 +6817,7 @@ export default function ProjectDevelopmentView({
[manifest, selectedResource],
);
const selectedResourceOpensUiEditor =
selectedResource?.subtype === 'UI' ||
selectedResource?.subtype === GAME_CREATION_APP_UI_DESIGN_DOC_ASSET_KIND ||
selectedResource?.subtype === 'ui-prototype';
const selectedToolbarStyle = selectedResourceLayer
@@ -7222,9 +7227,11 @@ export default function ProjectDevelopmentView({
type: 'resource',
resourceId:
selectedResource.manifestAssetId!,
kind:
kind: parseGameCreationAppAssetKind(
selectedResource.subtype ||
selectedResource.category,
selectedResource.category,
'project-development.resource-reference',
),
mediaType: selectedResource.mediaType,
label: selectedResource.label,
category:
@@ -4,6 +4,10 @@ import type {
ProjectResourceCanvasLayout,
ProjectResourceCanvasPosition,
} from '../../../../packages/shared/src/contracts/gameCreationApp';
import {
GAME_CREATION_APP_UI_DESIGN_DOC_ASSET_KIND,
GAME_CREATION_APP_UI_DESIGN_DOC_MEDIA_TYPE,
} from '../../../../packages/shared/src/contracts/gameCreationApp';
import { RESOURCE_REFERENCE_INSERT_EVENT } from '../../src/features/project-workspace/resourceReferences';
import { RESOURCE_BOOK_OVERVIEW_STACK_LIMIT } from '../../src/view/project-development/resourceBookLayout';
import {
@@ -5908,8 +5912,8 @@ export function registerProjectWorkbenchFoundationTests() {
manifest.assets = [
{
id: 'ui-design-resource',
kind: 'UI',
mediaType: 'application/json',
kind: GAME_CREATION_APP_UI_DESIGN_DOC_ASSET_KIND,
mediaType: GAME_CREATION_APP_UI_DESIGN_DOC_MEDIA_TYPE,
localPath: 'assets/ui-design.json',
source: { kind: 'generated' },
},
@@ -0,0 +1,28 @@
import { describe, expect, it, vi } from 'vitest';
import {
GAME_CREATION_APP_ASSET_KINDS,
parseGameCreationAppAssetKind,
} from '../src/contracts/assetKind';
describe('shell generated asset kind boundary', () => {
it('keeps the generated kind values aligned with the manifest vocabulary', () => {
expect(GAME_CREATION_APP_ASSET_KINDS).toContain('font');
expect(GAME_CREATION_APP_ASSET_KINDS).toContain('audio');
expect(GAME_CREATION_APP_ASSET_KINDS).toContain('sound-effect');
expect(GAME_CREATION_APP_ASSET_KINDS).toContain('background-music');
expect(GAME_CREATION_APP_ASSET_KINDS).toContain('unknown');
});
it('does not alias old values and records unknown input', () => {
const warn = vi.spyOn(console, 'warn').mockImplementation(() => undefined);
expect(parseGameCreationAppAssetKind('ui', 'manifest.asset.kind')).toBe(
'unknown',
);
expect(warn).toHaveBeenCalledWith(
'未知的 GameCreationApp 资源 kind,已收口为 unknown',
{ rawKind: 'ui', sourceContext: 'manifest.asset.kind' },
);
warn.mockRestore();
});
});
@@ -10,6 +10,7 @@ import {
canonicalGameCreationAppAssetKind,
GAME_CREATION_APP_ASSET_CATEGORY_BY_KIND,
GAME_CREATION_APP_CANONICAL_ASSET_KINDS,
GAME_CREATION_APP_UI_DESIGN_DOC_ASSET_KIND,
type GameCreationAppAssetCategory,
gameCreationAppAssetCategory,
gameCreationAppAssetCategoryForKind,
@@ -219,8 +220,13 @@ describe('真机出现的 kind 归类口径', () => {
canonical: 'icon',
category: 'ui-interaction',
},
// 现役写入侧的大写字面量与字体 kind,两者都必须落进明确栏目。
// UI 文档与字体 kind,两者都必须落进明确栏目。
{ kind: 'UI', canonical: 'ui-design', category: 'ui-interaction' },
{
kind: GAME_CREATION_APP_UI_DESIGN_DOC_ASSET_KIND,
canonical: GAME_CREATION_APP_UI_DESIGN_DOC_ASSET_KIND,
category: 'ui-interaction',
},
{ kind: 'font', canonical: 'document', category: 'document' },
// `Object.prototype` 上的键不是别名:TS 侧必须用 `Object.hasOwn` 挡住对象字面量的
// 原型命中,Rust 侧 match 字面量本来就落 `image`;这类极端输入两侧也必须一致。
@@ -277,13 +283,15 @@ describe('写侧 kind 字面量 → 分类的端到端口径', () => {
return kinds;
}
test('UI 设计写点仍直接写 `"UI"`,且它落 UI 交互而不是待归类', () => {
// 8 条真机 UI 资产是 `kind:"UI"` + `mediaType:"application/json"`
// 该 kind 不在别名表里时派生结果也是 unclassified,读时自愈同样救不回来。
test('UI 设计写点统一使用文档资产常量,且它落 UI 交互而不是待归类', () => {
for (const file of UI_EDITOR_WRITE_SITES) {
expect(registeredKindLiterals(file)).toContain('UI');
expect(readFileSync(file, 'utf8')).toContain('UI_DESIGN_DOC_ASSET_KIND');
}
expect(gameCreationAppAssetCategoryForKind('UI')).toBe('ui-interaction');
expect(
gameCreationAppAssetCategoryForKind(
GAME_CREATION_APP_UI_DESIGN_DOC_ASSET_KIND,
),
).toBe('ui-interaction');
});
test('UI 编辑器写点写出的每个 kind 都落明确栏目', () => {

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