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 f250142dc..dd5e99492 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 @@ -80,6 +80,7 @@ type ResourceCardDrag = { type ProjectResource = { id: string; category: ResourceCategory; + subtype: string; label: string; path: string; mediaType: string; @@ -303,6 +304,7 @@ function resourcesFromProject( resources.push({ id: `task:${task.id}:${path}`, category, + subtype: 'task-artifact', label: fileName(path), path, mediaType: category === 'document' ? '项目文档' : '项目产物', @@ -324,6 +326,7 @@ function resourcesFromProject( resources.push({ id: `asset:${asset.id}`, category: categoryFromResource(asset.localPath, asset.mediaType), + subtype: asset.kind, label: `${fileName(asset.localPath)}${ isPendingUiPrototype ? '(待视觉验收)' : '' }`, @@ -353,6 +356,7 @@ function resourcesFromProject( attachment.localPath, attachment.mediaType, ), + subtype: 'attachment', label: attachment.fileName, path: attachment.localPath, mediaType: attachment.mediaType || '未知媒体类型', @@ -367,6 +371,7 @@ function resourcesFromProject( resources.push({ id: `agent-result:${result.agentId}:${result.runId}`, category: 'document', + subtype: 'agent-result', label: result.title, path: `专业 Agent 文本回执 · ${result.label}`, mediaType: 'Agent 历史文本回执', 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 bec57a56f..d488c68f9 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 @@ -25,6 +25,7 @@ const sectionOrder: ProjectResourceCanvasSection[] = [ export type ResourceCanvasItem = { id: string; category: ProjectResourceCanvasSection; + subtype: string; label: string; mediaType: string; dependencyDepth: number; @@ -175,6 +176,10 @@ function defaultTypePosition( } } +function compareStableIdentifier(left: string, right: string) { + return left < right ? -1 : left > right ? 1 : 0; +} + function compareResources( mode: ProjectResourceCanvasLayoutMode, left: ResourceCanvasItem, @@ -184,13 +189,14 @@ function compareResources( return ( left.dependencyDepth - right.dependencyDepth || left.label.localeCompare(right.label, 'zh-CN') || - left.id.localeCompare(right.id) + compareStableIdentifier(left.id, right.id) ); } return ( - left.mediaType.localeCompare(right.mediaType) || + compareStableIdentifier(left.subtype, right.subtype) || + compareStableIdentifier(left.mediaType, right.mediaType) || left.label.localeCompare(right.label, 'zh-CN') || - left.id.localeCompare(right.id) + compareStableIdentifier(left.id, right.id) ); } 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 573a0670b..bc636d454 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 @@ -57,12 +57,13 @@ function createScopeKey( return JSON.stringify([projectPath, projectId, mode]); } -function createResourceSignature(resources: ResourceCanvasItem[]) { +export function createResourceSignature(resources: ResourceCanvasItem[]) { return resources .map((resource) => JSON.stringify([ resource.id, resource.category, + resource.subtype, resource.label, resource.dependencyDepth, resource.mediaType, 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 1486f042d..932dab81c 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 @@ -12,6 +12,10 @@ import { collectGameChatRuntimeEvents, SupervisorChatOnlyView, } from '../../src/features/project-workspace/SupervisorChatOnlyView'; +import { + RESOURCE_CANVAS_CARD_WIDTH, + RESOURCE_CANVAS_COLUMN_GAP, +} from '../../src/view/project-development/resourceCanvasLayoutModel'; import { act, agentRuntimeUserInputRequest, @@ -693,6 +697,104 @@ export function registerProjectWorkbenchFoundationTests() { expect(updateCalls).toBe(1); }); + it('maps manifest asset kinds into stable type layout ordering', async () => { + const projectId = 'workbench-layout-asset-subtypes'; + const manifest = createGameCreationAppManifest( + projectId, + '资源子类型排序测试', + ); + manifest.assets.push( + { + id: 'ui-prototype-first-by-label', + kind: 'ui-prototype', + mediaType: 'image/png', + localPath: 'assets/a-ui-prototype.png', + source: { kind: 'generated', taskId: 'design-foundation' }, + }, + { + id: 'art-spritesheet-last-by-label', + kind: 'art-spritesheet', + mediaType: 'image/png', + localPath: 'assets/z-art-spritesheet.png', + source: { kind: 'generated', taskId: 'art-asset-plan' }, + }, + ); + const updates: Array<{ + mode: 'dependency' | 'type'; + positions: ProjectResourceCanvasPosition[]; + }> = []; + const invoke = vi.fn( + async (command: string, args?: Record) => { + const mode = args?.mode as 'dependency' | 'type'; + if (command === 'read_local_project_resource_canvas_layout') { + return { + schemaVersion: 'game-creator-resource-layout.v1', + projectId, + mode, + revision: 0, + positions: [], + updatedAt: 0, + }; + } + if (command === 'update_local_project_resource_canvas_layout') { + const positions = structuredClone( + args?.positions as ProjectResourceCanvasPosition[], + ); + updates.push({ mode, positions }); + return { + status: 'updated', + layout: { + schemaVersion: 'game-creator-resource-layout.v1', + projectId, + mode, + revision: 1, + positions, + updatedAt: 1, + }, + }; + } + throw new Error(`unexpected invoke ${command}`); + }, + ); + window.__TAURI__ = { core: { invoke } }; + + render( + React.createElement(ProjectDevelopmentView, { + projectName: '资源子类型排序测试', + projectPath: '/tmp/workbench-layout-asset-subtypes', + manifest, + attachments: [], + agentResults: [], + supervisor: React.createElement('div', null, '项目总控'), + }), + ); + await waitFor(() => + expect(updates.some(({ mode }) => mode === 'dependency')).toBe(true), + ); + fireEvent.click(screen.getByRole('button', { name: '按类型' })); + await waitFor(() => + expect(updates.some(({ mode }) => mode === 'type')).toBe(true), + ); + + const typePositions = updates.find( + ({ mode }) => mode === 'type', + )?.positions; + expect(typePositions).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + resourceId: 'asset:art-spritesheet-last-by-label', + x: 0, + y: 0, + }), + expect.objectContaining({ + resourceId: 'asset:ui-prototype-first-by-label', + x: RESOURCE_CANVAS_CARD_WIDTH + RESOURCE_CANVAS_COLUMN_GAP, + y: 0, + }), + ]), + ); + }); + 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 0324b4cf4..768b3f812 100644 --- a/apps/ai-game-creator-shell/tests/resourceCanvasLayoutModel.test.ts +++ b/apps/ai-game-creator-shell/tests/resourceCanvasLayoutModel.test.ts @@ -19,6 +19,7 @@ function resource( return { id, category, + subtype: 'default', dependencyDepth, label: id, mediaType: category === 'art' ? 'image/png' : 'text/markdown', @@ -133,6 +134,52 @@ describe('resource canvas layout model', () => { ]); }); + it('sorts type defaults by subtype before media type and label with an id fallback', () => { + const typeLayout = reconcileResourceCanvasLayout( + createEmptyResourceCanvasLayout('project-type-order', 'type'), + [ + { + ...resource('ui-prototype', 'art'), + subtype: 'ui-prototype', + mediaType: 'image/png', + label: '甲界面', + }, + { + ...resource('art-spritesheet-b', 'art'), + subtype: 'art-spritesheet', + mediaType: 'image/png', + label: '乙图集', + }, + { + ...resource('art-spritesheet-a', 'art'), + subtype: 'art-spritesheet', + mediaType: 'image/png', + label: '乙图集', + }, + ], + ).layout; + + expect( + typeLayout.positions.map(({ resourceId, x, y }) => ({ + resourceId, + x, + y, + })), + ).toEqual([ + { resourceId: 'art-spritesheet-a', x: 0, y: 0 }, + { + resourceId: 'art-spritesheet-b', + x: RESOURCE_CANVAS_CARD_WIDTH + RESOURCE_CANVAS_COLUMN_GAP, + y: 0, + }, + { + resourceId: 'ui-prototype', + x: 2 * (RESOURCE_CANVAS_CARD_WIDTH + RESOURCE_CANVAS_COLUMN_GAP), + y: 0, + }, + ]); + }); + it.each(['dependency', 'type'] as const)( 'reconciles 4096 resources in %s mode within the bounded layout budget', (mode) => { diff --git a/apps/ai-game-creator-shell/tests/useProjectResourceCanvasLayout.test.ts b/apps/ai-game-creator-shell/tests/useProjectResourceCanvasLayout.test.ts index cd78e38c1..610ff22c4 100644 --- a/apps/ai-game-creator-shell/tests/useProjectResourceCanvasLayout.test.ts +++ b/apps/ai-game-creator-shell/tests/useProjectResourceCanvasLayout.test.ts @@ -9,7 +9,10 @@ import type { ProjectResourceCanvasPosition, } from '../../../packages/shared/src/contracts/gameCreationApp'; import type { ResourceCanvasItem } from '../src/view/project-development/resourceCanvasLayoutModel'; -import { useProjectResourceCanvasLayout } from '../src/view/project-development/useProjectResourceCanvasLayout'; +import { + createResourceSignature, + useProjectResourceCanvasLayout, +} from '../src/view/project-development/useProjectResourceCanvasLayout'; const projectId = 'layout-hook-project'; const projectPath = '/tmp/layout-hook-project'; @@ -18,6 +21,7 @@ function resource(id: string): ResourceCanvasItem { return { id, category: 'document', + subtype: 'agent-result', label: id, mediaType: 'text/markdown', dependencyDepth: 0, @@ -59,6 +63,13 @@ afterEach(() => { }); describe('useProjectResourceCanvasLayout', () => { + it('includes subtype changes in the resource coordination signature', () => { + const original = resource('resource-a'); + expect(createResourceSignature([original])).not.toBe( + createResourceSignature([{ ...original, subtype: 'task-artifact' }]), + ); + }); + it('uses the latest resource snapshot when the initial scope read resolves', async () => { const resourceA = resource('resource-a'); const resourceB = resource('resource-b'); diff --git a/docs/prd/【AI游戏创作】项目开发工作台PRD-2026-07-20.md b/docs/prd/【AI游戏创作】项目开发工作台PRD-2026-07-20.md index 89dce9373..ea67b0a84 100644 --- a/docs/prd/【AI游戏创作】项目开发工作台PRD-2026-07-20.md +++ b/docs/prd/【AI游戏创作】项目开发工作台PRD-2026-07-20.md @@ -236,7 +236,7 @@ type UpdateProjectResourceCanvasLayoutResult = - 资源卡改用 Pointer Events 驱动二维拖动;超过统一移动阈值后才进入拖动态,普通点击仍打开唯一资源详情浮层,`pointercancel` 恢复拖动前位置。 - 资源只能在原 `section` 内拖动。不同 section 之间既不能通过指针拖入,也不能通过持久 payload 改变当前资源的前端分类事实。 -- dependency 默认布局按 `dependencyDepth` 形成横向层级,同层资源纵向寻找第一个不重叠位置;type 默认布局按资源子类型、媒体类型和名称的稳定顺序,在分区内从左到右、从上到下寻找第一个空位。卡片尺寸、间距和拖动阈值必须由单一前端布局模型常量维护。 +- dependency 默认布局按 `dependencyDepth` 形成横向层级,同层资源纵向寻找第一个不重叠位置;type 默认布局固定按“资源子类型 -> 媒体类型 -> 名称 -> 资源 ID”稳定排序,在分区内从左到右、从上到下寻找第一个空位。布局模型的 `subtype` 必填:manifest 资产使用 `asset.kind`,任务产物、导入附件与 Agent 文本成果分别使用稳定的 `task-artifact`、`attachment`、`agent-result`,不得以缺失值或显示文案兜底;资源协调签名必须包含 subtype。卡片尺寸、间距和拖动阈值必须由单一前端布局模型常量维护。 - 资源集合变化时保留全部仍存在的坐标,只为新 ID 计算默认位置,并删除已确认失效的旧 ID;无论 `manuallyPlaced` 为何,已经写入的现存坐标都不得因重新排序、模式切换或新增资源被自动改写。 - 搜索或筛选只隐藏卡片,不删除、压缩或重排其坐标;清空搜索后恢复原位置。 - 窗口尺寸变化只改变可视范围和分区滚动边界,不回写、裁切或缩放持久坐标。当前客户端继续以 `1280×800` 横屏合同验收。 diff --git a/docs/project-memory/shared-memory/decision-log.md b/docs/project-memory/shared-memory/decision-log.md index cb79f5898..97cd3f0d3 100644 --- a/docs/project-memory/shared-memory/decision-log.md +++ b/docs/project-memory/shared-memory/decision-log.md @@ -19,7 +19,7 @@ ## 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 被自动覆盖。 +- 决策:dependency 与 type 两套布局分别保存为项目内 `.agent/workbench/resource-layouts/dependency.json` 和 `type.json`,统一使用 `game-creator-resource-layout.v1`。`x / y` 是 section 内容 CSS 像素,revision 从缺文件时的 `0` 单调递增;新资源首次默认放置,任何已有坐标不因排序、筛选、模式切换或 resize 被自动覆盖。type 默认布局固定按 `subtype -> mediaType -> label -> id` 排序,manifest 资产使用 `asset.kind`,任务产物、附件与 Agent 文本成果使用稳定 fallback,subtype 同时进入资源协调签名。 - 并发与失败:Tauri 用 `read_local_project_resource_canvas_layout` 和 `update_local_project_resource_canvas_layout` 暴露读写,以 `projectId + mode + expectedRevision` 在专用跨窗口布局锁内做 CAS。更新额外携带只读结果中的 `expectedProjectId` 身份栅栏,路径被重建为新项目时旧窗口在锁副作用前失败;revision 使用 checked increment,耗尽时保持原文件。锁入口文件持久存在,Unix 以 `flock` 文件描述符、Windows 以不共享句柄持有互斥;应用不按 mtime / PID 猜测 stale、不删除锁文件,进程退出由操作系统释放。更新在创建锁目录前只读验证 manifest,锁内复核 projectId;无效根保持零 workbench 副作用。前端以 project/path/mode epoch 丢弃旧 scope 迟到响应,资源变化不得取消首读或同 scope 在途写;同 scope 的手动拖动与资源协调进入单写者 FIFO,后一笔只使用前一笔权威响应的 revision。切换 scope 会释放旧活动槽,旧请求即使卡死也不能阻塞新 scope;同资源尚未发送的连续拖动折叠为最后坐标,已经在途的 CAS 不取消。冲突返回最新完整布局且零写入,前端载入最新值、丢弃基于旧快照排队的手动拖动并要求重新操作;资源协调最多追加两次冲突重试,普通失败恢复最近可信布局。写入复用项目安全路径、链接校验、容量上限、恢复副本与原子替换,损坏或身份冲突不能被空布局覆盖。 - 业务边界:布局是本地工作台 UI sidecar,不进入 manifest,不推进游戏项目 mutation revision,不使 Runtime verification 失效,不触发 Agent 权限,也不属于资产、Agent 产物、Git 或云端事实。本切片不包含关系线、资源替换、浮层位置、缩放 / 平移、搜索 / 筛选条件和当前 mode。 - 影响范围:`packages/shared` 与 Rust `shared-contracts` 的跨边界 DTO、AI 游戏创作 Tauri 项目持久层与命令、项目开发资源画布、定向 Rust / React 测试、工作台 PRD 和客户端实施计划。 diff --git a/docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md b/docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md index e6bdb9fd1..872bca9cc 100644 --- a/docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md +++ b/docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md @@ -339,6 +339,7 @@ game-project/ - Tauri 命令固定为 `read_local_project_resource_canvas_layout` 与 `update_local_project_resource_canvas_layout`。写命令先只读确认有效 manifest,再通过持久 `.layout.lock` 入口获取句柄级跨窗口系统锁,并在锁内复核 projectId、重新读取当前 sidecar。Unix 使用 `flock`,Windows 使用不共享文件句柄;释放只通过句柄 Drop / 进程退出完成,不使用 mtime stale 回收,也不删除锁文件。其余写入继续复用安全路径、链接检查、容量上限、恢复副本与原子替换能力;不能只依赖 React 状态或进程内锁。 - 前端从当前项目开发大组件中拆出纯布局模型与持久 Hook。默认布局、碰撞检查、资源增删协调和 section 边界由纯模型负责;读取、异步身份、CAS、错误回滚和冲突载入由 Hook 负责。Hook 以 `projectPath + projectId + mode` epoch 隔离异步结果,资源变化不取消首读或在途保存;单窗口写入经同一 FIFO 串行提交,每笔都使用最近一次成功 / 冲突响应的权威 revision。视图使用 Pointer Events 做二维拖动,保存中仍允许继续拖动并排队,普通点击、搜索、筛选和唯一资源详情浮层语义保持不变。 - 新资源只在第一次进入某个 mode 时计算默认不重叠位置;全部现存坐标保持不变。搜索、筛选、窗口 resize 和 mode 切换不得重排或回写已有坐标,窄视图通过 section 画布范围与滚动访问,不裁切持久坐标。 +- type 默认布局固定按 `subtype -> mediaType -> label -> id` 排序。manifest 资产的 subtype 使用 `asset.kind`,任务产物、导入附件和 Agent 文本成果使用稳定的来源 fallback;subtype 必须进入资源协调签名,不能因 MIME 相同而退化成按名称混排。 - 普通保存失败恢复最近可信持久布局;CAS 冲突载入对方最新布局并要求用户重新拖动,同时清除基于旧快照排队的全部手动意图,不自动重放旧坐标。即使冲突发生在允许自动重试的资源协调请求上,只要本次冲突清除了排队手动意图,重新拖动提示就必须保留且不能被后续资源协调成功静默清除。资源自动协调可基于冲突布局最多追加两次重试,持续跨窗口竞争时停止自旋并保留当前会话协调结果。损坏、未知 schema、身份冲突、超限与链接文件失败关闭,不能用空布局覆盖原文件。 - 本切片不包含资源关系线、资源替换、详情浮层位置、缩放 / 平移、搜索 / 筛选条件、当前 mode,也不修改 `api-server` 或 SpacetimeDB。关系线与其它 P1 能力必须在本切片独立验收后继续接入。