收紧 AGC 资源 kind 类型边界并统一族判据

- 将 manifest 与本地导入回执的资源 kind 改为 ts-rs 生成绑定

- 集中音频与视觉资源族判据并复用 Rust 枚举方法

- 放宽 Unknown 未解析语义并清理上传类型墓碑注释

- 标记共享生成绑定并同步项目决策与实施计划
This commit is contained in:
2026-09-18 15:07:36 +08:00
parent 03d7bca9a5
commit e5fd454bb3
16 changed files with 175 additions and 47 deletions
-1
View File
@@ -164,7 +164,6 @@ module.exports = {
'server-rs/target-*',
'apps/desktop-shell/src-tauri/target',
'apps/ai-game-creator-shell/src/features/ui-editor/types/**',
'packages/shared/src/contracts/generated/**',
'apps/ai-game-creator-shell/src/features/project-workspace/generated/**',
'target',
'src/main.tsx',
+3
View File
@@ -23,3 +23,6 @@
*.meta text
*.anim text
*.controller text
# Rust ts-rs 生成的共享契约:保留在仓库中供 TS 消费,但不作为手写源文件统计。
packages/shared/src/contracts/generated/** linguist-generated=true
-2
View File
@@ -4,8 +4,6 @@ node_modules
.codex-logs
public/Icons
apps/ai-game-creator-shell/src/features/ui-editor/types/
# ts-rs 直接落盘的生成产物:保持生成器原始格式,`cargo test export_bindings` 才可幂等复现。
packages/shared/src/contracts/generated/
apps/ai-game-creator-shell/src-tauri/resources/agc-skills/
media
*.log
+10 -1
View File
@@ -1,5 +1,14 @@
{
"singleQuote": true,
"semi": true,
"trailingComma": "all"
"trailingComma": "all",
"overrides": [
{
"files": "packages/shared/src/contracts/generated/**/*.ts",
"options": {
"printWidth": 1000,
"singleQuote": false
}
}
]
}
@@ -468,16 +468,8 @@ pub(crate) fn external_editor_api_credentials_override_is_active() -> bool {
}
/// 上传素材落盘的 manifest `kind`:只由**内容证据**推导(`mediaType` 优先,扩展名兜底)。
///
/// 不用 `uploaded` 这类**来源词**当类型:它不在 canonical 目录里,会经别名表落到
/// `image → unclassified`,把本该归「音频」「文档」的上传素材一起说成图片。
///
/// 更不能把 `ui` 当"图片的默认类型"`ui → ui-design → ui-interaction` 会把任意上传图片
/// 钉死在「UI 交互」栏,而"是不是 UI 素材"跟"扩展名是不是 .png"毫无关系;这类错值还不可恢复
/// ——读时自愈只在落盘 `category` 是 `unclassified` 且该 `kind` 能派生出非 `unclassified`
/// 分类时才生效,`kind` 本身错时自愈只会把错值放大。
///
/// 判不出内容类型时返回中性的 `asset`(派生 `unclassified` → 「待归类」),**不猜具体类型**。
/// 判不出内容类型时返回 `Unknown`;调用者负责决定是否拒绝写入或保留待后续归类,
/// 这里不猜具体类型。
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)
@@ -538,8 +530,9 @@ fn uploaded_asset_kind(file_name: &str, media_type: &str) -> GameCreationAppAsse
}
}
/// 登记边界的 kind 解析:空白入参按「没有信息」处理,落中性 `image`(→「待归类」)
/// 非空但认不出的原值严格收口成 `Unknown`(同样「待归类」)并把原始串交给 reporter 留痕
/// 登记边界的 kind 解析:空白入参按「没有信息」处理,沿用登记入口的 `Image` 默认值
/// 非空但认不出的原值严格收口成 `Unknown` 并把原始串交给 reporter 留痕
/// 由调用者决定是否以错误结束或继续后续归类。
///
/// 三条外部登记边界必须同口径:平台导入(`commands::imported_platform_asset_kind`)、
/// 画板导入(`sync_canvas_project_assets_at`)、外部 `register_local_asset`。
@@ -3999,17 +3999,7 @@ pub(crate) fn normalize_local_project_raster_resource_at(
"sourceSubtype 必须是图片族 kind,无法识别:{value}"
));
}
if !matches!(
kind,
GameCreationAppAssetKind::Image
| GameCreationAppAssetKind::Scene
| GameCreationAppAssetKind::Character
| GameCreationAppAssetKind::CharacterAnimation
| GameCreationAppAssetKind::Icon
| GameCreationAppAssetKind::IconSpritesheet
| GameCreationAppAssetKind::IconSpec
| GameCreationAppAssetKind::UiDesign
) {
if !kind.is_visual() {
return Err(format!(
"sourceSubtype 必须是图片族 kind,不能使用:{}",
kind.as_str()
@@ -6,6 +6,7 @@ import { open as openNativeFileDialog } from '@tauri-apps/plugin-dialog';
import { X } from 'lucide-react';
import { useCallback, useEffect, useRef, useState } from 'react';
import type { GameCreationAppAssetKind } from '../../../../../packages/shared/src/contracts/gameCreationApp';
import {
loadEditorAssetLibrary,
resolveClientAssetReadUrl,
@@ -58,7 +59,7 @@ type LocalProjectFilesResponse = { files?: LocalProjectFile[] };
type ImportAssetResponse = {
id: string;
localPath: string;
assetKind?: string | null;
assetKind?: GameCreationAppAssetKind | null;
};
type GenericImportResponse = { assets: ImportAssetResponse[] };
@@ -1,10 +1,12 @@
import type { FileManagerFile } from '@cubone/react-file-manager';
import type { ComponentType, ReactNode } from 'react';
import type { GameCreationAppAssetKind } from '../../../../../packages/shared/src/contracts/gameCreationApp';
export type ImportedAsset = {
id: string;
localPath: string;
assetKind: string | null;
assetKind: GameCreationAppAssetKind | null;
};
export type AssetSource = 'local' | 'remote';
@@ -104,7 +106,7 @@ export type ManifestAsset = {
id: string;
localPath: string;
mediaType: string;
kind?: string;
kind?: GameCreationAppAssetKind;
source?: { kind?: string };
};
@@ -1,6 +1,8 @@
import {
type GameCreationAppAssetSourceKind,
type GameCreationAppManifest,
isGameCreationAppAssetAudioKind,
isGameCreationAppAssetVisualKind,
selectGameCreationAppReadyTasks,
} from '../../../../../packages/shared/src/contracts/gameCreationApp';
import { taskRowsFromManifest } from '../agent-runtime';
@@ -102,12 +104,9 @@ export function isProjectAudioAsset(
asset: GameCreationAppManifest['assets'][number],
) {
const mediaType = asset.mediaType.toLowerCase();
const kind = asset.kind.toLowerCase();
return (
mediaType.startsWith('audio/') ||
kind === 'audio' ||
kind === 'sound-effect' ||
kind === 'background-music'
isGameCreationAppAssetAudioKind(asset.kind)
);
}
@@ -115,14 +114,11 @@ export function isProjectVisualAsset(
asset: GameCreationAppManifest['assets'][number],
) {
const mediaType = asset.mediaType.toLowerCase();
const kind = asset.kind.toLowerCase();
return (
mediaType.startsWith('image/') ||
mediaType.startsWith('video/') ||
mediaType === 'application/vnd.genarrative.image-sequence' ||
kind === 'image' ||
kind === 'icon' ||
kind === 'character-animation'
isGameCreationAppAssetVisualKind(asset.kind)
);
}
@@ -1,6 +1,7 @@
import { invoke } from '@tauri-apps/api/core';
import { useCallback, useEffect, useMemo, useState } from 'react';
import type { GameCreationAppAssetKind } from '../../../../../packages/shared/src/contracts/gameCreationApp';
import type { ImportedAsset } from '../../components/AssetImporter';
import {
prepareDesignImageBatch,
@@ -78,7 +79,11 @@ import { useUiEditorNodeFocus } from './useUiEditorNodeFocus';
const SEPARATION_IMPORT_BATCH_SIZE = 100;
type LocalImageImportResponse = {
assets: Array<{ id: string; localPath: string; assetKind?: string | null }>;
assets: Array<{
id: string;
localPath: string;
assetKind?: GameCreationAppAssetKind | null;
}>;
};
function normalizeProjectRelativePath(path: string): string {
@@ -1074,7 +1079,11 @@ export function useUiEditorSession(
];
const importedByPath = new Map<
string,
{ id: string; localPath: string; assetKind: string | null }
{
id: string;
localPath: string;
assetKind: GameCreationAppAssetKind | null;
}
>();
for (
let index = 0;
@@ -23,7 +23,8 @@
- shell 内 manifest kind 的写入侧与读取侧统一为正式枚举成员:资源登记、字体导入、direct runtime 产物、平台美术登记与恢复、manifest 必填校验、资源编辑派生、UI 设计桥接与工作流。
- 资源编辑请求的 `sourceSubtype` 只传正式枚举成员;资源编辑内部源身份拆成「正式 kind / 项目版本 / Agent 回执」三类,项目版本与 Agent 回执不再冒充 manifest kind。
- ts-rs 绑定改由 `cargo test export_bindings` 生成:枚举自带 `#[ts(export, export_to = …/src/contracts/generated/)]`;退役的 `BindingDTO` / `BindingChange` 不再导出,手写的 `exports_ui_editor_types` 已删除。
- `src/contracts/generated/` 加入 `.prettierignore` 与 eslint `ignorePatterns`生成文件保持 ts-rs 原始格式`cargo test export_bindings` 可幂等复现且不再污染工作树。
- `packages/shared/src/contracts/generated/` 作为受控生成目录在 `.prettierrc.json` 中使用与 ts-rs 原始输出一致的格式选项,并在 `.gitattributes` 标记为 `linguist-generated`生成文件仍参与 Prettier/ESLint 检查`cargo test export_bindings` 可幂等复现且不再污染工作树。
- `Unknown` 表示解析边界无法判定的输入;写入方可以保留该结果,但必须明确决定拒绝写入或交由后续归类,不得把它误写成某个具体 kind。
- shell TS 资源引用边界消费生成绑定,资源 kind 集合从生成的 union 穷举派生,并为未知值保留原始值与上下文日志。
明确不修改:
@@ -7,7 +7,7 @@
- 背景:kind 曾经有三份实现——Rust 手写 `GAME_CREATION_APP_CANONICAL_ASSET_KINDS` + `canonical_game_creation_app_asset_kind()`(带 legacy 别名表与 `font → document` 特例)、TS 手写 `GAME_CREATION_APP_CANONICAL_ASSET_KINDS` + `GAME_CREATION_APP_LEGACY_ASSET_KINDS` + `canonicalGameCreationAppAssetKind()`、以及 ts-rs 生成的 TS union。两份手写表互相引用又各自收口,判据直接分叉(同一个 `"UI"` 一边归一成 `ui-design`、一边收口成 `unknown`),跨语言一致性只能靠正则解析源码的测试来钉。
- 决策(唯一真源):kind 的变体、线上值、`as_str()``ALL`、严格解析与 ts-rs 绑定全部由 `server-rs/crates/shared-contracts/src/game_creation_app/asset_kind.rs` 的声明表派生。两份手写 canonical 列表、legacy 别名表、`canonical_*()` 函数一律删除;生成的 TS union 落在 `packages/shared/src/contracts/generated/GameCreationAppAssetKind.ts``packages/shared/src/contracts/gameCreationApp.ts` 只 re-export 它,运行期列表 `GAME_CREATION_APP_ASSET_KINDS` 用穷举 `Record<GameCreationAppAssetKind, true>` 守住。
- 决策(单一解析入口):`GameCreationAppAssetKind::parse_with_context()` 是唯一公开解析入口,严格匹配降为私有 `match_canonical()`;易混的 `from_str_lossy()` 与公开 `from_str_or_unknown()` 都已删除,认不出 canonical 值只有这一处收口并留痕(读侧回捞 legacy kind 只查 `ALL` 判真值,不另开解析函数)。口径是等值匹配——不 trim、不 lowercase、不查别名、不迁移;认不出的值收口成 `Unknown`(分类落 `unclassified`)。这是有意接受的行为(历史误写的 kind 不会被"救回"正确栏目),不再提供任何兼容入口;`font` 是正式成员,不再走别名。
- 决策(登记边界的 kind 口径):外部登记统一走 `assets::registration_asset_kind()`——空白入参落中性 `image`,非空认不出的值严格收口成 `unknown` 并留痕;三条登记边界(Tauri `register_local_asset`、画板导入、平台导入)不再各写一份 trim/兜底。栅格归一化的 `source_subtype` 只接受图片族成员,文档/字体/音频等一律按 `image` 登记;Agent 回执派生物按 `Text => Document``document`,不主动写 `unknown`
- 决策(登记边界的 kind 口径):外部登记统一走 `assets::registration_asset_kind()`——空白入参落中性 `image`,非空认不出的值严格收口成 `unknown` 并留痕;三条登记边界(Tauri `register_local_asset`、画板导入、平台导入)不再各写一份 trim/兜底。`Unknown` 是无法解析的边界结果,具体写入方必须自行决定拒绝或保留待后续归类,不能静默猜成其他 kind。栅格归一化的 `source_subtype` 只接受图片族成员,文档/字体/音频等一律按 `image` 登记;Agent 回执派生物按 `Text => Document``document`
- 决策(判据不许散落):切片残留登记只看 `assets/art-spritesheet-slices/` 路径(不再附带 `kind == Icon`);sprite 身份比较忽略随 kind 派生的 `metadata.asset_type`;TS 侧「UI 编辑器文档资产」判据只留 `isGameCreationAppUiDesignDocAsset()` 一份,资源画布入口 / UI 编辑器桥接 / 资源引用缩略图统一调用。
- 决策(已知代价,不补救):既有项目里 legacy kind 读入即 `unknown`,依赖 kind 等值比较的运行门禁会按"缺少该资源"处理。这是「不迁移、不 fallback」的必然结果,本次明确不为存量数据做迁移;将来若要迁就必须单独立项(一次性迁移或读侧白名单),不得把别名表加回解析路径。
- 决策(留痕必须真的落地):原实现用 `tracing::warn!`,而 AGC 壳没有 tracing subscriber,等于没有日志。现在 `shared-contracts` 只暴露可注册回调 `set_non_canonical_asset_kind_reporter()`AGC 壳在 `main()` 里接到 `app_log!`,日志同时含原始输入串与调用上下文;`kind-observability` feature 与 `tracing` 依赖一并删除。TS 侧对应 `parseGameCreationAppAssetKind()``console.warn`
@@ -25,6 +25,8 @@ import {
gameCreationAppAssetPersistedCategory,
gameCreationAppAssetTags,
type GameCreationAppManifest,
isGameCreationAppAssetAudioKind,
isGameCreationAppAssetVisualKind,
isGameCreationAppUiDesignDocAsset,
normalizeGameCreationAppAssetCategory,
normalizeGameCreationAppAssetTags,
@@ -798,6 +800,24 @@ describe('AI 游戏创作 App 共享契约', () => {
}
});
it('shares exhaustive audio and visual kind families', () => {
expect(
GAME_CREATION_APP_ASSET_KINDS.filter(isGameCreationAppAssetAudioKind),
).toEqual(['audio', 'sound-effect', 'background-music']);
expect(
GAME_CREATION_APP_ASSET_KINDS.filter(isGameCreationAppAssetVisualKind),
).toEqual([
'image',
'scene',
'character',
'character-animation',
'icon',
'icon-spritesheet',
'icon-spec',
'ui-design',
]);
});
it('识别 UI 编辑器文档资产只认 kind + mediaType 的唯一定义', () => {
expect(
isGameCreationAppUiDesignDocAsset({
@@ -468,7 +468,7 @@ export interface GameCreationAppAssetSource {
export interface GameCreationAppAssetManifestEntry {
id: string;
kind: string;
kind: GameCreationAppAssetKind;
mediaType: string;
localPath: string;
source: GameCreationAppAssetSource;
@@ -556,6 +556,54 @@ export const GAME_CREATION_APP_ASSET_KINDS: readonly GameCreationAppAssetKind[]
GAME_CREATION_APP_ASSET_KIND_MEMBERS,
) as GameCreationAppAssetKind[];
const GAME_CREATION_APP_AUDIO_ASSET_KIND_MEMBERS: Record<
Extract<
GameCreationAppAssetKind,
'audio' | 'sound-effect' | 'background-music'
>,
true
> = {
audio: true,
'sound-effect': true,
'background-music': true,
};
const GAME_CREATION_APP_VISUAL_ASSET_KIND_MEMBERS: Record<
Extract<
GameCreationAppAssetKind,
| 'image'
| 'scene'
| 'character'
| 'character-animation'
| 'icon'
| 'icon-spritesheet'
| 'icon-spec'
| 'ui-design'
>,
true
> = {
image: true,
scene: true,
character: true,
'character-animation': true,
icon: true,
'icon-spritesheet': true,
'icon-spec': true,
'ui-design': true,
};
export function isGameCreationAppAssetAudioKind(
kind: GameCreationAppAssetKind,
): boolean {
return kind in GAME_CREATION_APP_AUDIO_ASSET_KIND_MEMBERS;
}
export function isGameCreationAppAssetVisualKind(
kind: GameCreationAppAssetKind,
): boolean {
return kind in GAME_CREATION_APP_VISUAL_ASSET_KIND_MEMBERS;
}
const GAME_CREATION_APP_ASSET_KIND_SET = new Set<string>(
GAME_CREATION_APP_ASSET_KINDS,
);
@@ -4,7 +4,7 @@
* GameCreationApp manifest 资源 kind 的**唯一**类型,也是 TS 侧 kind union 的唯一真源
* (经 ts-rs 生成,前端不再手写第二份列表)。
*
* JSON 使用 kebab-case`Unknown` 表示解析边界遇到尚未登记的输入
* 正常资源写入路径不应主动选择它
* 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";
@@ -45,8 +45,8 @@ macro_rules! game_creation_app_asset_kinds {
/// GameCreationApp manifest 资源 kind 的**唯一**类型,也是 TS 侧 kind union 的唯一真源
/// (经 ts-rs 生成,前端不再手写第二份列表)。
///
/// JSON 使用 kebab-case`Unknown` 表示解析边界遇到尚未登记的输入
/// 正常资源写入路径不应主动选择它
/// JSON 使用 kebab-case`Unknown` 表示当前边界无法解析输入
/// 写入方可以保留这个未解析结果,但必须由调用者决定是否拒绝写入或交给后续归类
#[derive(Clone, Copy, Debug, Eq, Hash, PartialEq)]
#[cfg_attr(feature = "ts-bindings", derive(ts_rs::TS))]
#[cfg_attr(
@@ -88,6 +88,29 @@ macro_rules! game_creation_app_asset_kinds {
}
}
/// 资源编辑器和摘要视图共用的音频资源族。
pub const fn is_audio(self) -> bool {
matches!(
self,
Self::Audio | Self::SoundEffect | Self::BackgroundMusic
)
}
/// 资源编辑器和摘要视图共用的视觉资源族。
pub const fn is_visual(self) -> bool {
matches!(
self,
Self::Image
| Self::Scene
| Self::Character
| Self::CharacterAnimation
| Self::Icon
| Self::IconSpritesheet
| Self::IconSpec
| Self::UiDesign
)
}
/// 严格解析:只认 canonical 值,其余(含大小写、空白变体)一律 `Unknown`。
///
/// 私有实现细节,**不是**解析入口:外部一律走 [`Self::parse_with_context`]
@@ -368,4 +391,40 @@ mod tests {
// 每个非 canonical 变体都要留痕(5 条大小写/空白/legacy 变体都算异常)。
assert_eq!(recorded_reports().len(), 5);
}
#[test]
fn media_family_helpers_cover_the_shared_kind_sets() {
let audio = GameCreationAppAssetKind::ALL
.iter()
.copied()
.filter(|kind| kind.is_audio())
.collect::<Vec<_>>();
assert_eq!(
audio,
vec![
GameCreationAppAssetKind::Audio,
GameCreationAppAssetKind::SoundEffect,
GameCreationAppAssetKind::BackgroundMusic,
]
);
let visual = GameCreationAppAssetKind::ALL
.iter()
.copied()
.filter(|kind| kind.is_visual())
.collect::<Vec<_>>();
assert_eq!(
visual,
vec![
GameCreationAppAssetKind::Image,
GameCreationAppAssetKind::Scene,
GameCreationAppAssetKind::Character,
GameCreationAppAssetKind::CharacterAnimation,
GameCreationAppAssetKind::Icon,
GameCreationAppAssetKind::IconSpritesheet,
GameCreationAppAssetKind::IconSpec,
GameCreationAppAssetKind::UiDesign,
]
);
}
}