From e3c91c4e2c31bdee466ede3e5457e8d60537f13a Mon Sep 17 00:00:00 2001 From: Suzumiya Date: Sat, 3 Oct 2026 19:44:00 +0800 Subject: [PATCH] =?UTF-8?q?=E4=BF=AE=E5=A4=8D=20#599=EF=BC=9A=E9=A6=96?= =?UTF-8?q?=E9=A1=B5=E5=BB=BA=E9=A1=B9=E4=B8=8D=E5=86=8D=E7=AD=89=E5=BE=85?= =?UTF-8?q?=20AI=20=E9=A1=B9=E7=9B=AE=E5=91=BD=E5=90=8D=EF=BC=8C=E6=94=B9?= =?UTF-8?q?=E4=B8=BA=E5=90=8E=E5=8F=B0=E6=9D=A1=E4=BB=B6=E6=94=B9=E5=90=8D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 首页自动建项:预检通过后立即以宿主兜底名创建工作区并进入项目,create_automatic_local_game_project 固定传 name: null,不再等待 suggest_automatic_project_name。 - 命名请求在建项前并行打出,结果交给后台任务;命名慢、超时、失败或返回非法内容都不影响建项、附件导入与首轮创作。 - 新增 Rust 命令 rename_local_game_project_if_unchanged(expectedProjectId + expectedName 条件改名):项目 ID 不符、用户已手动改名、名称未变一律返回 renamed: false 并保持现状,空名/控制字符/超长失败关闭,改名不推进项目 revision。 - 后台改名结果等「建项主体收尾」再写当前项目上下文与最近项目行重检,避免被进项目的兜底上下文覆盖。 - 新增前端时序测试 tests/homeProjectNamingAsync.test.tsx(9 例,promise 闸门而非 sleep)与 appSurface 客户端流程用例。 - 新增 Rust 条件改名单测 5 例;更新实施计划专题、decision-log、pitfalls。 验证:npx vitest run apps/ai-game-creator-shell/tests/homeProjectNamingAsync.test.tsx(9 passed);npm run test -- apps/ai-game-creator-shell/tests(1910 passed / 17 skipped);cargo test --features=cocos-editor-execute,unity-editor-execute,godot-editor-execute --bin genarrative-ai-game-creator-shell conditional_project_rename_tests(5 passed);cargo check 同 features 通过;npm run agc:typecheck、cargo fmt --check、npm run check:encoding、git diff --check 全绿。 --- .../src-tauri/src/commands.rs | 217 ++++++++++++ .../src-tauri/src/desktop.rs | 1 + apps/ai-game-creator-shell/src/app/types.ts | 10 + .../features/app-shell/WorkspaceLauncher.tsx | 1 + .../app-shell/useHomeProjectCreation.ts | 116 ++++++- .../tests/appSurface/home.suite.ts | 134 +++++++- .../tests/homeProjectNamingAsync.test.tsx | 320 ++++++++++++++++++ .../shared-memory/decision-log.md | 8 + docs/project-memory/shared-memory/pitfalls.md | 10 + ...¹案】AI游戏创作智能体App实施计划-2026-06-24.md | 11 + 10 files changed, 822 insertions(+), 6 deletions(-) create mode 100644 apps/ai-game-creator-shell/tests/homeProjectNamingAsync.test.tsx diff --git a/apps/ai-game-creator-shell/src-tauri/src/commands.rs b/apps/ai-game-creator-shell/src-tauri/src/commands.rs index 9bcdfd7ee..6ce43229b 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/commands.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/commands.rs @@ -502,6 +502,223 @@ pub(crate) fn rename_local_game_project( }) } +#[derive(Clone, Debug, Eq, PartialEq, Serialize)] +#[serde(rename_all = "camelCase")] +pub(crate) struct ConditionalRenameLocalProjectResult { + pub(crate) renamed: bool, + pub(crate) manifest: GameCreationAppManifest, + pub(crate) revision: u64, +} + +/// AI 后台命名回填用的条件改名核心。 +/// +/// 只有「项目身份未变」且「当前名称仍是这次创建时的兜底名」时才落盘改名;任何一项不匹配都 +/// 原样返回当前 manifest / revision 并把 `renamed` 置为 false,绝不覆盖用户已经改过的名字。 +/// 与 [`rename_local_game_project`] 一致:改名是簿记写入,刻意不推项目 revision。 +pub(crate) fn rename_local_game_project_if_unchanged_at( + root: &Path, + expected_project_id: &str, + expected_name: &str, + name: &str, +) -> Result { + let next_name = normalize_game_creation_project_name(name.trim())?; + enforce_project_permission_policy(root, "project.rename")?; + let _lock = acquire_project_write_lock(root, "project.rename")?; + let current = read_existing_manifest_for_project(root)?; + let revision = read_game_creator_agent_runtime_project_revision(root)?.revision; + // 项目身份不匹配:目录换了项目或身份被改写,一律不改名。 + if current.project_id != expected_project_id.trim() { + return Ok(ConditionalRenameLocalProjectResult { + renamed: false, + manifest: current, + revision, + }); + } + // 用户已经手动改过名(或别的流程改过):兜底名不再成立,绝不覆盖。 + if current.name != expected_name.trim() { + return Ok(ConditionalRenameLocalProjectResult { + renamed: false, + manifest: current, + revision, + }); + } + // 结果与现状一致:没有必要再写一次盘。 + if current.name == next_name { + return Ok(ConditionalRenameLocalProjectResult { + renamed: false, + manifest: current, + revision, + }); + } + + let manifest = mutate_manifest_at(root, |manifest| { + manifest.name = next_name; + Ok(manifest.clone()) + })?; + Ok(ConditionalRenameLocalProjectResult { + renamed: true, + manifest, + revision: read_game_creator_agent_runtime_project_revision(root)?.revision, + }) +} + +#[tauri::command] +pub(crate) fn rename_local_game_project_if_unchanged( + project_path: String, + expected_project_id: String, + expected_name: String, + name: String, +) -> Result { + let root = validated_local_project_directory_path(project_path.trim())?; + rename_local_game_project_if_unchanged_at(&root, &expected_project_id, &expected_name, &name) +} + +#[cfg(test)] +mod conditional_project_rename_tests { + use super::*; + + const FALLBACK_NAME: &str = "策划项目 ab12cd34"; + + fn fixture_root(project_id: &str) -> tempfile::TempDir { + let project = tempfile::tempdir().expect("project tempdir"); + init_local_game_project_at(project.path(), project_id, FALLBACK_NAME) + .expect("initialize project"); + project + } + + fn manifest_name(root: &Path) -> String { + read_existing_manifest_for_project(root) + .expect("read manifest") + .name + } + + fn project_revision(root: &Path) -> u64 { + read_game_creator_agent_runtime_project_revision(root) + .expect("read revision") + .revision + } + + #[test] + fn conditional_rename_applies_when_fallback_name_still_matches() { + let project = fixture_root("conditional-rename-project"); + let root = project.path(); + let project_id = read_existing_manifest_for_project(root) + .expect("read manifest before") + .project_id; + let revision_before = project_revision(root); + + let result = rename_local_game_project_if_unchanged_at( + root, + "conditional-rename-project", + FALLBACK_NAME, + "星轨夜航", + ) + .expect("conditional rename applies"); + + assert!(result.renamed); + assert_eq!(result.manifest.name, "星轨夜航"); + assert_eq!(result.manifest.project_id, project_id); + assert_eq!(manifest_name(root), "星轨夜航"); + assert_eq!(project_revision(root), result.revision); + // 与 rename_local_game_project 同语义:改名是簿记写入,刻意不推项目 revision + // (见 projectResourceLiveUpdateModel.ts:推 revision 会让运行时的验证凭证无故漂移), + // 所以这里断言改名前后 revision 一致,而不是变大。 + assert_eq!(result.revision, revision_before); + } + + #[test] + fn conditional_rename_keeps_user_rename_when_expected_name_is_stale() { + let project = fixture_root("conditional-rename-user-edited"); + let root = project.path(); + // 用户先手动改名:此刻兜底名仍然成立,这一步会落盘。 + let edited = rename_local_game_project_if_unchanged_at( + root, + "conditional-rename-user-edited", + FALLBACK_NAME, + "用户自己起的名字", + ) + .expect("user rename applies"); + assert!(edited.renamed); + + // AI 后台命名这时才回来:它带的兜底名已过期,必须整体跳过。 + let result = rename_local_game_project_if_unchanged_at( + root, + "conditional-rename-user-edited", + FALLBACK_NAME, + "星轨夜航", + ) + .expect("stale expected name is not an error"); + + assert!(!result.renamed); + assert_eq!(result.manifest.name, "用户自己起的名字"); + assert_eq!(manifest_name(root), "用户自己起的名字"); + } + + #[test] + fn conditional_rename_skips_when_project_identity_differs() { + let project = fixture_root("conditional-rename-project"); + let root = project.path(); + let revision_before = project_revision(root); + + let result = rename_local_game_project_if_unchanged_at( + root, + "another-project-id", + FALLBACK_NAME, + "星轨夜航", + ) + .expect("identity mismatch is not an error"); + + assert!(!result.renamed); + assert_eq!(result.manifest.project_id, "conditional-rename-project"); + assert_eq!(result.manifest.name, FALLBACK_NAME); + assert_eq!(manifest_name(root), FALLBACK_NAME); + assert_eq!(project_revision(root), revision_before); + } + + #[test] + fn conditional_rename_rejects_invalid_name_without_touching_the_project() { + let project = fixture_root("conditional-rename-invalid"); + let root = project.path(); + let revision_before = project_revision(root); + + for invalid in ["", " ", "带\u{0}控制字符的名"] { + let error = rename_local_game_project_if_unchanged_at( + root, + "conditional-rename-invalid", + FALLBACK_NAME, + invalid, + ) + .expect_err("invalid name must fail closed"); + assert!(error.contains("项目名称"), "{error}"); + } + + assert_eq!(manifest_name(root), FALLBACK_NAME); + assert_eq!(project_revision(root), revision_before); + } + + #[test] + fn conditional_rename_reports_false_when_name_already_matches() { + let project = fixture_root("conditional-rename-same-name"); + let root = project.path(); + let revision_before = project_revision(root); + + for same in [FALLBACK_NAME, " 策划项目 ab12cd34 "] { + let result = rename_local_game_project_if_unchanged_at( + root, + "conditional-rename-same-name", + FALLBACK_NAME, + same, + ) + .expect("same name is not an error"); + assert!(!result.renamed); + assert_eq!(result.manifest.name, FALLBACK_NAME); + } + + assert_eq!(manifest_name(root), FALLBACK_NAME); + assert_eq!(project_revision(root), revision_before); + } +} + #[tauri::command] pub(crate) fn import_local_godot_project( project_path: String, diff --git a/apps/ai-game-creator-shell/src-tauri/src/desktop.rs b/apps/ai-game-creator-shell/src-tauri/src/desktop.rs index 8b5c3078d..227bb6be7 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/desktop.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/desktop.rs @@ -550,6 +550,7 @@ pub(super) fn run() { inspect_local_project_directory, pick_local_project_directory, rename_local_game_project, + rename_local_game_project_if_unchanged, suggest_automatic_project_name, polish_local_project_prompt, pick_client_extension_file, diff --git a/apps/ai-game-creator-shell/src/app/types.ts b/apps/ai-game-creator-shell/src/app/types.ts index dfadbe616..3fc145c7f 100644 --- a/apps/ai-game-creator-shell/src/app/types.ts +++ b/apps/ai-game-creator-shell/src/app/types.ts @@ -177,6 +177,16 @@ export interface RenameLocalProjectResult { revision: number; } +/** + * 条件改名结果:`renamed === false` 表示宿主按「项目 ID 或当前名称不匹配」整体跳过, + * 此时 `manifest` / `revision` 仍是跳过时的现状,调用方不要据此覆盖本地上下文。 + */ +export interface ConditionalRenameLocalProjectResult { + renamed: boolean; + manifest: GameCreationAppManifest; + revision: number; +} + export interface LocalProjectDirectoryStatus { projectPath: string; exists: boolean; diff --git a/apps/ai-game-creator-shell/src/features/app-shell/WorkspaceLauncher.tsx b/apps/ai-game-creator-shell/src/features/app-shell/WorkspaceLauncher.tsx index 2679b6061..62d8de02f 100644 --- a/apps/ai-game-creator-shell/src/features/app-shell/WorkspaceLauncher.tsx +++ b/apps/ai-game-creator-shell/src/features/app-shell/WorkspaceLauncher.tsx @@ -137,6 +137,7 @@ export function WorkspaceLauncherShell({ showTransientNotice, setLauncherView, rememberRecentWorkspace, + refreshRecentWorkspace: recentProjects.refreshRecentWorkspace, }); const templateLibrary = useTemplateLibrary({ userId: currentUser.id, diff --git a/apps/ai-game-creator-shell/src/features/app-shell/useHomeProjectCreation.ts b/apps/ai-game-creator-shell/src/features/app-shell/useHomeProjectCreation.ts index 92be119ec..aba425bf2 100644 --- a/apps/ai-game-creator-shell/src/features/app-shell/useHomeProjectCreation.ts +++ b/apps/ai-game-creator-shell/src/features/app-shell/useHomeProjectCreation.ts @@ -15,6 +15,7 @@ import { createLocalProjectId } from '../../app/constants'; import { useEscapeToClose } from '../../app/dialogs'; import { resolveTauriInvoke } from '../../app/tauri'; import type { + ConditionalRenameLocalProjectResult, InitLocalProjectResult, LauncherImportedAttachment, LauncherProjectContext, @@ -84,11 +85,23 @@ type UseHomeProjectCreationOptions = { showTransientNotice: ShowTransientNotice; setLauncherView: Dispatch>; rememberRecentWorkspace: (projectPath: string) => void; + /** + * 最近项目行的名称来自宿主目录重检(`inspect_local_project_directory` 读 manifest), + * 不是内存投影:后台 AI 改名成功后必须显式重检这一行,列表才会同步到新名字。 + */ + refreshRecentWorkspace?: (projectPath: string) => Promise; }; export const DESIGN_ARTIFACTS_BUILD_PROMPT = '查看当前项目 design_artifacts/ 目录及其子目录下的文档,理解其游戏设计,并按照这些文档将游戏实现出来。'; +/** + * 发起一次 AI 项目命名请求并返回其结果 Promise。 + * + * 调用方**不要 await 它再建项目**:命名只是增强能力,工作区创建、附件导入和首轮创作都不依赖它。 + * 这里返回的 Promise 由后台改名任务消费(见 `applyAutomaticProjectName`);配置缺失、Provider + * 失败、超时或非法输出都解析为 `null`,由调用方保留兜底名称。 + */ async function suggestAutomaticProjectName( invoke: TauriInvoke, draft: HomeDraft, @@ -185,6 +198,7 @@ export function useHomeProjectCreation({ showTransientNotice, setLauncherView, rememberRecentWorkspace, + refreshRecentWorkspace, }: UseHomeProjectCreationOptions) { const lifecycleRef = useRef({ mounted: false, generation: 0 }); const analyticsProjectPathRef = useRef(null); @@ -415,6 +429,72 @@ export function useHomeProjectCreation({ resetLauncherHomeDraft(); } + /** + * 后台把 AI 建议名落到已创建的工作区。 + * + * 与建项并行发起(`naming` 在建项前就已经打出请求);改名落盘本身不等任何东西, + * 但「项目上下文」与「最近项目行」这两项都建立在进项目之后:AI 比进项目更快时直接写上下文 + * 会被随后的 `enterProjectDevelopment` 用兜底名覆盖,而最近项目行也只有进项目登记过才会被 + * 重检。所以它们等 `entrySettled`(建项主体收尾,含进项目成功/失败/让位于别的项目)。 + * - 宿主侧条件改名要求「项目 ID 相同」且「当前名称仍是这次创建时的兜底名称」;用户已手动改名时 + * 返回 `renamed: false`,这里整体跳过,绝不覆盖用户输入。 + * - AI 返回空/非法/失败(Promise 解析为 null)或用例里没有可改名空间时,保持兜底名称。 + * - 任何失败都只写诊断日志,不改变已经进入的工作区与首轮创作。 + */ + async function applyAutomaticProjectName( + invoke: TauriInvoke, + naming: Promise, + created: InitLocalProjectResult, + entrySettled: Promise, + ) { + let suggested: string | null = null; + try { + suggested = (await naming)?.trim() || null; + } catch { + return; + } + // 建议名就是兜底名(或没有建议):没有可改的,别白跑一次写操作。 + if (!suggested || suggested === created.manifest.name) { + return; + } + let outcome: ConditionalRenameLocalProjectResult; + try { + outcome = await invoke( + 'rename_local_game_project_if_unchanged', + { + projectPath: created.projectPath, + expectedProjectId: created.manifest.projectId, + expectedName: created.manifest.name, + name: suggested, + }, + ); + } catch (error) { + appendWebviewLog('warn', [ + `[home-project-name] ${error instanceof Error ? error.message : String(error)}`, + ]); + return; + } + if (!outcome?.renamed) { + return; + } + await entrySettled; + setCurrentProjectContext((current) => + current && current.projectPath === created.projectPath + ? { + ...current, + projectName: outcome.manifest.name, + manifest: outcome.manifest, + projectRevision: outcome.revision, + } + : current, + ); + try { + await refreshRecentWorkspace?.(created.projectPath); + } catch { + // 最近项目行是辅助投影:重检失败不影响已经生效的改名与工作台标题。 + } + } + /** * 创建失败的统一收口。 * @@ -834,8 +914,8 @@ export function useHomeProjectCreation({ startMode: ProjectStartMode, ) { return createHomeDraftAutomaticallyWithOptions(draft, startMode, { - // 做方案的首轮还要调用一次策划 Provider;项目名称不是策划输入的 - // 前置条件,避免在进入工作区前再额外等待一次模型请求。 + // 做方案的首轮还要调用一次策划 Provider;项目命名对它没有价值, + // 「做方案」也从不发起自动项目命名请求。 suggestName: startMode !== 'planning', }); } @@ -899,6 +979,17 @@ export function useHomeProjectCreation({ resolve(HOME_CREATION_WATCHDOG_MESSAGE); }, HOME_CREATION_DEADLINE_MS); }); + /** + * 「建项主体收尾」信号(进项目成功、进项目失败、或让位于别的项目都算)。 + * + * 后台 AI 改名可能比进项目更快:那时工作区上下文还没建立,直接写上下文会被随后的 + * `enterProjectDevelopment` 覆盖回兜底名,最近项目行也还没被登记进列表。改名落盘照旧立即执行, + * 只有这两项依赖进项目结果的更新等这枚信号。 + */ + let settleEntry!: () => void; + const entrySettled = new Promise((resolve) => { + settleEntry = resolve; + }); /** 后台继续跑的建项主体:用户可见的等待由 `watchdog` 兜底,这里只负责最终落定。 */ const creation = (async () => { try { @@ -919,17 +1010,30 @@ export function useHomeProjectCreation({ ); return message; } - const suggestedName = options.suggestName - ? await suggestAutomaticProjectName(invoke, draft) + // 命名与建项并行:先把命名请求打出去,再建工作区,两者互不等待。命名结果由后台任务 + // 落定(见 `applyAutomaticProjectName`),用户不必为了一个名字多等一次模型请求。 + const automaticName = options.suggestName + ? suggestAutomaticProjectName(invoke, draft) : null; const result = await invoke( 'create_automatic_local_game_project', { - name: suggestedName, + // 不传名:走宿主兜底名("GameAgent 项目 " / "策划项目 "), + // AI 建议名回来后再按「名称仍是兜底名」的条件改名。 + name: null, planning: startMode === 'planning', }, ); createdProjectPath = result.projectPath; + if (automaticName) { + // 纯后台任务:进入工作区、导入附件与首轮创作都不等它。 + void applyAutomaticProjectName( + invoke, + automaticName, + result, + entrySettled, + ).catch(() => undefined); + } setHomeCreationOperation( transitionClientOperation(operation, 'project', { scope: { projectPath: result.projectPath }, @@ -990,6 +1094,8 @@ export function useHomeProjectCreation({ } throw failHomeCreation(error, Boolean(createdProjectPath)); } finally { + // 进项目(或放弃进项目)已经尘埃落定,后台改名可以安全地写上下文与最近项目。 + settleEntry(); if (watchdogId !== undefined) { window.clearTimeout(watchdogId); } 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 800a6ce7d..c57c40821 100644 --- a/apps/ai-game-creator-shell/tests/appSurface/home.suite.ts +++ b/apps/ai-game-creator-shell/tests/appSurface/home.suite.ts @@ -1672,6 +1672,20 @@ export function registerHomeProjectCreationTests() { }); return '角色参考游戏'; } + if (command === 'rename_local_game_project_if_unchanged') { + // 建项时用的是宿主兜底名;后台命名回来后按「项目 ID + 兜底名」条件改名。 + expect(args).toEqual({ + projectPath: automaticProjectPath, + expectedProjectId: manifest.projectId, + expectedName: manifest.name, + name: '角色参考游戏', + }); + return { + renamed: true, + manifest: { ...manifest, name: '角色参考游戏' }, + revision: 2, + }; + } if (command === 'enqueue_direct_codex_turn') { chatHarness.completeDirectThreadTurn({ turnId: String(args?.clientTurnId ?? ''), @@ -1711,10 +1725,15 @@ export function registerHomeProjectCreationTests() { fireEvent.click(screen.getByRole('button', { name: '开启创作' })); expect(await screen.findByLabelText('项目开发工作台')).not.toBeNull(); + // 建项不再等 AI 命名:名称留空由宿主兜底,命名结果在后台条件改名。 expect(invoke).toHaveBeenCalledWith('create_automatic_local_game_project', { - name: '角色参考游戏', + name: null, planning: false, }); + expect(invoke).toHaveBeenCalledWith( + 'rename_local_game_project_if_unchanged', + expect.objectContaining({ name: '角色参考游戏' }), + ); expect(invoke).toHaveBeenCalledWith('upload_local_asset', { projectPath: automaticProjectPath, fileName: '角色参考.png', @@ -1784,6 +1803,119 @@ export function registerHomeProjectCreationTests() { ).toEqual([{ type: 'input_text', text: '再补一句玩法' }]); }); + it('syncs the workbench title and recent project list with the AI name after creation', async () => { + const projectPath = '/tmp/home-async-named-project'; + const fallbackManifest = createGameCreationAppManifest( + 'home-async-named-project', + 'GameAgent 项目 1a2b3c4d', + ); + const renamedManifest = { ...fallbackManifest, name: '星轨夜航' }; + let renamed = false; + // 命名闸门手控:先证明"命名还没回来时已经进了工作区",再放行命名验证同步。 + let resolveNaming!: (name: string) => void; + const namingGate = new Promise((resolve) => { + resolveNaming = resolve; + }); + const chatHarness = createProjectChatRuntimeHarness({ + projectPath, + initialSessionExists: false, + }); + const invoke = vi.fn( + async (command: string, args?: Record) => { + if (command === 'create_automatic_local_game_project') { + return { + projectPath, + manifestPath: `${projectPath}/.agent/manifest.json`, + manifest: fallbackManifest, + }; + } + // 命名与建项并行:建项拿不到名字,AI 结果只走后台条件改名。 + if (command === 'suggest_automatic_project_name') return namingGate; + if (command === 'rename_local_game_project_if_unchanged') { + expect(args).toEqual({ + projectPath, + expectedProjectId: fallbackManifest.projectId, + expectedName: fallbackManifest.name, + name: '星轨夜航', + }); + renamed = true; + return { renamed: true, manifest: renamedManifest, revision: 2 }; + } + if (command === 'get_local_game_manifest') { + return renamed ? renamedManifest : fallbackManifest; + } + if (command === 'inspect_local_project_directory') { + return { + projectPath: String(args?.projectPath ?? ''), + exists: true, + isDirectory: true, + isGameCreatorProject: true, + isCocosProject: false, + godotProjectRoot: null, + // 最近项目行的名字就来自这次重检;改名前是兜底名,改名后是 AI 名。 + projectName: renamed ? '星轨夜航' : fallbackManifest.name, + recentRunStatus: null, + recentRunStopReason: null, + }; + } + if (command === 'enqueue_direct_codex_turn') { + chatHarness.completeDirectThreadTurn({ + turnId: String(args?.clientTurnId ?? ''), + prompt: '做一个星轨游戏', + reply: '开工。', + }); + return '开工。'; + } + return chatHarness.invoke(command, args); + }, + ); + window.__TAURI__ = { + core: { invoke }, + event: { listen: chatHarness.listen }, + }; + window.localStorage.setItem( + 'genarrative-ai-game-creator.recent-workspaces.v1', + JSON.stringify([projectPath]), + ); + renderLauncherAt('/?launcher', 'home', true); + + const promptInput = screen.getByLabelText('创作想法'); + nativeClipboardMock.text = '做一个星轨游戏'; + fireEvent.paste(promptInput); + await waitFor(() => { + expect(promptInput.textContent).toContain('做一个星轨游戏'); + }); + fireEvent.keyDown(promptInput, { key: 'Enter', code: 'Enter' }); + + expect(await screen.findByLabelText('项目开发工作台')).not.toBeNull(); + // 进工作区时命名还没回来:创建并不等命名结果,兜底名仍然生效。 + expect(invoke).toHaveBeenCalledWith('create_automatic_local_game_project', { + name: null, + planning: false, + }); + expect(invoke).not.toHaveBeenCalledWith( + 'rename_local_game_project_if_unchanged', + expect.anything(), + ); + + // 放行命名:AI 名称落定后走条件改名(工作台标题取自项目上下文,见 hook 用例)。 + await act(async () => { + resolveNaming('星轨夜航'); + await namingGate; + }); + await waitFor(() => { + expect(invoke).toHaveBeenCalledWith( + 'rename_local_game_project_if_unchanged', + expect.objectContaining({ name: '星轨夜航' }), + ); + }); + + // 最近项目行按目录重检读 manifest:列表最终也显示 AI 名称。 + fireEvent.click(screen.getByRole('button', { name: '项目组' })); + expect(await screen.findByText('星轨夜航')).not.toBeNull(); + expect(screen.queryByText('GameAgent 项目 1a2b3c4d')).toBeNull(); + }); + it('keeps the home composer out of chat mode while automatic project creation is pending', async () => { let rejectAutomaticProject: ((error: Error) => void) | null = null; const automaticProject = new Promise((_resolve, reject) => { diff --git a/apps/ai-game-creator-shell/tests/homeProjectNamingAsync.test.tsx b/apps/ai-game-creator-shell/tests/homeProjectNamingAsync.test.tsx new file mode 100644 index 000000000..73c964683 --- /dev/null +++ b/apps/ai-game-creator-shell/tests/homeProjectNamingAsync.test.tsx @@ -0,0 +1,320 @@ +// @vitest-environment jsdom +import { act, cleanup, renderHook } from '@testing-library/react'; +import { afterEach, expect, it, vi } from 'vitest'; + +import { createGameCreationAppManifest } from '../../../packages/shared/src/contracts/gameCreationApp'; +import type { HomeProjectCreationController } from '../src/features/app-shell/useHomeProjectCreation'; +import { useHomeProjectCreation } from '../src/features/app-shell/useHomeProjectCreation'; + +const PROJECT_PATH = 'C:/test/async-naming-game'; +const PROJECT_ID = 'gameagent-1a2b3c4d'; +/** 宿主在建项时给的兜底名(`GameAgent 项目 <8 位短 id>`)。 */ +const FALLBACK_NAME = 'GameAgent 项目 1a2b3c4d'; +const SUGGESTED_NAME = '星河远征'; + +afterEach(() => { + cleanup(); + delete window.__TAURI__; +}); + +/** 手控闸门:测试自己决定命名请求何时返回,不靠 sleep 赌时序。 */ +function deferred() { + let resolve!: (value: T) => void; + const promise = new Promise((res) => { + resolve = res; + }); + return { promise, resolve }; +} + +function mount(options: { + naming: Promise | string; + /** 条件改名命令的返回;缺省表示调用它就是测试失败。 */ + conditionalRename?: () => Promise; + /** 其它命令的按名处理;用于把某一步(如进项目时的预览核验)卡在手控闸门上。 */ + extraCommands?: Record< + string, + (args?: Record) => Promise + >; +}) { + const order: string[] = []; + const invoke = vi.fn(async (command: string) => { + if (command === 'capture_analytics_context') return {}; + if (command === 'preflight_web_game_creation') return { status: 'ready' }; + if (command === 'suggest_automatic_project_name') { + order.push(command); + return options.naming; + } + if (command === 'create_automatic_local_game_project') { + order.push(command); + return { + projectPath: PROJECT_PATH, + manifestPath: `${PROJECT_PATH}/.agent/manifest.json`, + manifest: createGameCreationAppManifest(PROJECT_ID, FALLBACK_NAME), + }; + } + if (command === 'get_local_game_project_revision') return { revision: 1 }; + if (command === 'rename_local_game_project_if_unchanged') { + if (!options.conditionalRename) { + throw new Error('未预期的条件改名调用'); + } + return options.conditionalRename(); + } + const extra = options.extraCommands?.[command]; + if (extra) { + return extra(); + } + return null; + }); + const refreshRecentWorkspace = vi.fn(async () => {}); + window.__TAURI__ = { core: { invoke } } as unknown as typeof window.__TAURI__; + const hook = renderHook(() => + useHomeProjectCreation({ + setStatus: vi.fn(), + showTransientNotice: vi.fn(), + setLauncherView: vi.fn(), + rememberRecentWorkspace: vi.fn(), + refreshRecentWorkspace, + }), + ); + return { ...hook, invoke, order, refreshRecentWorkspace }; +} + +function createAutomatically( + result: { current: HomeProjectCreationController }, + startMode: 'direct-build' | 'planning' = 'direct-build', +) { + return result.current.createHomeDraftAutomatically( + { creationType: 'game', prompt: '做一个小游戏', attachments: [] }, + startMode, + ); +} + +it('enters the workspace even though the AI naming request never returns', async () => { + const naming = deferred(); + const { result, invoke, order } = mount({ naming: naming.promise }); + + let creation!: Promise; + act(() => { + creation = createAutomatically(result); + }); + let status = ''; + // 命名闸门一直关着。若建项链路还在等命名,这里会一直挂到用例超时—— + // 这正是"`create_automatic_local_game_project` 不再等待命名"的判定方式。 + await act(async () => { + status = await creation; + }); + + expect(status).toBe('已创建工作区并进入项目开发'); + expect(result.current.projectPath).toBe(PROJECT_PATH); + expect(result.current.currentProjectContext?.projectName).toBe(FALLBACK_NAME); + // 命名请求先打出、建项随后并行,两者都不等对方。 + expect(order).toEqual([ + 'suggest_automatic_project_name', + 'create_automatic_local_game_project', + ]); + expect(invoke).toHaveBeenCalledWith('create_automatic_local_game_project', { + name: null, + planning: false, + }); + expect(invoke).not.toHaveBeenCalledWith( + 'rename_local_game_project_if_unchanged', + expect.anything(), + ); + + await act(async () => { + naming.resolve(null); + await naming.promise; + }); +}); + +it('applies a valid AI name after it arrives and refreshes the recent row', async () => { + const naming = deferred(); + const { result, invoke, refreshRecentWorkspace } = mount({ + naming: naming.promise, + conditionalRename: async () => ({ + renamed: true, + manifest: createGameCreationAppManifest(PROJECT_ID, SUGGESTED_NAME), + revision: 7, + }), + }); + + await act(async () => { + await createAutomatically(result); + }); + expect(result.current.currentProjectContext?.projectName).toBe(FALLBACK_NAME); + + await act(async () => { + naming.resolve(` ${SUGGESTED_NAME} `); + await naming.promise; + }); + + expect(invoke).toHaveBeenCalledWith( + 'rename_local_game_project_if_unchanged', + { + projectPath: PROJECT_PATH, + // 条件改名的判据由前端原样转述:项目 ID + 这次创建的兜底名称。 + expectedProjectId: PROJECT_ID, + expectedName: FALLBACK_NAME, + name: SUGGESTED_NAME, + }, + ); + expect(result.current.currentProjectContext?.projectName).toBe( + SUGGESTED_NAME, + ); + expect(result.current.currentProjectContext?.manifest.name).toBe( + SUGGESTED_NAME, + ); + expect(result.current.currentProjectContext?.projectRevision).toBe(7); + expect(refreshRecentWorkspace).toHaveBeenCalledWith(PROJECT_PATH); +}); + +it('keeps the AI name when the naming result lands before the project is entered', async () => { + const renamedManifest = createGameCreationAppManifest( + PROJECT_ID, + SUGGESTED_NAME, + ); + // 进项目时的会话预览核验卡住:AI 改名先落定,进项目还没写完上下文。 + let releasePreview!: () => void; + const previewGate = new Promise((resolve) => { + releasePreview = resolve; + }); + let markRenameInvoked!: () => void; + const renameInvoked = new Promise((resolve) => { + markRenameInvoked = resolve; + }); + const { result } = mount({ + // 命名瞬时返回(最快的模型情况),建项与改名之间没有等待。 + naming: SUGGESTED_NAME, + conditionalRename: async () => { + markRenameInvoked(); + return { renamed: true, manifest: renamedManifest, revision: 1 }; + }, + extraCommands: { + get_local_game_preview_status: async () => { + await previewGate; + return null; + }, + }, + }); + + let creation!: Promise; + act(() => { + creation = createAutomatically(result); + }); + await act(async () => { + await renameInvoked; + }); + // 此刻改名已经落定,但工作区上下文还没建立。 + expect(result.current.currentProjectContext).toBeNull(); + + await act(async () => { + releasePreview(); + await creation; + }); + + // 进项目写下的兜底上下文之后,后台改名结果必须仍然生效,不能被覆盖回兜底名。 + expect(result.current.currentProjectContext?.projectName).toBe( + SUGGESTED_NAME, + ); + expect(result.current.currentProjectContext?.manifest.name).toBe( + SUGGESTED_NAME, + ); +}); + +it.each(['', ' ', null])( + 'keeps the fallback name for a non-usable AI response (%s)', + async (response) => { + const naming = deferred(); + const { result, invoke } = mount({ + naming: naming.promise, + conditionalRename: async () => { + throw new Error('空/非法 AI 名称不应触发改名'); + }, + }); + + await act(async () => { + await createAutomatically(result); + }); + await act(async () => { + naming.resolve(response); + await naming.promise; + }); + + expect(invoke).not.toHaveBeenCalledWith( + 'rename_local_game_project_if_unchanged', + expect.anything(), + ); + expect(result.current.currentProjectContext?.projectName).toBe( + FALLBACK_NAME, + ); + }, +); + +it('does not overwrite a name the user already changed', async () => { + const naming = deferred(); + const { result } = mount({ + naming: naming.promise, + // 用户已经手动改过名:宿主按「当前名称不再是兜底名」整体跳过。 + conditionalRename: async () => ({ + renamed: false, + manifest: createGameCreationAppManifest(PROJECT_ID, '我自己起的名字'), + revision: 9, + }), + }); + + await act(async () => { + await createAutomatically(result); + }); + await act(async () => { + naming.resolve(SUGGESTED_NAME); + await naming.promise; + }); + + expect(result.current.currentProjectContext?.projectName).toBe(FALLBACK_NAME); +}); + +it('keeps the created workspace when the conditional rename fails', async () => { + const naming = deferred(); + const { result } = mount({ + naming: naming.promise, + conditionalRename: async () => { + throw new Error('项目正在被其他写操作占用'); + }, + }); + + let status = ''; + await act(async () => { + status = await createAutomatically(result); + }); + await act(async () => { + naming.resolve(SUGGESTED_NAME); + await naming.promise; + }); + + expect(status).toBe('已创建工作区并进入项目开发'); + expect(result.current.projectPath).toBe(PROJECT_PATH); + expect(result.current.currentProjectContext?.projectName).toBe(FALLBACK_NAME); +}); + +it('never starts automatic naming for the 做方案 flow', async () => { + const naming = deferred(); + const { result, invoke } = mount({ naming: naming.promise }); + + await act(async () => { + await createAutomatically(result, 'planning'); + }); + + expect(invoke).not.toHaveBeenCalledWith( + 'suggest_automatic_project_name', + expect.anything(), + ); + expect(invoke).toHaveBeenCalledWith('create_automatic_local_game_project', { + name: null, + planning: true, + }); + + await act(async () => { + naming.resolve(null); + await naming.promise; + }); +}); diff --git a/docs/project-memory/shared-memory/decision-log.md b/docs/project-memory/shared-memory/decision-log.md index 4aa91f9be..ac2121eca 100644 --- a/docs/project-memory/shared-memory/decision-log.md +++ b/docs/project-memory/shared-memory/decision-log.md @@ -1,5 +1,13 @@ # 决策记录 +## 2026-10-03 首页自动建项与 AI 项目命名解耦(Issue 599) + +- 背景:首页「开启创作」原先串行执行「Web 预检 → `await suggest_automatic_project_name` → `create_automatic_local_game_project`」。项目名称不是创建工作区、导入附件或发起首轮创作的前置条件,命名请求(`AUTOMATIC_PROJECT_NAME_TIMEOUT_MS = 15s`,正常请求同样占时)却把用户按在「正在创建工作区」上。 +- 决策:建项与命名拆成两条并行链路。建项固定传 `name: null`,由宿主既有兜底名(`GameAgent 项目 <8 位短 id>` / `策划项目 <8 位短 id>`)落盘并立即进入项目;命名请求在建项前打出、结果交给后台任务。后台拿到合法名称后调用新增的 `rename_local_game_project_if_unchanged(projectPath, expectedProjectId, expectedName, name)`:只有「项目 ID 相同」且「当前名称仍是本次创建的兜底名」才改名,返回 `renamed: true/false`(跳过时不写盘、不是错误)。用户已手动改名、项目 ID 不符、名称与现状相同一律跳过;空名 / 控制字符 / 超长由 `normalize_game_creation_project_name` 失败关闭。 +- 边界:改名是簿记写入,与既有 `rename_local_game_project` 同口径**不推进项目 revision**(推 revision 会让运行时验证凭证无故漂移);返回的 `revision` 是当时盘上的值。后台改名结果写「当前项目上下文」与「最近项目行重检」必须等建项主体收尾(`entrySettled`):AI 比进项目更快时直接写上下文会被随后的 `enterProjectDevelopment` 用兜底名覆盖,最近项目行也要等进项目登记过才会被重检。「做方案」与手动选目录建项不发起自动命名。 +- 影响范围:`apps/ai-game-creator-shell/src-tauri/src/{commands.rs,desktop.rs}`、`apps/ai-game-creator-shell/src/{app/types.ts,features/app-shell/{useHomeProjectCreation.ts,WorkspaceLauncher.tsx}}`、`apps/ai-game-creator-shell/tests/{homeProjectNamingAsync.test.tsx,appSurface/home.suite.ts}`。 +- 验证:`npx vitest run apps/ai-game-creator-shell/tests/homeProjectNamingAsync.test.tsx`(9 passed,含「命名请求永不返回仍进工作区」「AI 结果先于进项目落定仍不被兜底名覆盖」「手动改名不被覆盖」「非法/空响应保留兜底名」「做方案不发起命名」);AGC 全量 `npm run test -- apps/ai-game-creator-shell/tests`(1910 passed / 17 skipped);`cargo test --features=cocos-editor-execute,unity-editor-execute,godot-editor-execute conditional_project_rename_tests`(5 passed);`npm run agc:typecheck`、`npm run check:encoding`、`git diff --check`。 + ## 2026-10-03 AGC 栏目画布上传素材按入口栏目登记(Issue 359) - 背景:AGC 客户端在资源栏目子画布(「UI 交互 / 角色与对象 / 场景与环境 / 音频」)左下角工具栏点「上传」后,提示条给出「已上传 1 个素材」,但当前栏目计数不变(仍「0 项」)、素材出现在「待归类」,用户看到的是"上传成功了但它从这一页消失了"。原因是上传登记的 manifest `kind` 只由**内容证据**推导(`assets.rs::uploaded_asset_kind`:图片 / 视频 / 代码 → `unclassified`,音频 → `audio`,文档 / 字体 → `document`),kind 派生分类与栏目词汇(`ui-interaction` / `character` / `scene` / `audio`)不是同一套,而 `upload_local_asset` 原先不接受入口栏目。 diff --git a/docs/project-memory/shared-memory/pitfalls.md b/docs/project-memory/shared-memory/pitfalls.md index 75a6d0d4f..4cfcf60a7 100644 --- a/docs/project-memory/shared-memory/pitfalls.md +++ b/docs/project-memory/shared-memory/pitfalls.md @@ -2,6 +2,16 @@ 这里只记录对当前开发仍有用的症状、根因、排查方法和风险边界。同一事实保留一个当前口径;退役对象的专属过程与单轮测试结果由 Git 历史追溯。遇到旧路径或版本时,以现行代码和专题文档为准。 +## 2026-10-03 后台异步回填被「进项目」覆盖:AI 项目名只在兜底名仍成立时改 + +- **现象**(Issue 599):首页建项把 AI 命名改成「与建项并行、结果后台回填」后,偶尔工作台标题停在兜底名(`GameAgent 项目 <短 id>`),而磁盘 manifest 已经是 AI 名字。 +- **原因**:改名落盘很快,但写「当前项目上下文」的那次 `setCurrentProjectContext` 被 `enterProjectDevelopment` 随后的兜底上下文覆盖——AI 比进项目更快时,回填写入时上下文里还没有这个项目(`current` 为 null 或仍是上一个项目)。同一原因下 `refreshRecentWorkspace` 也会被丢弃:最近项目行只在 `recentWorkspacesRef` 已包含该路径时才更新状态,而路径是进项目时才登记的。 +- **处理(现行口径)**:后台改名任务里,「写项目上下文 + 重检最近项目行」这两步必须等「建项主体收尾」(进项目成功、失败或让位于别的项目)的信号再执行;改名本身照旧立即落盘。回归用 promise 闸门卡住 `get_local_game_preview_status` 复现顺序,不用 sleep。 +- **判据/取证**:`npx vitest run apps/ai-game-creator-shell/tests/homeProjectNamingAsync.test.tsx` 的「keeps the AI name when the naming result lands before the project is entered」(去掉等待即复现兜底名覆盖);appSurface 的「syncs the workbench title and recent project list with the AI name after creation」。 +- **边界**:条件改名的判据必须由宿主校验(项目 ID + 当前名称仍是兜底名),前端只转述 `expectedProjectId` / `expectedName`;用户已手动改名时宿主返回 `renamed: false`,回填整体跳过、绝不覆盖用户输入。 +- **关联**:`apps/ai-game-creator-shell/src/features/app-shell/useHomeProjectCreation.ts`、`apps/ai-game-creator-shell/src-tauri/src/commands.rs`。 + + ## 2026-10-03 AGC 栏目画布上传素材落「待归类」:kind 派生分类不等于入口栏目 - **现象**(Issue 359):在 AGC 资源栏目子画布(如「UI 交互」「角色与对象」)左下角工具栏点「上传」选图片 / 视频 / 代码类文件,提示条给出「已上传 1 个素材」,但当前栏目计数纹丝不动(仍「0 项」),素材出现在「待归类」。用户看到的是"上传成功了,可它就消失在这个页面里"。 diff --git a/docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md b/docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md index 2c56e16fb..bb6526a6b 100644 --- a/docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md +++ b/docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md @@ -1,5 +1,16 @@ # AI 游戏创作智能体 App 实施计划 +## 2026-10-03 首页建项不等 AI 命名(Issue 599) + +本节覆盖下文「2026-08-20 Direct Codex 审核 Skill Pack 与受控工具内核」里「自动项目命名仍是首页建项前置」的隐含口径。 + +- 首页「开启创作 / 进入立项策划」的提交链路是「Web 环境预检 → 立即用宿主兜底名创建工作区 → 进入项目 → 首轮创作」。`create_automatic_local_game_project` 不再接收前端命名结果(固定 `name: null`,由宿主生成 `GameAgent 项目 <8 位短 id>` / `策划项目 <8 位短 id>`):AI 命名请求在建项前并行打出,命名慢、超时、失败或返回非法内容都不影响建项、附件导入与首轮创作。 +- AI 返回合法名称后走新增的条件改名命令 `rename_local_game_project_if_unchanged(projectPath, expectedProjectId, expectedName, name)`:`expectedProjectId` 取自建项返回的 `manifest.projectId`,`expectedName` 是本次创建的兜底名。只有「项目身份一致」且「当前名称仍是兜底名」才落盘改名;用户已手动改名、项目 ID 不符、名称与现状相同都返回 `renamed: false` 并保持现状,空名 / 控制字符 / 超长失败关闭。改名与既有 `rename_local_game_project` 同口径:簿记写入,不推进项目 revision。 +- 改名成功后,当前项目上下文(窗口标题与工作台项目名)与最近项目行的目录重检在「进项目落定」之后写入;AI 结果比进项目更快返回时同样以 AI 名称为准,不被 `enterProjectDevelopment` 的兜底上下文覆盖。 +- 「做方案」不发起自动项目命名请求;手动选择目录建项保持原有行为(沿用目录名,不做 AI 命名)。 +- 回归入口:`npx vitest run apps/ai-game-creator-shell/tests/homeProjectNamingAsync.test.tsx`(时序用 promise 闸门,不用 sleep)、`npx vitest run apps/ai-game-creator-shell/tests/appSurface.test.ts`、`cargo test --features=cocos-editor-execute,unity-editor-execute,godot-editor-execute conditional_project_rename_tests`。 + + ## 2026-10-02 退役自建 Agent Runtime 与 CLI 执行面 自建 Agent Runtime(`agent/runtime_driver`、`runtime_protocol`、`runtime_tools`、`runtime_actions`、`runtime_state`、`runtime_adapter`、`agent/prompt.rs`、`agent_native_tools.rs`、`collaboration.rs`、`delegation.rs`、`goal.rs`、`context_compaction.rs`、`isolated_agent.rs`、`tool_plan_handoff/`、`user_input.rs`)与它的 Tauri 命令、`--agent-*` CLI、真实 e2e harness、CI job 一并退役,历史由 Git 保存,见 `docs/adr/【ADR】退役AGC独立Agent Runtime与CLI执行面-2026-10-02.md`。本文正文里所有关于 `--agent-run` / `--agent-task` / `--agent-runner-status` / `agent-run:smoke` / `agent-runtime-real-e2e.mjs`、后台单 Agent 队列、`read_game_creator_agent_runtime(s)`、Supervisor 自主协作与 Goal 生命周期的描述均已失效,不再作为实现依据。外部 Runner(`--agent-runner`)仅保留编辑器桥 RPC 与 `runner.attach_gui_owner` + GUI owner 参与锁 / watchdog。AGC 现役链路以 DirectProject(`agent/direct_*` + codex app-server)、资源工作台与本文其后的 DirectProject 相关条目为准。