补齐资源画布子类型稳定排序
为全部资源来源补充必填且稳定的 subtype 映射 按 subtype、媒体类型、名称和资源 ID 生成确定性类型布局 将 subtype 纳入资源协调签名并补充模型与 AppSurface 回归 同步更新资源画布产品、技术与长期决策合同
This commit is contained in:
@@ -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 历史文本回执',
|
||||
|
||||
+9
-3
@@ -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)
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
+2
-1
@@ -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 能力必须在本切片独立验收后继续接入。
|
||||
|
||||
|
||||
Reference in New Issue
Block a user