补齐资源画布子类型稳定排序

为全部资源来源补充必填且稳定的 subtype 映射
按 subtype、媒体类型、名称和资源 ID 生成确定性类型布局
将 subtype 纳入资源协调签名并补充模型与 AppSurface 回归
同步更新资源画布产品、技术与长期决策合同
This commit is contained in:
2026-07-31 11:18:09 +08:00
parent b25b85b525
commit 0c817383b8
9 changed files with 180 additions and 7 deletions
@@ -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 历史文本回执',
@@ -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)
);
}
@@ -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,
@@ -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<string, unknown>) => {
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, '布局失败测试');
@@ -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) => {
@@ -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');
@@ -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` 横屏合同验收。
@@ -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 和客户端实施计划。
@@ -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 能力必须在本切片独立验收后继续接入。