diff --git a/apps/ai-game-creator-shell/src-tauri/src/tests/project.rs b/apps/ai-game-creator-shell/src-tauri/src/tests/project.rs index 5d746c7cc..b2f1bdac8 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/tests/project.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/tests/project.rs @@ -728,13 +728,17 @@ async fn background_agent_runtime_can_generate_platform_art_asset() { "tool": "canvas.asset_generate", "reason": "生成可用于首版原型的主角素材", "input": { - "prompt": "透明 PNG 像素月光主角,适合厨房弹幕游戏", + "prompt": "透明 PNG 像素月光主角图集,按 2 行 2 列等分网格排布,适合厨房弹幕游戏", "outputPath": "assets/art-spritesheet.png", "aspectRatio": "1:1", "imageSize": "1K", "assetKind": "art-spritesheet", "assetLabel": "游戏首版核心美术素材", - "replaceExisting": false + "replaceExisting": false, + "sliceMode": "grid", + "gridX": 2, + "gridY": 2, + "sliceCount": null } } ], @@ -775,7 +779,7 @@ async fn background_agent_runtime_can_generate_platform_art_asset() { start_game_creator_agent_background_task_at( &root, "art-asset-plan", - "为月光厨房生成首版主角素材", + "为月光厨房生成首版主角素材图集,按 2 行 2 列等分网格排布", "art-generate-run", ) .expect("start background task"); @@ -933,6 +937,17 @@ async fn background_agent_runtime_can_generate_platform_art_asset() { let generation_idempotency_key = request_header(generation_request, "idempotency-key").expect("generation idempotency key"); assert!(uuid::Uuid::parse_str(&generation_idempotency_key).is_ok()); + let generation_body: Value = serde_json::from_str( + generation_request + .split_once("\r\n\r\n") + .expect("generation request body") + .1, + ) + .expect("generation request json"); + assert_eq!(generation_body["sliceMode"], "grid"); + assert_eq!(generation_body["gridX"], 2); + assert_eq!(generation_body["gridY"], 2); + assert!(generation_body["sliceCount"].is_null()); assert!(generation_request.contains(r#""source":"ai-game-creator-client""#)); assert_eq!( canvas_requests diff --git a/apps/ai-game-creator-shell/src-tauri/src/tests/provider.rs b/apps/ai-game-creator-shell/src-tauri/src/tests/provider.rs index dd4946ea4..7bc72b7af 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/tests/provider.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/tests/provider.rs @@ -6916,7 +6916,11 @@ fn agent_native_function_catalog_exposes_each_runtime_tool_with_core_schemas() { "imageSize", "assetKind", "assetLabel", - "replaceExisting" + "replaceExisting", + "sliceMode", + "gridX", + "gridY", + "sliceCount" ]) ); assert_eq!( @@ -6927,6 +6931,10 @@ fn agent_native_function_catalog_exposes_each_runtime_tool_with_core_schemas() { canvas_asset.parameters["properties"]["input"]["properties"]["imageSize"]["enum"], serde_json::json!(["0.5K", "1K", "2K", null]) ); + assert_eq!( + canvas_asset.parameters["properties"]["input"]["properties"]["sliceMode"]["enum"], + serde_json::json!(["connected-components", "grid", null]) + ); assert_eq!( canvas_asset.parameters["properties"]["input"]["properties"]["assetKind"]["enum"], serde_json::json!([ diff --git a/apps/ai-game-creator-shell/src/features/agent-runtime/directActiveTurns.ts b/apps/ai-game-creator-shell/src/features/agent-runtime/directActiveTurns.ts index a9d4c478e..f7b69de22 100644 --- a/apps/ai-game-creator-shell/src/features/agent-runtime/directActiveTurns.ts +++ b/apps/ai-game-creator-shell/src/features/agent-runtime/directActiveTurns.ts @@ -45,7 +45,8 @@ export function useDirectActiveTurns({ * 换掉数组身份:所有依赖 `activeTurns` 的 effect 都会跟着重跑(窗口标题栏的活动项目 * 面板就是这么被反复重发布的)。这里只在内容真的变了才更新状态。 */ - const lastSnapshotSignatureRef = useRef(''); + const lastSnapshotSignatureRef = useRef('[]'); + const requestGenerationRef = useRef(0); const retryTimerRef = useRef(null); useEffect(() => { @@ -60,13 +61,16 @@ export function useDirectActiveTurns({ }, []); const refreshActiveTurns = useCallback(async () => { - if (!invoke) { + if (!enabled || !invoke) { return; } // 单飞:轮询与"回合刚开始/刚结束"的主动刷新不叠成两个在途请求。 if (inFlightRef.current) { return inFlightRef.current; } + const generation = requestGenerationRef.current; + const isCurrent = () => + mountedRef.current && generation === requestGenerationRef.current; const request = (async () => { for ( let attempt = 1; @@ -77,7 +81,7 @@ export function useDirectActiveTurns({ const turns = await invoke( 'list_game_creator_direct_active_turns', ); - if (!mountedRef.current) { + if (!isCurrent()) { return; } const nextTurns = Array.isArray(turns) ? turns : []; @@ -90,6 +94,7 @@ export function useDirectActiveTurns({ inFlightRef.current = null; return; } catch { + if (!isCurrent()) return; if (attempt < DIRECT_ACTIVE_TURNS_READ_ATTEMPTS) { await new Promise((resolve) => { retryTimerRef.current = window.setTimeout(() => { @@ -97,22 +102,23 @@ export function useDirectActiveTurns({ resolve(); }, DIRECT_ACTIVE_TURNS_READ_RETRY_DELAY_MS * attempt); }); + if (!isCurrent()) return; } } } // 三次都读不到:保留上一份快照(读不到不等于没有在跑),只标记"本次没读到"。 - if (mountedRef.current) { + if (isCurrent()) { setSnapshotReadFailed(true); + inFlightRef.current = null; } - inFlightRef.current = null; })(); inFlightRef.current = request; return request; - }, [invoke]); + }, [enabled, invoke]); useEffect(() => { if (!enabled || !invoke) { - lastSnapshotSignatureRef.current = ''; + lastSnapshotSignatureRef.current = '[]'; // 空态也要保持引用稳定:已经空了就不要再换一个新数组。 setActiveTurns((current) => (current.length === 0 ? current : [])); setSnapshotReadFailed((current) => (current ? false : current)); @@ -123,7 +129,12 @@ export function useDirectActiveTurns({ () => void refreshActiveTurns(), Math.max(1_000, pollIntervalMs), ); - return () => window.clearInterval(timer); + return () => { + window.clearInterval(timer); + // 停用或切换读取器后,旧请求不得覆盖新状态,也不能占住新一轮单飞。 + requestGenerationRef.current += 1; + inFlightRef.current = null; + }; }, [enabled, invoke, pollIntervalMs, refreshActiveTurns]); return { activeTurns, refreshActiveTurns, snapshotReadFailed }; diff --git a/apps/ai-game-creator-shell/src/view/project-development/index.tsx b/apps/ai-game-creator-shell/src/view/project-development/index.tsx index cd0e4276a..1675dabb4 100644 --- a/apps/ai-game-creator-shell/src/view/project-development/index.tsx +++ b/apps/ai-game-creator-shell/src/view/project-development/index.tsx @@ -14,8 +14,8 @@ import { CanvasChromeButton, SelectionOverlay, } from '@genarrative/image-canvas-react'; -import { save as saveNativeFileDialog } from '@tauri-apps/plugin-dialog'; import { CanvasCardCornerActions } from '@genarrative/shared/components'; +import { save as saveNativeFileDialog } from '@tauri-apps/plugin-dialog'; import { AtSign, Crosshair, diff --git a/apps/ai-game-creator-shell/tests/directActiveTurns.test.tsx b/apps/ai-game-creator-shell/tests/directActiveTurns.test.tsx index 521cf634a..f7acdba4e 100644 --- a/apps/ai-game-creator-shell/tests/directActiveTurns.test.tsx +++ b/apps/ai-game-creator-shell/tests/directActiveTurns.test.tsx @@ -60,10 +60,11 @@ describe('useDirectActiveTurns', () => { { initialProps: { enabled: true } }, ); - await waitFor(() => { - expect(result.current.activeTurns).toEqual([]); - }); const emptySnapshot = result.current.activeTurns; + await act(async () => { + await result.current.refreshActiveTurns(); + }); + expect(result.current.activeTurns).toBe(emptySnapshot); rerender({ enabled: false }); expect(result.current.activeTurns).toBe(emptySnapshot); }); @@ -96,6 +97,59 @@ describe('useDirectActiveTurns', () => { clearTimeoutSpy.mockRestore(); } }); + + it('停用后晚到的非空快照不能恢复活动回合,手动刷新也不发请求', async () => { + let complete!: (turns: GameCreatorDirectActiveTurn[]) => void; + const invoke = vi.fn( + () => + new Promise((resolve) => { + complete = resolve; + }), + ); + const { result, rerender } = renderHook( + ({ enabled }) => + useDirectActiveTurns({ invoke: invoke as never, enabled }), + { initialProps: { enabled: true } }, + ); + rerender({ enabled: false }); + const empty = result.current.activeTurns; + await act(async () => { + complete([ACTIVE_TURN]); + await result.current.refreshActiveTurns(); + }); + expect(result.current.activeTurns).toBe(empty); + expect(result.current.snapshotReadFailed).toBe(false); + expect(invoke).toHaveBeenCalledTimes(1); + }); + + it('重新启用后读取新快照,旧请求晚到不能覆盖新快照', async () => { + let completeOld!: (turns: GameCreatorDirectActiveTurn[]) => void; + const invoke = vi + .fn() + .mockImplementationOnce( + () => + new Promise((resolve) => { + completeOld = resolve; + }), + ) + .mockResolvedValue([{ ...ACTIVE_TURN, runId: 'new-run' }]); + const { result, rerender } = renderHook( + ({ enabled }) => + useDirectActiveTurns({ invoke: invoke as never, enabled }), + { initialProps: { enabled: true } }, + ); + rerender({ enabled: false }); + rerender({ enabled: true }); + await waitFor(() => + expect(result.current.activeTurns[0]?.runId).toBe('new-run'), + ); + const current = result.current.activeTurns; + await act(async () => { + completeOld([ACTIVE_TURN]); + }); + expect(result.current.activeTurns).toBe(current); + expect(invoke).toHaveBeenCalledTimes(2); + }); }); describe('ActiveProjectRunsPanel', () => { diff --git a/apps/ai-game-creator-shell/tests/workspaceWindowSync.test.tsx b/apps/ai-game-creator-shell/tests/workspaceWindowSync.test.tsx index 457f61417..897a71ac9 100644 --- a/apps/ai-game-creator-shell/tests/workspaceWindowSync.test.tsx +++ b/apps/ai-game-creator-shell/tests/workspaceWindowSync.test.tsx @@ -84,8 +84,8 @@ it('真实窗口与工作台状态同步收敛,回调读取最新处理器且 await act(async () => { await Promise.resolve(); }); - // 无原生 invoke 时 active-turn Hook 会把初始快照归一为空数组一次。 - expect(publications).toHaveLength(2); + // 无原生 invoke 时空快照引用不变,只发布一次。 + expect(publications).toHaveLength(1); expect(cleanups).toBe(0); expect(new Set(publications.map((item) => item.onOpenProject)).size).toBe( 1, @@ -94,13 +94,13 @@ it('真实窗口与工作台状态同步收敛,回调读取最新处理器且 const latestOpen = vi.fn(async () => undefined); homeProjectOverride.openProject = latestOpen; rendered.rerender(view('更改显示名')); - expect(publications).toHaveLength(2); + expect(publications).toHaveLength(1); act(() => openProject('/tmp/window-latest-project')); expect(latestOpen).toHaveBeenCalledWith( '/tmp/window-latest-project', 'open', ); - expect(publications).toHaveLength(2); + expect(publications).toHaveLength(1); expect(cleanups).toBe(0); rendered.unmount(); expect(cleanups).toBe(1); diff --git a/docs/project-memory/plans/【实施计划】AGC资源菜单收纳-2026-09-17.md b/docs/project-memory/plans/【实施计划】AGC资源菜单收纳-2026-09-17.md index 96333cf43..6a8060348 100644 --- a/docs/project-memory/plans/【实施计划】AGC资源菜单收纳-2026-09-17.md +++ b/docs/project-memory/plans/【实施计划】AGC资源菜单收纳-2026-09-17.md @@ -2,6 +2,12 @@ 对应:[里程碑](./【里程碑】AGC资源菜单收纳-2026-09-17.md),Issue #409,产品已确认方案 A。 +## PR #410 CI 修复 + +以远端合并提交 959beebf 为基线:修复菜单文件 import 排序、Web 角标结构断言、活动回合空快照与晚到请求竞态;窗口发布次数断言对齐稳定快照合同。原生 HTTP scope 检查对齐官方 updater 当前权限,不恢复退役 OSS 白名单;Rust 图集测试补齐显式切片模式与 strict schema 字段,不放宽正式校验。按故障项定向测试后运行前端全套及原生契约检查;Rust 使用独立 target,实际未执行的检查必须单独列出。推送需再次确认。 + +本地修复验证:`npm test` 342 个文件通过(4137 项通过、37 项跳过),窗口与空快照最后一次定向复验 9 项通过;`lint:eslint`、根目录/AGC 类型检查、原生 contract 检查、Rust fmt、编码、文档索引与 diff 检查通过。Rust 工具目录 schema 用例及后台平台美术生成用例均在 Windows 独立 target 下通过;生成用例同时检查真实 mock 请求中的 grid、2×2 参数与响应匹配。未执行全量 Rust 分片、Linux CI、生产服务或真实客户端手感验收。 + 1. 在 shared 扩展通用操作收纳及卡片角标控件;共用工具栏只给 AGC 开启 5 项限制,Web 卡片迁移共用角标而不改现有回调。 2. AGC 卡片承接类型和信息,保留当前面板与命令链;信息使用资源身份防止换选竞态。 3. 补工具栏/工作台定向回归,检查禁用、移入、Escape、换选和卡片事件边界。 diff --git a/docs/project-memory/shared-memory/pitfalls.md b/docs/project-memory/shared-memory/pitfalls.md index 52b39d70a..633245b1a 100644 --- a/docs/project-memory/shared-memory/pitfalls.md +++ b/docs/project-memory/shared-memory/pitfalls.md @@ -21,6 +21,8 @@ JSON 的文本读取分支不等于卡面应该展示原始 State 摘要。卡 工作台向窗口标题栏发布运行项目时,若 effect 依赖普通函数派生的回调,发布 Context 会重新渲染工作台,进而再次发布并清理,形成更新深度循环。转发入口须稳定,并在提交阶段更新实际处理器引用;发布数据变化与卸载清理分开。回归测试必须组合真实窗口 Provider 和工作台消费者,只有独立画布测试无法覆盖这条反馈链;回归时用有界发布次数阻止测试失控。画布快速操作时暴露的更新深度错误,也须检查外层状态同步,不能直接归因于滚轮频率。 +活动回合快照的初始签名须与初始空数组一致,首次异步返回空数组不能额外换引用。停用、重新启用或切换读取器时应使旧请求失效,避免晚到结果覆盖新快照;测试需控制 Promise 完成时机,不能用“初始数组已为空”当作请求已结束。无原生读取器时窗口只发布一次空状态。 + ## 2026-09-17 工具 schema 声明的上限与真实校验不一致,会表现成「agent 调不动这个功能」 - **现象**:用户反馈「客户端没法由 agent 调用图片快速编辑功能以及背景音乐生成功能」。查工具目录时两个工具都在(`agc_edit_image`、`agc_create_or_derive_resource`),图片快速编辑在真实项目日志里还有成功记录;但 agent 侧写一句正常长度的背景音乐描述就失败,而客户端 UI 用同一个提示词却只是被截断加提示。 diff --git a/docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md b/docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md index 177465f2b..b1ee2415e 100644 --- a/docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md +++ b/docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md @@ -12,6 +12,7 @@ ## 资源画布交互与工作台状态同步 +- 活动回合轮询的初始空快照与后续空结果保持同一引用;停用或切换读取器使旧请求失效,晚到快照不得恢复已停用的活动回合或覆盖新轮询结果。无原生读取器时窗口只发布一次空状态,不通过额外空数组触发重复发布。 - 工作台向窗口标题栏发布正在运行的项目时,输入未变化不得形成重复发布与清理的渲染循环;打开项目动作始终使用当前工作台处理逻辑,退出工作台后清除其标题栏状态。 - 资源子画布(含「所有资源」)保留空白处左键框选、资源卡左键选中/拖动、触摸板双指平移及捏合缩放;右键按住空白处或资源卡拖动时平移画布,不改变资源选择与布局。中键和空格抓手继续可用。总览保留既有左键平移,并支持右键平移。 - 画布接管的右键手势不弹出原生菜单;输入框、媒体操作、工具条和独立浮层不被画布抢占。指针取消、捕获丢失或窗口失焦后终止平移,不能继续跟随指针。 diff --git a/packages/shared/src/components/CanvasCardCornerActions.tsx b/packages/shared/src/components/CanvasCardCornerActions.tsx index 4199f474c..9996e8923 100644 --- a/packages/shared/src/components/CanvasCardCornerActions.tsx +++ b/packages/shared/src/components/CanvasCardCornerActions.tsx @@ -1,5 +1,6 @@ import { Info } from 'lucide-react'; import type { CSSProperties, Ref } from 'react'; + import { PlatformIconButton } from './PlatformIconButton'; /** 画布卡片共用的类型标签与信息入口,不承接资源业务状态。 */ diff --git a/packages/shared/src/components/OverflowActions.test.tsx b/packages/shared/src/components/OverflowActions.test.tsx index 5f25b8d3a..85ad1d46a 100644 --- a/packages/shared/src/components/OverflowActions.test.tsx +++ b/packages/shared/src/components/OverflowActions.test.tsx @@ -7,8 +7,9 @@ import { within, } from '@testing-library/react'; import { afterEach, describe, expect, it, vi } from 'vitest'; -import { OverflowActions } from './OverflowActions'; + import { CanvasCardCornerActions } from './CanvasCardCornerActions'; +import { OverflowActions } from './OverflowActions'; afterEach(cleanup); describe('操作收纳与卡片角标', () => { diff --git a/packages/shared/src/components/OverflowActions.tsx b/packages/shared/src/components/OverflowActions.tsx index d79421308..23c4d222a 100644 --- a/packages/shared/src/components/OverflowActions.tsx +++ b/packages/shared/src/components/OverflowActions.tsx @@ -3,12 +3,12 @@ import { cloneElement, Fragment, isValidElement, + type ReactNode, useEffect, useId, useLayoutEffect, useRef, useState, - type ReactNode, } from 'react'; import { createPortal } from 'react-dom'; diff --git a/scripts/check-native-shells.mjs b/scripts/check-native-shells.mjs index 6748f9628..7b5447faf 100644 --- a/scripts/check-native-shells.mjs +++ b/scripts/check-native-shells.mjs @@ -2582,7 +2582,6 @@ function assertAiGameCreatorShellUserDevBoundary() { JSON.stringify([ { url: 'https://dev.genarrative.world/api/*' }, { url: 'https://www.genarrative.world/api/*' }, - { url: 'https://agc-dev.oss-rg-china-mainland.aliyuncs.com/*' }, { url: 'https://*/api/*' }, { url: 'http://localhost:*/*' }, { url: 'http://127.0.0.1:*/*' }, diff --git a/src/components/image-editor/ImageCanvasEditorGenerationIntegration.test.tsx b/src/components/image-editor/ImageCanvasEditorGenerationIntegration.test.tsx index cdbef119d..1c3c42bf5 100644 --- a/src/components/image-editor/ImageCanvasEditorGenerationIntegration.test.tsx +++ b/src/components/image-editor/ImageCanvasEditorGenerationIntegration.test.tsx @@ -3875,9 +3875,7 @@ describe('ImageCanvasEditorView generation integration', () => { if (!metadataCornerButton) { throw new Error('metadata corner button should exist'); } - expect(metadataCornerButton.className).toContain( - 'image-canvas-editor__metadata-corner', - ); + expect(metadataCornerButton.className).toContain('shared-canvas-card-info'); fireEvent.click(metadataCornerButton); const metadataDialog = screen.getByRole('dialog', { name: '图片信息' }); diff --git a/src/components/image-editor/ImageCanvasEditorView.test.tsx b/src/components/image-editor/ImageCanvasEditorView.test.tsx index 32d777569..92b947c88 100644 --- a/src/components/image-editor/ImageCanvasEditorView.test.tsx +++ b/src/components/image-editor/ImageCanvasEditorView.test.tsx @@ -1387,9 +1387,7 @@ describe('ImageCanvasEditorView', () => { const infoButton = screen.getByRole('button', { name: '查看拼图素材图片信息', }); - expect(infoButton.className).toContain( - 'image-canvas-editor__metadata-corner', - ); + expect(infoButton.className).toContain('shared-canvas-card-info'); fireEvent.click(infoButton); const infoPanel = screen.getByRole('dialog', { name: '图片信息' });