From 04b738755fdf90f247b9352ff83db5d65016f192 Mon Sep 17 00:00:00 2001 From: Suzumiya Date: Wed, 23 Sep 2026 12:38:04 +0800 Subject: [PATCH 1/6] =?UTF-8?q?=E4=BF=AE=E5=A4=8D=20AGC=20=E6=9C=80?= =?UTF-8?q?=E8=BF=91=E9=A1=B9=E7=9B=AE=E3=80=8C=E6=A3=80=E6=9F=A5=E5=A4=B1?= =?UTF-8?q?=E8=B4=A5=E3=80=8D=E8=A2=AB=E9=92=89=E6=AD=BB=EF=BC=9A=E5=A4=B1?= =?UTF-8?q?=E8=B4=A5=E9=80=80=E9=81=BF=E9=87=8D=E8=AF=95=20+=20=E6=8C=89?= =?UTF-8?q?=E8=BD=AE=E9=87=8D=E6=9F=A5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - useRecentProjects:单次目录检查失败先就地重试一次(300ms),不再立刻落成失败状态 - useRecentProjects:增量投影只保留成功结果,上一轮的失败项回到「检查中」并重新检查 - useRecentProjects:一轮结束仍有失败行时按 15s/45s/120s 退避重跑整张列表,连续无失败即清零 - app/tauri:导出命名类型 TauriInvoke,替换 3 处 ReturnType 写法 - tests/recentProjectsHook:新增「单次失败会重试」「刷新时重查失败项」两个用例 - tests/appSurface/home.suite:失败状态改为异步等待(重试后才会落成) --- apps/ai-game-creator-shell/src/app/tauri.ts | 11 ++- .../features/app-shell/useRecentProjects.ts | 75 +++++++++++++++++-- .../useDirectProjectChatController.ts | 4 +- .../tests/appSurface/home.suite.ts | 3 +- .../tests/recentProjectsHook.test.tsx | 72 ++++++++++++++++++ 5 files changed, 153 insertions(+), 12 deletions(-) diff --git a/apps/ai-game-creator-shell/src/app/tauri.ts b/apps/ai-game-creator-shell/src/app/tauri.ts index b1e3d4346..8a8cbd62d 100644 --- a/apps/ai-game-creator-shell/src/app/tauri.ts +++ b/apps/ai-game-creator-shell/src/app/tauri.ts @@ -1,3 +1,12 @@ -export function resolveTauriInvoke() { +/** + * AGC 调用 Rust 命令的唯一入口类型。全局 `window.__TAURI__.core.invoke` 由 Tauri 注入, + * 消费方一律从这个名字取类型,不要再写 `ReturnType`。 + */ +export type TauriInvoke = ( + command: string, + args?: Record, +) => Promise; + +export function resolveTauriInvoke(): TauriInvoke | undefined { return window.__TAURI__?.core?.invoke; } diff --git a/apps/ai-game-creator-shell/src/features/app-shell/useRecentProjects.ts b/apps/ai-game-creator-shell/src/features/app-shell/useRecentProjects.ts index b8f141ad3..93698a88e 100644 --- a/apps/ai-game-creator-shell/src/features/app-shell/useRecentProjects.ts +++ b/apps/ai-game-creator-shell/src/features/app-shell/useRecentProjects.ts @@ -7,7 +7,7 @@ import { useState, } from 'react'; -import { resolveTauriInvoke } from '../../app/tauri'; +import { resolveTauriInvoke, type TauriInvoke } from '../../app/tauri'; import type { LocalProjectDirectoryStatus } from '../../app/types'; import { isAbsoluteProjectPath, @@ -21,6 +21,17 @@ import { } from './model'; const RECENT_WORKSPACE_CHECK_TIMEOUT_MS = 5_000; +/** + * 单次目录检查失败后的就地重试退避。Tauri IPC 或磁盘的瞬时抖动不该把整行钉成 + * 「检查失败」;只有持续失败才落成失败状态。 + */ +const RECENT_WORKSPACE_CHECK_RETRY_DELAYS_MS = [300]; +/** + * 一轮检查结束仍有失败行时,按退避重跑整张列表(重跑次数上限 = 数组长度)。 + * 失败不进终态:例如 IPC 响应链路卡住十几秒之后,列表必须能自己恢复, + * 而不是保持「检查失败」直到用户重启客户端。 + */ +const RECENT_WORKSPACE_FAILURE_RECHECK_DELAYS_MS = [15_000, 45_000, 120_000]; export function useRecentProjects(setStatus: Dispatch>) { const [recentWorkspaces, setRecentWorkspaces] = @@ -35,9 +46,11 @@ export function useRecentProjects(setStatus: Dispatch>) { const recentWorkspacesRef = useRef(recentWorkspaces); recentWorkspacesRef.current = recentWorkspaces; const inspectionGenerationRef = useRef(0); + const failureRecheckTimerRef = useRef(undefined); + const failureStreakRef = useRef(0); async function inspectRecentWorkspace( - invoke: NonNullable>, + invoke: TauriInvoke, workspace: string, ): Promise<[string, LocalProjectDirectoryStatus | null]> { let timeoutHandle: number | undefined; @@ -63,6 +76,28 @@ export function useRecentProjects(setStatus: Dispatch>) { } } + /** + * 一轮检查结束仍有失败行时,按退避重跑整张列表;连续无失败的一轮会把计数清零。 + * 这是「一次抖动不能把列表钉死」的兜底:即使无人操作,列表也会自己恢复。 + */ + function scheduleFailureRecheck(failedCount: number) { + window.clearTimeout(failureRecheckTimerRef.current); + if (failedCount === 0) { + failureStreakRef.current = 0; + return; + } + const recheckDelayMs = + RECENT_WORKSPACE_FAILURE_RECHECK_DELAYS_MS[failureStreakRef.current]; + if (recheckDelayMs === undefined) { + return; + } + failureStreakRef.current += 1; + failureRecheckTimerRef.current = window.setTimeout(() => { + failureRecheckTimerRef.current = undefined; + setRecentWorkspaceRefreshKey((current) => current + 1); + }, recheckDelayMs); + } + useEffect(() => { const invoke = resolveTauriInvoke(); if (!invoke || recentWorkspaces.length === 0) { @@ -72,16 +107,34 @@ export function useRecentProjects(setStatus: Dispatch>) { return; } const inspectionGeneration = ++inspectionGenerationRef.current; + // 单次目录检查失败就地重试:瞬时抖动不该把整行钉成「检查失败」。 + const inspectRecentWorkspaceWithRetry = async ( + workspace: string, + ): Promise<[string, LocalProjectDirectoryStatus | null]> => { + for (let attempt = 0; ; attempt += 1) { + const result = await inspectRecentWorkspace(invoke, workspace); + const retryDelayMs = RECENT_WORKSPACE_CHECK_RETRY_DELAYS_MS[attempt]; + if (result[1] || retryDelayMs === undefined) { + return result; + } + await new Promise((resolve) => { + window.setTimeout(resolve, retryDelayMs); + }); + } + }; let disposed = false; let pendingCount = recentWorkspaces.length; - // 保留已完成项目的最后一个独立结果。刷新是增量投影:只有新项目或 - // 尚未完成检查的项目显示“检查中”,不能因为另一个坏目录而把整张列表 - // 清空成同一个异常状态。 + let failedCount = 0; + // 刷新是增量投影:已完成项目的成功结果保留下来(不能因为另一个坏目录把整张 + // 列表清空成同一个异常状态),但上一轮的失败结果**不进新投影**——失败项回到 + // 「检查中」并重新检查。否则一次瞬时失败会把结果原样搬到下一轮,整行永久钉在 + // 「检查失败」上。 setRecentWorkspaceStatuses((current) => { const next: Record = {}; for (const workspace of recentWorkspaces) { - if (Object.prototype.hasOwnProperty.call(current, workspace)) { - next[workspace] = current[workspace] ?? null; + const status = current[workspace]; + if (status) { + next[workspace] = status; } } return next; @@ -89,7 +142,7 @@ export function useRecentProjects(setStatus: Dispatch>) { setRecentWorkspaceRefreshing(true); for (const workspace of recentWorkspaces) { - void inspectRecentWorkspace(invoke, workspace).then( + void inspectRecentWorkspaceWithRetry(workspace).then( ([projectPath, status]) => { if ( disposed || @@ -98,6 +151,9 @@ export function useRecentProjects(setStatus: Dispatch>) { ) { return; } + if (!status) { + failedCount += 1; + } setRecentWorkspaceStatuses((current) => ({ ...current, [projectPath]: status, @@ -105,12 +161,15 @@ export function useRecentProjects(setStatus: Dispatch>) { pendingCount -= 1; if (pendingCount === 0) { setRecentWorkspaceRefreshing(false); + scheduleFailureRecheck(failedCount); } }, ); } return () => { disposed = true; + window.clearTimeout(failureRecheckTimerRef.current); + failureRecheckTimerRef.current = undefined; }; }, [recentWorkspaces, recentWorkspaceRefreshKey]); diff --git a/apps/ai-game-creator-shell/src/view/project-development/chat/controller/useDirectProjectChatController.ts b/apps/ai-game-creator-shell/src/view/project-development/chat/controller/useDirectProjectChatController.ts index 57356ad13..3d7a55c46 100644 --- a/apps/ai-game-creator-shell/src/view/project-development/chat/controller/useDirectProjectChatController.ts +++ b/apps/ai-game-creator-shell/src/view/project-development/chat/controller/useDirectProjectChatController.ts @@ -4,7 +4,7 @@ import { CONVERSATION_INITIAL_VISIBLE_COUNT, CONVERSATION_VISIBLE_STEP, } from '../../../../app/constants'; -import { resolveTauriInvoke } from '../../../../app/tauri'; +import { resolveTauriInvoke, type TauriInvoke } from '../../../../app/tauri'; import type { ChatMessage, DirectTurnCancelView } from '../../../../app/types'; import { projectRuntimeVisibleError } from '../../../../features/agent-runtime'; import { uploadLocalFilesAsAttachments } from '../../../../features/app-shell/useHomeProjectCreation'; @@ -556,7 +556,7 @@ export function useDirectProjectChatController({ } async function runTurn( - invoke: NonNullable>, + invoke: TauriInvoke, nextProjectPath: string, input: DirectProjectTurnInput, ) { 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 e4607c0d0..e081afb8b 100644 --- a/apps/ai-game-creator-shell/tests/appSurface/home.suite.ts +++ b/apps/ai-game-creator-shell/tests/appSurface/home.suite.ts @@ -2915,7 +2915,8 @@ export function registerRecentProjectsTests() { expect(screen.getByText('不是文件夹')).not.toBeNull(); expect(screen.getAllByText('未初始化').length).toBeGreaterThan(0); expect(screen.getByText('无法读取')).not.toBeNull(); - expect(screen.getByText('检查失败')).not.toBeNull(); + // 目录检查失败会先就地重试一次(失败不进终态),因此这里等它落成最终的失败状态。 + expect(await screen.findByText('检查失败')).not.toBeNull(); expect(screen.getByText('厨房突围')).not.toBeNull(); expect(screen.getByText('已完成 · 预览运行中')).not.toBeNull(); expect( diff --git a/apps/ai-game-creator-shell/tests/recentProjectsHook.test.tsx b/apps/ai-game-creator-shell/tests/recentProjectsHook.test.tsx index e564a6fca..e351f650a 100644 --- a/apps/ai-game-creator-shell/tests/recentProjectsHook.test.tsx +++ b/apps/ai-game-creator-shell/tests/recentProjectsHook.test.tsx @@ -91,3 +91,75 @@ test('刷新坏项目时保留其它项目已确认的正常状态', async () => await pendingInspection; }); }); + +test('单次目录检查失败会就地重试,不会把整行钉成「检查失败」', async () => { + let attempts = 0; + const invoke = vi.fn( + async (command: string, args?: Record) => { + if (command !== 'inspect_local_project_directory') { + throw new Error(`unexpected invoke ${command}`); + } + attempts += 1; + if (attempts === 1) { + throw new Error('Tauri IPC 瞬时失败'); + } + return READY_PROJECT; + }, + ); + window.__TAURI__ = { core: { invoke } }; + window.localStorage.setItem( + 'genarrative-ai-game-creator.recent-workspaces.v1', + JSON.stringify(['/tmp/ready-project']), + ); + + const { result } = renderHook(() => useRecentProjects(vi.fn())); + + await waitFor(() => { + expect(result.current.projectRows[0]).toMatchObject({ + status: '本地项目', + canOpen: true, + }); + }); + expect(attempts).toBe(2); + expect(result.current.projectRows[0]?.status).not.toBe('检查失败'); +}); + +test('刷新时重新检查上一轮失败的项目,不沿用失败结果', async () => { + const inspectCounts: Record = {}; + const invoke = vi.fn( + async (command: string, args?: Record) => { + if (command !== 'inspect_local_project_directory') { + throw new Error(`unexpected invoke ${command}`); + } + const projectPath = String(args?.projectPath ?? ''); + inspectCounts[projectPath] = (inspectCounts[projectPath] ?? 0) + 1; + if (projectPath === '/tmp/broken-project') { + throw new Error('Tauri IPC 持续失败'); + } + return { ...READY_PROJECT, projectPath }; + }, + ); + window.__TAURI__ = { core: { invoke } }; + window.localStorage.setItem( + 'genarrative-ai-game-creator.recent-workspaces.v1', + JSON.stringify(['/tmp/broken-project']), + ); + + const { result } = renderHook(() => useRecentProjects(vi.fn())); + + await waitFor(() => { + expect(result.current.projectRows[0]?.status).toBe('检查失败'); + }); + const attemptsAfterFirstRun = inspectCounts['/tmp/broken-project'] ?? 0; + expect(attemptsAfterFirstRun).toBeGreaterThan(1); + + act(() => { + result.current.rememberRecentWorkspace('/tmp/ready-project'); + }); + + await waitFor(() => { + expect(inspectCounts['/tmp/broken-project']).toBeGreaterThan( + attemptsAfterFirstRun, + ); + }); +}); From 25d331ca0a03252ef4230483efcc799a287956c8 Mon Sep 17 00:00:00 2001 From: Suzumiya Date: Wed, 23 Sep 2026 13:28:17 +0800 Subject: [PATCH 2/6] =?UTF-8?q?=E5=90=8C=E6=AD=A5=20#491=20=E6=BC=8F?= =?UTF-8?q?=E6=94=B9=E7=9A=84=E6=8F=90=E7=A4=BA=E8=AF=8D=E6=96=AD=E8=A8=80?= =?UTF-8?q?=EF=BC=8C=E4=BF=AE=20master=20=E4=B8=8A=E4=B8=A4=E6=9D=A1=20AGC?= =?UTF-8?q?=20Rust=20lane=20=E7=9A=84=20CI=20=E7=BA=A2?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - tests/provider:工具计划与修复请求的 run_id 断言反转为「不得携带」(#491 已把 runId 移出提示词) - tests/response_stream:4 个 finalization/replan 用例同步 run_id 与 session_id 断言(同一 run 改由 runtime/DB 断言承担) - tests/project_tools:删除确认后的重规划请求同样反转为「不得携带 run_id」 - admin-web:AGC 模板 409 写入错误用例把聚焦断言套进 waitFor,消除 CI 抢跑 flake --- .../src/pages/AdminAgcTemplatesPage.test.tsx | 5 +++- .../src-tauri/src/tests/project_tools.rs | 4 ++- .../src-tauri/src/tests/provider.rs | 7 +++-- .../src-tauri/src/tests/response_stream.rs | 30 ++++++++++++------- 4 files changed, 31 insertions(+), 15 deletions(-) diff --git a/apps/admin-web/src/pages/AdminAgcTemplatesPage.test.tsx b/apps/admin-web/src/pages/AdminAgcTemplatesPage.test.tsx index 667e44af5..11c3f5493 100644 --- a/apps/admin-web/src/pages/AdminAgcTemplatesPage.test.tsx +++ b/apps/admin-web/src/pages/AdminAgcTemplatesPage.test.tsx @@ -339,7 +339,10 @@ test.each([409, 503])( await confirmWrite(); const message = await screen.findByRole('alert'); const feedback = message.parentElement!; - expect(document.activeElement).toBe(feedback); + // 聚焦发生在 React passive effect 里(AdminAgcTemplatesPage 的 feedback 聚焦 useEffect), + // 而 findByRole 在 alert 节点一挂上就返回,可能早于该 effect 执行;这里等聚焦落地, + // 避免在 CI 负载下抢跑。断言口径不变:焦点最终必须落在提示区而不是弹窗面板。 + await waitFor(() => expect(document.activeElement).toBe(feedback)); expect(feedback.tabIndex).toBe(-1); expect(focus).toHaveBeenCalledWith({ preventScroll: true }); expect(viewport.scrollTop).toBe(20); diff --git a/apps/ai-game-creator-shell/src-tauri/src/tests/project_tools.rs b/apps/ai-game-creator-shell/src-tauri/src/tests/project_tools.rs index 7636655ef..b8fe675e5 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/tests/project_tools.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/tests/project_tools.rs @@ -1011,7 +1011,9 @@ async fn background_agent_runtime_file_delete_confirmation_replans_after_stale_p let replanning_request = receiver .recv_timeout(Duration::from_secs(10)) .expect("same-run replanning request after stale delete approval"); - assert!(replanning_request.contains("design-stale-delete-confirm-run")); + // 提示词不再携带 run/session 标识(team-conventions:提示词不写宿主实现细节); + // 同一 run 由下面的 completed.run_id 判定,这里守住“内部标识不进请求”的边界。 + assert!(!replanning_request.contains("design-stale-delete-confirm-run")); assert!(replanning_request.contains("projectRevisionDrift=true")); assert!(replanning_request.contains("旧动作未执行")); 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 2834c0a52..8094d65e8 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 @@ -5492,7 +5492,10 @@ async fn background_agent_runtime_repairs_malformed_tool_plan_in_same_run() { let initial_request = receiver .recv_timeout(Duration::from_secs(2)) .expect("initial tool plan request"); - assert!(initial_request.contains(run_id)); + // run/session 标识不再进入提示词(team-conventions:AGC 外置智能体提示词只写模型要遵守的 + // 指令与契约,不写宿主实现细节),同一 run 由下面的 runtime 状态与 DB 记录判定; + // 这里改成守住反方向边界——内部标识不得再泄露进请求。 + assert!(!initial_request.contains(run_id)); assert!(!initial_request.contains("上一条输出不符合工具计划协议")); assert!(mock_http_request_json(&initial_request)["tools"] .as_array() @@ -5500,7 +5503,7 @@ async fn background_agent_runtime_repairs_malformed_tool_plan_in_same_run() { let repair_request = receiver .recv_timeout(Duration::from_secs(2)) .expect("tool plan repair request"); - assert!(repair_request.contains(run_id)); + assert!(!repair_request.contains(run_id)); assert!(repair_request.contains("\"role\":\"assistant\"")); assert!(repair_request.contains("格式损坏")); assert!(repair_request.contains("上一条输出不符合工具计划协议")); diff --git a/apps/ai-game-creator-shell/src-tauri/src/tests/response_stream.rs b/apps/ai-game-creator-shell/src-tauri/src/tests/response_stream.rs index ccb92dcac..0e8681ab6 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/tests/response_stream.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/tests/response_stream.rs @@ -1957,7 +1957,9 @@ fn finalization_resume_discards_unpersisted_reply_after_revision_drift() { let request = receiver .recv_timeout(Duration::from_secs(2)) .expect("stale prepared finalization must replan"); - assert!(request.contains(run_id)); + // 提示词不再携带 run/session 标识(team-conventions:提示词不写宿主实现细节), + // 同一 run 由下面的 runtime.run_id 判定;这里守住“内部标识不进请求”的边界。 + assert!(!request.contains(run_id)); let runtime = wait_for_agent_runtime_idle(&root, "design-director"); assert_eq!(runtime.phase, "completed"); assert_eq!(runtime.run_id, run_id); @@ -2651,11 +2653,11 @@ async fn background_finalization_replans_same_run_after_cross_agent_revision_dri let initial_plan_request = request_receiver .recv_timeout(Duration::from_secs(2)) .expect("initial convergence plan request"); - assert!(initial_plan_request.contains(run_id)); + assert!(!initial_plan_request.contains(run_id)); let stale_final_reply_request = request_receiver .recv_timeout(Duration::from_secs(2)) .expect("blocked stale final reply request"); - assert!(stale_final_reply_request.contains(run_id)); + assert!(!stale_final_reply_request.contains(run_id)); assert!( !game_creator_agent_runtime_task_lock_is_available(&root, "design-director") .expect("inspect lock while final reply is blocked"), @@ -2677,8 +2679,10 @@ async fn background_finalization_replans_same_run_after_cross_agent_revision_dri let replanned_request = request_receiver .recv_timeout(Duration::from_secs(30)) .expect("same run replanning request"); - assert!(replanned_request.contains(run_id)); - assert!(replanned_request.contains(&session_id)); + // 提示词不再携带 run/session 标识(team-conventions:提示词不写宿主实现细节); + // 同一 run 由本用例末尾的 runtime.run_id / runtime.session_id 判定。 + assert!(!replanned_request.contains(run_id)); + assert!(!replanned_request.contains(&session_id)); assert!(replanned_request.contains("runtime.verification")); assert!(replanned_request.contains("currentRevision=2")); assert!( @@ -2689,13 +2693,13 @@ async fn background_finalization_replans_same_run_after_cross_agent_revision_dri let verified_request = request_receiver .recv_timeout(Duration::from_secs(10)) .expect("request after current revision verification"); - assert!(verified_request.contains(run_id)); + assert!(!verified_request.contains(run_id)); assert!(verified_request.contains("project.verify")); assert!(verified_request.contains("AGENT_RUNTIME_CURRENT_REVISION_OK")); let current_final_reply_request = request_receiver .recv_timeout(Duration::from_secs(30)) .expect("current final reply request"); - assert!(current_final_reply_request.contains(run_id)); + assert!(!current_final_reply_request.contains(run_id)); let runtime = wait_for_agent_runtime_idle(&root, "design-director"); wait_for_provider_handoff_terminal_cleanup(&root, "design-director", run_id); @@ -2830,8 +2834,10 @@ async fn read_only_background_finalization_replans_when_reply_revision_becomes_s let replanned_request = request_receiver .recv_timeout(Duration::from_secs(30)) .expect("read-only same-run replanning request"); - assert!(replanned_request.contains(run_id)); - assert!(replanned_request.contains(&session_id)); + // 提示词不再携带 run/session 标识(team-conventions:提示词不写宿主实现细节); + // 同一 run 由下面的 runtime.run_id / runtime.session_id 判定。 + assert!(!replanned_request.contains(run_id)); + assert!(!replanned_request.contains(&session_id)); assert!(replanned_request.contains("responseRevision=0")); assert!(replanned_request.contains("currentRevision=1")); let runtime = wait_for_agent_runtime_idle(&root, "design-director"); @@ -3024,7 +3030,9 @@ async fn stale_finalization_context_survives_restart_before_same_run_replanning( let replanned_request = request_receiver .recv_timeout(Duration::from_secs(2)) .expect("restart replanning request"); - assert!(replanned_request.contains(run_id)); + // 提示词不再携带 run/session 标识(team-conventions:提示词不写宿主实现细节); + // 同一 run 由本用例末尾的 runtime.run_id / runtime.session_id 判定。 + assert!(!replanned_request.contains(run_id)); assert!(replanned_request.contains("currentRevision=2")); let verified_request = request_receiver .recv_timeout(Duration::from_secs(10)) @@ -3033,7 +3041,7 @@ async fn stale_finalization_context_survives_restart_before_same_run_replanning( let final_reply_request = request_receiver .recv_timeout(Duration::from_secs(2)) .expect("restart final reply request"); - assert!(final_reply_request.contains(run_id)); + assert!(!final_reply_request.contains(run_id)); let runtime = read_game_creator_agent_runtime_at(&root, "design-director") .expect("read completed restart runtime") From 5bdf25cfd7a4f1ecca762fa2d2a06ca88e3f4819 Mon Sep 17 00:00:00 2001 From: Suzumiya Date: Wed, 23 Sep 2026 16:41:10 +0800 Subject: [PATCH 3/6] =?UTF-8?q?=E8=AF=84=E5=AE=A1=E6=94=B6=E5=8F=A3?= =?UTF-8?q?=EF=BC=9A=E6=8F=90=E6=9D=83=E7=B1=BB=E5=A4=B1=E8=B4=A5=E4=B8=8D?= =?UTF-8?q?=E5=86=8D=E9=87=8D=E8=AF=95=E3=80=81=E9=87=8D=E5=91=BD=E5=90=8D?= =?UTF-8?q?=E8=B7=AF=E5=BE=84=E5=A4=8D=E7=94=A8=E9=87=8D=E8=AF=95=E3=80=81?= =?UTF-8?q?=E9=80=80=E9=81=BF=E9=A2=84=E7=AE=97=E7=BB=91=E5=AE=9A=E5=A4=B1?= =?UTF-8?q?=E8=B4=A5=E9=9B=86=E5=90=88?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - useRecentProjects:区分可重试失败与提权/权限类失败,后者不再就地重试、也不驱动整表重查,避免自己驱动 UAC 反复弹窗 - useRecentProjects:提权类失败的项目在用户主动打开/新建项目或重命名刷新前跳过检查 - useRecentProjects:退避重查预算绑定到失败集合,集合变化或整轮无失败即重置,长期坏目录不再吃满额度 - useRecentProjects:重命名后的单条刷新复用同一套就地重试与有界重查 - app/tauri:删除重复的 TauriInvoke 类型,统一引用 app/types 的既有定义 - tests/recentProjectsHook:失败不跨轮保留改为断言在途「检查中」,新增提权类失败不重试用例 - docs:decision-log、pitfalls、生命周期方案与异步闭环方案同步失败自愈口径与提权边界 --- apps/ai-game-creator-shell/src/app/tauri.ts | 10 +- .../features/app-shell/useRecentProjects.ts | 211 ++++++++++++------ .../useDirectProjectChatController.ts | 8 +- .../tests/recentProjectsHook.test.tsx | 55 ++++- .../shared-memory/decision-log.md | 9 +- docs/project-memory/shared-memory/pitfalls.md | 8 + ...】AGC客户端稳定版生命周期大切换-2026-09-14.md | 4 +- ...术方案】AGC异步操作可恢复闭环-2026-09-14.md | 2 + 8 files changed, 214 insertions(+), 93 deletions(-) diff --git a/apps/ai-game-creator-shell/src/app/tauri.ts b/apps/ai-game-creator-shell/src/app/tauri.ts index 8a8cbd62d..39daea483 100644 --- a/apps/ai-game-creator-shell/src/app/tauri.ts +++ b/apps/ai-game-creator-shell/src/app/tauri.ts @@ -1,12 +1,6 @@ -/** - * AGC 调用 Rust 命令的唯一入口类型。全局 `window.__TAURI__.core.invoke` 由 Tauri 注入, - * 消费方一律从这个名字取类型,不要再写 `ReturnType`。 - */ -export type TauriInvoke = ( - command: string, - args?: Record, -) => Promise; +import type { TauriInvoke } from './types'; +/** 返回 undefined 表示不在 Tauri 环境(浏览器预览、测试壳),调用方必须先判空。 */ export function resolveTauriInvoke(): TauriInvoke | undefined { return window.__TAURI__?.core?.invoke; } diff --git a/apps/ai-game-creator-shell/src/features/app-shell/useRecentProjects.ts b/apps/ai-game-creator-shell/src/features/app-shell/useRecentProjects.ts index 93698a88e..00080005e 100644 --- a/apps/ai-game-creator-shell/src/features/app-shell/useRecentProjects.ts +++ b/apps/ai-game-creator-shell/src/features/app-shell/useRecentProjects.ts @@ -7,8 +7,8 @@ import { useState, } from 'react'; -import { resolveTauriInvoke, type TauriInvoke } from '../../app/tauri'; -import type { LocalProjectDirectoryStatus } from '../../app/types'; +import { resolveTauriInvoke } from '../../app/tauri'; +import type { LocalProjectDirectoryStatus, TauriInvoke } from '../../app/types'; import { isAbsoluteProjectPath, projectPathHasControlCharacter, @@ -21,17 +21,90 @@ import { } from './model'; const RECENT_WORKSPACE_CHECK_TIMEOUT_MS = 5_000; -/** - * 单次目录检查失败后的就地重试退避。Tauri IPC 或磁盘的瞬时抖动不该把整行钉成 - * 「检查失败」;只有持续失败才落成失败状态。 - */ +/** 单次检查失败后的就地重试退避,数组长度即重试次数。 */ const RECENT_WORKSPACE_CHECK_RETRY_DELAYS_MS = [300]; -/** - * 一轮检查结束仍有失败行时,按退避重跑整张列表(重跑次数上限 = 数组长度)。 - * 失败不进终态:例如 IPC 响应链路卡住十几秒之后,列表必须能自己恢复, - * 而不是保持「检查失败」直到用户重启客户端。 - */ +/** 一轮结束仍有可重试失败时重跑整张列表的退避,数组长度即重跑次数上限。 */ const RECENT_WORKSPACE_FAILURE_RECHECK_DELAYS_MS = [15_000, 45_000, 120_000]; +/** + * 提权/权限类失败不重试:Rust 侧会重新走 `Start-Process -Verb RunAs -Wait`, + * 而提权闸门只存在于单次 invoke 内,重试等于在用户刚点「否」后再弹一次 UAC。 + * 判据与 config.rs 的 `windows_acl_error_may_need_elevation` 同口径。 + */ +const RECENT_WORKSPACE_ELEVATION_ERROR_MARKERS = [ + 'DACL', + '权限', + 'error 5', + '安全对象不属于当前用户', + '启用 Windows', + '特权', + '1300', + 'AGC ACL 提权修复未成功', +]; + +type RecentWorkspaceInspection = { + path: string; + status: LocalProjectDirectoryStatus | null; + /** false 表示提权/权限类失败:既不重试,也不驱动整表重查。 */ + retryable: boolean; +}; + +function recentWorkspaceFailureIsRetryable(message: string): boolean { + return !RECENT_WORKSPACE_ELEVATION_ERROR_MARKERS.some((marker) => + message.includes(marker), + ); +} + +async function inspectRecentWorkspace( + invoke: TauriInvoke, + workspace: string, +): Promise { + let timeoutHandle: number | undefined; + try { + const status = await Promise.race([ + invoke('inspect_local_project_directory', { + projectPath: workspace, + }), + new Promise((_, reject) => { + timeoutHandle = window.setTimeout( + () => reject(new Error('项目目录检查超时')), + RECENT_WORKSPACE_CHECK_TIMEOUT_MS, + ); + }), + ]); + return { path: workspace, status, retryable: true }; + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + return { + path: workspace, + status: null, + retryable: recentWorkspaceFailureIsRetryable(message), + }; + } finally { + if (timeoutHandle !== undefined) { + window.clearTimeout(timeoutHandle); + } + } +} + +async function inspectRecentWorkspaceWithRetry( + invoke: TauriInvoke, + workspace: string, +): Promise { + for (let attempt = 0; ; attempt += 1) { + const inspection = await inspectRecentWorkspace(invoke, workspace); + const retryDelayMs = RECENT_WORKSPACE_CHECK_RETRY_DELAYS_MS[attempt]; + if ( + inspection.status || + !inspection.retryable || + retryDelayMs === undefined + ) { + return inspection; + } + await new Promise((resolve) => { + window.setTimeout(resolve, retryDelayMs); + }); + } +} export function useRecentProjects(setStatus: Dispatch>) { const [recentWorkspaces, setRecentWorkspaces] = @@ -48,41 +121,13 @@ export function useRecentProjects(setStatus: Dispatch>) { const inspectionGenerationRef = useRef(0); const failureRecheckTimerRef = useRef(undefined); const failureStreakRef = useRef(0); + const lastRetryableFailureKeyRef = useRef(''); + // 提权类失败在用户再次主动操作前不再自动重试。 + const nonRetryablePathsRef = useRef>(new Set()); - async function inspectRecentWorkspace( - invoke: TauriInvoke, - workspace: string, - ): Promise<[string, LocalProjectDirectoryStatus | null]> { - let timeoutHandle: number | undefined; - try { - const result = await Promise.race([ - invoke('inspect_local_project_directory', { - projectPath: workspace, - }), - new Promise((_, reject) => { - timeoutHandle = window.setTimeout( - () => reject(new Error('项目目录检查超时')), - RECENT_WORKSPACE_CHECK_TIMEOUT_MS, - ); - }), - ]); - return [workspace, result]; - } catch { - return [workspace, null]; - } finally { - if (timeoutHandle !== undefined) { - window.clearTimeout(timeoutHandle); - } - } - } - - /** - * 一轮检查结束仍有失败行时,按退避重跑整张列表;连续无失败的一轮会把计数清零。 - * 这是「一次抖动不能把列表钉死」的兜底:即使无人操作,列表也会自己恢复。 - */ - function scheduleFailureRecheck(failedCount: number) { + function scheduleFailureRecheck(retryableFailureCount: number) { window.clearTimeout(failureRecheckTimerRef.current); - if (failedCount === 0) { + if (retryableFailureCount === 0) { failureStreakRef.current = 0; return; } @@ -107,28 +152,24 @@ export function useRecentProjects(setStatus: Dispatch>) { return; } const inspectionGeneration = ++inspectionGenerationRef.current; - // 单次目录检查失败就地重试:瞬时抖动不该把整行钉成「检查失败」。 - const inspectRecentWorkspaceWithRetry = async ( - workspace: string, - ): Promise<[string, LocalProjectDirectoryStatus | null]> => { - for (let attempt = 0; ; attempt += 1) { - const result = await inspectRecentWorkspace(invoke, workspace); - const retryDelayMs = RECENT_WORKSPACE_CHECK_RETRY_DELAYS_MS[attempt]; - if (result[1] || retryDelayMs === undefined) { - return result; - } - await new Promise((resolve) => { - window.setTimeout(resolve, retryDelayMs); - }); - } - }; let disposed = false; - let pendingCount = recentWorkspaces.length; - let failedCount = 0; - // 刷新是增量投影:已完成项目的成功结果保留下来(不能因为另一个坏目录把整张 - // 列表清空成同一个异常状态),但上一轮的失败结果**不进新投影**——失败项回到 - // 「检查中」并重新检查。否则一次瞬时失败会把结果原样搬到下一轮,整行永久钉在 - // 「检查失败」上。 + const pendingWorkspaces = recentWorkspaces.filter( + (workspace) => !nonRetryablePathsRef.current.has(workspace), + ); + let pendingCount = pendingWorkspaces.length; + const retryableFailures: string[] = []; + const finishRound = () => { + setRecentWorkspaceRefreshing(false); + // 失败集合变化即重置退避预算,避免长期坏目录吃满新瞬时失败的额度。 + const failureKey = [...retryableFailures].sort().join('|'); + if (failureKey !== lastRetryableFailureKeyRef.current) { + lastRetryableFailureKeyRef.current = failureKey; + failureStreakRef.current = 0; + } + scheduleFailureRecheck(retryableFailures.length); + }; + // 刷新是增量投影:成功结果保留,但上一轮的失败结果不进新投影, + // 失败项回到「检查中」并在本轮重新检查。 setRecentWorkspaceStatuses((current) => { const next: Record = {}; for (const workspace of recentWorkspaces) { @@ -141,9 +182,14 @@ export function useRecentProjects(setStatus: Dispatch>) { }); setRecentWorkspaceRefreshing(true); - for (const workspace of recentWorkspaces) { - void inspectRecentWorkspaceWithRetry(workspace).then( - ([projectPath, status]) => { + if (pendingCount === 0) { + finishRound(); + return; + } + + for (const workspace of pendingWorkspaces) { + void inspectRecentWorkspaceWithRetry(invoke, workspace).then( + ({ path: projectPath, status, retryable }) => { if ( disposed || inspectionGeneration !== inspectionGenerationRef.current || @@ -151,8 +197,12 @@ export function useRecentProjects(setStatus: Dispatch>) { ) { return; } - if (!status) { - failedCount += 1; + if (status) { + nonRetryablePathsRef.current.delete(projectPath); + } else if (retryable) { + retryableFailures.push(projectPath); + } else { + nonRetryablePathsRef.current.add(projectPath); } setRecentWorkspaceStatuses((current) => ({ ...current, @@ -160,8 +210,7 @@ export function useRecentProjects(setStatus: Dispatch>) { })); pendingCount -= 1; if (pendingCount === 0) { - setRecentWorkspaceRefreshing(false); - scheduleFailureRecheck(failedCount); + finishRound(); } }, ); @@ -174,6 +223,8 @@ export function useRecentProjects(setStatus: Dispatch>) { }, [recentWorkspaces, recentWorkspaceRefreshKey]); function rememberRecentWorkspace(projectPath: string) { + // 用户主动打开或新建项目:解除提权类失败的跳过标记。 + nonRetryablePathsRef.current.clear(); setRecentWorkspaces(writeRecentWorkspace(projectPath)); setRecentWorkspaceRefreshKey((current) => current + 1); } @@ -183,17 +234,29 @@ export function useRecentProjects(setStatus: Dispatch>) { if (!invoke) { return; } - const [, status] = await inspectRecentWorkspace(invoke, projectPath); + nonRetryablePathsRef.current.delete(projectPath); + const inspection = await inspectRecentWorkspaceWithRetry( + invoke, + projectPath, + ); if (!recentWorkspacesRef.current.includes(projectPath)) { return; } + if (inspection.status) { + nonRetryablePathsRef.current.delete(projectPath); + } else if (inspection.retryable) { + scheduleFailureRecheck(1); + } else { + nonRetryablePathsRef.current.add(projectPath); + } setRecentWorkspaceStatuses((current) => ({ ...current, - [projectPath]: status, + [projectPath]: inspection.status, })); } function handleRecentWorkspaceRemove(projectPath: string) { + nonRetryablePathsRef.current.delete(projectPath); setRecentWorkspaces(removeRecentWorkspace(projectPath)); setRecentWorkspaceStatuses((current) => { const { [projectPath]: _removed, ...rest } = current; diff --git a/apps/ai-game-creator-shell/src/view/project-development/chat/controller/useDirectProjectChatController.ts b/apps/ai-game-creator-shell/src/view/project-development/chat/controller/useDirectProjectChatController.ts index 3d7a55c46..24084d798 100644 --- a/apps/ai-game-creator-shell/src/view/project-development/chat/controller/useDirectProjectChatController.ts +++ b/apps/ai-game-creator-shell/src/view/project-development/chat/controller/useDirectProjectChatController.ts @@ -4,8 +4,12 @@ import { CONVERSATION_INITIAL_VISIBLE_COUNT, CONVERSATION_VISIBLE_STEP, } from '../../../../app/constants'; -import { resolveTauriInvoke, type TauriInvoke } from '../../../../app/tauri'; -import type { ChatMessage, DirectTurnCancelView } from '../../../../app/types'; +import { resolveTauriInvoke } from '../../../../app/tauri'; +import type { + ChatMessage, + DirectTurnCancelView, + TauriInvoke, +} from '../../../../app/types'; import { projectRuntimeVisibleError } from '../../../../features/agent-runtime'; import { uploadLocalFilesAsAttachments } from '../../../../features/app-shell/useHomeProjectCreation'; import { diff --git a/apps/ai-game-creator-shell/tests/recentProjectsHook.test.tsx b/apps/ai-game-creator-shell/tests/recentProjectsHook.test.tsx index e351f650a..7abb15b64 100644 --- a/apps/ai-game-creator-shell/tests/recentProjectsHook.test.tsx +++ b/apps/ai-game-creator-shell/tests/recentProjectsHook.test.tsx @@ -124,8 +124,13 @@ test('单次目录检查失败会就地重试,不会把整行钉成「检查 expect(result.current.projectRows[0]?.status).not.toBe('检查失败'); }); -test('刷新时重新检查上一轮失败的项目,不沿用失败结果', async () => { +test('失败结果不跨轮保留:刷新时该项回到「检查中」并重新检查', async () => { const inspectCounts: Record = {}; + let secondRoundPending: (() => void) | null = null; + const secondRoundInspection = new Promise((resolve) => { + secondRoundPending = () => resolve(READY_PROJECT); + }); + let brokenRound = 0; const invoke = vi.fn( async (command: string, args?: Record) => { if (command !== 'inspect_local_project_directory') { @@ -134,6 +139,10 @@ test('刷新时重新检查上一轮失败的项目,不沿用失败结果', as const projectPath = String(args?.projectPath ?? ''); inspectCounts[projectPath] = (inspectCounts[projectPath] ?? 0) + 1; if (projectPath === '/tmp/broken-project') { + brokenRound += 1; + if (brokenRound > 2) { + return secondRoundInspection; + } throw new Error('Tauri IPC 持续失败'); } return { ...READY_PROJECT, projectPath }; @@ -150,16 +159,50 @@ test('刷新时重新检查上一轮失败的项目,不沿用失败结果', as await waitFor(() => { expect(result.current.projectRows[0]?.status).toBe('检查失败'); }); - const attemptsAfterFirstRun = inspectCounts['/tmp/broken-project'] ?? 0; - expect(attemptsAfterFirstRun).toBeGreaterThan(1); + expect(inspectCounts['/tmp/broken-project']).toBe(2); act(() => { result.current.rememberRecentWorkspace('/tmp/ready-project'); }); + // 上一轮的失败结果不进新投影:第二轮在途时该项目显示「检查中」而不是沿用「检查失败」。 await waitFor(() => { - expect(inspectCounts['/tmp/broken-project']).toBeGreaterThan( - attemptsAfterFirstRun, - ); + expect( + result.current.projectRows.find( + (row) => row.path === '/tmp/broken-project', + )?.status, + ).toBe('检查中'); + }); + + await act(async () => { + secondRoundPending?.(); + await secondRoundInspection; }); }); + +test('提权类失败不重试:不放大 UAC 弹窗', async () => { + let attempts = 0; + const invoke = vi.fn( + async (command: string, args?: Record) => { + if (command !== 'inspect_local_project_directory') { + throw new Error(`unexpected invoke ${command}`); + } + attempts += 1; + throw new Error( + '读取待修复私有对象失败:C:\\p\\.agent(DACL 不包含当前用户)', + ); + }, + ); + window.__TAURI__ = { core: { invoke } }; + window.localStorage.setItem( + 'genarrative-ai-game-creator.recent-workspaces.v1', + JSON.stringify(['/tmp/elevation-project']), + ); + + const { result } = renderHook(() => useRecentProjects(vi.fn())); + + await waitFor(() => { + expect(result.current.projectRows[0]?.status).toBe('检查失败'); + }); + expect(attempts).toBe(1); +}); diff --git a/docs/project-memory/shared-memory/decision-log.md b/docs/project-memory/shared-memory/decision-log.md index f1b42991f..71ce80d4f 100644 --- a/docs/project-memory/shared-memory/decision-log.md +++ b/docs/project-memory/shared-memory/decision-log.md @@ -1,5 +1,12 @@ # 决策记录 +## 2026-09-23 最近项目检查失败不进终态 + +- 背景:最近项目列表把一次性的目录检查失败当成终态——5s 超时被吞成 `null`,增量投影又把上一轮的 `null` 原样搬进下一轮,且没有重试或重查入口。AGC 一次 IPC 停顿之后,整张列表会永久停在「检查失败 + 待识别」,首页「最近项目」同时因 `canOpen` 过滤变空,只能重启客户端恢复(issue #490)。 +- 决策:单次检查失败先就地重试一次(300ms);失败结果不进新投影(失败项回到「检查中」并重新检查);一轮结束仍有**可重试**失败时按 15s / 45s / 120s 重跑整张列表,重跑上限 3 次,失败集合变化或整轮无失败即重置预算;重命名后的单条刷新复用同一套重试与有界重查。 +- 提权边界:Windows ACL 自动提权类失败(`DACL`、`权限`、`error 5`、`安全对象不属于当前用户`、`特权`、`1300`、`AGC ACL 提权修复未成功`)判定为不可重试——重试等于在用户刚点「否」后再弹一次 UAC(提权闸门只存在于单次 invoke 内,进程级没有冷却记忆)。这类项目在用户再次主动打开/新建项目或重命名刷新之前不再自动重试,也不驱动整表重查。 +- 验证:`apps/ai-game-creator-shell/tests/recentProjectsHook.test.tsx` 覆盖「单次失败就地重试」「失败不跨轮保留」「提权类失败不重试」(前两者在改前代码上必挂);`tests/appSurface/home.suite.ts` 的失败态改为等待最终状态;退避重查用一次性脚本验证持续失败后 15s 自动恢复(脚本未入库)。 + ## 2026-09-23 AGC 发布前先守可运行原型门禁 - 背景:客户端已经显示“首个可运行原型尚未完成,运行视图暂不可用”,但发布入口仍会先执行用户项目的 `build`,导致未完成原型也进入构建并在后续失败。 @@ -289,7 +296,7 @@ Godot 编辑器操控复用既有 AGC 插件宿主、EditorAdapter、Runner 和 ## 2026-09-20 最近项目检查保持项目级隔离 - 背景:最近项目刷新会重新检查所有路径。若其中一个目录损坏、超时或不可读,清空整张状态表会让已确认正常的项目暂时全部显示“检查中”,用户只能移除坏项目后看到列表恢复。 -- 决策:最近项目状态按路径独立投影;刷新时保留仍在列表中的最后一次结果,只有新增或尚未检查的项目进入“检查中”。检查代次或列表成员变化后,迟到结果不得写回,单个项目的失败不能改变其它项目的可打开状态。 +- 决策:最近项目状态按路径独立投影;刷新时保留仍在列表中的最后一次**成功**结果,失败结果不进新投影并在本轮重新检查(见 2026-09-23 条目),只有新增或尚未检查的项目进入“检查中”。检查代次或列表成员变化后,迟到结果不得写回,单个项目的失败不能改变其它项目的可打开状态。 - 验证:`recentProjectsHook.test.tsx` 覆盖“新增慢/坏项目刷新时保留正常项目”;`recentProjectsModel.test.ts`、`unityProjectOpen.test.tsx` 与前端类型检查一并执行。 ## 2026-09-17 GameCreationApp 资源 kind 只保留一份词汇表:严格解析 + `app_log!` 留痕 diff --git a/docs/project-memory/shared-memory/pitfalls.md b/docs/project-memory/shared-memory/pitfalls.md index 54c8f7deb..8de85604a 100644 --- a/docs/project-memory/shared-memory/pitfalls.md +++ b/docs/project-memory/shared-memory/pitfalls.md @@ -1,5 +1,13 @@ # 踩坑与排障记录 +## 最近项目一次失败会被钉成终态 + +- **现象**:AGC 卡住一次后,项目列表每一行都显示「检查失败 + 待识别」,首页「最近项目」变成「暂无最近项目」;现场在后端恢复后逐条复跑 `inspect_local_project_directory`(8 个项目)全部 0ms 成功,界面仍然全红(issue #490)。 +- **原因**:单次检查的 5s 超时被吞成 `null` 写入状态表,而刷新用的增量投影是 `next[path] = current[path] ?? null`,把失败结果原样搬进下一轮;effect 只依赖列表与刷新计数器,既没有重试也没有 focus/visibility 重查。于是一次抖动会让整张列表永久停在失败态,首页同时被 `canOpen` 过滤清空。 +- **处理**:失败就地重试一次(300ms);失败结果不进新投影;一轮仍有可重试失败时按 15s / 45s / 120s 重跑整表(上限 3 次,失败集合变化即重置预算)。提权/权限类失败(`DACL`、`权限`、`error 5`、`安全对象不属于当前用户`、`特权`、`1300`、`AGC ACL 提权修复未成功`)按不可重试处理,在用户主动打开/新建项目或重命名刷新之前跳过——否则「提权被拒 → 300ms 后重试」会自己驱动 UAC 反复弹窗。 +- **验证**:`apps/ai-game-creator-shell/tests/recentProjectsHook.test.tsx` 的三条用例(「单次失败就地重试」「失败不跨轮保留」「提权类失败不重试」),改前代码上前两条必挂;`tests/appSurface/home.suite.ts` 的失败态断言改为等待最终状态。 +- **关联**:`apps/ai-game-creator-shell/src/features/app-shell/useRecentProjects.ts`、`src-tauri/src/config.rs`。 + ## 策划 Agent 提示词中的相对路径不要当作内部实现删去 `project/...` 是 Agent 读写策划工作区的目标路径,`resources/...` 是查找内置分册、模板和例子的资源定位;即使阶段上下文也注入了同一产物路径,提示词里的路径仍是 Agent 需要的契约。清理宿主实现细节时不要误删这些相对路径,具体用法见[策划 Agent 路径说明](../../technical/【技术方案】策划Agent生产迁移与工作区浏览-2026-09-10.md#6-阶段与提示词注入)。 diff --git a/docs/【技术方案】AGC客户端稳定版生命周期大切换-2026-09-14.md b/docs/【技术方案】AGC客户端稳定版生命周期大切换-2026-09-14.md index 63fcf35f1..69dd33d1b 100644 --- a/docs/【技术方案】AGC客户端稳定版生命周期大切换-2026-09-14.md +++ b/docs/【技术方案】AGC客户端稳定版生命周期大切换-2026-09-14.md @@ -29,7 +29,7 @@ ### RecentProjectInspection -每个最近项目拥有独立 operation 和结果。检查中、可打开、失败、超时、不存在、非目录、未初始化和可导入不能通过一个全局刷新 gate 互相覆盖。刷新采用增量投影:已确认的项目结果继续保留,只有新增或尚未完成检查的项目显示“检查中”;项目被移除或检查代次变化后,迟到结果不得写回列表。 +每个最近项目拥有独立 operation 和结果。检查中、可打开、失败、超时、不存在、非目录、未初始化和可导入不能通过一个全局刷新 gate 互相覆盖。刷新采用增量投影:已确认的项目结果继续保留,只有新增或尚未完成检查的项目显示“检查中”;失败结果不进新投影,失败项在本轮重新检查,并在仍有可重试失败时按有界退避重跑整张列表。提权/权限类失败不重试(重试会再次弹 UAC),在用户主动打开/新建项目或重命名刷新前跳过。项目被移除或检查代次变化后,迟到结果不得写回列表。 ### DevStackIdentity @@ -88,7 +88,7 @@ | --- | --- | | operation identity 与 stale-result | `clientOperation.test.ts`、认证/home 定向测试 | | HTTP/auth/Runner | client HTTP/API 测试、Rust cargo check、认证 appSurface | -| 最近项目逐行刷新 | `recentProjectsModel.test.ts`、home appSurface | +| 最近项目逐行刷新 | `recentProjectsModel.test.ts`、`recentProjectsHook.test.tsx`、home appSurface | | dev-stack 身份 | `scripts/dev.test.ts`、`start-dev-stack.test.ts`、端口 marker 检查 | | 本地恢复边界 | 现有 manifest/runtime/resource recovery tests;未确认外部副作用不自动重放 | | 超时隔离与迟到结果 | `auth.suite.ts`(stalled native install 不阻塞后续登录、floor 瞬时失败可重试、围栏超时后迟到安装落地)、`home.suite.ts`(建项看门狗解围并保留迟到成功、设计运行时初始化失败后保留工作区并可打开) | diff --git a/docs/【技术方案】AGC异步操作可恢复闭环-2026-09-14.md b/docs/【技术方案】AGC异步操作可恢复闭环-2026-09-14.md index 616ef3042..fe08886e7 100644 --- a/docs/【技术方案】AGC异步操作可恢复闭环-2026-09-14.md +++ b/docs/【技术方案】AGC异步操作可恢复闭环-2026-09-14.md @@ -24,6 +24,7 @@ 2. 最近项目逐项独立检查;单项超时/失败只影响该行,已完成且可打开的项目立即可操作。 3. 首页自动创建状态由 `WorkspaceLauncher` 生命周期持有;切页期间仍防重,迟到结果不能覆盖用户已打开的其它项目。 4. 认证恢复和 Runner 连接继续有明确超时、错误和重试入口;本地 Runner 会话安装/清除的阻塞工作不得占用 Tauri 窗口线程。 +5. 最近项目检查失败不进终态:单次失败就地重试一次,失败结果不进增量投影并在本轮重新检查;一轮仍有可重试失败时按 15s / 45s / 120s 有界退避重跑整张列表(失败集合变化即重置预算)。提权/权限类失败不重试——重试等同于再次触发 UAC 提权,这类项目在用户主动打开/新建项目或重命名刷新前跳过。 ## 契约与迁移 @@ -35,6 +36,7 @@ | --- | --- | --- | | 响应体超时 | client auth/http 定向测试 | body 卡住抛出稳定超时,第二次 refresh 请求计数为 2 | | 最近项目独立完成 | model/controller 定向测试或 appSurface 场景 | A 完成时可打开,B 继续检查 | +| 最近项目失败自愈 | `recentProjectsHook.test.tsx`(单次失败就地重试、失败不跨轮保留、提权类失败不重试) | 一次抖动后整行恢复为可打开;提权被拒时不产生第二次检查(不重复弹 UAC) | | 首页创建跨页防重 | appSurface 场景 | 切页返回后按钮仍禁用,迟到创建不覆盖已有项目 | | Runner 会话不阻塞窗口 | Rust 编译检查与登录/退出 UI fence | command 使用 blocking worker,前端使用 45 秒可恢复超时 | | 现有行为不回归 | typecheck、AGC 定向测试、编码和 diff 检查 | 命令输出 | From e072c66ce9593c7e5fb55ddf562e617aa0b2d154 Mon Sep 17 00:00:00 2001 From: kdletters <61648117+kdletters@users.noreply.github.com> Date: Wed, 23 Sep 2026 16:46:49 +0800 Subject: [PATCH 4/6] =?UTF-8?q?=E4=BF=AE=E5=A4=8D=20AGC=20=E6=A8=A1?= =?UTF-8?q?=E6=9D=BF=E5=BA=93=E7=AD=9B=E9=80=89=E4=B8=8E=E5=8D=A1=E7=89=87?= =?UTF-8?q?=E7=8A=B6=E6=80=81=E7=9A=84=E8=A1=A8=E7=8E=B0=E7=BC=BA=E9=99=B7?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 模板库筛选区改用共享筛选条与标签 chip:选中为实心品牌填充 + 反白文字,未选为浅底描边 - 未选中悬停不再借用品牌色(品牌色专属已选中),状态阶梯固定为静止 / 悬停 / 按下 / 选中 - 新增 getPlatformCategoryChipClassName 收敛筛选 chip 类名口径,模板库、资源画布、参考图弹窗与平台 Web 端共用 - PlatformSegmentedTabs 增加 accent 选中口径,与筛选 chip 共用 --platform-chip-* 语义色 - 修复卡片文字区按 grid auto 行排版导致的标题 / 简介 / 标签被裁:行高契约拆为分项常量,文字区固定 174 - 修复忙状态下动作行塞入第三个元素导致的按钮文案换行顶出卡片:忙状态写在触发它的按钮上 - 修复经典滚动条占宽导致的卡片区横向滚动条:按竖滚动条预留列宽后再算列宽行高 - 焦点环由 15% 透明度改为实心色并用 outline 绘制,选中项聚焦不再被状态投影盖掉 - 封面加载失败改为隐藏图片留中性底色,不再出现浏览器裂图图标 - 补充状态对比度、行高契约、滚动条预留、封面兜底与忙状态文案的回归测试 - 同步技术方案、踩坑记录与决策记录的口径 --- .../template-library/templateLibraryGrid.ts | 61 +++++- .../template-library/templateLibraryModel.ts | 18 +- .../template-library/useTemplateLibrary.ts | 6 - .../ResourceFilterPanel.tsx | 16 +- .../view/template-library/TemplateCard.tsx | 59 +++--- .../src/view/template-library/index.tsx | 195 ++++++++++-------- .../tests/templateLibraryGrid.test.ts | 74 +++++++ .../tests/templateLibraryModel.test.ts | 19 ++ .../tests/templateLibraryView.test.tsx | 45 +++- .../tests/workbenchThemeContrast.test.ts | 121 +++++++++++ .../shared-memory/decision-log.md | 10 + docs/project-memory/shared-memory/pitfalls.md | 40 ++++ ...技术方案】AGC模板库与模板建项-2026-09-17.md | 10 +- .../components/PlatformResourceFilterBar.tsx | 22 +- .../src/components/PlatformSegmentedTabs.tsx | 7 + packages/shared/src/components/index.ts | 1 + .../platformCategoryChipModel.test.ts | 15 ++ .../components/platformCategoryChipModel.ts | 15 ++ packages/shared/src/components/styles.css | 64 +++++- packages/shared/src/theme.css | 25 ++- src/index.css | 41 +++- 21 files changed, 697 insertions(+), 167 deletions(-) create mode 100644 packages/shared/src/components/platformCategoryChipModel.test.ts create mode 100644 packages/shared/src/components/platformCategoryChipModel.ts diff --git a/apps/ai-game-creator-shell/src/features/template-library/templateLibraryGrid.ts b/apps/ai-game-creator-shell/src/features/template-library/templateLibraryGrid.ts index 539c5b7a8..030fb8b73 100644 --- a/apps/ai-game-creator-shell/src/features/template-library/templateLibraryGrid.ts +++ b/apps/ai-game-creator-shell/src/features/template-library/templateLibraryGrid.ts @@ -14,8 +14,31 @@ export const TEMPLATE_CARD_MIN_WIDTH = 250; export const TEMPLATE_CARD_GAP = 14; /** 封面宽高比:16:9。 */ export const TEMPLATE_CARD_COVER_RATIO = 9 / 16; -/** 卡片封面以下的文字与按钮区固定高度。 */ -export const TEMPLATE_CARD_TEXT_HEIGHT = 150; +/** + * 卡片封面以下的文字与按钮区固定高度。 + * + * 虚拟列表要求行高完全确定,所以文字区**不放任内容撑高**:`TemplateCard` 里每一行都 + * 写死高度(标题 `h-5`、元信息 `h-4`、简介 `h-8`、标签 `h-5.5`、按钮行 `h-7`), + * 这里按同样的口径把总高算出来。两边必须同时改,下面的分项常量就是这条契约的锚点。 + */ +export const TEMPLATE_CARD_TITLE_HEIGHT = 20; +export const TEMPLATE_CARD_META_HEIGHT = 16; +export const TEMPLATE_CARD_SUMMARY_HEIGHT = 32; +export const TEMPLATE_CARD_TAGS_HEIGHT = 22; +export const TEMPLATE_CARD_ACTIONS_HEIGHT = 28; +/** 文字区上下内边距(`p-3`)。 */ +export const TEMPLATE_CARD_TEXT_PADDING = 12; +/** 文字区行间距(`gap-2`)。 */ +export const TEMPLATE_CARD_TEXT_GAP = 8; +/** 文字区共 5 行、4 个行间距。 */ +export const TEMPLATE_CARD_TEXT_HEIGHT = + TEMPLATE_CARD_TEXT_PADDING * 2 + + TEMPLATE_CARD_TITLE_HEIGHT + + TEMPLATE_CARD_META_HEIGHT + + TEMPLATE_CARD_SUMMARY_HEIGHT + + TEMPLATE_CARD_TAGS_HEIGHT + + TEMPLATE_CARD_ACTIONS_HEIGHT + + TEMPLATE_CARD_TEXT_GAP * 4; /** 额外预渲染的行数,减小快速滚动时的白屏。 */ export const TEMPLATE_GRID_OVERSCAN_ROWS = 2; @@ -68,6 +91,40 @@ export function computeTemplateGridLayout({ }; } +/** + * 带竖滚动条预留的布局。 + * + * react-window 的内层宽度是 `列数 × 列宽`,而**经典(非 overlay)滚动条**会吃掉外层 + * `clientWidth`:Windows / WebView2 上竖滚动条约 15–17px,于是内层比外层可用宽度多出 + * 正好一个滚动条,卡片区底部就多出一条横向滚动条。这里在「内容确实会竖向溢出」时, + * 先把滚动条宽度从容器宽度里扣掉再算列宽;不会竖向溢出时保持原口径,避免右侧留一条 + * 无意义的白边。overlay 滚动条平台(占宽 0)结果与不预留完全一致。 + */ +export function computeTemplateGridLayoutWithScrollbar({ + containerWidth, + itemCount, + viewportHeight, + scrollbarWidth, +}: { + containerWidth: number; + itemCount: number; + viewportHeight: number; + scrollbarWidth: number; +}): TemplateGridLayout { + const full = computeTemplateGridLayout({ containerWidth, itemCount }); + const reserve = Math.max(0, scrollbarWidth); + if (reserve === 0 || full.rowCount * full.rowHeight <= viewportHeight) { + return full; + } + const usableWidth = Math.max(0, containerWidth - reserve); + const reserved = computeTemplateGridLayout({ + containerWidth: usableWidth, + itemCount, + }); + // 预留后行数变多时,竖向溢出只会更明显,不会退回「不需要滚动条」的情况。 + return reserved; +} + /** 按行切分,行尾补 `null` 占位,保证虚拟列表的列索引与条目一一对应。 */ export function buildTemplateRows( templates: readonly GameTemplateEntry[], diff --git a/apps/ai-game-creator-shell/src/features/template-library/templateLibraryModel.ts b/apps/ai-game-creator-shell/src/features/template-library/templateLibraryModel.ts index 65f460fb9..8b473d08d 100644 --- a/apps/ai-game-creator-shell/src/features/template-library/templateLibraryModel.ts +++ b/apps/ai-game-creator-shell/src/features/template-library/templateLibraryModel.ts @@ -127,10 +127,16 @@ export function filterGameTemplates( }); } +/** 筛选条上的单个标签:名称 + 命中的模板数。 */ +export type GameTemplateTagOption = { + tag: string; + assetCount: number; +}; + /** 标签按出现次数降序,次数相同按名称排序,保证筛选条顺序稳定。 */ -export function collectGameTemplateTags( +export function collectGameTemplateTagOptions( templates: readonly GameTemplateEntry[], -): string[] { +): GameTemplateTagOption[] { const counts = new Map(); for (const template of templates) { for (const tag of template.tags) { @@ -144,7 +150,13 @@ export function collectGameTemplateTags( ([leftTag, leftCount], [rightTag, rightCount]) => rightCount - leftCount || leftTag.localeCompare(rightTag, 'zh-CN'), ) - .map(([tag]) => tag); + .map(([tag, assetCount]) => ({ tag, assetCount })); +} + +export function collectGameTemplateTags( + templates: readonly GameTemplateEntry[], +): string[] { + return collectGameTemplateTagOptions(templates).map((option) => option.tag); } export function collectGameTemplateRuntimes( diff --git a/apps/ai-game-creator-shell/src/features/template-library/useTemplateLibrary.ts b/apps/ai-game-creator-shell/src/features/template-library/useTemplateLibrary.ts index 2d38e27f9..eadf2d2d0 100644 --- a/apps/ai-game-creator-shell/src/features/template-library/useTemplateLibrary.ts +++ b/apps/ai-game-creator-shell/src/features/template-library/useTemplateLibrary.ts @@ -21,7 +21,6 @@ import { import { readProjectCreationDirectory } from '../app-shell/model'; import { collectGameTemplateRuntimes, - collectGameTemplateTags, EMPTY_TEMPLATE_LIBRARY_FILTERS, filterGameTemplates, type GameTemplateEntry, @@ -298,10 +297,6 @@ export function useTemplateLibrary({ () => filterGameTemplates(templates, filters), [templates, filters], ); - const tagOptions = useMemo( - () => collectGameTemplateTags(templates), - [templates], - ); const runtimeOptions = useMemo( () => collectGameTemplateRuntimes(templates), [templates], @@ -343,7 +338,6 @@ export function useTemplateLibrary({ notice, templates, visibleTemplates, - tagOptions, runtimeOptions, installedCount, filters, diff --git a/apps/ai-game-creator-shell/src/view/project-development/ResourceFilterPanel.tsx b/apps/ai-game-creator-shell/src/view/project-development/ResourceFilterPanel.tsx index b61cd23c3..4a014aab5 100644 --- a/apps/ai-game-creator-shell/src/view/project-development/ResourceFilterPanel.tsx +++ b/apps/ai-game-creator-shell/src/view/project-development/ResourceFilterPanel.tsx @@ -1,5 +1,6 @@ import { type RefObject, useEffect, useId, useRef } from 'react'; +import { getPlatformCategoryChipClassName } from '../../../../../packages/shared/src/components/platformCategoryChipModel'; import { PlatformFilterPanel, PlatformFilterPanelField, @@ -15,19 +16,6 @@ import { type ResourceFilterTagOption, } from './resourceCanvasFilterModel'; -/** - * 已选标签的 chip 用与既有筛选条同一套类名,保持「选中的标签长什么样」只有一处实现。 - * `PlatformResourceFilterBar` 自己渲染未选标签时用的是同一个类。 - */ -function resourceFilterTagChipClassName(active: boolean) { - return [ - 'platform-category-chip gap-1.5 px-2.5 text-xs font-bold', - active ? 'platform-category-chip--active' : null, - ] - .filter(Boolean) - .join(' '); -} - type ResourceFilterPanelProps = { onClose: () => void; /** 关键词:右下角放大镜与 Ctrl/Cmd+F 叫出的就是这一个面板,状态由宿主持有。 */ @@ -186,7 +174,7 @@ export function ResourceFilterPanel({ key={option.tag} type="button" aria-pressed={active} - className={resourceFilterTagChipClassName(active)} + className={getPlatformCategoryChipClassName(active)} onClick={() => onToggleTag(option.tag)} > {option.tag} diff --git a/apps/ai-game-creator-shell/src/view/template-library/TemplateCard.tsx b/apps/ai-game-creator-shell/src/view/template-library/TemplateCard.tsx index c484074ba..b6a2e1539 100644 --- a/apps/ai-game-creator-shell/src/view/template-library/TemplateCard.tsx +++ b/apps/ai-game-creator-shell/src/view/template-library/TemplateCard.tsx @@ -18,6 +18,10 @@ export type TemplateCardActions = { /** * 虚拟列表里的单个模板卡片:高度由行高契约固定(封面 16:9 + 固定文字区), * 用 memo 包住,滚动时只重渲染可视区域内的少量卡片。 + * + * 文字区每行都写死高度且不参与压缩(`shrink-0`):行高契约(`templateLibraryGrid.ts` + * 的 `TEMPLATE_CARD_TEXT_HEIGHT`)按同样的口径算出卡片总高,两边一旦不一致, + * 被截断的就是标题、简介和标签这些真实文字。 */ function TemplateCardView({ template, @@ -28,12 +32,8 @@ function TemplateCardView({ }: { template: GameTemplateEntry } & TemplateCardActions) { const busy = busyTemplateId === template.id; const needsDownload = needsTemplateDownload(template); - const busyLabel = - busy && busyKind === 'download' - ? '正在下载模板' - : busy && busyKind === 'create' - ? '正在创建项目' - : ''; + const creating = busy && busyKind === 'create'; + const downloading = busy && busyKind === 'download'; const meta = [ templateRuntimeLabel(template.runtime), template.engine, @@ -56,6 +56,11 @@ function TemplateCardView({ alt="" loading="lazy" decoding="async" + /* 封面读不到时留出中性的占位底色,而不是让浏览器画一个「裂图」图标。 + 直接改样式、不进 state:卡片是被 memo 包住的纯展示组件。 */ + onError={(event) => { + event.currentTarget.style.visibility = 'hidden'; + }} /> {template.installed ? ( ) : null} -
- +
+ {template.title} - + {meta} {template.summary ? ( -

+

{template.summary}

) : null} {template.tags.length > 0 ? ( -
+ // 卡片只给标签一行(行高契约固定 22px)。清单模板 ≤4 个标签时正好放得下; + // 真出现超长标签集合时这一行会裁掉后半段,用 title 兜住完整列表。 +
{template.tags.map((tag) => ( {tag} @@ -91,29 +104,32 @@ function TemplateCardView({ ))}
) : null} -
+ {/* 动作行固定高度、按钮不换行:忙状态写在**触发它的那个按钮**上,不要再往这一行 + 塞第三个元素 —— 卡片最小宽度只有 250px,多一段「正在下载模板」就会把两个 + 按钮的文案挤成两行、顶出卡片。 */} +
{/* 已下载且版本一致时不再提供下载入口;只有缺包或版本落后才显示(落后时按「更新」)。 */} {needsDownload ? ( ) : null} - {busyLabel ? ( - - {busyLabel} - - ) : null}
diff --git a/apps/ai-game-creator-shell/src/view/template-library/index.tsx b/apps/ai-game-creator-shell/src/view/template-library/index.tsx index 37616da12..c246d56ad 100644 --- a/apps/ai-game-creator-shell/src/view/template-library/index.tsx +++ b/apps/ai-game-creator-shell/src/view/template-library/index.tsx @@ -1,25 +1,25 @@ -import { PlatformRuntimeStatusToast } from '@genarrative/shared/components'; import { - ArrowLeft, - Loader2, - RefreshCw, - Search, - SearchX, - SlidersHorizontal, -} from 'lucide-react'; + getPlatformCategoryChipClassName, + PlatformRuntimeStatusToast, +} from '@genarrative/shared/components'; +import { ArrowLeft, Loader2, RefreshCw, SearchX } from 'lucide-react'; import { useEffect, useMemo, useRef, useState } from 'react'; import { createPortal } from 'react-dom'; import { FixedSizeGrid, type GridChildComponentProps } from 'react-window'; +import { PlatformResourceFilterBar } from '../../../../../packages/shared/src/components/PlatformResourceFilterBar'; import { buildTemplateRows, - computeTemplateGridLayout, + computeTemplateGridLayoutWithScrollbar, TEMPLATE_CARD_GAP, TEMPLATE_GRID_OVERSCAN_ROWS, templateGridItemKey, } from '../../features/template-library/templateLibraryGrid'; -import type { GameTemplateEntry } from '../../features/template-library/templateLibraryModel'; -import { templateRuntimeLabel } from '../../features/template-library/templateLibraryModel'; +import { + collectGameTemplateTagOptions, + type GameTemplateEntry, + templateRuntimeLabel, +} from '../../features/template-library/templateLibraryModel'; import type { TemplateLibraryController } from '../../features/template-library/useTemplateLibrary'; import { TemplateCard, type TemplateCardActions } from './TemplateCard'; @@ -73,15 +73,33 @@ function TemplateLibraryToast({ ); } -const chipClass = - 'cursor-pointer rounded-full border border-(--platform-subpanel-border) bg-transparent px-2.5 py-1 text-[11px] text-(--platform-text-soft) transition hover:border-(--platform-warm-text) hover:text-(--platform-warm-text)'; -const activeChipClass = - 'cursor-pointer rounded-full border border-(--platform-warm-text) bg-transparent px-2.5 py-1 text-[11px] text-(--platform-warm-text)'; - type TemplateGridCellData = TemplateCardActions & { rows: Array>; }; +let cachedScrollbarWidth: number | null = null; + +/** + * 量一次经典(非 overlay)竖滚动条的占宽:Windows / WebView2 上约 15–17px,会吃掉 + * grid 外层的可用宽度。overlay 滚动条平台测得 0(此时预留逻辑不生效)。 + */ +function measureVerticalScrollbarWidth(): number { + if (cachedScrollbarWidth !== null) { + return cachedScrollbarWidth; + } + if (typeof document === 'undefined' || !document.body) { + return 0; + } + const probe = document.createElement('div'); + probe.setAttribute('aria-hidden', 'true'); + probe.style.cssText = + 'position:absolute;top:-9999px;left:-9999px;width:100px;height:100px;overflow:scroll'; + document.body.appendChild(probe); + cachedScrollbarWidth = Math.max(0, probe.offsetWidth - probe.clientWidth); + probe.remove(); + return cachedScrollbarWidth; +} + function TemplateGridCell({ columnIndex, rowIndex, @@ -115,7 +133,6 @@ export default function TemplateLibraryView({ notice, templates, visibleTemplates, - tagOptions, runtimeOptions, installedCount, filters, @@ -201,16 +218,30 @@ export default function TemplateLibraryView({ const layout = useMemo( () => - computeTemplateGridLayout({ + computeTemplateGridLayoutWithScrollbar({ containerWidth: viewportSize.width, itemCount: visibleTemplates.length, + viewportHeight: viewportSize.height, + scrollbarWidth: measureVerticalScrollbarWidth(), }), - [viewportSize.width, visibleTemplates.length], + [viewportSize.width, viewportSize.height, visibleTemplates.length], ); const rows = useMemo( () => buildTemplateRows(visibleTemplates, layout.columnCount), [visibleTemplates, layout.columnCount], ); + const tagItems = useMemo( + () => collectGameTemplateTagOptions(templates), + [templates], + ); + const runtimeItems = useMemo( + () => + runtimeOptions.map((runtime) => { + const label = templateRuntimeLabel(runtime); + return { id: runtime, label, ariaLabel: `运行时筛选 ${label}` }; + }), + [runtimeOptions], + ); // 换筛选条件回到列表顶部:否则筛选后条目变少会把视口留在空白处,看起来像“卡住”。 useEffect(() => { @@ -285,83 +316,75 @@ export default function TemplateLibraryView({ - {/* 标签/运行时筛选区可独立滚动:标签数量随库量增长时不会把卡片区挤出窗口。 */} -
-
- + {/* 搜索 + 运行时用共享筛选条(与资源画布、参考图弹窗同一套筛选 UI); + 「仅看已下载」「清除筛选」是模板库自己的口径,靠右跟在同一条上。 */} +
+ +
{filtersActive ? ( - ) : null}
- {runtimeOptions.length > 0 ? ( -
- - - {runtimeOptions.map((runtime) => ( - - ))} -
- ) : null} - {tagOptions.length > 0 ? ( -
- - 标签 - - {tagOptions.map((tag) => ( - - ))} -
- ) : null}
+ {/* 标签单独一行并换行排布:标签数量随库量增长时优先换行,超过上限再滚动, + 不会把卡片区挤出窗口,也不会让用户只能看到横向滚动条切掉的后半截标签。 */} + {tagItems.length > 0 ? ( +
+ {tagItems.map((option) => { + const active = filters.tags.includes(option.tag); + return ( + + ); + })} +
+ ) : null} + {error ? (
{error}
diff --git a/apps/ai-game-creator-shell/tests/templateLibraryGrid.test.ts b/apps/ai-game-creator-shell/tests/templateLibraryGrid.test.ts index 2ddf16993..bd3aecbf6 100644 --- a/apps/ai-game-creator-shell/tests/templateLibraryGrid.test.ts +++ b/apps/ai-game-creator-shell/tests/templateLibraryGrid.test.ts @@ -4,10 +4,18 @@ import { buildTemplateRows, computeTemplateGridColumns, computeTemplateGridLayout, + computeTemplateGridLayoutWithScrollbar, computeTemplateRowHeight, + TEMPLATE_CARD_ACTIONS_HEIGHT, TEMPLATE_CARD_GAP, + TEMPLATE_CARD_META_HEIGHT, TEMPLATE_CARD_MIN_WIDTH, + TEMPLATE_CARD_SUMMARY_HEIGHT, + TEMPLATE_CARD_TAGS_HEIGHT, + TEMPLATE_CARD_TEXT_GAP, TEMPLATE_CARD_TEXT_HEIGHT, + TEMPLATE_CARD_TEXT_PADDING, + TEMPLATE_CARD_TITLE_HEIGHT, } from '../src/features/template-library/templateLibraryGrid'; import type { GameTemplateEntry } from '../src/features/template-library/templateLibraryModel'; @@ -50,6 +58,19 @@ describe('computeTemplateGridColumns', () => { }); describe('computeTemplateRowHeight', () => { + it('budgets the same height the card rows actually need', () => { + // 这些数字对应 TemplateCard 的 `h-5`/`h-4`/`h-8`/`h-5.5`/`h-7` 与 `p-3`/`gap-2`: + // 行高契约比真实内容小,被截断的就是卡片里的标题与简介。 + expect(TEMPLATE_CARD_TITLE_HEIGHT).toBe(20); + expect(TEMPLATE_CARD_META_HEIGHT).toBe(16); + expect(TEMPLATE_CARD_SUMMARY_HEIGHT).toBe(32); + expect(TEMPLATE_CARD_TAGS_HEIGHT).toBe(22); + expect(TEMPLATE_CARD_ACTIONS_HEIGHT).toBe(28); + expect(TEMPLATE_CARD_TEXT_PADDING).toBe(12); + expect(TEMPLATE_CARD_TEXT_GAP).toBe(8); + expect(TEMPLATE_CARD_TEXT_HEIGHT).toBe(174); + }); + it('keeps cover ratio + fixed text block', () => { // 列宽 300 → 卡片 286 → 封面 286*9/16 = 160.875 → 161 expect(computeTemplateRowHeight(300)).toBe( @@ -94,6 +115,59 @@ describe('computeTemplateGridLayout', () => { }); }); +describe('computeTemplateGridLayoutWithScrollbar', () => { + const innerWidth = (layout: { columnCount: number; columnWidth: number }) => + layout.columnCount * layout.columnWidth; + + it('reserves the classic scrollbar width once the grid scrolls vertically', () => { + // 经典滚动条(Windows/WebView2 ≈ 17px)不在包裹层宽度里,不预留就会多出一条横向滚动条。 + const plain = computeTemplateGridLayout({ + containerWidth: 1184, + itemCount: 13, + }); + const layout = computeTemplateGridLayoutWithScrollbar({ + containerWidth: 1184, + itemCount: 13, + viewportHeight: 500, + scrollbarWidth: 17, + }); + + expect(layout.columnCount).toBe(4); + // 内层宽度必须落在竖滚动条左侧的可用宽度里,否则又会出现横向滚动条。 + expect(innerWidth(layout)).toBeLessThanOrEqual(1184 - 17); + expect(innerWidth(layout)).toBeLessThan(innerWidth(plain)); + // 行高仍按预留后的列宽算,卡片内容不会被压。 + expect(layout.rowHeight).toBe(computeTemplateRowHeight(layout.columnWidth)); + }); + + it('keeps the plain layout when nothing overflows or the scrollbar is an overlay', () => { + const plain = computeTemplateGridLayout({ + containerWidth: 1184, + itemCount: 4, + }); + // 只有一行:不会竖向滚动,不需要预留,右侧不留白边。 + expect( + computeTemplateGridLayoutWithScrollbar({ + containerWidth: 1184, + itemCount: 4, + viewportHeight: 900, + scrollbarWidth: 17, + }), + ).toEqual(plain); + // overlay 滚动条占宽 0:与不预留完全一致。 + expect( + computeTemplateGridLayoutWithScrollbar({ + containerWidth: 1184, + itemCount: 13, + viewportHeight: 500, + scrollbarWidth: 0, + }), + ).toEqual( + computeTemplateGridLayout({ containerWidth: 1184, itemCount: 13 }), + ); + }); +}); + describe('buildTemplateRows', () => { it('chunks entries per row and pads the tail with nulls', () => { const rows = buildTemplateRows([entry('a'), entry('b'), entry('c')], 2); diff --git a/apps/ai-game-creator-shell/tests/templateLibraryModel.test.ts b/apps/ai-game-creator-shell/tests/templateLibraryModel.test.ts index 5eb52ca01..a3852fcdf 100644 --- a/apps/ai-game-creator-shell/tests/templateLibraryModel.test.ts +++ b/apps/ai-game-creator-shell/tests/templateLibraryModel.test.ts @@ -2,6 +2,7 @@ import { describe, expect, it } from 'vitest'; import { collectGameTemplateRuntimes, + collectGameTemplateTagOptions, collectGameTemplateTags, EMPTY_TEMPLATE_LIBRARY_FILTERS, filterGameTemplates, @@ -145,6 +146,24 @@ describe('tag and runtime options', () => { ]); }); + it('keeps the same order while reporting how many templates carry each tag', () => { + // 筛选条上的标签 chip 要显示命中数量,顺序必须与 `collectGameTemplateTags` 完全一致。 + const withBlank = [ + ...templates, + template({ id: 'blank-tag', tags: ['', ' ', '经营'] }), + ]; + const options = collectGameTemplateTagOptions(withBlank); + expect(options).toEqual([ + { tag: '经营', assetCount: 3 }, + { tag: '三消', assetCount: 1 }, + { tag: '射击', assetCount: 1 }, + { tag: '像素', assetCount: 1 }, + ]); + expect(options.map((option) => option.tag)).toEqual( + collectGameTemplateTags(withBlank), + ); + }); + it('collects distinct runtimes and labels them', () => { expect(collectGameTemplateRuntimes(templates)).toEqual([ 'godot', diff --git a/apps/ai-game-creator-shell/tests/templateLibraryView.test.tsx b/apps/ai-game-creator-shell/tests/templateLibraryView.test.tsx index 7a27cdbd9..5e3d3086d 100644 --- a/apps/ai-game-creator-shell/tests/templateLibraryView.test.tsx +++ b/apps/ai-game-creator-shell/tests/templateLibraryView.test.tsx @@ -8,7 +8,6 @@ import type { TemplateLibraryFilters, } from '../src/features/template-library/templateLibraryModel'; import { - collectGameTemplateTags, EMPTY_TEMPLATE_LIBRARY_FILTERS, filterGameTemplates, } from '../src/features/template-library/templateLibraryModel'; @@ -81,7 +80,6 @@ function controller( notice: '', templates, visibleTemplates: templates, - tagOptions: ['空白', '2d', 'canvas', '网页'], runtimeOptions: ['html'], installedCount: 1, filters, @@ -190,6 +188,28 @@ describe('TemplateLibraryView', () => { expect(viewport?.querySelector('article')).not.toBeNull(); }); + it('pins every card text row to the height the grid contract budgets for it', () => { + // 回归点:文字区若是 `grid` 的 auto 行,行高会按 max-content 算成「一行」, + // 标题 / 简介 / 标签会被逐行截断(现场表现为标题文字被切掉)。这里钉住 + // 卡片每一行的固定高度,改动必须同时改 `TEMPLATE_CARD_*_HEIGHT` 那组常量。 + render( {}} />); + + const card = cardFor('空白网页工程'); + const textBlock = card.children[1] as HTMLElement; + const rows = Array.from(textBlock.children) as HTMLElement[]; + + expect(textBlock.className).toContain('flex-col'); + expect(rows.map((row) => row.className)).toEqual([ + expect.stringContaining('h-5'), + expect.stringContaining('h-4'), + expect.stringContaining('h-8'), + expect.stringContaining('h-5.5'), + expect.stringContaining('h-7'), + ]); + // 每行都不参与压缩,否则 flex 会把文字压回去。 + rows.forEach((row) => expect(row.className).toContain('shrink-0')); + }); + it('offers 更新 instead of 下载 when the installed version is stale', () => { const stale = template({ id: 'blank-web', @@ -253,6 +273,17 @@ describe('TemplateLibraryView', () => { expect(clearFilters).toHaveBeenCalled(); }); + it('hides a cover that failed to load instead of showing a broken image', () => { + render( {}} />); + + const cover = cardFor('空白网页工程').querySelector( + 'img', + ) as HTMLImageElement; + expect(cover.style.visibility).toBe(''); + fireEvent.error(cover); + expect(cover.style.visibility).toBe('hidden'); + }); + it('starts a download and a template project from the card actions', () => { const downloadTemplate = vi.fn(async () => undefined); const createProjectFromTemplate = vi.fn(async () => undefined); @@ -289,11 +320,16 @@ describe('TemplateLibraryView', () => { const busyCard = cardFor('空白二维画布工程'); const buttons = Array.from(busyCard.querySelectorAll('button')); + // 忙状态写在触发它的按钮上(文案就地变成「创建中」),动作行里不额外塞第三个元素, + // 否则最小卡宽(250px)下两个按钮的文案会被挤成两行、顶出卡片。 + expect(buttons).toHaveLength(2); expect(buttons.every((button) => button.hasAttribute('disabled'))).toBe( true, ); - expect(busyCard.textContent).toContain('正在创建项目'); - expect(cardFor('空白网页工程').textContent).not.toContain('正在创建项目'); + expect(busyCard.textContent).toContain('创建中'); + expect(busyCard.textContent).not.toContain('使用模板'); + expect(cardFor('空白网页工程').textContent).toContain('使用模板'); + expect(cardFor('空白网页工程').textContent).not.toContain('创建中'); }); it('shows empty, no-match, error and notice states', () => { @@ -390,7 +426,6 @@ describe('大库量渲染(1000 条假数据)', () => { templates: bulk, visibleTemplates: bulk, installedCount: bulk.filter((entry) => entry.installed).length, - tagOptions: collectGameTemplateTags(bulk), })} onBack={() => {}} />, diff --git a/apps/ai-game-creator-shell/tests/workbenchThemeContrast.test.ts b/apps/ai-game-creator-shell/tests/workbenchThemeContrast.test.ts index c54b50f16..8cde18820 100644 --- a/apps/ai-game-creator-shell/tests/workbenchThemeContrast.test.ts +++ b/apps/ai-game-creator-shell/tests/workbenchThemeContrast.test.ts @@ -98,7 +98,128 @@ function contrastRatio(first: Rgba, second: Rgba) { ); } +/** 取渐变里的色标(`linear-gradient(135deg, #b3542f, #8f3f22)` → 两个颜色)。 */ +function parseGradientStops(source: string): Rgba[] { + const body = source.slice(source.indexOf('(') + 1, source.lastIndexOf(')')); + return body + .split(',') + .map((part) => part.trim()) + .filter((part) => part.startsWith('#') || part.startsWith('rgb')) + .map((part) => parseCssColor(part.split(/\s+/)[0] ?? part)); +} + +/** 页面背景(`--platform-body-fill`)里的不透明色标:chip 实际落在这层之上。 */ +function parseBodyFillUnderlays(source: string): Rgba[] { + return Array.from(source.matchAll(/#[\da-f]{6}/gi)).map((match) => + parseCssColor(match[0]), + ); +} + describe('workbench theme contrast', () => { + /** + * 筛选 chip 的两态对比:用户反馈「选中和没选中的颜色看不出差别」,根因是选中态 + * 只换了低透明度的暖色底(两态对比 1.09:1)。这里把「选中 = 实心填充」这条口径 + * 钉死:反白文字在渐变两端都要过 AA,且与未选底色至少差 3:1。 + */ + it('keeps the chip selected state legible and distinct in both themes', () => { + const css = readFileSync(themePath, 'utf8'); + const themes = [ + { + name: 'light', + block: getCssBlock(css, '.platform-theme--light'), + // 浅色主题下 chip 落在页面底色上,用页面渐变的最亮与最暗色标夹住两种情况。 + idleUnderlays: parseBodyFillUnderlays( + getCssVariable( + getCssBlock(css, '.platform-theme--light'), + '--platform-body-fill', + ), + ), + }, + { + name: 'dark', + block: getCssBlock(css, '.platform-theme--dark'), + idleUnderlays: parseBodyFillUnderlays( + getCssVariable( + getCssBlock(css, '.platform-theme--dark'), + '--platform-body-fill', + ), + ), + }, + ]; + + expect( + parseGradientStops('linear-gradient(135deg, #b3542f, #8f3f22)'), + ).toEqual([ + [179, 84, 47, 1], + [143, 63, 34, 1], + ]); + + for (const theme of themes) { + const activeFill = parseGradientStops( + getCssVariable(theme.block, '--platform-chip-active-fill'), + ); + const activeText = parseCssColor( + getCssVariable(theme.block, '--platform-chip-active-text'), + ); + const idleFill = parseCssColor( + getCssVariable(theme.block, '--platform-chip-idle-fill'), + ); + expect(activeFill, `${theme.name} active gradient stops`).toHaveLength(2); + expect( + theme.idleUnderlays.length, + `${theme.name} body fill stops`, + ).toBeGreaterThan(0); + + // 反白文字:渐变两端都要过 AA,不能只保证深的那一端。 + for (const stop of activeFill) { + expect( + contrastRatio(activeText, stop), + `${theme.name} label on fill ${stop.slice(0, 3).join(',')}`, + ).toBeGreaterThanOrEqual(4.5); + } + + // 两态可分辨:选中填充与任意页面底色上的未选 chip 至少差 3:1。 + for (const underlay of theme.idleUnderlays) { + const idleChip = compositeColor(idleFill, underlay); + for (const stop of activeFill) { + expect( + contrastRatio(stop, idleChip), + `${theme.name} selected vs idle over ${underlay.slice(0, 3).join(',')}`, + ).toBeGreaterThanOrEqual(3); + } + } + } + }); + + /** + * 焦点环可见性:键盘用户靠它找焦点。旧口径是 15% 透明度的暖色,合成到页面底色只有 + * 1.17:1——等于没有焦点提示。这里按 WCAG 非文本对比 3:1 钉住两套皮肤。 + */ + it('keeps the keyboard focus ring visible in both themes', () => { + const css = readFileSync(themePath, 'utf8'); + for (const selector of [ + '.platform-theme--light', + '.platform-theme--dark', + ]) { + const block = getCssBlock(css, selector); + const ring = parseCssColor( + getCssVariable(block, '--platform-input-focus-ring'), + ); + const underlays = parseBodyFillUnderlays( + getCssVariable(block, '--platform-body-fill'), + ); + expect(underlays.length, `${selector} body fill stops`).toBeGreaterThan( + 0, + ); + for (const underlay of underlays) { + expect( + contrastRatio(ring, underlay), + `${selector} focus ring over ${underlay.slice(0, 3).join(',')}`, + ).toBeGreaterThanOrEqual(3); + } + } + }); + it('keeps warm user bubbles above WCAG AA text contrast', () => { const css = readFileSync(themePath, 'utf8'); const light = getCssBlock(css, '.platform-theme--light'); diff --git a/docs/project-memory/shared-memory/decision-log.md b/docs/project-memory/shared-memory/decision-log.md index c530a218f..f72796adc 100644 --- a/docs/project-memory/shared-memory/decision-log.md +++ b/docs/project-memory/shared-memory/decision-log.md @@ -1,5 +1,15 @@ # 决策记录 +## 2026-09-22 筛选控件选中态:类名收敛到 helper,视觉收敛到「实心填充 + 反白文字」 + +- 背景:`platform-category-chip` 的类名字符串此前在三个宿主各抄一份(共享筛选条 `PlatformResourceFilterBar`、资源画布筛选浮层 `ResourceFilterPanel`、模板库筛选区),「选中的筛选胶囊长什么样」随时会各自漂移;更严重的是选中态本身只用了 `--platform-cool-*` 这组低透明度暖色,实测选中/未选底色对比只有 1.09:1,用户反馈「选中和没选中的颜色看不出差别」。 +- 决策:① 类名口径收敛到 `packages/shared/src/components/platformCategoryChipModel.ts` 的 `getPlatformCategoryChipClassName(active)`(从 `@genarrative/shared/components` 导出),三处宿主统一改调它;② 选中态改为**实心品牌填充 + 反白文字**,语义色收在新的 `--platform-chip-idle-fill` / `--platform-chip-active-{fill,border,text,shadow}`(浅色皮肤深暖填充、深色皮肤亮靛蓝填充 + 深文字),`PlatformSegmentedTabs` 新增 `tone="accent"` 与 chip 共用这套色;③ `src/index.css`(平台 Web/平台 H5)里那份重复的 `--active` 规则同步改口径,避免覆盖共享样式把 Web 端打回旧样子。 +- 状态阶梯(同一份口径,三个状态不许互相冒充):静止 = 浅底 + 中性描边 + 常规文字;悬停 = 中性加描边 + 极淡暖底 + 深色文字(**品牌色只能属于「已选中」**,悬停用品牌色会让未选中的 chip 看起来已选中);按下 = 再压一层;选中 = 实心填充 + 反白文字,是唯一的强状态。运行时分段的 `accent` 未选中悬停同理(淡暖底 + 深文字)。 +- 焦点态同批收口:`--platform-input-focus-ring` 从 15% 透明度改成实心色(合成后 1.17:1 的环等于没有),筛选 chip / 分段项 / 排序按钮的焦点提示改用 `outline: 2px solid ; outline-offset: 2px`——不再用 `box-shadow` 画环,避免被选中态自己的投影盖掉。 +- 原因:二元状态必须靠**填充/明度**表达而不是色相微调;颜色只允许在 `packages/shared/src/theme.css` 的语义变量里出现,组件不再自己写颜色字面量。 +- 影响范围:`packages/shared/src/theme.css`、`packages/shared/src/components/{platformCategoryChipModel.ts,styles.css,PlatformSegmentedTabs.tsx,PlatformResourceFilterBar.tsx,index.ts}`、`src/index.css`、`apps/ai-game-creator-shell/src/view/{project-development/ResourceFilterPanel.tsx,template-library/index.tsx}`。 +- 验证方式:`apps/ai-game-creator-shell/tests/workbenchThemeContrast.test.ts` 按 WCAG 公式断言两套皮肤都满足「选中文字 ≥ 4.5:1(渐变两端)」且「选中填充 vs 未选底色 ≥ 3:1」;`platformCategoryChipModel.test.ts` 钉住选中类名分支;`PlatformResourceFilterBar.test.tsx` / `resourceFilterPanel.test.tsx` / `src/index.test.ts` 覆盖各宿主。真机 AGC 客户端截图实测两态填充对比 6.0:1、选中文字 4.8–6.0:1。 + ## 2026-09-22 退役 AGC 项目对话斜杠命令与终端 swarm chat 入口 - 背景:AGC 项目对话曾把大量能力挂在「聊天输入 `/`」上(`/history`、`/read`、`/help`、`/status`、`/trace`、`/export`、`/preview`、`/remember`、`/brief` 等),无 GUI 的终端 swarm chat 入口 `--swarm-chat` 又自带一套控制命令(`/help`、`/agents`、`/status`、`/history`、`/compact`、`/resume`、`/goal`、`/quit`)。两套入口都没有现役调用方,撤回成本却持续存在:命令字面量散落在前端命令分支、润色绕过、摘要模块、`swarm_cli` 终端输入解析、构建期门禁条目和文档承诺里,任何新对话形态都要额外维护这套死词汇表。 diff --git a/docs/project-memory/shared-memory/pitfalls.md b/docs/project-memory/shared-memory/pitfalls.md index d46135301..7f0a63bab 100644 --- a/docs/project-memory/shared-memory/pitfalls.md +++ b/docs/project-memory/shared-memory/pitfalls.md @@ -5875,3 +5875,43 @@ Cocos Creator 根目录由 `package.json.creator.version` 与普通 `assets/` - CI 产物清理:Gitea 1.26.4 的仓库 REST 仅列出 finalized/expired V4 artifact,内置到期清理不回收上传中断的 tmp-upload 分块。缓存上传块须带专属标识,宿主只清理目标仓库已结束且超过 7 天的 master run 中同样过期的自有普通文件,未知文件/符号链接保护,不改数据库或全局 prune。Artifact.workflow_run 仅含 ID/SHA,判断过期产物所属事件和状态须再读 run API,不能当作完整 run 使用。 - 自动切换:Gitea 1.26.4 的 disabled 检查与 FetchTask 事务不原子,Runner 客户端超时不能证明服务端回滚,容器暂时为空也不能证明没有已领取任务。网关必须解析实际 Connect Protobuf/gzip,转发 FetchTask 结果前持久化任务 ID,仅在最终日志及执行清理后的最终 UpdateTask 确认后清账;取消响应不能提前释放。暂停新领取、在途为零、账本为零且内层活动容器为空才可切换,无需全局 Runner admin API。未知协议/响应或崩溃遗留标记停止切换;旧网关缺 active_tasks 不能默认零。首次接入与账本升级须空闲窗口。.runner 的 mtime 不证明地址已加载,应核验真实 FetchTask 来源及本次容器启动时间。 - 扩展:预热所有 Rust 测试组时保留各自 cwd、profile、features 和锁策略;同一临时 target 的 Cargo fresh 不代表不同 cwd 都已生成缓存键,AGC 提示词契约、分片和 smoke 切换入口前清理预热 target。不要把 workspace 与 spacetime-module 合并成一次编译;Native shell release step 清空双 wrapper,避免将测试缓存扩展成发布缓存。当前 sccache 0.18.0 的 READ_ONLY 在 miss 后仍打包产物并产生 cache write error,不适合用来承诺“未命中无开销”。 + +## 2026-09-22 卡片文字用 grid 的 auto 行排版,会被按「一行」裁掉 + +- **现象**:AGC 模板库卡片标题看着被切掉、简介只剩一行、标签行缺半截;`TEMPLATE_CARD_TEXT_HEIGHT` 与真实内容相差约 24px,但卡片底部看起来仍「刚好贴住」,很容易误判成没问题。 +- **原因**:文字区原本是 `grid min-h-0 content-start gap-2 overflow-hidden`,行高由 auto 轨道决定。auto 轨道的 max-content 高度对可换行文本等于**一行**的高度:标题拿到 14px(实际需要 20)、简介 14px(两行需要 32)、标签 14px(需要 19),只有最后一个子项(按钮行)拿到完整高度。真实浏览器实测 `clientHeight`/`scrollHeight` 为 14/20、14/32、14/19。jsdom 不计算布局,单测全绿也照不出来。 +- **处理(现行口径)**:文字区改 `flex flex-col`,每行写死高度并加 `shrink-0`(标题 `h-5`、元信息 `h-4`、简介 `h-8`、标签 `h-5.5`、按钮行 `h-7`,内边距 `p-3`、行距 `gap-2`),行高契约按同一组分项常量(`TEMPLATE_CARD_*_HEIGHT`)算出文字区 174。卡片行高、`TemplateCard` 类名与这组常量必须同时改。 +- **写死高度的连带约束**:动作行一旦固定成 `h-7`,那它就**只能放两个按钮**。再往里塞第三段文本(当时的「正在下载模板」)时,最小卡宽 250px 下按钮文案会被挤成两行并顶出卡片(用户看到「使用模 板 / 更 新」叠成一团)。现行口径是忙状态写在触发它的按钮上(`下载中` / `创建中`,按钮就地换图标+文案),按钮一律 `whitespace-nowrap shrink-0`,动作行 `overflow-hidden` 兜底。 +- **验证**:真实浏览器逐行核对 `clientHeight === scrollHeight`(20/20、16/16、32/32、22/22、28/28);单测钉住分项常量与卡片各行类名(`templateLibraryGrid.test.ts`、`templateLibraryView.test.tsx`)。 +- **关联**:`apps/ai-game-creator-shell/src/features/template-library/templateLibraryGrid.ts`、`apps/ai-game-creator-shell/src/view/template-library/TemplateCard.tsx`、`docs/technical/【技术方案】AGC模板库与模板建项-2026-09-17.md`(卡片列表虚拟滚动)。 + +## 2026-09-22 筛选 chip 的选中态只靠低透明度色相微调,用户看不出选中了什么 + +- **现象**:模板库筛选 chip 选中与未选中的底色实测只差 **1.09:1**(选中 `rgba(238,208,183,0.26)` 合成后 ≈ `#f9eee5`,未选 ≈ `#fdf9f5`),选中文字 `#b76038` 在自己底色上只有 3.88:1(低于 AA);运行时分段选中的白底 pill 也和米色页面几乎同色。反馈原话是「选中和没选中的颜色都看不出有差别」。 +- **原因**:选中态走的是 `--platform-cool-bg/border/text`,这三个变量在浅色皮肤里是同一个暖色调的低透明度版本(bg 26%、border 24% alpha),只够做「淡淡的染色」,不足以表达二元状态;深色皮肤里 `rgba(8,145,178,0.14)` 同样太弱,而深色皮肤里「更深的填充」反而更不可分辨。 +- **处理(现行口径)**:筛选控件的二元状态统一为**未选 = 浅底描边 + 常规文字,选中 = 实心品牌填充 + 反白文字**(`.platform-category-chip--active` 与 `PlatformSegmentedTabs` 的 `tone="accent"` 共用 `--platform-chip-*` 语义色;深色皮肤用「亮填充 + 深文字」,因为深色下只有更亮才算选中)。改色只改 `packages/shared/src/theme.css` 的语义变量,不要再往组件里写颜色。 +- **同一坑的第二种表现**:把「悬停」也刷成品牌色(暖色描边 + 暖色文字)后,未选中的 chip 一悬停就像已选中——品牌色一旦被悬停借走,「已选中」这个强状态就没有颜色可用了。现行阶梯是「静止 = 浅底中性描边 / 悬停 = 中性加描边 + 极淡暖底 + 深色文字 / 按下 = 再压一层 / 选中 = 实心填充 + 反白文字」。客户端实测:选中填充 `#b0522e` vs 悬停底 `#f9efe4` = 4.52:1,选中 vs 静止 = 4.97:1,悬停 vs 静止 = 1.10:1(只是提示,不抢选中)。 +- **验证**:`apps/ai-game-creator-shell/tests/workbenchThemeContrast.test.ts` 按 WCAG 公式断言两套皮肤都满足「选中标签文字 ≥ 4.5:1(渐变两端都要过)」且「选中填充 vs 未选底色 ≥ 3:1」;真实浏览器实测改后两态对比 5.6–6.1:1、选中文字 5.5–6.0:1(改前分别是 1.09:1 与 3.88:1)。 +- **关联**:`packages/shared/src/theme.css`、`packages/shared/src/components/styles.css`、`packages/shared/src/components/PlatformSegmentedTabs.tsx`、`apps/ai-game-creator-shell/tests/workbenchThemeContrast.test.ts`。 + +## 2026-09-22 虚拟网格按「包裹层宽度」算列宽,经典滚动条一出现就多出横向滚动条 + +- **现象**:AGC 模板库卡片区底部在客户端里凭空多出一条横向滚动条(外层并没有横向溢出内容);窗口放到没有竖向滚动的尺寸时又不出现。 +- **原因**:react-window 的内层宽度 = `列数 × 列宽`,而列宽是按**包裹层**宽度算的(`floor(容器宽 / 列数)`)。经典(非 overlay)滚动条会吃掉 grid 外层的 `clientWidth`:Windows / WebView2 上竖向滚动条约 17px,于是内层 1184 比外层可用宽 1167 宽出正好一个滚动条,react-window 就按「横向也要滚」处理。**Playwright 自带的 Chromium 用的是 overlay 滚动条(占宽 0),本地量 `offsetWidth - clientWidth` 是 0,完全复现不出来** —— 这类问题只能在 WebView2 客户端里看,或者按算术推。 +- **处理(现行口径)**:`computeTemplateGridLayoutWithScrollbar`(`templateLibraryGrid.ts`)在「内容确实会竖向溢出」时先把滚动条宽度从容器宽度里扣掉再算列宽/行高,不竖向溢出时不预留(否则右侧会留一条无意义的白边);滚动条宽度由 `measureVerticalScrollbarWidth()` 量一次(overlay 平台为 0,逻辑自动退化)。同类虚拟列表再出现「莫名其妙的横向滚动条」,先查这里的算术,不要靠 `overflow-x: hidden` 掩盖(那会把最后一列切掉)。 +- **验证**:`templateLibraryGrid.test.ts` 断言「竖向溢出时 `列数 × 列宽 ≤ 容器宽 - 滚动条`」「不溢出或 overlay 时与不预留完全一致」;真机客户端截图确认横向滚动条消失、右侧只剩竖向滚动条。 +- **关联**:`apps/ai-game-creator-shell/src/features/template-library/templateLibraryGrid.ts`、`apps/ai-game-creator-shell/src/view/template-library/index.tsx`。 + +## 2026-09-22 「15% 透明度的焦点环」等于没有焦点提示;选中态自己的投影还会把焦点环顶掉 + +- **现象**:键盘 Tab 走到筛选 chip、开关、卡片按钮上时,屏幕上完全看不出焦点在哪;自动化里更隐蔽——`box-shadow` 计算值非空(一串 `rgba(0,0,0,0) 0 0 0 0` 的 Tailwind ring 占位),只查「有没有 shadow」会全部判过。 +- **原因**:两个叠加的问题。① `--platform-input-focus-ring` 是 `rgba(204,117,76,0.15)`,合成到页面底色后对底色只有 **1.17:1**,远低于 WCAG 非文本对比要求的 3:1;② 焦点环用 `box-shadow` 画,而**选中态自己也有 `box-shadow`**(实心 chip 的投影),选中的 chip / 分段项聚焦时环被状态投影盖掉,等于没有提示。 +- **处理(现行口径)**:`--platform-input-focus-ring` 改成实心色(浅色 `#b6623f`,对页面 4.3:1;深色 `#9fb0ff`,对深色底 6.5:1);筛选 chip / 分段项 / 排序按钮的焦点环改用 `outline: 2px solid var(--platform-input-focus-ring); outline-offset: 2px`——`outline` 不参与 `box-shadow` 层叠,不会被选中态投影顶掉,也不撑开布局。 +- **验证**:`tests/workbenchThemeContrast.test.ts` 断言焦点环对两套皮肤的页面底色 ≥ 3:1;真实浏览器里对页面上**全部 175 个可聚焦控件**做 blur→focus 前后比对,无一例外都能看到焦点变化(改前有 8 个控件聚焦前后完全一致)。查焦点态时必须比较「聚焦前后的计算样式差异」,不能只看属性是否非空。 +- **关联**:`packages/shared/src/theme.css`、`packages/shared/src/components/styles.css`、`apps/ai-game-creator-shell/tests/workbenchThemeContrast.test.ts`。 + +## 2026-09-22 封面图加载失败会画出浏览器的「裂图」图标 + +- **现象**:模板清单里的封面 URL 失效(或离线)时,卡片封面上出现浏览器的破碎图片图标,比没有封面更难看。 +- **处理**:`TemplateCard` 的 `img` 加 `onError` 直接把自身 `visibility` 设为 `hidden`(不进 state,卡片是 memo 的纯展示组件),留下封面容器本身的中性底色;单测用 `fireEvent.error(cover)` 钉住。 +- **关联**:`apps/ai-game-creator-shell/src/view/template-library/TemplateCard.tsx`、`apps/ai-game-creator-shell/tests/templateLibraryView.test.tsx`。 diff --git a/docs/technical/【技术方案】AGC模板库与模板建项-2026-09-17.md b/docs/technical/【技术方案】AGC模板库与模板建项-2026-09-17.md index 90aa75885..d454023b5 100644 --- a/docs/technical/【技术方案】AGC模板库与模板建项-2026-09-17.md +++ b/docs/technical/【技术方案】AGC模板库与模板建项-2026-09-17.md @@ -116,7 +116,10 @@ templates/ - `src/features/template-library/templateLibraryModel.ts`:清单类型、搜索(空白分隔多关键词「与」)、标签/运行时/已下载筛选、标签选项聚合、体积格式化等纯函数。 - `src/features/template-library/useTemplateLibrary.ts`:一次拉清单,暴露筛选状态、下载与「用模板建项目」;下载成功后只就地更新该条目的已下载状态。 - `src/view/template-library/index.tsx`:模板库全屏页(返回、刷新、搜索、运行时/标签筛选、仅看已下载、卡片显示封面与已下载徽标、下载/使用模板)。 + - 筛选区拆两行:第一行是共享筛选条 `PlatformResourceFilterBar`(搜索框 + 运行时分段)与靠右的「仅看已下载 / 清除筛选」,第二行是标签 chip(带命中数量,换行排布、`max-h-[20vh]` 上限内滚动)。 + - 筛选控件的两态口径(模板库、资源画布、参考图弹窗共用):**未选 = 浅底描边 + 常规文字,选中 = 实心品牌填充 + 反白文字**,两态填充对比 ≥ 3:1、标签文字在选中填充上 ≥ 4.5:1(由 `tests/workbenchThemeContrast.test.ts` 钉住)。运行时分段用 `PlatformSegmentedTabs` 的 `tone="accent"`,与标签 chip 共用同一组 `--platform-chip-active-*` 语义色,不再出现「选中只换了一点点暖色」的弱状态。 - 卡片动作按安装状态收口:已下载且版本一致时**不再显示下载入口**,只留「使用模板」;版本落后才显示「更新」;缺包显示「下载」。 + - 忙状态写在**触发它的那个按钮**上(下载中 / 创建中,按钮就地换图标与文案),动作行里不额外塞状态文本:最小卡宽(250px)下「使用模板 + 下载/更新 + 状态文本」三个元素会把按钮文案挤成两行并顶出卡片。 - 过程提示(下载完成、开始建项目)走浮层 toast(复用 `packages/shared` 的 `PlatformRuntimeStatusToast`,`document.body` 浮层 + 2.6 秒自动消失),不再占用页面内位置;页面内只保留可操作的错误与空态。 - 首页「灵感推荐」替换为「模板库」推荐位(`src/view/home/TemplateRecommendations.tsx`):只展示封面、标题、运行时与已下载徽标,点击进入模板库页面;首页不再直接触发建项目。 - 左侧导航新增模板库入口(`LauncherView = 'template-library'`)。 @@ -164,14 +167,15 @@ AGC_TEMPLATE_LIBRARY_SYNTHETIC_COUNT=300 AGC_DEV_CARGO_FEATURES=template-library ### 卡片列表虚拟滚动(react-window) - 列表改用 workspace 里已有的 `react-window@1.8.11` 的 `FixedSizeGrid`(`react-arborist` 已在用同一版本,不引入新包;类型来自 devDependency `@types/react-window`)。 -- 布局契约收在纯函数 `templateLibraryGrid.ts`(单测覆盖):列数 = `floor((容器宽 + gap) / (最小卡宽 + gap))`、列宽 = 容器宽 / 列数、行高 = `卡片宽 × 9/16 + 文字区 150 + gap`;`buildTemplateRows` 按行切分并在行尾补 `null` 占位。 +- 布局契约收在纯函数 `templateLibraryGrid.ts`(单测覆盖):列数 = `floor((容器宽 + gap) / (最小卡宽 + gap))`、列宽 = 容器宽 / 列数、行高 = `卡片宽 × 9/16 + 文字区 174 + gap`;`buildTemplateRows` 按行切分并在行尾补 `null` 占位。 + - **卡片文字区行高契约**:文字区 174 = 内边距 12×2 + 标题 20(`h-5`)+ 元信息 16(`h-4`)+ 简介 32(`h-8`,两行)+ 标签 22(`h-5.5`)+ 按钮行 28(`h-7`)+ 行距 8×4;这些分项在 `templateLibraryGrid.ts` 里各有一个常量,`TemplateCard` 用同一组固定高度 + `shrink-0` 渲染。**不要**再把文字区写成 `grid` 的 auto 行:auto 行按 max-content 计高,多行文字只按一行算,标题 / 简介 / 标签会被逐行裁掉。 - 卡片抽成 `TemplateCard`(`memo`),网格只渲染可视行 + 2 行 overscan;筛选条件(关键词/标签/运行时/仅看已下载)变化时把滚动位置复位到顶部,避免"从筛选切回全量后停在空白处"。 - **页面高度契约**:页面根节点的高度按**父级 `.launcher-main` 的实测高度**内联设置,既不用百分比也不用 `100vh`。原因:外壳样式 `.launcher-main > .platform-theme { height: 100% }` 特异性高于 Tailwind 工具类,而这条百分比在 `.launcher-shell { min-height: 100vh }` 链路上是不定高,页面会退化成内容高度(虚拟网格视口高度 0、卡片区整片空白);`100vh` 又比真实舞台高一个标题栏高度(窗口 100vh=800 / 舞台 750),底部会被裁掉。 -- 筛选区(运行时/标签)改成可独立滚动的区块(`max-h-[24vh]`),标签数量随库量增长时不再把卡片区挤出窗口。 +- 筛选区(运行时/标签)在窗口变窄时整体换行,标签行单独限高(`max-h-[20vh]` 内滚动),标签数量随库量增长时不再把卡片区挤出窗口。 - 回归:`templateLibraryGrid.test.ts` 覆盖列数/行高/行数/切行;页面测试用固定视口断言「1000 条只渲染 ≤ 40 张卡片,滚动高度仍按 250 行计算」。 - 页面能正常渲染 1000 张卡片(头部显示「共 1000 个模板 · 已下载 335 个」),并且滚动容器生效(窗口高度压到 430px 时右侧出现滚动条,页面内容被裁切而不是溢出到窗口外)。 -- 需要后续收口的两点(本次未改):① 标签筛选条随库量膨胀——1000 条时聚合出 35 个标签、占三行;② 一次性渲染 1000 个卡片节点并触发 1000 次封面请求。建议标签只展示 Top N + 「更多」,卡片列表加分页或虚拟滚动。 +- 仍需关注:① 标签筛选条随库量膨胀——1000 条时聚合出 35 个标签;现为「换行 + `max-h-[20vh]` 滚动」兜底,量大时仍建议只展示 Top N + 「更多」;② 一次性渲染 1000 个卡片节点并触发 1000 次封面请求,建议加封面懒加载上限或分页。 - 前端回归:1000 条渲染 + 已安装过滤(334)/标签过滤(50)/关键词过滤数量自洽,见 `apps/ai-game-creator-shell/tests/templateLibraryView.test.tsx`。 ```bash diff --git a/packages/shared/src/components/PlatformResourceFilterBar.tsx b/packages/shared/src/components/PlatformResourceFilterBar.tsx index a5b685b1c..ee6efd87e 100644 --- a/packages/shared/src/components/PlatformResourceFilterBar.tsx +++ b/packages/shared/src/components/PlatformResourceFilterBar.tsx @@ -1,6 +1,7 @@ import { Search, Tag } from 'lucide-react'; import type { Ref } from 'react'; +import { getPlatformCategoryChipClassName } from './platformCategoryChipModel'; import { PlatformSegmentedTabs } from './PlatformSegmentedTabs'; export type PlatformResourceFilterOption = { @@ -53,15 +54,6 @@ export type PlatformResourceFilterBarProps = onToggleTag: (tag: string) => void; }); -function tagChipClassName(active: boolean) { - return [ - 'platform-category-chip gap-1.5 px-2.5 text-xs font-bold', - active ? 'platform-category-chip--active' : null, - ] - .filter(Boolean) - .join(' '); -} - /** * 资源搜索 + 功能分类 + 标签的筛选条。 * @@ -115,6 +107,9 @@ export function PlatformResourceFilterBar({ frame="bare" surface="transparent" size="sm" + // 选中项与下面的标签 chip 用同一套实心口径:筛选条里「哪个条件在生效」 + // 只允许一种视觉语言。 + tone="accent" className="platform-theme platform-theme--light min-w-0 flex-none" /> {tagOptions.length > 0 ? ( @@ -130,7 +125,7 @@ export function PlatformResourceFilterBar({ key={option.tag} type="button" aria-pressed={active} - className={tagChipClassName(active)} + className={getPlatformCategoryChipClassName(active)} // 类型上 `tagItems` 必然带 `onToggleTag`;这一层兜底是给不带类型检查的 // 调用方(JS / 动态构造的 props):没有处理函数就不该渲染成可点按钮。 disabled={!onToggleTag} @@ -138,7 +133,12 @@ export function PlatformResourceFilterBar({ >