WIP: AGC 资源画布与替换改造 V3.0 #316

Draft
suzmii wants to merge 131 commits from feat/agc-canvas-resource-workbench-v3 into master
4 changed files with 9 additions and 4709 deletions
Showing only changes of commit 05d4d6d1d3 - Show all commits
@@ -4347,7 +4347,7 @@ export function registerProjectWorkbenchFoundationTests() {
/<div className="game-workbench-chat-wallet">\{walletEntry\}<\/div>/,
);
expect(projectDevelopmentSource).toMatch(
/const showRunUnavailableHint\s*=\s*!runAvailable\s*&&\s*!focusedResource\s*&&\s*!assetCanvasRoute\s*&&\s*!resourceEditorRoute\s*&&\s*!uiEditorRoute/s,
/const showRunUnavailableHint\s*=\s*!runAvailable\s*&&\s*!focusedResource\s*&&\s*!resourceEditorRoute\s*&&\s*!uiEditorRoute/s,
);
expect(projectDevelopmentSource).toMatch(
/aria-describedby=\{\s*showRunUnavailableHint\s*\?\s*'run-unavailable-hint'\s*:\s*undefined\s*\}/s,
@@ -4357,7 +4357,7 @@ export function registerProjectWorkbenchFoundationTests() {
);
});
it('invalidates only the committed asset preview after an in-place refine', () => {
it('keeps the resource preview version cache wired and cleared on project switch', () => {
const projectDevelopmentSource = readFileSync(
resolve(
process.cwd(),
@@ -4369,9 +4369,6 @@ export function registerProjectWorkbenchFoundationTests() {
expect(projectDevelopmentSource).toMatch(
/previewVersionByResourceId:\s*resourcePreviewVersionByResourceId/,
);
expect(projectDevelopmentSource).toMatch(
/const committedResourceId = `asset:\$\{notification\.assetId\}`;[\s\S]*?next\.set\(committedResourceId, notification\.commitId\)/,
);
expect(projectDevelopmentSource).toMatch(
/setResourcePreviewVersionByResourceId\(new Map\(\)\)/,
);
File diff suppressed because it is too large Load Diff
@@ -7,142 +7,8 @@ import type { GameCreationAppManifest } from '../../../packages/shared/src/contr
const canvasFixture = vi.hoisted(() => ({
manifest: null as GameCreationAppManifest | null,
revision: 0,
sequence: 0,
recoveredDraftIds: [] as string[],
}));
vi.mock('../src/features/asset-canvas/AssetCanvasSurface', async () => {
const ReactModule = await import('react');
return {
AssetCanvasSurface: (props: {
scope: {
projectId: string;
draftId: string;
intent: 'create' | 'refine';
sourceAssetId: string | null;
};
sessionId: string;
initialAssetName?: string;
initialAssetKind?: string;
onCancel?: (result: {
draftId: string;
disposition: 'kept' | 'discarded';
}) => void;
onSaveAttempt?: (attempt: {
saveAttemptId: string;
sessionId: string;
projectId: string;
draftId: string;
commitId: string;
}) => void;
onCommitted?: (notification: Record<string, unknown>) => void;
host: {
generation: {
recoverImages: (input: {
scope: {
projectId: string;
draftId: string;
intent: 'create' | 'refine';
sourceAssetId: string | null;
};
}) => Promise<unknown>;
};
};
}) => {
ReactModule.useEffect(() => {
void props.host.generation.recoverImages({ scope: props.scope });
}, [props.host, props.scope]);
return ReactModule.createElement(
'section',
{ 'aria-label': '测试素材创作画布' },
ReactModule.createElement(
'span',
null,
`${props.scope.intent}:${props.scope.sourceAssetId ?? 'none'}:${props.initialAssetName ?? 'unset'}:${props.initialAssetKind ?? 'unset'}`,
),
ReactModule.createElement(
'button',
{
type: 'button',
onClick: () =>
props.onCancel?.({
draftId: props.scope.draftId,
disposition: 'kept',
}),
},
'取消并返回',
),
ReactModule.createElement(
'button',
{
type: 'button',
onClick: () => {
const base = canvasFixture.manifest;
if (!base) throw new Error('missing manifest fixture');
canvasFixture.sequence += 1;
canvasFixture.revision += 1;
const assetId =
props.scope.intent === 'refine' && props.scope.sourceAssetId
? props.scope.sourceAssetId
: `canvas-output-${canvasFixture.sequence}`;
const commitId = `commit-${canvasFixture.sequence}`;
const existingAsset = base.assets.find(
(asset) => asset.id === assetId,
);
const committedAsset = {
id: assetId,
kind: existingAsset?.kind ?? 'art-image',
mediaType: 'image/png',
localPath:
props.scope.intent === 'refine'
? `assets/canvas/${assetId}--${canvasFixture.sequence}.png`
: `assets/${assetId}.png`,
source:
existingAsset?.source ??
({
kind: 'canvas' as const,
taskId: null,
resourceId: `canvas-resource-${canvasFixture.sequence}`,
referenceResourceIds: [],
} as const),
};
const nextManifest = {
...base,
assets: existingAsset
? base.assets.map((asset) =>
asset.id === assetId ? committedAsset : asset,
)
: [...base.assets, committedAsset],
};
canvasFixture.manifest = nextManifest;
props.onSaveAttempt?.({
saveAttemptId: `save-${canvasFixture.sequence}`,
sessionId: props.sessionId,
projectId: props.scope.projectId,
draftId: props.scope.draftId,
commitId,
});
props.onCommitted?.({
source: 'command',
projectPath: '/tmp/live-canvas-integration',
projectId: props.scope.projectId,
draftId: props.scope.draftId,
commitId,
assetId,
manifest: nextManifest,
projectRevision: canvasFixture.revision,
committedProjectRevision: canvasFixture.revision,
eventId: `event-${canvasFixture.sequence}`,
});
},
},
'完成测试保存',
),
);
},
};
});
import ProjectDevelopmentView from '../src/view/project-development';
import {
act,
@@ -343,43 +209,11 @@ function SwitchableDerivedWorkbench({
);
}
function TaskImageWorkbench() {
const initial = createGameCreationAppManifest(
'live-canvas-project',
'实时画布项目',
);
const task = initial.tasks.find(
(candidate) => candidate.id === 'art-asset-plan',
);
if (!task) throw new Error('missing art task fixture');
task.status = 'completed';
task.artifacts = ['assets/task-hero.png'];
const [manifest, setManifest] = useState(initial);
canvasFixture.manifest = manifest;
return (
<ProjectDevelopmentView
projectName={manifest.name}
projectPath={projectPath}
manifest={manifest}
attachments={[]}
recentRunStatus={null}
recentRunStopReason={null}
supervisor={<div>Supervisor</div>}
onHomeOpen={() => undefined}
onProjectsOpen={() => undefined}
onManifestChange={(_path, nextManifest) => setManifest(nextManifest)}
/>
);
}
describe('project resource live canvas integration', () => {
afterEach(() => {
delete window.__TAURI__;
canvasFixture.manifest = null;
canvasFixture.revision = 0;
canvasFixture.sequence = 0;
canvasFixture.recoveredDraftIds = [];
});
function installTauri(
@@ -406,14 +240,12 @@ describe('project resource live canvas integration', () => {
const layoutWrites: Array<Record<string, unknown>> = [];
const graphReads: Array<Record<string, unknown>> = [];
const deriveCalls: Array<Record<string, unknown>> = [];
const normalizeCalls: Array<Record<string, unknown>> = [];
const resumeCalls: Array<Record<string, unknown>> = [];
const serviceIdentityRequestCalls: Array<Record<string, unknown>> = [];
const serviceIdentityConfirmCalls: Array<Record<string, unknown>> = [];
const archiveCalls: Array<Record<string, unknown>> = [];
const pendingReadCalls: Array<Record<string, unknown>> = [];
let pendingReadCount = 0;
let discoverableDraftId: string | null = null;
let pendingEdits =
options.pendingResourceEdits ??
(options.pendingResourceEdit
@@ -430,20 +262,6 @@ describe('project resource live canvas integration', () => {
: []);
const invoke = vi.fn(
async (command: string, args?: Record<string, unknown>) => {
if (command === 'discover_local_project_asset_canvas_draft') {
const input = (args?.input ?? {}) as Record<string, unknown>;
return discoverableDraftId
? {
status: 'found',
draft: {
draftId: discoverableDraftId,
projectId: String(input.expectedProjectId),
intent: 'refine',
sourceAssetId: String(input.sourceAssetId),
},
}
: { status: 'not-found', draft: null };
}
if (command === 'get_local_game_project_revision') {
return { revision: canvasFixture.revision };
}
@@ -494,17 +312,6 @@ describe('project resource live canvas integration', () => {
dataUrl: 'data:image/png;base64,AA==',
};
}
if (command === 'recover_local_project_asset_canvas_generations') {
const input = structuredClone(
(args?.input ?? {}) as Record<string, unknown>,
);
discoverableDraftId = String(input.draftId);
canvasFixture.recoveredDraftIds.push(String(input.draftId));
return {
resumedGenerationIds: [`generation-for-${String(input.draftId)}`],
serviceIdentityConfirmations: [],
};
}
if (command === 'read_local_project_text_preview') {
return {
path: String(args?.relativePath ?? ''),
@@ -646,33 +453,6 @@ describe('project resource live canvas integration', () => {
manifest: nextManifest,
};
}
if (command === 'normalize_local_project_raster_resource') {
const input = structuredClone(
(args?.input ?? {}) as Record<string, unknown>,
);
normalizeCalls.push(input);
const base = canvasFixture.manifest;
if (!base) throw new Error('missing manifest fixture');
const asset = {
id: 'normalized-task-hero',
kind: 'art-image',
mediaType: 'image/png',
localPath: String(input.sourcePath),
source: {
kind: 'generated' as const,
taskId: String(input.producerTaskId),
resourceId: String(input.sourceResourceId),
},
};
canvasFixture.revision += 1;
const nextManifest = { ...base, assets: [...base.assets, asset] };
canvasFixture.manifest = nextManifest;
return {
committedProjectRevision: canvasFixture.revision,
asset,
manifest: nextManifest,
};
}
throw new Error(`unexpected invoke ${command}`);
},
);
@@ -684,7 +464,6 @@ describe('project resource live canvas integration', () => {
deriveCalls,
graphReads,
layoutWrites,
normalizeCalls,
pendingReadCalls,
resumeCalls,
archiveCalls,
@@ -693,147 +472,6 @@ describe('project resource live canvas integration', () => {
};
}
it('enters refine in the central view, keeps the draft on return, and preserves the source after a durable edit', async () => {
const { graphReads, layoutWrites } = installTauri();
render(<LiveWorkbench />);
const sourceCard = await screen.findByRole('button', {
name: /source-art\.png/,
});
for (const label of [
'生成视频',
'生成音效',
'生成背景音乐',
'新增 UI 设计',
]) {
expect(screen.queryByRole('button', { name: label })).toBeNull();
}
fireEvent.click(sourceCard);
const sourceDetails = screen.getByRole('dialog', {
name: /source-art\.png/,
});
expect(sourceDetails.closest('.game-workbench-stage')).toBe(
screen.getByLabelText('项目主视窗'),
);
expect(sourceDetails.hasAttribute('aria-modal')).toBe(false);
expect(document.querySelector('.game-resource-focus-backdrop')).toBeNull();
for (const label of [
'生成视频',
'生成音效',
'生成背景音乐',
'新增 UI 设计',
]) {
expect(screen.queryByRole('button', { name: label })).toBeNull();
}
expect(
screen
.getByRole('button', { name: '按依赖' })
.getAttribute('aria-pressed'),
).toBe('true');
expect(screen.getByRole('button', { name: '按类型' })).not.toBeNull();
expect(screen.getByRole('button', { name: '复位资源视图' })).not.toBeNull();
expect(screen.getByLabelText('资源依赖视图').hasAttribute('inert')).toBe(
false,
);
fireEvent.click(screen.getByRole('button', { name: '编辑资源' }));
expect(
(await screen.findByLabelText('测试素材创作画布')).textContent,
).toContain('refine:source-art:source-art:art-image');
fireEvent.click(screen.getByRole('button', { name: '取消并返回' }));
expect(
await screen.findByRole('dialog', { name: /source-art\.png/ }),
).not.toBeNull();
fireEvent.click(screen.getByRole('button', { name: '编辑资源' }));
fireEvent.click(
await screen.findByRole('button', { name: '完成测试保存' }),
);
expect(await screen.findByLabelText('测试素材创作画布')).not.toBeNull();
expect(screen.queryByRole('dialog', { name: /canvas-output/ })).toBeNull();
fireEvent.click(screen.getByRole('button', { name: '取消并返回' }));
const updatedSource = await screen.findByRole('button', {
name: /source-art--1.png/,
});
fireEvent.click(updatedSource);
expect(
await screen.findByRole('dialog', { name: /source-art--1.png/ }),
).not.toBeNull();
fireEvent.click(screen.getByRole('button', { name: '收起资源' }));
expect(
layoutWrites.every(
(write) =>
!(write.positions as Array<{ resourceId: string }>).some(
(position) => position.resourceId === 'asset:canvas-output-1',
),
),
).toBe(true);
expect(
graphReads.every((read) => {
const resourceIds = (
read.resources as Array<{ resourceId: string }>
).map((resource) => resource.resourceId);
return (
resourceIds.includes('asset:source-art') &&
!resourceIds.includes('asset:canvas-output-1')
);
}),
).toBe(true);
});
it('reuses a kept refine draft and recovers its generation after a real workbench remount', async () => {
installTauri();
const firstWorkbench = render(<LiveWorkbench />);
fireEvent.click(
await screen.findByRole('button', { name: /source-art\.png/ }),
);
fireEvent.click(screen.getByRole('button', { name: '编辑资源' }));
await waitFor(() => {
expect(canvasFixture.recoveredDraftIds).toHaveLength(1);
});
const keptDraftId = canvasFixture.recoveredDraftIds[0];
fireEvent.click(screen.getByRole('button', { name: '取消并返回' }));
firstWorkbench.unmount();
render(<LiveWorkbench />);
fireEvent.click(
await screen.findByRole('button', { name: /source-art\.png/ }),
);
fireEvent.click(screen.getByRole('button', { name: '编辑资源' }));
await waitFor(() => {
expect(canvasFixture.recoveredDraftIds).toEqual([
keptDraftId,
keptDraftId,
]);
});
});
it('keeps an existing search while replacing the same final-image resource', async () => {
installTauri();
render(<LiveWorkbench />);
const search = await screen.findByLabelText('搜索项目资源');
fireEvent.change(search, { target: { value: 'source-art' } });
fireEvent.click(
await screen.findByRole('button', { name: /source-art\.png/ }),
);
fireEvent.click(screen.getByRole('button', { name: '编辑资源' }));
fireEvent.click(
await screen.findByRole('button', { name: '完成测试保存' }),
);
expect(await screen.findByLabelText('测试素材创作画布')).not.toBeNull();
fireEvent.click(screen.getByRole('button', { name: '取消并返回' }));
const restoredSearch = (await screen.findByLabelText(
'搜索项目资源',
)) as HTMLInputElement;
expect(restoredSearch.value).toBe('source-art');
expect(
await screen.findByRole('button', { name: /source-art--1.png/ }),
).not.toBeNull();
expect(screen.queryByText('新资源已保存,但被当前搜索条件隐藏')).toBeNull();
});
it('derives a document non-destructively and retries with the original operation identity', async () => {
const { deriveCalls } = installTauri({ failFirstDerive: true });
render(<DerivedWorkbench />);
@@ -869,22 +507,6 @@ describe('project resource live canvas integration', () => {
).not.toBeNull();
});
it('normalizes a completed task image before opening the existing refine canvas', async () => {
const { normalizeCalls } = installTauri();
render(<TaskImageWorkbench />);
fireEvent.click(
await screen.findByRole('button', { name: /task-hero\.png/ }),
);
fireEvent.click(screen.getByRole('button', { name: '编辑资源' }));
expect(
(await screen.findByLabelText('测试素材创作画布')).textContent,
).toContain('refine:normalized-task-hero');
expect(normalizeCalls).toHaveLength(1);
expect(normalizeCalls[0]?.sourcePath).toBe('assets/task-hero.png');
expect(normalizeCalls[0]?.producerTaskId).toBe('art-asset-plan');
});
it('lists an unfinished edit and resumes it using only the private-ledger operation id', async () => {
const { resumeCalls } = installTauri({ pendingResourceEdit: true });
render(<DerivedWorkbench />);
@@ -29,12 +29,11 @@ function resource(
}
describe('全类型资源编辑能力模型', () => {
it('把现役资源稳定分流到图片画布、媒体派生、文本派生和版本分支', () => {
it('把现役资源稳定分流到图片派生、媒体派生、文本派生和版本分支', () => {
const cases: Array<{
resource: ProjectResource;
route: 'image-canvas' | 'derive';
route: 'derive';
editKind?: string;
normalizeSource?: boolean;
}> = [
{
resource: resource({
@@ -44,8 +43,8 @@ describe('全类型资源编辑能力模型', () => {
mediaType: 'image/png',
manifestAssetId: 'png',
}),
route: 'image-canvas',
normalizeSource: false,
route: 'derive',
editKind: 'image-reference',
},
{
resource: resource({
@@ -54,8 +53,8 @@ describe('全类型资源编辑能力模型', () => {
path: 'hero.webp',
mediaType: '美术产物',
}),
route: 'image-canvas',
normalizeSource: true,
route: 'derive',
editKind: 'image-reference',
},
{
resource: resource({
@@ -163,15 +162,7 @@ describe('全类型资源编辑能力模型', () => {
expected.resource,
);
expect(capability.route, expected.resource.id).toBe(expected.route);
if (capability.route === 'derive') {
expect(capability.editKind, expected.resource.id).toBe(
expected.editKind,
);
} else {
expect(capability.normalizeSource, expected.resource.id).toBe(
expected.normalizeSource,
);
}
expect(capability.editKind, expected.resource.id).toBe(expected.editKind);
}
});