完成资源分类投影与中央聚焦
收口文档、版本、美术和音频资源分类 将资源详情改为中央主视窗聚焦并恢复列表上下文 补充资源身份、聚焦状态和依赖图回归测试 同步工作台PRD、技术方案和项目决策记录
This commit is contained in:
@@ -3944,7 +3944,8 @@ iframe.preview-frame {
|
||||
stroke-width: 2px;
|
||||
}
|
||||
|
||||
.game-resource-dependency-edge--task.is-highlighted .game-resource-dependency-trunk {
|
||||
.game-resource-dependency-edge--task.is-highlighted
|
||||
.game-resource-dependency-trunk {
|
||||
stroke-width: 2.4px;
|
||||
}
|
||||
|
||||
@@ -4092,38 +4093,16 @@ iframe.preview-frame {
|
||||
font-size: 9px;
|
||||
}
|
||||
|
||||
.game-resource-focus-layer {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: 240;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.game-resource-focus {
|
||||
position: fixed;
|
||||
left: 50%;
|
||||
top: 50%;
|
||||
display: grid;
|
||||
grid-template-rows: auto minmax(0, 1fr);
|
||||
width: min(520px, calc(100vw - 108px));
|
||||
max-height: min(620px, calc(100dvh - 190px));
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
min-height: 0;
|
||||
overflow: hidden;
|
||||
border: 1px solid #dda07e;
|
||||
border-radius: 16px;
|
||||
background: rgb(255 251 247 / 97%);
|
||||
background: #fffdfa;
|
||||
color: #563b31;
|
||||
box-shadow: 0 20px 42px rgb(84 49 34 / 18%);
|
||||
outline: 0;
|
||||
pointer-events: auto;
|
||||
transform: translate(-50%, -50%);
|
||||
}
|
||||
|
||||
.game-resource-focus[style] {
|
||||
transform: none;
|
||||
}
|
||||
|
||||
.game-resource-focus--art {
|
||||
width: min(760px, calc(100vw - 140px));
|
||||
}
|
||||
|
||||
.game-resource-focus-titlebar {
|
||||
@@ -4132,16 +4111,9 @@ iframe.preview-frame {
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
min-height: 58px;
|
||||
padding: 10px 12px 10px 14px;
|
||||
padding: 10px 14px 10px 16px;
|
||||
border-bottom: 1px solid #ead8cf;
|
||||
background: #fff8f3;
|
||||
cursor: grab;
|
||||
touch-action: none;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.game-resource-focus-titlebar:active {
|
||||
cursor: grabbing;
|
||||
}
|
||||
|
||||
.game-resource-focus-heading {
|
||||
@@ -4151,6 +4123,17 @@ iframe.preview-frame {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.game-resource-focus-heading > span:last-child {
|
||||
display: grid;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.game-resource-focus-heading small {
|
||||
color: #a27764;
|
||||
font-size: 9px;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.game-resource-focus-icon {
|
||||
display: grid;
|
||||
width: 36px;
|
||||
@@ -4176,26 +4159,12 @@ iframe.preview-frame {
|
||||
align-content: start;
|
||||
gap: 6px;
|
||||
min-height: 0;
|
||||
padding: 14px 16px 16px;
|
||||
padding: 18px 20px 24px;
|
||||
overflow: auto;
|
||||
overscroll-behavior: contain;
|
||||
scrollbar-gutter: stable;
|
||||
}
|
||||
|
||||
.game-resource-focus span,
|
||||
.game-resource-focus small {
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.game-resource-focus span {
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
.game-resource-focus small {
|
||||
color: #92776c;
|
||||
font-size: 10px;
|
||||
}
|
||||
|
||||
.game-resource-focus-close {
|
||||
display: grid;
|
||||
width: 28px;
|
||||
@@ -4210,10 +4179,43 @@ iframe.preview-frame {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.game-resource-focus-metadata {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: 8px 18px;
|
||||
margin: 0 0 8px;
|
||||
padding: 12px 14px;
|
||||
border: 1px solid #eaded8;
|
||||
border-radius: 12px;
|
||||
background: #fff;
|
||||
}
|
||||
|
||||
.game-resource-focus-metadata > div {
|
||||
display: grid;
|
||||
grid-template-columns: 72px minmax(0, 1fr);
|
||||
gap: 8px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.game-resource-focus-metadata dt,
|
||||
.game-resource-focus-metadata dd {
|
||||
margin: 0;
|
||||
overflow-wrap: anywhere;
|
||||
font-size: 10px;
|
||||
}
|
||||
|
||||
.game-resource-focus-metadata dt {
|
||||
color: #a08377;
|
||||
}
|
||||
|
||||
.game-resource-focus-metadata dd {
|
||||
color: #5d4339;
|
||||
}
|
||||
|
||||
.game-resource-image-preview {
|
||||
position: relative;
|
||||
display: grid;
|
||||
height: min(420px, calc(100dvh - 360px));
|
||||
height: min(460px, calc(100dvh - 330px));
|
||||
min-height: 260px;
|
||||
margin-bottom: 8px;
|
||||
overflow: hidden;
|
||||
@@ -4251,13 +4253,13 @@ iframe.preview-frame {
|
||||
|
||||
.game-resource-document-body {
|
||||
height: max-content;
|
||||
min-height: 120px;
|
||||
min-height: 220px;
|
||||
margin-top: 6px;
|
||||
padding: 14px;
|
||||
border-radius: 10px;
|
||||
background: #f8f2ee;
|
||||
color: #9e8579;
|
||||
font-size: 11px;
|
||||
color: #765c51;
|
||||
font-size: 12px;
|
||||
line-height: 1.6;
|
||||
overflow: hidden;
|
||||
overflow-wrap: anywhere;
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,264 @@
|
||||
import type {
|
||||
GameCreationAppManifest,
|
||||
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 = {
|
||||
versionId: string;
|
||||
label: string;
|
||||
projectRevision: number;
|
||||
parentVersionId: string | null;
|
||||
createdAt: number;
|
||||
};
|
||||
|
||||
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;
|
||||
};
|
||||
|
||||
const documentExtension = /\.(md|markdown|mdx|txt|json|ya?ml|toml)$/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 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.includes('json') ||
|
||||
normalizedMediaType.includes('yaml') ||
|
||||
normalizedMediaType.startsWith('text/') ||
|
||||
documentExtension.test(normalizedPath)
|
||||
) {
|
||||
return 'document';
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
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[],
|
||||
projectVersions: ProjectVersionResourceSummary[] = [],
|
||||
) {
|
||||
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 || category === 'audio') {
|
||||
continue;
|
||||
}
|
||||
resources.push({
|
||||
id: `task:${task.id}:${path}`,
|
||||
category,
|
||||
subtype: 'task-artifact',
|
||||
label: fileName(path),
|
||||
path,
|
||||
mediaType: category === 'document' ? '项目文档' : '美术产物',
|
||||
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,
|
||||
});
|
||||
}
|
||||
|
||||
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,
|
||||
});
|
||||
}
|
||||
|
||||
for (const version of projectVersions) {
|
||||
resources.push({
|
||||
id: `version:${version.versionId}`,
|
||||
category: 'version',
|
||||
subtype: 'project-version',
|
||||
label: version.label,
|
||||
path: `项目版本 · ${version.versionId}`,
|
||||
mediaType: '正式项目版本',
|
||||
sourceLabel: `项目修订 ${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());
|
||||
}
|
||||
@@ -676,7 +676,7 @@ export function registerProjectWorkbenchFoundationTests() {
|
||||
expect(screen.getByRole('article', { name: /发布 Agent/ })).not.toBeNull();
|
||||
});
|
||||
|
||||
it('keeps project chrome out and opens text receipts from non-draggable resource cards', () => {
|
||||
it('opens text receipts in the central focus state and restores the resource list context', () => {
|
||||
const manifest = createGameCreationAppManifest(
|
||||
'workbench-resource-details',
|
||||
'不应显示的项目标题',
|
||||
@@ -735,6 +735,17 @@ export function registerProjectWorkbenchFoundationTests() {
|
||||
screen.getByText('美术资源计划已完成,尚未生成或登记图片'),
|
||||
).not.toBeNull();
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: '按类型' }));
|
||||
const searchInput = screen.getByLabelText(
|
||||
'搜索项目资源',
|
||||
) as HTMLInputElement;
|
||||
fireEvent.change(searchInput, { target: { value: '美术资源计划' } });
|
||||
const resourceCanvas = screen.getByLabelText(
|
||||
'资源类型视图',
|
||||
) as HTMLDivElement;
|
||||
resourceCanvas.scrollLeft = 48;
|
||||
resourceCanvas.scrollTop = 36;
|
||||
|
||||
const resourceCards = screen.getAllByTitle('打开资源详情');
|
||||
const artReceiptCard = resourceCards.find((card) =>
|
||||
card.textContent?.includes('美术资源计划 Agent 文本回执'),
|
||||
@@ -770,22 +781,59 @@ export function registerProjectWorkbenchFoundationTests() {
|
||||
expect(styles).not.toMatch(/\.game-resource-card\.is-dragging/);
|
||||
|
||||
fireEvent.click(artReceiptCard!);
|
||||
const receiptDialog = screen.getByRole('dialog', {
|
||||
const workbenchStage = screen.getByLabelText('项目主视窗');
|
||||
expect(workbenchStage.getAttribute('data-resource-view-state')).toBe(
|
||||
'resources.focused.document',
|
||||
);
|
||||
expect(screen.queryByLabelText('搜索项目资源')).toBeNull();
|
||||
expect(screen.queryByRole('button', { name: '按类型' })).toBeNull();
|
||||
expect(screen.getByLabelText('陶泥儿 Agent 对话')).not.toBeNull();
|
||||
expect(screen.getByLabelText('子 Agent 状态栏')).not.toBeNull();
|
||||
const receiptFocus = screen.getByRole('region', {
|
||||
name: '美术资源计划 Agent 文本回执',
|
||||
});
|
||||
expect(
|
||||
within(receiptDialog).getByRole('heading', { name: '美术计划' }),
|
||||
within(receiptFocus).getByRole('heading', { name: '美术计划' }),
|
||||
).not.toBeNull();
|
||||
expect(within(receiptDialog).getByText('仅有美术计划')).not.toBeNull();
|
||||
expect(within(receiptDialog).getByText('没有图片文件')).not.toBeNull();
|
||||
expect(within(receiptFocus).getByText('仅有美术计划')).not.toBeNull();
|
||||
expect(within(receiptFocus).getByText('没有图片文件')).not.toBeNull();
|
||||
expect(within(receiptFocus).getByText('等待实际素材生成。')).not.toBeNull();
|
||||
expect(
|
||||
within(receiptDialog).getByText('等待实际素材生成。'),
|
||||
receiptFocus.querySelector('.game-resource-focus-body'),
|
||||
).not.toBeNull();
|
||||
expect(receiptFocus.querySelector('ul')).not.toBeNull();
|
||||
expect(receiptFocus.querySelector('strong')).not.toBeNull();
|
||||
expect(receiptFocus.closest('.game-workbench-stage')).toBe(workbenchStage);
|
||||
expect(receiptFocus.closest('.game-resource-focus-layer')).toBeNull();
|
||||
expect(styles).not.toMatch(
|
||||
/\.game-resource-focus-titlebar\s*\{[^}]*cursor:/s,
|
||||
);
|
||||
|
||||
fireEvent.click(
|
||||
within(receiptFocus).getByRole('button', { name: '收起资源' }),
|
||||
);
|
||||
expect(workbenchStage.getAttribute('data-resource-view-state')).toBe(
|
||||
'resources.list',
|
||||
);
|
||||
expect(
|
||||
receiptDialog.querySelector('.game-resource-focus-body'),
|
||||
).not.toBeNull();
|
||||
expect(receiptDialog.querySelector('ul')).not.toBeNull();
|
||||
expect(receiptDialog.querySelector('strong')).not.toBeNull();
|
||||
(screen.getByLabelText('搜索项目资源') as HTMLInputElement).value,
|
||||
).toBe('美术资源计划');
|
||||
expect(
|
||||
screen
|
||||
.getByRole('button', { name: '按类型' })
|
||||
.getAttribute('aria-pressed'),
|
||||
).toBe('true');
|
||||
const restoredCanvas = screen.getByLabelText(
|
||||
'资源类型视图',
|
||||
) as HTMLDivElement;
|
||||
expect(restoredCanvas.scrollLeft).toBe(48);
|
||||
expect(restoredCanvas.scrollTop).toBe(36);
|
||||
const restoredCard = screen
|
||||
.getAllByTitle('打开资源详情')
|
||||
.find((card) =>
|
||||
card.textContent?.includes('美术资源计划 Agent 文本回执'),
|
||||
);
|
||||
expect(restoredCard?.getAttribute('aria-pressed')).toBe('true');
|
||||
});
|
||||
|
||||
it('renders, filters, highlights, and destroys the resource dependency overlay without pointer previews', async () => {
|
||||
@@ -834,8 +882,7 @@ export function registerProjectWorkbenchFoundationTests() {
|
||||
'asset-reference:["asset:dependency-spec","asset:dependency-ui"]';
|
||||
const selfReferenceId =
|
||||
'asset-reference:["asset:unrelated-cycle","asset:unrelated-cycle"]';
|
||||
const flowId =
|
||||
'task-flow:["art-director","design-foundation"]';
|
||||
const flowId = 'task-flow:["art-director","design-foundation"]';
|
||||
let layoutRevision = 0;
|
||||
const invoke = vi.fn(
|
||||
async (command: string, args?: Record<string, unknown>) => {
|
||||
@@ -899,12 +946,8 @@ export function registerProjectWorkbenchFoundationTests() {
|
||||
},
|
||||
{
|
||||
resourceId: 'asset:unrelated-cycle',
|
||||
upstreamReferenceResourceIds: [
|
||||
'asset:unrelated-cycle',
|
||||
],
|
||||
downstreamReferenceResourceIds: [
|
||||
'asset:unrelated-cycle',
|
||||
],
|
||||
upstreamReferenceResourceIds: ['asset:unrelated-cycle'],
|
||||
downstreamReferenceResourceIds: ['asset:unrelated-cycle'],
|
||||
referenceEdgeIds: [selfReferenceId],
|
||||
taskFlowIds: [],
|
||||
},
|
||||
@@ -992,7 +1035,7 @@ export function registerProjectWorkbenchFoundationTests() {
|
||||
});
|
||||
fireEvent.change(search, { target: { value: '' } });
|
||||
|
||||
const sourceCard = screen.getByRole('button', {
|
||||
let sourceCard = screen.getByRole('button', {
|
||||
name: /spec-source\.json/,
|
||||
});
|
||||
const targetCard = screen.getByRole('button', {
|
||||
@@ -1042,6 +1085,14 @@ export function registerProjectWorkbenchFoundationTests() {
|
||||
).toHaveLength(layoutUpdatesBeforePointer);
|
||||
|
||||
fireEvent.click(targetCard);
|
||||
const targetFocus = screen.getByRole('region', {
|
||||
name: /ui-dependency\.json/u,
|
||||
});
|
||||
fireEvent.click(
|
||||
within(targetFocus).getByRole('button', { name: '收起资源' }),
|
||||
);
|
||||
overlay = await screen.findByTestId('resource-dependency-overlay');
|
||||
sourceCard = screen.getByRole('button', { name: /spec-source\.json/ });
|
||||
await waitFor(() => {
|
||||
expect(sourceCard.classList.contains('is-relation-upstream')).toBe(true);
|
||||
expect(
|
||||
@@ -1259,7 +1310,7 @@ export function registerProjectWorkbenchFoundationTests() {
|
||||
|
||||
fireEvent.click(card!);
|
||||
expect(
|
||||
screen.getByRole('dialog', { name: '布局持久化回执' }),
|
||||
screen.getByRole('region', { name: '布局持久化回执' }),
|
||||
).not.toBeNull();
|
||||
|
||||
cleanup();
|
||||
@@ -1383,23 +1434,23 @@ export function registerProjectWorkbenchFoundationTests() {
|
||||
const manifest = createGameCreationAppManifest(projectId, '布局失败测试');
|
||||
const invoke = vi.fn(
|
||||
async (command: string, args?: Record<string, unknown>) => {
|
||||
if (command === 'read_local_project_resource_graph') {
|
||||
return resourceGraphForInputs(args);
|
||||
}
|
||||
if (command === 'read_local_project_resource_canvas_layout') {
|
||||
return {
|
||||
schemaVersion: 'game-creator-resource-layout.v1',
|
||||
projectId,
|
||||
mode: 'dependency',
|
||||
revision: 2,
|
||||
positions: [],
|
||||
updatedAt: 200,
|
||||
};
|
||||
}
|
||||
if (command === 'update_local_project_resource_canvas_layout') {
|
||||
throw new Error('disk full');
|
||||
}
|
||||
throw new Error(`unexpected invoke ${command}`);
|
||||
if (command === 'read_local_project_resource_graph') {
|
||||
return resourceGraphForInputs(args);
|
||||
}
|
||||
if (command === 'read_local_project_resource_canvas_layout') {
|
||||
return {
|
||||
schemaVersion: 'game-creator-resource-layout.v1',
|
||||
projectId,
|
||||
mode: 'dependency',
|
||||
revision: 2,
|
||||
positions: [],
|
||||
updatedAt: 200,
|
||||
};
|
||||
}
|
||||
if (command === 'update_local_project_resource_canvas_layout') {
|
||||
throw new Error('disk full');
|
||||
}
|
||||
throw new Error(`unexpected invoke ${command}`);
|
||||
},
|
||||
);
|
||||
window.__TAURI__ = { core: { invoke } };
|
||||
@@ -1551,7 +1602,7 @@ export function registerProjectWorkbenchFoundationTests() {
|
||||
target: { value: 'hero.png' },
|
||||
});
|
||||
fireEvent.click(screen.getByRole('button', { name: /hero\.png/ }));
|
||||
expect(screen.getByLabelText('资源焦点')).not.toBeNull();
|
||||
expect(screen.getByRole('region', { name: 'hero.png' })).not.toBeNull();
|
||||
expect(screen.getAllByText('assets/hero.png').length).toBeGreaterThan(0);
|
||||
const previewImage = await screen.findByRole('img', {
|
||||
name: 'hero.png 图片预览',
|
||||
@@ -6945,7 +6996,7 @@ export function registerProjectSupervisorSurfaceTests() {
|
||||
return card;
|
||||
});
|
||||
fireEvent.click(updatedDesignCard);
|
||||
const updatedDesignDialog = await screen.findByRole('dialog', {
|
||||
const updatedDesignDialog = await screen.findByRole('region', {
|
||||
name: '策划 Agent 文本回执',
|
||||
});
|
||||
expect(
|
||||
|
||||
@@ -0,0 +1,186 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import { createGameCreationAppManifest } from '../../../packages/shared/src/contracts/gameCreationApp';
|
||||
import { projectResourcesFromReadModels } from '../src/view/project-development/resourceProjectionModel';
|
||||
|
||||
describe('项目资源投影', () => {
|
||||
it('只把明确资源投影到固定分类,未知任务产物不会伪装成项目版本', () => {
|
||||
const manifest = createGameCreationAppManifest(
|
||||
'resource-projection',
|
||||
'资源投影测试',
|
||||
);
|
||||
const designTask = manifest.tasks.find(
|
||||
(task) => task.id === 'design-foundation',
|
||||
);
|
||||
const audioTask = manifest.tasks.find(
|
||||
(task) => task.id === 'audio-asset-plan',
|
||||
);
|
||||
if (!designTask || !audioTask) {
|
||||
throw new Error('缺少资源投影测试任务');
|
||||
}
|
||||
designTask.status = 'completed';
|
||||
designTask.artifacts = [
|
||||
'memory/game-design.md',
|
||||
'assets/ui-preview.svg',
|
||||
'build/game.bundle',
|
||||
];
|
||||
audioTask.status = 'completed';
|
||||
audioTask.artifacts = ['audio/unregistered-bgm.wav'];
|
||||
manifest.assets = [
|
||||
{
|
||||
id: 'registered-bgm',
|
||||
kind: 'bgm',
|
||||
mediaType: 'audio/wav',
|
||||
localPath: 'audio/registered-bgm.wav',
|
||||
source: { kind: 'generated' },
|
||||
},
|
||||
{
|
||||
id: 'character-animation',
|
||||
kind: 'character-animation',
|
||||
mediaType: 'application/json',
|
||||
localPath: 'assets/character-animation.json',
|
||||
source: { kind: 'generated' },
|
||||
},
|
||||
{
|
||||
id: 'unknown-binary',
|
||||
kind: 'binary',
|
||||
mediaType: 'application/octet-stream',
|
||||
localPath: 'build/game.bundle',
|
||||
source: { kind: 'generated' },
|
||||
},
|
||||
];
|
||||
|
||||
const resources = projectResourcesFromReadModels(
|
||||
manifest,
|
||||
[
|
||||
{
|
||||
fileName: 'rules.yaml',
|
||||
mediaType: 'application/yaml',
|
||||
localPath: 'imports/rules.yaml',
|
||||
status: 'imported',
|
||||
},
|
||||
{
|
||||
fileName: 'voice.ogg',
|
||||
mediaType: 'audio/ogg',
|
||||
localPath: 'imports/voice.ogg',
|
||||
status: 'imported',
|
||||
},
|
||||
{
|
||||
fileName: 'archive.zip',
|
||||
mediaType: 'application/zip',
|
||||
localPath: 'imports/archive.zip',
|
||||
status: 'imported',
|
||||
},
|
||||
],
|
||||
[
|
||||
{
|
||||
agentId: 'design-foundation',
|
||||
runId: 'final-run',
|
||||
label: '玩法策划 Agent',
|
||||
title: '玩法文档回执',
|
||||
content: '回执正文',
|
||||
updatedAt: 1,
|
||||
},
|
||||
],
|
||||
[
|
||||
{
|
||||
versionId: 'version-1',
|
||||
label: '首个可运行版本',
|
||||
projectRevision: 7,
|
||||
parentVersionId: null,
|
||||
createdAt: 1,
|
||||
},
|
||||
],
|
||||
);
|
||||
|
||||
expect(resources.map(({ id, category }) => ({ id, category }))).toEqual(
|
||||
expect.arrayContaining([
|
||||
{
|
||||
id: 'task:design-foundation:memory/game-design.md',
|
||||
category: 'document',
|
||||
},
|
||||
{
|
||||
id: 'task:design-foundation:assets/ui-preview.svg',
|
||||
category: 'art',
|
||||
},
|
||||
{ id: 'asset:registered-bgm', category: 'audio' },
|
||||
{ id: 'asset:character-animation', category: 'art' },
|
||||
{ id: 'attachment:imports/rules.yaml', category: 'document' },
|
||||
{ id: 'attachment:imports/voice.ogg', category: 'audio' },
|
||||
{
|
||||
id: 'agent-result:design-foundation:final-run',
|
||||
category: 'document',
|
||||
},
|
||||
{ id: 'version:version-1', category: 'version' },
|
||||
]),
|
||||
);
|
||||
expect(
|
||||
resources.some(({ id }) =>
|
||||
[
|
||||
'task:design-foundation:build/game.bundle',
|
||||
'task:audio-asset-plan:audio/unregistered-bgm.wav',
|
||||
'asset:unknown-binary',
|
||||
'attachment:imports/archive.zip',
|
||||
].includes(id),
|
||||
),
|
||||
).toBe(false);
|
||||
expect(
|
||||
resources.filter(({ category }) => category === 'version'),
|
||||
).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('显示名称变化不会改变正式资源身份', () => {
|
||||
const manifest = createGameCreationAppManifest(
|
||||
'stable-resource-id',
|
||||
'稳定资源身份测试',
|
||||
);
|
||||
const first = projectResourcesFromReadModels(
|
||||
manifest,
|
||||
[],
|
||||
[
|
||||
{
|
||||
agentId: 'design-foundation',
|
||||
runId: 'stable-run',
|
||||
label: '策划 Agent',
|
||||
title: '旧标题',
|
||||
content: '正文',
|
||||
updatedAt: 1,
|
||||
},
|
||||
],
|
||||
[
|
||||
{
|
||||
versionId: 'stable-version',
|
||||
label: '旧版本名',
|
||||
projectRevision: 1,
|
||||
parentVersionId: null,
|
||||
createdAt: 1,
|
||||
},
|
||||
],
|
||||
);
|
||||
const second = projectResourcesFromReadModels(
|
||||
manifest,
|
||||
[],
|
||||
[
|
||||
{
|
||||
agentId: 'design-foundation',
|
||||
runId: 'stable-run',
|
||||
label: '策划 Agent',
|
||||
title: '新标题',
|
||||
content: '正文',
|
||||
updatedAt: 2,
|
||||
},
|
||||
],
|
||||
[
|
||||
{
|
||||
versionId: 'stable-version',
|
||||
label: '新版本名',
|
||||
projectRevision: 1,
|
||||
parentVersionId: null,
|
||||
createdAt: 1,
|
||||
},
|
||||
],
|
||||
);
|
||||
|
||||
expect(first.map(({ id }) => id)).toEqual(second.map(({ id }) => id));
|
||||
});
|
||||
});
|
||||
@@ -124,10 +124,12 @@ idle -> focused(document|art|audio|version) -> idle
|
||||
```
|
||||
|
||||
- 文档:在中央画布展开并独立滚动。
|
||||
- 美术/音频:进入对应媒体聚焦状态,工具能力复用现有编辑器。
|
||||
- 版本:高亮版本引用资源;替换动作只创建下一迭代版本。
|
||||
- 美术/音频:进入对应媒体聚焦状态,当前只展示已有预览或资源元数据。
|
||||
- 版本:只展示正式版本 read model 的身份与修订元数据;版本引用高亮和替换留给后续切片。
|
||||
- mentor 最新决定:资源聚焦不提供工具栏,也不提供工具侧边栏。
|
||||
- 本阶段只保留点击资源打开当前已有详情,不实施后续文档读取、美术编辑、音频能力、项目版本或运行模块。
|
||||
- 点击资源后,中央主视窗从 `resources.list` 切换为 `resources.focused.document / art / audio / version`,左侧平台导航、右侧 Supervisor 对话和底部 Agent 状态栏保持原位;聚焦容器只包含标题、资源主体、必要元数据与右上角收起按钮,不使用页面级浮层或可拖动标题栏。
|
||||
- 退出聚焦后恢复进入前的搜索条件、dependency / type 布局模式、资源画布滚动位置和选中资源;这些只属于当前前端会话,不写入布局 sidecar。
|
||||
- 本阶段不新增项目文件读取命令、美术编辑、音频播放 / 编辑、版本替换或运行模块。
|
||||
|
||||
### 4.4 历史成果与当前状态
|
||||
|
||||
@@ -238,7 +240,7 @@ type UpdateProjectResourceCanvasLayoutResult =
|
||||
|
||||
#### 5.2.4 前端布局与协调合同
|
||||
|
||||
- 资源卡是可点击按钮,只负责选择资源并打开当前唯一资源详情浮层;不得绑定卡片级 `pointerdown / pointermove / pointerup / pointercancel` 拖动处理器。
|
||||
- 资源卡是可点击按钮,只负责选择资源并让中央主视窗进入当前唯一资源聚焦状态;不得绑定卡片级 `pointerdown / pointermove / pointerup / pointercancel` 拖动处理器。
|
||||
- 指针移动不得修改卡片 `x / y`、不得产生拖动预览、不得更新依赖线几何,也不得提交手动布局 CAS。卡片 title、cursor、`touch-action` 和 class 不得暗示可拖动。
|
||||
- dependency 默认布局按 `dependencyDepth` 形成横向层级,同层资源纵向寻找第一个不重叠位置;type 默认布局固定按“资源子类型 -> 媒体类型 -> 名称 -> 资源 ID”稳定排序,在分区内从左到右、从上到下寻找第一个空位。布局模型的 `subtype` 必填:manifest 资产使用 `asset.kind`,任务产物、导入附件与 Agent 文本成果分别使用稳定的 `task-artifact`、`attachment`、`agent-result`,不得以缺失值或显示文案兜底;资源协调签名必须包含 subtype。卡片尺寸与间距由单一前端布局模型常量维护。
|
||||
- type 模式资源集合变化时保留全部仍存在的坐标,只为新 ID 计算默认位置,并删除已确认失效的旧 ID。dependency 模式只永久保留 `manuallyPlaced=true` 的用户坐标;`manuallyPlaced=false` 属于可派生自动位置,在 Rust 关系图首次就绪或可信 producer / dependency depth 变化后按最终拓扑确定性重算。自动重算不得移动手动坐标,协调结果与持久布局逐项一致时不得产生 CAS 写入。
|
||||
@@ -271,6 +273,10 @@ type UpdateProjectResourceCanvasLayoutResult =
|
||||
|
||||
### 5.3 资源类型与替换兼容性(P1)
|
||||
|
||||
实现状态(2026-08-03):当前资源投影已收口到固定的“文档 -> 项目版本 -> 美术资源 -> 音乐音效资源”四区。文档只接收 Markdown / 文本 / JSON / YAML 等正式项目文档和合法 Agent 文本回执;项目版本只接收显式 `ProjectVersionResourceSummary` read model,未知任务产物不得兜底为版本;美术接收图片、SVG、动画和视频类产物;音频只接收 manifest 已登记音频资产或已成功导入的音频附件,任务声明中的未登记音频路径不冒充正式音频资源。无法识别的二进制任务产物和附件不进入资源画布。
|
||||
|
||||
资源身份固定使用 manifest asset ID、正式 version ID、Agent ID + run ID 或已导入资源稳定路径;显示标题、来源文案变化不得改变 `resourceId`,从而避免布局、依赖边、选择和聚焦状态因改名失效。
|
||||
|
||||
```ts
|
||||
type ProjectResourceDescriptor = {
|
||||
resourceId: string;
|
||||
@@ -361,7 +367,7 @@ type ProjectAgentMudPointAttribution = {
|
||||
|
||||
- 复用现有四区工作台壳。
|
||||
- 资源/运行切换与客户端内 loopback 预览。
|
||||
- 只读资源画布与资源卡点击详情;资源聚焦工具栏 / 工具侧边栏及后续媒体能力暂缓。
|
||||
- 固定四类资源投影、只读资源画布与中央主视窗资源聚焦;资源聚焦工具栏 / 工具侧边栏及后续媒体能力暂缓。
|
||||
- Supervisor 正式会话、上传、Runtime 确认与安全错误。
|
||||
- 当前 run 专业状态与项目历史成果分离。
|
||||
- 默认三专业组,并可展开另外三组。
|
||||
@@ -393,6 +399,8 @@ type ProjectAgentMudPointAttribution = {
|
||||
4. 底栏默认显示策划、美术、程序,可展开数值、音频、发布;状态与任务来自真实 Runtime/manifest。
|
||||
5. 风险审批和无需审批不能改变运行策略,点击后明确提示尚未开放;严格审批继续使用现有 Runtime 门禁。
|
||||
6. 不显示伪造泥点、伪造资源完成度、伪造图片或外部浏览器成功提示。
|
||||
7. 未识别任务产物不进入“项目版本”,只有正式版本 read model 可以生成版本卡;资源显示名称变化不改变资源身份。
|
||||
8. 点击任一资源后只替换中央主视窗,右侧对话与底部 Agent 状态栏保持原位;收起或按 Escape 退出后恢复原搜索、布局模式、滚动位置和选中资源。
|
||||
|
||||
### 7.2 P1 资源画布布局持久化验收
|
||||
|
||||
@@ -410,15 +418,15 @@ type ProjectAgentMudPointAttribution = {
|
||||
2. 精确引用只接受唯一有效的外部资源 ID 映射,删除或不存在的资源不产生幽灵连线。
|
||||
3. 多资源任务依赖只形成一条聚合主线与 `O(S+T)` 条端点分支,不产生 `S×T` 连线。
|
||||
4. 资源引用环和无资源产物参与的任务环都可被有限遍历识别,界面不死循环。
|
||||
5. 搜索触发端点过滤,选择触发直接上下游与关联边高亮;资源卡指针移动不更新线段,点击与详情浮层行为不回归。
|
||||
5. 搜索触发端点过滤,选择触发直接上下游与关联边高亮;资源卡指针移动不更新线段,点击与中央聚焦行为不回归。
|
||||
6. 切换布局模式或项目后旧 SVG、ResizeObserver 与窗口监听全部清理;图层从不写入 layout sidecar、manifest 或其它持久化。
|
||||
7. 4096 资源链式 fixture 继续验证拓扑、聚合复杂度和自动布局性能;拖动局部更新与真实 Chromium 拖动帧预算暂缓,不作为当前验收条件。最右侧自环与箭头仍需完整显示。
|
||||
8. Rust 图读取延迟时,dependency sidecar 在图进入 `ready / failed` 前没有读取或写入;首次布局直接使用 Rust 返回的最终 producer 与 dependency depth。重新打开旧布局时手动位置逐项不变,自动位置按最终拓扑协调且相同结果不增加 revision。
|
||||
|
||||
## 8. 非目标
|
||||
|
||||
- 本切片不实现资源卡手动拖动,也不实现资源聚焦工具栏、资源聚焦工具侧边栏、后续文档读取、美术编辑、音频能力、资源替换、不可变迭代版本、运行模块扩展、测试切片、数值参数或泥点归因;已实现的资源关系图只提供 Rust 只读拓扑与前端派生展示,不建立新的资源业务真相。
|
||||
- 本切片不保存资源详情浮层位置、画布缩放 / 平移、搜索条件、筛选条件或当前 mode;这些状态如需持久化必须另行扩展合同,不能塞入 `game-creator-resource-layout.v1`。
|
||||
- 本切片不实现资源卡手动拖动,也不实现资源聚焦工具栏、资源聚焦工具侧边栏、后续项目文件读取、美术编辑、音频播放 / 编辑、资源替换、不可变迭代版本写入、运行模块扩展、测试切片、数值参数或泥点归因;已实现的资源关系图只提供 Rust 只读拓扑与前端派生展示,不建立新的资源业务真相。
|
||||
- 本切片不持久化资源聚焦状态、画布缩放 / 平移、搜索条件、筛选条件或当前 mode;聚焦退出时的列表上下文恢复只限当前前端会话,这些状态如需跨重启保存必须另行扩展合同,不能塞入 `game-creator-resource-layout.v1`。
|
||||
- 不修改 SpacetimeDB schema。
|
||||
- 不开放普通用户 Agent.md/Skill。
|
||||
- 不自动确认 Agent 动作,不自动触发可能扣费的生成。
|
||||
|
||||
@@ -1,12 +1,13 @@
|
||||
# 决策记录
|
||||
|
||||
## 2026-08-03 资源卡手动拖动暂缓且当前只开放自动布局与点击详情
|
||||
## 2026-08-03 资源卡手动拖动暂缓并完成固定资源投影与中央聚焦
|
||||
|
||||
- 背景:飞书《陶泥儿GameAgent-V1.0 项目开发界面需求》曾要求资源卡可拖动,并由后续 PRD、技术方案和实现扩展为手动布局 CAS、依赖线拖动预览与性能验收;mentor 最新决定明确资源卡暂时禁止拖动,资源聚焦也不需要工具栏或工具侧边栏。
|
||||
- 决策:当前资源卡不绑定 `pointerdown / pointermove / pointerup / pointercancel` 拖动入口,title、cursor、`touch-action` 与 class 只表达可点击;Pointer Move 不改变坐标、不更新依赖线、不提交手动布局。资源卡点击继续打开已有详情;dependency / type 自动布局、搜索、依赖线和直接上下游高亮保持现役。
|
||||
- 决策:当前资源卡不绑定 `pointerdown / pointermove / pointerup / pointercancel` 拖动入口,title、cursor、`touch-action` 与 class 只表达可点击;Pointer Move 不改变坐标、不更新依赖线、不提交手动布局。资源投影固定为文档、项目版本、美术资源、音乐音效资源四区;未知任务产物不兜底为版本,版本只接受显式 read model,音频只接受正式登记资产或已导入附件。资源身份不使用显示名称。
|
||||
- 保留边界:项目内 dependency / type 布局 sidecar、历史坐标读取、资源集合自动协调、scope FIFO、跨窗口系统锁、Tauri/Rust CAS 与命令式 SVG preview 基础设施可以保留;历史 `manuallyPlaced=true` 坐标不删除、不重置、不迁移,但当前没有用户手动布局入口。手动拖动持久化、拖动性能和冲突后的重新拖动提示不再是当前验收条件。
|
||||
- 后续边界:本阶段不继续实现资源聚焦、文档读取、美术编辑、音频能力、项目版本或运行模块;若重新开放手动拖动,必须先更新 PRD、技术方案和 AppSurface 验收合同。
|
||||
- 验证方式:AppSurface 覆盖 Pointer Down / Move / Up / Cancel 后卡片坐标、SVG path 与布局更新调用均不变,并继续覆盖点击详情、dependency / type 切换、搜索、依赖图和直接上下游高亮;追加 shell typecheck、编码检查与 `git diff --check`。
|
||||
- 聚焦边界:点击资源后只把中央主视窗切换为 `resources.focused.document / art / audio / version`,不遮盖或替换右侧 Supervisor 与底部 Agent 状态栏;通用容器不提供工具栏、工具侧边栏、底部画板工具栏或可拖动标题栏。退出恢复当前会话内搜索、dependency / type、画布滚动位置和选中资源,不把这些状态写入 sidecar。
|
||||
- 后续边界:本阶段不新增项目文件读取、美术编辑、音频播放 / 编辑、版本替换或运行模块;若重新开放手动拖动,必须先更新 PRD、技术方案和 AppSurface 验收合同。
|
||||
- 验证方式:纯投影测试覆盖四类映射、未知产物拒绝和显示名称改动下身份稳定;AppSurface 覆盖 Pointer Down / Move / Up / Cancel 后卡片坐标、SVG path 与布局更新调用均不变,并覆盖中央聚焦、上下文恢复、dependency / type 切换、搜索、依赖图和直接上下游高亮;追加 shell typecheck、编码检查与 `git diff --check`。
|
||||
- 关联文档:`docs/prd/【AI游戏创作】项目开发工作台PRD-2026-07-20.md`、`docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md`。
|
||||
|
||||
## 2026-08-03 依赖布局等待 Rust 图终态且拖动热路径脱离 React state
|
||||
@@ -5559,7 +5560,7 @@
|
||||
- 资源投影:上述回执同步投影到“资源管理 → 文档”,保留来源 Agent 和 run 身份。它是持久回执的可见视图,不得冒充 manifest asset、项目目录中的实际文件或可下载交付物。
|
||||
- 重试确认:`agent.resume` 默认仍为 `confirm`。普通自动 retry command 保留 auto gate;正式失败卡的“在当前项目重试”按钮本身视为本次明确确认,调用单独的 confirmed retry command,但仍不得绕过 deny。点击后必须在原卡即时显示提交中、成功或安全错误,不能把错误放到专业列表末尾。若总控已为同一 delegation 准备精确 repair,按钮优先确认该 repair,不再创建重复的无合同重试。
|
||||
- 回执命名:无文件的 completed 结果统一称“专业 Agent 文本回执”,不得称“美术产物”或直接暴露 `design-foundation / art-asset-plan / balance-seed` 等内部 ID。美术任务只完成计划且 manifest 没有图片时,普通界面明确显示“仅完成计划,尚未生成或登记图片”。
|
||||
- 工作台布局:PDF 方案外的顶部项目标题条不进入项目工作台;本条原定的资源卡同分类、当前会话内拖拽重排已由 2026-08-03 mentor 最新决定取代,当前资源卡只允许自动布局与点击详情。资源详情使用独立可拖动浮层,位置约束在工作台与 viewport 内并避开底部 Agent dock,长正文独立滚动。工作区与 dock 精确占满客户端可用高度,不保留 dock 下方空白。
|
||||
- 工作台布局:PDF 方案外的顶部项目标题条不进入项目工作台;本条原定的资源卡同分类、当前会话内拖拽重排已由 2026-08-03 mentor 最新决定取代,当前资源卡只允许自动布局与点击聚焦。原独立可拖动详情浮层已被同日后续阶段三替换为中央主视窗资源聚焦状态,右侧对话与底部 Agent dock 常驻,长正文在聚焦主体内独立滚动;退出恢复当前会话的列表上下文。工作区与 dock 精确占满客户端可用高度,不保留 dock 下方空白。
|
||||
|
||||
## 2026-07-20 AI 游戏创作策划与美术图片交付门禁
|
||||
|
||||
|
||||
@@ -154,7 +154,7 @@ V1.17 计划快照随 `game-creator-runtime-context-bundle.v3` 持久化,v2
|
||||
|
||||
2026-07-19 起,当前父 run 下的专业 Agent 进入 `failed` 后,正式工作台必须提供“在当前项目重试”恢复入口,不得要求用户新建项目。重试必须精确核对原 `agentId + runId + parentRunId`,复用原 task、active Session 和父 run 归属,同时生成新的专业 Agent runId;新 run 继承已持久化的上下文和父子绑定,不覆写旧失败 run 的审计事实,也不得把 UI 重试解释为底层 transport 根因已修复。`agent.resume` 默认 `confirm` 不变:自动 retry command 继续执行 auto gate;正式失败卡按钮自身是本次明确确认,使用 deny-only 的 confirmed retry command。按钮必须原卡即时显示“正在提交重试”、受理或安全错误;若 Supervisor 已为同一 delegation 准备合同 repair,则该按钮优先确认既有 repair,避免重复派发。
|
||||
|
||||
completed 专业 Agent 的用户可见成果不能仅依赖项目文件。当委派合同为 `expectedArtifacts=[]` 或未产生显式文件时,工作台必须读取该 Agent 持久对话的最后一条 assistant,作为明确标注的“专业 Agent 文本回执”提供查看,并投影到“资源管理 → 文档”。该投影是持久回执的可见视图,必须保留来源 Agent 与 run 身份,不冒充 manifest asset、项目目录中的实际文件或可下载交付物;内部 Agent ID 不进入用户资源名。美术只交付计划且 manifest 没有图片时必须显示“仅完成计划,尚未生成或登记图片”,不能把文本回执称为美术图片产物。策划 `design-foundation` 与美术 `art-asset-plan` 从 2026-07-20 起属于图片产物型 canonical task:前者必须生成并登记 `assets/ui-prototype.png` 横屏界面原型图,后者必须生成并登记 `assets/art-spritesheet.png` 首版核心美术素材;只有文本计划或空 `expectedArtifacts` 不构成完成。策划图还必须由 `design-foundation` 的 `image.inspect` 对当前图片 SHA 形成 `ui-prototype.v2` 结构化视觉验收:信息 HUD、主要可玩区域、当前玩法的关键实体、主要操作、失败/重开流程、移动端布局意图、实现清晰度与原创主题八项全部通过且问题列表为空。验收不得预设塔防或任何固定玩法;`ui-prototype.v1` 仅供历史动作回执安全读取,不能作为新建或恢复 run 的 completion authority。Runtime finalization 只接受同 run、当前 SHA 的 v2 证据。视觉调用失败、响应不可解析、检查未通过或图片被替换后 SHA 不一致都继续阻塞。未通过的已登记图片在工作台只称“候选界面图(待视觉验收)”,不得冒充正式 UI 原型。图片生成未配置、待确认或失败时保持阻塞/失败,不得投影为 completed。资源卡当前只允许点击打开详情,不提供拖动重排;详情继续使用受 viewport 与底部 dock 约束的独立可拖浮层,长正文由唯一外层滚动容器承载并用 Markdown 安全渲染。PDF 方案外的顶部项目标题条移除,dock 下方不得保留空白;底部专业 Agent 为只读状态卡,不得因点击保留多个详情浮层。
|
||||
completed 专业 Agent 的用户可见成果不能仅依赖项目文件。当委派合同为 `expectedArtifacts=[]` 或未产生显式文件时,工作台必须读取该 Agent 持久对话的最后一条 assistant,作为明确标注的“专业 Agent 文本回执”提供查看,并投影到“资源管理 → 文档”。该投影是持久回执的可见视图,必须保留来源 Agent 与 run 身份,不冒充 manifest asset、项目目录中的实际文件或可下载交付物;内部 Agent ID 不进入用户资源名。美术只交付计划且 manifest 没有图片时必须显示“仅完成计划,尚未生成或登记图片”,不能把文本回执称为美术图片产物。策划 `design-foundation` 与美术 `art-asset-plan` 从 2026-07-20 起属于图片产物型 canonical task:前者必须生成并登记 `assets/ui-prototype.png` 横屏界面原型图,后者必须生成并登记 `assets/art-spritesheet.png` 首版核心美术素材;只有文本计划或空 `expectedArtifacts` 不构成完成。策划图还必须由 `design-foundation` 的 `image.inspect` 对当前图片 SHA 形成 `ui-prototype.v2` 结构化视觉验收:信息 HUD、主要可玩区域、当前玩法的关键实体、主要操作、失败/重开流程、移动端布局意图、实现清晰度与原创主题八项全部通过且问题列表为空。验收不得预设塔防或任何固定玩法;`ui-prototype.v1` 仅供历史动作回执安全读取,不能作为新建或恢复 run 的 completion authority。Runtime finalization 只接受同 run、当前 SHA 的 v2 证据。视觉调用失败、响应不可解析、检查未通过或图片被替换后 SHA 不一致都继续阻塞。未通过的已登记图片在工作台只称“候选界面图(待视觉验收)”,不得冒充正式 UI 原型。图片生成未配置、待确认或失败时保持阻塞/失败,不得投影为 completed。资源卡当前只允许点击聚焦,不提供拖动重排;详情使用中央主视窗内唯一聚焦状态,长正文在聚焦主体内独立滚动并用 Markdown 安全渲染。PDF 方案外的顶部项目标题条移除,右侧对话和底部 Agent dock 常驻且 dock 下方不得保留空白;底部专业 Agent 为只读状态卡。
|
||||
|
||||
V1.17 同时把 finalization journal 升级为 v2 并绑定最终完整计划快照:assistant 已落盘而 Runtime state 丢失时,从 v2 journal 恢复原结构化计划后补齐终态;assistant 尚未落盘且 state 丢失时失败关闭。外层 `failed / budget-exhausted` 只保留最后可信计划,不把未完成步骤机械改成失败。thinking summary、legacy plan event 和 tool-plan repair 公共审计只保留哈希、字符数或计数,必要的模型输出与错误上下文仅留在有界私有 repair 请求中。
|
||||
|
||||
@@ -348,7 +348,7 @@ game-project/
|
||||
- `canvas.project_sync` 复用 Genarrative External Editor API,读取用户平台 API Key 可访问的画板项目快照,通过 `/api/external/v1/assets/read-url` 换签并把资源下载到本地项目 `assets/canvas-sync/`;默认 API base URL 为 `http://127.0.0.1:8082`,可用 Tauri 应用配置目录中的 `game-creator.config.json` 的 `editorApi.baseUrl` 覆盖,API Key 从同一配置的 `editorApi.apiKey` 读取,不写入项目文件、trace、manifest 或日志。
|
||||
- Agent loop 中美术组 `Asset` 和音乐组 `SFX` 会读取 `.agent/manifest.json`;图片生成先通过 External Editor API 项目与素材库接口准备同名画布会话,再调用 `/api/external/v1/editor/images/generations`,携带 `projectId`、`assetFolderId`、`assetLabel`、`generationInputs.artSpec` 和 `canvasCompletion`,随后通过 `/api/external/v1/assets/read-url` 换签下载到受控本地 `assets/` 路径,登记为 `canvas` 来源资产并追加 `canvas.asset_generate` 本地索引记录。API Key 不写入项目文件、agent.db、trace、manifest 或日志;未配置 Key 或生成失败时,图片产物型任务保持阻塞/失败,不能以文字计划完成。音乐组仍只建议同步已有音频资源,不调用图片生成接口。
|
||||
- `canvas.asset_import` 当前作为最小真实链路:导入项目目录内已有文件为 `canvas` 来源资产,并要求记录画板项目 ID 以及 resourceId 或 assetObjectId。
|
||||
- 项目工作台点击已登记图片时必须在客户端资源浮层中直接渲染图片,而不是只展示路径与 MIME。图片通过受控 Tauri 命令从项目 `assets/` / `game/` 读取,只允许 manifest 已登记资产或已完成任务产物,并复用 `file.read` auto 权限、图片魔数、文件大小、像素尺寸、普通文件、路径漂移和符号链接校验后以 data URL 返回;首版只支持 PNG、JPEG、WEBP,不向 WebView 暴露任意本机文件协议或绝对路径。
|
||||
- 项目工作台点击已登记图片时必须在中央主视窗的资源聚焦状态中直接渲染图片,而不是只展示路径与 MIME。图片通过受控 Tauri 命令从项目 `assets/` / `game/` 读取,只允许 manifest 已登记资产或已完成任务产物,并复用 `file.read` auto 权限、图片魔数、文件大小、像素尺寸、普通文件、路径漂移和符号链接校验后以 data URL 返回;首版只支持 PNG、JPEG、WEBP,不向 WebView 暴露任意本机文件协议或绝对路径。
|
||||
- `canvas.export_import` 复用 `/editor/canvas` 已有素材导出 ZIP 格式,读取根 `metadata.json`、复制 `images/` / `media/` / `sequences/` 到本地项目 `assets/canvas-imports/`,再按导出层登记为 `canvas` 来源资产;导出包不保存真实 resourceId 时,使用 `canvas-export:<file>` 作为可追踪 assetObjectId,不伪造后端资源行。
|
||||
|
||||
## GameAgent V1.0 项目开发工作台首版界面
|
||||
@@ -359,8 +359,8 @@ game-project/
|
||||
|
||||
- 页面骨架固定为左侧现有全局导航、中间主视窗、右侧陶泥儿对话和底部子 Agent 状态栏;不新建第二套客户端或平行项目页。
|
||||
- 中间主视窗提供 `资源管理 / 运行` 切换。`code-prototype` 任务完成前运行入口保持视觉不可用,但仍可点击查看“当前无可运行版本”,不能使用会阻断说明交互的原生 `disabled` 或 `aria-disabled`;完成后才允许进入运行表现层。切回资源管理只修改前端展示态,不伪造后端预览暂停结果。
|
||||
- 资源管理从当前 `GameCreationAppManifest` 派生项目文档、项目版本和 `assets`,并把首页已导入附件作为当前项目上传资源展示。资源按文档、版本、美术、动作、音乐音效分区;`按依赖 / 按类型` 使用各自前端排列,dependency 模式额外绘制当前 manifest 与资源投影可证明的依赖关系。排列与图层都不写回 manifest,不能推断或伪造缺失依赖。
|
||||
- 资源卡支持点击详情、搜索和类型筛选。2026-07-28 起完成两套二维坐标与本地 CAS sidecar;2026-07-31 起 dependency 模式增加不持久化的原生 SVG 关系图层。2026-08-03 mentor 决定暂缓资源卡拖动,当前卡片不挂载 Pointer Down / Move / Up / Cancel 拖动入口,只允许自动布局和点击详情。资源聚焦不提供工具栏或工具侧边栏;本阶段不继续实现文档读取、美术编辑、音频能力、项目版本或运行模块。
|
||||
- 资源管理从当前 `GameCreationAppManifest`、合法 Agent 文本回执、已导入附件和显式项目版本 read model 派生资源,固定按文档、项目版本、美术资源、音乐音效资源分区;未知任务产物不再兜底为版本,任务声明中的未登记音频也不冒充正式音频。`按依赖 / 按类型` 使用各自前端排列,dependency 模式额外绘制当前 manifest 与资源投影可证明的依赖关系。排列与图层都不写回 manifest,不能推断或伪造缺失依赖。
|
||||
- 资源卡支持点击聚焦、搜索和类型筛选。2026-07-28 起完成两套二维坐标与本地 CAS sidecar;2026-07-31 起 dependency 模式增加不持久化的原生 SVG 关系图层。2026-08-03 mentor 决定暂缓资源卡拖动,当前卡片不挂载 Pointer Down / Move / Up / Cancel 拖动入口,只允许自动布局和点击聚焦。聚焦态替换中央主视窗内容,保留左侧导航、右侧对话和底部 Agent 状态栏,退出后恢复搜索、布局模式、滚动位置与选中资源;不提供工具栏、工具侧边栏或可拖动标题栏。本阶段不新增项目文件读取、美术编辑、音频播放 / 编辑、版本替换或运行模块。
|
||||
- 运行表现层首版直接嵌入当前项目的 loopback 游戏画面,并展示上一项 / 暂停继续 / 下一项切片控制、素材信息和数值微调面板。`preview.start` 启动本地 server 后把真实 URL 回写工作台,`preview.open` 只激活客户端内运行视图,不再调用系统浏览器;切片、参数调整和自然语言新增调节项首版仍只保留本地 UI 草稿,不修改代码或 manifest。
|
||||
- 右侧继续复用现有 Project Supervisor 会话、Runtime 澄清和确认链路;输入区展示 `严格审批 / 风险审批 / 无需审批` 独立面板。P0 只有严格审批可选;风险审批和无需审批保持视觉不可用但允许点击查看原因,不替代 Runtime 的逐动作权限、确认、sandbox 或 reconciliation 门禁。风险 Rank 算法记录在 `docs/project-memory/todos/【待解决】AI游戏创作高风险审批Rank-2026-07-20.md`,前端不得自行计算。
|
||||
- 底部状态栏默认展示策划、美术、程序 3 组,并允许在同一栏展开数值、音频、发布组;状态来自 manifest 与当前 Supervisor run 的 Runtime,悬停显示当前任务与进度。累计泥点必须等待后端计费归因投影;Agent.md 编辑和自定义 Skill 在来源审核、版本、权限、sandbox 与回滚合同完备前不向普通用户开放。
|
||||
@@ -384,7 +384,7 @@ game-project/
|
||||
- 新资源只在第一次进入某个 mode 时计算默认不重叠位置;全部现存坐标保持不变。搜索、筛选、窗口 resize 和 mode 切换不得重排或回写已有坐标,窄视图通过 section 画布范围与滚动访问,不裁切持久坐标。
|
||||
- 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 能力继续独立实施。
|
||||
|
||||
历史实施顺序已完成 TypeScript / Rust DTO、Tauri sidecar/CAS、前端纯模型与持久 Hook。二维手动拖动接线现已暂缓;重新开放前必须先更新 PRD 与验收合同。任何后续步骤不得用 `localStorage`、manifest 字段或只在当前 React 会话有效的状态冒充项目持久化。
|
||||
|
||||
|
||||
Reference in New Issue
Block a user