From 4330ec4b5f059e88dac6bdbde91fd9d150951b94 Mon Sep 17 00:00:00 2001 From: menghao Date: Thu, 30 Jul 2026 15:35:01 +0800 Subject: [PATCH] =?UTF-8?q?=E4=BF=AE=E5=A4=8D=E8=B5=84=E6=BA=90=E7=94=BB?= =?UTF-8?q?=E5=B8=83=E5=89=8D=E7=AB=AF=E7=AB=9E=E6=80=81=E4=B8=8E=E5=B8=83?= =?UTF-8?q?=E5=B1=80=E6=80=A7=E8=83=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 以 scope epoch 和单写者 FIFO 串行首读、拖动及资源协调写入 CAS 冲突载入权威布局并丢弃旧手动意图,资源协调有界重试 使用空间占用索引将 4096 项默认布局降至可接受复杂度 补充异步竞态、冲突、性能回归测试并同步契约文档 --- .../src/view/project-development/index.tsx | 2 +- .../resourceCanvasLayoutModel.ts | 172 ++++-- .../useProjectResourceCanvasLayout.ts | 507 ++++++++++++++---- .../appSurface/project-development.suite.ts | 134 ++++- .../tests/resourceCanvasLayoutModel.test.ts | 26 + .../useProjectResourceCanvasLayout.test.ts | 453 ++++++++++++++++ ...AI游戏创作】项目开发工作台PRD-2026-07-20.md | 3 +- .../shared-memory/decision-log.md | 3 +- ...案】AI游戏创作智能体App实施计划-2026-06-24.md | 6 +- 9 files changed, 1144 insertions(+), 162 deletions(-) create mode 100644 apps/ai-game-creator-shell/tests/useProjectResourceCanvasLayout.test.ts diff --git a/apps/ai-game-creator-shell/src/view/project-development/index.tsx b/apps/ai-game-creator-shell/src/view/project-development/index.tsx index 19d61429c..f250142dc 100644 --- a/apps/ai-game-creator-shell/src/view/project-development/index.tsx +++ b/apps/ai-game-creator-shell/src/view/project-development/index.tsx @@ -749,7 +749,7 @@ export default function ProjectDevelopmentView({ event: ReactPointerEvent, resource: ProjectResource, ) { - if (event.button !== 0 || resourceLayoutSaving) { + if (event.button !== 0) { return; } const position = resourcePositionById.get(resource.id); diff --git a/apps/ai-game-creator-shell/src/view/project-development/resourceCanvasLayoutModel.ts b/apps/ai-game-creator-shell/src/view/project-development/resourceCanvasLayoutModel.ts index 071def658..bec57a56f 100644 --- a/apps/ai-game-creator-shell/src/view/project-development/resourceCanvasLayoutModel.ts +++ b/apps/ai-game-creator-shell/src/view/project-development/resourceCanvasLayoutModel.ts @@ -69,46 +69,107 @@ function positionsEqual( ); } -function positionOverlaps( - x: number, - y: number, - positions: ProjectResourceCanvasPosition[], +const RESOURCE_CANVAS_SLOT_WIDTH = + RESOURCE_CANVAS_CARD_WIDTH + RESOURCE_CANVAS_COLUMN_GAP; +const RESOURCE_CANVAS_SLOT_HEIGHT = + RESOURCE_CANVAS_CARD_HEIGHT + RESOURCE_CANVAS_ROW_GAP; + +function positionsOverlap( + leftX: number, + leftY: number, + right: 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, + return ( + leftX < right.x + RESOURCE_CANVAS_SLOT_WIDTH && + leftX + RESOURCE_CANVAS_SLOT_WIDTH > right.x && + leftY < right.y + RESOURCE_CANVAS_SLOT_HEIGHT && + leftY + RESOURCE_CANVAS_SLOT_HEIGHT > right.y ); } +class ResourceCanvasOccupancyIndex { + private readonly positionsByCell = new Map< + string, + ProjectResourceCanvasPosition[] + >(); + + constructor(positions: ProjectResourceCanvasPosition[]) { + positions.forEach((position) => this.add(position)); + } + + private cellKeys(x: number, y: number) { + const firstColumn = Math.floor(x / RESOURCE_CANVAS_SLOT_WIDTH); + const lastColumn = Math.floor( + (x + RESOURCE_CANVAS_SLOT_WIDTH - 1) / RESOURCE_CANVAS_SLOT_WIDTH, + ); + const firstRow = Math.floor(y / RESOURCE_CANVAS_SLOT_HEIGHT); + const lastRow = Math.floor( + (y + RESOURCE_CANVAS_SLOT_HEIGHT - 1) / RESOURCE_CANVAS_SLOT_HEIGHT, + ); + const keys: string[] = []; + for (let column = firstColumn; column <= lastColumn; column += 1) { + for (let row = firstRow; row <= lastRow; row += 1) { + keys.push(`${column}:${row}`); + } + } + return keys; + } + + add(position: ProjectResourceCanvasPosition) { + for (const key of this.cellKeys(position.x, position.y)) { + const positions = this.positionsByCell.get(key); + if (positions) { + positions.push(position); + } else { + this.positionsByCell.set(key, [position]); + } + } + } + + overlaps(x: number, y: number) { + const visited = new Set(); + for (const key of this.cellKeys(x, y)) { + for (const position of this.positionsByCell.get(key) ?? []) { + if (!visited.has(position)) { + visited.add(position); + if (positionsOverlap(x, y, position)) { + return true; + } + } + } + } + return false; + } +} + function defaultDependencyPosition( resource: ResourceCanvasItem, - positions: ProjectResourceCanvasPosition[], + occupancy: ResourceCanvasOccupancyIndex, + nextYByColumn: Map, ) { 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; + RESOURCE_CANVAS_SLOT_WIDTH; + let y = nextYByColumn.get(x) ?? 0; + while (occupancy.overlaps(x, y)) { + y += RESOURCE_CANVAS_SLOT_HEIGHT; } + nextYByColumn.set(x, y + RESOURCE_CANVAS_SLOT_HEIGHT); return { x, y }; } -function defaultTypePosition(positions: ProjectResourceCanvasPosition[]) { - let slot = 0; +function defaultTypePosition( + occupancy: ResourceCanvasOccupancyIndex, + nextSlot: number, +) { + let slot = nextSlot; 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 }; + const x = column * RESOURCE_CANVAS_SLOT_WIDTH; + const y = row * RESOURCE_CANVAS_SLOT_HEIGHT; + if (!occupancy.overlaps(x, y)) { + return { point: { x, y }, nextSlot: slot + 1 }; } slot += 1; } @@ -140,30 +201,49 @@ export function reconcileResourceCanvasLayout( const resourceById = new Map( resources.map((resource) => [resource.id, resource]), ); + const positionsBySection = new Map( + sectionOrder.map((section) => [ + section, + [] as ProjectResourceCanvasPosition[], + ]), + ); const preserved = source.positions.filter((position) => { const resource = resourceById.get(position.resourceId); - return resource?.category === position.section; + const keep = resource?.category === position.section; + if (keep) { + positionsBySection.get(position.section)?.push(position); + } + return keep; }); const preservedIds = new Set( preserved.map((position) => position.resourceId), ); - const next = [...preserved]; + const newResourcesBySection = new Map( + sectionOrder.map((section) => [section, [] as ResourceCanvasItem[]]), + ); + for (const resource of resources) { + if (!preservedIds.has(resource.id)) { + newResourcesBySection.get(resource.category)?.push(resource); + } + } for (const section of sectionOrder) { - const sectionPositions = next.filter( - (position) => position.section === section, + const sectionPositions = positionsBySection.get(section) ?? []; + const occupancy = new ResourceCanvasOccupancyIndex(sectionPositions); + const nextYByColumn = new Map(); + let nextTypeSlot = 0; + const newResources = (newResourcesBySection.get(section) ?? []).sort( + (left, right) => compareResources(source.mode, left, right), ); - 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); + let point: { x: number; y: number }; + if (source.mode === 'dependency') { + point = defaultDependencyPosition(resource, occupancy, nextYByColumn); + } else { + const placement = defaultTypePosition(occupancy, nextTypeSlot); + point = placement.point; + nextTypeSlot = placement.nextSlot; + } const position: ProjectResourceCanvasPosition = { resourceId: resource.id, section, @@ -172,19 +252,17 @@ export function reconcileResourceCanvasLayout( manuallyPlaced: false, }; sectionPositions.push(position); - next.push(position); + occupancy.add(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), - ), + (positionsBySection.get(section) ?? []).sort( + (left, right) => + left.y - right.y || + left.x - right.x || + left.resourceId.localeCompare(right.resourceId), + ), ); return { layout: { diff --git a/apps/ai-game-creator-shell/src/view/project-development/useProjectResourceCanvasLayout.ts b/apps/ai-game-creator-shell/src/view/project-development/useProjectResourceCanvasLayout.ts index ae0a034e3..f86d2f989 100644 --- a/apps/ai-game-creator-shell/src/view/project-development/useProjectResourceCanvasLayout.ts +++ b/apps/ai-game-creator-shell/src/view/project-development/useProjectResourceCanvasLayout.ts @@ -21,6 +21,64 @@ type LayoutNotice = | '布局保存失败,已恢复上次布局' | '布局已在其他窗口更新,请重新拖动'; +type LayoutScope = { + key: string; + epoch: number; + projectPath: string; + projectId: string; + mode: ProjectResourceCanvasLayoutMode; +}; + +type ManualLayoutWriteIntent = { + kind: 'manual'; + scopeEpoch: number; + resourceId: string; + section: ProjectResourceCanvasSection; + x: number; + y: number; +}; + +type ResourceLayoutWriteIntent = { + kind: 'resources'; + scopeEpoch: number; + resourceSignature: string; + conflictRetries: number; +}; + +type LayoutWriteIntent = ManualLayoutWriteIntent | ResourceLayoutWriteIntent; + +const MAX_RESOURCE_SYNC_CONFLICT_RETRIES = 2; + +function createScopeKey( + projectPath: string, + projectId: string, + mode: ProjectResourceCanvasLayoutMode, +) { + return JSON.stringify([projectPath, projectId, mode]); +} + +function createResourceSignature(resources: ResourceCanvasItem[]) { + return resources + .map((resource) => + JSON.stringify([ + resource.id, + resource.category, + resource.label, + resource.dependencyDepth, + resource.mediaType, + ]), + ) + .sort() + .join('\n'); +} + +function layoutMatchesScope( + layout: ProjectResourceCanvasLayout, + scope: LayoutScope, +) { + return layout.projectId === scope.projectId && layout.mode === scope.mode; +} + export function useProjectResourceCanvasLayout({ projectPath, projectId, @@ -32,15 +90,9 @@ export function useProjectResourceCanvasLayout({ mode: ProjectResourceCanvasLayoutMode; resources: ResourceCanvasItem[]; }) { + const scopeKey = createScopeKey(projectPath, projectId, mode); const resourceSignature = useMemo( - () => - resources - .map( - (resource) => - `${resource.id}\u0000${resource.category}\u0000${resource.dependencyDepth}\u0000${resource.mediaType}`, - ) - .sort() - .join('\u0001'), + () => createResourceSignature(resources), [resources], ); const fallback = useMemo( @@ -54,77 +106,298 @@ export function useProjectResourceCanvasLayout({ const [layout, setLayout] = useState(fallback); const [notice, setNotice] = useState(''); const [saving, setSaving] = useState(false); + const mountedRef = useRef(true); + const layoutRef = useRef(fallback); const persistedLayoutRef = useRef(fallback); - const requestTokenRef = useRef(0); + const resourcesRef = useRef(resources); + const resourceSignatureRef = useRef(resourceSignature); + const initializedScopeEpochRef = useRef(null); + const scopeRef = useRef({ + key: scopeKey, + epoch: 0, + projectPath, + projectId, + mode, + }); + const writeQueueRef = useRef([]); + const activeWriteIntentRef = useRef(null); + const pumpWritesRef = useRef<() => void>(() => undefined); + const enqueueResourceSyncRef = useRef< + (scopeEpoch: number, conflictRetries?: number) => void + >(() => undefined); - const scopeMatches = layout.projectId === projectId && layout.mode === mode; - const currentLayout = scopeMatches ? layout : fallback; + resourcesRef.current = resources; + resourceSignatureRef.current = resourceSignature; - 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); - } + const applyLayout = useCallback((next: ProjectResourceCanvasLayout) => { + layoutRef.current = next; + if (mountedRef.current) { + setLayout(next); + } + }, []); + + const removeWriteIntent = useCallback((intent: LayoutWriteIntent) => { + writeQueueRef.current = writeQueueRef.current.filter( + (queued) => queued !== intent, + ); + }, []); + + const rebuildOptimisticLayout = useCallback( + (scopeEpoch: number) => { + const scope = scopeRef.current; + if (scope.epoch !== scopeEpoch) { return; } - setSaving(true); - try { - const result = await invoke( - '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, + let next = reconcileResourceCanvasLayout( + persistedLayoutRef.current, + resourcesRef.current, + ).layout; + for (const intent of writeQueueRef.current) { + if (intent.scopeEpoch === scopeEpoch && intent.kind === 'manual') { + next = moveResourceCanvasPosition( + next, + intent.resourceId, + intent.section, + intent.x, + intent.y, ); - setNotice('布局已在其他窗口更新,请重新拖动'); - } - } catch { - if (requestTokenRef.current !== token) { - return; - } - setLayout(rollback); - setNotice(failureNotice); - } finally { - if (requestTokenRef.current === token) { - setSaving(false); } } + applyLayout(next); }, - [mode, projectPath, resources], + [applyLayout], ); - useEffect(() => { - const token = requestTokenRef.current + 1; - requestTokenRef.current = token; - setNotice(''); - setSaving(false); + enqueueResourceSyncRef.current = (scopeEpoch, conflictRetries = 0) => { + const scope = scopeRef.current; + if (scope.epoch !== scopeEpoch) { + return; + } + const signature = resourceSignatureRef.current; + const queued = writeQueueRef.current.find( + (intent): intent is ResourceLayoutWriteIntent => + intent.kind === 'resources' && + intent.scopeEpoch === scopeEpoch && + intent !== activeWriteIntentRef.current, + ); + if (queued) { + if (queued.resourceSignature !== signature) { + queued.resourceSignature = signature; + queued.conflictRetries = 0; + } else { + queued.conflictRetries = Math.min( + queued.conflictRetries, + conflictRetries, + ); + } + } else { + writeQueueRef.current.push({ + kind: 'resources', + scopeEpoch, + resourceSignature: signature, + conflictRetries, + }); + } + if (mountedRef.current) { + setSaving(true); + } + pumpWritesRef.current(); + }; + + pumpWritesRef.current = () => { + if (activeWriteIntentRef.current) { + return; + } + const scope = scopeRef.current; + writeQueueRef.current = writeQueueRef.current.filter( + (intent) => intent.scopeEpoch === scope.epoch, + ); + const intent = writeQueueRef.current[0]; + if (!intent) { + if (mountedRef.current) { + setSaving(false); + } + return; + } + if (initializedScopeEpochRef.current !== scope.epoch) { + if (mountedRef.current) { + setSaving(true); + } + return; + } + + const reconciled = reconcileResourceCanvasLayout( + persistedLayoutRef.current, + resourcesRef.current, + ); + if (intent.kind === 'resources' && !reconciled.changed) { + removeWriteIntent(intent); + rebuildOptimisticLayout(scope.epoch); + void Promise.resolve().then(() => pumpWritesRef.current()); + return; + } + if ( + intent.kind === 'manual' && + !reconciled.layout.positions.some( + (position) => + position.resourceId === intent.resourceId && + position.section === intent.section, + ) + ) { + removeWriteIntent(intent); + rebuildOptimisticLayout(scope.epoch); + void Promise.resolve().then(() => pumpWritesRef.current()); + return; + } + const candidate = + intent.kind === 'manual' + ? moveResourceCanvasPosition( + reconciled.layout, + intent.resourceId, + intent.section, + intent.x, + intent.y, + ) + : reconciled.layout; const invoke = window.__TAURI__?.core?.invoke; if (!invoke) { - persistedLayoutRef.current = fallback; - setLayout(fallback); + persistedLayoutRef.current = candidate; + removeWriteIntent(intent); + rebuildOptimisticLayout(scope.epoch); + const hasQueuedWrite = writeQueueRef.current.some( + (queued) => queued.scopeEpoch === scope.epoch, + ); + if (mountedRef.current) { + setSaving(hasQueuedWrite); + } + if (hasQueuedWrite) { + void Promise.resolve().then(() => pumpWritesRef.current()); + } + return; + } + + activeWriteIntentRef.current = intent; + if (mountedRef.current) { + setSaving(true); + } + void invoke( + 'update_local_project_resource_canvas_layout', + { + projectPath: scope.projectPath, + mode: scope.mode, + expectedRevision: persistedLayoutRef.current.revision, + positions: candidate.positions, + }, + ) + .then((result) => { + const currentScope = scopeRef.current; + if (currentScope.epoch !== intent.scopeEpoch) { + return; + } + if (!layoutMatchesScope(result.layout, currentScope)) { + throw new Error('layout response scope mismatch'); + } + persistedLayoutRef.current = result.layout; + removeWriteIntent(intent); + if (result.status === 'updated') { + if (intent.kind === 'manual') { + setNotice('布局已保存'); + } + if ( + reconcileResourceCanvasLayout(result.layout, resourcesRef.current) + .changed + ) { + enqueueResourceSyncRef.current(currentScope.epoch); + } + } else { + writeQueueRef.current = writeQueueRef.current.filter( + (queued) => + queued.scopeEpoch !== currentScope.epoch || + queued.kind !== 'manual', + ); + setNotice('布局已在其他窗口更新,请重新拖动'); + const needsResourceSync = reconcileResourceCanvasLayout( + result.layout, + resourcesRef.current, + ).changed; + const nextRetry = + intent.kind === 'resources' ? intent.conflictRetries + 1 : 0; + if ( + needsResourceSync && + nextRetry <= MAX_RESOURCE_SYNC_CONFLICT_RETRIES + ) { + enqueueResourceSyncRef.current(currentScope.epoch, nextRetry); + } + } + rebuildOptimisticLayout(currentScope.epoch); + }) + .catch(() => { + const currentScope = scopeRef.current; + if (currentScope.epoch !== intent.scopeEpoch) { + return; + } + removeWriteIntent(intent); + rebuildOptimisticLayout(currentScope.epoch); + setNotice( + intent.kind === 'resources' + ? '布局保存失败,已保留当前会话布局' + : '布局保存失败,已恢复上次布局', + ); + }) + .finally(() => { + if (activeWriteIntentRef.current === intent) { + activeWriteIntentRef.current = null; + } + const currentScope = scopeRef.current; + if (mountedRef.current && currentScope.epoch === intent.scopeEpoch) { + setSaving( + writeQueueRef.current.some( + (queued) => queued.scopeEpoch === currentScope.epoch, + ), + ); + } + pumpWritesRef.current(); + }); + }; + + useEffect(() => { + mountedRef.current = true; + return () => { + mountedRef.current = false; + scopeRef.current = { + ...scopeRef.current, + epoch: scopeRef.current.epoch + 1, + }; + initializedScopeEpochRef.current = null; + writeQueueRef.current = []; + }; + }, []); + + useEffect(() => { + const epoch = scopeRef.current.epoch + 1; + const scope: LayoutScope = { + key: scopeKey, + epoch, + projectPath, + projectId, + mode, + }; + scopeRef.current = scope; + initializedScopeEpochRef.current = null; + writeQueueRef.current = []; + const initialFallback = reconcileResourceCanvasLayout( + createEmptyResourceCanvasLayout(projectId, mode), + resourcesRef.current, + ).layout; + persistedLayoutRef.current = initialFallback; + applyLayout(initialFallback); + setNotice(''); + setSaving(false); + + const invoke = window.__TAURI__?.core?.invoke; + if (!invoke) { + initializedScopeEpochRef.current = epoch; + pumpWritesRef.current(); return undefined; } @@ -134,41 +407,69 @@ export function useProjectResourceCanvasLayout({ { projectPath, mode }, ) .then((loaded) => { - if (cancelled || requestTokenRef.current !== token) { + if ( + cancelled || + scopeRef.current.epoch !== epoch || + !layoutMatchesScope(loaded, scope) + ) { return; } - const reconciled = reconcileResourceCanvasLayout(loaded, resources); persistedLayoutRef.current = loaded; - setLayout(reconciled.layout); - if (reconciled.changed) { - void persistLayout( - reconciled.layout, - reconciled.layout, - token, - '布局保存失败,已保留当前会话布局', - ); + initializedScopeEpochRef.current = epoch; + if ( + reconcileResourceCanvasLayout(loaded, resourcesRef.current).changed + ) { + enqueueResourceSyncRef.current(epoch); } + rebuildOptimisticLayout(epoch); + pumpWritesRef.current(); }) .catch(() => { - if (cancelled || requestTokenRef.current !== token) { + if (cancelled || scopeRef.current.epoch !== epoch) { return; } - persistedLayoutRef.current = fallback; - setLayout(fallback); + persistedLayoutRef.current = initialFallback; + initializedScopeEpochRef.current = epoch; + rebuildOptimisticLayout(epoch); setNotice('布局读取失败,已使用当前会话布局'); + pumpWritesRef.current(); }); return () => { cancelled = true; }; }, [ - fallback, + applyLayout, mode, - persistLayout, + projectId, projectPath, - resourceSignature, - resources, + rebuildOptimisticLayout, + scopeKey, ]); + useEffect(() => { + const scope = scopeRef.current; + if ( + scope.key !== scopeKey || + initializedScopeEpochRef.current !== scope.epoch + ) { + return; + } + const reconciledCurrent = reconcileResourceCanvasLayout( + layoutRef.current, + resourcesRef.current, + ); + applyLayout(reconciledCurrent.layout); + if ( + window.__TAURI__?.core?.invoke && + reconcileResourceCanvasLayout( + persistedLayoutRef.current, + resourcesRef.current, + ).changed + ) { + enqueueResourceSyncRef.current(scope.epoch); + } + }, [applyLayout, resourceSignature, scopeKey]); + useEffect(() => { if (!notice) { return undefined; @@ -184,33 +485,41 @@ export function useProjectResourceCanvasLayout({ x: number, y: number, ) => { - if (saving) { + const scope = scopeRef.current; + if (scope.key !== scopeKey) { return; } - const rollback = reconcileResourceCanvasLayout( - persistedLayoutRef.current, - resources, - ).layout; - const candidate = moveResourceCanvasPosition( - currentLayout, + const intent: ManualLayoutWriteIntent = { + kind: 'manual', + scopeEpoch: scope.epoch, resourceId, section, x, y, + }; + writeQueueRef.current.push(intent); + applyLayout( + moveResourceCanvasPosition( + layoutRef.current, + resourceId, + section, + x, + y, + ), ); - setLayout(candidate); - void persistLayout( - candidate, - rollback, - requestTokenRef.current, - '布局保存失败,已恢复上次布局', - ); + setSaving(true); + pumpWritesRef.current(); }, - [currentLayout, persistLayout, resources, saving], + [applyLayout, scopeKey], ); + const scopeMatches = + scopeRef.current.key === scopeKey && + layout.projectId === projectId && + layout.mode === mode; + return { - layout: currentLayout, + layout: scopeMatches ? layout : fallback, notice, saving, commitPosition, diff --git a/apps/ai-game-creator-shell/tests/appSurface/project-development.suite.ts b/apps/ai-game-creator-shell/tests/appSurface/project-development.suite.ts index 74a2dfd00..1486f042d 100644 --- a/apps/ai-game-creator-shell/tests/appSurface/project-development.suite.ts +++ b/apps/ai-game-creator-shell/tests/appSurface/project-development.suite.ts @@ -2,6 +2,16 @@ import type { ProjectResourceCanvasLayout, ProjectResourceCanvasPosition, } from '../../../../packages/shared/src/contracts/gameCreationApp'; +import { consumeInitialGameChatMessage } from '../../src/App'; +import type { + AgentRuntimeEventRecord, + AgentRuntimeState, +} from '../../src/app/types'; +import { + collectGameChatResultImages, + collectGameChatRuntimeEvents, + SupervisorChatOnlyView, +} from '../../src/features/project-workspace/SupervisorChatOnlyView'; import { act, agentRuntimeUserInputRequest, @@ -31,16 +41,6 @@ import { waitFor, within, } from './harness'; -import type { - AgentRuntimeEventRecord, - AgentRuntimeState, -} from '../../src/app/types'; -import { consumeInitialGameChatMessage } from '../../src/App'; -import { - collectGameChatResultImages, - collectGameChatRuntimeEvents, - SupervisorChatOnlyView, -} from '../../src/features/project-workspace/SupervisorChatOnlyView'; function gameChatRuntimeEvent({ agentId = 'project-supervisor', @@ -579,6 +579,120 @@ export function registerProjectWorkbenchFoundationTests() { ).toHaveLength(1); }); + it('drops manual writes queued behind a CAS conflict so stale coordinates cannot overwrite the winner', async () => { + const projectId = 'workbench-layout-queued-conflict'; + const projectPath = '/tmp/workbench-layout-queued-conflict'; + const resourceId = 'agent-result:design-foundation:queued-conflict-run'; + const manifest = createGameCreationAppManifest( + projectId, + '布局排队冲突测试', + ); + const latestLayout: ProjectResourceCanvasLayout = { + schemaVersion: 'game-creator-resource-layout.v1', + projectId, + mode: 'dependency', + revision: 2, + positions: [ + { + resourceId, + section: 'document', + x: 400, + y: 80, + manuallyPlaced: true, + }, + ], + updatedAt: 200, + }; + let resolveFirstUpdate: + | ((result: { + status: 'conflict'; + layout: ProjectResourceCanvasLayout; + }) => void) + | null = null; + let updateCalls = 0; + const invoke = vi.fn(async (command: string) => { + if (command === 'read_local_project_resource_canvas_layout') { + return { + ...structuredClone(latestLayout), + revision: 1, + positions: [{ ...latestLayout.positions[0], x: 20, y: 30 }], + }; + } + if (command === 'update_local_project_resource_canvas_layout') { + updateCalls += 1; + return await new Promise((resolve) => { + resolveFirstUpdate = resolve; + }); + } + throw new Error(`unexpected invoke ${command}`); + }); + window.__TAURI__ = { core: { invoke } }; + + render( + React.createElement(ProjectDevelopmentView, { + projectName: '布局排队冲突测试', + projectPath, + manifest, + attachments: [], + agentResults: [ + { + agentId: 'design-foundation', + runId: 'queued-conflict-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'); + }); + + for (const [pointerId, startX, endX] of [ + [31, 0, 100], + [32, 100, 220], + ] as const) { + fireEvent.pointerDown(card!, { + pointerId, + button: 0, + clientX: startX, + clientY: 0, + }); + fireEvent.pointerMove(card!, { + pointerId, + clientX: endX, + clientY: 20, + }); + fireEvent.pointerUp(card!, { + pointerId, + clientX: endX, + clientY: 20, + }); + } + expect(updateCalls).toBe(1); + + await act(async () => { + resolveFirstUpdate?.({ + status: 'conflict', + layout: structuredClone(latestLayout), + }); + await Promise.resolve(); + }); + + expect( + await screen.findByText('布局已在其他窗口更新,请重新拖动'), + ).not.toBeNull(); + await waitFor(() => { + expect(card?.getAttribute('style')).toContain('--resource-x: 400px'); + expect(card?.getAttribute('style')).toContain('--resource-y: 80px'); + }); + expect(updateCalls).toBe(1); + }); + it('keeps newly reconciled resources visible when their automatic layout save fails', async () => { const projectId = 'workbench-layout-save-failure'; const manifest = createGameCreationAppManifest(projectId, '布局失败测试'); diff --git a/apps/ai-game-creator-shell/tests/resourceCanvasLayoutModel.test.ts b/apps/ai-game-creator-shell/tests/resourceCanvasLayoutModel.test.ts index 11ad51d96..0324b4cf4 100644 --- a/apps/ai-game-creator-shell/tests/resourceCanvasLayoutModel.test.ts +++ b/apps/ai-game-creator-shell/tests/resourceCanvasLayoutModel.test.ts @@ -132,4 +132,30 @@ describe('resource canvas layout model', () => { }, ]); }); + + it.each(['dependency', 'type'] as const)( + 'reconciles 4096 resources in %s mode within the bounded layout budget', + (mode) => { + const resources = Array.from({ length: 4096 }, (_, index) => ({ + ...resource(`resource-${index.toString().padStart(4, '0')}`, 'art'), + dependencyDepth: index % 64, + mediaType: `image/type-${index % 16}`, + })); + + const startedAt = performance.now(); + const layout = reconcileResourceCanvasLayout( + createEmptyResourceCanvasLayout('project-performance', mode), + resources, + ).layout; + const elapsedMs = performance.now() - startedAt; + + expect(layout.positions).toHaveLength(4096); + expect( + new Set( + layout.positions.map((position) => `${position.x}:${position.y}`), + ).size, + ).toBe(4096); + expect(elapsedMs).toBeLessThan(2000); + }, + ); }); diff --git a/apps/ai-game-creator-shell/tests/useProjectResourceCanvasLayout.test.ts b/apps/ai-game-creator-shell/tests/useProjectResourceCanvasLayout.test.ts new file mode 100644 index 000000000..6cec13fda --- /dev/null +++ b/apps/ai-game-creator-shell/tests/useProjectResourceCanvasLayout.test.ts @@ -0,0 +1,453 @@ +// @vitest-environment jsdom + +import { act, cleanup, renderHook, waitFor } from '@testing-library/react'; +import { afterEach, describe, expect, it, vi } from 'vitest'; + +import type { + ProjectResourceCanvasLayout, + ProjectResourceCanvasLayoutMode, + ProjectResourceCanvasPosition, +} from '../../../packages/shared/src/contracts/gameCreationApp'; +import type { ResourceCanvasItem } from '../src/view/project-development/resourceCanvasLayoutModel'; +import { useProjectResourceCanvasLayout } from '../src/view/project-development/useProjectResourceCanvasLayout'; + +const projectId = 'layout-hook-project'; +const projectPath = '/tmp/layout-hook-project'; + +function resource(id: string): ResourceCanvasItem { + return { + id, + category: 'document', + label: id, + mediaType: 'text/markdown', + dependencyDepth: 0, + }; +} + +function persistedLayout( + mode: ProjectResourceCanvasLayoutMode, + revision: number, + positions: ProjectResourceCanvasPosition[], +): ProjectResourceCanvasLayout { + return { + schemaVersion: 'game-creator-resource-layout.v1', + projectId, + mode, + revision, + positions, + updatedAt: revision * 100, + }; +} + +function position( + resourceId: string, + x: number, + y: number, +): ProjectResourceCanvasPosition { + return { + resourceId, + section: 'document', + x, + y, + manuallyPlaced: true, + }; +} + +afterEach(() => { + cleanup(); + window.__TAURI__ = undefined; +}); + +describe('useProjectResourceCanvasLayout', () => { + it('uses the latest resource snapshot when the initial scope read resolves', async () => { + const resourceA = resource('resource-a'); + const resourceB = resource('resource-b'); + let resolveRead: ((layout: ProjectResourceCanvasLayout) => void) | null = + null; + const updates: Array<{ + expectedRevision: number; + positions: ProjectResourceCanvasPosition[]; + }> = []; + const invoke = vi.fn( + async (command: string, args?: Record) => { + if (command === 'read_local_project_resource_canvas_layout') { + return await new Promise((resolve) => { + resolveRead = resolve; + }); + } + if (command === 'update_local_project_resource_canvas_layout') { + const input = args as { + expectedRevision: number; + positions: ProjectResourceCanvasPosition[]; + }; + updates.push(structuredClone(input)); + return { + status: 'updated', + layout: persistedLayout( + 'dependency', + input.expectedRevision + 1, + structuredClone(input.positions), + ), + }; + } + throw new Error(`unexpected invoke ${command}`); + }, + ); + window.__TAURI__ = { core: { invoke } }; + const { result, rerender } = renderHook( + ({ resources }: { resources: ResourceCanvasItem[] }) => + useProjectResourceCanvasLayout({ + projectPath, + projectId, + mode: 'dependency', + resources, + }), + { initialProps: { resources: [resourceA] } }, + ); + await waitFor(() => expect(resolveRead).not.toBeNull()); + + rerender({ resources: [resourceA, resourceB] }); + await act(async () => { + resolveRead?.( + persistedLayout('dependency', 1, [position('resource-a', 10, 20)]), + ); + await Promise.resolve(); + }); + + await waitFor(() => expect(updates).toHaveLength(1)); + expect( + result.current.layout.positions.map(({ resourceId }) => resourceId), + ).toEqual(expect.arrayContaining(['resource-a', 'resource-b'])); + expect(updates[0]?.expectedRevision).toBe(1); + expect( + updates[0]?.positions.some( + ({ resourceId }) => resourceId === 'resource-b', + ), + ).toBe(true); + expect( + invoke.mock.calls.filter( + ([command]) => command === 'read_local_project_resource_canvas_layout', + ), + ).toHaveLength(1); + }); + + it('keeps an in-flight manual CAS alive and serializes resource sync behind its revision', async () => { + const resourceA = resource('resource-a'); + const resourceB = resource('resource-b'); + const updates: Array<{ + expectedRevision: number; + positions: ProjectResourceCanvasPosition[]; + }> = []; + let resolveFirstUpdate: + | ((result: { + status: 'updated'; + layout: ProjectResourceCanvasLayout; + }) => void) + | null = null; + const invoke = vi.fn( + async (command: string, args?: Record) => { + if (command === 'read_local_project_resource_canvas_layout') { + return persistedLayout('dependency', 1, [ + position('resource-a', 10, 20), + ]); + } + if (command === 'update_local_project_resource_canvas_layout') { + const input = args as { + expectedRevision: number; + positions: ProjectResourceCanvasPosition[]; + }; + updates.push(structuredClone(input)); + if (updates.length === 1) { + return await new Promise((resolve) => { + resolveFirstUpdate = resolve; + }); + } + return { + status: 'updated', + layout: persistedLayout( + 'dependency', + input.expectedRevision + 1, + structuredClone(input.positions), + ), + }; + } + throw new Error(`unexpected invoke ${command}`); + }, + ); + window.__TAURI__ = { core: { invoke } }; + const { result, rerender } = renderHook( + ({ resources }: { resources: ResourceCanvasItem[] }) => + useProjectResourceCanvasLayout({ + projectPath, + projectId, + mode: 'dependency', + resources, + }), + { initialProps: { resources: [resourceA] } }, + ); + await waitFor(() => expect(result.current.layout.positions[0]?.x).toBe(10)); + + act(() => result.current.commitPosition('resource-a', 'document', 100, 30)); + await waitFor(() => expect(updates).toHaveLength(1)); + rerender({ resources: [resourceA, resourceB] }); + await waitFor(() => + expect( + result.current.layout.positions.some( + ({ resourceId }) => resourceId === 'resource-b', + ), + ).toBe(true), + ); + expect(updates).toHaveLength(1); + + await act(async () => { + resolveFirstUpdate?.({ + status: 'updated', + layout: persistedLayout( + 'dependency', + 2, + structuredClone(updates[0]!.positions), + ), + }); + await Promise.resolve(); + }); + + await waitFor(() => expect(updates).toHaveLength(2)); + expect(updates.map(({ expectedRevision }) => expectedRevision)).toEqual([ + 1, 2, + ]); + expect( + updates[1]?.positions.some( + ({ resourceId }) => resourceId === 'resource-b', + ), + ).toBe(true); + }); + + it('drops manual intents queued behind a CAS conflict', async () => { + const resourceA = resource('resource-a'); + let updateCalls = 0; + let resolveUpdate: + | ((result: { + status: 'conflict'; + layout: ProjectResourceCanvasLayout; + }) => void) + | null = null; + const invoke = vi.fn(async (command: string) => { + if (command === 'read_local_project_resource_canvas_layout') { + return persistedLayout('dependency', 1, [ + position('resource-a', 10, 20), + ]); + } + if (command === 'update_local_project_resource_canvas_layout') { + updateCalls += 1; + return await new Promise((resolve) => { + resolveUpdate = resolve; + }); + } + throw new Error(`unexpected invoke ${command}`); + }); + window.__TAURI__ = { core: { invoke } }; + const { result } = renderHook(() => + useProjectResourceCanvasLayout({ + projectPath, + projectId, + mode: 'dependency', + resources: [resourceA], + }), + ); + await waitFor(() => expect(result.current.layout.positions[0]?.x).toBe(10)); + + act(() => { + result.current.commitPosition('resource-a', 'document', 100, 30); + result.current.commitPosition('resource-a', 'document', 220, 40); + }); + await waitFor(() => expect(updateCalls).toBe(1)); + await act(async () => { + resolveUpdate?.({ + status: 'conflict', + layout: persistedLayout('dependency', 2, [ + position('resource-a', 400, 80), + ]), + }); + await Promise.resolve(); + }); + + await waitFor(() => { + expect(result.current.layout.positions[0]).toMatchObject({ + x: 400, + y: 80, + }); + expect(result.current.saving).toBe(false); + }); + expect(result.current.notice).toBe('布局已在其他窗口更新,请重新拖动'); + expect(updateCalls).toBe(1); + }); + + it('ignores a late read from the previous mode', async () => { + const resourceA = resource('resource-a'); + const readResolvers = new Map< + ProjectResourceCanvasLayoutMode, + (layout: ProjectResourceCanvasLayout) => void + >(); + const invoke = vi.fn( + async (command: string, args?: Record) => { + if (command !== 'read_local_project_resource_canvas_layout') { + throw new Error(`unexpected invoke ${command}`); + } + const mode = args?.mode as ProjectResourceCanvasLayoutMode; + return await new Promise((resolve) => { + readResolvers.set(mode, resolve); + }); + }, + ); + window.__TAURI__ = { core: { invoke } }; + const { result, rerender } = renderHook( + ({ mode }: { mode: ProjectResourceCanvasLayoutMode }) => + useProjectResourceCanvasLayout({ + projectPath, + projectId, + mode, + resources: [resourceA], + }), + { initialProps: { mode: 'dependency' as const } }, + ); + await waitFor(() => expect(readResolvers.has('dependency')).toBe(true)); + rerender({ mode: 'type' }); + await waitFor(() => expect(readResolvers.has('type')).toBe(true)); + + await act(async () => { + readResolvers.get('type')?.( + persistedLayout('type', 5, [position('resource-a', 200, 50)]), + ); + await Promise.resolve(); + }); + await waitFor(() => expect(result.current.layout.mode).toBe('type')); + await act(async () => { + readResolvers.get('dependency')?.( + persistedLayout('dependency', 4, [position('resource-a', 10, 20)]), + ); + await Promise.resolve(); + }); + + expect(result.current.layout.mode).toBe('type'); + expect(result.current.layout.positions[0]).toMatchObject({ x: 200, y: 50 }); + }); + + it('waits for an old-scope write to settle before pumping the new scope queue', async () => { + const resourceA = resource('resource-a'); + const updates: Array<{ + mode: ProjectResourceCanvasLayoutMode; + expectedRevision: number; + }> = []; + let resolveDependencyUpdate: + | ((result: { + status: 'updated'; + layout: ProjectResourceCanvasLayout; + }) => void) + | null = null; + const invoke = vi.fn( + async (command: string, args?: Record) => { + const mode = args?.mode as ProjectResourceCanvasLayoutMode; + if (command === 'read_local_project_resource_canvas_layout') { + return persistedLayout(mode, mode === 'dependency' ? 1 : 5, [ + position('resource-a', mode === 'dependency' ? 10 : 200, 20), + ]); + } + if (command === 'update_local_project_resource_canvas_layout') { + const expectedRevision = args?.expectedRevision as number; + updates.push({ mode, expectedRevision }); + if (mode === 'dependency') { + return await new Promise((resolve) => { + resolveDependencyUpdate = resolve; + }); + } + return { + status: 'updated', + layout: persistedLayout(mode, expectedRevision + 1, [ + position('resource-a', 300, 40), + ]), + }; + } + throw new Error(`unexpected invoke ${command}`); + }, + ); + window.__TAURI__ = { core: { invoke } }; + const { result, rerender } = renderHook( + ({ mode }: { mode: ProjectResourceCanvasLayoutMode }) => + useProjectResourceCanvasLayout({ + projectPath, + projectId, + mode, + resources: [resourceA], + }), + { initialProps: { mode: 'dependency' as const } }, + ); + await waitFor(() => expect(result.current.layout.positions[0]?.x).toBe(10)); + + act(() => result.current.commitPosition('resource-a', 'document', 100, 30)); + await waitFor(() => expect(updates).toHaveLength(1)); + rerender({ mode: 'type' }); + await waitFor(() => expect(result.current.layout.mode).toBe('type')); + act(() => result.current.commitPosition('resource-a', 'document', 300, 40)); + expect(updates).toEqual([{ mode: 'dependency', expectedRevision: 1 }]); + + await act(async () => { + resolveDependencyUpdate?.({ + status: 'updated', + layout: persistedLayout('dependency', 2, [ + position('resource-a', 100, 30), + ]), + }); + await Promise.resolve(); + }); + + await waitFor(() => expect(updates).toHaveLength(2)); + expect(updates[1]).toEqual({ mode: 'type', expectedRevision: 5 }); + await waitFor(() => expect(result.current.saving).toBe(false)); + expect(result.current.layout.mode).toBe('type'); + expect(result.current.layout.positions[0]?.x).toBe(300); + }); + + it('bounds automatic resource reconciliation retries under repeated CAS conflicts', async () => { + const resourceA = resource('resource-a'); + const resourceB = resource('resource-b'); + const expectedRevisions: number[] = []; + const invoke = vi.fn( + async (command: string, args?: Record) => { + if (command === 'read_local_project_resource_canvas_layout') { + return persistedLayout('dependency', 1, [ + position('resource-a', 10, 20), + ]); + } + if (command === 'update_local_project_resource_canvas_layout') { + const expectedRevision = args?.expectedRevision as number; + expectedRevisions.push(expectedRevision); + return { + status: 'conflict', + layout: persistedLayout('dependency', expectedRevision + 1, [ + position('resource-a', 10, 20), + ]), + }; + } + throw new Error(`unexpected invoke ${command}`); + }, + ); + window.__TAURI__ = { core: { invoke } }; + const { result } = renderHook(() => + useProjectResourceCanvasLayout({ + projectPath, + projectId, + mode: 'dependency', + resources: [resourceA, resourceB], + }), + ); + + await waitFor(() => expect(result.current.saving).toBe(false)); + expect(expectedRevisions).toEqual([1, 2, 3]); + expect(result.current.notice).toBe('布局已在其他窗口更新,请重新拖动'); + expect( + result.current.layout.positions.some( + ({ resourceId }) => resourceId === 'resource-b', + ), + ).toBe(true); + }); +}); diff --git a/docs/prd/【AI游戏创作】项目开发工作台PRD-2026-07-20.md b/docs/prd/【AI游戏创作】项目开发工作台PRD-2026-07-20.md index 073c43d7b..62d133352 100644 --- a/docs/prd/【AI游戏创作】项目开发工作台PRD-2026-07-20.md +++ b/docs/prd/【AI游戏创作】项目开发工作台PRD-2026-07-20.md @@ -239,8 +239,9 @@ type UpdateProjectResourceCanvasLayoutResult = - 搜索或筛选只隐藏卡片,不删除、压缩或重排其坐标;清空搜索后恢复原位置。 - 窗口尺寸变化只改变可视范围和分区滚动边界,不回写、裁切或缩放持久坐标。当前客户端继续以 `1280×800` 横屏合同验收。 - 打开项目、切换 mode 或当前 mode 首次出现新资源时执行“读取 -> 协调 -> 必要时 CAS 写入”;项目或 mode 已切换后返回的旧异步结果必须丢弃。 +- 同一 `projectPath + projectId + mode` 的首次读取与资源集合协调必须分开:资源集合变化不得取消已经发出的读取或保存。单窗口内全部手动拖动和资源自动协调写入使用同一 FIFO,任一时刻最多一个 CAS 在途,后一笔必须使用前一笔成功返回的 revision,不能用“最后请求获胜”跳过中间 CAS。 - 用户拖动结束后先乐观更新,再立即提交一次 CAS。成功后以返回布局更新 revision;普通写入失败时恢复最近可信持久布局并提示“布局保存失败,已恢复上次布局”。 -- CAS 冲突时直接载入返回的最新布局并提示“布局已在其他窗口更新,请重新拖动”,不得自动重放本地旧坐标或静默覆盖另一窗口结果。 +- CAS 冲突时直接载入返回的最新布局并提示“布局已在其他窗口更新,请重新拖动”,丢弃所有基于冲突前快照排队的手动拖动,不得自动重放本地旧坐标或静默覆盖另一窗口结果。资源自动协调可以基于冲突返回的新 revision 有界重试,单次资源签名最多追加 `2` 次,持续跨窗口写入时不得无限自旋。 - 缺少 Tauri bridge 的浏览器开发态可以保留当前会话内布局用于界面测试,但不得宣称已经持久保存。 ### 5.3 资源类型与替换兼容性(P1) diff --git a/docs/project-memory/shared-memory/decision-log.md b/docs/project-memory/shared-memory/decision-log.md index 00b244d83..63cf6a2cc 100644 --- a/docs/project-memory/shared-memory/decision-log.md +++ b/docs/project-memory/shared-memory/decision-log.md @@ -20,11 +20,12 @@ - 背景:项目开发工作台当前只在 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。冲突返回最新完整布局且零写入,前端载入最新值并要求重新拖动;普通失败恢复最近可信布局。写入复用项目安全路径、链接校验、容量上限、恢复副本与原子替换,损坏或身份冲突不能被空布局覆盖。 +- 并发与失败:Tauri 用 `read_local_project_resource_canvas_layout` 和 `update_local_project_resource_canvas_layout` 暴露读写,以 `projectId + mode + expectedRevision` 在专用跨窗口布局锁内做 CAS。前端以 project/path/mode epoch 丢弃旧 scope 迟到响应,资源变化不得取消首读或在途写;同一窗口的手动拖动与资源协调进入单写者 FIFO,后一笔只使用前一笔权威响应的 revision。冲突返回最新完整布局且零写入,前端载入最新值、丢弃基于旧快照排队的手动拖动并要求重新操作;资源协调最多追加两次冲突重试,普通失败恢复最近可信布局。写入复用项目安全路径、链接校验、容量上限、恢复副本与原子替换,损坏或身份冲突不能被空布局覆盖。 - 业务边界:布局是本地工作台 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-29 game-chat 在创建 WebView 前确定初始 URL - 背景:`agc:game-chat` 曾在 Tauri `.setup()` 中读取仍可能是 `about:blank` 或配置期地址的 `client.url()`,再导航到 game-chat;Windows WebView2 首航被覆盖后只剩黑边白块或全白原生窗口,刷新无法恢复。 diff --git a/docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md b/docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md index f0a25138f..27ba73903 100644 --- a/docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md +++ b/docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md @@ -337,14 +337,14 @@ game-project/ - 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 做二维拖动,普通点击、搜索、筛选和唯一资源详情浮层语义保持不变。 +- 前端从当前项目开发大组件中拆出纯布局模型与持久 Hook。默认布局、碰撞检查、资源增删协调和 section 边界由纯模型负责;读取、异步身份、CAS、错误回滚和冲突载入由 Hook 负责。Hook 以 `projectPath + projectId + mode` epoch 隔离异步结果,资源变化不取消首读或在途保存;单窗口写入经同一 FIFO 串行提交,每笔都使用最近一次成功 / 冲突响应的权威 revision。视图使用 Pointer Events 做二维拖动,保存中仍允许继续拖动并排队,普通点击、搜索、筛选和唯一资源详情浮层语义保持不变。 - 新资源只在第一次进入某个 mode 时计算默认不重叠位置;全部现存坐标保持不变。搜索、筛选、窗口 resize 和 mode 切换不得重排或回写已有坐标,窄视图通过 section 画布范围与滚动访问,不裁切持久坐标。 -- 普通保存失败恢复最近可信持久布局;CAS 冲突载入对方最新布局并要求用户重新拖动,不自动重放旧坐标。损坏、未知 schema、身份冲突、超限与链接文件失败关闭,不能用空布局覆盖原文件。 +- 普通保存失败恢复最近可信持久布局;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 并发双写、资源增删与分区漂移、保存失败保留新资源、成功恢复和冲突载入最新布局。 +2026-07-30 前端并发与性能加固状态:首读、资源更新和拖动保存已拆成 scope epoch + 单写者 FIFO;定向 Hook 测试覆盖首读期间资源变化、在途手动 CAS 后资源协调、冲突清除排队拖动、旧 mode 迟到读取 / 写入、新 scope 队列唤醒和持续冲突有界停止。默认布局用 section 分组与二维占用索引替代逐 slot 全量扫描,dependency 使用按列单调游标,type 使用单调 slot 游标;`4096` 项双模式性能回归纳入前端测试,避免恢复到接近 `O(N³)` 的主线程阻塞实现。 ## 分阶段实施