Files
Genarrative/apps/ai-game-creator-shell/src/view/project-development/resourceProjectionModel.ts
T
suzmii 1c2f82e246
Project CI / Repository checks (push) Successful in 3m11s
Project CI / Frontend tests (push) Successful in 4m59s
Project CI / Backend tests (push) Successful in 9m53s
Project CI / Native shell tests (push) Successful in 19m18s
优化 Game Agent 资源画布展示与默认缩放 (#208)
## 变更内容

### 1. 普通资源画布隐藏游戏代码

- 资源画布的大纲、分页和卡片展示不再显示“游戏代码”栏目。
- 保留内部完整的五栏目布局契约,避免已有代码资源坐标被重写。
- 补充回归测试,确认游戏代码不会出现在普通资源画布中,且代码端点不可见时不会渲染对应依赖边。

### 2. 增加资源类型角标

- 资源卡右上角增加类型角标,用于快速识别资源类型。
- 类型从既有资源投影字段推导,覆盖图片、SVG、视频、音频、文档、任务产物、Agent 回执、项目版本等类型,并为未知类型兜底。
- 该能力仅用于前端展示,不新增后端字段或修改资源投影契约。

### 3. 限制默认自动适配缩放

- 画布首次自动适配和显式复位时的默认最大缩放统一限制为 `1.5`,避免内容较少或只有一个资源时卡片被放得过大。
- 该限制对设计文档、美术、音频、游戏代码、项目版本全部资源栏目生效。
- 用户主动缩放仍沿用现有上限,不受默认适配上限影响。
- 用户调整后的画布缩放会按排序模式和栏目保留;切换到其他栏目再返回后,仍恢复用户调整后的 viewport,而不是重新自动放大。

## 兼容性

- 不修改后端 API、SpacetimeDB schema 或 `/api/external/v1`。
- 资源类型角标是展示层推导逻辑,不改变资源数据结构。
- 内部布局仍保留游戏代码栏目契约,已有布局数据不需要迁移。

## 验证

- `resourceCanvasLayoutModel.test.ts`
- `project-development.suite.ts` 相关 App Surface 用例
- `npm run typecheck`
- `npm run check:encoding`
- Prettier 相关文件检查
- `git diff --check`

历史分支检查不替代最新 head CI;PR 更新后将以最新 CI 结果为准。

---------

Co-authored-by: 段舒康 <kdletters@qq.com>
Reviewed-on: http://192.168.35.82/git/GenarrativeAI/Genarrative/pulls/208
Co-authored-by: 董羽秦 <suzmii@qq.com>
Co-committed-by: 董羽秦 <suzmii@qq.com>
2026-08-31 14:36:58 +08:00

372 lines
11 KiB
TypeScript

import type {
GameCreationAppManifest,
GameIterationVersion,
ProjectResourceCanvasSection,
} from '../../../../../packages/shared/src/contracts/gameCreationApp';
export type ProjectResourceCategory = ProjectResourceCanvasSection;
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;
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 function classifyProjectedResource(input: {
path: string;
mediaType: string;
kind?: string;
}): Exclude<ProjectResourceCategory, 'version'> | null {
const normalizedPath = input.path.trim().toLowerCase();
const normalizedMediaType = input.mediaType.trim().toLowerCase();
const normalizedKind = input.kind?.trim().toLowerCase() ?? '';
if (
normalizedMediaType.startsWith('audio/') ||
audioExtension.test(normalizedPath) ||
audioKind.test(normalizedKind)
) {
return 'audio';
}
if (
normalizedMediaType.startsWith('image/') ||
normalizedMediaType.startsWith('video/') ||
artExtension.test(normalizedPath) ||
artKind.test(normalizedKind)
) {
return 'art';
}
if (
normalizedMediaType === 'text/html' ||
normalizedMediaType === 'text/css' ||
normalizedMediaType.includes('javascript') ||
normalizedMediaType.includes('typescript') ||
gameCodeExtension.test(normalizedPath) ||
gameCodeKind.test(normalizedKind)
) {
return 'code';
}
if (
normalizedMediaType.includes('json') ||
normalizedMediaType.includes('yaml') ||
normalizedMediaType.startsWith('text/') ||
documentExtension.test(normalizedPath)
) {
return 'document';
}
return null;
}
/**
* 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,
'category' | 'subtype' | 'path' | 'mediaType'
>,
): ProjectResourceTypeLabel {
const subtype = resource.subtype.trim().toLowerCase();
const path = resource.path.trim().toLowerCase();
const mediaType = resource.mediaType.trim().toLowerCase();
if (resource.category === 'version' || subtype === 'project-version') {
return '项目版本';
}
if (subtype === 'agent-result') {
return 'Agent 回执';
}
if (resource.category === 'code') {
return '游戏代码';
}
if (mediaType === 'image/svg+xml' || /\.svg$/iu.test(path)) {
return 'SVG';
}
if (
resource.category === 'audio' ||
mediaType.startsWith('audio/') ||
audioExtension.test(path)
) {
return '音频';
}
if (mediaType.startsWith('video/') || /\.(mp4|webm|mov)$/iu.test(path)) {
return '视频';
}
if (resource.category === 'document' || mediaType.startsWith('text/')) {
return '文档';
}
if (resource.category === 'art' || mediaType.startsWith('image/')) {
return '图片';
}
if (subtype === 'task-artifact') {
return '任务产物';
}
return '未知';
}
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 category = classifyProjectedResource({ path, mediaType: '' });
if (!category) {
continue;
}
resources.push({
id: `task:${task.id}:${path}`,
category,
subtype: 'task-artifact',
label: fileName(path),
path,
mediaType:
category === 'document'
? '项目文档'
: category === 'code'
? '游戏代码'
: category === '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 category = classifyProjectedResource({
path: asset.localPath,
mediaType: asset.mediaType,
kind: asset.kind,
});
if (!category) {
continue;
}
const isPendingUiPrototype =
asset.kind === 'ui-prototype' &&
taskById.get('design-foundation')?.status !== 'completed';
resources.push({
id: `asset:${asset.id}`,
category,
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,
imageSequenceFrames: asset.imageSequenceFrames ?? null,
imageSequenceDurationMs: asset.imageSequenceDurationMs ?? null,
});
}
for (const attachment of attachments) {
if (attachment.status !== 'imported' || !attachment.localPath) {
continue;
}
const category = classifyProjectedResource({
path: attachment.localPath,
mediaType: attachment.mediaType,
});
if (!category) {
continue;
}
resources.push({
id: `attachment:${attachment.localPath}`,
category,
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: 'document',
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: '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());
}