暂缓资源卡拖动并统一资源管理合同
Project CI / Repository checks (pull_request) Failing after 10s
Project CI / Backend tests (pull_request) Failing after 10s
Project CI / Frontend tests (pull_request) Failing after 2m18s
Project CI / Native shell tests (pull_request) Successful in 11m37s

移除资源卡 Pointer 拖动入口并保留点击详情
改写交互与布局测试,覆盖指针不改坐标且不提交 CAS
同步工作台 PRD、技术方案和项目决策记录
This commit is contained in:
2026-08-03 15:35:54 +08:00
parent c24e3010f8
commit 7339bb5da1
8 changed files with 130 additions and 709 deletions
+2 -12
View File
@@ -4041,11 +4041,10 @@ iframe.preview-frame {
color: #4e382f;
text-align: left;
box-shadow: 0 6px 18px rgb(96 62 47 / 6%);
cursor: grab;
touch-action: none;
cursor: pointer;
touch-action: manipulation;
user-select: none;
transform: translate3d(var(--resource-x, 0), var(--resource-y, 0), 0);
will-change: transform;
}
.game-resource-card:hover,
@@ -4056,15 +4055,6 @@ iframe.preview-frame {
box-shadow: 0 8px 22px rgb(195 105 62 / 15%);
}
.game-resource-card.is-dragging {
z-index: 2;
opacity: 0.72;
cursor: grabbing;
box-shadow:
0 12px 28px rgb(195 105 62 / 24%),
0 0 0 2px rgb(213 123 81 / 18%);
}
.game-resource-card.is-relation-upstream,
.game-resource-card.is-relation-downstream,
.game-resource-card.is-relation-both {
@@ -42,14 +42,7 @@ import {
LocalGamePreviewFrame,
resolveEmbeddedPreviewUrl,
} from '../../features/project-workspace/LocalGamePreviewFrame';
import {
RESOURCE_CANVAS_CARD_HEIGHT,
RESOURCE_CANVAS_CARD_WIDTH,
RESOURCE_CANVAS_COLUMN_GAP,
RESOURCE_CANVAS_DRAG_THRESHOLD,
RESOURCE_CANVAS_ROW_GAP,
resourceCanvasSectionExtent,
} from './resourceCanvasLayoutModel';
import { resourceCanvasSectionExtent } from './resourceCanvasLayoutModel';
import {
EMPTY_PROJECT_RESOURCE_GRAPH,
normalizeProjectResourceGraph,
@@ -58,10 +51,7 @@ import {
type ProjectResourceGraphNodeInput,
type ProjectResourceGraphReadModel,
} from './resourceDependencyGraphModel';
import {
ResourceDependencyOverlay,
type ResourceDependencyOverlayHandle,
} from './ResourceDependencyOverlay';
import { ResourceDependencyOverlay } from './ResourceDependencyOverlay';
import { useProjectResourceCanvasLayout } from './useProjectResourceCanvasLayout';
type AttachmentResult = {
@@ -82,22 +72,6 @@ type Point = {
y: number;
};
type ResourceCardDrag = {
pointerId: number;
resourceId: string;
section: ResourceCategory;
startClientX: number;
startClientY: number;
startX: number;
startY: number;
moved: boolean;
element: HTMLButtonElement;
plane: HTMLElement | null;
planeBaseWidth: number;
planeBaseHeight: number;
visualGutter: number;
};
type ProjectResource = {
id: string;
category: ResourceCategory;
@@ -455,41 +429,28 @@ function summarizeAgent(
const ResourceCard = memo(function ResourceCard({
resource,
selected,
dragging,
relationState,
x,
y,
onSelect,
onPointerDown,
onPointerMove,
onPointerUp,
onPointerCancel,
}: {
resource: ProjectResource;
selected: boolean;
dragging: boolean;
relationState: 'upstream' | 'downstream' | 'both' | null;
x: number;
y: number;
onSelect: (resourceId: string) => void;
onPointerDown: (
event: ReactPointerEvent<HTMLButtonElement>,
resource: ProjectResource,
) => void;
onPointerMove: (event: ReactPointerEvent<HTMLButtonElement>) => void;
onPointerUp: (event: ReactPointerEvent<HTMLButtonElement>) => void;
onPointerCancel: (event: ReactPointerEvent<HTMLButtonElement>) => void;
}) {
const Icon = categoryIcons[resource.category];
return (
<button
type="button"
className={`game-resource-card${selected ? ' is-selected' : ''}${
dragging ? ' is-dragging' : ''
}${relationState ? ` is-relation-${relationState}` : ''}`}
relationState ? ` is-relation-${relationState}` : ''
}`}
aria-pressed={selected}
data-resource-id={resource.id}
title="拖动调整资源位置"
title="打开资源详情"
style={
{
'--resource-x': `${x}px`,
@@ -497,10 +458,6 @@ const ResourceCard = memo(function ResourceCard({
} as CSSProperties
}
onClick={() => onSelect(resource.id)}
onPointerDown={(event) => onPointerDown(event, resource)}
onPointerMove={onPointerMove}
onPointerUp={onPointerUp}
onPointerCancel={onPointerCancel}
>
<span className="game-resource-card-icon">
<Icon size={20} aria-hidden="true" />
@@ -534,9 +491,6 @@ export default function ProjectDevelopmentView({
const [showAllAgentGroups, setShowAllAgentGroups] = useState(false);
const [runPlaying, setRunPlaying] = useState(false);
const [activeSlice, setActiveSlice] = useState(0);
const [draggedResourceId, setDraggedResourceId] = useState<string | null>(
null,
);
const [resourceDialogPosition, setResourceDialogPosition] =
useState<Point | null>(null);
const [imagePreview, setImagePreview] = useState<ImagePreviewState>({
@@ -547,14 +501,6 @@ export default function ProjectDevelopmentView({
const stageRef = useRef<HTMLElement>(null);
const dockRef = useRef<HTMLElement>(null);
const resourceDialogRef = useRef<HTMLElement>(null);
const resourceCardDragRef = useRef<ResourceCardDrag | null>(null);
const resourceDependencyOverlayRef =
useRef<ResourceDependencyOverlayHandle>(null);
const pendingResourceDragPreviewRef = useRef<
(Point & { resourceId: string }) | null
>(null);
const resourceDragFrameRef = useRef<number | null>(null);
const suppressResourceClickRef = useRef<string | null>(null);
const resourceDialogDragRef = useRef<{
pointerId: number;
offsetX: number;
@@ -706,7 +652,6 @@ export default function ProjectDevelopmentView({
layout: resourceLayout,
notice: resourceLayoutNotice,
saving: resourceLayoutSaving,
commitPosition: commitResourcePosition,
} = useProjectResourceCanvasLayout({
projectPath,
projectId: manifest.projectId,
@@ -826,68 +771,6 @@ export default function ProjectDevelopmentView({
approvalOptions.find((option) => option.id === approvalMode)?.label ??
'严格审批';
const applyResourceDragPreview = useCallback(
(preview: Point & { resourceId: string }) => {
const drag = resourceCardDragRef.current;
if (!drag || drag.resourceId !== preview.resourceId) {
return;
}
drag.element.style.setProperty('--resource-x', `${preview.x}px`);
drag.element.style.setProperty('--resource-y', `${preview.y}px`);
if (drag.plane) {
drag.plane.style.width = `${Math.max(
drag.planeBaseWidth,
preview.x +
RESOURCE_CANVAS_CARD_WIDTH +
RESOURCE_CANVAS_COLUMN_GAP +
drag.visualGutter,
)}px`;
drag.plane.style.height = `${Math.max(
drag.planeBaseHeight,
preview.y +
RESOURCE_CANVAS_CARD_HEIGHT +
RESOURCE_CANVAS_ROW_GAP,
)}px`;
}
resourceDependencyOverlayRef.current?.updateDragPreview(preview);
},
[],
);
const scheduleResourceDragPreview = useCallback(
(preview: Point & { resourceId: string }) => {
pendingResourceDragPreviewRef.current = preview;
if (resourceDragFrameRef.current !== null) {
return;
}
resourceDragFrameRef.current = window.requestAnimationFrame(() => {
resourceDragFrameRef.current = null;
const pending = pendingResourceDragPreviewRef.current;
pendingResourceDragPreviewRef.current = null;
if (pending) {
applyResourceDragPreview(pending);
}
});
},
[applyResourceDragPreview],
);
const clearScheduledResourceDragPreview = useCallback(() => {
pendingResourceDragPreviewRef.current = null;
if (resourceDragFrameRef.current !== null) {
window.cancelAnimationFrame(resourceDragFrameRef.current);
resourceDragFrameRef.current = null;
}
}, []);
useEffect(
() => () => {
clearScheduledResourceDragPreview();
resourceDependencyOverlayRef.current?.clearDragPreview();
},
[clearScheduledResourceDragPreview],
);
useEffect(() => {
if (embeddedPreviewUrl) {
setMode('run');
@@ -896,11 +779,7 @@ export default function ProjectDevelopmentView({
useEffect(() => {
setSelectedResourceId(null);
resourceCardDragRef.current = null;
clearScheduledResourceDragPreview();
resourceDependencyOverlayRef.current?.clearDragPreview();
setDraggedResourceId(null);
}, [clearScheduledResourceDragPreview, projectPath]);
}, [projectPath]);
useEffect(() => {
if (!selectedResourceId) {
@@ -1026,135 +905,9 @@ export default function ProjectDevelopmentView({
}, [clampResourceDialogPosition, selectedResource]);
const handleResourceSelect = useCallback((resourceId: string) => {
if (suppressResourceClickRef.current === resourceId) {
suppressResourceClickRef.current = null;
return;
}
setSelectedResourceId(resourceId);
}, []);
const handleResourceCardPointerDown = useCallback(
(
event: ReactPointerEvent<HTMLButtonElement>,
resource: ProjectResource,
) => {
if (event.button !== 0) {
return;
}
const position = resourcePositionById.get(resource.id);
if (!position || position.section !== resource.category) {
return;
}
clearScheduledResourceDragPreview();
const plane = event.currentTarget.parentElement;
resourceCardDragRef.current = {
pointerId: event.pointerId,
resourceId: resource.id,
section: resource.category,
startClientX: event.clientX,
startClientY: event.clientY,
startX: position.x,
startY: position.y,
moved: false,
element: event.currentTarget,
plane,
planeBaseWidth:
Number.parseFloat(plane?.style.width ?? '') ||
plane?.getBoundingClientRect().width ||
0,
planeBaseHeight:
Number.parseFloat(plane?.style.height ?? '') ||
plane?.getBoundingClientRect().height ||
0,
visualGutter:
sortMode === 'dependency' ? RESOURCE_DEPENDENCY_VISUAL_GUTTER : 0,
};
event.currentTarget.setPointerCapture?.(event.pointerId);
},
[clearScheduledResourceDragPreview, resourcePositionById, sortMode],
);
const handleResourceCardPointerMove = useCallback(
(event: ReactPointerEvent<HTMLButtonElement>) => {
const drag = resourceCardDragRef.current;
if (!drag || drag.pointerId !== event.pointerId) {
return;
}
const deltaX = event.clientX - drag.startClientX;
const deltaY = event.clientY - drag.startClientY;
if (
!drag.moved &&
Math.hypot(deltaX, deltaY) < RESOURCE_CANVAS_DRAG_THRESHOLD
) {
return;
}
if (!drag.moved) {
drag.moved = true;
setDraggedResourceId(drag.resourceId);
}
scheduleResourceDragPreview({
resourceId: drag.resourceId,
x: Math.max(0, drag.startX + deltaX),
y: Math.max(0, drag.startY + deltaY),
});
event.preventDefault();
},
[scheduleResourceDragPreview],
);
const handleResourceCardPointerEnd = useCallback(
(event: ReactPointerEvent<HTMLButtonElement>, cancelled: boolean) => {
const drag = resourceCardDragRef.current;
if (!drag || drag.pointerId !== event.pointerId) {
return;
}
clearScheduledResourceDragPreview();
const finalPreview = {
resourceId: drag.resourceId,
x: cancelled
? drag.startX
: Math.max(0, drag.startX + event.clientX - drag.startClientX),
y: cancelled
? drag.startY
: Math.max(0, drag.startY + event.clientY - drag.startClientY),
};
applyResourceDragPreview(finalPreview);
resourceCardDragRef.current = null;
if (event.currentTarget.hasPointerCapture?.(event.pointerId)) {
event.currentTarget.releasePointerCapture?.(event.pointerId);
}
if (drag.moved && !cancelled) {
suppressResourceClickRef.current = drag.resourceId;
commitResourcePosition(
drag.resourceId,
drag.section,
finalPreview.x,
finalPreview.y,
);
const overlay = resourceDependencyOverlayRef.current;
window.requestAnimationFrame(() => overlay?.clearDragPreview());
} else {
resourceDependencyOverlayRef.current?.clearDragPreview();
}
setDraggedResourceId(null);
},
[
applyResourceDragPreview,
clearScheduledResourceDragPreview,
commitResourcePosition,
],
);
const handleResourceCardPointerUp = useCallback(
(event: ReactPointerEvent<HTMLButtonElement>) =>
handleResourceCardPointerEnd(event, false),
[handleResourceCardPointerEnd],
);
const handleResourceCardPointerCancel = useCallback(
(event: ReactPointerEvent<HTMLButtonElement>) =>
handleResourceCardPointerEnd(event, true),
[handleResourceCardPointerEnd],
);
function handleResourceDialogPointerDown(
event: ReactPointerEvent<HTMLElement>,
) {
@@ -1328,7 +1081,6 @@ export default function ProjectDevelopmentView({
<div className="game-resource-canvas-content">
{sortMode === 'dependency' ? (
<ResourceDependencyOverlay
ref={resourceDependencyOverlayRef}
key={`${projectPath}:${manifest.projectId}`}
graph={resourceGraph}
positions={resourceLayout.positions}
@@ -1403,17 +1155,10 @@ export default function ProjectDevelopmentView({
key={resource.id}
resource={resource}
selected={resource.id === selectedResourceId}
dragging={resource.id === draggedResourceId}
relationState={relationState}
x={position.x}
y={position.y}
onSelect={handleResourceSelect}
onPointerDown={handleResourceCardPointerDown}
onPointerMove={handleResourceCardPointerMove}
onPointerUp={handleResourceCardPointerUp}
onPointerCancel={
handleResourceCardPointerCancel
}
/>
);
})}
@@ -20,7 +20,7 @@ type LayoutNotice =
| '布局读取失败,已使用当前会话布局'
| '布局保存失败,已保留当前会话布局'
| '布局保存失败,已恢复上次布局'
| '布局已在其他窗口更新,请重新拖动';
| '布局已在其他窗口更新';
type LayoutScope = {
key: string;
@@ -418,7 +418,7 @@ export function useProjectResourceCanvasLayout({
enqueueResourceSyncRef.current(currentScope.epoch, nextRetry);
}
if (redragRequired || !willRetryResourceSync) {
setNotice('布局已在其他窗口更新,请重新拖动');
setNotice('布局已在其他窗口更新');
}
}
rebuildOptimisticLayout(currentScope.epoch);
@@ -605,7 +605,7 @@ export function useProjectResourceCanvasLayout({
return undefined;
}
if (
notice === '布局已在其他窗口更新,请重新拖动' &&
notice === '布局已在其他窗口更新' &&
redragRequiredScopeEpochRef.current === scopeRef.current.epoch
) {
return undefined;
File diff suppressed because it is too large Load Diff
@@ -438,7 +438,7 @@ describe('useProjectResourceCanvasLayout', () => {
).toBe(true);
});
it('coalesces repeated queued drags for the same resource behind an in-flight CAS', async () => {
it('coalesces repeated queued manual placements for the same resource behind an in-flight CAS', async () => {
const resourceA = resource('resource-a');
const updates: Array<{
expectedRevision: number;
@@ -577,11 +577,11 @@ describe('useProjectResourceCanvasLayout', () => {
});
expect(result.current.saving).toBe(false);
});
expect(result.current.notice).toBe('布局已在其他窗口更新,请重新拖动');
expect(result.current.notice).toBe('布局已在其他窗口更新');
expect(updateCalls).toBe(1);
});
it('keeps the redrag notice when resource reconciliation drops a queued manual intent and retries', async () => {
it('keeps the manual conflict notice when resource reconciliation drops a queued manual intent and retries', async () => {
const resourceA = resource('resource-a');
const resourceB = resource('resource-b');
const updates: Array<{
@@ -675,7 +675,7 @@ describe('useProjectResourceCanvasLayout', () => {
expect.objectContaining({ resourceId: 'resource-a', x: 400, y: 80 }),
]),
);
expect(result.current.notice).toBe('布局已在其他窗口更新,请重新拖动');
expect(result.current.notice).toBe('布局已在其他窗口更新');
expect(setTimeoutSpy.mock.calls.some(([, delay]) => delay === 2400)).toBe(
false,
);
@@ -858,7 +858,7 @@ describe('useProjectResourceCanvasLayout', () => {
await waitFor(() => expect(expectedRevisions).toHaveLength(3));
await waitFor(() => expect(result.current.saving).toBe(false));
expect(expectedRevisions).toEqual([1, 2, 3]);
expect(result.current.notice).toBe('布局已在其他窗口更新,请重新拖动');
expect(result.current.notice).toBe('布局已在其他窗口更新');
expect(
result.current.layout.positions.some(
({ resourceId }) => resourceId === 'resource-b',
@@ -43,10 +43,12 @@
### 3.3 资源布局
- “按依赖”和“按类型”分别保存画布位置。
- 切换布局模式后恢复该模式最后一次用户手动拖动结果
- 当前只允许 dependency / type 自动布局与资源卡点击,不提供资源卡手动拖动入口
- 切换布局模式后恢复该模式 sidecar 中已有坐标;历史手动坐标只读恢复,不删除、不重置、不迁移。
- 新资源首次进入某个布局时才执行默认不重叠排版;已有坐标不得被自动排序覆盖。
- 依赖布局使用资源生成/引用关系;类型布局按资源大类、子类型、尺寸规格排序。
- 不同资源分区不可互相拖入
- 资源分区由资源投影固定,前端交互不能改变分类
- 资源卡手动拖动、拖动持久化、拖动性能与冲突后的重新拖动提示全部暂缓,不作为当前产品合同或验收条件。
### 3.4 数值微调
@@ -124,6 +126,8 @@ idle -> focused(document|art|audio|version) -> idle
- 文档:在中央画布展开并独立滚动。
- 美术/音频:进入对应媒体聚焦状态,工具能力复用现有编辑器。
- 版本:高亮版本引用资源;替换动作只创建下一迭代版本。
- mentor 最新决定:资源聚焦不提供工具栏,也不提供工具侧边栏。
- 本阶段只保留点击资源打开当前已有详情,不实施后续文档读取、美术编辑、音频能力、项目版本或运行模块。
### 4.4 历史成果与当前状态
@@ -153,7 +157,7 @@ P0 中 `approvalMode` 只能有效写入 `strict`;其它值只能作为不可
### 5.2 资源画布布局(P1
实现状态(2026-08-03):本节布局合同已在独立客户端落地,dependency / type 双模式通过项目内 CAS sidecar 独立持久化;dependency 模式由 Tauri Rust 只读构建关系拓扑与确定性依赖深度、前端 SVG 派生几何,图结构和线段均不写入布局 sidecar。依赖图加载完成前设布局初始化屏障,避免以临时 `dependencyDepth=0` 生成并持久化错误坐标。资源替换、缩放 / 平移等其余 P1 能力仍按本文非目标保持未实现。
实现状态(2026-08-03):dependency / type 双模式通过项目内 CAS sidecar 独立持久化;dependency 模式由 Tauri Rust 只读构建关系拓扑与确定性依赖深度、前端 SVG 派生几何,图结构和线段均不写入布局 sidecar。依赖图加载完成前设布局初始化屏障,避免以临时 `dependencyDepth=0` 生成并持久化错误坐标。当前用户入口只允许自动布局与资源卡点击;资源卡手动拖动已按 mentor 决定暂缓。历史 sidecar 坐标继续只读恢复,底层布局读写与 CAS 合同保留,但当前没有用户手动布局入口。资源替换、缩放 / 平移等其余 P1 能力仍按本文非目标保持未实现。
```ts
type ProjectResourceCanvasLayout = {
@@ -179,7 +183,7 @@ type ProjectResourceCanvasLayout = {
- `x / y` 是相对所属 `section` 内容原点的 CSS 像素坐标,落盘前四舍五入为非负整数;坐标不使用 viewport、页面或资源详情浮层坐标系。
- `updatedAt` 是持久层生成的 Unix 毫秒时间戳,前端不得自行覆盖。
- `revision``0` 开始;布局文件不存在时读取接口合成 `revision=0 / positions=[]`,首次成功写入返回 `revision=1`,后续每次成功 CAS 写入递增 `1`。JSON / Tauri / TypeScript 全链路合法范围固定为 `0..=9_007_199_254_740_991``Number.MAX_SAFE_INTEGER`),读取、返回或提交负数、小数、非有限值与超限整数都必须失败关闭。
- 新资源第一次进入某个 mode 时由默认布局写入 `manuallyPlaced=false`;用户完成一次有效拖动后写为 `true`
- 新资源第一次进入某个 mode 时由默认布局写入 `manuallyPlaced=false``manuallyPlaced=true` 仅用于兼容历史 sidecar 和保留底层合同;当前界面不会因指针操作新增该值
- 同一份布局中 `resourceId` 必须唯一。持久层允许暂时存在当前资源投影中没有的旧 ID,因为 Agent 文本成果等资源可能晚于 manifest 恢复;前端协调后必须在下一次成功写入中清除已确认失效的坐标。
- 单份布局最多保存 `4096` 个位置,序列化文件不得超过 `2 MiB``resourceId` 最多 `512` 个 Unicode 字符,`x / y` 取值范围固定为 `0..=1_000_000`
@@ -234,23 +238,27 @@ type UpdateProjectResourceCanvasLayoutResult =
#### 5.2.4 前端布局与协调合同
- 资源卡改用 Pointer Events 驱动二维拖动;超过统一移动阈值后才进入拖动态,普通点击仍打开唯一资源详情浮层,`pointercancel` 恢复拖动前位置
- 资源只能在原 `section` 内拖动。不同 section 之间既不能通过指针拖入,也不能通过持久 payload 改变当前资源的前端分类事实
- dependency 默认布局按 `dependencyDepth` 形成横向层级,同层资源纵向寻找第一个不重叠位置;type 默认布局固定按“资源子类型 -> 媒体类型 -> 名称 -> 资源 ID”稳定排序,在分区内从左到右、从上到下寻找第一个空位。布局模型的 `subtype` 必填:manifest 资产使用 `asset.kind`,任务产物、导入附件与 Agent 文本成果分别使用稳定的 `task-artifact``attachment``agent-result`,不得以缺失值或显示文案兜底;资源协调签名必须包含 subtype。卡片尺寸间距和拖动阈值必须由单一前端布局模型常量维护。
- 资源卡是可点击按钮,只负责选择资源并打开当前唯一资源详情浮层;不得绑定卡片级 `pointerdown / pointermove / pointerup / pointercancel` 拖动处理器
- 指针移动不得修改卡片 `x / y`、不得产生拖动预览、不得更新依赖线几何,也不得提交手动布局 CAS。卡片 title、cursor、`touch-action` 和 class 不得暗示可拖动
- dependency 默认布局按 `dependencyDepth` 形成横向层级,同层资源纵向寻找第一个不重叠位置;type 默认布局固定按“资源子类型 -> 媒体类型 -> 名称 -> 资源 ID”稳定排序,在分区内从左到右、从上到下寻找第一个空位。布局模型的 `subtype` 必填:manifest 资产使用 `asset.kind`,任务产物、导入附件与 Agent 文本成果分别使用稳定的 `task-artifact``attachment``agent-result`,不得以缺失值或显示文案兜底;资源协调签名必须包含 subtype。卡片尺寸间距由单一前端布局模型常量维护。
- type 模式资源集合变化时保留全部仍存在的坐标,只为新 ID 计算默认位置,并删除已确认失效的旧 ID。dependency 模式只永久保留 `manuallyPlaced=true` 的用户坐标;`manuallyPlaced=false` 属于可派生自动位置,在 Rust 关系图首次就绪或可信 producer / dependency depth 变化后按最终拓扑确定性重算。自动重算不得移动手动坐标,协调结果与持久布局逐项一致时不得产生 CAS 写入。
- 搜索或筛选只隐藏卡片,不删除、压缩或重排其坐标;清空搜索后恢复原位置。
- 窗口尺寸变化只改变可视范围和分区滚动边界,不回写、裁切或缩放持久坐标。当前客户端继续以 `1280×800` 横屏合同验收。
- 打开项目、切换 mode 或当前 mode 首次出现新资源时执行“读取 -> 协调 -> 必要时 CAS 写入”;dependency 模式必须先等待与当前 `projectPath + projectId + resource inputs` 匹配的 Rust 图进入 `ready``failed` 终态,等待期间不得创建 fallback、读取 sidecar、协调资源或入队保存。`failed` 只允许以空图降级初始化一次。项目或 mode 已切换后返回的旧异步结果必须丢弃。
- 同一 `projectPath + projectId + mode` 的首次读取与资源集合协调必须分开:资源集合变化不得取消已经发出的读取或保存。同一 scope 内全部手动拖动和资源自动协调写入使用同一 FIFO,任一时刻最多一个 CAS 在途,后一笔必须使用前一笔成功返回的 revision,不能用“最后请求获胜”跳过中间 CAS。切换项目或 mode 后,旧 scope 的在途请求不能阻塞新 scope 队列;前端放弃旧请求槽位并丢弃其迟到响应,后端继续依靠 `expectedProjectId + expectedRevision + 系统锁` 仲裁已发出的请求。
- 某笔 CAS 在途期间,同一 scope 内对相同 `resourceId + section` 重复产生但尚未发送的拖动意图必须折叠为最后坐标;已经在途的请求不得取消,不同资源的顺序不得跨越。队列增长必须受当前资源与分区数量约束,不能随连续 pointer 事件无界累积
- 用户拖动结束后先乐观更新,再立即提交一次 CAS。成功后以返回布局更新 revision;普通写入失败时恢复最近可信持久布局并提示“布局保存失败,已恢复上次布局”。
- CAS 冲突时直接载入返回的最新布局并提示“布局已在其他窗口更新,请重新拖动”,丢弃所有基于冲突前快照排队的手动拖动,不得自动重放本地旧坐标或静默覆盖另一窗口结果。即使当前在途请求是允许自动重试的资源协调,只要本次冲突实际清除了任何排队手动拖动,也必须按当前 scope 保留重新拖动提示;后续资源协调成功、失败或通用提示定时器都不得静默清除,只有新的手动布局成功保存或切换 scope 才能解除。资源自动协调可以基于冲突返回的新 revision 有界重试,单次资源签名最多追加 `2` 次,持续跨窗口写入时不得无限自旋。
- 同一 `projectPath + projectId + mode` 的首次读取与资源集合协调必须分开:资源集合变化不得取消已经发出的读取或保存。当前 scope 内资源自动协调写入使用单写者 FIFO,任一时刻最多一个 CAS 在途,后一笔必须使用前一笔成功返回的 revision。切换项目或 mode 后,旧 scope 的在途请求不能阻塞新 scope 队列;前端放弃旧请求槽位并丢弃其迟到响应,后端继续依靠 `expectedProjectId + expectedRevision + 系统锁` 仲裁已发出的请求。
- 自动协调 CAS 冲突时直接载入返回的最新布局;仍需协调时可以基于权威 revision 最多追加 `2` 次重试,持续跨窗口写入时不得无限自旋。当前提示只说明“布局已在其他窗口更新”,不得要求用户重新拖动
- 缺少 Tauri bridge 的浏览器开发态可以保留当前会话内布局用于界面测试,但不得宣称已经持久保存。
#### 5.2.5 资源依赖关系图层
#### 5.2.5 暂缓的手动拖动合同
- Pointer Events 二维拖动、移动阈值、拖动态 class、乐观手动坐标与结束时单次 CAS 提交暂缓。
- 手动意图折叠、冲突后丢弃旧手动坐标、重新拖动提示和 4096 卡片拖动帧预算暂缓,不进入当前验收。
- 已有 Hook 手动意图、命令式 SVG preview、sidecar 字段和 Rust CAS 基础设施可以保留为未接线技术资产;当前资源卡不得调用这些入口。
#### 5.2.6 资源依赖关系图层
- 图层只在 dependency 模式挂载;type 模式不得渲染 SVG、连线或 marker。切换 mode、切换项目或卸载工作台时必须销毁旧图层,并清理尺寸观察和窗口事件监听。
- 输入固定为当前资源投影的全部卡片身份 / 坐标与 Tauri Rust 返回的 `ProjectResourceGraph` 只读 DTO;Rust 负责资源过滤、去重、迭代式环检测、SCC 压缩后的确定性依赖深度、任务流聚合和一跳连接索引,前端只负责 DTO 防御归一化、浏览器几何与原生 SVG path / marker。SVG 叠加在资源卡底层并设置 `pointer-events: none`,不得引入 D3、React Flow 等图表库,也不得阻断卡片点击和 Pointer Events 拖动。实时拖动坐标属于 DOM 临时状态,不得逐帧通过 Tauri IPC 交给 Rust
- 输入固定为当前资源投影的全部卡片身份 / 坐标与 Tauri Rust 返回的 `ProjectResourceGraph` 只读 DTO;Rust 负责资源过滤、去重、迭代式环检测、SCC 压缩后的确定性依赖深度、任务流聚合和一跳连接索引,前端只负责 DTO 防御归一化、浏览器几何与原生 SVG path / marker。SVG 叠加在资源卡底层并设置 `pointer-events: none`,不得引入 D3、React Flow 等图表库,也不得阻断卡片点击。
- `asset-reference` 表示精确资源引用,使用橙色实线与连续贝塞尔曲线。`GameCreationAppAssetManifestEntry.source.referenceResourceIds` 中的外部资源 ID 必须先唯一匹配另一项资产的 `source.resourceId`,再映射为当前资源卡 ID;缺失、重复或已删除的目标均不得渲染幽灵连线。
- `task-flow` 表示任务产物流转,使用灰色圆头虚线。任务依赖按 `sourceTaskId -> targetTaskId` 聚合为一条主线,两端资源仅绘制平滑曲线分支,不得出现直角折线;禁止对上下游资源生成笛卡尔积连线。任务主线与分支可以使用不同线宽和透明度表达聚合层级,但不能改变端点或方向语义。
- 画布资产 producer 只能来自 `agent.runtime.canvas.asset_generate``assetId -> agentId` 审计且 `agentId` 必须存在于当前 manifestExternal Editor `source.taskId` 属于平台生成任务命名空间,禁止当作 manifest task ID。证据缺失、冲突或有界审计读取未覆盖时不生成对应 task flow,不猜测归属。
@@ -258,8 +266,8 @@ type UpdateProjectResourceCanvasLayoutResult =
- 资源自引用的起点与终点为同一张卡片时,必须绘制在卡片外侧的可见闭环并保留箭头,不得让路径穿过卡片后被底层 SVG 层级遮挡。
- 搜索只允许为当前可见端点生成几何;任一精确引用端点隐藏时该线隐藏,聚合任务流只保留仍可见的两端分支,任一侧没有可见资源时整条任务流隐藏。
- 选中资源后,高亮其直接上游、直接下游卡片和关联边,弱化其余边;不做跨多层递归高亮。选中 ID 已失效时按未选中处理。
- 拖动预览坐标必须直接进入 SVG 几何计算,使连线随 pointer move 实时更新;拖动结束仍只保存卡片布局坐标,不持久化 path、marker、section 原点或任何图结构
- Pointer Move 必须按动画帧合并并完全避开工作台父组件 state:拖动卡片通过 ref 直接更新 CSS 坐标,SVG 通过命令式句柄只更新当前资源局部索引关联的 path。基础 positions 不随每帧复制,静态卡片和 SVG 拓扑保持复用,非拖动卡片不得因预览帧重新渲染。`ResizeObserver` 在单个图层生命周期只允许构造一次。dependency section 额外提供至少 `64px` 右侧视觉 gutter,确保最右侧自环和箭头可完整滚动显示,但不得修改卡片坐标或布局 sidecar。
- 资源卡 Pointer Move 不改变基础 positions 或 SVG 几何。连线只随布局读取、资源自动协调、搜索、选择、项目切换或 section origin 变化而更新
- `ResizeObserver` 在单个图层生命周期只允许构造一次。dependency section 额外提供至少 `64px` 右侧视觉 gutter,确保最右侧自环和箭头可完整滚动显示,但不得修改卡片坐标或布局 sidecar。
### 5.3 资源类型与替换兼容性(P1)
@@ -353,7 +361,7 @@ type ProjectAgentMudPointAttribution = {
- 复用现有四区工作台壳。
- 资源/运行切换与客户端内 loopback 预览。
- 只读资源画布、文档展开、美术/音频聚焦入口
- 只读资源画布与资源卡点击详情;资源聚焦工具栏 / 工具侧边栏及后续媒体能力暂缓
- Supervisor 正式会话、上传、Runtime 确认与安全错误。
- 当前 run 专业状态与项目历史成果分离。
- 默认三专业组,并可展开另外三组。
@@ -362,7 +370,7 @@ type ProjectAgentMudPointAttribution = {
### P1
- 实施依赖/类型两套坐标持久化、首次默认不重叠布局、跨重启恢复与 CAS 冲突处理。
- 实施依赖/类型两套坐标持久化、首次默认不重叠布局、历史坐标跨重启恢复与自动协调 CAS 冲突处理;资源卡手动拖动暂缓
- 资源关系线在布局持久化验收通过后单独实施,不与本切片捆绑伪造完成。
- 版本资源高亮、兼容性判断和不可变下一迭代版本。
- 美术/音频编辑状态接线。
@@ -388,10 +396,10 @@ type ProjectAgentMudPointAttribution = {
### 7.2 P1 资源画布布局持久化验收
1. 同一项目在 dependency 与 type mode 分别拖动资源后,关闭并重启客户端,两种 mode 都恢复各自最后一次成功保存的位置
1. 同一项目在 dependency 与 type mode 分别读取并协调布局,关闭并重启客户端后恢复各自 sidecar 坐标;历史 `manuallyPlaced=true` 坐标保持不变
2. 新资源进入任一 mode 时获得不重叠默认位置,现存资源坐标保持逐项不变;删除资源后,下一次成功写入不再包含已确认失效的 ID。
3. 资源不能跨 document、version、art、audio 分区;点击、搜索、筛选和资源详情浮层行为不因二维拖动回归
4. 两个窗口基于同一 revision 写入时最多一个成功;失败方收到 `conflict` 与最新完整布局,界面不静默覆盖成功方结果。
3. 资源卡 title、cursor、`touch-action` 与 class 只表达可点击;Pointer Down / Move / Up / Cancel 不改变坐标、不产生拖动预览、不提交手动布局 CAS,点击仍打开当前资源详情
4. 两个窗口基于同一 revision 执行资源自动协调写入时最多一个成功;失败方收到 `conflict` 与最新完整布局,界面不静默覆盖成功方结果。
5. 布局文件缺失的旧项目可以无迁移打开;损坏、未知 schema、身份冲突、超限和链接文件失败关闭,且原文件不被空布局覆盖。
6. 布局读写不改变 manifest、游戏项目 mutation revision、Runtime verification、Agent 权限与预览状态。
7. `1280×800` 最小横屏下全部资源可通过分区滚动访问,不出现页面级横向或纵向溢出,右侧对话和底部 Agent 状态栏保持可见。
@@ -402,14 +410,14 @@ type ProjectAgentMudPointAttribution = {
2. 精确引用只接受唯一有效的外部资源 ID 映射,删除或不存在的资源不产生幽灵连线。
3. 多资源任务依赖只形成一条聚合主线与 `O(S+T)` 条端点分支,不产生 `S×T` 连线。
4. 资源引用环和无资源产物参与的任务环都可被有限遍历识别,界面不死循环。
5. 搜索、选择和拖动分别触发端点过滤、直接上下游高亮和实时几何更新;原有点击详情浮层与拖动保存行为不回归。
5. 搜索触发端点过滤,选择触发直接上下游与关联边高亮;资源卡指针移动不更新线段,点击详情浮层行为不回归。
6. 切换布局模式或项目后旧 SVG、ResizeObserver 与窗口监听全部清理;图层从不写入 layout sidecar、manifest 或其它持久化。
7. 4096 资源链式 fixture 下,拖动一张卡片只更新它关联的线段;同一图层 100 次拖动期间 Observer 仍只构造一次。真实 Chromium 目标为拖动 p95 小于 `16.7ms`、不出现超过 `50ms` 的 long task,并完整显示最右侧自环与箭头。
7. 4096 资源链式 fixture 继续验证拓扑、聚合复杂度和自动布局性能;拖动局部更新与真实 Chromium 拖动帧预算暂缓,不作为当前验收条件。最右侧自环与箭头仍需完整显示
8. Rust 图读取延迟时,dependency sidecar 在图进入 `ready / failed` 前没有读取或写入;首次布局直接使用 Rust 返回的最终 producer 与 dependency depth。重新打开旧布局时手动位置逐项不变,自动位置按最终拓扑协调且相同结果不增加 revision。
## 8. 非目标
- 本切片不实现资源替换、不可变迭代版本、画板编辑状态、测试切片、数值参数或泥点归因;已实现的资源关系图只提供 Rust 只读拓扑与前端派生展示,不建立新的资源业务真相。
- 本切片不实现资源卡手动拖动,也不实现资源聚焦工具栏、资源聚焦工具侧边栏、后续文档读取、美术编辑、音频能力、资源替换、不可变迭代版本、运行模块扩展、测试切片、数值参数或泥点归因;已实现的资源关系图只提供 Rust 只读拓扑与前端派生展示,不建立新的资源业务真相。
- 本切片不保存资源详情浮层位置、画布缩放 / 平移、搜索条件、筛选条件或当前 mode;这些状态如需持久化必须另行扩展合同,不能塞入 `game-creator-resource-layout.v1`
- 不修改 SpacetimeDB schema。
- 不开放普通用户 Agent.md/Skill。
@@ -1,7 +1,18 @@
# 决策记录
## 2026-08-03 资源卡手动拖动暂缓且当前只开放自动布局与点击详情
- 背景:飞书《陶泥儿GameAgent-V1.0 项目开发界面需求》曾要求资源卡可拖动,并由后续 PRD、技术方案和实现扩展为手动布局 CAS、依赖线拖动预览与性能验收;mentor 最新决定明确资源卡暂时禁止拖动,资源聚焦也不需要工具栏或工具侧边栏。
- 决策:当前资源卡不绑定 `pointerdown / pointermove / pointerup / pointercancel` 拖动入口,title、cursor、`touch-action` 与 class 只表达可点击;Pointer Move 不改变坐标、不更新依赖线、不提交手动布局。资源卡点击继续打开已有详情;dependency / type 自动布局、搜索、依赖线和直接上下游高亮保持现役。
- 保留边界:项目内 dependency / type 布局 sidecar、历史坐标读取、资源集合自动协调、scope FIFO、跨窗口系统锁、Tauri/Rust CAS 与命令式 SVG preview 基础设施可以保留;历史 `manuallyPlaced=true` 坐标不删除、不重置、不迁移,但当前没有用户手动布局入口。手动拖动持久化、拖动性能和冲突后的重新拖动提示不再是当前验收条件。
- 后续边界:本阶段不继续实现资源聚焦、文档读取、美术编辑、音频能力、项目版本或运行模块;若重新开放手动拖动,必须先更新 PRD、技术方案和 AppSurface 验收合同。
- 验证方式:AppSurface 覆盖 Pointer Down / Move / Up / Cancel 后卡片坐标、SVG path 与布局更新调用均不变,并继续覆盖点击详情、dependency / type 切换、搜索、依赖图和直接上下游高亮;追加 shell typecheck、编码检查与 `git diff --check`
- 关联文档:`docs/prd/【AI游戏创作】项目开发工作台PRD-2026-07-20.md``docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md`
## 2026-08-03 依赖布局等待 Rust 图终态且拖动热路径脱离 React state
> 状态:其中依赖图初始化屏障继续生效;手动拖动热路径与性能验收已由上一条 mentor 最新决定暂缓。
- 背景:资源图异步返回前,dependency 布局会先以 `dependencyDepth=0` 创建并持久化自动坐标;图返回后的 reconcile 保留既有位置,导致首次布局永久停留在错误层级。4096 张真实资源卡拖动时,逐帧父组件 state 还会重渲染全部卡片,即使 SVG 已只更新局部 path 也无法满足帧预算。
- 决策:Rust 关系图 read model 负责在完整任务图 SCC 压缩后返回确定性 resource dependency depthdependency 模式等待当前 scope 图进入 `ready / failed` 后才启动布局读取与协调。手动位置永久保留,自动位置允许按最终图重新派生。拖动 preview 留在前端 ref/DOM 热路径,命令式更新卡片 CSS 与局部 SVG path,不逐帧跨 Tauri IPC,也不写 layout sidecar。
- 边界:不修改 `game-creator-resource-layout.v1`、布局 Rust 持久层、manifest、api-server 或 SpacetimeDBtype 模式不等待资源图且继续保留全部已有坐标。图失败只降级初始化一次,项目或 mode 切换后旧图结果必须丢弃。
@@ -25,6 +36,8 @@
## 2026-07-31 AI 游戏创作资源依赖图采用 Rust 只读拓扑与前端派生 SVG
> 状态:其中资源卡 Pointer Move 拖动预览与局部更新验收已由 2026-08-03 mentor 最新决定暂缓;只读拓扑、SVG 派生展示、搜索与选择高亮合同继续生效。
- 背景:资源画布已有 dependency / type 双模式坐标与本地 CAS sidecar,但 dependency 模式尚未把当前 manifest 中可证明的资源引用和任务流转可视化;关系图不能反向污染布局持久化或建立第二套资源真相。
- 决策:dependency 模式由 Tauri Rust 只读命令从当前 manifest、资源卡身份和有界 `.agent/agent.db` 审计构建稳定 `ProjectResourceGraph` read model,前端只归一化 DTO、测量卡片坐标并用原生 SVG 渲染。资产 `source.referenceResourceIds` 只在唯一匹配另一资产 `source.resourceId` 后形成橙色实线;任务依赖按任务对聚合为灰色虚线主线与两端分支,禁止资源笛卡尔积。Rust 以迭代式强连通分量分析识别资源环和完整任务 DAG 环,并返回资源局部连接索引。
- 任务身份:External Editor 响应中的 `source.taskId` 是平台生成任务 ID,不等于本地 manifest task ID,禁止据此分配 producer。画布资产只接受 `agent.runtime.canvas.asset_generate` 审计中经当前 manifest task 校验的 `assetId -> agentId`;证据缺失、冲突或已超出有界读取窗口时不生成对应 task flow。任务产物与 Agent 回执继续使用自身已有的 manifest task 身份。
@@ -5546,7 +5559,7 @@
- 资源投影:上述回执同步投影到“资源管理 → 文档”,保留来源 Agent 和 run 身份。它是持久回执的可见视图,不得冒充 manifest asset、项目目录中的实际文件或可下载交付物。
- 重试确认:`agent.resume` 默认仍为 `confirm`。普通自动 retry command 保留 auto gate;正式失败卡的“在当前项目重试”按钮本身视为本次明确确认,调用单独的 confirmed retry command,但仍不得绕过 deny。点击后必须在原卡即时显示提交中、成功或安全错误,不能把错误放到专业列表末尾。若总控已为同一 delegation 准备精确 repair,按钮优先确认该 repair,不再创建重复的无合同重试。
- 回执命名:无文件的 completed 结果统一称“专业 Agent 文本回执”,不得称“美术产物”或直接暴露 `design-foundation / art-asset-plan / balance-seed` 等内部 ID。美术任务只完成计划且 manifest 没有图片时,普通界面明确显示“仅完成计划,尚未生成或登记图片”。
- 工作台布局:PDF 方案外的顶部项目标题条不进入项目工作台;资源卡支持同分类、当前会话内的真实拖拽重排,不宣称持久保存。资源详情使用独立可拖动浮层,位置约束在工作台与 viewport 内并避开底部 Agent dock,长正文独立滚动。工作区与 dock 精确占满客户端可用高度,不保留 dock 下方空白。
- 工作台布局:PDF 方案外的顶部项目标题条不进入项目工作台;本条原定的资源卡同分类、当前会话内拖拽重排已由 2026-08-03 mentor 最新决定取代,当前资源卡只允许自动布局与点击详情。资源详情使用独立可拖动浮层,位置约束在工作台与 viewport 内并避开底部 Agent dock,长正文独立滚动。工作区与 dock 精确占满客户端可用高度,不保留 dock 下方空白。
## 2026-07-20 AI 游戏创作策划与美术图片交付门禁
@@ -154,7 +154,7 @@ V1.17 计划快照随 `game-creator-runtime-context-bundle.v3` 持久化,v2
2026-07-19 起,当前父 run 下的专业 Agent 进入 `failed` 后,正式工作台必须提供“在当前项目重试”恢复入口,不得要求用户新建项目。重试必须精确核对原 `agentId + runId + parentRunId`,复用原 task、active Session 和父 run 归属,同时生成新的专业 Agent runId;新 run 继承已持久化的上下文和父子绑定,不覆写旧失败 run 的审计事实,也不得把 UI 重试解释为底层 transport 根因已修复。`agent.resume` 默认 `confirm` 不变:自动 retry command 继续执行 auto gate;正式失败卡按钮自身是本次明确确认,使用 deny-only 的 confirmed retry command。按钮必须原卡即时显示“正在提交重试”、受理或安全错误;若 Supervisor 已为同一 delegation 准备合同 repair,则该按钮优先确认既有 repair,避免重复派发。
completed 专业 Agent 的用户可见成果不能仅依赖项目文件。当委派合同为 `expectedArtifacts=[]` 或未产生显式文件时,工作台必须读取该 Agent 持久对话的最后一条 assistant,作为明确标注的“专业 Agent 文本回执”提供查看,并投影到“资源管理 → 文档”。该投影是持久回执的可见视图,必须保留来源 Agent 与 run 身份,不冒充 manifest asset、项目目录中的实际文件或可下载交付物;内部 Agent ID 不进入用户资源名。美术只交付计划且 manifest 没有图片时必须显示“仅完成计划,尚未生成或登记图片”,不能把文本回执称为美术图片产物。策划 `design-foundation` 与美术 `art-asset-plan` 从 2026-07-20 起属于图片产物型 canonical task:前者必须生成并登记 `assets/ui-prototype.png` 横屏界面原型图,后者必须生成并登记 `assets/art-spritesheet.png` 首版核心美术素材;只有文本计划或空 `expectedArtifacts` 不构成完成。策划图还必须由 `design-foundation``image.inspect` 对当前图片 SHA 形成 `ui-prototype.v2` 结构化视觉验收:信息 HUD、主要可玩区域、当前玩法的关键实体、主要操作、失败/重开流程、移动端布局意图、实现清晰度与原创主题八项全部通过且问题列表为空。验收不得预设塔防或任何固定玩法;`ui-prototype.v1` 仅供历史动作回执安全读取,不能作为新建或恢复 run 的 completion authority。Runtime finalization 只接受同 run、当前 SHA 的 v2 证据。视觉调用失败、响应不可解析、检查未通过或图片被替换后 SHA 不一致都继续阻塞。未通过的已登记图片在工作台只称“候选界面图(待视觉验收)”,不得冒充正式 UI 原型。图片生成未配置、待确认或失败时保持阻塞/失败,不得投影为 completed。资源卡允许同分类内做当前会话拖拽重排;详情使用受 viewport 与底部 dock 约束的独立可拖浮层,长正文由唯一外层滚动容器承载并用 Markdown 安全渲染。PDF 方案外的顶部项目标题条移除,dock 下方不得保留空白;底部专业 Agent 为只读状态卡,不得因点击保留多个详情浮层。
completed 专业 Agent 的用户可见成果不能仅依赖项目文件。当委派合同为 `expectedArtifacts=[]` 或未产生显式文件时,工作台必须读取该 Agent 持久对话的最后一条 assistant,作为明确标注的“专业 Agent 文本回执”提供查看,并投影到“资源管理 → 文档”。该投影是持久回执的可见视图,必须保留来源 Agent 与 run 身份,不冒充 manifest asset、项目目录中的实际文件或可下载交付物;内部 Agent ID 不进入用户资源名。美术只交付计划且 manifest 没有图片时必须显示“仅完成计划,尚未生成或登记图片”,不能把文本回执称为美术图片产物。策划 `design-foundation` 与美术 `art-asset-plan` 从 2026-07-20 起属于图片产物型 canonical task:前者必须生成并登记 `assets/ui-prototype.png` 横屏界面原型图,后者必须生成并登记 `assets/art-spritesheet.png` 首版核心美术素材;只有文本计划或空 `expectedArtifacts` 不构成完成。策划图还必须由 `design-foundation``image.inspect` 对当前图片 SHA 形成 `ui-prototype.v2` 结构化视觉验收:信息 HUD、主要可玩区域、当前玩法的关键实体、主要操作、失败/重开流程、移动端布局意图、实现清晰度与原创主题八项全部通过且问题列表为空。验收不得预设塔防或任何固定玩法;`ui-prototype.v1` 仅供历史动作回执安全读取,不能作为新建或恢复 run 的 completion authority。Runtime finalization 只接受同 run、当前 SHA 的 v2 证据。视觉调用失败、响应不可解析、检查未通过或图片被替换后 SHA 不一致都继续阻塞。未通过的已登记图片在工作台只称“候选界面图(待视觉验收)”,不得冒充正式 UI 原型。图片生成未配置、待确认或失败时保持阻塞/失败,不得投影为 completed。资源卡当前只允许点击打开详情,不提供拖动重排;详情继续使用受 viewport 与底部 dock 约束的独立可拖浮层,长正文由唯一外层滚动容器承载并用 Markdown 安全渲染。PDF 方案外的顶部项目标题条移除,dock 下方不得保留空白;底部专业 Agent 为只读状态卡,不得因点击保留多个详情浮层。
V1.17 同时把 finalization journal 升级为 v2 并绑定最终完整计划快照:assistant 已落盘而 Runtime state 丢失时,从 v2 journal 恢复原结构化计划后补齐终态;assistant 尚未落盘且 state 丢失时失败关闭。外层 `failed / budget-exhausted` 只保留最后可信计划,不把未完成步骤机械改成失败。thinking summary、legacy plan event 和 tool-plan repair 公共审计只保留哈希、字符数或计数,必要的模型输出与错误上下文仅留在有界私有 repair 请求中。
@@ -360,7 +360,7 @@ game-project/
- 页面骨架固定为左侧现有全局导航、中间主视窗、右侧陶泥儿对话和底部子 Agent 状态栏;不新建第二套客户端或平行项目页。
- 中间主视窗提供 `资源管理 / 运行` 切换。`code-prototype` 任务完成前运行入口保持视觉不可用,但仍可点击查看“当前无可运行版本”,不能使用会阻断说明交互的原生 `disabled``aria-disabled`;完成后才允许进入运行表现层。切回资源管理只修改前端展示态,不伪造后端预览暂停结果。
- 资源管理从当前 `GameCreationAppManifest` 派生项目文档、项目版本和 `assets`,并把首页已导入附件作为当前项目上传资源展示。资源按文档、版本、美术、动作、音乐音效分区;`按依赖 / 按类型` 使用各自前端排列,dependency 模式额外绘制当前 manifest 与资源投影可证明的依赖关系。排列与图层都不写回 manifest,不能推断或伪造缺失依赖。
- 资源卡支持选择聚焦、文档展开 / 收起、搜索和类型筛选的界面交互。2026-07-28 起,原一维会话拖拽已替换为两套二维坐标与本地 CAS sidecar2026-07-31 起dependency 模式增加不持久化的原生 SVG 关系图层。画板编辑和同类型版本资源替换仍不得在缺少各自正式写回契约时保存为业务事实
- 资源卡支持点击详情、搜索和类型筛选。2026-07-28 起完成两套二维坐标与本地 CAS sidecar2026-07-31 起 dependency 模式增加不持久化的原生 SVG 关系图层。2026-08-03 mentor 决定暂缓资源卡拖动,当前卡片不挂载 Pointer Down / Move / Up / Cancel 拖动入口,只允许自动布局和点击详情。资源聚焦不提供工具栏或工具侧边栏;本阶段不继续实现文档读取、美术编辑、音频能力、项目版本或运行模块
- 运行表现层首版直接嵌入当前项目的 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 与回滚合同完备前不向普通用户开放。
@@ -375,18 +375,20 @@ game-project/
2026-07-28 起,资源画布布局持久化以工作台 PRD §5.2 和 §7.2 为唯一编码合同,实施边界如下:
2026-08-03 需求收口:资源卡手动拖动暂缓。历史 sidecar 坐标继续只读恢复,自动布局仍可为资源集合协调提交 CAS;sidecar schema、布局读取、FIFO、跨窗口系统锁和 Rust CAS 基础设施全部保留,但当前没有用户手动布局入口。
- 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 加一,冲突返回最新完整布局且不写文件。revision 虽在 Rust 中使用 `u64`,但跨 JSON / Tauri / TypeScript 的合法域固定为 `0..=Number.MAX_SAFE_INTEGER`;共享 DTO 序列化与反序列化、Tauri 输入和前端 IPC 响应均执行同一边界校验。
- 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 做二维拖动,保存中仍允许继续拖动并排队,普通点击、搜索、筛选和唯一资源详情浮层语义保持不变
- 前端从当前项目开发大组件中拆出纯布局模型与持久 Hook。默认布局、碰撞检查、资源增删协调和 section 边界由纯模型负责;读取、异步身份、CAS、错误回滚和冲突载入由 Hook 负责。Hook 以 `projectPath + projectId + mode` epoch 隔离异步结果,资源变化不取消首读或在途保存;自动协调写入经同一 FIFO 串行提交,每笔都使用最近一次成功 / 冲突响应的权威 revision。资源卡是普通可点击按钮,不绑定卡片级 Pointer 拖动处理器;指针移动不修改 CSS 坐标、不调用 SVG preview、不提交手动布局 CAS
- 新资源只在第一次进入某个 mode 时计算默认不重叠位置;全部现存坐标保持不变。搜索、筛选、窗口 resize 和 mode 切换不得重排或回写已有坐标,窄视图通过 section 画布范围与滚动访问,不裁切持久坐标。
- type 默认布局固定按 `subtype -> mediaType -> label -> id` 排序。manifest 资产的 subtype 使用 `asset.kind`,任务产物、导入附件和 Agent 文本成果使用稳定的来源 fallback;subtype 必须进入资源协调签名,不能因 MIME 相同而退化成按名称混排。
- 普通保存失败恢复最近可信持久布局;CAS 冲突载入对方最新布局并要求用户重新拖动,同时清除基于旧快照排队的全部手动意图,不自动重放旧坐标。即使冲突发生在允许自动重试的资源协调请求上,只要本次冲突清除了排队手动意图,重新拖动提示就必须绑定当前 scope 保留,不得被后续资源协调成功、失败或通用提示定时器静默清除;新的手动布局成功保存或 scope 切换后才解除。资源自动协调可基于冲突布局最多追加两次重试,持续跨窗口竞争时停止自旋并保留当前会话协调结果。损坏、未知 schema、身份冲突、超限与链接文件失败关闭,不能用空布局覆盖原文件。
- 自动协调保存失败时保留当前会话布局;CAS 冲突载入对方最新布局,需要继续协调时最多追加两次重试,持续跨窗口竞争时停止自旋。用户提示只说明“布局已在其他窗口更新”,不要求重新拖动。损坏、未知 schema、身份冲突、超限与链接文件失败关闭,不能用空布局覆盖原文件。
- 本布局持久化切片不包含资源关系线、资源替换、详情浮层位置、缩放 / 平移、搜索 / 筛选条件、当前 mode,也不修改 `api-server` 或 SpacetimeDB。资源关系线已在后续独立的纯前端切片接入,不改变本段 sidecar 合同;其余 P1 能力继续独立实施。
实施顺序固定为:先同步 TypeScript / Rust DTO 与序列化测试,再实现 Tauri sidecar 读写和 CAS,随后接入前端纯模型持久 Hook 与二维拖动,最后完成 Rust 安全测试、React 交互测试、跨重启 / 双窗口验收和文档状态回写。任何一步不得用 `localStorage`、manifest 字段或只在当前 React 会话有效的状态冒充项目持久化。
历史实施顺序已完成 TypeScript / Rust DTOTauri sidecar/CAS、前端纯模型持久 Hook。二维手动拖动接线现已暂缓;重新开放前必须先更新 PRD 与验收合同。任何后续步骤不得用 `localStorage`、manifest 字段或只在当前 React 会话有效的状态冒充项目持久化。
2026-07-30 前端并发与性能加固状态:首读、资源更新和拖动保存已拆成 scope epoch + scope 内单写者 FIFO;定向 Hook 测试覆盖首读期间资源变化、在途手动 CAS 后资源协调、冲突清除排队拖动、旧 mode 迟到读取 / 写入、新 scope 不等待旧 scope 卡死请求、持续冲突有界停止,以及在途 CAS 后同资源连续拖动折叠为最后坐标。旧 scope 请求已经发出后不做不安全取消,但会释放前端活动槽并由 epoch 丢弃迟到响应;同 scope 的在途请求仍保持唯一。默认布局用 section 分组与二维占用索引替代逐 slot 全量扫描dependency 使用按列单调游标,type 使用单调 slot 游标;`4096` 项双模式性能回归纳入前端测试,避免恢复到接近 `O(N³)` 的主线程阻塞实现。
2026-07-30 前端并发与性能加固形成的 scope epochscope 内单写者 FIFO、手动意图折叠和冲突恢复实现继续保留为底层技术资产;当前用户入口只消费布局读取与资源自动协调。默认布局仍使用 section 分组与二维占用索引,dependency 使用按列单调游标,type 使用单调 slot 游标;`4096` 项双模式自动布局性能回归继续防止恢复到接近 `O(N³)` 的主线程阻塞实现。手动拖动帧预算、局部 preview 性能和重新拖动提示不再是当前验收条件。
2026-07-30 Rust 并发与零副作用加固状态:资源布局锁已由 `create_new + mtime stale 删除` 改为持久锁文件上的 Unix `flock` / Windows 独占句柄,活锁即使 mtime 很旧也不能被另一个写入者回收,释放后仍复用同一文件实例。更新命令携带只用于校验的 `expectedProjectId`,在任何目录创建前先读取 manifest 并拒绝旧项目窗口,锁内再次核对 projectId;不存在根、非项目根、损坏 manifest 和路径重建后的旧窗口均不产生 `.agent/workbench`。revision 在共享 serde、Tauri 命令和前端 IPC 三层限制到 `Number.MAX_SAFE_INTEGER`,达到上限时保持原文件并失败关闭,不能让 Rust `u64` 值在 JavaScript 中失真后击穿 CAS。
@@ -402,12 +404,12 @@ game-project/
- `ResourceDependencyOverlay.tsx` 使用原生 SVG path/marker,绝对定位在 `.game-resource-canvas-content` 底层并统一 `pointer-events: none`。橙色实线表示 `asset-reference`,灰色圆头虚线表示 `task-flow`;两类连线统一使用连续贝塞尔曲线,任务主线略强于两端分支,箭头使用不随高亮线宽缩放的稳定用户空间尺寸,避免直角折线、突兀拐弯和箭头跳变。不引入 D3、React Flow 或其它图表依赖。
- `asset-reference` 的 source / target 是同一资源时使用卡片右侧外绕贝塞尔闭环,两个锚点分开且 marker 保留在返回锚点;路径不穿过卡片。dependency section 在现有 extent 外额外增加 `64px` 右侧视觉 gutter,确保最右卡片的闭环和箭头可滚动显示;不改卡片坐标、`resourceCanvasLayoutModel.ts` 或 sidecar。
- 图层用 SVG 自身节点定位所属画布容器,测量各 section plane 相对原点;`ResizeObserver` 在图层挂载时只创建一次,与 window resize 一起负责重新测量并在卸载时清理。type 模式不挂载图层且释放 graph state;项目身份作为 key,切换 mode、项目或工作台卸载都会销毁旧 SVG。
- 基础 positions 保持稳定,拖动预览单独传入。Pointer Move 通过 `requestAnimationFrame` 合帧;静态 task/reference React SVG 子树不依赖 drag preview,每帧只按局部连接索引重新计算当前资源关联的 edge / flow 并更新对应 path `d`搜索、选择、项目切换、真实 positions 或 section origin 变化才允许重新协调静态图层;结束后仍只通过原布局 Hook 保存卡片坐标,SVG 几何从不持久化。
- Rust、前端 DTO/SVG 和工作台 AppSurface 回归覆盖生产 `task-1` 数据形状、真实 producer、证据缺失、精确引用、去重、无效 ID、完整任务环、4096 链式拓扑、聚合复杂度、搜索过滤、选择高亮、局部拖动更新、稳定 Observer、type 模式卸载与项目切换销毁。真实 Chromium 性能目标为拖动 p95 `<16.7ms`、不出现 `>50ms` long task;若实测仍超过预算,再增加 viewport + overscan 边裁剪,不在首轮预先引入额外复杂度
- 基础 positions 保持稳定;卡片 Pointer Move 不进入 SVG preview,只有搜索、选择、项目切换、真实 positions 或 section origin 变化才重新协调图层。SVG 几何从不持久化。
- Rust、前端 DTO/SVG 和工作台 AppSurface 回归覆盖生产 `task-1` 数据形状、真实 producer、证据缺失、精确引用、去重、无效 ID、完整任务环、4096 链式拓扑、聚合复杂度、搜索过滤、选择高亮、稳定 Observer、type 模式卸载与项目切换销毁。局部拖动更新与真实 Chromium 拖动性能目标暂缓
2026-08-03 评审加固:Rust read model 在现有 producer assignment 上同时返回经完整任务图 SCC 压缩计算的确定性 `dependencyDepth`,前端不再递归推导正式依赖层级。dependency 模式以 scope 化 `idle / loading / ready / failed` 状态阻断布局 Hook;图终态前不创建 fallback、不读取或写入 sidecar,图失败只以空图初始化一次。读取已有 dependency 布局时保留全部 `manuallyPlaced=true` 坐标,把 `manuallyPlaced=false` 作为可派生自动位置按最终深度重新协调;结果未变化时不写入。
拖动热路径不再把 preview 写入工作台父组件 state。卡片使用稳定回调与 `React.memo`,动画帧直接更新拖动卡片 CSS 变量和 section 临时 extentSVG 图层暴露命令式 preview 句柄,复用 Rust 局部连接索引和已缓存 path 节点,只更新受影响 reference / task-flow 几何。ResizeObserver 仍为单图层单实例。4096 张真实卡片测试必须证明非拖动卡片零重渲染、静态 SVG 子树不重建,Chromium p95 继续以 `<16.7ms` 为门槛。实时 DOM 几何不得通过 Tauri IPC 往返 Rust。
历史命令式 drag preview 句柄与局部连接索引可以保留,但项目工作台不再向资源卡传入该入口。拖动热路径、4096 张真实卡片拖动重渲染和 Chromium p95 门槛统一暂缓;当前回归只要求 Pointer Move 不改变卡片坐标、SVG path 或布局 revision。`ResizeObserver` 仍保持单图层单实例,任何实时 DOM 几何不得通过 Tauri IPC 往返 Rust。
## 分阶段实施
@@ -421,7 +423,7 @@ game-project/
- 用户能创建本地 Web 游戏项目。
- 用户进入项目开发页后能看到资源管理主视窗、陶泥儿对话栏和底部策划 / 美术 / 程序 Agent 状态栏;`1280×800` 最小横屏窗口和更大桌面窗口均不得出现页面级横向 / 纵向溢出,对话输入与底部 Agent 状态栏始终位于视口内。
- 资源管理可在按依赖 / 按类型之间切换、搜索资源、展开文档和聚焦资源dependency 模式展示可验证的资源引用和聚合任务流,搜索、选择与拖动同步更新线段;所有展示数据来自当前 manifest、当前资源投影或当前项目导入附件。
- 资源管理可在按依赖 / 按类型之间切换、搜索资源并点击打开当前资源详情dependency 模式展示可验证的资源引用和聚合任务流,搜索过滤端点、选择高亮直接上下游。资源卡不可拖动,Pointer Move 不更新坐标、线段或手动布局;所有展示数据来自当前 manifest、当前资源投影或当前项目导入附件。
- 首个 `code-prototype` 任务未完成时运行入口不可进入并给出可感知提示;完成后可进入运行表现层,真实预览直接加载到客户端内受限运行容器。
- 审批档位通过独立弹出面板切换,默认严格审批;界面选择不得绕过 Runtime 现有确认门禁。
- 聊天输入 `/plan` 可在普通聊天消息里查看下一轮分工计划,不读取任务文件、不启动 run、不修改项目,也不新增普通用户计划面板。