Files
Genarrative/apps/ai-game-creator-shell/src/view/project-development/resourceProjectionModel.ts
T
suzmii 083df0aa2c 类型判定改为内容证据优先:消除 kind 与内容证据同权的脆弱点
**口径更正(后加的)**:本条提交**不是**"修复真机那 8 张卡"。经用真源码复算真机 manifest 确认,当前 HEAD 上这 8 张 `.json` 本就正确落 `document`、角标「文档」,该现象不复现——用户看到的是更早构建的行为。本条的真实价值是**消除 `kind` 与内容证据同权的脆弱点,并把「派发决策由内容证据决定」钉成不变量**,见下。

- 背景:真机「待归类」栏目曾出现 8 张卡——角标写「图片」、卡面却只有占位图标。该现象在当前 HEAD 上不复现(见上),但它对应一个**真实存在且此前只是碰巧没被踩中的脆弱点**:`projectedResourceKind` 里 `kind` 与内容证据**同权**,而 `artKind` 含 `ui`;一旦界面规格类资产丢了 `.json` 扩展名,预览派发就会翻车。原先能通过**完全依赖 `.json` 恰好在那张扩展名表里**,属巧合而非设计。
- 机制(旧行为):`kind:"UI"` 被 `artKind` 命中 → 判定为 `art`;预览调度再按 `art` 走图像分支,又因 `mediaType:"application/json"` 不是图像而兜底成 `placeholder`,而 `requestPreview` 对 `placeholder` 直接返回——**一次 IPC 都不发**,卡片既不报错也没有图。
- `resourceProjectionModel.ts` 的 `projectedResourceKind` 改为两趟判定:第一趟只看 `mediaType` 与路径扩展名(文件内容的可验证证据),第二趟才看 `kind`(登记标签,可能过时或宽泛)。`kind` 不再与内容证据同权,因此 `kind:"ui"` 无法再把 `application/json` 改写成 `art`。
- 不改 `artKind` 的取值(`ui` 仍在表内),只调整判定顺序:最小改动面,`kind:"ui"` + `image/png` 这类正常资产行为不变(仍由 `mediaType` 命中)。
- 界面规格 JSON 现在落 `document` 分支,走 `read_local_project_text_preview`(Rust 侧 `.json` 与 `application/json` 本就在支持范围内且这些条目已登记),卡面渲染 JSON 文本摘要,角标随 `projectResourceTypeLabel` 变为「文档」。预览链条数、缓存、并发与门禁一律未动。
- `tests/resourceCardPreviewRealManifest.test.ts` 中记录旧行为的用例同步跟上新合同:`placeholder: 8` 改为 `document: 8`,用例名与注释改写为「52 张 PNG 走图片分支,8 条 UI 规格走文档分支,无一落占位」,并新增「角标与预览分支必须说同一件事」的逐条断言。旧断言记录的是缺陷本身,按「四不写」直接更新为当前权威行为,不做兼容保留;其余既有断言一条未放宽。
- 验证:`npm run test -- apps/ai-game-creator-shell/tests` 1202 passed / 4 skipped / 0 failed;`src/components/image-editor` 1385 passed;typecheck exit 0;check:encoding 通过;`git diff --check` 干净。
2026-09-11 13:30:14 +08:00

457 lines
15 KiB
TypeScript

import {
type GameCreationAppAssetCategory,
gameCreationAppAssetCategory,
gameCreationAppAssetTags,
type GameCreationAppManifest,
type GameIterationVersion,
type ProjectResourceCanvasCategory,
} from '../../../../../packages/shared/src/contracts/gameCreationApp';
export type ProjectResourceCategory = ProjectResourceCanvasCategory;
export type ProjectAttachmentResult = {
fileName: string;
mediaType: string;
localPath?: string;
status: 'imported' | 'failed';
error?: string;
};
export type ProjectAgentResultSummary = {
agentId: string;
runId: string;
label: string;
title: string;
content: string;
updatedAt: number;
};
export type ProjectVersionResourceSummary = GameIterationVersion & {
label: string;
childVersionIds: string[];
};
export type ProjectResource = {
id: string;
category: ProjectResourceCategory;
subtype: string;
label: string;
path: string;
mediaType: string;
sourceLabel: string;
taskTitle: string | null;
manifestAssetId: string | null;
producerTaskId: string | null;
externalResourceId: string | null;
referenceResourceIds: string[];
dependencies: string[];
dependencyDepth: number;
content?: string;
version?: ProjectVersionResourceSummary;
/**
* manifest 资产条目的功能分类与自定义标签投影。只有登记在 manifest 的资产才有该口径;
* 任务产物、附件、Agent 回执与项目版本不是 manifest 资产,按筛选口径归入 `待归类`。
*/
assetCategory?: GameCreationAppAssetCategory;
assetTags?: string[];
imageSequenceFrames?: Array<{
imageSrc: string;
objectKey?: string | null;
assetObjectId?: string | null;
width: number;
height: number;
}> | null;
imageSequenceDurationMs?: number | null;
};
export type ProjectResourceTypeLabel =
| '图片'
| 'SVG'
| '视频'
| '音频'
| '文档'
| '任务产物'
| 'Agent 回执'
| '项目版本'
| '游戏代码'
| '未知';
const documentExtension =
/\.(md|markdown|mdx|txt|json|ya?ml|toml|csv|ini|conf|xml)$/iu;
const gameCodeExtension =
/\.(html?|css|scss|less|m?[jt]sx?|cjs|rs|py|go|java|kt|kts|c|cc|cpp|h|hpp|cs|swift|php|rb|lua|sh|bash|zsh|sql|graphql|gql|vue|svelte)$/iu;
const artExtension = /\.(png|jpe?g|webp|gif|svg|avif|bmp|mp4|webm|mov)$/iu;
const audioExtension = /\.(mp3|wav|ogg|m4a|aac|flac|opus)$/iu;
const gameCodeKind =
/(?:^|[-_])(game-(?:entry|style|script)|code|source)(?:$|[-_])/iu;
const artKind =
/(?:^|[-_])(art|animation|character|icon|image|scene|sprite|spritesheet|ui|video)(?:$|[-_])/iu;
const audioKind = /(?:^|[-_])(audio|bgm|music|sfx|sound|voice)(?:$|[-_])/iu;
function fileName(path: string) {
return path.split(/[\\/]/u).filter(Boolean).pop() || path;
}
export type ProjectedResourceKind = 'audio' | 'art' | 'code' | 'document';
/**
* 资源类型判定:按 mediaType、扩展名与 manifest `kind` 判定资源类型。
*
* 只有能判定类型的资源才进入资源画布:`.zip` / `.exe` 等无法识别的二进制产物与附件返回
* `null`,卡片显示类型复用同一口径。画布分区口径见 `projectResourceCanvasCategory`。
*
* 判定分两趟,**顺序是这条函数的核心合同**:
*
* 1. 第一趟只看 `mediaType` 与路径扩展名——它们是文件内容的可验证证据;
* 2. 第二趟才看 `kind`——它是登记时的标签,可能过时、可能宽泛。
*
* 为什么不能把 `kind` 混进第一趟:`artKind` 含 `ui`,而 `ui` 是极常见的登记标签
* (旧版本把界面素材统一写成 `kind:"ui"`)。一旦 `kind` 与 mediaType 同权,
* `kind:"ui"` + `mediaType:"application/json"` 的界面规格就会被判成 `art`,
* 于是卡面角标按 `art` 派生显示「图片」,而预览调度按 `art` 走图像分支、
* 又因 mediaType 不是图像而兜底成 `placeholder` 且**永不发起读取** ——
* 出现「标着图片、却是占位、没有任何报错」的分叉。分趟后 JSON/HTML 这类
* 有明确文档 / 代码 mediaType 的资产不会再被 `kind` 顶掉。
*/
export function projectedResourceKind(input: {
path: string;
mediaType: string;
kind?: string;
}): ProjectedResourceKind | null {
const normalizedPath = input.path.trim().toLowerCase();
const normalizedMediaType = input.mediaType.trim().toLowerCase();
const normalizedKind = input.kind?.trim().toLowerCase() ?? '';
// 第一趟:mediaType 与扩展名(内容证据,权威)。
if (
normalizedMediaType.startsWith('audio/') ||
audioExtension.test(normalizedPath)
) {
return 'audio';
}
if (
normalizedMediaType.startsWith('image/') ||
normalizedMediaType.startsWith('video/') ||
artExtension.test(normalizedPath)
) {
return 'art';
}
if (
normalizedMediaType === 'text/html' ||
normalizedMediaType === 'text/css' ||
normalizedMediaType.includes('javascript') ||
normalizedMediaType.includes('typescript') ||
gameCodeExtension.test(normalizedPath)
) {
return 'code';
}
if (
normalizedMediaType.includes('json') ||
normalizedMediaType.includes('yaml') ||
normalizedMediaType.startsWith('text/') ||
documentExtension.test(normalizedPath)
) {
return 'document';
}
// 第二趟:登记标签兜底。只在 mediaType 与扩展名都给不出结论时生效,
// 保证「有明确 mediaType 的资产」永远不被宽泛的 `kind` 改写类型。
if (audioKind.test(normalizedKind)) {
return 'audio';
}
if (gameCodeKind.test(normalizedKind)) {
return 'code';
}
if (artKind.test(normalizedKind)) {
return 'art';
}
return null;
}
/**
* 资源显示类型判定:与准入使用同一套扩展名 / mediaType / manifest `kind` 口径。
*
* manifest 资产的 `subtype` 就是 `asset.kind`,任务产物、附件与回执使用各自的稳定
* 人造 subtype,不会命中 kind 规则,因此这里可以直接透传。
*/
export function projectResourceDisplayKind(
resource: Pick<ProjectResource, 'path' | 'mediaType' | 'subtype'>,
): ProjectedResourceKind | null {
return projectedResourceKind({
path: resource.path,
mediaType: resource.mediaType,
kind: resource.subtype,
});
}
/**
* Converts an already projected resource into short user-facing type text.
* This is deliberately display-only: it derives from existing projection
* fields and does not add a backend/read-model attribute.
*/
export function projectResourceTypeLabel(
resource: Pick<ProjectResource, 'subtype' | 'path' | 'mediaType'>,
): ProjectResourceTypeLabel {
const subtype = resource.subtype.trim().toLowerCase();
const path = resource.path.trim().toLowerCase();
const mediaType = resource.mediaType.trim().toLowerCase();
const kind = projectResourceDisplayKind(resource);
if (subtype === 'project-version') {
return '项目版本';
}
if (subtype === 'agent-result') {
return 'Agent 回执';
}
if (kind === 'code') {
return '游戏代码';
}
if (mediaType === 'image/svg+xml' || /\.svg$/iu.test(path)) {
return 'SVG';
}
if (kind === 'audio') {
return '音频';
}
if (mediaType.startsWith('video/') || /\.(mp4|webm|mov)$/iu.test(path)) {
return '视频';
}
if (kind === 'document') {
return '文档';
}
if (kind === 'art') {
return '图片';
}
if (subtype === 'task-artifact') {
return '任务产物';
}
return '未知';
}
/**
* 资源画布筛选口径:只有 manifest 资产携带功能分类,其余投影资源(任务产物、附件、
* Agent 回执与项目版本)没有 `category` 事实源,统一归 `待归类`,不按扩展名另造分类。
*/
export function projectResourceAssetCategory(
resource: Pick<ProjectResource, 'assetCategory'>,
): GameCreationAppAssetCategory {
return resource.assetCategory ?? 'unclassified';
}
/**
* 资源画布分区口径:manifest 资产的 6 类功能分类,加上末尾独立的「项目版本」栏目。
*
* 项目版本不是资源资产,6 类资产分类轴对它不适用,固定单独成栏。Agent 文本回执是文本
* 成果,保持在「文档」栏目。其余非 manifest 资源(任务产物、附件)没有 `category` 事实源,
* 统一归 `待归类`,不按扩展名另造分区。
*/
export function projectResourceCanvasCategory(
resource: Pick<ProjectResource, 'subtype' | 'assetCategory'>,
): ProjectResourceCanvasCategory {
if (resource.subtype === 'project-version') {
return 'version';
}
if (resource.subtype === 'agent-result') {
return 'document';
}
return projectResourceAssetCategory(resource);
}
function resourcePriority(resource: ProjectResource) {
if (resource.manifestAssetId) {
return 4;
}
if (resource.id.startsWith('attachment:')) {
return 3;
}
if (resource.id.startsWith('task:')) {
return 2;
}
return 1;
}
export function projectResourcesFromReadModels(
manifest: GameCreationAppManifest,
attachments: ProjectAttachmentResult[],
agentResults: ProjectAgentResultSummary[],
) {
const taskById = new Map(manifest.tasks.map((task) => [task.id, task]));
const resources: ProjectResource[] = [];
for (const task of manifest.tasks) {
if (task.status !== 'completed') {
continue;
}
for (const path of task.artifacts) {
const kind = projectedResourceKind({ path, mediaType: '' });
if (!kind) {
continue;
}
resources.push({
id: `task:${task.id}:${path}`,
category: projectResourceCanvasCategory({ subtype: 'task-artifact' }),
subtype: 'task-artifact',
label: fileName(path),
path,
mediaType:
kind === 'document'
? '项目文档'
: kind === 'code'
? '游戏代码'
: kind === 'art'
? '美术产物'
: '音乐音效产物',
sourceLabel: '任务产物',
taskTitle: task.title,
manifestAssetId: null,
producerTaskId: task.id,
externalResourceId: null,
referenceResourceIds: [],
dependencies: task.dependencies,
dependencyDepth: 0,
});
}
}
for (const asset of manifest.assets) {
const kind = projectedResourceKind({
path: asset.localPath,
mediaType: asset.mediaType,
kind: asset.kind,
});
if (!kind) {
continue;
}
const assetCategory = gameCreationAppAssetCategory(asset);
const isPendingUiPrototype =
asset.kind === 'ui-prototype' &&
taskById.get('design-foundation')?.status !== 'completed';
resources.push({
id: `asset:${asset.id}`,
category: projectResourceCanvasCategory({
subtype: asset.kind,
assetCategory,
}),
subtype: asset.kind,
label: `${fileName(asset.localPath)}${
isPendingUiPrototype ? '(待视觉验收)' : ''
}`,
path: asset.localPath,
mediaType: asset.mediaType,
sourceLabel:
isPendingUiPrototype && asset.source.kind === 'canvas'
? '画板 · 候选界面图'
: asset.source.kind === 'canvas'
? '画板'
: asset.source.kind === 'generated'
? 'Agent 生成'
: '用户上传',
taskTitle: null,
manifestAssetId: asset.id,
producerTaskId: null,
externalResourceId: asset.source.resourceId ?? null,
referenceResourceIds: asset.source.referenceResourceIds ?? [],
dependencies: [],
dependencyDepth: 0,
assetCategory,
assetTags: gameCreationAppAssetTags(asset),
imageSequenceFrames: asset.imageSequenceFrames ?? null,
imageSequenceDurationMs: asset.imageSequenceDurationMs ?? null,
});
}
for (const attachment of attachments) {
if (attachment.status !== 'imported' || !attachment.localPath) {
continue;
}
const kind = projectedResourceKind({
path: attachment.localPath,
mediaType: attachment.mediaType,
});
if (!kind) {
continue;
}
resources.push({
id: `attachment:${attachment.localPath}`,
category: projectResourceCanvasCategory({ subtype: 'attachment' }),
subtype: 'attachment',
label: attachment.fileName,
path: attachment.localPath,
mediaType: attachment.mediaType || '未知媒体类型',
sourceLabel: '用户上传',
taskTitle: null,
manifestAssetId: null,
producerTaskId: null,
externalResourceId: null,
referenceResourceIds: [],
dependencies: [],
dependencyDepth: 0,
});
}
for (const result of agentResults) {
resources.push({
id: `agent-result:${result.agentId}:${result.runId}`,
category: projectResourceCanvasCategory({ subtype: 'agent-result' }),
subtype: 'agent-result',
label: result.title,
path: `专业 Agent 文本回执 · ${result.label}`,
mediaType: 'Agent 历史文本回执',
sourceLabel: `历史成果 · ${result.label}`,
taskTitle: null,
manifestAssetId: null,
producerTaskId: taskById.has(result.agentId) ? result.agentId : null,
externalResourceId: null,
referenceResourceIds: [],
dependencies: [],
dependencyDepth: 0,
content: result.content,
});
}
const childVersionIdsByParent = new Map<string, string[]>();
for (const version of manifest.versions ?? []) {
if (!version.parentVersionId) {
continue;
}
const children = childVersionIdsByParent.get(version.parentVersionId) ?? [];
children.push(version.versionId);
childVersionIdsByParent.set(version.parentVersionId, children);
}
for (const [index, manifestVersion] of (manifest.versions ?? []).entries()) {
const version: ProjectVersionResourceSummary = {
...manifestVersion,
label: `版本 ${index + 1}`,
childVersionIds:
childVersionIdsByParent.get(manifestVersion.versionId) ?? [],
};
resources.push({
id: `version:${version.versionId}`,
category: projectResourceCanvasCategory({ subtype: 'project-version' }),
subtype: 'project-version',
label: version.label,
path: `项目版本 · ${version.versionId}`,
mediaType: '正式项目版本',
sourceLabel: version.parentVersionId
? `项目修订 ${version.projectRevision} · 父版本 ${version.parentVersionId}`
: `项目修订 ${version.projectRevision} · 初始版本`,
taskTitle: null,
manifestAssetId: null,
producerTaskId: null,
externalResourceId: null,
referenceResourceIds: [],
dependencies: [],
dependencyDepth: 0,
version,
});
}
const uniqueByPath = new Map<string, ProjectResource>();
for (const resource of resources) {
const existing = uniqueByPath.get(resource.path);
if (!existing || resourcePriority(resource) > resourcePriority(existing)) {
uniqueByPath.set(resource.path, resource);
}
}
return Array.from(uniqueByPath.values());
}