实现资源画布布局持久化
Project CI / Backend tests (pull_request) Successful in 3m58s
Project CI / Frontend tests (pull_request) Successful in 4m4s
Project CI / Native shell tests (pull_request) Successful in 12m26s
Project CI / Repository checks (pull_request) Failing after 40s

冻结资源画布布局数据与 CAS 合同
实现双模式本地 sidecar 安全读写
接入二维拖动、默认排版和跨重启恢复
补齐并发冲突、安全边界和界面测试
同步技术文档与共享决策
This commit is contained in:
2026-07-28 14:54:01 +08:00
parent d29240b954
commit 4c503b7fc3
17 changed files with 2043 additions and 308 deletions
@@ -196,6 +196,29 @@ pub(crate) fn get_local_game_manifest(
read_manifest_for_project(root)
}
#[tauri::command]
pub(crate) fn read_local_project_resource_canvas_layout(
project_path: String,
mode: ProjectResourceCanvasLayoutMode,
) -> Result<ProjectResourceCanvasLayout, String> {
read_project_resource_canvas_layout_at(Path::new(project_path.trim()), mode)
}
#[tauri::command]
pub(crate) fn update_local_project_resource_canvas_layout(
project_path: String,
mode: ProjectResourceCanvasLayoutMode,
expected_revision: u64,
positions: Vec<ProjectResourceCanvasPosition>,
) -> Result<UpdateProjectResourceCanvasLayoutResult, String> {
update_project_resource_canvas_layout_at(
Path::new(project_path.trim()),
mode,
expected_revision,
positions,
)
}
#[tauri::command]
pub(crate) async fn control_agent_run(
app: tauri::AppHandle,
@@ -30,9 +30,11 @@ use shared_contracts::game_creation_app::{
GameCreationAppCommandRunStatus, GameCreationAppLimitedRunCommandDescriptor,
GameCreationAppManifest, GameCreationAppPermission, GameCreationAppPreviewState,
GameCreationAppPreviewStatus, GameCreationAppTaskState, GameCreationAppTaskStatus,
ProjectResourceCanvasLayout, ProjectResourceCanvasLayoutMode, ProjectResourceCanvasPosition,
UpdateProjectResourceCanvasLayoutResult, UpdateProjectResourceCanvasLayoutStatus,
GAME_CREATION_AGENT_CAPABILITIES, GAME_CREATION_AGENT_RUN_SCHEMA_VERSION,
GAME_CREATION_AGENT_TOOL_CALL_MAX, GAME_CREATION_APP_COMMANDS,
GAME_CREATION_APP_LIMITED_RUN_COMMANDS,
GAME_CREATION_APP_LIMITED_RUN_COMMANDS, GAME_CREATION_RESOURCE_LAYOUT_SCHEMA_VERSION,
};
use tauri::{Emitter, Manager};
use tauri_plugin_dialog::DialogExt;
@@ -1784,6 +1786,8 @@ fn main() {
activate_local_game_preview,
stop_local_game_preview,
get_local_game_preview_status,
read_local_project_resource_canvas_layout,
update_local_project_resource_canvas_layout,
get_local_game_manifest
])
.run(tauri::generate_context!())
@@ -10,6 +10,7 @@ mod export;
mod filesystem;
mod manifest;
mod memory;
mod resource_layout;
mod verification;
pub(crate) use agent_db::*;
@@ -19,4 +20,5 @@ pub(crate) use export::*;
pub(crate) use filesystem::*;
pub(crate) use manifest::*;
pub(crate) use memory::*;
pub(crate) use resource_layout::*;
pub(crate) use verification::*;
@@ -159,6 +159,7 @@ pub(crate) fn list_local_project_files_at(
let relative_path = relative_project_path(root, &path)?;
if is_agent_runtime_private_control_path(&relative_path)
|| is_agent_checkpoint_control_path(&relative_path)
|| is_agent_workbench_control_path(&relative_path)
{
continue;
}
@@ -236,6 +237,12 @@ fn is_agent_checkpoint_control_path(normalized_path: &str) -> bool {
&& matches!(parts.next(), Some(part) if part.eq_ignore_ascii_case("checkpoints"))
}
fn is_agent_workbench_control_path(normalized_path: &str) -> bool {
let mut parts = normalized_path.split('/');
matches!(parts.next(), Some(part) if part.eq_ignore_ascii_case(".agent"))
&& matches!(parts.next(), Some(part) if part.eq_ignore_ascii_case("workbench"))
}
pub(crate) fn reject_agent_runtime_private_control_path(
normalized_path: &str,
) -> Result<(), String> {
@@ -245,6 +252,9 @@ pub(crate) fn reject_agent_runtime_private_control_path(
if is_agent_checkpoint_control_path(normalized_path) {
return Err("Agent checkpoint 控制面不可通过通用文件工具访问".to_string());
}
if is_agent_workbench_control_path(normalized_path) {
return Err("Agent workbench 控制面不可通过通用文件工具访问".to_string());
}
Ok(())
}
File diff suppressed because it is too large Load Diff
+17 -30
View File
@@ -3443,19 +3443,16 @@ iframe.preview-frame {
font-size: 11px;
}
.game-resource-row {
display: flex;
align-items: flex-start;
gap: 12px;
.game-resource-plane {
position: relative;
min-width: 620px;
min-height: 108px;
}
.game-resource-canvas--dependency .game-resource-card {
position: relative;
margin-left: calc(var(--resource-depth, 0) * 12px);
}
.game-resource-card {
position: absolute;
top: 0;
left: 0;
display: grid;
grid-template-columns: 36px minmax(100px, 1fr);
grid-template-rows: auto auto auto;
@@ -3472,7 +3469,11 @@ iframe.preview-frame {
color: #4e382f;
text-align: left;
box-shadow: 0 6px 18px rgb(96 62 47 / 6%);
cursor: pointer;
cursor: grab;
touch-action: none;
user-select: none;
transform: translate3d(var(--resource-x, 0), var(--resource-y, 0), 0);
will-change: transform;
}
.game-resource-card:hover,
@@ -3484,20 +3485,12 @@ iframe.preview-frame {
}
.game-resource-card.is-dragging {
opacity: 0.42;
z-index: 2;
opacity: 0.72;
cursor: grabbing;
}
.game-resource-card.is-drop-before {
box-shadow:
-5px 0 0 -2px #cf7047,
0 8px 22px rgb(195 105 62 / 15%);
}
.game-resource-card.is-drop-after {
box-shadow:
5px 0 0 -2px #cf7047,
0 8px 22px rgb(195 105 62 / 15%);
0 12px 28px rgb(195 105 62 / 24%),
0 0 0 2px rgb(213 123 81 / 18%);
}
.game-resource-card-icon {
@@ -3655,12 +3648,10 @@ iframe.preview-frame {
overflow: hidden;
border: 1px solid #ead8cf;
border-radius: 12px;
background:
linear-gradient(45deg, #f1ebe7 25%, transparent 25%),
background: linear-gradient(45deg, #f1ebe7 25%, transparent 25%),
linear-gradient(-45deg, #f1ebe7 25%, transparent 25%),
linear-gradient(45deg, transparent 75%, #f1ebe7 75%),
linear-gradient(-45deg, transparent 75%, #f1ebe7 75%),
#faf7f5;
linear-gradient(-45deg, transparent 75%, #f1ebe7 75%), #faf7f5;
background-position:
0 0,
0 8px,
@@ -4440,10 +4431,6 @@ iframe.preview-frame {
min-width: 460px;
}
.game-resource-row {
flex-wrap: wrap;
}
.game-run-slice-controls {
grid-template-columns: repeat(3, minmax(0, 1fr));
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,241 @@
import {
GAME_CREATION_RESOURCE_LAYOUT_SCHEMA_VERSION,
type ProjectResourceCanvasLayout,
type ProjectResourceCanvasLayoutMode,
type ProjectResourceCanvasPosition,
type ProjectResourceCanvasSection,
} from '../../../../../packages/shared/src/contracts/gameCreationApp';
export const RESOURCE_CANVAS_CARD_WIDTH = 180;
export const RESOURCE_CANVAS_CARD_HEIGHT = 92;
export const RESOURCE_CANVAS_COLUMN_GAP = 16;
export const RESOURCE_CANVAS_ROW_GAP = 16;
export const RESOURCE_CANVAS_DRAG_THRESHOLD = 5;
export const RESOURCE_CANVAS_TYPE_COLUMNS = 3;
export const RESOURCE_CANVAS_SECTION_MIN_WIDTH = 620;
export const RESOURCE_CANVAS_SECTION_MIN_HEIGHT = 108;
const sectionOrder: ProjectResourceCanvasSection[] = [
'document',
'version',
'art',
'audio',
];
export type ResourceCanvasItem = {
id: string;
category: ProjectResourceCanvasSection;
label: string;
mediaType: string;
dependencyDepth: number;
};
export type ReconciledResourceCanvasLayout = {
layout: ProjectResourceCanvasLayout;
changed: boolean;
};
export function createEmptyResourceCanvasLayout(
projectId: string,
mode: ProjectResourceCanvasLayoutMode,
): ProjectResourceCanvasLayout {
return {
schemaVersion: GAME_CREATION_RESOURCE_LAYOUT_SCHEMA_VERSION,
projectId,
mode,
revision: 0,
positions: [],
updatedAt: 0,
};
}
function positionsEqual(
left: ProjectResourceCanvasPosition[],
right: ProjectResourceCanvasPosition[],
) {
return (
left.length === right.length &&
left.every((position, index) => {
const other = right[index];
return (
other !== undefined &&
position.resourceId === other.resourceId &&
position.section === other.section &&
position.x === other.x &&
position.y === other.y &&
position.manuallyPlaced === other.manuallyPlaced
);
})
);
}
function positionOverlaps(
x: number,
y: number,
positions: ProjectResourceCanvasPosition[],
) {
return positions.some(
(position) =>
x <
position.x + RESOURCE_CANVAS_CARD_WIDTH + RESOURCE_CANVAS_COLUMN_GAP &&
x + RESOURCE_CANVAS_CARD_WIDTH + RESOURCE_CANVAS_COLUMN_GAP >
position.x &&
y < position.y + RESOURCE_CANVAS_CARD_HEIGHT + RESOURCE_CANVAS_ROW_GAP &&
y + RESOURCE_CANVAS_CARD_HEIGHT + RESOURCE_CANVAS_ROW_GAP > position.y,
);
}
function defaultDependencyPosition(
resource: ResourceCanvasItem,
positions: ProjectResourceCanvasPosition[],
) {
const x =
Math.max(0, Math.round(resource.dependencyDepth)) *
(RESOURCE_CANVAS_CARD_WIDTH + RESOURCE_CANVAS_COLUMN_GAP);
let y = 0;
while (positionOverlaps(x, y, positions)) {
y += RESOURCE_CANVAS_CARD_HEIGHT + RESOURCE_CANVAS_ROW_GAP;
}
return { x, y };
}
function defaultTypePosition(positions: ProjectResourceCanvasPosition[]) {
let slot = 0;
for (;;) {
const column = slot % RESOURCE_CANVAS_TYPE_COLUMNS;
const row = Math.floor(slot / RESOURCE_CANVAS_TYPE_COLUMNS);
const x =
column * (RESOURCE_CANVAS_CARD_WIDTH + RESOURCE_CANVAS_COLUMN_GAP);
const y = row * (RESOURCE_CANVAS_CARD_HEIGHT + RESOURCE_CANVAS_ROW_GAP);
if (!positionOverlaps(x, y, positions)) {
return { x, y };
}
slot += 1;
}
}
function compareResources(
mode: ProjectResourceCanvasLayoutMode,
left: ResourceCanvasItem,
right: ResourceCanvasItem,
) {
if (mode === 'dependency') {
return (
left.dependencyDepth - right.dependencyDepth ||
left.label.localeCompare(right.label, 'zh-CN') ||
left.id.localeCompare(right.id)
);
}
return (
left.mediaType.localeCompare(right.mediaType) ||
left.label.localeCompare(right.label, 'zh-CN') ||
left.id.localeCompare(right.id)
);
}
export function reconcileResourceCanvasLayout(
source: ProjectResourceCanvasLayout,
resources: ResourceCanvasItem[],
): ReconciledResourceCanvasLayout {
const resourceById = new Map(
resources.map((resource) => [resource.id, resource]),
);
const preserved = source.positions.filter((position) => {
const resource = resourceById.get(position.resourceId);
return resource?.category === position.section;
});
const preservedIds = new Set(
preserved.map((position) => position.resourceId),
);
const next = [...preserved];
for (const section of sectionOrder) {
const sectionPositions = next.filter(
(position) => position.section === section,
);
const newResources = resources
.filter(
(resource) =>
resource.category === section && !preservedIds.has(resource.id),
)
.sort((left, right) => compareResources(source.mode, left, right));
for (const resource of newResources) {
const point =
source.mode === 'dependency'
? defaultDependencyPosition(resource, sectionPositions)
: defaultTypePosition(sectionPositions);
const position: ProjectResourceCanvasPosition = {
resourceId: resource.id,
section,
x: point.x,
y: point.y,
manuallyPlaced: false,
};
sectionPositions.push(position);
next.push(position);
}
}
const ordered = sectionOrder.flatMap((section) =>
next
.filter((position) => position.section === section)
.sort(
(left, right) =>
left.y - right.y ||
left.x - right.x ||
left.resourceId.localeCompare(right.resourceId),
),
);
return {
layout: {
...source,
positions: ordered,
},
changed: !positionsEqual(source.positions, ordered),
};
}
export function moveResourceCanvasPosition(
layout: ProjectResourceCanvasLayout,
resourceId: string,
section: ProjectResourceCanvasSection,
x: number,
y: number,
): ProjectResourceCanvasLayout {
const normalizedX = Math.max(0, Math.round(x));
const normalizedY = Math.max(0, Math.round(y));
return {
...layout,
positions: layout.positions.map((position) =>
position.resourceId === resourceId && position.section === section
? {
...position,
x: normalizedX,
y: normalizedY,
manuallyPlaced: true,
}
: position,
),
};
}
export function resourceCanvasSectionExtent(
positions: ProjectResourceCanvasPosition[],
) {
return {
width: Math.max(
RESOURCE_CANVAS_SECTION_MIN_WIDTH,
...positions.map(
(position) =>
position.x + RESOURCE_CANVAS_CARD_WIDTH + RESOURCE_CANVAS_COLUMN_GAP,
),
),
height: Math.max(
RESOURCE_CANVAS_SECTION_MIN_HEIGHT,
...positions.map(
(position) =>
position.y + RESOURCE_CANVAS_CARD_HEIGHT + RESOURCE_CANVAS_ROW_GAP,
),
),
};
}
@@ -0,0 +1,218 @@
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import type {
ProjectResourceCanvasLayout,
ProjectResourceCanvasLayoutMode,
ProjectResourceCanvasSection,
UpdateProjectResourceCanvasLayoutResult,
} from '../../../../../packages/shared/src/contracts/gameCreationApp';
import {
createEmptyResourceCanvasLayout,
moveResourceCanvasPosition,
reconcileResourceCanvasLayout,
type ResourceCanvasItem,
} from './resourceCanvasLayoutModel';
type LayoutNotice =
| ''
| '布局已保存'
| '布局读取失败,已使用当前会话布局'
| '布局保存失败,已保留当前会话布局'
| '布局保存失败,已恢复上次布局'
| '布局已在其他窗口更新,请重新拖动';
export function useProjectResourceCanvasLayout({
projectPath,
projectId,
mode,
resources,
}: {
projectPath: string;
projectId: string;
mode: ProjectResourceCanvasLayoutMode;
resources: ResourceCanvasItem[];
}) {
const resourceSignature = useMemo(
() =>
resources
.map(
(resource) =>
`${resource.id}\u0000${resource.category}\u0000${resource.dependencyDepth}\u0000${resource.mediaType}`,
)
.sort()
.join('\u0001'),
[resources],
);
const fallback = useMemo(
() =>
reconcileResourceCanvasLayout(
createEmptyResourceCanvasLayout(projectId, mode),
resources,
).layout,
[mode, projectId, resourceSignature, resources],
);
const [layout, setLayout] = useState<ProjectResourceCanvasLayout>(fallback);
const [notice, setNotice] = useState<LayoutNotice>('');
const [saving, setSaving] = useState(false);
const persistedLayoutRef = useRef<ProjectResourceCanvasLayout>(fallback);
const requestTokenRef = useRef(0);
const scopeMatches = layout.projectId === projectId && layout.mode === mode;
const currentLayout = scopeMatches ? layout : fallback;
const persistLayout = useCallback(
async (
candidate: ProjectResourceCanvasLayout,
rollback: ProjectResourceCanvasLayout,
token: number,
failureNotice: Extract<
LayoutNotice,
'布局保存失败,已保留当前会话布局' | '布局保存失败,已恢复上次布局'
>,
) => {
const invoke = window.__TAURI__?.core?.invoke;
if (!invoke) {
if (requestTokenRef.current === token) {
setLayout(candidate);
}
return;
}
setSaving(true);
try {
const result = await invoke<UpdateProjectResourceCanvasLayoutResult>(
'update_local_project_resource_canvas_layout',
{
projectPath,
mode,
expectedRevision: candidate.revision,
positions: candidate.positions,
},
);
if (requestTokenRef.current !== token) {
return;
}
persistedLayoutRef.current = result.layout;
if (result.status === 'updated') {
setLayout(result.layout);
setNotice('布局已保存');
} else {
setLayout(
reconcileResourceCanvasLayout(result.layout, resources).layout,
);
setNotice('布局已在其他窗口更新,请重新拖动');
}
} catch {
if (requestTokenRef.current !== token) {
return;
}
setLayout(rollback);
setNotice(failureNotice);
} finally {
if (requestTokenRef.current === token) {
setSaving(false);
}
}
},
[mode, projectPath, resources],
);
useEffect(() => {
const token = requestTokenRef.current + 1;
requestTokenRef.current = token;
setNotice('');
setSaving(false);
const invoke = window.__TAURI__?.core?.invoke;
if (!invoke) {
persistedLayoutRef.current = fallback;
setLayout(fallback);
return undefined;
}
let cancelled = false;
void invoke<ProjectResourceCanvasLayout>(
'read_local_project_resource_canvas_layout',
{ projectPath, mode },
)
.then((loaded) => {
if (cancelled || requestTokenRef.current !== token) {
return;
}
const reconciled = reconcileResourceCanvasLayout(loaded, resources);
persistedLayoutRef.current = loaded;
setLayout(reconciled.layout);
if (reconciled.changed) {
void persistLayout(
reconciled.layout,
reconciled.layout,
token,
'布局保存失败,已保留当前会话布局',
);
}
})
.catch(() => {
if (cancelled || requestTokenRef.current !== token) {
return;
}
persistedLayoutRef.current = fallback;
setLayout(fallback);
setNotice('布局读取失败,已使用当前会话布局');
});
return () => {
cancelled = true;
};
}, [
fallback,
mode,
persistLayout,
projectPath,
resourceSignature,
resources,
]);
useEffect(() => {
if (!notice) {
return undefined;
}
const timeout = window.setTimeout(() => setNotice(''), 2400);
return () => window.clearTimeout(timeout);
}, [notice]);
const commitPosition = useCallback(
(
resourceId: string,
section: ProjectResourceCanvasSection,
x: number,
y: number,
) => {
if (saving) {
return;
}
const rollback = reconcileResourceCanvasLayout(
persistedLayoutRef.current,
resources,
).layout;
const candidate = moveResourceCanvasPosition(
currentLayout,
resourceId,
section,
x,
y,
);
setLayout(candidate);
void persistLayout(
candidate,
rollback,
requestTokenRef.current,
'布局保存失败,已恢复上次布局',
);
},
[currentLayout, persistLayout, resources, saving],
);
return {
layout: currentLayout,
notice,
saving,
commitPosition,
};
}
@@ -25,6 +25,22 @@ const originalRevokeObjectUrl = Object.getOwnPropertyDescriptor(
URL,
'revokeObjectURL',
);
const originalPointerEvent = Object.getOwnPropertyDescriptor(
window,
'PointerEvent',
);
class TestPointerEvent extends MouseEvent {
readonly pointerId: number;
constructor(
type: string,
init: MouseEventInit & { pointerId?: number } = {},
) {
super(type, init);
this.pointerId = init.pointerId ?? 0;
}
}
function createMemoryStorage(): Storage {
const values = new Map<string, string>();
@@ -659,6 +675,10 @@ async function openMainProject(projectPath: string) {
}
beforeEach(() => {
Object.defineProperty(window, 'PointerEvent', {
configurable: true,
value: TestPointerEvent,
});
Object.defineProperties(URL, {
createObjectURL: {
configurable: true,
@@ -690,6 +710,11 @@ afterEach(() => {
} else {
Reflect.deleteProperty(URL, 'revokeObjectURL');
}
if (originalPointerEvent) {
Object.defineProperty(window, 'PointerEvent', originalPointerEvent);
} else {
Reflect.deleteProperty(window, 'PointerEvent');
}
});
export {
act,
@@ -27,6 +27,10 @@ import {
waitFor,
within,
} from './harness';
import type {
ProjectResourceCanvasLayout,
ProjectResourceCanvasPosition,
} from '../../../../packages/shared/src/contracts/gameCreationApp';
export function registerProjectWorkbenchFoundationTests() {
it('renders the first project workbench slice with honest disabled run and local approval UI', () => {
@@ -131,7 +135,7 @@ export function registerProjectWorkbenchFoundationTests() {
expect(screen.getByRole('article', { name: /发布 Agent/ })).not.toBeNull();
});
it('keeps project chrome out, reorders resources by drag, and contains text receipts in a dialog', () => {
it('keeps project chrome out, moves resources by pointer, and contains text receipts in a dialog', () => {
const manifest = createGameCreationAppManifest(
'workbench-resource-drag',
'不应显示的项目标题',
@@ -190,25 +194,33 @@ export function registerProjectWorkbenchFoundationTests() {
screen.getByText('美术资源计划已完成,尚未生成或登记图片'),
).not.toBeNull();
const transferData = new Map<string, string>();
const dataTransfer = {
effectAllowed: 'none',
dropEffect: 'none',
setData: (type: string, value: string) => transferData.set(type, value),
getData: (type: string) => transferData.get(type) ?? '',
};
let resourceCards = screen.getAllByTitle('拖动可调整当前会话中的排列');
expect(resourceCards[0]?.textContent).toContain(
'美术资源计划 Agent 文本回执',
const resourceCards = screen.getAllByTitle('拖动调整资源位置');
const artReceiptCard = resourceCards.find((card) =>
card.textContent?.includes('美术资源计划 Agent 文本回执'),
);
expect(artReceiptCard).not.toBeUndefined();
fireEvent.pointerDown(artReceiptCard!, {
pointerId: 7,
button: 0,
clientX: 0,
clientY: 0,
});
fireEvent.pointerMove(artReceiptCard!, {
pointerId: 7,
clientX: 240,
clientY: 32,
});
fireEvent.pointerUp(artReceiptCard!, {
pointerId: 7,
clientX: 240,
clientY: 32,
});
expect(artReceiptCard?.getAttribute('style')).toContain(
'--resource-x: 240px',
);
fireEvent.dragStart(resourceCards[0]!, { dataTransfer });
fireEvent.dragOver(resourceCards[1]!, { dataTransfer, clientX: 10 });
fireEvent.drop(resourceCards[1]!, { dataTransfer, clientX: 10 });
resourceCards = screen.getAllByTitle('拖动可调整当前会话中的排列');
expect(resourceCards[0]?.textContent).toContain('玩法策划 Agent 文本回执');
expect(screen.getByText('已调整当前会话排列')).not.toBeNull();
fireEvent.click(resourceCards[1]!);
fireEvent.click(artReceiptCard!);
fireEvent.click(artReceiptCard!);
const receiptDialog = screen.getByRole('dialog', {
name: '美术资源计划 Agent 文本回执',
});
@@ -227,6 +239,279 @@ export function registerProjectWorkbenchFoundationTests() {
expect(receiptDialog.querySelector('strong')).not.toBeNull();
});
it('persists a resource position with CAS and restores it after remount', async () => {
const projectId = 'workbench-layout-persistence';
const projectPath = '/tmp/workbench-layout-persistence';
const resourceId = 'agent-result:design-foundation:layout-result-run';
const manifest = createGameCreationAppManifest(projectId, '布局持久化测试');
const agentResults = [
{
agentId: 'design-foundation',
runId: 'layout-result-run',
label: '玩法策划 Agent',
title: '布局持久化回执',
content: '布局持久化正文',
updatedAt: 1,
},
];
let persistedLayout: ProjectResourceCanvasLayout = {
schemaVersion: 'game-creator-resource-layout.v1',
projectId,
mode: 'dependency',
revision: 1,
positions: [
{
resourceId,
section: 'document',
x: 12,
y: 24,
manuallyPlaced: true,
},
],
updatedAt: 100,
};
const invoke = vi.fn(
async (command: string, args?: Record<string, unknown>) => {
if (command === 'read_local_project_resource_canvas_layout') {
return structuredClone(persistedLayout);
}
if (command === 'update_local_project_resource_canvas_layout') {
const input = args as {
expectedRevision: number;
positions: ProjectResourceCanvasPosition[];
};
expect(input.expectedRevision).toBe(persistedLayout.revision);
persistedLayout = {
...persistedLayout,
revision: persistedLayout.revision + 1,
positions: structuredClone(input.positions),
updatedAt: persistedLayout.updatedAt + 1,
};
return {
status: 'updated',
layout: structuredClone(persistedLayout),
};
}
throw new Error(`unexpected invoke ${command}`);
},
);
window.__TAURI__ = { core: { invoke } };
function renderWorkbench() {
render(
React.createElement(ProjectDevelopmentView, {
projectName: '布局持久化测试',
projectPath,
manifest,
attachments: [],
agentResults,
supervisor: React.createElement('div', null, '项目总控'),
}),
);
}
renderWorkbench();
const card = screen.getByText('布局持久化回执').closest('button');
expect(card).not.toBeNull();
await waitFor(() => {
expect(card?.getAttribute('style')).toContain('--resource-x: 12px');
expect(card?.getAttribute('style')).toContain('--resource-y: 24px');
});
fireEvent.pointerDown(card!, {
pointerId: 11,
button: 0,
clientX: 20,
clientY: 30,
});
fireEvent.pointerMove(card!, {
pointerId: 11,
clientX: 100,
clientY: 60,
});
fireEvent.pointerUp(card!, {
pointerId: 11,
clientX: 100,
clientY: 60,
});
await waitFor(() => {
expect(persistedLayout.revision).toBe(2);
expect(persistedLayout.positions).toContainEqual({
resourceId,
section: 'document',
x: 92,
y: 54,
manuallyPlaced: true,
});
});
expect(invoke).toHaveBeenCalledWith(
'update_local_project_resource_canvas_layout',
expect.objectContaining({
projectPath,
mode: 'dependency',
expectedRevision: 1,
}),
);
cleanup();
renderWorkbench();
const restoredCard = screen.getByText('布局持久化回执').closest('button');
await waitFor(() => {
expect(restoredCard?.getAttribute('style')).toContain(
'--resource-x: 92px',
);
expect(restoredCard?.getAttribute('style')).toContain(
'--resource-y: 54px',
);
});
});
it('loads the latest layout after a CAS conflict without replaying the drag', async () => {
const projectId = 'workbench-layout-conflict';
const projectPath = '/tmp/workbench-layout-conflict';
const resourceId = 'agent-result:design-foundation:conflict-result-run';
const manifest = createGameCreationAppManifest(projectId, '布局冲突测试');
const latestLayout: ProjectResourceCanvasLayout = {
schemaVersion: 'game-creator-resource-layout.v1',
projectId,
mode: 'dependency',
revision: 4,
positions: [
{
resourceId,
section: 'document',
x: 400,
y: 80,
manuallyPlaced: true,
},
],
updatedAt: 400,
};
const invoke = vi.fn(async (command: string) => {
if (command === 'read_local_project_resource_canvas_layout') {
return {
...structuredClone(latestLayout),
revision: 3,
positions: [
{
...latestLayout.positions[0],
x: 20,
y: 30,
},
],
};
}
if (command === 'update_local_project_resource_canvas_layout') {
return { status: 'conflict', layout: structuredClone(latestLayout) };
}
throw new Error(`unexpected invoke ${command}`);
});
window.__TAURI__ = { core: { invoke } };
render(
React.createElement(ProjectDevelopmentView, {
projectName: '布局冲突测试',
projectPath,
manifest,
attachments: [],
agentResults: [
{
agentId: 'design-foundation',
runId: 'conflict-result-run',
label: '玩法策划 Agent',
title: '布局冲突回执',
content: '布局冲突正文',
updatedAt: 1,
},
],
supervisor: React.createElement('div', null, '项目总控'),
}),
);
const card = screen.getByText('布局冲突回执').closest('button');
await waitFor(() => {
expect(card?.getAttribute('style')).toContain('--resource-x: 20px');
});
fireEvent.pointerDown(card!, {
pointerId: 12,
button: 0,
clientX: 0,
clientY: 0,
});
fireEvent.pointerMove(card!, {
pointerId: 12,
clientX: 200,
clientY: 40,
});
fireEvent.pointerUp(card!, {
pointerId: 12,
clientX: 200,
clientY: 40,
});
expect(
await screen.findByText('布局已在其他窗口更新,请重新拖动'),
).not.toBeNull();
expect(card?.getAttribute('style')).toContain('--resource-x: 400px');
expect(card?.getAttribute('style')).toContain('--resource-y: 80px');
expect(
invoke.mock.calls.filter(
([command]) =>
command === 'update_local_project_resource_canvas_layout',
),
).toHaveLength(1);
});
it('keeps newly reconciled resources visible when their automatic layout save fails', async () => {
const projectId = 'workbench-layout-save-failure';
const manifest = createGameCreationAppManifest(projectId, '布局失败测试');
const invoke = vi.fn(async (command: string) => {
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 } };
render(
React.createElement(ProjectDevelopmentView, {
projectName: '布局失败测试',
projectPath: '/tmp/workbench-layout-save-failure',
manifest,
attachments: [],
agentResults: [
{
agentId: 'design-foundation',
runId: 'save-failure-run',
label: '玩法策划 Agent',
title: '自动排版失败回执',
content: '自动排版失败正文',
updatedAt: 1,
},
],
supervisor: React.createElement('div', null, '项目总控'),
}),
);
expect(
await screen.findByText('布局保存失败,已保留当前会话布局'),
).not.toBeNull();
expect(
screen.getByText('自动排版失败回执').closest('button'),
).not.toBeNull();
});
it('keeps the landscape workbench inside the viewport with internal chat scrolling', () => {
const styles = readFileSync(
resolve(process.cwd(), 'apps/ai-game-creator-shell/src/styles.css'),
@@ -354,7 +639,11 @@ export function registerProjectWorkbenchFoundationTests() {
expect(previewImage.getAttribute('src')).toBe(
'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+A8AAQUBAScY42YAAAAASUVORK5CYII=',
);
expect(invoke).toHaveBeenCalledTimes(1);
expect(
invoke.mock.calls.filter(
([command]) => command === 'read_local_project_image_preview',
),
).toHaveLength(1);
fireEvent.error(previewImage);
expect(screen.getByRole('alert').textContent).toBe(
'图片内容无法解码,请重新生成或替换该资源',
@@ -0,0 +1,135 @@
import { describe, expect, it } from 'vitest';
import {
createEmptyResourceCanvasLayout,
moveResourceCanvasPosition,
reconcileResourceCanvasLayout,
RESOURCE_CANVAS_CARD_HEIGHT,
RESOURCE_CANVAS_CARD_WIDTH,
RESOURCE_CANVAS_COLUMN_GAP,
RESOURCE_CANVAS_ROW_GAP,
type ResourceCanvasItem,
} from '../src/view/project-development/resourceCanvasLayoutModel';
function resource(
id: string,
category: ResourceCanvasItem['category'],
dependencyDepth = 0,
): ResourceCanvasItem {
return {
id,
category,
dependencyDepth,
label: id,
mediaType: category === 'art' ? 'image/png' : 'text/markdown',
};
}
describe('resource canvas layout model', () => {
it('creates non-overlapping defaults and keeps the two modes independent', () => {
const resources = [
resource('a', 'art', 0),
resource('b', 'art', 0),
resource('c', 'art', 1),
];
const dependency = reconcileResourceCanvasLayout(
createEmptyResourceCanvasLayout('project-1', 'dependency'),
resources,
).layout;
const type = reconcileResourceCanvasLayout(
createEmptyResourceCanvasLayout('project-1', 'type'),
resources,
).layout;
expect(dependency.positions).toMatchObject([
{ resourceId: 'a', x: 0, y: 0, manuallyPlaced: false },
{
resourceId: 'c',
x: RESOURCE_CANVAS_CARD_WIDTH + RESOURCE_CANVAS_COLUMN_GAP,
y: 0,
manuallyPlaced: false,
},
{
resourceId: 'b',
x: 0,
y: RESOURCE_CANVAS_CARD_HEIGHT + RESOURCE_CANVAS_ROW_GAP,
manuallyPlaced: false,
},
]);
expect(type.positions).toMatchObject([
{ resourceId: 'a', x: 0, y: 0 },
{
resourceId: 'b',
x: RESOURCE_CANVAS_CARD_WIDTH + RESOURCE_CANVAS_COLUMN_GAP,
y: 0,
},
{
resourceId: 'c',
x: 2 * (RESOURCE_CANVAS_CARD_WIDTH + RESOURCE_CANVAS_COLUMN_GAP),
y: 0,
},
]);
});
it('preserves existing positions, appends new resources, and removes stale ids', () => {
const base = reconcileResourceCanvasLayout(
createEmptyResourceCanvasLayout('project-1', 'type'),
[resource('a', 'document'), resource('stale', 'document')],
).layout;
const moved = moveResourceCanvasPosition(
base,
'a',
'document',
333.4,
41.6,
);
const reconciled = reconcileResourceCanvasLayout(moved, [
resource('a', 'document'),
resource('new', 'document'),
]);
expect(reconciled.changed).toBe(true);
expect(reconciled.layout.positions).toContainEqual({
resourceId: 'a',
section: 'document',
x: 333,
y: 42,
manuallyPlaced: true,
});
expect(
reconciled.layout.positions.some(
(position) => position.resourceId === 'stale',
),
).toBe(false);
expect(
reconciled.layout.positions.find(
(position) => position.resourceId === 'new',
)?.manuallyPlaced,
).toBe(false);
});
it('drops a persisted position whose section no longer matches the resource', () => {
const layout = createEmptyResourceCanvasLayout('project-1', 'dependency');
layout.positions = [
{
resourceId: 'asset-a',
section: 'audio',
x: 10,
y: 10,
manuallyPlaced: true,
},
];
const reconciled = reconcileResourceCanvasLayout(layout, [
resource('asset-a', 'art'),
]);
expect(reconciled.layout.positions).toEqual([
{
resourceId: 'asset-a',
section: 'art',
x: 0,
y: 0,
manuallyPlaced: false,
},
]);
});
});
@@ -1,6 +1,6 @@
# AI 游戏创作项目开发工作台 PRD
更新时间:`2026-07-20`
更新时间:`2026-07-28`
## 1. 产品定位
@@ -153,6 +153,8 @@ P0 中 `approvalMode` 只能有效写入 `strict`;其它值只能作为不可
### 5.2 资源画布布局(P1
实现状态(2026-07-28):本节布局合同已在独立客户端落地,dependency / type 双模式通过项目内 CAS sidecar 独立持久化;关系线、资源替换、缩放 / 平移等其余 P1 能力仍按本文非目标保持未实现。
```ts
type ProjectResourceCanvasLayout = {
schemaVersion: 'game-creator-resource-layout.v1';
@@ -170,7 +172,76 @@ type ProjectResourceCanvasLayout = {
};
```
两个 mode 是两份独立坐标集合;服务端或本地项目持久层以 `projectId + mode` 做 CAS 更新。
两个 mode 是两份独立坐标集合;独立客户端的本地项目持久层以 `projectId + mode` 做 CAS 更新。
#### 5.2.1 字段语义
- `x / y` 是相对所属 `section` 内容原点的 CSS 像素坐标,落盘前四舍五入为非负整数;坐标不使用 viewport、页面或资源详情浮层坐标系。
- `updatedAt` 是持久层生成的 Unix 毫秒时间戳,前端不得自行覆盖。
- `revision``0` 开始;布局文件不存在时读取接口合成 `revision=0 / positions=[]`,首次成功写入返回 `revision=1`,后续每次成功 CAS 写入递增 `1`
- 新资源第一次进入某个 mode 时由默认布局写入 `manuallyPlaced=false`;用户完成一次有效拖动后写为 `true`
- 同一份布局中 `resourceId` 必须唯一。持久层允许暂时存在当前资源投影中没有的旧 ID,因为 Agent 文本成果等资源可能晚于 manifest 恢复;前端协调后必须在下一次成功写入中清除已确认失效的坐标。
- 单份布局最多保存 `4096` 个位置,序列化文件不得超过 `2 MiB``resourceId` 最多 `512` 个 Unicode 字符,`x / y` 取值范围固定为 `0..=1_000_000`
#### 5.2.2 本地存储与业务边界
独立客户端把两份布局保存为项目内 UI sidecar:
```text
.agent/workbench/resource-layouts/
├─ dependency.json
└─ type.json
```
- 文件名必须与 payload 的 `mode` 一致;payload 的 `projectId` 必须与当前 `.agent/manifest.json` 一致。
- 布局只属于工作台 UI 状态,不写入 manifest,不增加游戏项目 mutation revision,不使 Runtime verification 失效,不触发权限确认,也不作为 Agent 产物、资产或 Git 提交依据。
- 写入复用项目安全路径解析、普通文件 / 链接校验和原子 JSON sidecar 安装能力;使用资源布局专用写锁串行化 read-check-write,不用前端进程内互斥替代跨窗口锁,也不长期占用 Agent Runtime 的项目 mutation 锁。
- 主文件损坏、schema 不支持、身份不匹配、文件超限或安全文件检查失败时必须失败关闭,不得把默认空布局覆盖到原文件。若原子安装留下可验证的恢复副本,读取时可按既有 sidecar 恢复规则恢复后再返回。
- 本切片不把布局同步到 `api-server`、SpacetimeDB、云端账号或其它设备。
#### 5.2.3 Tauri 命令合同
```ts
type ReadProjectResourceCanvasLayoutInput = {
projectPath: string;
mode: 'dependency' | 'type';
};
type UpdateProjectResourceCanvasLayoutInput = {
projectPath: string;
mode: 'dependency' | 'type';
expectedRevision: number;
positions: ProjectResourceCanvasLayout['positions'];
};
type UpdateProjectResourceCanvasLayoutResult =
| {
status: 'updated';
layout: ProjectResourceCanvasLayout;
}
| {
status: 'conflict';
layout: ProjectResourceCanvasLayout;
};
```
- 读取命令固定为 `read_local_project_resource_canvas_layout`,返回当前 mode 的完整布局;文件不存在时返回合成的 revision `0` 布局,不为只读操作创建目录或文件。
- 更新命令固定为 `update_local_project_resource_canvas_layout`。调用方不提交 `projectId / revision / updatedAt` 的权威新值;Tauri 在锁内从 manifest 取得 `projectId`,重新读取当前布局,并生成新的 revision 与时间戳。
- `expectedRevision` 与锁内 revision 相同才允许原子写入并返回 `updated`;不同时不得写文件,返回 `conflict` 和锁内最新完整布局。前端不得通过解析错误字符串识别 CAS 冲突。
- 项目无效、布局损坏、字段校验失败和文件系统错误继续作为安全、可理解的 Tauri command error 返回;错误不得包含配置、凭据或项目外绝对路径。
#### 5.2.4 前端布局与协调合同
- 资源卡改用 Pointer Events 驱动二维拖动;超过统一移动阈值后才进入拖动态,普通点击仍打开唯一资源详情浮层,`pointercancel` 恢复拖动前位置。
- 资源只能在原 `section` 内拖动。不同 section 之间既不能通过指针拖入,也不能通过持久 payload 改变当前资源的前端分类事实。
- dependency 默认布局按 `dependencyDepth` 形成横向层级,同层资源纵向寻找第一个不重叠位置;type 默认布局按资源子类型、媒体类型和名称的稳定顺序,在分区内从左到右、从上到下寻找第一个空位。卡片尺寸、间距和拖动阈值必须由单一前端布局模型常量维护。
- 资源集合变化时保留全部仍存在的坐标,只为新 ID 计算默认位置,并删除已确认失效的旧 ID;无论 `manuallyPlaced` 为何,已经写入的现存坐标都不得因重新排序、模式切换或新增资源被自动改写。
- 搜索或筛选只隐藏卡片,不删除、压缩或重排其坐标;清空搜索后恢复原位置。
- 窗口尺寸变化只改变可视范围和分区滚动边界,不回写、裁切或缩放持久坐标。当前客户端继续以 `1280×800` 横屏合同验收。
- 打开项目、切换 mode 或当前 mode 首次出现新资源时执行“读取 -> 协调 -> 必要时 CAS 写入”;项目或 mode 已切换后返回的旧异步结果必须丢弃。
- 用户拖动结束后先乐观更新,再立即提交一次 CAS。成功后以返回布局更新 revision;普通写入失败时恢复最近可信持久布局并提示“布局保存失败,已恢复上次布局”。
- CAS 冲突时直接载入返回的最新布局并提示“布局已在其他窗口更新,请重新拖动”,不得自动重放本地旧坐标或静默覆盖另一窗口结果。
- 缺少 Tauri bridge 的浏览器开发态可以保留当前会话内布局用于界面测试,但不得宣称已经持久保存。
### 5.3 资源类型与替换兼容性(P1)
@@ -273,8 +344,8 @@ type ProjectAgentMudPointAttribution = {
### P1
- 依赖/类型两套坐标持久化。
- 资源关系线与首次自动布局
- 先实施依赖/类型两套坐标持久化、首次默认不重叠布局、跨重启恢复与 CAS 冲突处理
- 资源关系线在布局持久化验收通过后单独实施,不与本切片捆绑伪造完成
- 版本资源高亮、兼容性判断和不可变下一迭代版本。
- 美术/音频编辑状态接线。
@@ -286,7 +357,9 @@ type ProjectAgentMudPointAttribution = {
- Agent.md/Skill 安全合同。
- 高风险审批 Rank 与无需审批运行合同。
## 7. P0 验收
## 7. 验收
### 7.1 P0 验收
1. `1280×800` 下页面无横向或纵向溢出,输入框与 Agent Dock 始终可见。
2. 运行入口不可用时点击给出原因;可用时只在客户端内打开 loopback 预览。
@@ -295,9 +368,20 @@ type ProjectAgentMudPointAttribution = {
5. 风险审批和无需审批不能改变运行策略,点击后明确提示尚未开放;严格审批继续使用现有 Runtime 门禁。
6. 不显示伪造泥点、伪造资源完成度、伪造图片或外部浏览器成功提示。
### 7.2 P1 资源画布布局持久化验收
1. 同一项目在 dependency 与 type mode 分别拖动资源后,关闭并重启客户端,两种 mode 都恢复各自最后一次成功保存的位置。
2. 新资源进入任一 mode 时获得不重叠默认位置,现存资源坐标保持逐项不变;删除资源后,下一次成功写入不再包含已确认失效的 ID。
3. 资源不能跨 document、version、art、audio 分区;点击、搜索、筛选和资源详情浮层行为不因二维拖动回归。
4. 两个窗口基于同一 revision 写入时最多一个成功;失败方收到 `conflict` 与最新完整布局,界面不静默覆盖成功方结果。
5. 布局文件缺失的旧项目可以无迁移打开;损坏、未知 schema、身份冲突、超限和链接文件失败关闭,且原文件不被空布局覆盖。
6. 布局读写不改变 manifest、游戏项目 mutation revision、Runtime verification、Agent 权限与预览状态。
7. `1280×800` 最小横屏下全部资源可通过分区滚动访问,不出现页面级横向或纵向溢出,右侧对话和底部 Agent 状态栏保持可见。
## 8. 非目标
- 本切片不实现 P1/P2 持久合同
- 资源画布布局持久化切片不实现资源关系线、资源替换、不可变迭代版本、画板编辑状态、测试切片、数值参数或泥点归因
- 本切片不保存资源详情浮层位置、画布缩放 / 平移、搜索条件、筛选条件或当前 mode;这些状态如需持久化必须另行扩展合同,不能塞入 `game-creator-resource-layout.v1`
- 不修改 SpacetimeDB schema。
- 不开放普通用户 Agent.md/Skill。
- 不自动确认 Agent 动作,不自动触发可能扣费的生成。
@@ -16,6 +16,16 @@
---
## 2026-07-28 AI 游戏创作资源画布布局使用本地双模式 CAS sidecar
- 背景:项目开发工作台当前只在 React 会话内保存同分类资源的一维拖拽顺序,项目切换或客户端重启后重建默认排列;工作台 PRD 虽已给出二维位置字段,但缺少落盘路径、坐标系、Tauri API、CAS、异常与安全边界,仍不足以直接编码。
- 决策:dependency 与 type 两套布局分别保存为项目内 `.agent/workbench/resource-layouts/dependency.json``type.json`,统一使用 `game-creator-resource-layout.v1``x / y` 是 section 内容 CSS 像素,revision 从缺文件时的 `0` 单调递增;新资源首次默认放置,任何已有坐标不因排序、筛选、模式切换或 resize 被自动覆盖。
- 并发与失败:Tauri 用 `read_local_project_resource_canvas_layout``update_local_project_resource_canvas_layout` 暴露读写,以 `projectId + mode + expectedRevision` 在专用跨窗口布局锁内做 CAS。冲突返回最新完整布局且零写入,前端载入最新值并要求重新拖动;普通失败恢复最近可信布局。写入复用项目安全路径、链接校验、容量上限、恢复副本与原子替换,损坏或身份冲突不能被空布局覆盖。
- 业务边界:布局是本地工作台 UI sidecar,不进入 manifest,不推进游戏项目 mutation revision,不使 Runtime verification 失效,不触发 Agent 权限,也不属于资产、Agent 产物、Git 或云端事实。本切片不包含关系线、资源替换、浮层位置、缩放 / 平移、搜索 / 筛选条件和当前 mode。
- 影响范围:`packages/shared` 与 Rust `shared-contracts` 的跨边界 DTO、AI 游戏创作 Tauri 项目持久层与命令、项目开发资源画布、定向 Rust / React 测试、工作台 PRD 和客户端实施计划。
- 验证方式:序列化与字段上限测试、缺文件 / 损坏 / 原子恢复 / 链接安全测试、同 revision 双写最多一个成功、两种 mode 跨重启独立恢复、新增资源不移动旧坐标、`1280×800` 横屏无页面级溢出,以及 `npm run agc:typecheck`、定向测试、`npm run check:encoding``git diff --check`
- 关联文档:`docs/prd/【AI游戏创作】项目开发工作台PRD-2026-07-20.md``docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md`
## 2026-07-21 AI 游戏创作客户端采用薄入口与领域模块
- 背景:`apps/ai-game-creator-shell` 的前端入口、Tauri 项目能力、Rust 测试、界面测试和真实 Runtime E2E 随能力增长形成超大文件,单文件所有权已经影响并行开发、审查和定向验证。
@@ -307,7 +307,7 @@ game-project/
- 页面骨架固定为左侧现有全局导航、中间主视窗、右侧陶泥儿对话和底部子 Agent 状态栏;不新建第二套客户端或平行项目页。
- 中间主视窗提供 `资源管理 / 运行` 切换。`code-prototype` 任务完成前运行入口保持视觉不可用,但仍可点击查看“当前无可运行版本”,不能使用会阻断说明交互的原生 `disabled``aria-disabled`;完成后才允许进入运行表现层。切回资源管理只修改前端展示态,不伪造后端预览暂停结果。
- 资源管理从当前 `GameCreationAppManifest` 派生项目文档、项目版本和 `assets`,并把首页已导入附件作为当前项目上传资源展示。资源按文档、版本、美术、动作、音乐音效分区;`按依赖 / 按类型` 只改变当前前端排列方式,不写回 manifest,也不伪造资源依赖。
- 资源卡首版支持选择聚焦、文档展开 / 收起、搜索和类型筛选的界面交互。拖拽自由排版、画板编辑、生成关系连线、同类型版本资源替换均保留清晰入口或状态提示,但在具备正式布局 / 版本引用写回契约前不保存为业务事实。
- 资源卡支持选择聚焦、文档展开 / 收起、搜索和类型筛选的界面交互。2026-07-28 起,原一维会话拖拽已替换为两套二维坐标与本地 CAS sidecar;画板编辑、生成关系连线、同类型版本资源替换仍不得在缺少各自正式写回契约保存为业务事实。
- 运行表现层首版直接嵌入当前项目的 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 与回滚合同完备前不向普通用户开放。
@@ -318,6 +318,22 @@ game-project/
- 外部 Runner 模式下,重试命令的 Session Runtime 快照可能仍指向旧 run,因此响应必须额外返回精确 `acceptedRunId` 作为入队受理事实,前端据此锁定恢复按钮并持续同步该 run,不能用 `state.runId` 是否立即切换判断失败。同一 `agentId + sourceRunId` 已存在非终态 retry successor 时必须幂等复用并返回其 `acceptedRunId`,不得再次入队或追加第二条 retry audit。
- 该界面切片只允许受限的 loopback iframe,不得引入远程 URL、第二套资产模型、前端正式资源关系、前端版本替换真相或前端计费结论。
### 资源画布布局持久化 V1
2026-07-28 起,资源画布布局持久化以工作台 PRD §5.2 和 §7.2 为唯一编码合同,实施边界如下:
- dependency 与 type 分别保存到 `.agent/workbench/resource-layouts/dependency.json``type.json`schema 固定为 `game-creator-resource-layout.v1`。布局是本地工作台 UI sidecar,不进入 manifest、游戏项目 mutation revision、Runtime verification、Agent 产物、资产或云端事实。
- `x / y` 使用 section 内容坐标,`updatedAt` 使用 Unix 毫秒;文件缺失只合成 revision `0` 空布局且不产生只读副作用。每个 mode 按 `projectId + mode + expectedRevision` 做 CAS,成功 revision 加一,冲突返回最新完整布局且不写文件。
- Tauri 命令固定为 `read_local_project_resource_canvas_layout``update_local_project_resource_canvas_layout`。写命令在资源布局专用跨窗口锁内重新读取 manifest 和当前 sidecar,复用安全路径、链接检查、容量上限、恢复副本与原子替换能力;不能只依赖 React 状态或进程内锁。
- 前端从当前项目开发大组件中拆出纯布局模型与持久 Hook。默认布局、碰撞检查、资源增删协调和 section 边界由纯模型负责;读取、异步身份、CAS、错误回滚和冲突载入由 Hook 负责。视图使用 Pointer Events 做二维拖动,普通点击、搜索、筛选和唯一资源详情浮层语义保持不变。
- 新资源只在第一次进入某个 mode 时计算默认不重叠位置;全部现存坐标保持不变。搜索、筛选、窗口 resize 和 mode 切换不得重排或回写已有坐标,窄视图通过 section 画布范围与滚动访问,不裁切持久坐标。
- 普通保存失败恢复最近可信持久布局;CAS 冲突载入对方最新布局并要求用户重新拖动,不自动重放旧坐标。损坏、未知 schema、身份冲突、超限与链接文件失败关闭,不能用空布局覆盖原文件。
- 本切片不包含资源关系线、资源替换、详情浮层位置、缩放 / 平移、搜索 / 筛选条件、当前 mode,也不修改 `api-server` 或 SpacetimeDB。关系线与其它 P1 能力必须在本切片独立验收后继续接入。
实施顺序固定为:先同步 TypeScript / Rust DTO 与序列化测试,再实现 Tauri sidecar 读写和 CAS,随后接入前端纯模型、持久 Hook 与二维拖动,最后完成 Rust 安全测试、React 交互测试、跨重启 / 双窗口验收和文档状态回写。任何一步不得用 `localStorage`、manifest 字段或只在当前 React 会话有效的状态冒充项目持久化。
2026-07-28 实现状态:上述 V1 已落地。TypeScript / Rust DTO、两条 Tauri 命令、双模式 sidecar、专用跨窗口锁、CAS 冲突处理、默认排版、二维 Pointer Events 拖动和重启恢复均已接入;`.agent/workbench` 已从通用项目文件列表、读取、写入和删除能力中隔离。定向测试覆盖冻结 JSON 合同、缺文件、损坏与未知 schema、身份和字段上限、软硬链接、同 revision 并发双写、资源增删与分区漂移、保存失败保留新资源、成功恢复和冲突载入最新布局。
## 分阶段实施
1. 在 `platform-agent` 建立游戏创作专业组与种子任务图契约。
@@ -459,6 +459,44 @@ export interface GameCreationAppAssetManifestEntry {
source: GameCreationAppAssetSource;
}
export const GAME_CREATION_RESOURCE_LAYOUT_SCHEMA_VERSION =
'game-creator-resource-layout.v1' as const;
export type ProjectResourceCanvasLayoutMode = 'dependency' | 'type';
export type ProjectResourceCanvasSection =
| 'document'
| 'version'
| 'art'
| 'audio';
export interface ProjectResourceCanvasPosition {
resourceId: string;
section: ProjectResourceCanvasSection;
x: number;
y: number;
manuallyPlaced: boolean;
}
export interface ProjectResourceCanvasLayout {
schemaVersion: typeof GAME_CREATION_RESOURCE_LAYOUT_SCHEMA_VERSION;
projectId: string;
mode: ProjectResourceCanvasLayoutMode;
revision: number;
positions: ProjectResourceCanvasPosition[];
updatedAt: number;
}
export type UpdateProjectResourceCanvasLayoutResult =
| {
status: 'updated';
layout: ProjectResourceCanvasLayout;
}
| {
status: 'conflict';
layout: ProjectResourceCanvasLayout;
};
export type GameCreationAppPreviewStatus =
| 'stopped'
| 'starting'
@@ -476,6 +476,59 @@ pub struct GameCreationAppAssetManifestEntry {
pub source: GameCreationAppAssetSource,
}
pub const GAME_CREATION_RESOURCE_LAYOUT_SCHEMA_VERSION: &str = "game-creator-resource-layout.v1";
#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)]
#[serde(rename_all = "kebab-case")]
pub enum ProjectResourceCanvasLayoutMode {
Dependency,
Type,
}
#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)]
#[serde(rename_all = "kebab-case")]
pub enum ProjectResourceCanvasSection {
Document,
Version,
Art,
Audio,
}
#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct ProjectResourceCanvasPosition {
pub resource_id: String,
pub section: ProjectResourceCanvasSection,
pub x: u32,
pub y: u32,
pub manually_placed: bool,
}
#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct ProjectResourceCanvasLayout {
pub schema_version: String,
pub project_id: String,
pub mode: ProjectResourceCanvasLayoutMode,
pub revision: u64,
pub positions: Vec<ProjectResourceCanvasPosition>,
pub updated_at: u64,
}
#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)]
#[serde(rename_all = "kebab-case")]
pub enum UpdateProjectResourceCanvasLayoutStatus {
Updated,
Conflict,
}
#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct UpdateProjectResourceCanvasLayoutResult {
pub status: UpdateProjectResourceCanvasLayoutStatus,
pub layout: ProjectResourceCanvasLayout,
}
#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
#[serde(rename_all = "kebab-case")]
pub enum GameCreationAppPreviewStatus {
@@ -1413,4 +1466,42 @@ mod tests {
json!("canvas-project-1")
);
}
#[test]
fn resource_canvas_layout_uses_frozen_json_contract() {
let layout = ProjectResourceCanvasLayout {
schema_version: GAME_CREATION_RESOURCE_LAYOUT_SCHEMA_VERSION.to_string(),
project_id: "project-layout-1".to_string(),
mode: ProjectResourceCanvasLayoutMode::Dependency,
revision: 1,
positions: vec![ProjectResourceCanvasPosition {
resource_id: "asset-1".to_string(),
section: ProjectResourceCanvasSection::Art,
x: 10,
y: 20,
manually_placed: true,
}],
updated_at: 123,
};
assert_eq!(
serde_json::to_value(layout).expect("resource layout should serialize"),
json!({
"schemaVersion": "game-creator-resource-layout.v1",
"projectId": "project-layout-1",
"mode": "dependency",
"revision": 1,
"positions": [
{
"resourceId": "asset-1",
"section": "art",
"x": 10,
"y": 20,
"manuallyPlaced": true
}
],
"updatedAt": 123
})
);
}
}