From 8a18b1b181491f4ce62d95a35146d08717180bca Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E5=BE=B7=E5=AE=87?= Date: Thu, 3 Sep 2026 10:51:52 +0800 Subject: [PATCH 01/28] =?UTF-8?q?=E8=B0=83=E6=95=B4=20UI=20=E7=BC=96?= =?UTF-8?q?=E8=BE=91=E5=99=A8=E8=8A=82=E7=82=B9=E5=8F=98=E6=8D=A2=E4=B8=BA?= =?UTF-8?q?=E6=9D=BE=E5=BC=80=E6=97=B6=E6=8F=90=E4=BA=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 拖动和缩放期间仅更新预览临时变换 指针松开时提交最终变换,取消操作不写入 State 补充 UI 编辑器拖动变换提交边界文档 --- .../components/preview/PreviewWorkspace.tsx | 20 ++++++++++ .../components/preview/UiTreeRenderer.tsx | 14 ++++--- .../preview/useNodeTransformInteraction.ts | 37 +++++++++++++++---- ...【UI编辑器】拖动变换提交边界-2026-09-03.md | 14 +++++++ 4 files changed, 73 insertions(+), 12 deletions(-) create mode 100644 docs/【UI编辑器】拖动变换提交边界-2026-09-03.md diff --git a/apps/ai-game-creator-shell/src/view/ui-editor/components/preview/PreviewWorkspace.tsx b/apps/ai-game-creator-shell/src/view/ui-editor/components/preview/PreviewWorkspace.tsx index ec88d9571..f7d528b3e 100644 --- a/apps/ai-game-creator-shell/src/view/ui-editor/components/preview/PreviewWorkspace.tsx +++ b/apps/ai-game-creator-shell/src/view/ui-editor/components/preview/PreviewWorkspace.tsx @@ -24,6 +24,7 @@ import { } from 'react'; import { findNodePageContext } from '../../../../features/ui-editor/nodeTransformGeometry'; +import type { Node } from '../../../../features/ui-editor/types/Node'; import type { NodeId } from '../../../../features/ui-editor/types/NodeId'; import type { UiEditorCanvasProjection } from '../../useUiEditorPage'; import { UiNodeContextMenu } from '../UiNodeContextMenu'; @@ -48,6 +49,9 @@ export function PreviewWorkspace({ const [renderMode, setRenderMode] = useState('editor-overlay'); const [showFrame, setShowFrame] = useState(false); + const [previewTransforms, setPreviewTransforms] = useState< + ReadonlyMap + >(new Map()); const [contextMenu, setContextMenu] = useState<{ nodeId: NodeId; x: number; @@ -55,6 +59,20 @@ export function PreviewWorkspace({ isPageRoot: boolean; } | null>(null); const tree = canvas.tree ?? null; + const updatePreviewTransform = useCallback( + (nodeId: NodeId, transform: Node['layout']['transform'] | null) => { + setPreviewTransforms((current) => { + const next = new Map(current); + if (transform) next.set(nodeId, transform); + else next.delete(nodeId); + return next; + }); + }, + [], + ); + useEffect(() => { + setPreviewTransforms(new Map()); + }, [activeImageId]); const activeImagePixelWidth = activeImage?.pixel_size[0]; const activeImagePixelHeight = activeImage?.pixel_size[1]; const activeImagePixelsPerUnit = activeImage?.pixels_per_unit; @@ -88,6 +106,7 @@ export function PreviewWorkspace({ spaceHeld, tree, viewportRef, + onPreviewTransform: updatePreviewTransform, }); const { onNodePointerDown, @@ -382,6 +401,7 @@ export function PreviewWorkspace({ renderMode={renderMode} showFrame={showFrame} hiddenNodeIds={canvas.hiddenNodeIds} + previewTransforms={previewTransforms} selectedNodeId={canvas.selectedNodeId} resources={{ previewUrls, diff --git a/apps/ai-game-creator-shell/src/view/ui-editor/components/preview/UiTreeRenderer.tsx b/apps/ai-game-creator-shell/src/view/ui-editor/components/preview/UiTreeRenderer.tsx index ad91c0571..806c64e33 100644 --- a/apps/ai-game-creator-shell/src/view/ui-editor/components/preview/UiTreeRenderer.tsx +++ b/apps/ai-game-creator-shell/src/view/ui-editor/components/preview/UiTreeRenderer.tsx @@ -32,6 +32,7 @@ type UiTreeRendererProps = { renderMode: UiEditorRenderMode; showFrame: boolean; hiddenNodeIds: ReadonlySet; + previewTransforms?: ReadonlyMap; selectedNodeId: NodeId | null; resources: PreviewComponentResources; onSelectNode: (id: NodeId) => void; @@ -77,6 +78,7 @@ function RenderNode({ renderMode, showFrame, hiddenNodeIds, + previewTransforms, selectedNodeId, resources, onSelectNode, @@ -94,14 +96,15 @@ function RenderNode({ isRoot?: boolean; parentContainer?: UiNode['layout']['container']; }) { + const activePreviewTransforms = previewTransforms ?? new Map(); if (hiddenNodeIds.has(node.id)) return null; + const layout = activePreviewTransforms.has(node.id) + ? { ...node.layout, transform: activePreviewTransforms.get(node.id)! } + : node.layout; let geometry; try { - geometry = controlLayoutToPreviewCss( - node.layout, - parentContainer !== undefined, - ); + geometry = controlLayoutToPreviewCss(layout, parentContainer !== undefined); } catch { // Keep malformed nodes isolated from the rest of the tree. return null; @@ -120,7 +123,7 @@ function RenderNode({ style={{ ...geometry, ...(parentContainer - ? childInContainerToPreviewCss(node.layout, parentContainer) + ? childInContainerToPreviewCss(layout, parentContainer) : {}), ...containerToPreviewCss(node.layout.container), ...(isFrameVisible @@ -189,6 +192,7 @@ function RenderNode({ renderMode={renderMode} showFrame={showFrame} hiddenNodeIds={hiddenNodeIds} + previewTransforms={activePreviewTransforms} selectedNodeId={selectedNodeId} resources={resources} onSelectNode={onSelectNode} diff --git a/apps/ai-game-creator-shell/src/view/ui-editor/components/preview/useNodeTransformInteraction.ts b/apps/ai-game-creator-shell/src/view/ui-editor/components/preview/useNodeTransformInteraction.ts index 1d22c3531..3208dfba8 100644 --- a/apps/ai-game-creator-shell/src/view/ui-editor/components/preview/useNodeTransformInteraction.ts +++ b/apps/ai-game-creator-shell/src/view/ui-editor/components/preview/useNodeTransformInteraction.ts @@ -29,6 +29,7 @@ type GestureBase = { startClientY: number; startTransform: UiNode['layout']['transform']; hasMoved: boolean; + pendingTransform?: UiNode['layout']['transform']; }; type ActiveGesture = @@ -85,6 +86,7 @@ export function useNodeTransformInteraction({ spaceHeld, tree, viewportRef, + onPreviewTransform, }: { activeImageId: UiEditorCanvasProjection['activeImageId']; canvas: Pick; @@ -92,14 +94,21 @@ export function useNodeTransformInteraction({ spaceHeld: boolean; tree: UITree | null; viewportRef: RefObject; + onPreviewTransform?: ( + nodeId: string, + transform: UiNode['layout']['transform'] | null, + ) => void; }) { const activeGestureRef = useRef(null); const cancelGesture = useCallback(() => { const gesture = activeGestureRef.current; - if (gesture) releasePointer(gesture.target, gesture.pointerId); + if (gesture) { + releasePointer(gesture.target, gesture.pointerId); + onPreviewTransform?.(gesture.nodeId, null); + } activeGestureRef.current = null; - }, []); + }, [onPreviewTransform]); useEffect(() => cancelGesture, [cancelGesture]); @@ -185,18 +194,31 @@ export function useNodeTransformInteraction({ cancelGesture(); return; } - canvas.updateNodeTransform(gesture.treeId, gesture.nodeId, nextTransform); + gesture.pendingTransform = nextTransform; + onPreviewTransform?.(gesture.nodeId, nextTransform); }, - [acceptsGestureEvent, cancelGesture, canvas, viewportRef], + [acceptsGestureEvent, cancelGesture, onPreviewTransform, viewportRef], ); const onNodePointerUp = useCallback( (event: ReactPointerEvent) => { if (!acceptsGestureEvent(event)) return; event.stopPropagation(); + const gesture = activeGestureRef.current; + if ( + event.type !== 'pointercancel' && + gesture?.hasMoved && + gesture.pendingTransform + ) { + canvas.updateNodeTransform( + gesture.treeId, + gesture.nodeId, + gesture.pendingTransform, + ); + } cancelGesture(); }, - [acceptsGestureEvent, cancelGesture], + [acceptsGestureEvent, cancelGesture, canvas], ); const onNodeResizePointerDown = useCallback( @@ -310,9 +332,10 @@ export function useNodeTransformInteraction({ return; } gesture.hasMoved = true; - canvas.updateNodeTransform(gesture.treeId, gesture.nodeId, nextTransform); + gesture.pendingTransform = nextTransform; + onPreviewTransform?.(gesture.nodeId, nextTransform); }, - [acceptsGestureEvent, cancelGesture, canvas, viewportRef], + [acceptsGestureEvent, cancelGesture, onPreviewTransform, viewportRef], ); return { diff --git a/docs/【UI编辑器】拖动变换提交边界-2026-09-03.md b/docs/【UI编辑器】拖动变换提交边界-2026-09-03.md new file mode 100644 index 000000000..6988d039d --- /dev/null +++ b/docs/【UI编辑器】拖动变换提交边界-2026-09-03.md @@ -0,0 +1,14 @@ +# UI 编辑器拖动变换提交边界 + +## 当前约定 + +UI 编辑器预览中的节点拖动和缩放在指针移动期间只更新预览层的临时变换,不写入编辑器 State。指针松开时才把最后一次有效变换提交到 State,因此一次拖动或缩放只产生一次正式编辑更新。 + +指针取消、页面切换、树切换、组件卸载或没有超过拖动阈值时,不提交变换,并清理临时预览值。指针松开后的最终变换属于正常 State 修改,会参与脏状态、保存和后端持久化;仅拖动期间的临时变换不会进入这些流程。资产文件也不会因该交互被删除。 + +## 实现边界 + +- `useNodeTransformInteraction` 保存手势起始变换和最后一次有效变换。 +- `UiTreeRenderer` 通过 `previewTransforms` 渲染临时变换。 +- `canvas.updateNodeTransform` 仅在 `pointerup` 提交,`pointercancel` 不提交。 +- 拖动和缩放继续共用单指针捕获与有限数校验。 From c519f65ac25d2f707474fb88f115b59176ff0154 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E5=BE=B7=E5=AE=87?= Date: Thu, 3 Sep 2026 10:58:54 +0800 Subject: [PATCH 02/28] =?UTF-8?q?=E8=A1=A5=E5=85=85=20UI=20=E7=BC=96?= =?UTF-8?q?=E8=BE=91=E5=99=A8=E6=92=A4=E9=94=80=E9=87=8D=E5=81=9A=E8=A7=84?= =?UTF-8?q?=E8=8C=83?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 明确撤销与重做的 State 范围、事务边界和生命周期 明确连续控件与节点变换的低频提交规则 明确资产文件、副作用、快捷键和验收标准 --- docs/【UI编辑器】撤销重做规范-2026-09-03.md | 69 +++++++++++++++++++++ 1 file changed, 69 insertions(+) create mode 100644 docs/【UI编辑器】撤销重做规范-2026-09-03.md diff --git a/docs/【UI编辑器】撤销重做规范-2026-09-03.md b/docs/【UI编辑器】撤销重做规范-2026-09-03.md new file mode 100644 index 000000000..51b9c9c85 --- /dev/null +++ b/docs/【UI编辑器】撤销重做规范-2026-09-03.md @@ -0,0 +1,69 @@ +# UI 编辑器撤销与重做规范 + +## 目标 + +UI 编辑器支持撤销最近一次或多次作品编辑,并支持重做被撤销的编辑,降低误操作返工成本,同时保持现有保存、AI 工作流和资产文件行为不变。 + +## 适用范围 + +撤销历史属于当前 UI 编辑会话,历史只保存可序列化的编辑器 `State` 快照,不保存页面临时状态。 + +纳入历史的操作: + +- 设计图名称、描述、角色和从属关系修改; +- 设计图、精灵、字体资源的新增、删除和元数据修改; +- 节点新增、删除、移动、Transform、Layout、元数据、子节点显示模式修改; +- 组件新增、删除、排序和字段修改; +- AI suggest、recognize、merge、bind,以及批量导入、批量删除等批量 State 修改,整次成功调用作为一条记录; +- 节点拖动或缩放,按一次按下到松开的连续操作作为一条记录。 + +不纳入历史的操作: + +- 选择项、隐藏节点、工作流步骤、面板展开状态和画布视口等 UI 临时状态; +- 打开/切换项目、服务端重新加载和清空编辑器;这些操作替换 State 后重置历史; +- 保存、自动保存、生成代码、发布请求本身;撤销只改变本地 State,后续保存才同步远端; +- 素材上传、AI 生成等已发生的外部副作用;若副作用同时落地了本地 State,只撤销本地 State 变化; +- no-op、锁定、校验失败或目标不存在的操作。 + +导入资源被撤销时只回退编辑器 State 中的资源记录和引用,不删除已经写入磁盘的资产文件;重做恢复原资源 ID 与文件引用。 + +## 历史模型 + +- 栈按会话全局维护,最多保留最近 100 条事务;超限丢弃最旧记录。 +- 每条记录保存 `before` 与 `after` 的完整结构化 State 快照,快照不复制二进制文件内容。 +- 提交前后快照相同则不产生记录。 +- 撤销将当前 State 恢复为记录的 `before`,并把记录移入 redo 栈;重做恢复 `after`。 +- 撤销后发生新的有效编辑时清空 redo 栈。 +- 撤销/重做恢复 State 时不得再次写入历史。 + +## 事务边界 + +- 普通字段、按钮和列表操作一次成功调用对应一条记录。 +- 节点拖动/缩放期间只更新预览层临时变换;松开时提交最终变换并生成一条记录。取消、卸载、切换资源、未越过阈值或无变化不提交。 +- AI 和批量操作通过事务边界合并内部多次 State 替换,整次成功调用只生成一条记录;失败不留下部分历史。 +- 颜色选择器、九宫格边界拖动等连续控件在交互期间使用本地 draft 预览,释放或确认时一次提交。 + +## 用户入口 + +- 桌面端页面工具栏提供撤销和重做按钮。 +- 非文本编辑目标聚焦编辑器时支持 `Cmd/Ctrl+Z` 撤销、`Cmd/Ctrl+Shift+Z` 和 `Ctrl+Y` 重做。 +- `input`、`textarea`、`select`、`contenteditable` 以及按钮/链接等控件交给浏览器原生行为,不拦截文本撤销。 +- 无可撤销或重做记录时按钮禁用,并提供可访问名称。 + +## 脏状态与选择 + +撤销和重做恢复的 State 继续参与现有 dirty 判定、保存和后端持久化。历史快照不包含当前设计图、节点选择、隐藏集合或视口;恢复后若当前选择已不存在,页面清理无效选择并保持安全空态。 + +## 验收标准 + +1. 单次字段编辑可撤销和重做。 +2. 连续多次编辑按逆序撤销。 +3. 节点拖动/缩放一次手势只产生一条记录,取消和零变化不产生记录。 +4. AI/批量操作一次调用只产生一条记录,失败不产生部分记录。 +5. 撤销后新编辑清空 redo。 +6. 加载/切换/清空重置历史;保存/自动保存不清空历史。 +7. 撤销/重做资源导入不删除资产文件,并恢复原资源引用。 +8. 工具栏按钮、禁用态和桌面快捷键可用,文本控件保留原生撤销。 +9. no-op、锁定、校验失败和不存在目标不进入历史。 +10. 颜色选择器和九宫格边界拖动不会按每个 pointer move 写入 State。 + From f79724cb173cd08f18f89f1b06731b1d5b11a52e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E5=BE=B7=E5=AE=87?= Date: Thu, 3 Sep 2026 11:01:21 +0800 Subject: [PATCH 03/28] =?UTF-8?q?=E5=AE=9E=E7=8E=B0=20UI=20=E7=BC=96?= =?UTF-8?q?=E8=BE=91=E5=99=A8=20State=20=E6=92=A4=E9=94=80=E9=87=8D?= =?UTF-8?q?=E5=81=9A=E6=A0=B8=E5=BF=83?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 在统一 State 提交入口增加 100 条快照历史与 no-op 过滤 提供撤销、重做、事务分组及加载重置历史 API 补充 State 历史、事务回滚和加载清理测试 --- .../features/ui-editor/useUiEditorState.ts | 141 +++++++++++++++++- .../src/view/ui-editor/useUiEditorPage.ts | 2 +- .../tests/uiEditorState.test.ts | 107 +++++++++++++ 3 files changed, 245 insertions(+), 5 deletions(-) diff --git a/apps/ai-game-creator-shell/src/features/ui-editor/useUiEditorState.ts b/apps/ai-game-creator-shell/src/features/ui-editor/useUiEditorState.ts index 5bfe37adf..cf85bd10b 100644 --- a/apps/ai-game-creator-shell/src/features/ui-editor/useUiEditorState.ts +++ b/apps/ai-game-creator-shell/src/features/ui-editor/useUiEditorState.ts @@ -45,6 +45,15 @@ export type UiEditorOperationResult = | { ok: true; value: T } | { ok: false; reason: UiEditorOperationFailureReason }; +export type UiEditorHistoryState = { + canUndo: boolean; + canRedo: boolean; +}; + +export type UiEditorReplaceStateOptions = { + history?: 'record' | 'reset' | 'skip'; +}; + type UiEditorOperationFailure = Extract; export type NodeMetadataPatch = Partial< @@ -505,15 +514,123 @@ export function useUiEditorState(initialState: State = EMPTY_UI_EDITOR_STATE) { return next; }); const [isLocked, setIsLocked] = useState(false); + const [historyState, setHistoryState] = useState({ + canUndo: false, + canRedo: false, + }); const stateRef = useRef(state); const isLockedRef = useRef(false); + const undoStackRef = useRef>([]); + const redoStackRef = useRef>([]); + const transactionRef = useRef<{ before: State } | null>(null); stateRef.current = state; - const commit = useCallback((nextState: State) => { + const syncHistoryState = useCallback(() => { + setHistoryState({ + canUndo: undoStackRef.current.length > 0, + canRedo: redoStackRef.current.length > 0, + }); + }, []); + + const applyState = useCallback((nextState: State) => { stateRef.current = nextState; setState(nextState); }, []); + const commit = useCallback( + (nextState: State) => { + const current = stateRef.current; + if (JSON.stringify(current) === JSON.stringify(nextState)) return false; + if (transactionRef.current === null) { + undoStackRef.current.push({ + before: cloneState(current), + after: cloneState(nextState), + }); + if (undoStackRef.current.length > 100) { + undoStackRef.current.shift(); + } + redoStackRef.current = []; + syncHistoryState(); + } + applyState(nextState); + return true; + }, + [applyState, syncHistoryState], + ); + + const resetHistory = useCallback(() => { + undoStackRef.current = []; + redoStackRef.current = []; + transactionRef.current = null; + syncHistoryState(); + }, [syncHistoryState]); + + const beginHistoryTransaction = useCallback(() => { + if (transactionRef.current === null) { + transactionRef.current = { before: cloneState(stateRef.current) }; + } + }, []); + + const endHistoryTransaction = useCallback(() => { + const transaction = transactionRef.current; + if (transaction === null) return; + transactionRef.current = null; + const current = stateRef.current; + if (JSON.stringify(transaction.before) === JSON.stringify(current)) { + return; + } + undoStackRef.current.push({ + before: transaction.before, + after: cloneState(current), + }); + if (undoStackRef.current.length > 100) undoStackRef.current.shift(); + redoStackRef.current = []; + syncHistoryState(); + }, [syncHistoryState]); + + const cancelHistoryTransaction = useCallback(() => { + const transaction = transactionRef.current; + if (transaction === null) return; + transactionRef.current = null; + applyState(transaction.before); + syncHistoryState(); + }, [applyState, syncHistoryState]); + + const runInHistoryTransaction = useCallback( + async (operation: () => Promise): Promise => { + beginHistoryTransaction(); + try { + const result = await operation(); + endHistoryTransaction(); + return result; + } catch (error) { + cancelHistoryTransaction(); + throw error; + } + }, + [beginHistoryTransaction, cancelHistoryTransaction, endHistoryTransaction], + ); + + const undo = useCallback(() => { + if (isLockedRef.current || transactionRef.current !== null) return false; + const entry = undoStackRef.current.pop(); + if (!entry) return false; + redoStackRef.current.push(entry); + applyState(cloneState(entry.before)); + syncHistoryState(); + return true; + }, [applyState, syncHistoryState]); + + const redo = useCallback(() => { + if (isLockedRef.current || transactionRef.current !== null) return false; + const entry = redoStackRef.current.pop(); + if (!entry) return false; + undoStackRef.current.push(entry); + applyState(cloneState(entry.after)); + syncHistoryState(); + return true; + }, [applyState, syncHistoryState]); + const guard = useCallback((): UiEditorOperationFailure | null => { // Every semantic write exits before reading or committing State while locked. return isLockedRef.current ? { ok: false, reason: 'locked' } : null; @@ -1402,17 +1519,33 @@ export function useUiEditorState(initialState: State = EMPTY_UI_EDITOR_STATE) { }, [commit, guard]); const replaceState = useCallback( - (nextState: State) => { + (nextState: State, options: UiEditorReplaceStateOptions = {}) => { const next = cloneState(nextState); synchronizeDesignImageTrees(next); - commit(next); + if (options.history === 'reset') { + resetHistory(); + applyState(next); + } else if (options.history === 'skip') { + applyState(next); + } else { + commit(next); + } }, - [commit], + [applyState, commit, resetHistory], ); return { state, + historyState, + undo, + redo, + beginHistoryTransaction, + endHistoryTransaction, + cancelHistoryTransaction, + runInHistoryTransaction, + resetHistory, + isLocked, runWithStateLocked, setImageName, diff --git a/apps/ai-game-creator-shell/src/view/ui-editor/useUiEditorPage.ts b/apps/ai-game-creator-shell/src/view/ui-editor/useUiEditorPage.ts index ae17854be..e7a085ec6 100644 --- a/apps/ai-game-creator-shell/src/view/ui-editor/useUiEditorPage.ts +++ b/apps/ai-game-creator-shell/src/view/ui-editor/useUiEditorPage.ts @@ -274,7 +274,7 @@ export function useUiEditorSession( .load(resourceId) .then(({ state, revision }) => { if (!cancelled) { - replaceEditorState(state); + replaceEditorState(state, { history: 'reset' }); const ids = Object.keys( state.ui_design_images, ).sort() as UIDesignImageId[]; diff --git a/apps/ai-game-creator-shell/tests/uiEditorState.test.ts b/apps/ai-game-creator-shell/tests/uiEditorState.test.ts index 43c9dac4d..fed357201 100644 --- a/apps/ai-game-creator-shell/tests/uiEditorState.test.ts +++ b/apps/ai-game-creator-shell/tests/uiEditorState.test.ts @@ -603,4 +603,111 @@ describe('useUiEditorState', () => { }), ]); }); + + it('records, undoes, redoes, and clears redo after a new edit', () => { + const initial: State = { + ...structuredClone(EMPTY_UI_EDITOR_STATE), + ui_design_images: { page: image('Page') }, + }; + const { result } = renderHook(() => useUiEditorState(initial)); + + act(() => { + result.current.setImageName('page', '第一次'); + }); + expect(result.current.historyState).toEqual({ + canUndo: true, + canRedo: false, + }); + + act(() => { + expect(result.current.undo()).toBe(true); + }); + expect(result.current.state.ui_design_images.page?.metadata.name).toBe( + 'Page', + ); + expect(result.current.historyState).toEqual({ + canUndo: false, + canRedo: true, + }); + + act(() => { + expect(result.current.redo()).toBe(true); + }); + expect(result.current.state.ui_design_images.page?.metadata.name).toBe( + '第一次', + ); + + act(() => { + result.current.setImageName('page', '第二次'); + expect(result.current.redo()).toBe(false); + }); + expect(result.current.state.ui_design_images.page?.metadata.name).toBe( + '第二次', + ); + }); + + it('does not record no-op edits and resets history when replacing loaded state', () => { + const initial: State = { + ...structuredClone(EMPTY_UI_EDITOR_STATE), + ui_design_images: { page: image('Page') }, + }; + const { result } = renderHook(() => useUiEditorState(initial)); + + act(() => { + result.current.setImageName('page', 'Page'); + }); + expect(result.current.historyState.canUndo).toBe(false); + + act(() => { + result.current.setImageName('page', '编辑后'); + result.current.replaceState(initial, { history: 'reset' }); + }); + expect(result.current.historyState).toEqual({ + canUndo: false, + canRedo: false, + }); + }); + + it('groups multiple replacements into one history transaction and rolls back failures', async () => { + const initial: State = { + ...structuredClone(EMPTY_UI_EDITOR_STATE), + ui_design_images: { page: image('Page') }, + }; + const { result } = renderHook(() => useUiEditorState(initial)); + + await act(async () => { + await result.current.runInHistoryTransaction(async () => { + result.current.replaceState({ + ...initial, + ui_design_images: { page: image('中间') }, + }); + result.current.replaceState({ + ...initial, + ui_design_images: { page: image('最终') }, + }); + }); + }); + expect(result.current.state.ui_design_images.page?.metadata.name).toBe( + '最终', + ); + act(() => { + expect(result.current.undo()).toBe(true); + }); + expect(result.current.state.ui_design_images.page?.metadata.name).toBe( + 'Page', + ); + + await expect( + result.current.runInHistoryTransaction(async () => { + result.current.replaceState({ + ...initial, + ui_design_images: { page: image('失败中间态') }, + }); + throw new Error('失败'); + }), + ).rejects.toThrow('失败'); + expect(result.current.state.ui_design_images.page?.metadata.name).toBe( + 'Page', + ); + }); }); From 15caf1cafc4a092ee15f85c247b3c63873f7fe72 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E5=BE=B7=E5=AE=87?= Date: Thu, 3 Sep 2026 11:05:04 +0800 Subject: [PATCH 04/28] =?UTF-8?q?=E6=94=B6=E6=95=9B=20UI=20=E7=BC=96?= =?UTF-8?q?=E8=BE=91=E5=99=A8=E9=94=81=E4=B8=8E=E5=8E=86=E5=8F=B2=E4=BA=8B?= =?UTF-8?q?=E5=8A=A1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 新增统一的加锁并包裹历史事务操作 将 AI 建议、识别、合并和批量绑定改用统一入口 --- .../src/features/ui-editor/useUiEditorState.ts | 7 +++++++ .../src/view/ui-editor/useUiEditorPage.ts | 13 +++++++++---- 2 files changed, 16 insertions(+), 4 deletions(-) diff --git a/apps/ai-game-creator-shell/src/features/ui-editor/useUiEditorState.ts b/apps/ai-game-creator-shell/src/features/ui-editor/useUiEditorState.ts index cf85bd10b..7d3966fd1 100644 --- a/apps/ai-game-creator-shell/src/features/ui-editor/useUiEditorState.ts +++ b/apps/ai-game-creator-shell/src/features/ui-editor/useUiEditorState.ts @@ -658,6 +658,12 @@ export function useUiEditorState(initialState: State = EMPTY_UI_EDITOR_STATE) { [], ); + const runWithStateLockedAndHistory = useCallback( + async (operation: (snapshot: State) => Promise): Promise => + runInHistoryTransaction(() => runWithStateLocked(operation)), + [runInHistoryTransaction, runWithStateLocked], + ); + const setImageName = useCallback( (id: UIDesignImageId, name: string): UiEditorOperationResult => { const blocked = guard(); @@ -1544,6 +1550,7 @@ export function useUiEditorState(initialState: State = EMPTY_UI_EDITOR_STATE) { endHistoryTransaction, cancelHistoryTransaction, runInHistoryTransaction, + runWithStateLockedAndHistory, resetHistory, isLocked, diff --git a/apps/ai-game-creator-shell/src/view/ui-editor/useUiEditorPage.ts b/apps/ai-game-creator-shell/src/view/ui-editor/useUiEditorPage.ts index e7a085ec6..29dc33c6a 100644 --- a/apps/ai-game-creator-shell/src/view/ui-editor/useUiEditorPage.ts +++ b/apps/ai-game-creator-shell/src/view/ui-editor/useUiEditorPage.ts @@ -942,7 +942,7 @@ export function useUiEditorSession( setSuggestionStatus(null); setIsSuggesting(true); try { - await editor.runWithStateLocked(async (snapshot) => { + await editor.runWithStateLockedAndHistory(async (snapshot) => { const suggestions = await invoke( 'suggest_ui_design_semantic', { projectPath, state: snapshot }, @@ -965,7 +965,7 @@ export function useUiEditorSession( setRecognitionStatus(null); setIsRecognizing(true); try { - await editor.runWithStateLocked(async (snapshot) => { + await editor.runWithStateLockedAndHistory(async (snapshot) => { const result = await invoke('recognize_ui', { projectPath, state: snapshot, @@ -990,7 +990,7 @@ export function useUiEditorSession( setMergeStatus(null); setIsMerging(true); try { - await editor.runWithStateLocked(async (snapshot) => { + await editor.runWithStateLockedAndHistory(async (snapshot) => { const result = await invoke('merge_ui', { state: snapshot }); editor.replaceState(applyMergeResult(snapshot, result)); setSelectedNodeId(null); @@ -1008,7 +1008,7 @@ export function useUiEditorSession( setBindingStatus(null); setIsBinding(true); try { - await editor.runWithStateLocked(async (snapshot) => { + await editor.runWithStateLockedAndHistory(async (snapshot) => { const allSpriteIds = Object.keys(snapshot.sprite_assets); const batches: string[][] = []; for ( @@ -1228,6 +1228,11 @@ export function useUiEditorSession( deleteNode, openClearDialog: () => setClearOpen(true), }, + history: { + ...editor.historyState, + undo: editor.undo, + redo: editor.redo, + }, inspector: { isLocked: editor.isLocked, projectPath, From e379459820fc873a7964f310ee8f4d54f709bc73 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E5=BE=B7=E5=AE=87?= Date: Thu, 3 Sep 2026 11:05:58 +0800 Subject: [PATCH 05/28] =?UTF-8?q?=E9=99=8D=E4=BD=8E=20UI=20=E7=BC=96?= =?UTF-8?q?=E8=BE=91=E5=99=A8=E9=A2=9C=E8=89=B2=E9=80=89=E6=8B=A9=E5=99=A8?= =?UTF-8?q?=E6=8F=90=E4=BA=A4=E9=A2=91=E7=8E=87?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 颜色拖动期间使用本地草稿预览 指针释放时一次提交最终颜色 State --- .../Inspector/Components/TextPanel.tsx | 28 +++++++++++-------- 1 file changed, 17 insertions(+), 11 deletions(-) diff --git a/apps/ai-game-creator-shell/src/view/ui-editor/components/Inspector/Components/TextPanel.tsx b/apps/ai-game-creator-shell/src/view/ui-editor/components/Inspector/Components/TextPanel.tsx index 9285614f4..b3b9f0d9c 100644 --- a/apps/ai-game-creator-shell/src/view/ui-editor/components/Inspector/Components/TextPanel.tsx +++ b/apps/ai-game-creator-shell/src/view/ui-editor/components/Inspector/Components/TextPanel.tsx @@ -1,4 +1,4 @@ -import { useState } from 'react'; +import { useEffect, useState } from 'react'; import { type RgbaColor, RgbaColorPicker } from 'react-colorful'; import type { FontSizing } from '../../../../../features/ui-editor/types/FontSizing'; @@ -21,19 +21,25 @@ export function TextPanel({ onChange, }: TextEditorProps) { const [colorOpen, setColorOpen] = useState(false); - const rgba: RgbaColor = { + const [colorDraft, setColorDraft] = useState(null); + const committedRgba: RgbaColor = { r: component.color[0] ?? 255, g: component.color[1] ?? 255, b: component.color[2] ?? 255, a: (component.color[3] ?? 255) / 255, }; + const rgba = colorDraft ?? committedRgba; + useEffect(() => setColorDraft(null), [component.color]); const bestFit = 'BestFit' in component.font_sizing ? component.font_sizing.BestFit : null; - const updateColor = (next: RgbaColor) => + const commitColor = (next: RgbaColor = rgba) => { + if (!colorDraft) return; + setColorDraft(null); onChange({ ...component, color: [next.r, next.g, next.b, Math.round(next.a * 255)], }); + }; return (
@@ -172,22 +178,22 @@ export function TextPanel({ {colorOpen && !readOnly ? (
- +
commitColor()} + onPointerCancel={() => setColorDraft(null)} + > + +
onChange({ ...component, - color: [ - component.color[0], - component.color[1], - component.color[2], - Number(event.target.value), - ], + color: [rgba.r, rgba.g, rgba.b, Number(event.target.value)], }) } /> From 0544e78996360db1dcf775a8ffe158774be9103d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E5=BE=B7=E5=AE=87?= Date: Thu, 3 Sep 2026 11:06:47 +0800 Subject: [PATCH 06/28] =?UTF-8?q?=E4=BF=AE=E6=AD=A3=E6=B8=85=E7=A9=BA?= =?UTF-8?q?=E7=BC=96=E8=BE=91=E5=99=A8=E5=8E=86=E5=8F=B2=E8=BE=B9=E7=95=8C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 清空编辑器时重置撤销与重做栈 补充清空操作不会进入历史的测试 --- .../src/features/ui-editor/useUiEditorState.ts | 5 +++-- apps/ai-game-creator-shell/tests/uiEditorState.test.ts | 10 ++++++++++ 2 files changed, 13 insertions(+), 2 deletions(-) diff --git a/apps/ai-game-creator-shell/src/features/ui-editor/useUiEditorState.ts b/apps/ai-game-creator-shell/src/features/ui-editor/useUiEditorState.ts index 7d3966fd1..13ffa45e8 100644 --- a/apps/ai-game-creator-shell/src/features/ui-editor/useUiEditorState.ts +++ b/apps/ai-game-creator-shell/src/features/ui-editor/useUiEditorState.ts @@ -1520,9 +1520,10 @@ export function useUiEditorState(initialState: State = EMPTY_UI_EDITOR_STATE) { const clearState = useCallback((): UiEditorOperationResult => { const blocked = guard(); if (blocked) return blocked; - commit(cloneState(EMPTY_UI_EDITOR_STATE)); + resetHistory(); + applyState(cloneState(EMPTY_UI_EDITOR_STATE)); return { ok: true, value: undefined }; - }, [commit, guard]); + }, [applyState, guard, resetHistory]); const replaceState = useCallback( (nextState: State, options: UiEditorReplaceStateOptions = {}) => { diff --git a/apps/ai-game-creator-shell/tests/uiEditorState.test.ts b/apps/ai-game-creator-shell/tests/uiEditorState.test.ts index fed357201..8962e441a 100644 --- a/apps/ai-game-creator-shell/tests/uiEditorState.test.ts +++ b/apps/ai-game-creator-shell/tests/uiEditorState.test.ts @@ -666,6 +666,16 @@ describe('useUiEditorState', () => { canUndo: false, canRedo: false, }); + + act(() => { + result.current.setImageName('page', '清空前'); + result.current.clearState(); + }); + expect(result.current.state).toEqual(EMPTY_UI_EDITOR_STATE); + expect(result.current.historyState).toEqual({ + canUndo: false, + canRedo: false, + }); }); it('groups multiple replacements into one history transaction and rolls back failures', async () => { From dc0ea0eda0d8d26f7a7f7dc9d01d951ac6337b7f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E5=BE=B7=E5=AE=87?= Date: Thu, 3 Sep 2026 11:07:19 +0800 Subject: [PATCH 07/28] =?UTF-8?q?=E6=8E=A5=E5=85=A5=20UI=20=E7=BC=96?= =?UTF-8?q?=E8=BE=91=E5=99=A8=E6=92=A4=E9=94=80=E9=87=8D=E5=81=9A=E5=85=A5?= =?UTF-8?q?=E5=8F=A3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 在编辑器头部增加撤销与重做按钮及禁用态 增加桌面 Cmd/Ctrl 快捷键并保留文本控件原生行为 --- .../src/view/ui-editor/index.tsx | 53 ++++++++++++++++++- 1 file changed, 52 insertions(+), 1 deletion(-) diff --git a/apps/ai-game-creator-shell/src/view/ui-editor/index.tsx b/apps/ai-game-creator-shell/src/view/ui-editor/index.tsx index 84267aad5..26cd2ba1f 100644 --- a/apps/ai-game-creator-shell/src/view/ui-editor/index.tsx +++ b/apps/ai-game-creator-shell/src/view/ui-editor/index.tsx @@ -1,4 +1,4 @@ -import { ChevronLeft } from 'lucide-react'; +import { ChevronLeft, Redo2, Undo2 } from 'lucide-react'; import { type ReactNode, useEffect, useMemo, useState } from 'react'; import { ThemedModal } from '../../components/modal/ThemedModal'; @@ -82,6 +82,37 @@ export default function UiEditorPage({ if (session.save.isDirty) setGenerateSuccess(null); }, [session.save.isDirty]); + useEffect(() => { + const isEditableTarget = (target: EventTarget | null) => { + const element = target instanceof HTMLElement ? target : null; + return Boolean( + element?.isContentEditable || + element?.closest( + 'input, textarea, select, button, a, [contenteditable="true"], [role="button"]', + ), + ); + }; + const onKeyDown = (event: KeyboardEvent) => { + if ( + isEditableTarget(event.target) || + (!event.ctrlKey && !event.metaKey) + ) { + return; + } + const isUndo = event.key.toLowerCase() === 'z' && !event.shiftKey; + const isRedo = + (event.key.toLowerCase() === 'z' && event.shiftKey) || + (event.ctrlKey && event.key.toLowerCase() === 'y'); + if (isUndo && session.history.undo()) { + event.preventDefault(); + } else if (isRedo && session.history.redo()) { + event.preventDefault(); + } + }; + window.addEventListener('keydown', onKeyDown); + return () => window.removeEventListener('keydown', onKeyDown); + }, [session.history]); + async function save(afterReturn = false) { if (await session.save.save()) { if (afterReturn) { @@ -155,6 +186,26 @@ export default function UiEditorPage({ {resourceLabel ?? 'UI 设计'}
+ + {walletEntry ? (
{walletEntry}
) : null} From 90623f136d047a17b6bf86866a2133d6834503c5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E5=BE=B7=E5=AE=87?= Date: Thu, 3 Sep 2026 11:08:52 +0800 Subject: [PATCH 08/28] =?UTF-8?q?=E7=99=BB=E8=AE=B0=20UI=20=E7=BC=96?= =?UTF-8?q?=E8=BE=91=E5=99=A8=E6=92=A4=E9=94=80=E9=87=8D=E5=81=9A=E8=A7=84?= =?UTF-8?q?=E8=8C=83?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 将撤销重做规格加入文档总览入口 --- docs/README.md | 1 + 1 file changed, 1 insertion(+) diff --git a/docs/README.md b/docs/README.md index 8492cccb9..9ffe3c1d0 100644 --- a/docs/README.md +++ b/docs/README.md @@ -32,6 +32,7 @@ - [UI 编辑器 Godot 容器布局](./technical/【技术方案】UI编辑器Godot容器布局模型-2026-08-18.md) - [UI 编辑器子节点显示规则](./technical/【技术方案】UI编辑器子节点显示规则-2026-08-18.md) - [UI 编辑会话模块边界](./technical/【前端架构】UI编辑会话模块边界-2026-08-19.md) +- [UI 编辑器撤销重做规范](./【UI编辑器】撤销重做规范-2026-09-03.md) ## 图片画布与媒体 From 093a2ace76b1ffcf91a6093b9b0581943696fee7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E5=BE=B7=E5=AE=87?= Date: Thu, 3 Sep 2026 11:51:07 +0800 Subject: [PATCH 09/28] =?UTF-8?q?=E4=BC=98=E5=8C=96=E9=A2=84=E8=A7=88?= =?UTF-8?q?=E5=8F=98=E6=8D=A2=E7=8A=B6=E6=80=81=E6=9B=B4=E6=96=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 仅在预览变换实际变化时创建新 Map\n避免取消不存在手势时触发预览树重渲染 --- .../src/view/ui-editor/components/preview/PreviewWorkspace.tsx | 2 ++ 1 file changed, 2 insertions(+) diff --git a/apps/ai-game-creator-shell/src/view/ui-editor/components/preview/PreviewWorkspace.tsx b/apps/ai-game-creator-shell/src/view/ui-editor/components/preview/PreviewWorkspace.tsx index f7d528b3e..7a5228442 100644 --- a/apps/ai-game-creator-shell/src/view/ui-editor/components/preview/PreviewWorkspace.tsx +++ b/apps/ai-game-creator-shell/src/view/ui-editor/components/preview/PreviewWorkspace.tsx @@ -62,6 +62,8 @@ export function PreviewWorkspace({ const updatePreviewTransform = useCallback( (nodeId: NodeId, transform: Node['layout']['transform'] | null) => { setPreviewTransforms((current) => { + if (!transform && !current.has(nodeId)) return current; + if (transform && current.get(nodeId) === transform) return current; const next = new Map(current); if (transform) next.set(nodeId, transform); else next.delete(nodeId); From f79ccccf44f3af0e161f7e459e70b1fbdf63963f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E5=BE=B7=E5=AE=87?= Date: Thu, 3 Sep 2026 11:51:34 +0800 Subject: [PATCH 10/28] =?UTF-8?q?=E7=A8=B3=E5=AE=9A=E9=A2=84=E8=A7=88?= =?UTF-8?q?=E6=89=8B=E5=8A=BF=E6=B8=85=E7=90=86=E5=9B=9E=E8=B0=83?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 通过 ref 读取最新预览回调\n避免回调身份变化时重复取消进行中的手势 --- .../components/preview/useNodeTransformInteraction.ts | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/apps/ai-game-creator-shell/src/view/ui-editor/components/preview/useNodeTransformInteraction.ts b/apps/ai-game-creator-shell/src/view/ui-editor/components/preview/useNodeTransformInteraction.ts index 3208dfba8..d2ac9e82a 100644 --- a/apps/ai-game-creator-shell/src/view/ui-editor/components/preview/useNodeTransformInteraction.ts +++ b/apps/ai-game-creator-shell/src/view/ui-editor/components/preview/useNodeTransformInteraction.ts @@ -100,15 +100,18 @@ export function useNodeTransformInteraction({ ) => void; }) { const activeGestureRef = useRef(null); + // Keep cleanup stable while still invoking the latest preview callback. + const onPreviewTransformRef = useRef(onPreviewTransform); + onPreviewTransformRef.current = onPreviewTransform; const cancelGesture = useCallback(() => { const gesture = activeGestureRef.current; if (gesture) { releasePointer(gesture.target, gesture.pointerId); - onPreviewTransform?.(gesture.nodeId, null); + onPreviewTransformRef.current?.(gesture.nodeId, null); } activeGestureRef.current = null; - }, [onPreviewTransform]); + }, []); useEffect(() => cancelGesture, [cancelGesture]); From e616e2c89950a9588a5dbc98923d62be64fb29ab Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E5=BE=B7=E5=AE=87?= Date: Thu, 3 Sep 2026 11:51:58 +0800 Subject: [PATCH 11/28] =?UTF-8?q?=E6=B8=85=E7=90=86=E9=80=8F=E6=98=8E?= =?UTF-8?q?=E5=BA=A6=E8=BE=93=E5=85=A5=E7=9A=84=E9=A2=9C=E8=89=B2=E8=8D=89?= =?UTF-8?q?=E7=A8=BF?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 透明度修改提交前显式清除颜色草稿\n避免无效更新后残留未提交颜色 --- .../components/Inspector/Components/TextPanel.tsx | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/apps/ai-game-creator-shell/src/view/ui-editor/components/Inspector/Components/TextPanel.tsx b/apps/ai-game-creator-shell/src/view/ui-editor/components/Inspector/Components/TextPanel.tsx index b3b9f0d9c..ea5c1ed8a 100644 --- a/apps/ai-game-creator-shell/src/view/ui-editor/components/Inspector/Components/TextPanel.tsx +++ b/apps/ai-game-creator-shell/src/view/ui-editor/components/Inspector/Components/TextPanel.tsx @@ -190,12 +190,13 @@ export function TextPanel({ max={255} step={1} value={Math.round(rgba.a * 255)} - onChange={(event) => + onChange={(event) => { + setColorDraft(null); onChange({ ...component, color: [rgba.r, rgba.g, rgba.b, Number(event.target.value)], - }) - } + }); + }} />
) : null} From 95f5904b92796df5aaf9da413fb9ec6945505ef8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E5=BE=B7=E5=AE=87?= Date: Thu, 3 Sep 2026 11:54:27 +0800 Subject: [PATCH 12/28] =?UTF-8?q?=E6=94=BE=E5=AE=BD=E7=BC=96=E8=BE=91?= =?UTF-8?q?=E5=99=A8=E5=BF=AB=E6=8D=B7=E9=94=AE=E7=84=A6=E7=82=B9=E9=99=90?= =?UTF-8?q?=E5=88=B6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 仅将文本输入控件视为可编辑目标\n保持工具栏按钮聚焦时仍可使用撤销重做快捷键 --- apps/ai-game-creator-shell/src/view/ui-editor/index.tsx | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/apps/ai-game-creator-shell/src/view/ui-editor/index.tsx b/apps/ai-game-creator-shell/src/view/ui-editor/index.tsx index 26cd2ba1f..da336c2af 100644 --- a/apps/ai-game-creator-shell/src/view/ui-editor/index.tsx +++ b/apps/ai-game-creator-shell/src/view/ui-editor/index.tsx @@ -87,9 +87,7 @@ export default function UiEditorPage({ const element = target instanceof HTMLElement ? target : null; return Boolean( element?.isContentEditable || - element?.closest( - 'input, textarea, select, button, a, [contenteditable="true"], [role="button"]', - ), + element?.closest('input, textarea, select, [contenteditable="true"]'), ); }; const onKeyDown = (event: KeyboardEvent) => { From 37c465dbc3a2027fd371b892bfa572aa0ce9e1bd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E5=BE=B7=E5=AE=87?= Date: Thu, 3 Sep 2026 11:54:59 +0800 Subject: [PATCH 13/28] =?UTF-8?q?=E7=A8=B3=E5=AE=9A=E7=BC=96=E8=BE=91?= =?UTF-8?q?=E5=99=A8=E5=BF=AB=E6=8D=B7=E9=94=AE=E7=9B=91=E5=90=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 按稳定的撤销重做回调绑定全局监听\n尊重已被其他处理器阻止的键盘事件 --- apps/ai-game-creator-shell/src/view/ui-editor/index.tsx | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/apps/ai-game-creator-shell/src/view/ui-editor/index.tsx b/apps/ai-game-creator-shell/src/view/ui-editor/index.tsx index da336c2af..a4c3f8220 100644 --- a/apps/ai-game-creator-shell/src/view/ui-editor/index.tsx +++ b/apps/ai-game-creator-shell/src/view/ui-editor/index.tsx @@ -77,6 +77,8 @@ export default function UiEditorPage({ Boolean(session.save.loadError) || session.save.persistedRevision === null || session.save.isLocked; + const historyUndo = session.history.undo; + const historyRedo = session.history.redo; useEffect(() => { if (session.save.isDirty) setGenerateSuccess(null); @@ -92,6 +94,7 @@ export default function UiEditorPage({ }; const onKeyDown = (event: KeyboardEvent) => { if ( + event.defaultPrevented || isEditableTarget(event.target) || (!event.ctrlKey && !event.metaKey) ) { @@ -101,15 +104,15 @@ export default function UiEditorPage({ const isRedo = (event.key.toLowerCase() === 'z' && event.shiftKey) || (event.ctrlKey && event.key.toLowerCase() === 'y'); - if (isUndo && session.history.undo()) { + if (isUndo && historyUndo()) { event.preventDefault(); - } else if (isRedo && session.history.redo()) { + } else if (isRedo && historyRedo()) { event.preventDefault(); } }; window.addEventListener('keydown', onKeyDown); return () => window.removeEventListener('keydown', onKeyDown); - }, [session.history]); + }, [historyRedo, historyUndo]); async function save(afterReturn = false) { if (await session.save.save()) { From b07c8a5e2593d9ee4b317fc34083c33de4056ff8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E5=BE=B7=E5=AE=87?= Date: Thu, 3 Sep 2026 12:00:36 +0800 Subject: [PATCH 14/28] =?UTF-8?q?=E4=BC=98=E5=8C=96=E7=BC=96=E8=BE=91?= =?UTF-8?q?=E5=99=A8=E7=8A=B6=E6=80=81=E7=9B=B8=E7=AD=89=E6=80=A7=E6=A3=80?= =?UTF-8?q?=E6=9F=A5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 用可提前退出的递归比较替代完整状态序列化\n减少每次编辑提交的无谓 JSON 字符串构造 --- .../features/ui-editor/useUiEditorState.ts | 29 +++++++++++++++++-- 1 file changed, 26 insertions(+), 3 deletions(-) diff --git a/apps/ai-game-creator-shell/src/features/ui-editor/useUiEditorState.ts b/apps/ai-game-creator-shell/src/features/ui-editor/useUiEditorState.ts index 13ffa45e8..36c49730b 100644 --- a/apps/ai-game-creator-shell/src/features/ui-editor/useUiEditorState.ts +++ b/apps/ai-game-creator-shell/src/features/ui-editor/useUiEditorState.ts @@ -237,7 +237,30 @@ function cloneState(state: State): State { } function sameResource(left: T, right: T): boolean { - return JSON.stringify(left) === JSON.stringify(right); + if (Object.is(left, right)) return true; + if ( + typeof left !== 'object' || + left === null || + typeof right !== 'object' || + right === null + ) { + return false; + } + if (Array.isArray(left) || Array.isArray(right)) { + if (!Array.isArray(left) || !Array.isArray(right)) return false; + if (left.length !== right.length) return false; + return left.every((value, index) => sameResource(value, right[index])); + } + const leftRecord = left as Record; + const rightRecord = right as Record; + const leftKeys = Object.keys(leftRecord); + const rightKeys = Object.keys(rightRecord); + if (leftKeys.length !== rightKeys.length) return false; + return leftKeys.every( + (key) => + Object.prototype.hasOwnProperty.call(rightRecord, key) && + sameResource(leftRecord[key], rightRecord[key]), + ); } function visitComponents(nodes: Node[], visit: (component: Component) => void) { @@ -540,7 +563,7 @@ export function useUiEditorState(initialState: State = EMPTY_UI_EDITOR_STATE) { const commit = useCallback( (nextState: State) => { const current = stateRef.current; - if (JSON.stringify(current) === JSON.stringify(nextState)) return false; + if (sameResource(current, nextState)) return false; if (transactionRef.current === null) { undoStackRef.current.push({ before: cloneState(current), @@ -576,7 +599,7 @@ export function useUiEditorState(initialState: State = EMPTY_UI_EDITOR_STATE) { if (transaction === null) return; transactionRef.current = null; const current = stateRef.current; - if (JSON.stringify(transaction.before) === JSON.stringify(current)) { + if (sameResource(transaction.before, current)) { return; } undoStackRef.current.push({ From d44c924f5eee204f0beb798d4c024ba7abb547c2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E5=BE=B7=E5=AE=87?= Date: Thu, 3 Sep 2026 12:14:21 +0800 Subject: [PATCH 15/28] =?UTF-8?q?=E7=AE=80=E5=8C=96=20UI=20=E7=BC=96?= =?UTF-8?q?=E8=BE=91=E5=99=A8=E5=8E=86=E5=8F=B2=E6=8F=90=E4=BA=A4=E8=BE=B9?= =?UTF-8?q?=E7=95=8C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 移除撤销事务 API 与额外事务状态 让绑定每个 batch 独立提交一条撤销记录 同步更新规格和独立提交测试 --- .../features/ui-editor/useUiEditorState.ts | 81 +++---------------- .../src/view/ui-editor/useUiEditorPage.ts | 8 +- .../tests/uiEditorState.test.ts | 35 +++----- docs/【UI编辑器】撤销重做规范-2026-09-03.md | 8 +- 4 files changed, 28 insertions(+), 104 deletions(-) diff --git a/apps/ai-game-creator-shell/src/features/ui-editor/useUiEditorState.ts b/apps/ai-game-creator-shell/src/features/ui-editor/useUiEditorState.ts index 36c49730b..f15be0431 100644 --- a/apps/ai-game-creator-shell/src/features/ui-editor/useUiEditorState.ts +++ b/apps/ai-game-creator-shell/src/features/ui-editor/useUiEditorState.ts @@ -545,7 +545,6 @@ export function useUiEditorState(initialState: State = EMPTY_UI_EDITOR_STATE) { const isLockedRef = useRef(false); const undoStackRef = useRef>([]); const redoStackRef = useRef>([]); - const transactionRef = useRef<{ before: State } | null>(null); stateRef.current = state; const syncHistoryState = useCallback(() => { @@ -564,17 +563,15 @@ export function useUiEditorState(initialState: State = EMPTY_UI_EDITOR_STATE) { (nextState: State) => { const current = stateRef.current; if (sameResource(current, nextState)) return false; - if (transactionRef.current === null) { - undoStackRef.current.push({ - before: cloneState(current), - after: cloneState(nextState), - }); - if (undoStackRef.current.length > 100) { - undoStackRef.current.shift(); - } - redoStackRef.current = []; - syncHistoryState(); + undoStackRef.current.push({ + before: cloneState(current), + after: cloneState(nextState), + }); + if (undoStackRef.current.length > 100) { + undoStackRef.current.shift(); } + redoStackRef.current = []; + syncHistoryState(); applyState(nextState); return true; }, @@ -584,58 +581,11 @@ export function useUiEditorState(initialState: State = EMPTY_UI_EDITOR_STATE) { const resetHistory = useCallback(() => { undoStackRef.current = []; redoStackRef.current = []; - transactionRef.current = null; syncHistoryState(); }, [syncHistoryState]); - const beginHistoryTransaction = useCallback(() => { - if (transactionRef.current === null) { - transactionRef.current = { before: cloneState(stateRef.current) }; - } - }, []); - - const endHistoryTransaction = useCallback(() => { - const transaction = transactionRef.current; - if (transaction === null) return; - transactionRef.current = null; - const current = stateRef.current; - if (sameResource(transaction.before, current)) { - return; - } - undoStackRef.current.push({ - before: transaction.before, - after: cloneState(current), - }); - if (undoStackRef.current.length > 100) undoStackRef.current.shift(); - redoStackRef.current = []; - syncHistoryState(); - }, [syncHistoryState]); - - const cancelHistoryTransaction = useCallback(() => { - const transaction = transactionRef.current; - if (transaction === null) return; - transactionRef.current = null; - applyState(transaction.before); - syncHistoryState(); - }, [applyState, syncHistoryState]); - - const runInHistoryTransaction = useCallback( - async (operation: () => Promise): Promise => { - beginHistoryTransaction(); - try { - const result = await operation(); - endHistoryTransaction(); - return result; - } catch (error) { - cancelHistoryTransaction(); - throw error; - } - }, - [beginHistoryTransaction, cancelHistoryTransaction, endHistoryTransaction], - ); - const undo = useCallback(() => { - if (isLockedRef.current || transactionRef.current !== null) return false; + if (isLockedRef.current) return false; const entry = undoStackRef.current.pop(); if (!entry) return false; redoStackRef.current.push(entry); @@ -645,7 +595,7 @@ export function useUiEditorState(initialState: State = EMPTY_UI_EDITOR_STATE) { }, [applyState, syncHistoryState]); const redo = useCallback(() => { - if (isLockedRef.current || transactionRef.current !== null) return false; + if (isLockedRef.current) return false; const entry = redoStackRef.current.pop(); if (!entry) return false; undoStackRef.current.push(entry); @@ -681,12 +631,6 @@ export function useUiEditorState(initialState: State = EMPTY_UI_EDITOR_STATE) { [], ); - const runWithStateLockedAndHistory = useCallback( - async (operation: (snapshot: State) => Promise): Promise => - runInHistoryTransaction(() => runWithStateLocked(operation)), - [runInHistoryTransaction, runWithStateLocked], - ); - const setImageName = useCallback( (id: UIDesignImageId, name: string): UiEditorOperationResult => { const blocked = guard(); @@ -1570,11 +1514,6 @@ export function useUiEditorState(initialState: State = EMPTY_UI_EDITOR_STATE) { historyState, undo, redo, - beginHistoryTransaction, - endHistoryTransaction, - cancelHistoryTransaction, - runInHistoryTransaction, - runWithStateLockedAndHistory, resetHistory, isLocked, diff --git a/apps/ai-game-creator-shell/src/view/ui-editor/useUiEditorPage.ts b/apps/ai-game-creator-shell/src/view/ui-editor/useUiEditorPage.ts index 29dc33c6a..abc6c8159 100644 --- a/apps/ai-game-creator-shell/src/view/ui-editor/useUiEditorPage.ts +++ b/apps/ai-game-creator-shell/src/view/ui-editor/useUiEditorPage.ts @@ -942,7 +942,7 @@ export function useUiEditorSession( setSuggestionStatus(null); setIsSuggesting(true); try { - await editor.runWithStateLockedAndHistory(async (snapshot) => { + await editor.runWithStateLocked(async (snapshot) => { const suggestions = await invoke( 'suggest_ui_design_semantic', { projectPath, state: snapshot }, @@ -965,7 +965,7 @@ export function useUiEditorSession( setRecognitionStatus(null); setIsRecognizing(true); try { - await editor.runWithStateLockedAndHistory(async (snapshot) => { + await editor.runWithStateLocked(async (snapshot) => { const result = await invoke('recognize_ui', { projectPath, state: snapshot, @@ -990,7 +990,7 @@ export function useUiEditorSession( setMergeStatus(null); setIsMerging(true); try { - await editor.runWithStateLockedAndHistory(async (snapshot) => { + await editor.runWithStateLocked(async (snapshot) => { const result = await invoke('merge_ui', { state: snapshot }); editor.replaceState(applyMergeResult(snapshot, result)); setSelectedNodeId(null); @@ -1008,7 +1008,7 @@ export function useUiEditorSession( setBindingStatus(null); setIsBinding(true); try { - await editor.runWithStateLockedAndHistory(async (snapshot) => { + await editor.runWithStateLocked(async (snapshot) => { const allSpriteIds = Object.keys(snapshot.sprite_assets); const batches: string[][] = []; for ( diff --git a/apps/ai-game-creator-shell/tests/uiEditorState.test.ts b/apps/ai-game-creator-shell/tests/uiEditorState.test.ts index 8962e441a..df8e47bdd 100644 --- a/apps/ai-game-creator-shell/tests/uiEditorState.test.ts +++ b/apps/ai-game-creator-shell/tests/uiEditorState.test.ts @@ -678,23 +678,21 @@ describe('useUiEditorState', () => { }); }); - it('groups multiple replacements into one history transaction and rolls back failures', async () => { + it('records each replacement as an independent history entry', () => { const initial: State = { ...structuredClone(EMPTY_UI_EDITOR_STATE), ui_design_images: { page: image('Page') }, }; const { result } = renderHook(() => useUiEditorState(initial)); - await act(async () => { - await result.current.runInHistoryTransaction(async () => { - result.current.replaceState({ - ...initial, - ui_design_images: { page: image('中间') }, - }); - result.current.replaceState({ - ...initial, - ui_design_images: { page: image('最终') }, - }); + act(() => { + result.current.replaceState({ + ...initial, + ui_design_images: { page: image('中间') }, + }); + result.current.replaceState({ + ...initial, + ui_design_images: { page: image('最终') }, }); }); expect(result.current.state.ui_design_images.page?.metadata.name).toBe( @@ -704,20 +702,7 @@ describe('useUiEditorState', () => { expect(result.current.undo()).toBe(true); }); expect(result.current.state.ui_design_images.page?.metadata.name).toBe( - 'Page', - ); - - await expect( - result.current.runInHistoryTransaction(async () => { - result.current.replaceState({ - ...initial, - ui_design_images: { page: image('失败中间态') }, - }); - throw new Error('失败'); - }), - ).rejects.toThrow('失败'); - expect(result.current.state.ui_design_images.page?.metadata.name).toBe( - 'Page', + '中间', ); }); }); diff --git a/docs/【UI编辑器】撤销重做规范-2026-09-03.md b/docs/【UI编辑器】撤销重做规范-2026-09-03.md index 51b9c9c85..f3912e000 100644 --- a/docs/【UI编辑器】撤销重做规范-2026-09-03.md +++ b/docs/【UI编辑器】撤销重做规范-2026-09-03.md @@ -14,7 +14,8 @@ UI 编辑器支持撤销最近一次或多次作品编辑,并支持重做被 - 设计图、精灵、字体资源的新增、删除和元数据修改; - 节点新增、删除、移动、Transform、Layout、元数据、子节点显示模式修改; - 组件新增、删除、排序和字段修改; -- AI suggest、recognize、merge、bind,以及批量导入、批量删除等批量 State 修改,整次成功调用作为一条记录; +- AI suggest、recognize、merge,以及批量导入、批量删除等批量 State 修改,整次成功调用作为一条记录; +- bind 按后端批次逐次提交,每个成功 batch 作为一条独立记录,便于逐批撤销; - 节点拖动或缩放,按一次按下到松开的连续操作作为一条记录。 不纳入历史的操作: @@ -40,7 +41,7 @@ UI 编辑器支持撤销最近一次或多次作品编辑,并支持重做被 - 普通字段、按钮和列表操作一次成功调用对应一条记录。 - 节点拖动/缩放期间只更新预览层临时变换;松开时提交最终变换并生成一条记录。取消、卸载、切换资源、未越过阈值或无变化不提交。 -- AI 和批量操作通过事务边界合并内部多次 State 替换,整次成功调用只生成一条记录;失败不留下部分历史。 +- 每次有效 `commit` 或 `replaceState` 都直接生成一条记录;bind 的每个成功 batch 独立提交。失败 batch 不产生记录,已完成的前序 batch 保留。 - 颜色选择器、九宫格边界拖动等连续控件在交互期间使用本地 draft 预览,释放或确认时一次提交。 ## 用户入口 @@ -59,11 +60,10 @@ UI 编辑器支持撤销最近一次或多次作品编辑,并支持重做被 1. 单次字段编辑可撤销和重做。 2. 连续多次编辑按逆序撤销。 3. 节点拖动/缩放一次手势只产生一条记录,取消和零变化不产生记录。 -4. AI/批量操作一次调用只产生一条记录,失败不产生部分记录。 +4. AI suggest/recognize/merge 一次调用只产生一条记录;bind 每个成功 batch 产生一条记录,失败不产生该 batch 记录。 5. 撤销后新编辑清空 redo。 6. 加载/切换/清空重置历史;保存/自动保存不清空历史。 7. 撤销/重做资源导入不删除资产文件,并恢复原资源引用。 8. 工具栏按钮、禁用态和桌面快捷键可用,文本控件保留原生撤销。 9. no-op、锁定、校验失败和不存在目标不进入历史。 10. 颜色选择器和九宫格边界拖动不会按每个 pointer move 写入 State。 - From 074ee615ae8ed2db61309904f4bc378ba7a7dd8a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E5=BE=B7=E5=AE=87?= Date: Thu, 3 Sep 2026 12:17:25 +0800 Subject: [PATCH 16/28] =?UTF-8?q?=E6=9B=B4=E6=96=B0=E8=8A=82=E7=82=B9?= =?UTF-8?q?=E6=8B=96=E6=8B=BD=E6=8F=90=E4=BA=A4=E6=B5=8B=E8=AF=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 断言 pointermove 仅更新预览\n断言 pointerup 才提交一次节点变换 --- .../tests/useNodeTransformInteraction.test.tsx | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/apps/ai-game-creator-shell/tests/useNodeTransformInteraction.test.tsx b/apps/ai-game-creator-shell/tests/useNodeTransformInteraction.test.tsx index 2b79ab689..6482858fe 100644 --- a/apps/ai-game-creator-shell/tests/useNodeTransformInteraction.test.tsx +++ b/apps/ai-game-creator-shell/tests/useNodeTransformInteraction.test.tsx @@ -125,7 +125,7 @@ describe('useNodeTransformInteraction', () => { }); expect(resizeTarget.setPointerCapture).not.toHaveBeenCalled(); - expect(canvasProjection.updateNodeTransform).toHaveBeenCalledTimes(1); + expect(canvasProjection.updateNodeTransform).not.toHaveBeenCalled(); act(() => { result.current.onNodePointerUp(pointerEvent(dragTarget, 1, 12, 8)); @@ -135,6 +135,7 @@ describe('useNodeTransformInteraction', () => { 'se', ); }); + expect(canvasProjection.updateNodeTransform).toHaveBeenCalledTimes(1); expect(resizeTarget.setPointerCapture).toHaveBeenCalledWith(2); }); From d6f0c2642fb6e37f0306b12fcdf2e46ef23eb688 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E5=BE=B7=E5=AE=87?= Date: Thu, 3 Sep 2026 12:17:36 +0800 Subject: [PATCH 17/28] =?UTF-8?q?=E6=8F=90=E4=BA=A4=E9=A2=9C=E8=89=B2?= =?UTF-8?q?=E9=80=89=E6=8B=A9=E5=99=A8=E7=BB=93=E6=9D=9F=E6=80=81=E8=8D=89?= =?UTF-8?q?=E7=A8=BF?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 使用 RgbaColorPicker 的 onChangeEnd 处理指针与键盘结束\n关闭颜色弹层时提交仍存在的草稿 --- .../Inspector/Components/TextPanel.tsx | 17 +++++++++++------ 1 file changed, 11 insertions(+), 6 deletions(-) diff --git a/apps/ai-game-creator-shell/src/view/ui-editor/components/Inspector/Components/TextPanel.tsx b/apps/ai-game-creator-shell/src/view/ui-editor/components/Inspector/Components/TextPanel.tsx index ea5c1ed8a..d6321de42 100644 --- a/apps/ai-game-creator-shell/src/view/ui-editor/components/Inspector/Components/TextPanel.tsx +++ b/apps/ai-game-creator-shell/src/view/ui-editor/components/Inspector/Components/TextPanel.tsx @@ -40,6 +40,10 @@ export function TextPanel({ color: [next.r, next.g, next.b, Math.round(next.a * 255)], }); }; + const toggleColorOpen = () => { + if (colorOpen) commitColor(); + setColorOpen((open) => !open); + }; return (
@@ -165,7 +169,7 @@ export function TextPanel({ type="button" className="mt-1 flex h-9 w-full items-center gap-2 rounded-lg border border-(--platform-subpanel-border) bg-white/65 px-2 text-left text-xs disabled:opacity-40" disabled={readOnly} - onClick={() => setColorOpen((open) => !open)} + onClick={toggleColorOpen} > {colorOpen && !readOnly ? (
-
commitColor()} - onPointerCancel={() => setColorDraft(null)} - > - +
setColorDraft(null)}> +
Date: Thu, 3 Sep 2026 13:13:43 +0800 Subject: [PATCH 18/28] =?UTF-8?q?=E4=BC=98=E5=8C=96=E9=A2=84=E8=A7=88?= =?UTF-8?q?=E5=8F=98=E6=8D=A2=E5=9B=9E=E9=80=80=E5=88=86=E9=85=8D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 复用空的预览变换映射,避免节点递归渲染重复分配 --- .../view/ui-editor/components/preview/UiTreeRenderer.tsx | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/apps/ai-game-creator-shell/src/view/ui-editor/components/preview/UiTreeRenderer.tsx b/apps/ai-game-creator-shell/src/view/ui-editor/components/preview/UiTreeRenderer.tsx index 806c64e33..90e7caf15 100644 --- a/apps/ai-game-creator-shell/src/view/ui-editor/components/preview/UiTreeRenderer.tsx +++ b/apps/ai-game-creator-shell/src/view/ui-editor/components/preview/UiTreeRenderer.tsx @@ -71,6 +71,11 @@ const RESIZE_HANDLES: ReadonlyArray<{ { id: 'w', left: '0%', top: '50%', cursor: 'ew-resize' }, ]; +const EMPTY_PREVIEW_TRANSFORMS: ReadonlyMap< + NodeId, + UiNode['layout']['transform'] +> = new Map(); + function RenderNode({ node, isRoot, @@ -96,7 +101,7 @@ function RenderNode({ isRoot?: boolean; parentContainer?: UiNode['layout']['container']; }) { - const activePreviewTransforms = previewTransforms ?? new Map(); + const activePreviewTransforms = previewTransforms ?? EMPTY_PREVIEW_TRANSFORMS; if (hiddenNodeIds.has(node.id)) return null; const layout = activePreviewTransforms.has(node.id) ? { ...node.layout, transform: activePreviewTransforms.get(node.id)! } From d60d93a32710f735d9e373ad29e9bb402f58b370 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E5=BE=B7=E5=AE=87?= Date: Thu, 3 Sep 2026 13:14:18 +0800 Subject: [PATCH 19/28] =?UTF-8?q?=E7=AE=80=E5=8C=96=E9=A2=84=E8=A7=88?= =?UTF-8?q?=E5=8F=98=E6=8D=A2=E6=9F=A5=E6=89=BE?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 单次读取节点预览变换,移除重复 Map 查找和非空断言 --- .../src/view/ui-editor/components/preview/UiTreeRenderer.tsx | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/apps/ai-game-creator-shell/src/view/ui-editor/components/preview/UiTreeRenderer.tsx b/apps/ai-game-creator-shell/src/view/ui-editor/components/preview/UiTreeRenderer.tsx index 90e7caf15..38d1b9454 100644 --- a/apps/ai-game-creator-shell/src/view/ui-editor/components/preview/UiTreeRenderer.tsx +++ b/apps/ai-game-creator-shell/src/view/ui-editor/components/preview/UiTreeRenderer.tsx @@ -103,8 +103,9 @@ function RenderNode({ }) { const activePreviewTransforms = previewTransforms ?? EMPTY_PREVIEW_TRANSFORMS; if (hiddenNodeIds.has(node.id)) return null; - const layout = activePreviewTransforms.has(node.id) - ? { ...node.layout, transform: activePreviewTransforms.get(node.id)! } + const previewTransform = activePreviewTransforms.get(node.id); + const layout = previewTransform + ? { ...node.layout, transform: previewTransform } : node.layout; let geometry; From 071998fee9641ee86cc7b729bdc319e9164ed424 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E5=BE=B7=E5=AE=87?= Date: Thu, 3 Sep 2026 13:14:43 +0800 Subject: [PATCH 20/28] =?UTF-8?q?=E6=8F=90=E5=8F=96=E7=BC=96=E8=BE=91?= =?UTF-8?q?=E5=99=A8=E5=8E=86=E5=8F=B2=E4=B8=8A=E9=99=90=E5=B8=B8=E9=87=8F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 为撤销历史容量 100 增加命名常量,便于理解和调节内存权衡 --- .../src/features/ui-editor/useUiEditorState.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/apps/ai-game-creator-shell/src/features/ui-editor/useUiEditorState.ts b/apps/ai-game-creator-shell/src/features/ui-editor/useUiEditorState.ts index f15be0431..2ef8569e5 100644 --- a/apps/ai-game-creator-shell/src/features/ui-editor/useUiEditorState.ts +++ b/apps/ai-game-creator-shell/src/features/ui-editor/useUiEditorState.ts @@ -33,6 +33,8 @@ export const EMPTY_UI_EDITOR_STATE: State = { font_assets: {}, }; +const MAX_HISTORY_LENGTH = 100; + export type UiEditorOperationFailureReason = | 'locked' | 'duplicate' @@ -567,7 +569,7 @@ export function useUiEditorState(initialState: State = EMPTY_UI_EDITOR_STATE) { before: cloneState(current), after: cloneState(nextState), }); - if (undoStackRef.current.length > 100) { + if (undoStackRef.current.length > MAX_HISTORY_LENGTH) { undoStackRef.current.shift(); } redoStackRef.current = []; From cb86ab8be98bf669f4cc10dec54c621df90d32b3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E5=BE=B7=E5=AE=87?= Date: Thu, 3 Sep 2026 13:15:01 +0800 Subject: [PATCH 21/28] =?UTF-8?q?=E5=87=8F=E5=B0=91=E7=BC=96=E8=BE=91?= =?UTF-8?q?=E5=99=A8=E5=8E=86=E5=8F=B2=E5=BF=AB=E7=85=A7=E5=A4=8D=E5=88=B6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 提交历史时复用已克隆的 nextState,避免每次编辑产生冗余深拷贝 --- .../src/features/ui-editor/useUiEditorState.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/ai-game-creator-shell/src/features/ui-editor/useUiEditorState.ts b/apps/ai-game-creator-shell/src/features/ui-editor/useUiEditorState.ts index 2ef8569e5..6b14446ec 100644 --- a/apps/ai-game-creator-shell/src/features/ui-editor/useUiEditorState.ts +++ b/apps/ai-game-creator-shell/src/features/ui-editor/useUiEditorState.ts @@ -567,7 +567,7 @@ export function useUiEditorState(initialState: State = EMPTY_UI_EDITOR_STATE) { if (sameResource(current, nextState)) return false; undoStackRef.current.push({ before: cloneState(current), - after: cloneState(nextState), + after: nextState, }); if (undoStackRef.current.length > MAX_HISTORY_LENGTH) { undoStackRef.current.shift(); From 6b332e8c1216f3d3ed2f340dae9603caba60d911 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E5=BE=B7=E5=AE=87?= Date: Thu, 3 Sep 2026 13:15:17 +0800 Subject: [PATCH 22/28] =?UTF-8?q?=E9=98=BB=E6=AD=A2=E5=BF=AB=E6=8D=B7?= =?UTF-8?q?=E9=94=AE=E9=87=8D=E5=A4=8D=E8=A7=A6=E5=8F=91=E5=8E=86=E5=8F=B2?= =?UTF-8?q?=E6=93=8D=E4=BD=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 忽略 keydown 重复事件,避免长按撤销或重做键耗尽历史记录 --- apps/ai-game-creator-shell/src/view/ui-editor/index.tsx | 1 + 1 file changed, 1 insertion(+) diff --git a/apps/ai-game-creator-shell/src/view/ui-editor/index.tsx b/apps/ai-game-creator-shell/src/view/ui-editor/index.tsx index a4c3f8220..241185424 100644 --- a/apps/ai-game-creator-shell/src/view/ui-editor/index.tsx +++ b/apps/ai-game-creator-shell/src/view/ui-editor/index.tsx @@ -94,6 +94,7 @@ export default function UiEditorPage({ }; const onKeyDown = (event: KeyboardEvent) => { if ( + event.repeat || event.defaultPrevented || isEditableTarget(event.target) || (!event.ctrlKey && !event.metaKey) From 08caa1f29f3abf924361d916259ebc22ed466234 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E5=BE=B7=E5=AE=87?= Date: Thu, 3 Sep 2026 13:15:49 +0800 Subject: [PATCH 23/28] =?UTF-8?q?=E9=98=B2=E6=AD=A2=E8=B5=84=E6=BA=90?= =?UTF-8?q?=E6=AF=94=E8=BE=83=E5=BE=AA=E7=8E=AF=E9=80=92=E5=BD=92=E6=BA=A2?= =?UTF-8?q?=E5=87=BA?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 为 sameResource 增加对象对访问跟踪,遇到循环结构时安全终止递归 --- .../features/ui-editor/useUiEditorState.ts | 22 ++++++++++++++++--- 1 file changed, 19 insertions(+), 3 deletions(-) diff --git a/apps/ai-game-creator-shell/src/features/ui-editor/useUiEditorState.ts b/apps/ai-game-creator-shell/src/features/ui-editor/useUiEditorState.ts index 6b14446ec..f87b140bf 100644 --- a/apps/ai-game-creator-shell/src/features/ui-editor/useUiEditorState.ts +++ b/apps/ai-game-creator-shell/src/features/ui-editor/useUiEditorState.ts @@ -238,7 +238,11 @@ function cloneState(state: State): State { return structuredClone(state); } -function sameResource(left: T, right: T): boolean { +function sameResource( + left: T, + right: T, + seenPairs = new WeakMap>(), +): boolean { if (Object.is(left, right)) return true; if ( typeof left !== 'object' || @@ -248,10 +252,22 @@ function sameResource(left: T, right: T): boolean { ) { return false; } + const leftObject = left as object; + const rightObject = right as object; + let seenRightObjects = seenPairs.get(leftObject); + if (seenRightObjects?.has(rightObject)) return true; + if (!seenRightObjects) { + seenRightObjects = new WeakSet(); + seenPairs.set(leftObject, seenRightObjects); + } + seenRightObjects.add(rightObject); + if (Array.isArray(left) || Array.isArray(right)) { if (!Array.isArray(left) || !Array.isArray(right)) return false; if (left.length !== right.length) return false; - return left.every((value, index) => sameResource(value, right[index])); + return left.every((value, index) => + sameResource(value, right[index], seenPairs), + ); } const leftRecord = left as Record; const rightRecord = right as Record; @@ -261,7 +277,7 @@ function sameResource(left: T, right: T): boolean { return leftKeys.every( (key) => Object.prototype.hasOwnProperty.call(rightRecord, key) && - sameResource(leftRecord[key], rightRecord[key]), + sameResource(leftRecord[key], rightRecord[key], seenPairs), ); } From 79d3e5e6deac0338ffbbc98550f836174e231cb2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E5=BE=B7=E5=AE=87?= Date: Thu, 3 Sep 2026 13:17:15 +0800 Subject: [PATCH 24/28] =?UTF-8?q?=E4=BF=9D=E7=95=99=E9=A2=9C=E8=89=B2?= =?UTF-8?q?=E8=8D=89=E7=A8=BF=E5=8D=B8=E8=BD=BD=E6=97=B6=E7=9A=84=E4=BF=AE?= =?UTF-8?q?=E6=94=B9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 组件卸载前提交仍未关闭的颜色草稿,避免切换面板或删除组件导致颜色丢失 --- .../Inspector/Components/TextPanel.tsx | 39 ++++++++++++++++--- 1 file changed, 34 insertions(+), 5 deletions(-) diff --git a/apps/ai-game-creator-shell/src/view/ui-editor/components/Inspector/Components/TextPanel.tsx b/apps/ai-game-creator-shell/src/view/ui-editor/components/Inspector/Components/TextPanel.tsx index d6321de42..d576a7ccb 100644 --- a/apps/ai-game-creator-shell/src/view/ui-editor/components/Inspector/Components/TextPanel.tsx +++ b/apps/ai-game-creator-shell/src/view/ui-editor/components/Inspector/Components/TextPanel.tsx @@ -1,4 +1,4 @@ -import { useEffect, useState } from 'react'; +import { useEffect, useRef, useState } from 'react'; import { type RgbaColor, RgbaColorPicker } from 'react-colorful'; import type { FontSizing } from '../../../../../features/ui-editor/types/FontSizing'; @@ -22,6 +22,11 @@ export function TextPanel({ }: TextEditorProps) { const [colorOpen, setColorOpen] = useState(false); const [colorDraft, setColorDraft] = useState(null); + const colorDraftRef = useRef(null); + const componentRef = useRef(component); + const onChangeRef = useRef(onChange); + componentRef.current = component; + onChangeRef.current = onChange; const committedRgba: RgbaColor = { r: component.color[0] ?? 255, g: component.color[1] ?? 255, @@ -29,11 +34,26 @@ export function TextPanel({ a: (component.color[3] ?? 255) / 255, }; const rgba = colorDraft ?? committedRgba; - useEffect(() => setColorDraft(null), [component.color]); + useEffect(() => { + colorDraftRef.current = null; + setColorDraft(null); + }, [component.color]); + useEffect( + () => () => { + const draft = colorDraftRef.current; + if (!draft) return; + onChangeRef.current({ + ...componentRef.current, + color: [draft.r, draft.g, draft.b, Math.round(draft.a * 255)], + }); + }, + [], + ); const bestFit = 'BestFit' in component.font_sizing ? component.font_sizing.BestFit : null; const commitColor = (next: RgbaColor = rgba) => { - if (!colorDraft) return; + if (!colorDraftRef.current) return; + colorDraftRef.current = null; setColorDraft(null); onChange({ ...component, @@ -182,10 +202,18 @@ export function TextPanel({ {colorOpen && !readOnly ? (
-
setColorDraft(null)}> +
{ + colorDraftRef.current = null; + setColorDraft(null); + }} + > { + colorDraftRef.current = next; + setColorDraft(next); + }} onChangeEnd={commitColor} />
@@ -196,6 +224,7 @@ export function TextPanel({ step={1} value={Math.round(rgba.a * 255)} onChange={(event) => { + colorDraftRef.current = null; setColorDraft(null); onChange({ ...component, From e4680d6f7013f06739819da15d92535ebe87c743 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E5=BE=B7=E5=AE=87?= Date: Thu, 3 Sep 2026 13:38:19 +0800 Subject: [PATCH 25/28] =?UTF-8?q?=E7=BB=9F=E4=B8=80=E8=8A=82=E7=82=B9?= =?UTF-8?q?=E5=8F=98=E6=8D=A2=E5=AE=9E=E6=97=B6=E9=A2=84=E8=A7=88?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 预览拖动和缩放时同步解析保持子节点页面矩形的变换 将检查器的子节点保持选项传入预览交互并补充回归测试 --- .../components/preview/PreviewWorkspace.tsx | 1 + .../preview/useNodeTransformInteraction.ts | 133 +++++++++++++++++- .../src/view/ui-editor/useUiEditorPage.ts | 1 + .../useNodeTransformInteraction.test.tsx | 111 +++++++++++++++ 4 files changed, 240 insertions(+), 6 deletions(-) diff --git a/apps/ai-game-creator-shell/src/view/ui-editor/components/preview/PreviewWorkspace.tsx b/apps/ai-game-creator-shell/src/view/ui-editor/components/preview/PreviewWorkspace.tsx index 7a5228442..c2cae9af5 100644 --- a/apps/ai-game-creator-shell/src/view/ui-editor/components/preview/PreviewWorkspace.tsx +++ b/apps/ai-game-creator-shell/src/view/ui-editor/components/preview/PreviewWorkspace.tsx @@ -107,6 +107,7 @@ export function PreviewWorkspace({ logicalSize, spaceHeld, tree, + keepChildrenUnchanged: canvas.keepChildrenUnchanged, viewportRef, onPreviewTransform: updatePreviewTransform, }); diff --git a/apps/ai-game-creator-shell/src/view/ui-editor/components/preview/useNodeTransformInteraction.ts b/apps/ai-game-creator-shell/src/view/ui-editor/components/preview/useNodeTransformInteraction.ts index d2ac9e82a..74f652acb 100644 --- a/apps/ai-game-creator-shell/src/view/ui-editor/components/preview/useNodeTransformInteraction.ts +++ b/apps/ai-game-creator-shell/src/view/ui-editor/components/preview/useNodeTransformInteraction.ts @@ -9,9 +9,12 @@ import { import { findNodePageContext, type PageRect, + pageRectFromSize, type ResizeAxis, type ResizeHandle, resizePageRect, + resolveChildrenTransformsForParentRect, + resolvePageRect, resolveProportionalResizeAxis, setOffsetsForPageRect, } from '../../../../features/ui-editor/nodeTransformGeometry'; @@ -30,6 +33,7 @@ type GestureBase = { startTransform: UiNode['layout']['transform']; hasMoved: boolean; pendingTransform?: UiNode['layout']['transform']; + previewNodeIds: string[]; }; type ActiveGesture = @@ -79,12 +83,69 @@ function releasePointer(target: HTMLDivElement, pointerId: number) { } } +function previewNodeIds( + tree: UITree | null, + nodeId: string, + logicalSize: { width: number; height: number } | null, + keepChildrenUnchanged: boolean, +) { + if (!keepChildrenUnchanged || !tree || !logicalSize) return [nodeId]; + const context = findNodePageContext( + tree.root, + nodeId, + pageRectFromSize([logicalSize.width, logicalSize.height]), + ); + return context + ? [nodeId, ...context.node.children.map((child) => child.id)] + : [nodeId]; +} + +function emitPreviewTransforms( + gesture: ActiveGesture, + tree: UITree | null, + logicalSize: { width: number; height: number } | null, + keepChildrenUnchanged: boolean, + onPreviewTransform: ( + nodeId: string, + transform: UiNode['layout']['transform'] | null, + ) => void, +) { + onPreviewTransform(gesture.nodeId, gesture.pendingTransform ?? null); + if ( + !gesture.pendingTransform || + !keepChildrenUnchanged || + !tree || + !logicalSize + ) { + return; + } + const context = findNodePageContext( + tree.root, + gesture.nodeId, + pageRectFromSize([logicalSize.width, logicalSize.height]), + ); + if (!context) return; + const newNodeRect = resolvePageRect( + gesture.pendingTransform, + context.parentRect, + ); + if (!isFiniteRect(newNodeRect)) return; + for (const child of resolveChildrenTransformsForParentRect( + context.node.children, + context.rect, + newNodeRect, + )) { + onPreviewTransform(child.id, child.transform); + } +} + export function useNodeTransformInteraction({ activeImageId, canvas, logicalSize, spaceHeld, tree, + keepChildrenUnchanged, viewportRef, onPreviewTransform, }: { @@ -93,6 +154,7 @@ export function useNodeTransformInteraction({ logicalSize: { width: number; height: number } | null; spaceHeld: boolean; tree: UITree | null; + keepChildrenUnchanged: boolean; viewportRef: RefObject; onPreviewTransform?: ( nodeId: string, @@ -109,6 +171,11 @@ export function useNodeTransformInteraction({ if (gesture) { releasePointer(gesture.target, gesture.pointerId); onPreviewTransformRef.current?.(gesture.nodeId, null); + for (const nodeId of gesture.previewNodeIds) { + if (nodeId !== gesture.nodeId) { + onPreviewTransformRef.current?.(nodeId, null); + } + } } activeGestureRef.current = null; }, []); @@ -163,9 +230,22 @@ export function useNodeTransformInteraction({ startClientY: event.clientY, startTransform: structuredClone(node.layout.transform), hasMoved: false, + previewNodeIds: previewNodeIds( + tree, + node.id, + logicalSize, + keepChildrenUnchanged, + ), }; }, - [activeImageId, canvas, spaceHeld, tree?.root.id], + [ + activeImageId, + canvas, + keepChildrenUnchanged, + logicalSize, + spaceHeld, + tree, + ], ); const onNodePointerMove = useCallback( @@ -198,9 +278,23 @@ export function useNodeTransformInteraction({ return; } gesture.pendingTransform = nextTransform; - onPreviewTransform?.(gesture.nodeId, nextTransform); + emitPreviewTransforms( + gesture, + tree, + logicalSize, + keepChildrenUnchanged, + (nodeId, transform) => + onPreviewTransformRef.current?.(nodeId, transform), + ); }, - [acceptsGestureEvent, cancelGesture, onPreviewTransform, viewportRef], + [ + acceptsGestureEvent, + cancelGesture, + keepChildrenUnchanged, + logicalSize, + tree, + viewportRef, + ], ); const onNodePointerUp = useCallback( @@ -276,9 +370,22 @@ export function useNodeTransformInteraction({ parentRect: context.parentRect, ratioAxis: null, hasMoved: false, + previewNodeIds: previewNodeIds( + tree, + node.id, + logicalSize, + keepChildrenUnchanged, + ), }; }, - [activeImageId, canvas, logicalSize, spaceHeld, tree], + [ + activeImageId, + canvas, + keepChildrenUnchanged, + logicalSize, + spaceHeld, + tree, + ], ); const onNodeResizePointerMove = useCallback( @@ -336,9 +443,23 @@ export function useNodeTransformInteraction({ } gesture.hasMoved = true; gesture.pendingTransform = nextTransform; - onPreviewTransform?.(gesture.nodeId, nextTransform); + emitPreviewTransforms( + gesture, + tree, + logicalSize, + keepChildrenUnchanged, + (nodeId, transform) => + onPreviewTransformRef.current?.(nodeId, transform), + ); }, - [acceptsGestureEvent, cancelGesture, onPreviewTransform, viewportRef], + [ + acceptsGestureEvent, + cancelGesture, + keepChildrenUnchanged, + logicalSize, + tree, + viewportRef, + ], ); return { diff --git a/apps/ai-game-creator-shell/src/view/ui-editor/useUiEditorPage.ts b/apps/ai-game-creator-shell/src/view/ui-editor/useUiEditorPage.ts index abc6c8159..e00d86176 100644 --- a/apps/ai-game-creator-shell/src/view/ui-editor/useUiEditorPage.ts +++ b/apps/ai-game-creator-shell/src/view/ui-editor/useUiEditorPage.ts @@ -1214,6 +1214,7 @@ export function useUiEditorSession( tree: treeForActiveImage ?? null, selectedNode: selectedNodeContext?.node ?? null, selectedNodeId, + keepChildrenUnchanged, hiddenNodeIds, focusRequest, status, diff --git a/apps/ai-game-creator-shell/tests/useNodeTransformInteraction.test.tsx b/apps/ai-game-creator-shell/tests/useNodeTransformInteraction.test.tsx index 6482858fe..bd5f2071a 100644 --- a/apps/ai-game-creator-shell/tests/useNodeTransformInteraction.test.tsx +++ b/apps/ai-game-creator-shell/tests/useNodeTransformInteraction.test.tsx @@ -44,6 +44,17 @@ const pageTree: UITree = { root: node('root', [child]), }; +function transformedNode( + id: string, + transform: UiNode['layout']['transform'], + children: UiNode[] = [], +) { + return { + ...node(id, children), + layout: { ...node(id).layout, transform }, + }; +} + function gestureTarget() { const target = document.createElement('div'); Object.assign(target, { @@ -83,10 +94,17 @@ function renderInteraction({ activeImageId = 'page', scale = 1, tree = pageTree, + keepChildrenUnchanged = false, + onPreviewTransform, }: { activeImageId?: string | null; scale?: number; tree?: UITree | null; + keepChildrenUnchanged?: boolean; + onPreviewTransform?: ( + nodeId: string, + transform: UiNode['layout']['transform'] | null, + ) => void; } = {}) { const canvasProjection = canvas(); const viewportRef = { current: { scale } }; @@ -99,6 +117,8 @@ function renderInteraction({ spaceHeld: false, tree: currentTree, viewportRef, + keepChildrenUnchanged, + onPreviewTransform, }), { initialProps: { imageId: activeImageId, currentTree: tree } }, ); @@ -106,6 +126,97 @@ function renderInteraction({ } describe('useNodeTransformInteraction', () => { + it('previews stable child page rectangles when the parent moves', () => { + const nestedChild = transformedNode('child', { + anchor_min: [0, 0], + anchor_max: [0, 0], + offset_min: [10, 10], + offset_max: [60, 50], + }); + const parent = transformedNode( + 'parent', + { + anchor_min: [0, 0], + anchor_max: [0, 0], + offset_min: [20, 20], + offset_max: [120, 100], + }, + [nestedChild], + ); + const tree: UITree = { + src_ui_design: 'page', + root: node('root', [parent]), + }; + const onPreviewTransform = vi.fn(); + const { result } = renderInteraction({ + tree, + keepChildrenUnchanged: true, + onPreviewTransform, + }); + const target = gestureTarget(); + + act(() => { + result.current.onNodePointerDown(pointerEvent(target, 1, 0, 0), parent); + result.current.onNodePointerMove(pointerEvent(target, 1, 10, 5)); + }); + + expect(onPreviewTransform).toHaveBeenNthCalledWith( + 1, + 'parent', + expect.objectContaining({ offset_min: [30, 25], offset_max: [130, 105] }), + ); + expect(onPreviewTransform).toHaveBeenNthCalledWith( + 2, + 'child', + expect.objectContaining({ offset_min: [0, 5], offset_max: [50, 45] }), + ); + }); + + it('previews stable child page rectangles when the parent resizes', () => { + const nestedChild = transformedNode('child', { + anchor_min: [0, 0], + anchor_max: [0, 0], + offset_min: [10, 10], + offset_max: [60, 50], + }); + const parent = transformedNode( + 'parent', + { + anchor_min: [0, 0], + anchor_max: [0, 0], + offset_min: [20, 20], + offset_max: [120, 100], + }, + [nestedChild], + ); + const tree: UITree = { + src_ui_design: 'page', + root: node('root', [parent]), + }; + const onPreviewTransform = vi.fn(); + const { result } = renderInteraction({ + tree, + keepChildrenUnchanged: true, + onPreviewTransform, + }); + const target = gestureTarget(); + + act(() => { + result.current.onNodeResizePointerDown( + pointerEvent(target, 1, 0, 0), + parent, + 'nw', + ); + result.current.onNodeResizePointerMove(pointerEvent(target, 1, 10, 5)); + }); + + expect(onPreviewTransform).toHaveBeenNthCalledWith( + 2, + 'child', + expect.objectContaining({ offset_min: [0, 5], offset_max: [50, 45] }), + ); + }); + it('makes drag and resize mutually exclusive, then permits the next gesture', () => { const { result, canvasProjection } = renderInteraction(); const dragTarget = gestureTarget(); From 9fd160efc5fa5f2b6ea8db041f6b0c92168d7898 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E5=BE=B7=E5=AE=87?= Date: Thu, 3 Sep 2026 14:37:28 +0800 Subject: [PATCH 26/28] =?UTF-8?q?=E5=90=88=E5=B9=B6=E6=89=B9=E9=87=8F?= =?UTF-8?q?=E7=BB=84=E4=BB=B6=E7=BB=91=E5=AE=9A=E5=8E=86=E5=8F=B2=E8=AE=B0?= =?UTF-8?q?=E5=BD=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 批处理中的前置绑定批次跳过历史,最后一批使用非跳过替换统一形成撤销边界 为跳过后记录历史的状态基线补充撤销回归测试 --- .../features/ui-editor/useUiEditorState.ts | 17 +++++++- .../src/view/ui-editor/useUiEditorPage.ts | 4 +- .../tests/uiEditorState.test.ts | 43 +++++++++++++++++++ 3 files changed, 61 insertions(+), 3 deletions(-) diff --git a/apps/ai-game-creator-shell/src/features/ui-editor/useUiEditorState.ts b/apps/ai-game-creator-shell/src/features/ui-editor/useUiEditorState.ts index f87b140bf..a65ed50dc 100644 --- a/apps/ai-game-creator-shell/src/features/ui-editor/useUiEditorState.ts +++ b/apps/ai-game-creator-shell/src/features/ui-editor/useUiEditorState.ts @@ -563,6 +563,7 @@ export function useUiEditorState(initialState: State = EMPTY_UI_EDITOR_STATE) { const isLockedRef = useRef(false); const undoStackRef = useRef>([]); const redoStackRef = useRef>([]); + const pendingHistoryBeforeRef = useRef(null); stateRef.current = state; const syncHistoryState = useCallback(() => { @@ -580,15 +581,20 @@ export function useUiEditorState(initialState: State = EMPTY_UI_EDITOR_STATE) { const commit = useCallback( (nextState: State) => { const current = stateRef.current; - if (sameResource(current, nextState)) return false; + const before = pendingHistoryBeforeRef.current ?? current; + if (sameResource(before, nextState)) { + pendingHistoryBeforeRef.current = null; + return false; + } undoStackRef.current.push({ - before: cloneState(current), + before: cloneState(before), after: nextState, }); if (undoStackRef.current.length > MAX_HISTORY_LENGTH) { undoStackRef.current.shift(); } redoStackRef.current = []; + pendingHistoryBeforeRef.current = null; syncHistoryState(); applyState(nextState); return true; @@ -599,6 +605,7 @@ export function useUiEditorState(initialState: State = EMPTY_UI_EDITOR_STATE) { const resetHistory = useCallback(() => { undoStackRef.current = []; redoStackRef.current = []; + pendingHistoryBeforeRef.current = null; syncHistoryState(); }, [syncHistoryState]); @@ -606,6 +613,7 @@ export function useUiEditorState(initialState: State = EMPTY_UI_EDITOR_STATE) { if (isLockedRef.current) return false; const entry = undoStackRef.current.pop(); if (!entry) return false; + pendingHistoryBeforeRef.current = null; redoStackRef.current.push(entry); applyState(cloneState(entry.before)); syncHistoryState(); @@ -616,6 +624,7 @@ export function useUiEditorState(initialState: State = EMPTY_UI_EDITOR_STATE) { if (isLockedRef.current) return false; const entry = redoStackRef.current.pop(); if (!entry) return false; + pendingHistoryBeforeRef.current = null; undoStackRef.current.push(entry); applyState(cloneState(entry.after)); syncHistoryState(); @@ -642,6 +651,7 @@ export function useUiEditorState(initialState: State = EMPTY_UI_EDITOR_STATE) { try { return await operation(snapshot); } finally { + pendingHistoryBeforeRef.current = null; isLockedRef.current = false; setIsLocked(false); } @@ -1518,6 +1528,9 @@ export function useUiEditorState(initialState: State = EMPTY_UI_EDITOR_STATE) { resetHistory(); applyState(next); } else if (options.history === 'skip') { + if (!pendingHistoryBeforeRef.current) { + pendingHistoryBeforeRef.current = cloneState(stateRef.current); + } applyState(next); } else { commit(next); diff --git a/apps/ai-game-creator-shell/src/view/ui-editor/useUiEditorPage.ts b/apps/ai-game-creator-shell/src/view/ui-editor/useUiEditorPage.ts index e00d86176..b2c98484b 100644 --- a/apps/ai-game-creator-shell/src/view/ui-editor/useUiEditorPage.ts +++ b/apps/ai-game-creator-shell/src/view/ui-editor/useUiEditorPage.ts @@ -1028,7 +1028,9 @@ export function useUiEditorSession( spriteIds, }); current = applyBindingResult(current, result); - editor.replaceState(current); + editor.replaceState(current, { + history: index < batches.length - 1 ? 'skip' : 'record', + }); } setBindingStatus( `组件绑定完成(${batches.length}/${batches.length})。`, diff --git a/apps/ai-game-creator-shell/tests/uiEditorState.test.ts b/apps/ai-game-creator-shell/tests/uiEditorState.test.ts index df8e47bdd..89c4cdf3e 100644 --- a/apps/ai-game-creator-shell/tests/uiEditorState.test.ts +++ b/apps/ai-game-creator-shell/tests/uiEditorState.test.ts @@ -705,4 +705,47 @@ describe('useUiEditorState', () => { '中间', ); }); + + it('records one history entry after skipped replacement batches', () => { + const initial: State = { + ...structuredClone(EMPTY_UI_EDITOR_STATE), + ui_design_images: { page: image('Page') }, + }; + const { result } = renderHook(() => useUiEditorState(initial)); + + act(() => { + result.current.replaceState( + { + ...initial, + ui_design_images: { page: image('第一批') }, + }, + { history: 'skip' }, + ); + result.current.replaceState( + { + ...initial, + ui_design_images: { page: image('最终') }, + }, + { history: 'record' }, + ); + }); + + expect(result.current.state.ui_design_images.page?.metadata.name).toBe( + '最终', + ); + expect(result.current.historyState).toEqual({ + canUndo: true, + canRedo: false, + }); + act(() => { + expect(result.current.undo()).toBe(true); + }); + expect(result.current.state.ui_design_images.page?.metadata.name).toBe( + 'Page', + ); + expect(result.current.historyState).toEqual({ + canUndo: false, + canRedo: true, + }); + }); }); From 520b9344effef42d58bf9fc720c1072709da3449 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E5=BE=B7=E5=AE=87?= Date: Thu, 3 Sep 2026 19:30:21 +0800 Subject: [PATCH 27/28] =?UTF-8?q?Fix/AGC=E4=B8=8A=E4=B8=8B=E6=96=87?= =?UTF-8?q?=E4=B8=A2=E5=A4=B1=E9=97=AE=E9=A2=98=20(#247)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 权衡之后选择附加历史消息构造一个巨大的prompt before: ``` 1. user: aabb 2. assistant: bbaa 这里codex崩溃了或者重启上下文丢失 ``` after: 重启之后发送新消息abab codex看到: ``` user: aabb assistant: bbaa 这里崩溃了! user: abab ``` 移除了原来的"会话最后只有用户消息就自动重新发送用户消息"功能, 因为存在冲突: 现在的实现是每次用户手动发送新消息才做以上的步骤 before and after: ![shotmd-1788345830.jpg](/attachments/28549e4d-9913-459a-bae2-a195b8ee9e04) Close #249 --------- Co-authored-by: 段舒康 Reviewed-on: http://192.168.35.82/git/GenarrativeAI/Genarrative/pulls/247 Co-authored-by: 王德宇 Co-committed-by: 王德宇 --- .../src/pages/AdminErrorReportsPage.tsx | 14 +- .../src-tauri/src/agent/codex_app_server.rs | 368 +++++++++++++++++- .../src-tauri/src/agent/direct_runtime.rs | 128 ++++++ .../src/agent/runtime_driver/entrypoints.rs | 4 + apps/ai-game-creator-shell/src/App.tsx | 173 ++++---- .../tests/appSurface/home.suite.ts | 297 -------------- .../appSurface/project-conversation.suite.ts | 65 +--- .../shared-memory/decision-log.md | 12 + ...案】AI游戏创作智能体App实施计划-2026-06-24.md | 1 + 9 files changed, 584 insertions(+), 478 deletions(-) diff --git a/apps/admin-web/src/pages/AdminErrorReportsPage.tsx b/apps/admin-web/src/pages/AdminErrorReportsPage.tsx index e690a313b..fbb5f8650 100644 --- a/apps/admin-web/src/pages/AdminErrorReportsPage.tsx +++ b/apps/admin-web/src/pages/AdminErrorReportsPage.tsx @@ -88,10 +88,12 @@ export function AdminErrorReportsPage({ token, onUnauthorized }: Props) { setStatus(''); try { const updated = await updateAdminErrorReport(token, selected.batchId, { - status: nextStatus, - note: selected.note, - }); - setSelected((current) => (current ? { ...current, ...updated } : current)); + status: nextStatus, + note: selected.note, + }); + setSelected((current) => + current ? { ...current, ...updated } : current, + ); await loadRef.current(); } catch (error) { if (isAdminApiError(error) && error.status === 401) onUnauthorized(); @@ -244,7 +246,9 @@ export function AdminErrorReportsPage({ token, onUnauthorized }: Props) { value={selected.note ?? ''} onChange={(event) => setSelected((current) => - current ? { ...current, note: event.target.value } : current, + current + ? { ...current, note: event.target.value } + : current, ) } maxLength={2000} diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/codex_app_server.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/codex_app_server.rs index 13c863112..84504d2d8 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/codex_app_server.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/codex_app_server.rs @@ -1943,17 +1943,23 @@ impl CodexAppServerConnection { snapshot: &AgentRuntimeProviderRequestSnapshot, request: &LlmRunRequest, llm: &GameCreatorLlmConfig, - ) -> Result { + ) -> Result<(CodexThreadLease, bool), platform_llm::LlmError> { let key = CodexNodeThreadKey::from(snapshot); let mut threads = self.inner.threads.lock().await; + // The caller holds `turn_gate` while invoking this method. Returning + // the creation bit from the same threads lock keeps the replay + // decision atomic with thread reuse/creation. if let Some(entry) = threads.get_mut(&key) { entry.active_uses = entry.active_uses.saturating_add(1); entry.last_used = next_game_creator_codex_app_server_usage_tick(); - return Ok(CodexThreadLease { - connection: self.clone(), - key, - thread_id: entry.thread_id.clone(), - }); + return Ok(( + CodexThreadLease { + connection: self.clone(), + key, + thread_id: entry.thread_id.clone(), + }, + false, + )); } if threads.len() >= GAME_CREATOR_CODEX_APP_SERVER_THREAD_MAX { let evict_key = threads @@ -2015,11 +2021,14 @@ impl CodexAppServerConnection { active_uses: 1, }, ); - Ok(CodexThreadLease { - connection: self.clone(), - key, - thread_id, - }) + Ok(( + CodexThreadLease { + connection: self.clone(), + key, + thread_id, + }, + true, + )) } async fn register_turn(&self, turn_id: &str) -> mpsc::UnboundedReceiver { @@ -2085,11 +2094,60 @@ impl CodexAppServerConnection { mut on_agent_message_delta: Option<&mut (dyn FnMut(&platform_llm::LlmStreamDelta) + Send)>, mut direct_observer: Option<&mut (dyn FnMut(DirectCodexTurnObservation) + Send)>, mut audit: Option<&mut DirectCodexTurnAudit>, + ) -> Result { + self.run_turn_with_direct_observer_and_history( + snapshot, + llm, + request, + None, + None, + on_agent_message_delta, + direct_observer, + audit, + ) + .await + } + + async fn run_turn_with_direct_observer_and_history( + &self, + snapshot: &AgentRuntimeProviderRequestSnapshot, + llm: &GameCreatorLlmConfig, + request: LlmRunRequest, + direct_history_root: Option<&std::path::Path>, + direct_client_turn_id: Option<&str>, + mut on_agent_message_delta: Option<&mut (dyn FnMut(&platform_llm::LlmStreamDelta) + Send)>, + mut direct_observer: Option<&mut (dyn FnMut(DirectCodexTurnObservation) + Send)>, + mut audit: Option<&mut DirectCodexTurnAudit>, ) -> Result { let _turn_guard = self.inner.turn_gate.lock().await; - let thread_lease = self.thread_for(snapshot, &request, llm).await?; + let (thread_lease, thread_created) = self.thread_for(snapshot, &request, llm).await?; self.wait_for_initial_client_mcp_startup().await; let thread_id = thread_lease.thread_id.clone(); + let mut request = request; + if thread_created && self.inner.workspace_mode.uses_direct_conversation() { + // DirectProject owns the append-only project history in AGC. The + // replay builder derives a bounded prompt without mutating that + // durable fact source, so a new ephemeral thread can recover the + // newest contiguous context within the model budget. + let current_prompt = direct_codex_current_user_prompt(&request).to_string(); + let history_root = direct_history_root.unwrap_or(&self.inner.workspace_path); + let history_prompt = build_direct_codex_history_prompt( + history_root, + direct_client_turn_id.unwrap_or("__none__"), + ¤t_prompt, + &request, + llm, + ) + .map_err(platform_llm::LlmError::InvalidRequest)?; + if let Some(message) = request + .messages + .iter_mut() + .rev() + .find(|message| message.role == LlmMessageRole::User) + { + message.content = history_prompt; + } + } let prompt = if self.inner.workspace_mode.uses_direct_conversation() { direct_codex_user_prompt(&request) } else { @@ -3020,6 +3078,7 @@ pub(crate) async fn direct_game_creator_codex_chat_at( user_prompt, None, None, + None, ) .await } @@ -3034,6 +3093,7 @@ pub(crate) async fn direct_game_creator_codex_chat_at_with_observer( root, system_prompt, user_prompt, + None, Some(observer), None, ) @@ -3090,6 +3150,7 @@ pub(crate) async fn direct_game_creator_codex_chat_at_with_optional_observer( root: &std::path::Path, system_prompt: String, user_prompt: String, + client_turn_id: Option<&str>, observer: Option<&mut (dyn FnMut(DirectCodexTurnObservation) + Send)>, audit: Option<&mut DirectCodexTurnAudit>, ) -> Result { @@ -3129,11 +3190,6 @@ pub(crate) async fn direct_game_creator_codex_chat_at_with_optional_observer( }; let api_kind = parse_game_creator_llm_api_kind(&config.llm.api_kind).map_err(|error| error.to_string())?; - let request = LlmRunRequest::single_turn(system_prompt, user_prompt) - .with_api_kind(api_kind) - .with_model(config.llm.model.clone()) - .with_request_timeout_ms(config.llm.request_timeout_ms) - .with_max_output_tokens(16_000); let connection = CodexAppServerConnection::acquire_at_workspace( &snapshot, &config.llm, @@ -3142,13 +3198,176 @@ pub(crate) async fn direct_game_creator_codex_chat_at_with_optional_observer( ) .await .map_err(|error| error.to_string())?; + let request = LlmRunRequest::single_turn(system_prompt, user_prompt) + .with_api_kind(api_kind) + .with_model(config.llm.model.clone()) + .with_request_timeout_ms(config.llm.request_timeout_ms) + .with_max_output_tokens(16_000); connection - .run_turn_with_direct_observer(&snapshot, &config.llm, request, None, observer, audit) + .run_turn_with_direct_observer_and_history( + &snapshot, + &config.llm, + request, + Some(&codex_root), + client_turn_id, + None, + observer, + audit, + ) .await .map(|value| value.text) .map_err(|error| error.to_string()) } +/// Builds the prompt used when a new DirectProject thread needs replay. +/// +/// The JSONL history remains immutable; only the derived prompt is bounded. +/// When the full request exceeds the replay target, the oldest contiguous +/// records are omitted and a plain `system:` marker is prepended. +pub(crate) fn build_direct_codex_history_prompt( + root: &std::path::Path, + client_turn_id: &str, + current_prompt: &str, + base_request: &LlmRunRequest, + llm: &GameCreatorLlmConfig, +) -> Result { + let conversation = read_local_conversation_for_session_at(root, None, None)?; + let current_message_id = format!("direct-codex:{client_turn_id}:user"); + // The deliberately simple role-prefix format is part of the Direct + // replay contract. Do not introduce an envelope or implicit escaping + // here without updating the persisted-history compatibility decision. + let lines = conversation + .messages + .iter() + .filter(|message| message.message_id.as_deref() != Some(current_message_id.as_str())) + .map(|message| format!("{}: {}", message.role, message.content)) + .collect::>(); + let current_line = format!("user: {}", current_prompt.trim()); + let full_prompt = format_direct_codex_replay_prompt(&lines, ¤t_line, None); + let target_budget = direct_codex_replay_target_budget(llm, base_request)?; + if direct_codex_replay_prompt_fits(base_request, llm, &full_prompt, target_budget)? { + return Ok(full_prompt); + } + + // The persisted project conversation is immutable. We only derive a + // bounded prompt for this replay, keeping the newest contiguous records. + let omission_marker = DIRECT_CODEX_REPLAY_OMISSION_MARKER; + let mut selected_reversed = Vec::new(); + for line in lines.iter().rev() { + let mut candidate_reversed = selected_reversed.clone(); + candidate_reversed.push(line.as_str()); + let candidate_lines = candidate_reversed.iter().rev().copied().collect::>(); + let candidate_prompt = format_direct_codex_replay_prompt( + &candidate_lines, + ¤t_line, + Some(omission_marker), + ); + if direct_codex_replay_prompt_fits(base_request, llm, &candidate_prompt, target_budget)? { + selected_reversed.push(line.as_str()); + } else { + break; + } + } + + let selected_lines = selected_reversed.iter().rev().copied().collect::>(); + let marked_prompt = + format_direct_codex_replay_prompt(&selected_lines, ¤t_line, Some(omission_marker)); + if direct_codex_replay_prompt_fits(base_request, llm, &marked_prompt, target_budget)? { + return Ok(marked_prompt); + } + + // If the marker itself would push the request over the target, preserve + // the current user request and omit only the marker. + let current_only_prompt = + format_direct_codex_replay_prompt(&[] as &[&str], ¤t_line, None); + if direct_codex_replay_prompt_fits(base_request, llm, ¤t_only_prompt, target_budget)? { + return Ok(current_only_prompt); + } + direct_codex_replay_validate_context_budget(base_request, llm, ¤t_only_prompt) +} + +const DIRECT_CODEX_REPLAY_OMISSION_MARKER: &str = + "system: Earlier conversation history was omitted due to context budget."; + +fn format_direct_codex_replay_prompt( + history_lines: &[impl AsRef], + current_line: &str, + omission_marker: Option<&str>, +) -> String { + let mut lines = Vec::with_capacity(history_lines.len() + 2); + if let Some(marker) = omission_marker { + lines.push(marker.to_string()); + } + lines.extend(history_lines.iter().map(|line| line.as_ref().to_string())); + lines.push(current_line.to_string()); + lines.join("\n") +} + +fn direct_codex_replay_target_budget( + llm: &GameCreatorLlmConfig, + request: &LlmRunRequest, +) -> Result { + const SAFETY_MARGIN_TOKENS: u64 = 4_096; + let max_output_tokens = u64::from(request.max_output_tokens.unwrap_or(0)); + let hard_budget = llm + .context_window_tokens + .checked_sub(max_output_tokens) + .and_then(|value| value.checked_sub(SAFETY_MARGIN_TOKENS)) + .ok_or_else(|| "Direct replay 没有可用的输入上下文预算".to_string())?; + Ok(llm.auto_compact_token_limit.min(hard_budget)) +} + +fn direct_codex_replay_estimate( + base_request: &LlmRunRequest, + prompt: &str, +) -> Result { + let mut request = base_request.clone(); + let user = request + .messages + .iter_mut() + .rev() + .find(|message| message.role == LlmMessageRole::User) + .ok_or_else(|| "Direct replay 请求缺少 user message".to_string())?; + user.content = prompt.to_string(); + Ok(request) +} + +fn direct_codex_replay_prompt_fits( + base_request: &LlmRunRequest, + llm: &GameCreatorLlmConfig, + prompt: &str, + target_budget: u64, +) -> Result { + let request = direct_codex_replay_estimate(base_request, prompt)?; + let estimated = estimate_game_creator_llm_request_tokens(&request)?; + if estimated > target_budget { + return Ok(false); + } + validate_game_creator_llm_request_context_budget( + llm, + &request, + estimated, + "Direct replay 请求", + )?; + Ok(true) +} + +fn direct_codex_replay_validate_context_budget( + base_request: &LlmRunRequest, + llm: &GameCreatorLlmConfig, + prompt: &str, +) -> Result { + let request = direct_codex_replay_estimate(base_request, prompt)?; + let estimated = estimate_game_creator_llm_request_tokens(&request)?; + validate_game_creator_llm_request_context_budget( + llm, + &request, + estimated, + "Direct replay 请求", + )?; + Ok(prompt.to_string()) +} + /// Direct home-page chat never binds Codex to a user project. It gets a /// fresh isolated read-only workspace and a stable in-process thread so a /// normal conversation can continue without creating a project, assets, a @@ -3234,6 +3453,119 @@ pub(in crate::agent) fn shutdown_game_creator_codex_app_servers_impl() -> Result mod tests { use super::*; + #[test] + fn direct_history_prompt_replays_all_project_messages_in_order() { + let root = tempfile::tempdir().expect("temp dir"); + init_local_game_project_at(root.path(), "history-project", "history").expect("init"); + for (role, content, message_id) in [ + ("user", "hello", "direct-codex:old:user"), + ( + "assistant", + "partial\nunexpected interrupt happened here", + "partial-id", + ), + ("tool", "file-read result", "tool-id"), + ("user", "stored raw request", "direct-codex:new-turn:user"), + ] { + append_local_conversation_message_for_session_idempotent_at( + root.path(), + None, + None, + LocalConversationMessage { + role: role.to_string(), + content: content.to_string(), + agent_id: None, + }, + message_id, + ) + .expect("append history"); + } + let request = LlmRunRequest::single_turn("system", "new request") + .with_model("fixture-model") + .with_max_output_tokens(16_000); + let prompt = build_direct_codex_history_prompt( + root.path(), + "new-turn", + "new request", + &request, + &test_llm(), + ) + .expect("build prompt"); + assert_eq!( + prompt, + "user: hello\nassistant: partial\nunexpected interrupt happened here\ntool: file-read result\nuser: new request" + ); + } + + #[test] + fn direct_history_prompt_slides_old_prefix_when_budget_is_exceeded() { + let root = tempfile::tempdir().expect("temp dir"); + init_local_game_project_at(root.path(), "window-project", "window").expect("init"); + for (role, content, message_id) in [ + ("user", "old ".repeat(1_000), "old-user"), + ("assistant", "middle ".repeat(100), "middle-assistant"), + ("tool", "newest ".repeat(100), "newest-tool"), + ] { + append_local_conversation_message_for_session_idempotent_at( + root.path(), + None, + None, + LocalConversationMessage { + role: role.to_string(), + content, + agent_id: None, + }, + message_id, + ) + .expect("append history"); + } + let mut llm = test_llm(); + llm.auto_compact_token_limit = 800; + let request = LlmRunRequest::single_turn("system", "new request") + .with_model("fixture-model") + .with_max_output_tokens(16_000); + let before = + std::fs::read_to_string(root.path().join(".agent/conversations/project.jsonl")) + .expect("read history"); + let prompt = build_direct_codex_history_prompt( + root.path(), + "new-turn", + "new request", + &request, + &llm, + ) + .expect("build prompt"); + let after = std::fs::read_to_string(root.path().join(".agent/conversations/project.jsonl")) + .expect("read history"); + + assert!(prompt.starts_with(DIRECT_CODEX_REPLAY_OMISSION_MARKER)); + assert!(prompt.contains("tool: newest")); + assert!(!prompt.contains("user: old")); + assert!(prompt.ends_with("user: new request")); + assert_eq!(before, after); + } + + #[test] + fn direct_history_prompt_rejects_current_request_that_exceeds_context() { + let root = tempfile::tempdir().expect("temp dir"); + init_local_game_project_at(root.path(), "oversized-project", "oversized").expect("init"); + let mut llm = test_llm(); + llm.context_window_tokens = 5_000; + llm.auto_compact_token_limit = 1_000; + let request = LlmRunRequest::single_turn("system", "new request") + .with_model("fixture-model") + .with_max_output_tokens(100); + let error = build_direct_codex_history_prompt( + root.path(), + "new-turn", + &"request ".repeat(10_000), + &request, + &llm, + ) + .expect_err("oversized request should fail"); + assert!(error.contains("Direct replay 请求")); + } + #[test] fn direct_item_activities_are_closed_safe_categories() { let allowed = [ diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/direct_runtime.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/direct_runtime.rs index fcd96667b..fc36389d8 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/direct_runtime.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/direct_runtime.rs @@ -3835,6 +3835,7 @@ async fn run_direct_game_creator_turn_inner( DirectCodexTurnFailure::new(DirectCodexFailureStage::CodeGeneration, error) })?; let reply = if let Some(emitter) = turn_emitter { + let client_turn_id = emitter.turn_id().to_string(); let emitter = emitter.clone(); let mut has_streamed = false; let mut latest_accumulated_text = None; @@ -3856,6 +3857,7 @@ async fn run_direct_game_creator_turn_inner( root, system_prompt, prompt.to_string(), + Some(&client_turn_id), Some(&mut observer), audit, ) @@ -3866,6 +3868,7 @@ async fn run_direct_game_creator_turn_inner( system_prompt, prompt.to_string(), None, + None, audit, ) .await @@ -4189,6 +4192,27 @@ fn persist_direct_codex_assistant_reply_at( .map(|_| ()) } +fn persist_direct_codex_user_prompt_at( + root: &Path, + client_turn_id: &str, + prompt: &str, +) -> Result<(), String> { + enforce_project_permission_policy(root, "conversation.write")?; + let _lock = acquire_project_write_lock(root, "conversation.write")?; + append_local_conversation_message_for_session_idempotent_at( + root, + None, + None, + LocalConversationMessage { + role: "user".to_string(), + content: prompt.trim().to_string(), + agent_id: None, + }, + &format!("direct-codex:{client_turn_id}:user"), + ) + .map(|_| ()) +} + #[tauri::command] pub(crate) async fn chat_with_game_creator_direct_codex( project_path: String, @@ -4220,6 +4244,15 @@ pub(crate) async fn chat_with_game_creator_direct_codex( return Err(error); } }; + if let Err(error) = persist_direct_codex_user_prompt_at(root, &turn_id, &user_prompt) { + audit.finish(false); + turn_emitter.emit("failed", Some("none"), None); + return Err(redact_agent_runtime_error( + root, + &format!("Direct 用户消息持久化失败,已拒绝发起回合:{error}"), + 500, + )); + } let reply = match run_direct_game_creator_turn_at_with_creation_type_and_emitter( root, &user_prompt, @@ -4261,6 +4294,24 @@ pub(crate) async fn chat_with_game_creator_home_direct_codex( mod tests { use super::*; + fn direct_test_llm() -> GameCreatorLlmConfig { + GameCreatorLlmConfig { + api_key: "fixture-secret".to_string(), + base_url: "https://example.invalid/v1".to_string(), + model: "fixture-model".to_string(), + api_kind: "openai_responses".to_string(), + reasoning_effort: "high".to_string(), + stream: false, + web_search_enabled: false, + context_window_tokens: 128_000, + auto_compact_token_limit: 64_000, + tool_output_token_limit: 12_000, + request_timeout_ms: 10_000, + max_retries: 0, + retry_backoff_ms: 100, + } + } + #[test] fn client_turn_id_is_strictly_normalized_and_bounded() { assert_eq!( @@ -4326,6 +4377,83 @@ mod tests { ); } + #[test] + fn direct_user_prompt_is_persisted_idempotently_before_reply_and_survives_retry() { + let root = tempfile::tempdir().expect("temp dir"); + init_local_game_project_at(root.path(), "direct-user-project", "用户消息落盘") + .expect("init project"); + let turn_id = "client-turn-user-0001"; + let normalized_prompt = "请先检查附件,再继续上一轮的游戏设计。\n\n项目附件:\n- 原文件名:需求.md;项目路径:需求.md;类型:text/markdown;大小:12 字节"; + + persist_direct_codex_user_prompt_at(root.path(), turn_id, normalized_prompt) + .expect("persist user prompt before turn"); + persist_direct_codex_user_prompt_at(root.path(), turn_id, normalized_prompt) + .expect("retry reuses the same user message identity"); + + let conversation = read_local_conversation_for_session_at(root.path(), None, None) + .expect("read project conversation"); + let message_id = format!("direct-codex:{turn_id}:user"); + let persisted = conversation + .messages + .iter() + .filter(|message| message.message_id.as_deref() == Some(message_id.as_str())) + .collect::>(); + assert_eq!(persisted.len(), 1); + assert_eq!(persisted[0].role, "user"); + assert_eq!(persisted[0].content, normalized_prompt); + assert!( + persist_direct_codex_user_prompt_at(root.path(), turn_id, "不同的重试请求") + .expect_err("same turn identity cannot be rebound") + .contains("messageId 冲突") + ); + } + + #[test] + fn direct_history_recovers_multiple_user_assistant_turns_after_thread_restart() { + let root = tempfile::tempdir().expect("temp dir"); + init_local_game_project_at(root.path(), "direct-restart-project", "重启恢复") + .expect("init project"); + let request = LlmRunRequest::single_turn("system", "继续实现第二轮需求") + .with_model("fixture-model") + .with_max_output_tokens(16_000); + + persist_direct_codex_user_prompt_at(root.path(), "client-turn-0001", "先做一个主菜单") + .expect("persist first user turn"); + persist_direct_codex_assistant_reply_at( + root.path(), + "client-turn-0001", + "主菜单已完成,下一步可以继续扩展关卡。", + ) + .expect("persist first assistant turn"); + persist_direct_codex_user_prompt_at( + root.path(), + "client-turn-0002", + "请在主菜单基础上增加关卡选择", + ) + .expect("persist second user turn"); + persist_direct_codex_assistant_reply_at( + root.path(), + "client-turn-0002", + "关卡选择页面已加入,并保留主菜单入口。", + ) + .expect("persist second assistant turn"); + + // 模拟 app-server 重启后没有内存 thread:从项目 JSONL 重建时, + // 两轮完整的 user/assistant 上下文都必须保留。 + let prompt = crate::agent::codex_app_server::build_direct_codex_history_prompt( + root.path(), + "client-turn-0003", + "继续实现第二轮需求", + &request, + &direct_test_llm(), + ) + .expect("rebuild history after restart"); + assert_eq!( + prompt, + "user: 先做一个主菜单\nassistant: 主菜单已完成,下一步可以继续扩展关卡。\nuser: 请在主菜单基础上增加关卡选择\nassistant: 关卡选择页面已加入,并保留主菜单入口。\nuser: 继续实现第二轮需求" + ); + } + #[test] fn direct_turn_update_payload_is_the_exact_camel_case_contract() { let value = serde_json::to_value(GameCreatorDirectTurnUpdateEvent { diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_driver/entrypoints.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_driver/entrypoints.rs index bae479e11..e8c207db6 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_driver/entrypoints.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_driver/entrypoints.rs @@ -93,6 +93,10 @@ impl DirectGameCreatorTurnUpdateEmitter { }, ); } + + pub(crate) fn turn_id(&self) -> &str { + &self.turn_id + } } pub(crate) fn start_game_creator_manifest_invalidation_event_sink( diff --git a/apps/ai-game-creator-shell/src/App.tsx b/apps/ai-game-creator-shell/src/App.tsx index 80481f51b..e0507c47b 100644 --- a/apps/ai-game-creator-shell/src/App.tsx +++ b/apps/ai-game-creator-shell/src/App.tsx @@ -297,56 +297,6 @@ function directCodexConversationMessageId( return `${DIRECT_CODEX_CONVERSATION_MESSAGE_ID_PREFIX}${turnId}:${role}`; } -function directCodexConversationTurnId( - message: ChatMessage, - role: ChatMessage['role'], -) { - if (message.role !== role) { - return null; - } - const messageId = message.messageId?.trim() ?? ''; - const roleSuffix = `:${role}`; - if ( - !messageId.startsWith(DIRECT_CODEX_CONVERSATION_MESSAGE_ID_PREFIX) || - !messageId.endsWith(roleSuffix) - ) { - return null; - } - const turnId = messageId.slice( - DIRECT_CODEX_CONVERSATION_MESSAGE_ID_PREFIX.length, - -roleSuffix.length, - ); - return /^[a-z0-9][a-z0-9-]{5,159}$/iu.test(turnId) ? turnId : null; -} - -export function unansweredDirectCodexConversationTurn(messages: ChatMessage[]) { - for (let index = messages.length - 1; index >= 0; index -= 1) { - const message = messages[index]; - if (!message) { - continue; - } - const turnId = directCodexConversationTurnId(message, 'user'); - if (!turnId) { - continue; - } - const assistantMessageId = directCodexConversationMessageId( - turnId, - 'assistant', - ); - if ( - messages.some( - (candidate) => - candidate.role === 'assistant' && - candidate.messageId?.trim() === assistantMessageId, - ) - ) { - continue; - } - return { prompt: message.text, turnId }; - } - return null; -} - export function isDirectCodexTurnAlreadyRunningError(error: unknown) { const message = error instanceof Error ? error.message : String(error); return message @@ -544,6 +494,12 @@ export function App({ useState(null); const [directCodexTransientReply, setDirectCodexTransientReply] = useState(''); + const directCodexTransientReplyRef = useRef(''); + const directCodexInterruptedPartialRef = useRef<{ + projectPath: string; + text: string; + messageId: string; + } | null>(null); const [ directCodexTransientReplyUpdatedAt, setDirectCodexTransientReplyUpdatedAt, @@ -583,6 +539,7 @@ export function App({ setDirectCodexProgress(''); setDirectCodexProgressUpdatedAt(null); setDirectCodexTransientReply(''); + directCodexTransientReplyRef.current = ''; setDirectCodexTransientReplyUpdatedAt(null); } @@ -596,6 +553,7 @@ export function App({ } activeDirectCodexTurnRef.current = null; setDirectCodexTransientReply(''); + directCodexTransientReplyRef.current = ''; setDirectCodexTransientReplyUpdatedAt(null); return true; } @@ -1234,10 +1192,10 @@ export function App({ : Date.now(); if (payload.status === 'failed') { activeDirectCodexTurnRef.current = null; - setDirectCodexProgress('处理失败,正在同步错误'); - setDirectCodexProgressUpdatedAt(updatedAt); setDirectCodexTransientReply(''); setDirectCodexTransientReplyUpdatedAt(null); + setDirectCodexProgress('处理失败,正在同步错误'); + setDirectCodexProgressUpdatedAt(updatedAt); return; } if (payload.status === 'completed') { @@ -1249,6 +1207,7 @@ export function App({ } if (typeof payload.accumulatedText === 'string') { setDirectCodexTransientReply(payload.accumulatedText); + directCodexTransientReplyRef.current = payload.accumulatedText; setDirectCodexTransientReplyUpdatedAt(updatedAt); } }, @@ -1844,9 +1803,8 @@ export function App({ ); if (localProjectPathRef.current === nextProjectPath) { // Any history read started before this terminal append may hold - // a user-only snapshot. Invalidate it before releasing the - // in-memory claim so that stale hydration cannot replay the - // same billable Direct turn. + // A stale history snapshot may still be missing this terminal + // append. Invalidate it before releasing the in-memory claim. projectSupervisorHistoryLoadVersionRef.current += 1; } recoveredDirectCodexTurnClaimsRef.current.delete(claimKey); @@ -2702,9 +2660,6 @@ export function App({ projectConversation.messages, supervisorConversation?.messages ?? [], ); - const unansweredDirectTurn = directCodexProductRuntime - ? unansweredDirectCodexConversationTurn(conversationMessages) - : null; if ( conversationContainsProjectSupervisorResponseStream( supervisorConversation?.messages ?? [], @@ -2756,24 +2711,6 @@ export function App({ }); return nextConversationMessages; }); - if (unansweredDirectTurn) { - const claimKey = `${nextProjectPath}\u0000${unansweredDirectTurn.turnId}`; - void Promise.resolve().then(() => { - if ( - projectSupervisorHistoryLoadVersionRef.current !== loadVersion || - localProjectPathRef.current !== nextProjectPath || - activeDirectCodexTurnRef.current || - recoveredDirectCodexTurnClaimsRef.current.has(claimKey) - ) { - return; - } - recoveredDirectCodexTurnClaimsRef.current.add(claimKey); - void executeChatAgentReply({ - prompt: unansweredDirectTurn.prompt, - clientTurnId: unansweredDirectTurn.turnId, - }); - }); - } } catch (error) { if ( projectSupervisorHistoryLoadVersionRef.current !== loadVersion || @@ -5464,6 +5401,23 @@ export function App({ }, }, ); + const persistDirectPartialMessage = (messageId: string, text: string) => + directInvoke( + 'append_local_conversation_message', + { + projectPath: directProjectPath, + agentId: null, + messageId, + message: { + role: 'assistant', + // An interrupted partial is intentionally a normal assistant + // record so replay sees exactly what Codex emitted before the + // disconnect; the marker is product data, not UI metadata. + content: `${text.trim()}\nunexpected interrupt happened here`, + agentId: null, + }, + }, + ); const recoveredDirectCodexTurnClaimKey = `${directProjectPath}\u0000${clientTurnId}`; recoveredDirectCodexTurnClaimsRef.current.add( recoveredDirectCodexTurnClaimKey, @@ -5478,26 +5432,40 @@ export function App({ setDirectCodexProgress('已发送消息,正在等待陶泥儿回复'); setDirectCodexProgressUpdatedAt(Date.now()); setDirectCodexTransientReply(''); + directCodexTransientReplyRef.current = ''; setDirectCodexTransientReplyUpdatedAt(null); setProjectSupervisorRuntimeError(''); try { - // Persist the original user intent and stable turn identity before - // Codex can start any billable or externally visible work. The - // regular conversation writer may race this call, but messageId - // idempotency makes both writers converge on the same record. - await directInvoke( - 'append_local_conversation_message', - { - projectPath: directProjectPath, - agentId: null, - messageId: directUserMessageId, - message: { - role: 'user', - content: prompt, + const interruptedPartial = directCodexInterruptedPartialRef.current; + if ( + interruptedPartial?.projectPath === directProjectPath && + interruptedPartial.text.trim() + ) { + await persistDirectPartialMessage( + interruptedPartial.messageId, + interruptedPartial.text, + ); + directCodexInterruptedPartialRef.current = null; + } + // Rust owns the normalized user record for attachment turns so the + // durable message includes the same bounded project mapping that is + // sent to Codex. Plain turns keep the optimistic browser write; the + // Rust writer then converges on it through messageId idempotency. + if (!attachments?.length) { + await directInvoke( + 'append_local_conversation_message', + { + projectPath: directProjectPath, agentId: null, + messageId: directUserMessageId, + message: { + role: 'user', + content: prompt, + agentId: null, + }, }, - }, - ); + ); + } const directTurnInput: { projectPath: string; prompt: string; @@ -5531,10 +5499,10 @@ export function App({ } } // Rust persists a successful Direct reply before returning Ok. The - // browser append is redundant, so the hydrated-turn claim can be + // browser append is redundant, so the in-memory turn claim can be // released without reopening the Provider side effect. Invalidate - // any user-only history snapshot captured before Rust committed the - // terminal reply first. + // any history snapshot captured before Rust committed the terminal + // reply first. if (localProjectPathRef.current === directProjectPath) { projectSupervisorHistoryLoadVersionRef.current += 1; } @@ -5574,6 +5542,23 @@ export function App({ '陶泥儿智能创作', true, ); + const partial = directCodexTransientReplyRef.current.trim(); + if (partial) { + const partialMessageId = + globalThis.crypto?.randomUUID?.() || + `direct-partial-${Date.now().toString(36)}`; + directCodexInterruptedPartialRef.current = { + projectPath: directProjectPath, + text: partial, + messageId: partialMessageId, + }; + try { + await persistDirectPartialMessage(partialMessageId, partial); + } catch { + // The next user send retries this idempotent append before + // constructing the replay prompt. + } + } try { await persistDirectAssistantMessage(visibleMessage); if (localProjectPathRef.current === directProjectPath) { diff --git a/apps/ai-game-creator-shell/tests/appSurface/home.suite.ts b/apps/ai-game-creator-shell/tests/appSurface/home.suite.ts index aadd02977..5ab564020 100644 --- a/apps/ai-game-creator-shell/tests/appSurface/home.suite.ts +++ b/apps/ai-game-creator-shell/tests/appSurface/home.suite.ts @@ -2314,303 +2314,6 @@ export function registerHomeProjectCreationTests() { ), ).toHaveLength(1); }); - - it('releases a hydrated Direct Codex turn claim after an in-progress rejection so the same App can resume it later', async () => { - const projectPath = - 'C:\\Users\\tester\\Documents\\Genarrative GameAgent\\running-direct-project'; - const manifest = createGameCreationAppManifest( - 'running-direct-project', - '运行中直连项目', - ); - const stableTurnId = 'stable-running-turn-001'; - let directTurnCallCount = 0; - const persistedMessages: Array> = [ - { - schemaVersion: 'game-creator-conversation.v1', - role: 'user', - content: '继续完成运行中的项目', - agentId: null, - messageId: `direct-codex:${stableTurnId}:user`, - updatedAt: 1, - }, - ]; - const invoke = vi.fn( - async (command: string, args?: Record) => { - if (command === 'get_local_game_manifest') { - return manifest; - } - if (command === 'read_local_conversation') { - return { - path: `${projectPath}/.agent/conversations/project.jsonl`, - agentId: null, - sessionId: null, - messages: [...persistedMessages], - }; - } - if (command === 'append_local_permission_log') { - return {}; - } - if (command === 'read_project_permission_policy') { - return { - path: '.agent/policy.json', - policy: { deniedCommands: [], confirmCommands: [] }, - }; - } - if (command === 'append_local_conversation_message') { - const message = args?.message as Record; - const messageId = String(args?.messageId ?? ''); - if ( - !messageId || - !persistedMessages.some( - (candidate) => candidate.messageId === messageId, - ) - ) { - persistedMessages.push({ - schemaVersion: 'game-creator-conversation.v1', - ...message, - messageId, - updatedAt: Number( - message.updatedAt ?? persistedMessages.length + 1, - ), - }); - } - return { - path: `${projectPath}/.agent/conversations/project.jsonl`, - agentId: null, - sessionId: null, - messages: [...persistedMessages], - }; - } - if (command === 'chat_with_game_creator_direct_codex') { - directTurnCallCount += 1; - if (directTurnCallCount === 1) { - throw new Error( - 'direct-codex-turn-already-running: 当前 Direct 客户端回合仍在运行', - ); - } - return '恢复后的最终回复'; - } - throw new Error(`unexpected invoke ${command}`); - }, - ); - window.__TAURI__ = { core: { invoke } }; - - render( - React.createElement(App, { - initialProjectPath: projectPath, - initialProjectManifest: manifest, - projectSupervisorOnly: true, - }), - ); - - expect(await screen.findByText('继续完成运行中的项目')).not.toBeNull(); - await waitFor(() => { - expect(invoke).toHaveBeenCalledWith( - 'chat_with_game_creator_direct_codex', - { - projectPath, - prompt: '继续完成运行中的项目', - clientTurnId: stableTurnId, - }, - ); - }); - await act(async () => { - await Promise.resolve(); - await Promise.resolve(); - }); - expect(persistedMessages).toHaveLength(1); - expect( - invoke.mock.calls.filter( - ([command, args]) => - command === 'append_local_conversation_message' && - (args as Record | undefined)?.messageId === - `direct-codex:${stableTurnId}:assistant`, - ), - ).toHaveLength(0); - expect(screen.queryByText(/direct-codex-turn-already-running/)).toBeNull(); - - const directComposer = screen.getByLabelText('陶泥儿对话内容'); - fireEvent.change(directComposer, { target: { value: '/history' } }); - fireEvent.submit(directComposer.closest('form') as HTMLFormElement); - - expect(await screen.findByText('恢复后的最终回复')).not.toBeNull(); - await waitFor(() => { - expect(directTurnCallCount).toBe(2); - expect( - persistedMessages.filter( - (message) => - message.messageId === `direct-codex:${stableTurnId}:assistant`, - ), - ).toEqual([ - expect.objectContaining({ - role: 'assistant', - content: '恢复后的最终回复', - }), - ]); - }); - expect( - invoke.mock.calls.filter( - ([command]) => command === 'read_local_conversation', - ).length, - ).toBeGreaterThanOrEqual(2); - }); - - it.each([ - { - caseName: 'a successful reply', - firstError: null, - firstReply: '首次成功回复', - firstVisibleText: '首次成功回复', - }, - { - caseName: 'an ordinary error reply', - firstError: 'codex-app-server-error:unauthorized', - firstReply: null, - firstVisibleText: '陶泥儿智能创作 鉴权失败,请检查 API Key 或登录态', - }, - ])( - 'reconciles a hydrated Direct Codex claim after persisting $caseName fails', - async ({ firstError, firstReply, firstVisibleText }) => { - const projectPath = - 'C:\\Users\\tester\\Documents\\Genarrative GameAgent\\retry-terminal-persistence'; - const manifest = createGameCreationAppManifest( - 'retry-terminal-persistence', - '终态持久化重试项目', - ); - const stableTurnId = 'stable-terminal-persistence-001'; - let directTurnCallCount = 0; - let allowAssistantPersistence = false; - let failedAssistantPersistenceCount = 0; - const directTurnIds: string[] = []; - const persistedMessages: Array> = [ - { - schemaVersion: 'game-creator-conversation.v1', - role: 'user', - content: '恢复终态持久化失败的回合', - agentId: null, - messageId: `direct-codex:${stableTurnId}:user`, - updatedAt: 1, - }, - ]; - const invoke = vi.fn( - async (command: string, args?: Record) => { - if (command === 'get_local_game_manifest') { - return manifest; - } - if (command === 'read_local_conversation') { - return { - path: `${projectPath}/.agent/conversations/project.jsonl`, - agentId: null, - sessionId: null, - messages: [...persistedMessages], - }; - } - if (command === 'append_local_permission_log') { - return {}; - } - if (command === 'read_project_permission_policy') { - return { - path: '.agent/policy.json', - policy: { deniedCommands: [], confirmCommands: [] }, - }; - } - if (command === 'append_local_conversation_message') { - const message = args?.message as Record; - if (message.role === 'assistant' && !allowAssistantPersistence) { - failedAssistantPersistenceCount += 1; - throw new Error('assistant conversation persistence unavailable'); - } - const messageId = String(args?.messageId ?? ''); - if ( - !messageId || - !persistedMessages.some( - (candidate) => candidate.messageId === messageId, - ) - ) { - persistedMessages.push({ - schemaVersion: 'game-creator-conversation.v1', - ...message, - messageId, - updatedAt: Number( - message.updatedAt ?? persistedMessages.length + 1, - ), - }); - } - return { - path: `${projectPath}/.agent/conversations/project.jsonl`, - agentId: null, - sessionId: null, - messages: [...persistedMessages], - }; - } - if (command === 'chat_with_game_creator_direct_codex') { - directTurnCallCount += 1; - directTurnIds.push(String(args?.clientTurnId ?? '')); - if (directTurnCallCount === 1) { - if (firstError) { - throw new Error(firstError); - } - persistedMessages.push({ - schemaVersion: 'game-creator-conversation.v1', - role: 'assistant', - content: firstReply, - agentId: null, - messageId: `direct-codex:${stableTurnId}:assistant`, - updatedAt: 2, - }); - return firstReply ?? ''; - } - allowAssistantPersistence = true; - return '恢复后的最终回复'; - } - throw new Error(`unexpected invoke ${command}`); - }, - ); - window.__TAURI__ = { core: { invoke } }; - - render( - React.createElement(App, { - initialProjectPath: projectPath, - initialProjectManifest: manifest, - projectSupervisorOnly: true, - }), - ); - - expect(await screen.findByText(firstVisibleText)).not.toBeNull(); - await waitFor(() => { - expect(directTurnCallCount).toBe(1); - expect(failedAssistantPersistenceCount).toBeGreaterThan( - firstError ? 1 : 0, - ); - expect(persistedMessages).toHaveLength(firstError ? 1 : 2); - }); - - const directComposer = screen.getByLabelText('陶泥儿对话内容'); - fireEvent.change(directComposer, { target: { value: '/history' } }); - fireEvent.submit(directComposer.closest('form') as HTMLFormElement); - - expect( - await screen.findByText(firstError ? '恢复后的最终回复' : firstReply!), - ).not.toBeNull(); - await waitFor(() => { - expect(directTurnCallCount).toBe(firstError ? 2 : 1); - expect(directTurnIds).toEqual( - firstError ? [stableTurnId, stableTurnId] : [stableTurnId], - ); - expect( - persistedMessages.filter( - (message) => - message.messageId === `direct-codex:${stableTurnId}:assistant`, - ), - ).toEqual([ - expect.objectContaining({ - role: 'assistant', - content: firstError ? '恢复后的最终回复' : firstReply, - }), - ]); - }); - }, - ); } export function registerRecentProjectsTests() { diff --git a/apps/ai-game-creator-shell/tests/appSurface/project-conversation.suite.ts b/apps/ai-game-creator-shell/tests/appSurface/project-conversation.suite.ts index c6d0fbe0d..147b5f7ce 100644 --- a/apps/ai-game-creator-shell/tests/appSurface/project-conversation.suite.ts +++ b/apps/ai-game-creator-shell/tests/appSurface/project-conversation.suite.ts @@ -1,7 +1,4 @@ -import { - isDirectCodexTurnAlreadyRunningError, - unansweredDirectCodexConversationTurn, -} from '../../src/App'; +import { isDirectCodexTurnAlreadyRunningError } from '../../src/App'; import { act, agentRuntimeUserInputRequest, @@ -26,66 +23,6 @@ import { } from './harness'; export function registerProjectConversationTests() { - it('replays only the latest unanswered Direct Codex turn with its original stable identity', () => { - expect( - unansweredDirectCodexConversationTurn([ - { - role: 'user', - text: '请重新生成美术', - messageId: 'direct-codex:stable-turn-001:user', - }, - ]), - ).toEqual({ - prompt: '请重新生成美术', - turnId: 'stable-turn-001', - }); - expect( - unansweredDirectCodexConversationTurn([ - { - role: 'user', - text: '请重新生成美术', - messageId: 'direct-codex:stable-turn-001:user', - }, - { - role: 'assistant', - text: '已完成', - messageId: 'direct-codex:stable-turn-001:assistant', - }, - ]), - ).toBeNull(); - expect( - unansweredDirectCodexConversationTurn([ - { - role: 'user', - text: '恢复较早的未回答回合', - messageId: 'direct-codex:stable-turn-older:user', - }, - { - role: 'user', - text: '较新的已回答回合', - messageId: 'direct-codex:stable-turn-newer:user', - }, - { - role: 'assistant', - text: '较新的回复', - messageId: 'direct-codex:stable-turn-newer:assistant', - }, - ]), - ).toEqual({ - prompt: '恢复较早的未回答回合', - turnId: 'stable-turn-older', - }); - expect( - unansweredDirectCodexConversationTurn([ - { - role: 'user', - text: '伪造回合', - messageId: 'direct-codex:../unsafe:user', - }, - ]), - ).toBeNull(); - }); - it('filters only the stable same-turn in-progress rejection from terminal Direct Codex failures', () => { expect( isDirectCodexTurnAlreadyRunningError( diff --git a/docs/project-memory/shared-memory/decision-log.md b/docs/project-memory/shared-memory/decision-log.md index b62a2d88a..470f41c17 100644 --- a/docs/project-memory/shared-memory/decision-log.md +++ b/docs/project-memory/shared-memory/decision-log.md @@ -7612,6 +7612,7 @@ CI 上 `background_agent_runtime_recovers_stale_running_before_pending_task` 在 - 配置:有无 AGC LLM Key 都只支持 `openai_responses`;非空 Key 映射后 base URL 与逐 Agent model/effort 生效,Key 只经专用环境变量;空 Key 只桥接用户 Codex `auth.json`,移除继承环境 Key。`stream=true` 复用现有 durable final-reply delta;`webSearchEnabled=true`、`openai_chat / anthropic` 必须显式切换 `provider`,不得静默忽略已有 LLM 配置。 - 安全与恢复:app-server 使用隔离临时 `CODEX_HOME` 与 OS HOME,只桥接认证,不加载用户 MCP/config/skills/hooks;启动前关闭 web/multi-agent/shell/browser/plugin/image 等原生能力,固定 read-only、network off、never approval,AGC 是唯一 ToolHost。取消覆盖 turn-start 回包前窗口并只发送单 turn interrupt;已开始 turn 的连接/终态未知直接标记 reconciliation,明确 failed/interrupted 不自动重试。模式与 durable 指纹取同一配置快照。 - 资源与退出:pool 按实际凭据快照/base URL/API kind/CLI 版本和节点 run 身份隔离;空 AppData Key 只允许桥接一次性读取的有界 `auth.json` 快照,并用同一字节快照生成池指纹,宿主 `CODEX_API_KEY` 对 app-server 与一次性 CLI 都必须移除。节点进程与 thread 均有上限并只淘汰 inactive LRU;stdout NDJSON 与 stderr 无换行记录均有硬上限,stderr 原文不得写入错误或日志,只记录固定分类、总字节数、SHA-256 与可取得的退出状态。Runner 正常、强制、watchdog 退出显式关池;Linux child 绑定 parent-death signal,避免 Runner 被强杀后遗留带凭据孤儿进程。 +- DirectProject 会话恢复补充:Codex thread 继续使用 `ephemeral=true`,不保存或恢复 Codex 原生 thread。项目对话 `.agent/conversations/project.jsonl` 是唯一聊天事实源;仅当 app-server 连接没有可用的项目 thread(通常是进程重启或 thread 被淘汰)时,AGC 才读取全部 `user`、`assistant`、`tool` 行,按原顺序渲染为简单的 `user:` / `assistant:` / `tool:` 文本后,再追加本次新 user 请求发送给新 thread;已有 thread 的普通消息仍只发送新 user。app-server 意外中断时,已收到的 partial 文本作为普通 `assistant` 消息追加,并在末尾写入 `unexpected interrupt happened here`;断开处理与下一次发送均可重复追加,但使用普通消息 `messageId` 幂等。项目打开不再根据“只有 user 没有 assistant”自动重发旧请求;Direct 不新增 retry 入口。Runtime Agent 的恢复合同保持独立,不消费项目 Direct 对话历史。 - 兼容迁移:已有 AppData 未写 `agentMode` 时,仅当全局及逐 Agent 都是 `openai_responses` 才迁入 app-server;任何 `openai_chat / anthropic` 路由保持 `provider`,防止项目自动恢复先于用户改配置而批量失败。新安装仍默认 app-server;已确认兼容 Responses 的旧端点可由用户显式切换且继续使用原 model/base URL/API Key。 - 关联:`docs/technical/【技术方案】AI游戏创作Agent Runtime V1.1-2026-07-12.md` V1.52。 @@ -7937,6 +7938,17 @@ CI 上 `background_agent_runtime_recovers_stale_running_before_pending_task` 在 - 任务最终状态不再提前绑定平台画布、preview、static smoke 或发布产物检查;这些内容不参与该档位的完成判定,也不会因缺失而重置已完成任务。父 run 在任务图进入终态后直接收束并回复。 - 本档位仍沿用现有项目根和工具权限边界;本次调整只解除流程编排与平台产物验收前置,不新增第二套任务系统。 +## 2026-09-02 DirectProject replay 取舍与 thread 原子判定 + +- DirectProject 的全量 replay、简单 `user:` / `assistant:` / `tool:` 前缀和普通 assistant partial(末尾 `unexpected interrupt happened here`)都是有意的当前产品合同:分别保证 AGC JSONL 事实源无损重建、保持 prompt 形状稳定且不引入 envelope breaking change、让模型明确知道上次输出在中断处结束。后续若调整任一项,必须先更新恢复合同与兼容策略。 +- replay 与 thread 创建必须使用同一临界区结果。`turn_gate` 内的 `thread_for()` 原子返回 `(CodexThreadLease, created)`;只有 `created=true` 时才读取项目 JSONL 并构造历史 prompt,复用已有 thread 时只发送当前 user,避免并发首请求重复注入历史。 + +## 2026-09-03 DirectProject replay 有界滑动窗口 + +- DirectProject 继续以 `.agent/conversations/project.jsonl` 作为不可变、append-only 唯一事实源;窗口只生成本次恢复请求的派生 prompt,不写回 JSONL,不创建 Runtime compaction summary 或 sidecar。 +- 新建/恢复 ephemeral Codex thread 时,replay 使用 `contextWindowTokens`、`autoCompactTokenLimit`、本次 `maxOutputTokens` 与 4096 安全余量计算预算,从最新记录向前选择连续完整的 `user` / `assistant` / `tool` 行;超预算旧前缀被省略,单条记录不截断,当前 user request 始终保留。 +- 发生省略时在 prompt 开头加入普通 `system: Earlier conversation history was omitted due to context budget.` 提示;当前请求本身超过硬上下文预算则直接失败。该策略是 Direct 专用滑动窗口,不复用 Runtime Agent 的摘要、tail 或 session compaction 生命周期。 + ## 2026-08-31 AGC 错误报告与诊断上传 - AGC 采用 IDEA 风格的当前进程错误池:按 fingerprint 合并 React / window / Promise / Tauri / Agent 错误,重启后不恢复,不使用 run 或 run_id。 diff --git a/docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md b/docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md index ce63e5020..8e886e8a4 100644 --- a/docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md +++ b/docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md @@ -226,6 +226,7 @@ Supervisor 认领该回执后,由父 run 自己为每个原 delivery 逐一创 - 模式升级:`agentMode` 扩为 `codex_app_server / codex_cli / provider`,新默认为 `codex_app_server`;V1.51 的一次性 `codex exec` 保留为显式兼容模式,HTTP Provider 保留为非 Responses 配置及故障回退模式。 - 进程与节点:External Runner 按“有效 Agent LLM 凭据/Responses 路由 + `projectId/agentId/sessionId/runId`”隔离长期 `codex app-server --stdio`,即每个权威节点 run 直接持有自己的 Codex CLI 子进程与 ephemeral thread,每次完整权威请求映射 turn。同一节点 turn 串行,节点之间进程级隔离;单节点连接失败不得使其它节点同时失去终态。Codex thread 不写 durable recovery;节点完成、重启、retry、handoff 和 finalization 仍只认 AGC 账本。 +- DirectProject replay:Codex thread 仍保持 `ephemeral=true`。`.agent/conversations/project.jsonl` 是聊天唯一、append-only 事实源;每个 GUI turn 在发起 app-server turn 前,先把渲染后的规范化 user prompt 以 `direct-codex:{clientTurnId}:user` 幂等追加,持久化失败则不发起 turn 并公开 failed;LLM 失败 / 中断时保留该 user 记录,重试同一 `clientTurnId` 只复用它。仅当 app-server 连接没有可用的项目 thread(通常是进程重启或 thread 被淘汰)时,AGC 才读取历史,按原顺序渲染为简单 `user:` / `assistant:` / `tool:` 行,再追加本次新 user request,发送给新建 thread;已有 thread 的普通消息仍只发送新 user。为避免持久增长的历史超过模型上下文,replay builder 使用全局 `contextWindowTokens`、`autoCompactTokenLimit`、本次 `maxOutputTokens` 和 4096 安全余量计算输入预算,从最新记录向前选择连续、完整的消息;超预算的旧前缀只在本次 prompt 中省略,不改写 JSONL、不写 summary/sidecar、不拆分单条记录。发生省略时在 prompt 开头加入普通 `system: Earlier conversation history was omitted due to context budget.` 行;当前 user request 始终保留,若其自身超过硬上下文预算则直接失败。这里的简单 role 前缀和普通 assistant partial(末尾 `unexpected interrupt happened here`)仍是产品合同:保持 prompt 形状稳定、避免 envelope breaking change,并让模型明确知道上次输出在断开处结束。app-server 意外中断时,已收到的 partial 文本按普通 `assistant` 消息追加;断开处理和下一次发送都可尝试写入,依赖普通 `messageId` 幂等。项目打开只读取历史,不因 user-only 记录自动重发;Direct 不提供 retry 入口。Runtime Agent 继续使用独立的 runtime/context 恢复链路,不读取 DirectProject 对话作为原生 thread history。 - LLM 配置:`apiKind` 始终只接受 `openai_responses`;非空 Key 转换为 app-server model provider,base URL 生效,Key 仅走专用环境变量;空 Key 只桥接用户 Codex `auth.json`,不继承环境 `CODEX_API_KEY`。设置面板在 app-server 模式继续显示并保存 model、effort、stream、全局/逐 Agent Key 与路由配置;`openai_chat / anthropic` 明确提示切 `provider`,不得悄悄忽略。`stream=true` 接入 app-server 文本 delta;`webSearchEnabled=true` 只允许 DirectProject 经客户端审核的 `agc_web_search` 使用,不得启用 Codex 原生 webSearch 或任意网络。 - 安全与取消:临时 cwd、隔离 `CODEX_HOME` 与 OS HOME、read-only、network off、never approval,并在启动前关闭 web/multi-agent/shell/browser/plugin/image 等原生能力;取消从 turn-start pending 阶段就跟踪且只 interrupt 当前 turn。已发送 turn 后连接断开或终态丢失进入 reconciliation,只关闭当前节点进程且不重放同一 request slot;明确 failed/interrupted 不按 transport 重试。 - remote-control 认证边界:没有 ChatGPT `auth.json` 的 API Key / provider-proxy app-server 在启动时设置 Codex 内部环境变量 `CODEX_INTERNAL_APP_SERVER_REMOTE_CONTROL_DISABLED=1`,让 remote-control 以 `desired_state=Disabled` 启动,避免上游进入 1Hz 认证重试;不再依赖需要 ChatGPT 登录态的 `remoteControl/disable` RPC。只有实际桥接 ChatGPT 登录态的 AuthBridge 保持 remote-control 可用。API Key 子进程同时使用 `RUST_LOG=warn` 收敛剩余预期噪音,不伪造 `auth.json` 或静默继续。 From 4cd53688e9944a4a24c11833721968f128a45e2a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E5=BE=B7=E5=AE=87?= Date: Thu, 3 Sep 2026 19:36:18 +0800 Subject: [PATCH 28/28] =?UTF-8?q?Feat/=E4=BC=98=E5=8C=96UI=E7=BC=96?= =?UTF-8?q?=E8=BE=91=E5=99=A8inspector=20offset=E7=BC=96=E8=BE=91=E9=83=A8?= =?UTF-8?q?=E5=88=86UI=20(#265)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit before: ![image.png](/attachments/e0344964-55c6-4ba1-9191-a79d81f32b2e) after: ![shotmd-1788414839.jpg](/attachments/14c8a723-b2df-48d5-9773-7f576bcef873) close #267 --------- Co-authored-by: 段舒康 Reviewed-on: http://192.168.35.82/git/GenarrativeAI/Genarrative/pulls/265 Co-authored-by: 王德宇 Co-committed-by: 王德宇 --- .../Inspector/Transform/TransformEditor.tsx | 355 ++++++++++++++++-- .../tests/TransformEditor.test.tsx | 115 ++++++ docs/README.md | 1 + ...计】UI编辑器变换角点偏移编辑器-2026-09-03.md | 60 +++ 4 files changed, 493 insertions(+), 38 deletions(-) create mode 100644 apps/ai-game-creator-shell/tests/TransformEditor.test.tsx create mode 100644 docs/technical/【设计】UI编辑器变换角点偏移编辑器-2026-09-03.md diff --git a/apps/ai-game-creator-shell/src/view/ui-editor/components/Inspector/Transform/TransformEditor.tsx b/apps/ai-game-creator-shell/src/view/ui-editor/components/Inspector/Transform/TransformEditor.tsx index 85039f389..3749f9b48 100644 --- a/apps/ai-game-creator-shell/src/view/ui-editor/components/Inspector/Transform/TransformEditor.tsx +++ b/apps/ai-game-creator-shell/src/view/ui-editor/components/Inspector/Transform/TransformEditor.tsx @@ -1,12 +1,16 @@ import { AlertTriangle, + ArrowDown, + ArrowLeft, + ArrowRight, + ArrowUp, ChevronDown, ChevronUp, Crosshair, Info, Move, } from 'lucide-react'; -import { useEffect, useMemo, useRef, useState } from 'react'; +import { type ReactNode, useEffect, useMemo, useRef, useState } from 'react'; import { pageRectFromSize, @@ -30,6 +34,7 @@ export type TransformEditorProps = { }; type Axis = 0 | 1; +type Corner = 'top-left' | 'top-right' | 'bottom-left' | 'bottom-right'; type AnchorPreset = { id: string; label: string; @@ -53,6 +58,45 @@ const ROW_MODE_LABELS: Record = { const COLUMN_MODES: AnchorMode[] = ['start', 'center', 'end', 'stretch']; const ROW_MODES: AnchorMode[] = ['start', 'center', 'end', 'stretch']; +const CORNERS: readonly { + id: Corner; + label: string; +}[] = [ + { id: 'top-left', label: '左上角' }, + { id: 'top-right', label: '右上角' }, + { id: 'bottom-left', label: '左下角' }, + { id: 'bottom-right', label: '右下角' }, +]; + +function CornerIcon({ + corner, + active = false, +}: { + corner: Corner; + active?: boolean; +}) { + const paths: Record = { + 'top-left': 'M5 11V5h6', + 'top-right': 'M13 11V5H7', + 'bottom-left': 'M5 9v6h6', + 'bottom-right': 'M13 9v6H7', + }; + return ( + + ); +} + const PRESETS: AnchorPreset[] = ROW_MODES.flatMap((y) => COLUMN_MODES.map((x) => ({ id: `${y}-${x}`, @@ -74,10 +118,6 @@ const FIELD_HINTS = { '锚点最小值:用父容器的比例位置(0 到 1)定义元素的左上边界。0 表示左侧或顶部,1 表示右侧或底部。', anchor_max: '锚点最大值:用父容器的比例位置(0 到 1)定义元素的右下边界。与最小值不同可让元素随父容器拉伸。', - offset_min: - '偏移最小值:相对于最小锚点的像素偏移,控制元素左侧和顶部的位置。', - offset_max: - '偏移最大值:相对于最大锚点的像素偏移,控制元素右侧和底部的位置。', } as const; function cloneTransform(transform: Transform): Transform { @@ -166,9 +206,44 @@ function formatValue(value: number): string { return String(Number(value.toFixed(2))); } +function cornerFields(corner: Corner): { + x: 'offset_min' | 'offset_max'; + y: 'offset_min' | 'offset_max'; +} { + switch (corner) { + case 'top-left': + return { x: 'offset_min', y: 'offset_min' }; + case 'top-right': + return { x: 'offset_max', y: 'offset_min' }; + case 'bottom-left': + return { x: 'offset_min', y: 'offset_max' }; + case 'bottom-right': + return { x: 'offset_max', y: 'offset_max' }; + } +} + +function cornerValues(transform: Transform, corner: Corner): [number, number] { + const fields = cornerFields(corner); + return [transform[fields.x][0], transform[fields.y][1]]; +} + +function updateCorner( + transform: Transform, + corner: Corner, + axis: Axis, + value: number, +): Transform { + const fields = cornerFields(corner); + const next = cloneTransform(transform); + next[axis === 0 ? fields.x : fields.y][axis] = value; + return next; +} + function VectorInputRow({ label, hint, + hideLabel = false, + stacked = false, values, step, readOnly, @@ -176,24 +251,45 @@ function VectorInputRow({ }: { label: string; hint: string; + hideLabel?: boolean; + stacked?: boolean; values: readonly [number, number]; step: number; readOnly: boolean; onCommit: (axis: Axis, value: number) => void; }) { + const fields = AXIS_LABELS.map((axisLabel, axis) => ( + onCommit(axis as Axis, value)} + /> + )); + + if (stacked) { + return ( +
+ {hideLabel ? ( + {label} + ) : ( + + )} +
{fields}
+
+ ); + } + return (
- - {AXIS_LABELS.map((axisLabel, axis) => ( - onCommit(axis as Axis, value)} - /> - ))} + {hideLabel ? ( + {label} + ) : ( + + )} + {fields}
); } @@ -201,7 +297,7 @@ function VectorInputRow({ function FieldLabel({ label, hint }: { label: string; hint: string }) { return ( {label} @@ -213,12 +309,6 @@ function FieldLabel({ label, hint }: { label: string; hint: string }) { > ); } @@ -320,12 +410,105 @@ function ScalarInput({ ); } +function DirectionButton({ + ariaLabel, + disabled, + onAdjust, + children, +}: { + ariaLabel: string; + disabled: boolean; + onAdjust: (step: number) => void; + children: ReactNode; +}) { + const repeatTimeout = useRef | null>(null); + const repeatInterval = useRef | null>(null); + const repeated = useRef(false); + + const stopRepeating = () => { + if (repeatTimeout.current) { + clearTimeout(repeatTimeout.current); + repeatTimeout.current = null; + } + if (repeatInterval.current) { + clearInterval(repeatInterval.current); + repeatInterval.current = null; + } + }; + + const startRepeating = (multiplier: number) => { + if (disabled) { + return; + } + repeated.current = false; + repeatTimeout.current = setTimeout(() => { + repeated.current = true; + onAdjust(multiplier); + repeatInterval.current = setInterval(() => onAdjust(multiplier), 70); + }, 350); + }; + + useEffect(() => { + return () => { + if (repeatTimeout.current) { + clearTimeout(repeatTimeout.current); + repeatTimeout.current = null; + } + if (repeatInterval.current) { + clearInterval(repeatInterval.current); + repeatInterval.current = null; + } + }; + }, []); + + return ( + + ); +} + export function TransformEditor({ transform, parentSize, readOnly = false, onChange, }: TransformEditorProps) { + const [selectedCorner, setSelectedCorner] = useState('top-left'); + const transformRef = useRef(transform); + const selectedCornerRef = useRef(selectedCorner); + useEffect(() => { + transformRef.current = transform; + selectedCornerRef.current = selectedCorner; + }, [transform, selectedCorner]); const [presetOpen, setPresetOpen] = useState(false); const [customOpen, setCustomOpen] = useState( () => findPreset(transform).id === CUSTOM_PRESET.id, @@ -369,6 +552,24 @@ export function TransformEditor({ transform.anchor_min[1] > transform.anchor_max[1] || geometry?.invalid; + const selectedCornerLabel = + CORNERS.find((corner) => corner.id === selectedCorner)?.label ?? '左上角'; + const selectedCornerValues = cornerValues(transform, selectedCorner); + const adjustSelectedCorner = ( + axis: Axis, + direction: -1 | 1, + multiplier = 1, + ) => { + if (readOnly) { + return; + } + const currentTransform = transformRef.current; + const currentCorner = selectedCornerRef.current; + const current = cornerValues(currentTransform, currentCorner)[axis]; + const next = Number((current + direction * multiplier).toFixed(4)); + onChange(updateCorner(currentTransform, currentCorner, axis, next)); + }; + return (
- updateVector('offset_min', axis, value)} - /> - updateVector('offset_max', axis, value)} + + +
+
+ {CORNERS.map((corner) => { + const active = corner.id === selectedCorner; + return ( + + ); + })} +
+ +
+
+ + + adjustSelectedCorner(1, -1, multiplier * 1) + } + > + + + + adjustSelectedCorner(0, -1, multiplier * 1) + } + > + +
+ +
+ + adjustSelectedCorner(0, 1, multiplier * 1) + } + > + + + + adjustSelectedCorner(1, 1, multiplier * 1) + } + > + + +
+ + + onChange(updateCorner(transform, selectedCorner, axis, value)) + } + /> +
+
diff --git a/apps/ai-game-creator-shell/tests/TransformEditor.test.tsx b/apps/ai-game-creator-shell/tests/TransformEditor.test.tsx new file mode 100644 index 000000000..9145db48b --- /dev/null +++ b/apps/ai-game-creator-shell/tests/TransformEditor.test.tsx @@ -0,0 +1,115 @@ +// @vitest-environment jsdom + +import { act, fireEvent, render, screen } from '@testing-library/react'; +import { afterEach, describe, expect, it, vi } from 'vitest'; + +import type { Transform } from '../src/features/ui-editor/types/Transform'; +import { TransformEditor } from '../src/view/ui-editor/components/Inspector/Transform/TransformEditor'; + +const transform: Transform = { + anchor_min: [0, 0], + anchor_max: [1, 1], + offset_min: [10, 20], + offset_max: [30, 40], +}; + +function firePointerDown(element: HTMLElement, button: number) { + const event = new Event('pointerdown', { bubbles: true }); + Object.defineProperty(event, 'button', { value: button }); + fireEvent(element, event); +} + +function renderEditor(onChange = vi.fn()) { + return { + onChange, + ...render(), + }; +} + +describe('TransformEditor corner offset controls', () => { + afterEach(() => { + vi.useRealTimers(); + }); + + it('uses one X/Y input pair for the selected corner', () => { + renderEditor(); + + expect( + screen.getByRole('spinbutton', { name: '位置微调 X' }), + ).toHaveProperty('value', '10'); + expect( + screen.getByRole('spinbutton', { name: '位置微调 Y' }), + ).toHaveProperty('value', '20'); + expect(screen.getAllByRole('spinbutton')).toHaveLength(2); + + fireEvent.click(screen.getByRole('button', { name: '右下角' })); + + expect( + screen.getByRole('spinbutton', { name: '位置微调 X' }), + ).toHaveProperty('value', '30'); + expect( + screen.getByRole('spinbutton', { name: '位置微调 Y' }), + ).toHaveProperty('value', '40'); + }); + + it('writes the selected corner input back to the matching offset component', () => { + const { onChange } = renderEditor(); + + fireEvent.click(screen.getByRole('button', { name: '右上角' })); + const xInput = screen.getByRole('spinbutton', { name: '位置微调 X' }); + fireEvent.change(xInput, { target: { value: '55' } }); + fireEvent.blur(xInput); + + expect(onChange).toHaveBeenLastCalledWith({ + ...transform, + offset_max: [55, 40], + }); + }); + + it('moves only the selected corner with direction keys', () => { + const { onChange } = renderEditor(); + + fireEvent.click(screen.getByRole('button', { name: '右下角' })); + fireEvent.click(screen.getByRole('button', { name: '右下角向左' })); + expect(onChange).toHaveBeenLastCalledWith({ + ...transform, + offset_max: [29, 40], + }); + + fireEvent.click(screen.getByRole('button', { name: '右下角向上' }), { + shiftKey: true, + }); + expect(onChange).toHaveBeenLastCalledWith({ + ...transform, + offset_max: [30, 30], + }); + }); + + it('allows a click adjustment after a cancelled long press', () => { + vi.useFakeTimers(); + const { onChange } = renderEditor(); + const button = screen.getByRole('button', { name: '左上角向右' }); + + firePointerDown(button, 0); + act(() => { + vi.advanceTimersByTime(350); + }); + fireEvent.pointerCancel(button); + fireEvent.click(button); + + expect(onChange).toHaveBeenCalledTimes(2); + }); + + it('does not start repeating for non-primary pointer buttons', () => { + vi.useFakeTimers(); + const { onChange } = renderEditor(); + const button = screen.getByRole('button', { name: '左上角向右' }); + + firePointerDown(button, 2); + act(() => { + vi.advanceTimersByTime(350); + }); + + expect(onChange).not.toHaveBeenCalled(); + }); +}); diff --git a/docs/README.md b/docs/README.md index 985ec1c12..d44e466ad 100644 --- a/docs/README.md +++ b/docs/README.md @@ -30,6 +30,7 @@ - [GameAgent 资源自由画板与快速编辑](./technical/【技术方案】GameAgent资源自由画板与快速编辑-2026-08-20.md) - [UI 工作流资源桥接与 Runtime 执行](./【技术方案】UI工作流资源桥接与Runtime执行-2026-08-24.md) - [UI 编辑器 Godot 容器布局](./technical/【技术方案】UI编辑器Godot容器布局模型-2026-08-18.md) +- [UI 编辑器变换角点偏移编辑器](./technical/【设计】UI编辑器变换角点偏移编辑器-2026-09-03.md) - [UI 编辑器子节点显示规则](./technical/【技术方案】UI编辑器子节点显示规则-2026-08-18.md) - [UI 编辑会话模块边界](./technical/【前端架构】UI编辑会话模块边界-2026-08-19.md) - [UI 编辑器撤销重做规范](./【UI编辑器】撤销重做规范-2026-09-03.md) diff --git a/docs/technical/【设计】UI编辑器变换角点偏移编辑器-2026-09-03.md b/docs/technical/【设计】UI编辑器变换角点偏移编辑器-2026-09-03.md new file mode 100644 index 000000000..3ffc54549 --- /dev/null +++ b/docs/technical/【设计】UI编辑器变换角点偏移编辑器-2026-09-03.md @@ -0,0 +1,60 @@ +# UI 编辑器变换角点偏移编辑器 + +更新时间:`2026-09-03` + +## 目标 + +将 UI 编辑器 Inspector 中的 offset 编辑从两行 `offset_min/offset_max` 改为四角选择式编辑:界面展示左上、右上、左下、右下四个角,用户先选择一个角,再使用唯一的一组 X/Y 数字输入和四个方向键调整该角。 + +本次只改变编辑器表现和交互,不改变 `Transform` 数据结构、持久化格式或后端契约。 + +## 数据映射 + +四角是现有两个 offset 向量的投影,不是四组独立数据: + +| 选中角 | X 输入写入 | Y 输入写入 | +| ------ | --------------- | --------------- | +| 左上 | `offset_min[0]` | `offset_min[1]` | +| 右上 | `offset_max[0]` | `offset_min[1]` | +| 左下 | `offset_min[0]` | `offset_max[1]` | +| 右下 | `offset_max[0]` | `offset_max[1]` | + +输入框始终显示对应 `Transform` 的原始 offset 值,保留现有正负号;不转换成 CSS `right`/`bottom` 语义。切换角时读取同一组共享分量,修改后其他受影响角会随数据更新而同步。 + +## 交互 + +- 组件内部维护当前选中角,首次挂载时默认左上;切换角点由用户控制,编辑过程中不会因 offset 更新而跳回其他角。 +- 四角按钮按左上、右上、左下、右下空间位置排布;当前角高亮并提供可访问名称。 +- 页面只渲染一组 X/Y 数字输入和一组上、下、左、右方向键,二者都只作用于当前选中角。 +- 方向键每次按 `1` 调整对应分量;按住支持连续触发,按住 `Shift` 时步长为 `10`。 +- 方向键长按只响应主指针;指针取消或离开时清理长按抑制状态,正常释放仍抑制尾随点击。 +- 方向键映射:左/右调整选中角的 X, 上/下调整选中角的 Y。具体写入字段遵循上表,因此移动角点可能改变矩形尺寸。 +- 数字输入沿用现有行为:失焦或回车提交,Escape 恢复当前值,非法或空值不写回;只读状态禁用所有变更入口。 +- 锚点预设、自定义锚点、无效矩形 warning、根节点禁止修改等现有行为保持不变。 + +## 视觉约束 + +- 四角选择器使用 icon-only 的 L 形角标,按左上、右上、左下、右下方向旋转;不在按钮内显示角点文字。 +- 角点示意图中心不显示说明文字,仅保留淡色矩形和角点高亮。 +- 输入区不显示“偏移”标签,只保留 X/Y 字段;说明信息按钮 `i` 放在“位置微调”标题旁,完整语义通过 `aria-label` 和 `title` 提供。 +- 说明只使用浏览器原生 `title` 提示,不再渲染自定义悬浮气泡。 +- 方向键保持十字布局,使用紧凑的中性 icon-only 按钮;橙色只用于选中态和焦点/悬停强调,避免大面积绿色填充。 +- 角点选择器和方向键的可点击尺寸不小于 28px。 +- 主控区采用两列布局:左侧为无分隔线的方形四角选择器,右侧上方为四方向键、下方为唯一一组 X/Y 输入。 +- 四角选择器不绘制连接线或虚线框,依靠方形容器、角标图标和选中态表达空间关系。 +- “位置微调”作为右侧控制区的短标题显示在两列之上;两列内容从同一条顶线开始对齐。 +- 当前角图标只保留在方向键十字中心,数字输入行不再重复显示角点指示器。 +- 左侧四角选择器保持较小的正方形,并在右侧方向键、纵向 X/Y 输入区的整体高度中垂直居中;X/Y 输入在右侧纵向排列,以避免窄面板中的横向拥挤。 + +## 非目标 + +- 不新增四角持久化字段,不修改 `Transform` schema 或后端接口。 +- 不提供四角独立状态,也不提供整体平移快捷键。 +- 不增加移动端专属布局或移动端交互。 + +## 验收 + +1. 四角选择能正确驱动唯一一组 X/Y 输入,并按数据映射表写回 `offset_min/offset_max`。 +2. 四方向键只修改当前选中角对应分量,步长、Shift 加速、长按行为可用。 +3. 输入提交、Escape 回滚、非法值、只读状态和现有 warning 行为不回归。 +4. `Transform` 类型、持久化数据和现有锚点编辑行为保持不变。