From e3c91c4e2c31bdee466ede3e5457e8d60537f13a Mon Sep 17 00:00:00 2001 From: Suzumiya Date: Sat, 3 Oct 2026 19:44:00 +0800 Subject: [PATCH 1/7] =?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 相关条目为准。 -- 2.52.0 From 6c7033221d820be01c13fb3430ebc487a08d1bb9 Mon Sep 17 00:00:00 2001 From: Suzumiya Date: Sun, 4 Oct 2026 00:11:25 +0800 Subject: [PATCH 2/7] =?UTF-8?q?=E8=A1=A5=E5=85=85=20#599=20=E8=87=AA?= =?UTF-8?q?=E5=AE=A1=E5=9B=9E=E5=BD=92=E7=94=A8=E4=BE=8B=EF=BC=9A=E7=94=A8?= =?UTF-8?q?=E6=88=B7=E5=B7=B2=E5=88=87=E5=88=B0=E5=88=AB=E7=9A=84=E5=B7=A5?= =?UTF-8?q?=E4=BD=9C=E5=8C=BA=E6=97=B6=E5=90=8E=E5=8F=B0=E6=94=B9=E5=90=8D?= =?UTF-8?q?=E4=B8=8D=E5=8A=AB=E6=8C=81=E4=B8=8A=E4=B8=8B=E6=96=87?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 新增用例 does not hijack the project the user already moved to when the AI name lands:后台 AI 名称落定时若当前项目上下文已是另一个工作区(看门狗解围后打开别项目走同一条「上下文已被接管」判据),只重检刚建工作区的最近项目行,不把用户切走的上下文换成刚建的工作区。 - 该用例经反向证伪:临时去掉 applyAutomaticProjectName 的项目路径守卫后失败(projectName 由 另一个项目 变成 星河远征),随后已还原源码。 - 自审复核结论(无代码改动):entrySettled 在预检失败、建项失败、看门狗让位、进项目失败、成功五条路径都由建项主体的 finally 落定;看门狗与后台 creation 竞速没有重复 rememberRecentWorkspace、没有未捕获拒绝;expectedName 取建项回传的 manifest.name(与宿主落盘同源,宿主侧 trim 为 no-op);新命令已注册且旧 rename_local_game_project 的调用方未受影响。 - 定向验证:homeProjectNamingAsync 10 passed;appSurface 两条相关用例 passed;cargo conditional_project_rename_tests 5 passed;cargo fmt --check 与 npm run agc:typecheck 通过。 --- .../tests/homeProjectNamingAsync.test.tsx | 47 +++++++++++++++++++ 1 file changed, 47 insertions(+) diff --git a/apps/ai-game-creator-shell/tests/homeProjectNamingAsync.test.tsx b/apps/ai-game-creator-shell/tests/homeProjectNamingAsync.test.tsx index 73c964683..c23103b50 100644 --- a/apps/ai-game-creator-shell/tests/homeProjectNamingAsync.test.tsx +++ b/apps/ai-game-creator-shell/tests/homeProjectNamingAsync.test.tsx @@ -318,3 +318,50 @@ it('never starts automatic naming for the 做方案 flow', async () => { await naming.promise; }); }); + +it('does not hijack the project the user already moved to when the AI name lands', async () => { + const naming = deferred(); + const { result, refreshRecentWorkspace } = mount({ + naming: naming.promise, + conditionalRename: async () => ({ + renamed: true, + manifest: createGameCreationAppManifest(PROJECT_ID, SUGGESTED_NAME), + revision: 1, + }), + }); + + await act(async () => { + await createAutomatically(result); + }); + + // 用户已经切到另一个工作区(看门狗解围后打开别项目走的是同一条「上下文已被别人接管」判据)。 + await act(async () => { + result.current.setCurrentProjectContext({ + projectPath: 'C:/test/other-project', + projectName: '另一个项目', + projectKind: 'web', + manifest: createGameCreationAppManifest('other-project', '另一个项目'), + projectRevision: 3, + creationType: null, + startMode: null, + initialPrompt: '', + attachments: [], + recentRunStatus: null, + recentRunStopReason: null, + createdAt: 1, + }); + }); + + await act(async () => { + naming.resolve(SUGGESTED_NAME); + await naming.promise; + }); + + // 后台改名不能把用户当前所在的项目上下文换成刚建好的那个工作区。 + expect(result.current.currentProjectContext?.projectPath).toBe( + 'C:/test/other-project', + ); + expect(result.current.currentProjectContext?.projectName).toBe('另一个项目'); + // 但刚刚建好的工作区确实改名了:最近项目行照旧按目录重检。 + expect(refreshRecentWorkspace).toHaveBeenCalledWith(PROJECT_PATH); +}); -- 2.52.0 From 4144c04b1c630ecb005f57987784918a33f9496d Mon Sep 17 00:00:00 2001 From: Suzumiya Date: Sun, 4 Oct 2026 00:38:34 +0800 Subject: [PATCH 3/7] =?UTF-8?q?=E8=87=AA=E5=AE=A1=E6=94=B6=E5=8F=A3=20#599?= =?UTF-8?q?=EF=BC=9A=E7=A9=BA=20expected=20=E5=A4=B1=E8=B4=A5=E5=85=B3?= =?UTF-8?q?=E9=97=AD=E3=80=81=E5=BC=82=E6=AD=A5=E5=9B=9E=E5=A1=AB=E7=94=9F?= =?UTF-8?q?=E5=91=BD=E5=91=A8=E6=9C=9F=E5=AE=88=E5=8D=AB=E3=80=81=E5=90=88?= =?UTF-8?q?=E5=B9=B6=20master=20=E5=90=8E=E8=BF=87=E6=B5=8B=E8=AF=95?= =?UTF-8?q?=E7=B1=BB=E5=9E=8B=E9=97=A8=E7=A6=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 合并 origin/master tip ccd271572(merge commit eeac2c759),无冲突;本分支不修 master 既有共享红(check:nginx-spa-routes / external_mcp::semantic 32 与 30 / http_tracing 偶发 / AGC Rust shard 4/4,由 fix/ci-master-red 处理)。 - 按 master 新增的测试类型门禁(check:tests:types)把两处直接赋值 window.__TAURI__ 迁到 installTauriRuntime / resetTauriRuntime(tests/tauriRuntimeFake.ts 是全仓唯一安装点),否则 appSurface/home.suite.ts 报 TS2322。 - Rust:rename_local_game_project_if_unchanged_at 对空 expectedProjectId / expectedName 按仓库同类入口口径失败关闭(不再静默 renamed:false);非空但不匹配仍是正常跳过。新增 1 条单测,含「非空不匹配不是错误」的反向保护。 - 前端:applyAutomaticProjectName 在 await entrySettled 之后加壳生命周期守卫(mounted + 代次,与 enterProjectDevelopment 同口径):关窗 / 卸载后只保留已落盘的改名,不再写当前项目上下文、不再重检最近项目。新增 2 条 hook 用例(unmount / pagehide),去掉守卫即变红。 - 注释口径:把 suggestName 判据与 ProjectStartMode 的映射写清(planning 覆盖「做方案」与勾选「策划补全」的「做游戏」,见 view/home/homeStartMode.ts),行为不变。 - 文档:decision-log 的验证数字更新为 12 例 / 6 例 / 全量 1922 passed,并补记生命周期守卫与空 expected 口径。 - 定向验证:homeProjectNamingAsync 12 passed;appSurface.test.ts 197 passed / 9 skipped;AGC 全量 1922 passed / 17 skipped;cargo conditional_project_rename_tests 6 passed;cargo fmt --check、npm run agc:typecheck(含 check:tests:types)、check:encoding、git diff --check 全过。 --- .../src-tauri/src/commands.rs | 56 +++++++++++++++++- .../app-shell/useHomeProjectCreation.ts | 19 ++++++- .../tests/appSurface/home.suite.ts | 4 +- .../tests/homeProjectNamingAsync.test.tsx | 57 ++++++++++++++++++- .../shared-memory/decision-log.md | 4 +- 5 files changed, 130 insertions(+), 10 deletions(-) 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 6ce43229b..e98373ab7 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/commands.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/commands.rs @@ -522,12 +522,20 @@ pub(crate) fn rename_local_game_project_if_unchanged_at( name: &str, ) -> Result { let next_name = normalize_game_creation_project_name(name.trim())?; + let expected_project_id = expected_project_id.trim(); + if expected_project_id.is_empty() { + return Err("项目改名 expectedProjectId 不能为空".to_string()); + } + let expected_name = expected_name.trim(); + if expected_name.is_empty() { + return Err("项目改名 expectedName 不能为空".to_string()); + } 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() { + if current.project_id != expected_project_id { return Ok(ConditionalRenameLocalProjectResult { renamed: false, manifest: current, @@ -535,7 +543,7 @@ pub(crate) fn rename_local_game_project_if_unchanged_at( }); } // 用户已经手动改过名(或别的流程改过):兜底名不再成立,绝不覆盖。 - if current.name != expected_name.trim() { + if current.name != expected_name { return Ok(ConditionalRenameLocalProjectResult { renamed: false, manifest: current, @@ -696,6 +704,50 @@ mod conditional_project_rename_tests { assert_eq!(project_revision(root), revision_before); } + #[test] + fn conditional_rename_rejects_empty_expectations_without_touching_the_project() { + let project = fixture_root("conditional-rename-empty-expectation"); + let root = project.path(); + let revision_before = project_revision(root); + + // 空 expectedProjectId / expectedName 与其它按 expected 判等的入口同口径:失败关闭, + // 而不是静默地按「不匹配」跳过——空串永远不可能是调用方真正想表达的身份。 + for (expected_project_id, expected_name, marker) in [ + ("", FALLBACK_NAME, "expectedProjectId"), + (" ", FALLBACK_NAME, "expectedProjectId"), + ("conditional-rename-empty-expectation", "", "expectedName"), + ( + "conditional-rename-empty-expectation", + " ", + "expectedName", + ), + ] { + let error = rename_local_game_project_if_unchanged_at( + root, + expected_project_id, + expected_name, + "星轨夜航", + ) + .expect_err("empty expectation must fail closed"); + assert!(error.contains(marker), "{error} 应指出 {marker}"); + } + + assert_eq!(manifest_name(root), FALLBACK_NAME); + assert_eq!(project_revision(root), revision_before); + + // 非空但不匹配仍然是「跳过」这条正常路径:用户已改名不是错误。 + let skipped = rename_local_game_project_if_unchanged_at( + root, + "conditional-rename-empty-expectation", + "用户自己起的名字", + "星轨夜航", + ) + .expect("stale but non-empty expectation is not an error"); + assert!(!skipped.renamed); + assert_eq!(skipped.manifest.name, FALLBACK_NAME); + assert_eq!(manifest_name(root), FALLBACK_NAME); + } + #[test] fn conditional_rename_reports_false_when_name_already_matches() { let project = fixture_root("conditional-rename-same-name"); 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 aba425bf2..abcfce24c 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 @@ -447,6 +447,7 @@ export function useHomeProjectCreation({ created: InitLocalProjectResult, entrySettled: Promise, ) { + const generation = lifecycleRef.current.generation; let suggested: string | null = null; try { suggested = (await naming)?.trim() || null; @@ -478,6 +479,19 @@ export function useHomeProjectCreation({ return; } await entrySettled; + /** + * 等待期间壳可能已经被卸载(关窗 / 切走 / StrictMode 重放)。 + * + * 落盘的改名是项目级事实(换台机器打开也是 AI 名),照旧保留;但「当前项目上下文」与 + * 「最近项目行重检」都是本壳的 UI 投影,卸载后一律不写——与 `enterProjectDevelopment` + * 的守卫同口径(`mounted` + 代次),否则迟到的命名结果会写进已经作废的壳。 + */ + if ( + !lifecycleRef.current.mounted || + generation !== lifecycleRef.current.generation + ) { + return; + } setCurrentProjectContext((current) => current && current.projectPath === created.projectPath ? { @@ -914,8 +928,9 @@ export function useHomeProjectCreation({ startMode: ProjectStartMode, ) { return createHomeDraftAutomaticallyWithOptions(draft, startMode, { - // 做方案的首轮还要调用一次策划 Provider;项目命名对它没有价值, - // 「做方案」也从不发起自动项目命名请求。 + // 判据就是 `ProjectStartMode`:'planning' 覆盖「做方案」和勾了「策划补全」的「做游戏」 + // (见 view/home/homeStartMode.ts),这一档只调策划 Provider,项目命名对它没有价值; + // 只有 'direct-build' 才发起自动项目命名请求。 suggestName: startMode !== 'planning', }); } 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 74019c7a3..02c685ccd 100644 --- a/apps/ai-game-creator-shell/tests/appSurface/home.suite.ts +++ b/apps/ai-game-creator-shell/tests/appSurface/home.suite.ts @@ -1878,10 +1878,10 @@ export function registerHomeProjectCreationTests() { return chatHarness.invoke(command, args); }, ); - window.__TAURI__ = { + installTauriRuntime({ core: { invoke }, event: { listen: chatHarness.listen }, - }; + }); window.localStorage.setItem( 'genarrative-ai-game-creator.recent-workspaces.v1', JSON.stringify([projectPath]), diff --git a/apps/ai-game-creator-shell/tests/homeProjectNamingAsync.test.tsx b/apps/ai-game-creator-shell/tests/homeProjectNamingAsync.test.tsx index c23103b50..f259c5981 100644 --- a/apps/ai-game-creator-shell/tests/homeProjectNamingAsync.test.tsx +++ b/apps/ai-game-creator-shell/tests/homeProjectNamingAsync.test.tsx @@ -5,6 +5,7 @@ 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'; +import { installTauriRuntime, resetTauriRuntime } from './tauriRuntimeFake'; const PROJECT_PATH = 'C:/test/async-naming-game'; const PROJECT_ID = 'gameagent-1a2b3c4d'; @@ -14,7 +15,7 @@ const SUGGESTED_NAME = '星河远征'; afterEach(() => { cleanup(); - delete window.__TAURI__; + resetTauriRuntime(); }); /** 手控闸门:测试自己决定命名请求何时返回,不靠 sleep 赌时序。 */ @@ -66,7 +67,7 @@ function mount(options: { return null; }); const refreshRecentWorkspace = vi.fn(async () => {}); - window.__TAURI__ = { core: { invoke } } as unknown as typeof window.__TAURI__; + installTauriRuntime({ core: { invoke } }); const hook = renderHook(() => useHomeProjectCreation({ setStatus: vi.fn(), @@ -365,3 +366,55 @@ it('does not hijack the project the user already moved to when the AI name lands // 但刚刚建好的工作区确实改名了:最近项目行照旧按目录重检。 expect(refreshRecentWorkspace).toHaveBeenCalledWith(PROJECT_PATH); }); + +it('does not write the project context after the launcher unmounted', async () => { + const naming = deferred(); + const { result, unmount, refreshRecentWorkspace } = mount({ + naming: naming.promise, + conditionalRename: async () => ({ + renamed: true, + manifest: createGameCreationAppManifest(PROJECT_ID, SUGGESTED_NAME), + revision: 1, + }), + }); + + await act(async () => { + await createAutomatically(result); + }); + unmount(); + await act(async () => { + naming.resolve(SUGGESTED_NAME); + await naming.promise; + }); + + // 卸载后迟到的命名结果不再写上下文,也就不该走到上下文写入后面的最近项目重检。 + // (卸载后 `result.current` 冻结在最后一次渲染,只有这条副作用能观测到越界写入。) + expect(refreshRecentWorkspace).not.toHaveBeenCalled(); +}); + +it('does not write the project context after the window was hidden', async () => { + const naming = deferred(); + const { result, refreshRecentWorkspace } = mount({ + naming: naming.promise, + conditionalRename: async () => ({ + renamed: true, + manifest: createGameCreationAppManifest(PROJECT_ID, SUGGESTED_NAME), + revision: 1, + }), + }); + + await act(async () => { + await createAutomatically(result); + }); + // `pagehide` 是壳自己的作废信号(关窗 / 切走),与卸载走同一枚 mounted + 代次守卫。 + await act(async () => { + window.dispatchEvent(new Event('pagehide')); + }); + await act(async () => { + naming.resolve(SUGGESTED_NAME); + await naming.promise; + }); + + expect(result.current.currentProjectContext?.projectName).toBe(FALLBACK_NAME); + expect(refreshRecentWorkspace).not.toHaveBeenCalled(); +}); diff --git a/docs/project-memory/shared-memory/decision-log.md b/docs/project-memory/shared-memory/decision-log.md index ac2121eca..f8abf4607 100644 --- a/docs/project-memory/shared-memory/decision-log.md +++ b/docs/project-memory/shared-memory/decision-log.md @@ -4,9 +4,9 @@ - 背景:首页「开启创作」原先串行执行「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` 用兜底名覆盖,最近项目行也要等进项目登记过才会被重检。「做方案」与手动选目录建项不发起自动命名。 +- 边界:改名是簿记写入,与既有 `rename_local_game_project` 同口径**不推进项目 revision**(推 revision 会让运行时验证凭证无故漂移);返回的 `revision` 是当时盘上的值。后台改名结果写「当前项目上下文」与「最近项目行重检」必须等建项主体收尾(`entrySettled`):AI 比进项目更快时直接写上下文会被随后的 `enterProjectDevelopment` 用兜底名覆盖,最近项目行也要等进项目登记过才会被重检;写入前再过壳的生命周期守卫(`mounted` + 代次),关窗/卸载后只保留已落盘的改名、不写 UI 投影。「做方案」与手动选目录建项不发起自动命名。条件改名的 `expectedProjectId` / `expectedName` 为空时按仓库同类入口口径失败关闭(而不是静默 `renamed: false`)。 - 影响范围:`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`。 +- 验证:`npx vitest run apps/ai-game-creator-shell/tests/homeProjectNamingAsync.test.tsx`(12 passed,含「命名请求永不返回仍进工作区」「AI 结果先于进项目落定仍不被兜底名覆盖」「手动改名不被覆盖」「非法/空响应保留兜底名」「做方案不发起命名」「切到别的工作区不被劫持」「卸载/pagehide 后不写上下文」);AGC 全量 `npm run test -- apps/ai-game-creator-shell/tests`(1922 passed / 17 skipped,合并 `origin/master` tip `ccd271572` 后复测);`cargo test --features=cocos-editor-execute,unity-editor-execute,godot-editor-execute conditional_project_rename_tests`(6 passed,含空 `expectedProjectId` / `expectedName` 失败关闭);`npm run agc:typecheck`(含 `check:tests:types`)、`cargo fmt --check`、`npm run check:encoding`、`git diff --check`。 ## 2026-10-03 AGC 栏目画布上传素材按入口栏目登记(Issue 359) -- 2.52.0 From a554b0c7ca09ea6c58feb4ade8d2979aa1b0fdf7 Mon Sep 17 00:00:00 2001 From: Suzumiya Date: Sun, 4 Oct 2026 16:59:59 +0800 Subject: [PATCH 4/7] =?UTF-8?q?=E6=8A=8A=20AI=20=E9=A1=B9=E7=9B=AE?= =?UTF-8?q?=E5=91=BD=E5=90=8D=E6=8E=A5=E8=BF=9B=E3=80=8C=E7=94=9F=E6=88=90?= =?UTF-8?q?=E4=BB=BB=E5=8A=A1=E3=80=8D=E4=BD=93=E7=B3=BB=EF=BC=9A=E8=B4=A6?= =?UTF-8?q?=E6=9C=AC=20v2=20=E4=BB=BB=E5=8A=A1=E7=B1=BB=E5=9E=8B=E3=80=81?= =?UTF-8?q?=E5=91=BD=E5=90=8D=E4=BB=BB=E5=8A=A1=E5=85=A5=E9=98=9F=E4=B8=8E?= =?UTF-8?q?=E6=8E=A8=E8=BF=9B=E3=80=81=E9=9D=A2=E6=9D=BF=E5=91=BD=E5=90=8D?= =?UTF-8?q?=E8=A1=8C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 账本 v2:记录新增 taskType(asset-generation / project-naming,缺省 asset-generation,旧壳按素材任务读回),kind 改为可选;schema 升 agc-asset-generation-task.v2,读取同时接受 v1 与 v2。 - 新增 enqueue_local_project_naming_task / update_local_project_naming_task:命名任务入队时登记 live、终态写盘后摘除(写盘失败只回滚本轮插入),中断残留按命名口径收口为 failed(文案不含「目标素材」)。 - 首页命名链路改为推进这条任务:入队 → 命名中 → 已完成(已应用 AI 名称 / 建议名与现状一致 / 用户已手动改名而跳过)或失败(无可用名称 / 自动改名失败,均保留兜底名);账本只作展示旁路,推进失败只写诊断日志。 - 前端模型与面板支持命名行:按 taskType 把命名记录从素材任务分支剔除,命名行固定展示名「AI 项目命名」、只在终态显示结论、不渲染缩略图/提示词/定位动作;命令名写字面量以满足 check-config 静态枚举门禁。 - 工作台订阅账本变更事件做一次受控快照读取刷新命名行(不新增固定频率轮询);另修:命名行的 useLiveNow 收进独立组件,避免行种类变化导致 hooks 数量变化。 - 测试:homeProjectNamingAsync 12 例(新增入队→命名中→终态推进序列断言)、projectNamingGenerationTaskRow 3 例、appSurface 建项后命名行可见 + 事件刷新出终态结论 + 标题/列表同步;Rust asset_generation_task 22 例(含 naming_task_stays_in_flight_across_ledger_reads_until_terminal、legacy_v1_ledger_reads_back_*)。 - 文档:decision-log 改写为「生成任务体系里的后台任务」、pitfalls 新增 taskType/live 集合条目、实施计划专题补任务接入口径。 - 门禁:AGC 全量 1925 passed / 17 skipped;cargo 定向 22 + 6;npm run agc:typecheck(含 check:tests:types 与 check-config);cargo fmt --check;check:encoding;git diff --check。 --- .../src-tauri/src/asset_generation_tasks.rs | 548 +++++++++++++++++- .../src/asset_generation_tasks/runtime.rs | 1 - .../src-tauri/src/desktop.rs | 2 + .../app-shell/useHomeProjectCreation.ts | 103 +++- ...rceCanvasAssetGenerationTasksPanelView.tsx | 127 +++- .../resourceCanvasAssetGenerationTaskModel.ts | 130 ++++- .../src/view/project-development/index.tsx | 86 ++- .../tests/appSurface/home.suite.ts | 76 ++- .../tests/homeProjectNamingAsync.test.tsx | 142 +++-- .../projectNamingGenerationTaskRow.test.tsx | 159 +++++ .../shared-memory/decision-log.md | 9 +- docs/project-memory/shared-memory/pitfalls.md | 9 + ...¹案】AI游戏创作智能体App实施计划-2026-06-24.md | 3 +- 13 files changed, 1325 insertions(+), 70 deletions(-) create mode 100644 apps/ai-game-creator-shell/tests/projectNamingGenerationTaskRow.test.tsx diff --git a/apps/ai-game-creator-shell/src-tauri/src/asset_generation_tasks.rs b/apps/ai-game-creator-shell/src-tauri/src/asset_generation_tasks.rs index 44d7dc7a1..cdb03264e 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/asset_generation_tasks.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/asset_generation_tasks.rs @@ -12,6 +12,10 @@ //! **阶段文案由本模块拥有**(`phase_detail`):前端只渲染后端给的字符串,不自己造百分比或 //! 假阶段。这也是「进度可见」这条验收判据的落点。 //! +//! 账本同时承载**非素材任务**:AI 项目命名(`taskType = project-naming`)与素材任务共用同一份 +//! 记录形状,只是没有素材(`kind` 为 `null`),由前端通过 `enqueue_local_project_naming_task` / +//! `update_local_project_naming_task` 自行推进状态;中断收口一律为 failed,文案不借用素材口径。 +//! //! 账本落在**项目内** `.agent/runtime/asset-generation-tasks/tasks.json`(复用既有 agent runtime //! sidecar 读写原语:临时文件 + rename 替换),所以重开项目后仍能看到历史任务。进程重启时 //! 还在 `queued` / `running` 的记录不可能再有人推进,读账本时按「上次运行中断」收口;收口结论 @@ -27,6 +31,7 @@ use std::time::{SystemTime, UNIX_EPOCH}; use crate::agent::{ read_agent_runtime_json_sidecar_with_max_bytes, write_agent_runtime_json_sidecar_with_max_bytes, }; +use crate::commands::validated_local_project_directory_path; use crate::project::{ enforce_project_permission_policy, prepare_local_project_audio_generation, read_existing_manifest_for_project, LocalProjectAudioGenerationRequest, @@ -39,7 +44,13 @@ mod runtime; #[cfg(not(test))] pub(crate) use runtime::start_local_project_asset_generation; -pub(crate) const ASSET_GENERATION_TASK_SCHEMA_VERSION: &str = "agc-asset-generation-task.v1"; +pub(crate) const ASSET_GENERATION_TASK_SCHEMA_VERSION: &str = "agc-asset-generation-task.v2"; +/// v1 老账本:记录里没有 `taskType`、`kind` 必有值。读取路径必须继续接受(缺省按素材任务处理)。 +pub(crate) const ASSET_GENERATION_TASK_LEGACY_SCHEMA_VERSION: &str = "agc-asset-generation-task.v1"; +/// 任务类型:素材生成(`kind` 必有值)。 +pub(crate) const ASSET_GENERATION_TASK_TYPE_ASSET_GENERATION: &str = "asset-generation"; +/// 任务类型:AI 项目命名(没有素材,`kind` 为 null)。 +pub(crate) const ASSET_GENERATION_TASK_TYPE_PROJECT_NAMING: &str = "project-naming"; pub(crate) const ASSET_GENERATION_TASK_LEDGER_RELATIVE_PATH: &str = ".agent/runtime/asset-generation-tasks/tasks.json"; pub(crate) const ASSET_GENERATION_TASK_LEDGER_MAX_BYTES: usize = 1024 * 1024; @@ -48,6 +59,7 @@ pub(crate) const ASSET_GENERATION_TASK_LEDGER_MAX_RECORDS: usize = 50; pub(crate) const ASSET_GENERATION_TASK_ID_MAX_CHARS: usize = 128; pub(crate) const ASSET_GENERATION_TASK_STATUS_QUEUED: &str = "queued"; +pub(crate) const ASSET_GENERATION_TASK_STATUS_RUNNING: &str = "running"; pub(crate) const ASSET_GENERATION_TASK_STATUS_COMPLETED: &str = "completed"; pub(crate) const ASSET_GENERATION_TASK_STATUS_FAILED: &str = "failed"; pub(crate) const ASSET_GENERATION_TASK_CHANGED_EVENT: &str = @@ -90,13 +102,31 @@ const ASSET_GENERATION_TASK_INTERRUPTED_INCOMPLETE_ERROR: &str = "应用退出时生成任务仍在进行,目标素材未登记"; const ASSET_GENERATION_TASK_INTERRUPTED_UNKNOWN_ERROR: &str = "应用退出时生成任务仍在进行,未能在清单里确认结果"; +/// 项目命名任务的展示名:命名没有素材条目,但账本记录仍要有一个可读的名字。 +const ASSET_GENERATION_TASK_PROJECT_NAMING_ASSET_NAME: &str = "AI 项目命名"; +/// 中断收口(项目命名):命名没有素材可交叉核对,收口只能是失败;文案必须与素材口径区分。 +const ASSET_GENERATION_TASK_PHASE_NAMING_INTERRUPTED: &str = + "上次运行中断,项目命名未完成(保留兜底名)。"; +const ASSET_GENERATION_TASK_NAMING_INTERRUPTED_ERROR: &str = "应用退出时项目命名任务仍在进行"; + +/// v1 老记录没有 `taskType`:缺省按素材生成任务读回(见 `ASSET_GENERATION_TASK_LEGACY_SCHEMA_VERSION`)。 +fn default_asset_generation_task_type() -> String { + ASSET_GENERATION_TASK_TYPE_ASSET_GENERATION.to_string() +} + /// 一条生成任务的权威记录。字段名与前端一一对应(camelCase)。 #[derive(Clone, Debug, Eq, PartialEq, serde::Serialize, serde::Deserialize)] #[serde(rename_all = "camelCase")] pub(crate) struct AssetGenerationTaskRecord { pub(crate) task_id: String, pub(crate) project_id: String, - pub(crate) kind: GameCreationAppAssetKind, + /// 任务类型:素材生成还是项目命名(见 `ASSET_GENERATION_TASK_TYPE_*`)。 + /// + /// v1 老账本没有这个字段,读回时缺省为 `asset-generation`——这就是「v1 与 v2 都能读」的落点。 + #[serde(default = "default_asset_generation_task_type")] + pub(crate) task_type: String, + /// 素材类型。素材任务必有值;项目命名任务没有素材,为 `None`(JSON `null`)。 + pub(crate) kind: Option, pub(crate) asset_name: String, pub(crate) status: String, /// 阶段文案:**由后端拥有**,前端只渲染。 @@ -118,13 +148,18 @@ pub(crate) struct AssetGenerationTaskRecord { impl AssetGenerationTaskRecord { fn is_terminal(&self) -> bool { - matches!( - self.status.as_str(), - ASSET_GENERATION_TASK_STATUS_COMPLETED | ASSET_GENERATION_TASK_STATUS_FAILED - ) + is_terminal_asset_generation_status(&self.status) } } +/// 终态判据的单一出处:素材与命名任务共用同一组状态值。 +fn is_terminal_asset_generation_status(status: &str) -> bool { + matches!( + status, + ASSET_GENERATION_TASK_STATUS_COMPLETED | ASSET_GENERATION_TASK_STATUS_FAILED + ) +} + #[derive(Clone, Debug, serde::Serialize, serde::Deserialize)] #[serde(rename_all = "camelCase")] struct AssetGenerationTaskLedger { @@ -163,7 +198,21 @@ fn read_ledger(root: &Path) -> Result, String> { "生成任务账本", ASSET_GENERATION_TASK_LEDGER_MAX_BYTES, )?; - Ok(ledger.map(|ledger| ledger.tasks).unwrap_or_default()) + let Some(ledger) = ledger else { + return Ok(Vec::new()); + }; + // 读取路径同时接受 v1(老账本:无 `taskType`、`kind` 必有值)与 v2(当前):v1 记录由 + // `taskType` 的 serde 缺省补齐成素材任务;缺失 `schemaVersion` 也按兼容处理。 + let schema_version = ledger.schema_version.trim(); + if !schema_version.is_empty() + && schema_version != ASSET_GENERATION_TASK_SCHEMA_VERSION + && schema_version != ASSET_GENERATION_TASK_LEGACY_SCHEMA_VERSION + { + return Err(format!( + "生成任务账本 schema 版本不受支持:{schema_version}" + )); + } + Ok(ledger.tasks) } fn write_ledger(root: &Path, tasks: &[AssetGenerationTaskRecord]) -> Result<(), String> { @@ -229,6 +278,9 @@ type RegisteredAssetIdsByLocalPath = Option>; /// - 请求指定了精确落点、且清单里已有该落点 → 按事实收口为**已完成**(带上 assetId); /// - 请求指定了精确落点、清单里没有 → 这次写入确实没落地,收口为失败并说明「目标素材未登记」; /// - 没有精确落点(或清单读不到)→ 收口为失败但**不下结论**,文案是「状态未知(可能已完成)」。 +/// +/// 项目命名任务(`taskType = project-naming`)没有素材可核对,一律收口为 **failed**,文案走命名 +/// 口径(不出现「目标素材」)。 fn repair_interrupted_tasks( tasks: &mut [AssetGenerationTaskRecord], registered: &RegisteredAssetIdsByLocalPath, @@ -242,6 +294,16 @@ fn repair_interrupted_tasks( if task.is_terminal() || live.contains(&task.task_id) { continue; } + // 项目命名任务没有素材可交叉核对(`kind` / `outputPath` 都是空),所以这里不走 manifest + // 口径,直接收口为失败;文案也必须与素材口径区分(不带「目标素材」字样)。 + if task.task_type == ASSET_GENERATION_TASK_TYPE_PROJECT_NAMING { + task.status = ASSET_GENERATION_TASK_STATUS_FAILED.to_string(); + task.phase_detail = ASSET_GENERATION_TASK_PHASE_NAMING_INTERRUPTED.to_string(); + task.error = Some(ASSET_GENERATION_TASK_NAMING_INTERRUPTED_ERROR.to_string()); + task.finished_at_millis = Some(now_millis()); + repaired = true; + continue; + } let registered_asset = task .output_path .as_deref() @@ -337,7 +399,8 @@ pub(crate) fn begin_local_project_asset_generation_task( let record = AssetGenerationTaskRecord { task_id: task_id.to_string(), project_id: project_id.trim().to_string(), - kind: task_kind, + task_type: ASSET_GENERATION_TASK_TYPE_ASSET_GENERATION.to_string(), + kind: Some(task_kind), asset_name: asset_name.to_string(), status: ASSET_GENERATION_TASK_STATUS_QUEUED.to_string(), phase_detail: ASSET_GENERATION_TASK_PHASE_QUEUED.to_string(), @@ -448,6 +511,139 @@ pub(crate) fn list_local_project_asset_generations( list_local_project_asset_generation_tasks(root) } +/// 入队一条 AI 项目命名任务:与素材任务共用同一份账本,只是 `taskType` / `kind` 不同。 +/// +/// 命名由前端驱动(后台 agent 给出名字后由前端调 `update_local_project_naming_task` 推进), +/// 所以这里只负责落一条 `queued` 记录;推进语义全在 update 命令里。 +/// +/// 幂等:同一个 `task_id` 无论是否终态都**覆盖**(先删后插),账本里不会出现两条同 id 记录。 +/// +/// 在途任务必须登记进 `live_task_ids()`:命名任务同样是「本进程在推」的任务,否则工作台一读账本 +/// (`list_local_project_asset_generations`)就会把这条非终态记录当成上次运行的残留收口成 failed。 +/// 顺序同素材链路——**先登记 live 再落账本**;落账失败只回滚本轮真正插入的那条登记。 +#[tauri::command] +pub(crate) fn enqueue_local_project_naming_task( + project_path: String, + task_id: String, +) -> Result { + let task_id = asset_generation_task_id(&task_id)?; + let root = validated_local_project_directory_path(project_path.trim())?; + // 命名任务的权限判据与既有 `rename_local_game_project` 同口径。 + enforce_project_permission_policy(&root, "project.rename")?; + let project_id = read_existing_manifest_for_project(&root)?.project_id; + let record = AssetGenerationTaskRecord { + task_id: task_id.clone(), + project_id, + task_type: ASSET_GENERATION_TASK_TYPE_PROJECT_NAMING.to_string(), + kind: None, + asset_name: ASSET_GENERATION_TASK_PROJECT_NAMING_ASSET_NAME.to_string(), + status: ASSET_GENERATION_TASK_STATUS_QUEUED.to_string(), + phase_detail: ASSET_GENERATION_TASK_PHASE_QUEUED.to_string(), + output_path: None, + created_at_millis: now_millis(), + started_at_millis: None, + finished_at_millis: None, + asset_id: None, + error: None, + }; + let live_registered = register_live_task_id(&task_id); + let write_result = (|| -> Result<(), String> { + let _guard = lock_ledger()?; + let mut tasks = read_ledger(&root)?; + tasks.retain(|task| task.task_id != record.task_id); + tasks.push(record.clone()); + trim_ledger(&mut tasks); + write_ledger(&root, &tasks) + })(); + if let Err(error) = write_result { + if live_registered { + remove_live_task_id(&task_id); + } + return Err(error); + } + emit_asset_generation_task_changed(&root, &task_id); + Ok(record) +} + +/// 推进一条 AI 项目命名任务。 +/// +/// 语义(命名任务独有的口径,素材任务一律拒绝): +/// - 只作用于 `taskType = project-naming` 的记录,且 `task_id` 必须存在; +/// - `status` 只接受 `queued` / `running` / `completed` / `failed`; +/// - 进入 `running` 时补 `started_at_millis`;进入终态时置 `finished_at_millis`; +/// - **已终态的记录不接受改回非终态**(返回 Err,避免命名结果被一次迟到的排队事件抹掉); +/// - `phase_detail` 传入即覆盖;`error` 只在 `failed` 时保存,非 `failed` 传入会被忽略; +/// - live 集合与状态同步:非终态保持登记(在途任务不能被并发 `list` 收口成失败),终态摘除 +/// (真中断的任务重启后仍能被收口)。落账失败只回滚本轮真正插入的那条登记。 +#[tauri::command] +pub(crate) fn update_local_project_naming_task( + project_path: String, + task_id: String, + status: String, + phase_detail: Option, + error: Option, +) -> Result { + let task_id = asset_generation_task_id(&task_id)?; + let status = naming_task_status(&status)?; + let root = validated_local_project_directory_path(project_path.trim())?; + let _guard = lock_ledger()?; + let mut tasks = read_ledger(&root)?; + let record = tasks + .iter_mut() + .find(|task| task.task_id == task_id) + .ok_or_else(|| format!("生成任务不存在:{task_id}"))?; + if record.task_type != ASSET_GENERATION_TASK_TYPE_PROJECT_NAMING { + return Err(format!("生成任务不是项目命名任务:{task_id}")); + } + if record.is_terminal() && !is_terminal_asset_generation_status(status) { + return Err(format!("项目命名任务已结束,不能改回非终态:{task_id}")); + } + record.status = status.to_string(); + if status == ASSET_GENERATION_TASK_STATUS_RUNNING && record.started_at_millis.is_none() { + record.started_at_millis = Some(now_millis()); + } + if is_terminal_asset_generation_status(status) { + record.finished_at_millis = Some(now_millis()); + } + if let Some(phase_detail) = phase_detail { + record.phase_detail = phase_detail; + } + if status == ASSET_GENERATION_TASK_STATUS_FAILED { + if let Some(error) = error { + record.error = Some(error); + } + } + // 非终态:幂等登记 live(在账本锁内做,`list` 不可能插在中间)。终态的摘除放在写盘之后。 + let live_registered = + !is_terminal_asset_generation_status(status) && register_live_task_id(&task_id); + let snapshot = record.clone(); + if let Err(write_error) = write_ledger(&root, &tasks) { + drop(_guard); + if live_registered { + remove_live_task_id(&task_id); + } + return Err(write_error); + } + if is_terminal_asset_generation_status(status) { + remove_live_task_id(&task_id); + } + drop(_guard); + emit_asset_generation_task_changed(&root, &task_id); + Ok(snapshot) +} + +/// 命名任务的状态白名单:与素材任务共用同一组状态常量,其余一律拒绝。 +fn naming_task_status(status: &str) -> Result<&'static str, String> { + let status = status.trim(); + match status { + ASSET_GENERATION_TASK_STATUS_QUEUED => Ok(ASSET_GENERATION_TASK_STATUS_QUEUED), + ASSET_GENERATION_TASK_STATUS_RUNNING => Ok(ASSET_GENERATION_TASK_STATUS_RUNNING), + ASSET_GENERATION_TASK_STATUS_COMPLETED => Ok(ASSET_GENERATION_TASK_STATUS_COMPLETED), + ASSET_GENERATION_TASK_STATUS_FAILED => Ok(ASSET_GENERATION_TASK_STATUS_FAILED), + _ => Err(format!("未知的生成任务状态:{status}")), + } +} + #[cfg(test)] mod asset_generation_task_tests { use super::*; @@ -551,7 +747,7 @@ mod asset_generation_task_tests { assert_eq!(record.status, ASSET_GENERATION_TASK_STATUS_QUEUED); assert_eq!(record.phase_detail, ASSET_GENERATION_TASK_PHASE_QUEUED); assert_eq!(record.project_id, "project-1"); - assert_eq!(record.kind, "image"); + assert_eq!(record.kind.as_deref(), Some("image")); assert_eq!(record.asset_name, "AI 图"); assert!(record.started_at_millis.is_none()); assert!(record.asset_id.is_none()); @@ -894,7 +1090,7 @@ mod asset_generation_task_tests { "9a1b2c3d-4e5f-4a1b-8c2d-3e4f5a6b7c8d", ) .expect("background music task"); - assert_eq!(record.kind, GameCreationAppAssetKind::BackgroundMusic); + assert_eq!(record.kind, Some(GameCreationAppAssetKind::BackgroundMusic)); assert_eq!(record.status, ASSET_GENERATION_TASK_STATUS_QUEUED); assert_eq!(record.phase_detail, ASSET_GENERATION_TASK_PHASE_QUEUED); assert!(record.output_path.is_none()); @@ -912,7 +1108,10 @@ mod asset_generation_task_tests { let listed = list_local_project_asset_generation_tasks(&root).expect("list"); assert_eq!(listed.len(), 1); - assert_eq!(listed[0].kind, GameCreationAppAssetKind::BackgroundMusic); + assert_eq!( + listed[0].kind, + Some(GameCreationAppAssetKind::BackgroundMusic) + ); assert_eq!(listed[0].task_id, record.task_id); // 音效走同一条账本,只是落到另一个 canonical kind。 @@ -926,8 +1125,333 @@ mod asset_generation_task_tests { "2d8b4c9f-3a42-4d7e-8f1b-7c9d1e2f3a45", ) .expect("sound effect task"); - assert_eq!(sound_effect.kind, GameCreationAppAssetKind::SoundEffect); + assert_eq!( + sound_effect.kind, + Some(GameCreationAppAssetKind::SoundEffect) + ); assert_eq!(request.edit_kind, LocalProjectResourceEditKind::SoundEffect); std::fs::remove_dir_all(&root).ok(); } + + fn naming_task_project_path(root: &Path) -> String { + root.to_string_lossy().into_owned() + } + + /// v1 老账本(记录里没有 `taskType`、`kind` 必有值)必须原样读回:`taskType` 缺省为素材任务, + /// 其余字段一个都不能变。 + #[test] + fn legacy_v1_ledger_reads_back_with_the_default_asset_generation_task_type() { + let root = temp_project_root("legacy-v1"); + let ledger_path = root.join(ASSET_GENERATION_TASK_LEDGER_RELATIVE_PATH); + std::fs::create_dir_all(ledger_path.parent().expect("ledger parent")).expect("ledger dir"); + std::fs::write( + &ledger_path, + r#"{ + "schemaVersion": "agc-asset-generation-task.v1", + "tasks": [ + { + "taskId": "task-v1", + "projectId": "project-legacy", + "kind": "image", + "assetName": "旧图", + "status": "completed", + "phaseDetail": "生成已完成。", + "outputPath": "assets/legacy.png", + "createdAtMillis": 11, + "startedAtMillis": 12, + "finishedAtMillis": 13, + "assetId": "asset-legacy", + "error": null + } + ] + }"#, + ) + .expect("write legacy ledger"); + + let listed = list_local_project_asset_generation_tasks(&root).expect("list legacy ledger"); + assert_eq!(listed.len(), 1); + let record = &listed[0]; + assert_eq!( + record.task_type, + ASSET_GENERATION_TASK_TYPE_ASSET_GENERATION + ); + assert_eq!(record.kind.as_deref(), Some("image")); + assert_eq!(record.task_id, "task-v1"); + assert_eq!(record.project_id, "project-legacy"); + assert_eq!(record.asset_name, "旧图"); + assert_eq!(record.status, ASSET_GENERATION_TASK_STATUS_COMPLETED); + assert_eq!(record.phase_detail, ASSET_GENERATION_TASK_PHASE_COMPLETED); + assert_eq!(record.output_path.as_deref(), Some("assets/legacy.png")); + assert_eq!(record.created_at_millis, 11); + assert_eq!(record.started_at_millis, Some(12)); + assert_eq!(record.finished_at_millis, Some(13)); + assert_eq!(record.asset_id.as_deref(), Some("asset-legacy")); + assert!(record.error.is_none()); + std::fs::remove_dir_all(&root).ok(); + } + + #[test] + fn naming_task_enqueue_lands_in_the_shared_ledger_without_an_asset_kind() { + let root = initialized_project_root("naming-enqueue"); + let record = enqueue_local_project_naming_task( + naming_task_project_path(&root), + "naming-1".to_string(), + ) + .expect("enqueue naming task"); + assert_eq!(record.task_type, ASSET_GENERATION_TASK_TYPE_PROJECT_NAMING); + assert!(record.kind.is_none(), "命名任务没有素材类型"); + assert_eq!(record.status, ASSET_GENERATION_TASK_STATUS_QUEUED); + assert_eq!(record.phase_detail, ASSET_GENERATION_TASK_PHASE_QUEUED); + assert_eq!( + record.asset_name, + ASSET_GENERATION_TASK_PROJECT_NAMING_ASSET_NAME + ); + assert_eq!(record.project_id, "project-1"); + assert!(record.created_at_millis > 0); + assert!(record.started_at_millis.is_none()); + assert!(record.finished_at_millis.is_none()); + assert!(record.asset_id.is_none()); + assert!(record.error.is_none()); + + // 在途任务已登记 live:账本里是 queued,共享读命令读到的也必须仍是 queued(不是被 + // 「上次运行中断」收口出来的 failed)。 + let stored = read_ledger(&root).expect("read ledger"); + assert_eq!(stored.len(), 1); + assert_eq!(stored[0].status, ASSET_GENERATION_TASK_STATUS_QUEUED); + assert!(stored[0].kind.is_none()); + let listed = list_local_project_asset_generation_tasks(&root).expect("list"); + assert_eq!(listed.len(), 1); + assert_eq!(listed[0].task_id, "naming-1"); + assert_eq!(listed[0].status, ASSET_GENERATION_TASK_STATUS_QUEUED); + remove_live_task_id("naming-1"); + std::fs::remove_dir_all(&root).ok(); + } + + #[test] + fn enqueueing_the_same_naming_task_id_twice_keeps_a_single_record() { + let root = initialized_project_root("naming-idempotent"); + let project_path = naming_task_project_path(&root); + enqueue_local_project_naming_task(project_path.clone(), "naming-dup".to_string()) + .expect("first enqueue"); + let second = enqueue_local_project_naming_task(project_path, "naming-dup".to_string()) + .expect("second enqueue"); + assert_eq!(second.status, ASSET_GENERATION_TASK_STATUS_QUEUED); + + // 直接读账本(不经 `list` 的中断收口):同 id 只能有一条。 + let stored = read_ledger(&root).expect("read ledger"); + let matching: Vec<_> = stored + .iter() + .filter(|task| task.task_id == "naming-dup") + .collect(); + assert_eq!(matching.len(), 1, "同一 task id 不得出现两条记录"); + assert_eq!(matching[0].status, ASSET_GENERATION_TASK_STATUS_QUEUED); + assert_eq!( + matching[0].task_type, + ASSET_GENERATION_TASK_TYPE_PROJECT_NAMING + ); + std::fs::remove_dir_all(&root).ok(); + } + + #[test] + fn naming_task_updates_carry_started_finished_and_error_semantics() { + let root = initialized_project_root("naming-update"); + let project_path = naming_task_project_path(&root); + enqueue_local_project_naming_task(project_path.clone(), "naming-flow".to_string()) + .expect("enqueue"); + + let running = update_local_project_naming_task( + project_path.clone(), + "naming-flow".to_string(), + ASSET_GENERATION_TASK_STATUS_RUNNING.to_string(), + Some("正在生成项目名。".to_string()), + None, + ) + .expect("running"); + assert_eq!(running.status, ASSET_GENERATION_TASK_STATUS_RUNNING); + assert!(running.started_at_millis.is_some()); + assert!(running.finished_at_millis.is_none()); + assert_eq!(running.phase_detail, "正在生成项目名。"); + + let completed = update_local_project_naming_task( + project_path.clone(), + "naming-flow".to_string(), + ASSET_GENERATION_TASK_STATUS_COMPLETED.to_string(), + Some("命名已完成。".to_string()), + Some("不该被保存".to_string()), + ) + .expect("completed"); + assert_eq!(completed.status, ASSET_GENERATION_TASK_STATUS_COMPLETED); + assert!(completed.started_at_millis.is_some()); + assert!(completed.finished_at_millis.is_some()); + assert_eq!(completed.phase_detail, "命名已完成。"); + assert!(completed.error.is_none(), "非 failed 的 error 必须被忽略"); + + // 已终态不得改回非终态。 + assert!(update_local_project_naming_task( + project_path.clone(), + "naming-flow".to_string(), + ASSET_GENERATION_TASK_STATUS_RUNNING.to_string(), + None, + None, + ) + .is_err()); + + // failed 才保存 error。 + enqueue_local_project_naming_task(project_path.clone(), "naming-failed".to_string()) + .expect("enqueue failed"); + let failed = update_local_project_naming_task( + project_path, + "naming-failed".to_string(), + ASSET_GENERATION_TASK_STATUS_FAILED.to_string(), + Some("命名失败:超时".to_string()), + Some("命名通道超时".to_string()), + ) + .expect("failed"); + assert_eq!(failed.status, ASSET_GENERATION_TASK_STATUS_FAILED); + assert_eq!(failed.error.as_deref(), Some("命名通道超时")); + assert!(failed.finished_at_millis.is_some()); + std::fs::remove_dir_all(&root).ok(); + } + + #[test] + fn naming_task_update_rejects_unknown_status_and_non_naming_records() { + let root = initialized_project_root("naming-reject"); + let project_path = naming_task_project_path(&root); + enqueue_local_project_naming_task(project_path.clone(), "naming-reject".to_string()) + .expect("enqueue"); + + let error = update_local_project_naming_task( + project_path.clone(), + "naming-reject".to_string(), + "paused".to_string(), + None, + None, + ) + .expect_err("unknown status"); + assert!(error.contains("未知的生成任务状态"), "{error}"); + + // 素材任务不能走命名推进命令。 + begin(&root, "asset-task"); + let error = update_local_project_naming_task( + project_path.clone(), + "asset-task".to_string(), + ASSET_GENERATION_TASK_STATUS_RUNNING.to_string(), + None, + None, + ) + .expect_err("asset record must be rejected"); + assert!(error.contains("不是项目命名任务"), "{error}"); + + // 找不到的 task id 也要失败。 + let error = update_local_project_naming_task( + project_path, + "missing-task".to_string(), + ASSET_GENERATION_TASK_STATUS_QUEUED.to_string(), + None, + None, + ) + .expect_err("missing task"); + assert!(error.contains("生成任务不存在"), "{error}"); + std::fs::remove_dir_all(&root).ok(); + } + + #[test] + fn interrupted_naming_task_is_settled_as_failed_with_naming_wording() { + let root = initialized_project_root("naming-interrupted"); + let project_path = naming_task_project_path(&root); + enqueue_local_project_naming_task( + project_path.clone(), + "naming-interrupted-task".to_string(), + ) + .expect("enqueue"); + update_local_project_naming_task( + project_path, + "naming-interrupted-task".to_string(), + ASSET_GENERATION_TASK_STATUS_RUNNING.to_string(), + Some("正在生成项目名。".to_string()), + None, + ) + .expect("running"); + + // 模拟「上次运行留下的残留」:进程重启后 live 集合为空,所以这里先摘掉在途登记。 + remove_live_task_id("naming-interrupted-task"); + let listed = list_local_project_asset_generation_tasks(&root).expect("list"); + assert_eq!(listed.len(), 1); + let record = &listed[0]; + assert_eq!(record.status, ASSET_GENERATION_TASK_STATUS_FAILED); + assert_eq!( + record.phase_detail, + ASSET_GENERATION_TASK_PHASE_NAMING_INTERRUPTED + ); + assert!( + !record.phase_detail.contains("目标素材"), + "命名任务不得使用素材口径:{}", + record.phase_detail + ); + assert_eq!( + record.error.as_deref(), + Some(ASSET_GENERATION_TASK_NAMING_INTERRUPTED_ERROR) + ); + assert!(record.finished_at_millis.is_some()); + + // 收口结论写回账本,第二次读到仍是同一条终态记录。 + let again = list_local_project_asset_generation_tasks(&root).expect("list again"); + assert_eq!(again[0].status, ASSET_GENERATION_TASK_STATUS_FAILED); + std::fs::remove_dir_all(&root).ok(); + } + + /// 在途的命名任务不能被账本读取误杀:只有终态才从 live 集合摘除。 + #[test] + fn naming_task_stays_in_flight_across_ledger_reads_until_terminal() { + let root = initialized_project_root("naming-in-flight"); + let project_path = naming_task_project_path(&root); + let task_id = "naming-in-flight-task"; + + enqueue_local_project_naming_task(project_path.clone(), task_id.to_string()) + .expect("enqueue"); + assert!( + live_task_ids().lock().expect("live ids").contains(task_id), + "enqueue 必须把在途命名任务登记进 live 集合" + ); + let queued = list_local_project_asset_generation_tasks(&root).expect("list queued"); + assert_eq!(queued[0].status, ASSET_GENERATION_TASK_STATUS_QUEUED); + + update_local_project_naming_task( + project_path.clone(), + task_id.to_string(), + ASSET_GENERATION_TASK_STATUS_RUNNING.to_string(), + Some("正在生成项目名。".to_string()), + None, + ) + .expect("running"); + let running = list_local_project_asset_generation_tasks(&root).expect("list running"); + assert_eq!(running[0].status, ASSET_GENERATION_TASK_STATUS_RUNNING); + + update_local_project_naming_task( + project_path, + task_id.to_string(), + ASSET_GENERATION_TASK_STATUS_COMPLETED.to_string(), + Some("命名已完成。".to_string()), + None, + ) + .expect("completed"); + let completed = list_local_project_asset_generation_tasks(&root).expect("list completed"); + assert_eq!(completed[0].status, ASSET_GENERATION_TASK_STATUS_COMPLETED); + assert!( + !live_task_ids().lock().expect("live ids").contains(task_id), + "终态必须从 live 集合摘除" + ); + + // 终态后 live 已摘除:把账本记录改回非终态(模拟进程重启后的残留),读取必须收口 failed。 + let mut stored = read_ledger(&root).expect("read ledger"); + stored[0].status = ASSET_GENERATION_TASK_STATUS_RUNNING.to_string(); + write_ledger(&root, &stored).expect("write ledger"); + let settled = list_local_project_asset_generation_tasks(&root).expect("list settled"); + assert_eq!(settled[0].status, ASSET_GENERATION_TASK_STATUS_FAILED); + assert_eq!( + settled[0].phase_detail, + ASSET_GENERATION_TASK_PHASE_NAMING_INTERRUPTED + ); + std::fs::remove_dir_all(&root).ok(); + } } diff --git a/apps/ai-game-creator-shell/src-tauri/src/asset_generation_tasks/runtime.rs b/apps/ai-game-creator-shell/src-tauri/src/asset_generation_tasks/runtime.rs index 4a3ccad0d..87a925647 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/asset_generation_tasks/runtime.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/asset_generation_tasks/runtime.rs @@ -5,7 +5,6 @@ use crate::agent::{ use crate::commands::prepare_local_project_asset_generation; use crate::project::run_local_project_audio_generation_at; -pub(crate) const ASSET_GENERATION_TASK_STATUS_RUNNING: &str = "running"; const ASSET_GENERATION_TASK_PHASE_RUNNING: &str = "正在生成。"; /// 音频任务收口:通道跑完但没有登记出素材(`derive` 在有源 / 无源两条路上都必须登记 assets)。 const ASSET_GENERATION_AUDIO_MISSING_ASSET_ERROR: &str = "生成完成但未登记素材"; 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 227bb6be7..98ad052bc 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/desktop.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/desktop.rs @@ -653,6 +653,8 @@ pub(super) fn run() { generate_local_project_asset, start_local_project_asset_generation, list_local_project_asset_generations, + enqueue_local_project_naming_task, + update_local_project_naming_task, open_canvas_project, run_limited_local_command, append_local_permission_log, 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 abcfce24c..4aad18563 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 @@ -117,6 +117,58 @@ async function suggestAutomaticProjectName( } } +/** 「AI 项目命名」任务的结论文案:结论只在这条链路里,所以文案由命名链路给定。 */ +const PROJECT_NAMING_FAILED_PHASE = + 'AI 命名请求失败或没有返回可用名称,已保留兜底名。'; +const PROJECT_NAMING_UNCHANGED_PHASE = 'AI 建议名与当前名称一致,无需改名。'; +const PROJECT_NAMING_RENAME_FAILED_PHASE = '自动改名失败,已保留兜底名。'; +const PROJECT_NAMING_SKIPPED_PHASE = + '当前名称已不是本次创建的兜底名(用户已手动改名),已跳过 AI 命名。'; + +/** + * 推进「生成任务」列表里的那条「AI 项目命名」任务。 + * + * 账本是**展示旁路**:旧壳没有这两条命令、账本写盘失败或权限拒绝都不该影响命名与建项, + * 所以失败只写诊断日志、绝不向上抛。 + * + * 两条命令名都写成**字面量**:渲染层的 invoke 目标必须能被 `scripts/check-config.mjs` 静态枚举 + * (命令名走变量会让门禁报「App invoke 缺命令」)。 + */ +async function enqueueProjectNamingTask( + invoke: TauriInvoke, + input: { projectPath: string; taskId: string }, +): Promise { + try { + await invoke('enqueue_local_project_naming_task', input); + } catch (error) { + appendWebviewLog('warn', [ + `[home-project-name] enqueue_local_project_naming_task: ${ + error instanceof Error ? error.message : String(error) + }`, + ]); + } +} + +async function updateProjectNamingTask( + invoke: TauriInvoke, + input: { + projectPath: string; + taskId: string; + status: 'running' | 'completed' | 'failed'; + phaseDetail?: string; + }, +): Promise { + try { + await invoke('update_local_project_naming_task', input); + } catch (error) { + appendWebviewLog('warn', [ + `[home-project-name] update_local_project_naming_task: ${ + error instanceof Error ? error.message : String(error) + }`, + ]); + } +} + /** * 把浏览器 File 上传进项目并登记为资产,返回带项目相对路径的附件记录。 * @@ -432,14 +484,18 @@ export function useHomeProjectCreation({ /** * 后台把 AI 建议名落到已创建的工作区。 * + * 这条链路同时是「生成任务」列表里那条「AI 项目命名」任务的推进者: + * `enqueue_local_project_naming_task`(排队中)→ `update_…(running)` → 终态 + * (completed / failed)。任务行与素材生成共用同一份账本、同一个变更事件与同一个侧栏, + * 状态流转对用户可见;账本推进失败只写诊断日志,绝不影响命名与建项本身。 + * * 与建项并行发起(`naming` 在建项前就已经打出请求);改名落盘本身不等任何东西, * 但「项目上下文」与「最近项目行」这两项都建立在进项目之后:AI 比进项目更快时直接写上下文 * 会被随后的 `enterProjectDevelopment` 用兜底名覆盖,而最近项目行也只有进项目登记过才会被 * 重检。所以它们等 `entrySettled`(建项主体收尾,含进项目成功/失败/让位于别的项目)。 * - 宿主侧条件改名要求「项目 ID 相同」且「当前名称仍是这次创建时的兜底名称」;用户已手动改名时 - * 返回 `renamed: false`,这里整体跳过,绝不覆盖用户输入。 - * - AI 返回空/非法/失败(Promise 解析为 null)或用例里没有可改名空间时,保持兜底名称。 - * - 任何失败都只写诊断日志,不改变已经进入的工作区与首轮创作。 + * 返回 `renamed: false`,这里整体跳过、绝不覆盖用户输入,任务按「已完成(已跳过)」收口。 + * - AI 返回空/非法/失败(Promise 解析为 null)时保持兜底名称,任务收口为 failed。 */ async function applyAutomaticProjectName( invoke: TauriInvoke, @@ -448,14 +504,35 @@ export function useHomeProjectCreation({ entrySettled: Promise, ) { const generation = lifecycleRef.current.generation; + const namingTask = { + projectPath: created.projectPath, + // 一个项目一条命名任务:重开后账本里读到的还是同一行,不会越攒越多。 + taskId: `project-naming:${created.manifest.projectId}`, + }; + await enqueueProjectNamingTask(invoke, namingTask); + await updateProjectNamingTask(invoke, { ...namingTask, status: 'running' }); let suggested: string | null = null; try { suggested = (await naming)?.trim() || null; } catch { + suggested = null; + } + // 没有可用的建议名:保留兜底名,任务按失败收口(AI 失败 / 超时 / 非法内容同一条路径)。 + if (!suggested) { + await updateProjectNamingTask(invoke, { + ...namingTask, + status: 'failed', + phaseDetail: PROJECT_NAMING_FAILED_PHASE, + }); return; } - // 建议名就是兜底名(或没有建议):没有可改的,别白跑一次写操作。 - if (!suggested || suggested === created.manifest.name) { + // 建议名就是兜底名:没有可改的,别白跑一次写操作,按完成收口并说明原因。 + if (suggested === created.manifest.name) { + await updateProjectNamingTask(invoke, { + ...namingTask, + status: 'completed', + phaseDetail: PROJECT_NAMING_UNCHANGED_PHASE, + }); return; } let outcome: ConditionalRenameLocalProjectResult; @@ -473,11 +550,27 @@ export function useHomeProjectCreation({ appendWebviewLog('warn', [ `[home-project-name] ${error instanceof Error ? error.message : String(error)}`, ]); + await updateProjectNamingTask(invoke, { + ...namingTask, + status: 'failed', + phaseDetail: PROJECT_NAMING_RENAME_FAILED_PHASE, + }); return; } if (!outcome?.renamed) { + // 用户在结果回来之前已经手动改过名(或项目身份已变):跳过,并如实说明跳过原因。 + await updateProjectNamingTask(invoke, { + ...namingTask, + status: 'completed', + phaseDetail: PROJECT_NAMING_SKIPPED_PHASE, + }); return; } + await updateProjectNamingTask(invoke, { + ...namingTask, + status: 'completed', + phaseDetail: `已应用 AI 名称「${outcome.manifest.name}」。`, + }); await entrySettled; /** * 等待期间壳可能已经被卸载(关窗 / 切走 / StrictMode 重放)。 diff --git a/apps/ai-game-creator-shell/src/features/resource-canvas/ResourceCanvasAssetGenerationTasksPanelView.tsx b/apps/ai-game-creator-shell/src/features/resource-canvas/ResourceCanvasAssetGenerationTasksPanelView.tsx index 3399d5376..f8d22cb4f 100644 --- a/apps/ai-game-creator-shell/src/features/resource-canvas/ResourceCanvasAssetGenerationTasksPanelView.tsx +++ b/apps/ai-game-creator-shell/src/features/resource-canvas/ResourceCanvasAssetGenerationTasksPanelView.tsx @@ -6,11 +6,14 @@ import { useEffect, useMemo, useRef, useState } from 'react'; import { useLiveNow } from '../project-workspace/useLiveNow'; import { RESOURCE_CANVAS_ASSET_GENERATION_STATUS_LABELS, + RESOURCE_CANVAS_PROJECT_NAMING_STATUS_LABELS, + RESOURCE_CANVAS_PROJECT_NAMING_TASK_TITLE, resourceCanvasAssetGenerationElapsedLabel, type ResourceCanvasAssetGenerationTask, resourceCanvasAssetGenerationTaskElapsedMillis, resourceCanvasAssetGenerationTaskIsTerminal, resourceCanvasAssetGenerationTaskTone, + type ResourceCanvasProjectNamingTask, sortResourceCanvasAssetGenerationTasks, } from './resourceCanvasAssetGenerationTaskModel'; import { @@ -39,6 +42,13 @@ export type ResourceCanvasAssetGenerationTasksPanelViewProps = { * 账本渲染。缺省为空数组:老调用方不传就没有这一段。 */ resourceEditTasks?: readonly ResourceCanvasResourceEditTask[]; + /** + * 「AI 项目命名」任务(首页建项后的后台自动命名,账本里 `taskType = project-naming`)。 + * + * 与素材生成共用同一份账本、同一个变更事件与同一个侧栏:展示名固定为「AI 项目命名」,不渲染 + * 素材缩略图 / 提示词 / 派发按钮,也不参与「定位到素材」。缺省为空数组。 + */ + namingTasks?: readonly ResourceCanvasProjectNamingTask[]; /** 侧栏是否展开;折叠时只留贴边把手。 */ open: boolean; onToggleOpen: () => void; @@ -75,22 +85,37 @@ type ResourceCanvasGenerationTaskRow = readonly source: 'resource-edit'; readonly createdAtMillis: number; readonly task: ResourceCanvasResourceEditTask; + } + | { + readonly source: 'project-naming'; + readonly createdAtMillis: number; + readonly task: ResourceCanvasProjectNamingTask; }; function resourceCanvasGenerationTaskRowKey( row: ResourceCanvasGenerationTaskRow, ): string { - return row.source === 'asset-generation' - ? row.task.taskId - : row.task.operationId; + switch (row.source) { + case 'asset-generation': + return row.task.taskId; + case 'resource-edit': + return row.task.operationId; + default: + return row.task.taskId; + } } function resourceCanvasGenerationTaskRowIsTerminal( row: ResourceCanvasGenerationTaskRow, ): boolean { - return row.source === 'asset-generation' - ? resourceCanvasAssetGenerationTaskIsTerminal(row.task) - : resourceCanvasResourceEditTaskIsTerminal(row.task); + switch (row.source) { + case 'asset-generation': + return resourceCanvasAssetGenerationTaskIsTerminal(row.task); + case 'resource-edit': + return resourceCanvasResourceEditTaskIsTerminal(row.task); + default: + return row.task.status === 'completed' || row.task.status === 'failed'; + } } function sortResourceCanvasGenerationTaskRows( @@ -256,13 +281,89 @@ function resourceEditTaskRow(task: ResourceCanvasResourceEditTask) { ); } +/** + * 「AI 项目命名」行的已耗时。 + * + * 与素材任务 / 派生任务共用同一份 `useLiveNow` 口径(终态定格在账本结束时间上)。 + * **必须是一个组件**:行本身由函数返回,把 hook 直接留在行函数里会让「行数与行种类」的变化 + * 变成 hooks 数量的变化(React 直接抛「Rendered more hooks than during the previous render」)。 + */ +function ProjectNamingElapsedLabel({ + task, +}: { + task: ResourceCanvasProjectNamingTask; +}) { + const terminal = task.status === 'completed' || task.status === 'failed'; + const now = useLiveNow(!terminal); + return ( + + {`已耗时 ${resourceCanvasAssetGenerationElapsedLabel( + resourceCanvasAssetGenerationTaskElapsedMillis(task, now), + )}`} + + ); +} + +/** + * 「AI 项目命名」任务的一行(首页建项后的后台自动命名)。 + * + * 与素材任务同一套卡片 class 与 tone,但语义上刻意更窄:**展示名固定**为 + * 「AI 项目命名」,没有缩略图、没有提示词、没有派发/定位动作。收口结论(`phaseDetail`, + * 由后端拥有:已应用 / 用户已改名跳过 / 失败保留兜底名)**只在终态渲染**——进行中由状态徽章 + * 表达,用户不需要看素材通道那种阶段进度文案。 + */ +function projectNamingTaskRow(task: ResourceCanvasProjectNamingTask) { + const terminal = task.status === 'completed' || task.status === 'failed'; + return ( +
  • +
    + + {RESOURCE_CANVAS_PROJECT_NAMING_TASK_TITLE} + + + AI 起名 + +
    +
    + + {RESOURCE_CANVAS_PROJECT_NAMING_STATUS_LABELS[task.status]} + + {terminal ? ( + + {task.phaseDetail} + + ) : null} + +
    + {task.error ? ( +

    + {task.error} +

    + ) : null} +
  • + ); +} + function resourceCanvasGenerationTaskRowNode( row: ResourceCanvasGenerationTaskRow, onFocusTask: (task: ResourceCanvasAssetGenerationTask) => void, ) { - return row.source === 'asset-generation' - ? assetGenerationTaskRow(row.task, onFocusTask) - : resourceEditTaskRow(row.task); + switch (row.source) { + case 'asset-generation': + return assetGenerationTaskRow(row.task, onFocusTask); + case 'resource-edit': + return resourceEditTaskRow(row.task); + default: + return projectNamingTaskRow(row.task); + } } /** @@ -285,6 +386,7 @@ function resourceCanvasGenerationTaskRowNode( export function ResourceCanvasAssetGenerationTasksPanelView({ tasks, resourceEditTasks = [], + namingTasks = [], open, onToggleOpen, onFocusTask, @@ -315,8 +417,13 @@ export function ResourceCanvasAssetGenerationTasksPanelView({ createdAtMillis: task.createdAtMillis, task, })), + ...namingTasks.map((task) => ({ + source: 'project-naming' as const, + createdAtMillis: task.createdAtMillis, + task, + })), ]), - [resourceEditTasks, tasks], + [namingTasks, resourceEditTasks, tasks], ); const active = ordered.filter( (row) => !resourceCanvasGenerationTaskRowIsTerminal(row), diff --git a/apps/ai-game-creator-shell/src/features/resource-canvas/resourceCanvasAssetGenerationTaskModel.ts b/apps/ai-game-creator-shell/src/features/resource-canvas/resourceCanvasAssetGenerationTaskModel.ts index a7df6b4ba..57837856c 100644 --- a/apps/ai-game-creator-shell/src/features/resource-canvas/resourceCanvasAssetGenerationTaskModel.ts +++ b/apps/ai-game-creator-shell/src/features/resource-canvas/resourceCanvasAssetGenerationTaskModel.ts @@ -25,7 +25,15 @@ export type ResourceCanvasAssetGenerationTaskStatus = export type LocalProjectAssetGenerationTaskRecord = { taskId: string; projectId: string; - kind: GameCreationAppAssetKind; + /** + * 这条任务属于哪一类:素材生成(既有)或项目命名(首页建项后的 AI 命名)。 + * + * 旧壳(账本 v1)没有这个字段,一律按 `asset-generation` 读——见 + * `resolveLocalProjectGenerationTaskType`。 + */ + taskType?: LocalProjectGenerationTaskType | null; + /** 素材类任务才有;命名任务为 null。 */ + kind?: GameCreationAppAssetKind | null; assetName: string; status: string; phaseDetail: string; @@ -36,6 +44,34 @@ export type LocalProjectAssetGenerationTaskRecord = { error: string | null; }; +/** + * 账本里的任务类型。 + * + * 两者共用同一份账本、同一个变更事件与同一个「生成任务」侧栏:素材生成按后端账本推进, + * 项目命名由首页后台命名链路推进(`enqueue_local_project_naming_task` / + * `update_local_project_naming_task`)。命名任务不派发素材生成请求、没有素材缩略图与提示词。 + */ +export type LocalProjectGenerationTaskType = + | 'asset-generation' + | 'project-naming'; + +export const LOCAL_PROJECT_GENERATION_TASK_TYPE_ASSET_GENERATION = + 'asset-generation'; + +export const LOCAL_PROJECT_GENERATION_TASK_TYPE_PROJECT_NAMING = + 'project-naming'; + +/** 「生成任务」列表里「AI 项目命名」这条任务的固定展示名。 */ +export const RESOURCE_CANVAS_PROJECT_NAMING_TASK_TITLE = 'AI 项目命名'; + +export function resolveLocalProjectGenerationTaskType( + value: string | null | undefined, +): LocalProjectGenerationTaskType { + return value === LOCAL_PROJECT_GENERATION_TASK_TYPE_PROJECT_NAMING + ? 'project-naming' + : 'asset-generation'; +} + /** 宿主「生成任务」列表里的一条:本地排队信息 + 后端账本记录。 */ export type ResourceCanvasAssetGenerationTask = { /** 本地任务 id,同时作为提交给后端的 taskId(重开项目后靠它对上账本记录)。 */ @@ -373,6 +409,92 @@ export function restoreResourceCanvasAssetGenerationTask( }; } +/** + * 「生成任务」列表里的一条「AI 项目命名」任务。 + * + * 它与素材生成共用同一份账本、同一个变更事件与同一个侧栏行样式,但**只**保留状态流转需要的 + * 字段:没有素材缩略图、没有提示词、没有比例/落点,也不参与素材派发与「定位到素材」。 + */ +export type ResourceCanvasProjectNamingTask = { + taskId: string; + projectId: string; + status: ResourceCanvasAssetGenerationTaskStatus; + /** + * 收口结论(后端拥有)。**只在终态渲染**:草稿期用状态徽章表达,用户不需要看「排队中。」这类 + * 素材通道的阶段文案。 + */ + phaseDetail: string; + createdAtMillis: number; + startedAtMillis: number | null; + finishedAtMillis: number | null; + error: string | null; +}; + +/** 命名任务的状态文案:只有 `running` 与素材生成(「生成中」)不同。 */ +export const RESOURCE_CANVAS_PROJECT_NAMING_STATUS_LABELS: Record< + ResourceCanvasAssetGenerationTaskStatus, + string +> = { + queued: '排队中', + running: '命名中', + completed: '已完成', + failed: '失败', +}; + +/** 账本记录 → 命名任务行(重开项目、或入队当帧从账本读回时恢复显示)。 */ +export function restoreResourceCanvasProjectNamingTask( + record: LocalProjectAssetGenerationTaskRecord, +): ResourceCanvasProjectNamingTask { + return { + taskId: record.taskId, + projectId: record.projectId, + status: resolveResourceCanvasAssetGenerationTaskStatus(record.status), + phaseDetail: record.phaseDetail, + createdAtMillis: record.createdAtMillis, + startedAtMillis: record.startedAtMillis, + finishedAtMillis: record.finishedAtMillis, + error: record.error, + }; +} + +/** + * 把一次账本读取按任务类型拆开。 + * + * 旧壳(账本 v1)的记录没有 `taskType`,一律按素材生成读;命名记录**不能**进素材任务列表 + * (那里会按 `kind` 解析并参与派发)。 + */ +export function splitLocalProjectGenerationTaskRecords( + records: readonly LocalProjectAssetGenerationTaskRecord[] | null | undefined, +): { + assetRecords: LocalProjectAssetGenerationTaskRecord[]; + namingRecords: LocalProjectAssetGenerationTaskRecord[]; +} { + const assetRecords: LocalProjectAssetGenerationTaskRecord[] = []; + const namingRecords: LocalProjectAssetGenerationTaskRecord[] = []; + if (!Array.isArray(records)) { + return { assetRecords, namingRecords }; + } + for (const record of records) { + if ( + typeof record !== 'object' || + record === null || + typeof record.taskId !== 'string' || + record.taskId.trim().length === 0 + ) { + continue; + } + if ( + resolveLocalProjectGenerationTaskType(record.taskType) === + LOCAL_PROJECT_GENERATION_TASK_TYPE_PROJECT_NAMING + ) { + namingRecords.push(record); + } else { + assetRecords.push(record); + } + } + return { assetRecords, namingRecords }; +} + /** * 本地任务 + 后端记录 → 下一版任务列表。 * @@ -384,6 +506,8 @@ export function restoreResourceCanvasAssetGenerationTask( * 账本文件读坏、命令未注册都可能给出 `undefined` 或别的形状)。非数组一律按「没有后端记录」 * 处理——抛出去会变成 effect 里的未处理 Promise 拒绝,把「任务列表暂不可用」升级成整块视图出错。 * 数组里的垃圾条目(`null` / 数字 / 缺 `taskId`)同样逐条丢弃,不让一条坏记录带崩整次恢复。 + * 命名任务是**另一类行**(`splitLocalProjectGenerationTaskRecords`),这里一律剔除,避免拿 + * `kind` 解析它们。 */ export function applyLocalProjectAssetGenerationRecords( tasks: readonly ResourceCanvasAssetGenerationTask[], @@ -397,7 +521,9 @@ export function applyLocalProjectAssetGenerationRecords( typeof (record as LocalProjectAssetGenerationTaskRecord).taskId === 'string' && (record as LocalProjectAssetGenerationTaskRecord).taskId.trim() - .length > 0, + .length > 0 && + resolveLocalProjectGenerationTaskType(record.taskType) === + LOCAL_PROJECT_GENERATION_TASK_TYPE_ASSET_GENERATION, ) : []; const recordsByTaskId = new Map( diff --git a/apps/ai-game-creator-shell/src/view/project-development/index.tsx b/apps/ai-game-creator-shell/src/view/project-development/index.tsx index 50e0c00dc..0d92de597 100644 --- a/apps/ai-game-creator-shell/src/view/project-development/index.tsx +++ b/apps/ai-game-creator-shell/src/view/project-development/index.tsx @@ -167,7 +167,10 @@ import { createResourceCanvasAudioGenerationTask, type LocalProjectAssetGenerationTaskRecord, type ResourceCanvasAssetGenerationTask, + type ResourceCanvasProjectNamingTask, resourceCanvasAssetGenerationTaskIsTerminal, + restoreResourceCanvasProjectNamingTask, + splitLocalProjectGenerationTaskRecords, } from '../../features/resource-canvas/resourceCanvasAssetGenerationTaskModel'; import { ResourceCanvasAssetGenerationTasksPanelView } from '../../features/resource-canvas/ResourceCanvasAssetGenerationTasksPanelView'; import { @@ -2084,6 +2087,16 @@ export default function ProjectDevelopmentView({ resourceAssetGenerationTasksPanelOpen, setResourceAssetGenerationTasksPanelOpen, ] = useState(false); + /** + * 「AI 项目命名」任务(首页建项后的后台自动命名,账本 `taskType = project-naming`)。 + * + * 它与素材生成共用同一份账本与同一个「生成任务」侧栏,但推进不由本组件驱动:命名在首页那边跑 + * (`enqueue_local_project_naming_task` / `update_local_project_naming_task`),这里只按 + * 变更事件做一次受控快照读取把状态显示出来。 + */ + const [projectNamingTasks, setProjectNamingTasks] = useState< + ResourceCanvasProjectNamingTask[] + >([]); /** * 占位落点要用的几何:栏目位置表与卡片尺寸表。两者都在本渲染靠后处才算出来, * 所以用 ref 暴露给占位 Hook(工具点击发生在那之后,读到的总是最新一帧)。 @@ -8926,6 +8939,7 @@ export default function ProjectDevelopmentView({ const projectId = manifest.projectId; let cancelled = false; setResourceAssetGenerationTasksPanelOpen(false); + setProjectNamingTasks([]); resourceAssetGenerationPanelSubmissionRef.current = null; setResourceAssetGenerationPanelReopen(null); // 切项目:生成浮层与它的占位都不跨项目(占位本身由占位 Hook 按 projectId 收口)。 @@ -8962,20 +8976,87 @@ export default function ProjectDevelopmentView({ if (cancelled) { return; } + const { assetRecords, namingRecords } = + splitLocalProjectGenerationTaskRecords(records); const restored = mergeResourceCanvasAssetGenerationTasksWithRecords( resourceAssetGenerationTasksRef.current.filter( (task) => task.projectId === projectId, ), - records, + assetRecords, ); resourceAssetGenerationTasksRef.current = restored; setResourceAssetGenerationTasks(restored); + setProjectNamingTasks( + namingRecords.map(restoreResourceCanvasProjectNamingTask), + ); })(); return () => { cancelled = true; }; }, [manifest.projectId, projectPath]); + /** + * 刷新「AI 项目命名」行:从账本重读一次(受控快照读取)。 + * + * 读失败或返回值不是数组时**保留上一次的命名行**——一次 IPC 失败不该把列表清空。 + */ + const refreshProjectNamingTasks = useCallback(async () => { + const invoke = window.__TAURI__?.core?.invoke; + if (!invoke) { + return; + } + try { + const response = await invoke( + 'list_local_project_asset_generations', + { projectPath }, + ); + if (!Array.isArray(response)) { + return; + } + setProjectNamingTasks( + splitLocalProjectGenerationTaskRecords(response).namingRecords.map( + restoreResourceCanvasProjectNamingTask, + ), + ); + } catch { + // 账本读不到:保留上一次的命名行,不猜状态。 + } + }, [projectPath]); + + /** + * 「AI 项目命名」的推进发生在本组件之外(首页后台命名链路),所以这里只订阅账本变更事件, + * 在事件属于当前项目时做**一次**受控快照读取(团队口径:正式状态变化走事件 + 一次快照读取, + * 不新增固定频率轮询)。素材任务仍按各自既有的账本/队列路径推进,不在这里改。 + */ + useEffect(() => { + if (!canSubscribeTauriEvents() || !projectPath.trim()) { + return undefined; + } + let disposed = false; + let unsubscribe: (() => void) | null = null; + void subscribeTauriEvent<{ projectPath?: string }>( + RESOURCE_CANVAS_ASSET_GENERATION_CHANGED_EVENT, + (event) => { + if (disposed || event.payload?.projectPath !== projectPath) { + return; + } + void refreshProjectNamingTasks(); + }, + ) + .then((dispose) => { + if (disposed) { + dispose(); + } else { + unsubscribe = dispose; + } + }) + .catch(() => undefined); + return () => { + disposed = true; + unsubscribe?.(); + }; + }, [projectPath, refreshProjectNamingTasks]); + /** * 「生成任务」面板里点一条已完成任务:复用既有聚焦链定位到它的素材卡。 * @@ -11128,6 +11209,9 @@ export default function ProjectDevelopmentView({ !task.restored || !resourceCanvasResourceEditTaskIsTerminal(task), )} + namingTasks={projectNamingTasks.filter( + (task) => task.projectId === manifest.projectId, + )} open={resourceAssetGenerationTasksPanelOpen} onToggleOpen={() => setResourceAssetGenerationTasksPanelOpen((current) => !current) 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 02c685ccd..3864f4779 100644 --- a/apps/ai-game-creator-shell/tests/appSurface/home.suite.ts +++ b/apps/ai-game-creator-shell/tests/appSurface/home.suite.ts @@ -1820,6 +1820,28 @@ export function registerHomeProjectCreationTests() { ); const renamedManifest = { ...fallbackManifest, name: '星轨夜航' }; let renamed = false; + // 「生成任务」账本里的那条「AI 项目命名」:入队/推进由首页链路写,这里按同一份状态回放给工作台。 + let namingLedgerStatus = 'running'; + let namingLedgerPhase = '正在等待 AI 命名结果。'; + const namingLedger = () => [ + { + taskId: `project-naming:${fallbackManifest.projectId}`, + projectId: fallbackManifest.projectId, + taskType: 'project-naming', + kind: null, + assetName: 'AI 项目命名', + status: namingLedgerStatus, + phaseDetail: namingLedgerPhase, + createdAtMillis: 1, + startedAtMillis: 2, + finishedAtMillis: null, + assetId: null, + error: null, + }, + ]; + const namingTaskChangedHandlers = new Set< + (event: { payload: unknown }) => void + >(); // 命名闸门手控:先证明"命名还没回来时已经进了工作区",再放行命名验证同步。 let resolveNaming!: (name: string) => void; const namingGate = new Promise((resolve) => { @@ -1840,6 +1862,19 @@ export function registerHomeProjectCreationTests() { } // 命名与建项并行:建项拿不到名字,AI 结果只走后台条件改名。 if (command === 'suggest_automatic_project_name') return namingGate; + if (command === 'enqueue_local_project_naming_task') { + namingLedgerStatus = 'queued'; + namingLedgerPhase = '排队中。'; + return namingLedger()[0]; + } + if (command === 'update_local_project_naming_task') { + namingLedgerStatus = String(args?.status ?? namingLedgerStatus); + namingLedgerPhase = String(args?.phaseDetail ?? namingLedgerPhase); + return namingLedger()[0]; + } + if (command === 'list_local_project_asset_generations') { + return namingLedger(); + } if (command === 'rename_local_game_project_if_unchanged') { expect(args).toEqual({ projectPath, @@ -1878,9 +1913,22 @@ export function registerHomeProjectCreationTests() { return chatHarness.invoke(command, args); }, ); + // 账本变更事件由工作台订阅:这里把它记下来,测试可以按「一次变更 → 一次快照读取」驱动刷新。 + const listen = async ( + eventName: string, + handler: (event: { payload: unknown }) => void, + ) => { + if (eventName === 'game-creator-asset-generation-task-changed') { + namingTaskChangedHandlers.add(handler); + return () => { + namingTaskChangedHandlers.delete(handler); + }; + } + return chatHarness.listen(eventName, handler); + }; installTauriRuntime({ core: { invoke }, - event: { listen: chatHarness.listen }, + event: { listen }, }); window.localStorage.setItem( 'genarrative-ai-game-creator.recent-workspaces.v1', @@ -1907,6 +1955,17 @@ export function registerHomeProjectCreationTests() { expect.anything(), ); + // 「生成任务」侧栏里能看到这条在跑的「AI 项目命名」(与素材生成共用同一份账本与列表)。 + fireEvent.click(screen.getByLabelText('生成任务')); + expect(await screen.findByText('AI 项目命名')).not.toBeNull(); + expect( + within( + document.querySelector( + '[data-task-source="project-naming"]', + ) as HTMLElement, + ).getByText('命名中'), + ).not.toBeNull(); + // 放行命名:AI 名称落定后走条件改名(工作台标题取自项目上下文,见 hook 用例)。 await act(async () => { resolveNaming('星轨夜航'); @@ -1919,6 +1978,21 @@ export function registerHomeProjectCreationTests() { ); }); + // 账本变更事件 → 一次受控快照读取:终态结论出现在同一行上(不用重开项目)。 + await act(async () => { + for (const handler of namingTaskChangedHandlers) { + handler({ + payload: { + projectPath, + taskId: `project-naming:${fallbackManifest.projectId}`, + }, + }); + } + }); + expect( + await screen.findByText('已应用 AI 名称「星轨夜航」。'), + ).not.toBeNull(); + // 最近项目行按目录重检读 manifest:列表最终也显示 AI 名称。 fireEvent.click(screen.getByRole('button', { name: '项目组' })); expect(await screen.findByText('星轨夜航')).not.toBeNull(); diff --git a/apps/ai-game-creator-shell/tests/homeProjectNamingAsync.test.tsx b/apps/ai-game-creator-shell/tests/homeProjectNamingAsync.test.tsx index f259c5981..9140f1874 100644 --- a/apps/ai-game-creator-shell/tests/homeProjectNamingAsync.test.tsx +++ b/apps/ai-game-creator-shell/tests/homeProjectNamingAsync.test.tsx @@ -38,34 +38,47 @@ function mount(options: { >; }) { 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('未预期的条件改名调用'); + const namingTaskCalls: Array<{ + command: string; + args: Record; + }> = []; + const invoke = vi.fn( + async (command: string, args?: Record) => { + if (command === 'capture_analytics_context') return {}; + if (command === 'preflight_web_game_creation') return { status: 'ready' }; + if ( + command === 'enqueue_local_project_naming_task' || + command === 'update_local_project_naming_task' + ) { + namingTaskCalls.push({ command, args: args ?? {} }); + return null; } - return options.conditionalRename(); - } - const extra = options.extraCommands?.[command]; - if (extra) { - return extra(); - } - return null; - }); + 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 () => {}); installTauriRuntime({ core: { invoke } }); const hook = renderHook(() => @@ -77,7 +90,23 @@ function mount(options: { refreshRecentWorkspace, }), ); - return { ...hook, invoke, order, refreshRecentWorkspace }; + return { ...hook, invoke, order, namingTaskCalls, refreshRecentWorkspace }; +} + +/** + * 「AI 项目命名」任务在账本里的推进序列:`命令:状态`。 + * + * 三个断言点共用同一份口径:入队(无 status)→ running → 终态。 + */ +function namingTaskStatusTrace( + calls: ReadonlyArray<{ command: string; args: Record }>, +): string[] { + return calls.map( + (call) => + `${call.command.replace('_local_project_naming_task', '')}:${ + typeof call.args.status === 'string' ? call.args.status : '' + }`, + ); } function createAutomatically( @@ -92,7 +121,9 @@ function createAutomatically( it('enters the workspace even though the AI naming request never returns', async () => { const naming = deferred(); - const { result, invoke, order } = mount({ naming: naming.promise }); + const { result, invoke, order, namingTaskCalls } = mount({ + naming: naming.promise, + }); let creation!: Promise; act(() => { @@ -121,6 +152,11 @@ it('enters the workspace even though the AI naming request never returns', async 'rename_local_game_project_if_unchanged', expect.anything(), ); + // 命名还没回来:任务已经入队并标成「命名中」,用户能在生成任务里看到它在跑。 + expect(namingTaskStatusTrace(namingTaskCalls)).toEqual([ + 'enqueue:', + 'update:running', + ]); await act(async () => { naming.resolve(null); @@ -130,7 +166,7 @@ it('enters the workspace even though the AI naming request never returns', async it('applies a valid AI name after it arrives and refreshes the recent row', async () => { const naming = deferred(); - const { result, invoke, refreshRecentWorkspace } = mount({ + const { result, invoke, namingTaskCalls, refreshRecentWorkspace } = mount({ naming: naming.promise, conditionalRename: async () => ({ renamed: true, @@ -167,6 +203,20 @@ it('applies a valid AI name after it arrives and refreshes the recent row', asyn ); expect(result.current.currentProjectContext?.projectRevision).toBe(7); expect(refreshRecentWorkspace).toHaveBeenCalledWith(PROJECT_PATH); + // 「生成任务」列表里那条「AI 项目命名」按入队 → 命名中 → 已完成推进,结论可自解释。 + expect(namingTaskStatusTrace(namingTaskCalls)).toEqual([ + 'enqueue:', + 'update:running', + 'update:completed', + ]); + expect(namingTaskCalls.map((call) => call.args.taskId)).toEqual([ + `project-naming:${PROJECT_ID}`, + `project-naming:${PROJECT_ID}`, + `project-naming:${PROJECT_ID}`, + ]); + expect(String(namingTaskCalls.at(-1)?.args.phaseDetail)).toContain( + SUGGESTED_NAME, + ); }); it('keeps the AI name when the naming result lands before the project is entered', async () => { @@ -226,7 +276,7 @@ it.each(['', ' ', null])( 'keeps the fallback name for a non-usable AI response (%s)', async (response) => { const naming = deferred(); - const { result, invoke } = mount({ + const { result, invoke, namingTaskCalls } = mount({ naming: naming.promise, conditionalRename: async () => { throw new Error('空/非法 AI 名称不应触发改名'); @@ -248,12 +298,21 @@ it.each(['', ' ', null])( expect(result.current.currentProjectContext?.projectName).toBe( FALLBACK_NAME, ); + // 失败/超时/非法内容:任务按 failed 收口,兜底名保留。 + expect(namingTaskStatusTrace(namingTaskCalls)).toEqual([ + 'enqueue:', + 'update:running', + 'update:failed', + ]); + expect(String(namingTaskCalls.at(-1)?.args.phaseDetail)).toContain( + '保留兜底名', + ); }, ); it('does not overwrite a name the user already changed', async () => { const naming = deferred(); - const { result } = mount({ + const { result, namingTaskCalls } = mount({ naming: naming.promise, // 用户已经手动改过名:宿主按「当前名称不再是兜底名」整体跳过。 conditionalRename: async () => ({ @@ -272,11 +331,20 @@ it('does not overwrite a name the user already changed', async () => { }); expect(result.current.currentProjectContext?.projectName).toBe(FALLBACK_NAME); + // 跳过不是失败:任务按 completed 收口,并且结论自己说清「为什么没改名」。 + expect(namingTaskStatusTrace(namingTaskCalls)).toEqual([ + 'enqueue:', + 'update:running', + 'update:completed', + ]); + expect(String(namingTaskCalls.at(-1)?.args.phaseDetail)).toContain( + '已跳过 AI 命名', + ); }); it('keeps the created workspace when the conditional rename fails', async () => { const naming = deferred(); - const { result } = mount({ + const { result, namingTaskCalls } = mount({ naming: naming.promise, conditionalRename: async () => { throw new Error('项目正在被其他写操作占用'); @@ -295,6 +363,14 @@ it('keeps the created workspace when the conditional rename fails', async () => expect(status).toBe('已创建工作区并进入项目开发'); expect(result.current.projectPath).toBe(PROJECT_PATH); expect(result.current.currentProjectContext?.projectName).toBe(FALLBACK_NAME); + expect(namingTaskStatusTrace(namingTaskCalls)).toEqual([ + 'enqueue:', + 'update:running', + 'update:failed', + ]); + expect(String(namingTaskCalls.at(-1)?.args.phaseDetail)).toContain( + '已保留兜底名', + ); }); it('never starts automatic naming for the 做方案 flow', async () => { diff --git a/apps/ai-game-creator-shell/tests/projectNamingGenerationTaskRow.test.tsx b/apps/ai-game-creator-shell/tests/projectNamingGenerationTaskRow.test.tsx new file mode 100644 index 000000000..c726888b7 --- /dev/null +++ b/apps/ai-game-creator-shell/tests/projectNamingGenerationTaskRow.test.tsx @@ -0,0 +1,159 @@ +/** @vitest-environment jsdom */ + +import { afterEach, describe, expect, it, vi } from 'vitest'; + +import type { ResourceCanvasProjectNamingTask } from '../src/features/resource-canvas/resourceCanvasAssetGenerationTaskModel'; +import { ResourceCanvasAssetGenerationTasksPanelView } from '../src/features/resource-canvas/ResourceCanvasAssetGenerationTasksPanelView'; +import { cleanup, render, screen, within } from './appSurface/harness'; + +/** + * 「生成任务」侧栏里的「AI 项目命名」行。 + * + * 它与素材生成共用同一份账本、同一个事件与同一套卡片样式,但有两条自己的口径: + * 1. **展示名固定**为「AI 项目命名」:不渲染素材缩略图 / 提示词 / 派发与「定位到素材」; + * 2. **结论只在终态渲染**:进行中由状态徽章表达(排队中 / 命名中),终态才显示后端给的结论 + * (已应用 AI 名称 / 已跳过 / 失败保留兜底名)。 + */ + +afterEach(cleanup); + +function namingTask( + overrides: Partial = {}, +): ResourceCanvasProjectNamingTask { + return { + taskId: 'project-naming:local-project-1', + projectId: 'local-project-1', + status: 'running', + phaseDetail: '正在等待 AI 命名结果。', + createdAtMillis: 1000, + startedAtMillis: 1200, + finishedAtMillis: null, + error: null, + ...overrides, + }; +} + +function renderPanel(tasks: readonly ResourceCanvasProjectNamingTask[]) { + render( + , + ); +} + +describe('生成任务侧栏里的 AI 项目命名行', () => { + it('进行中只给状态徽章,不渲染结论、缩略图、提示词或定位动作', () => { + renderPanel([namingTask()]); + + const row = document.querySelector( + '[data-task-source="project-naming"]', + ) as HTMLElement | null; + expect(row).not.toBeNull(); + expect(within(row!).getByText('AI 项目命名')).not.toBeNull(); + expect(within(row!).getByText('命名中')).not.toBeNull(); + // 进行中不显示后端阶段文案;也没有素材任务的缩略图/提示词/派发按钮。 + expect(within(row!).queryByText('正在等待 AI 命名结果。')).toBeNull(); + expect(within(row!).queryByRole('button', { name: /定位素材/ })).toBeNull(); + expect(row!.querySelector('img')).toBeNull(); + }); + + it('终态渲染可自解释的结论(已应用 / 已跳过 / 失败保留兜底名)', () => { + const { unmount } = render( + , + ); + const appliedRow = document.querySelector( + '[data-task-source="project-naming"]', + ) as HTMLElement | null; + expect(within(appliedRow!).getByText('已完成')).not.toBeNull(); + expect( + within(appliedRow!).getByText('已应用 AI 名称「星河远征」。'), + ).not.toBeNull(); + unmount(); + render( + , + ); + const skippedRow = document.querySelector( + '[data-task-source="project-naming"]', + ) as HTMLElement | null; + expect( + within(skippedRow!).getByText( + '当前名称已不是本次创建的兜底名(用户已手动改名),已跳过 AI 命名。', + ), + ).not.toBeNull(); + }); + + it('失败行显示失败徽章与原因,并计入在途计数', () => { + const { unmount } = render( + , + ); + const row = document.querySelector( + '[data-task-source="project-naming"]', + ) as HTMLElement | null; + expect(within(row!).getByText('失败')).not.toBeNull(); + expect( + within(row!).getByText( + 'AI 命名请求失败或没有返回可用名称,已保留兜底名。', + ), + ).not.toBeNull(); + expect(within(row!).getByRole('alert').textContent).toContain( + '自动项目命名超时', + ); + unmount(); + + // 收起态:在途的命名任务要出现在右上角开关的计数里(与素材任务同一个计数)。 + render( + , + ); + expect(screen.getByLabelText('生成任务').textContent).toContain( + '生成任务 · 1', + ); + }); +}); diff --git a/docs/project-memory/shared-memory/decision-log.md b/docs/project-memory/shared-memory/decision-log.md index f8abf4607..4ea316a14 100644 --- a/docs/project-memory/shared-memory/decision-log.md +++ b/docs/project-memory/shared-memory/decision-log.md @@ -3,10 +3,11 @@ ## 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` 用兜底名覆盖,最近项目行也要等进项目登记过才会被重检;写入前再过壳的生命周期守卫(`mounted` + 代次),关窗/卸载后只保留已落盘的改名、不写 UI 投影。「做方案」与手动选目录建项不发起自动命名。条件改名的 `expectedProjectId` / `expectedName` 为空时按仓库同类入口口径失败关闭(而不是静默 `renamed: false`)。 -- 影响范围:`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`(12 passed,含「命名请求永不返回仍进工作区」「AI 结果先于进项目落定仍不被兜底名覆盖」「手动改名不被覆盖」「非法/空响应保留兜底名」「做方案不发起命名」「切到别的工作区不被劫持」「卸载/pagehide 后不写上下文」);AGC 全量 `npm run test -- apps/ai-game-creator-shell/tests`(1922 passed / 17 skipped,合并 `origin/master` tip `ccd271572` 后复测);`cargo test --features=cocos-editor-execute,unity-editor-execute,godot-editor-execute conditional_project_rename_tests`(6 passed,含空 `expectedProjectId` / `expectedName` 失败关闭);`npm run agc:typecheck`(含 `check:tests:types`)、`cargo fmt --check`、`npm run check:encoding`、`git diff --check`。 +- 决策(建项与命名解耦):建项固定传 `name: null`,由宿主既有兜底名(`GameAgent 项目 <8 位短 id>` / `策划项目 <8 位短 id>`)落盘并立即进入项目;命名请求在建项前并行打出、结果交给后台任务。后台拿到合法名称后调用新增的 `rename_local_game_project_if_unchanged(projectPath, expectedProjectId, expectedName, name)`:只有「项目 ID 相同」且「当前名称仍是本次创建的兜底名」才改名,返回 `renamed: true/false`(跳过时不写盘、不是错误)。用户已手动改名、项目 ID 不符、名称与现状相同一律跳过;空 `expectedProjectId` / `expectedName`、空名 / 控制字符 / 超长一律失败关闭。 +- 决策(纳入「生成任务」体系):自动命名做成**项目工作台那条既有「生成任务」列表里的一条后台任务**,而不只是页面里的一个 promise。账本记录加 `taskType`(`asset-generation` / `project-naming`,缺省 `asset-generation`)、`kind` 变可选,schema 升 `agc-asset-generation-task.v2`(读取同时接受 v1/v2,v1 记录按素材任务读回);新增 `enqueue_local_project_naming_task`(排队中)/ `update_local_project_naming_task`(命名中 → 已完成/失败),展示名固定「AI 项目命名」,与素材生成共用同一份账本、同一个变更事件与同一个侧栏。状态流转:排队中 → 命名中 → 已完成(已应用 AI 名称 / 建议名与现状一致 / 用户已手动改名而跳过)或失败(无可用名称 / 自动改名失败,均保留兜底名)。命名任务由命名链路推进、**不进 live 集合就会被中断收口误判**,所以 enqueue 写账本前登记 live、update 终态写盘后摘除(中断残留仍按命名口径收口为失败)。前端按 `taskType` 把命名记录从素材任务分支里剔除,命名行不渲染缩略图/提示词/派发与定位动作,结论只在终态显示。 +- 边界:改名是簿记写入,与既有 `rename_local_game_project` 同口径**不推进项目 revision**(推 revision 会让运行时验证凭证无故漂移);返回的 `revision` 是当时盘上的值。后台改名结果写「当前项目上下文」与「最近项目行重检」必须等建项主体收尾(`entrySettled`):AI 比进项目更快时直接写上下文会被随后的 `enterProjectDevelopment` 用兜底名覆盖,最近项目行也要等进项目登记过才会被重检;写入前再过壳的生命周期守卫(`mounted` + 代次),关窗/卸载后只保留已落盘的改名,不写 UI 投影。账本是**展示旁路**:`enqueue` / `update` 失败只写诊断日志,绝不影响建项、命名与首轮创作;「做方案」与手动选目录建项不发起自动命名。本次未改共享契约(`packages/shared/**`)、server-rs 与任何 SpacetimeDB schema/HTTP 路由。 +- 影响范围:`apps/ai-game-creator-shell/src-tauri/src/{commands.rs,desktop.rs,asset_generation_tasks.rs,asset_generation_tasks/runtime.rs}`、`apps/ai-game-creator-shell/src/{app/types.ts,features/app-shell/{useHomeProjectCreation.ts,WorkspaceLauncher.tsx},features/resource-canvas/{resourceCanvasAssetGenerationTaskModel.ts,ResourceCanvasAssetGenerationTasksPanelView.tsx},view/project-development/index.tsx}`、`apps/ai-game-creator-shell/tests/{homeProjectNamingAsync.test.tsx,projectNamingGenerationTaskRow.test.tsx,appSurface/home.suite.ts}`。 +- 验证:`npx vitest run apps/ai-game-creator-shell/tests/homeProjectNamingAsync.test.tsx`(12 passed,含「命名请求永不返回仍进工作区」「AI 结果先于进项目落定仍不被兜底名覆盖」「手动改名不被覆盖且任务按已完成+已跳过收口」「非法/空响应 → 任务 failed 且保留兜底名」「做方案不发起命名」「切到别的工作区不被劫持」「卸载/pagehide 后不写上下文」,并断言入队→命名中→终态的账本推进序列);`projectNamingGenerationTaskRow.test.tsx`(3 passed:固定展示名/不渲染缩略图提示词定位、终态才显示结论、失败徽章与原因 + 在途计数);AGC 全量 `npm run test -- apps/ai-game-creator-shell/tests`(1925 passed / 17 skipped);`cargo test --features=cocos-editor-execute,unity-editor-execute,godot-editor-execute asset_generation_task`(22 passed,含 `naming_task_stays_in_flight_across_ledger_reads_until_terminal` 与 `legacy_v1_ledger_reads_back_with_the_default_asset_generation_task_type`)与 `conditional_project_rename_tests`(6 passed);`npm run agc:typecheck`(含 `check:tests:types`)、`cargo fmt --check`、`npm run check:encoding`、`git diff --check`。 ## 2026-10-03 AGC 栏目画布上传素材按入口栏目登记(Issue 359) diff --git a/docs/project-memory/shared-memory/pitfalls.md b/docs/project-memory/shared-memory/pitfalls.md index 45302825f..ca5122260 100644 --- a/docs/project-memory/shared-memory/pitfalls.md +++ b/docs/project-memory/shared-memory/pitfalls.md @@ -2,6 +2,15 @@ 这里只记录对当前开发仍有用的症状、根因、排查方法和风险边界。同一事实保留一个当前口径;退役对象的专属过程与单轮测试结果由 Git 历史追溯。遇到旧路径或版本时,以现行代码和专题文档为准。 +## 2026-10-04 把非素材任务塞进「生成任务」账本:taskType 维度、v1 兼容与 live 集合 + +- **现象**:首页「AI 项目命名」改成进「生成任务」列表后,第一次读账本(工作台打开项目就会读)就把那条还在跑的命名任务标成**失败**;随后前端的终态推进被拒(记录已终态)。 +- **根因**:账本的中断收口判据是「非终态 + 不在本进程 live 集合」= 上次运行的残留。素材生成任务由 Rust 后台任务在 `start` 里先登记 live 再落账本;项目命名任务由前端驱动(`enqueue` / `update` 两条 IPC),没有登记 live,于是任何一次 `list_local_project_asset_generations` 都把它当成残留收口。 +- **处理(现行口径)**:账本记录加 `taskType`(`asset-generation` / `project-naming`,缺省 `asset-generation`)、`kind` 变可选;schema 升 `agc-asset-generation-task.v2`,读取同时接受 v1 与 v2(v1 记录按素材任务读回)。`enqueue_local_project_naming_task` 写账本前登记 live(写盘失败只回滚本轮插入的那条),`update_local_project_naming_task` 在非终态时幂等登记、终态写盘成功后摘除。命名没有素材可交叉核对,中断收口一律 failed 且文案与素材口径区分(不含「目标素材」)。 +- **边界**:命名任务的结论文案(已应用 / 用户已改名跳过 / 失败保留兜底名)由命名链路给出;账本只是展示旁路——`enqueue` / `update` 失败只写诊断日志,绝不影响建项、命名与首轮创作。前端按 `taskType` 把命名记录从素材任务列表里剔除(素材分支会按 `kind` 解析),命名行固定展示名「AI 项目命名」,只在终态显示结论。 +- **判据/取证**:Rust `cargo test … asset_generation_task` 22 条(含 `naming_task_stays_in_flight_across_ledger_reads_until_terminal`、`legacy_v1_ledger_reads_back_with_the_default_asset_generation_task_type`、`interrupted_naming_task_is_settled_as_failed_with_naming_wording`);前端 `homeProjectNamingAsync.test.tsx`(入队→running→终态的推进序列)、`projectNamingGenerationTaskRow.test.tsx`(行渲染)、appSurface 的「syncs the workbench title and recent project list with the AI name after creation」(建项后行可见 + 事件驱动刷新出终态结论)。 +- **关联**:`apps/ai-game-creator-shell/src-tauri/src/asset_generation_tasks.rs`、`apps/ai-game-creator-shell/src/features/app-shell/useHomeProjectCreation.ts`、`apps/ai-game-creator-shell/src/features/resource-canvas/resourceCanvasAssetGenerationTaskModel.ts`、`apps/ai-game-creator-shell/src/view/project-development/index.tsx`。 + ## 2026-10-03 后台异步回填被「进项目」覆盖:AI 项目名只在兜底名仍成立时改 - **现象**(Issue 599):首页建项把 AI 命名改成「与建项并行、结果后台回填」后,偶尔工作台标题停在兜底名(`GameAgent 项目 <短 id>`),而磁盘 manifest 已经是 AI 名字。 diff --git a/docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md b/docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md index 5fd450138..d73378b71 100644 --- a/docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md +++ b/docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md @@ -8,7 +8,8 @@ - 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`。 +- 自动命名是**项目工作台「生成任务」列表里的一条后台任务**:账本记录用 `taskType`(`asset-generation` / `project-naming`)区分,schema `agc-asset-generation-task.v2`(读取兼容 v1,v1 记录按素材任务读回),`kind` 对命名任务为 null;入队/推进走 `enqueue_local_project_naming_task` / `update_local_project_naming_task`,展示名固定「AI 项目命名」,状态流转「排队中 → 命名中 → 已完成(已应用 AI 名称 / 建议名一致 / 用户已改名跳过)或失败(无可用名称 / 自动改名失败,均保留兜底名)」。命名行的结论只在终态显示,没有缩略图、提示词与「定位到素材」。命名任务由命名链路推进,因此入队要登记 live、终态写盘后摘除,否则任何一次账本读取都会把它按「上次运行中断」误收口为失败;账本只作展示旁路,入队/推进失败只写诊断日志,不影响建项、命名与首轮创作。 +- 回归入口:`npx vitest run apps/ai-game-creator-shell/tests/homeProjectNamingAsync.test.tsx`(时序用 promise 闸门,不用 sleep)、`npx vitest run apps/ai-game-creator-shell/tests/projectNamingGenerationTaskRow.test.tsx`、`npx vitest run apps/ai-game-creator-shell/tests/appSurface.test.ts`、`cargo test --features=cocos-editor-execute,unity-editor-execute,godot-editor-execute asset_generation_task`、`cargo test --features=… conditional_project_rename_tests`。 ## 2026-10-02 退役自建 Agent Runtime 与 CLI 执行面 -- 2.52.0 From cd07b7375a0cc7005b31fd8851b8d90f89d6e8f3 Mon Sep 17 00:00:00 2001 From: Suzumiya Date: Sun, 4 Oct 2026 18:35:55 +0800 Subject: [PATCH 5/7] =?UTF-8?q?=E4=BF=AE=E5=A4=8D=20Repository=20checks=20?= =?UTF-8?q?=E7=9A=84=20ESLint=20=E6=8A=A5=E9=94=99=EF=BC=9Aimport=20?= =?UTF-8?q?=E9=A1=BA=E5=BA=8F=E6=8C=89=20simple-import-sort=20=E5=BD=92?= =?UTF-8?q?=E4=BD=8D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - CI「Repository checks」红在 lint:eslint:apps/ai-game-creator-shell/src/view/project-development/index.tsx 的 import 顺序未按 simple-import-sort 排序(type 导入与值导入混排)。 - 只调整该文件 import 顺序(1 行),无行为改动;已实跑 npm run lint:eslint(0 problems)、npx prettier --check、homeProjectNamingAsync + projectNamingGenerationTaskRow 15 passed、npm run agc:typecheck。 --- .../src/view/project-development/index.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/ai-game-creator-shell/src/view/project-development/index.tsx b/apps/ai-game-creator-shell/src/view/project-development/index.tsx index 0d92de597..838198866 100644 --- a/apps/ai-game-creator-shell/src/view/project-development/index.tsx +++ b/apps/ai-game-creator-shell/src/view/project-development/index.tsx @@ -167,8 +167,8 @@ import { createResourceCanvasAudioGenerationTask, type LocalProjectAssetGenerationTaskRecord, type ResourceCanvasAssetGenerationTask, - type ResourceCanvasProjectNamingTask, resourceCanvasAssetGenerationTaskIsTerminal, + type ResourceCanvasProjectNamingTask, restoreResourceCanvasProjectNamingTask, splitLocalProjectGenerationTaskRecords, } from '../../features/resource-canvas/resourceCanvasAssetGenerationTaskModel'; -- 2.52.0 From 67d06bc16853a7b18b609afd0236939c40559a29 Mon Sep 17 00:00:00 2001 From: Suzumiya Date: Mon, 5 Oct 2026 00:55:29 +0800 Subject: [PATCH 6/7] =?UTF-8?q?=E7=8B=AC=E7=AB=8B=E8=AF=84=E5=AE=A1?= =?UTF-8?q?=E6=94=B6=E5=8F=A3=20#599=EF=BC=9A=E5=91=BD=E5=90=8D=E8=A1=8C?= =?UTF-8?q?=E4=BA=8B=E4=BB=B6=E6=97=B6=E5=BA=8F=E3=80=81=E5=91=BD=E5=90=8D?= =?UTF-8?q?=E4=BB=BB=E5=8A=A1=20update=20=E9=97=A8=E7=A6=81=E3=80=81?= =?UTF-8?q?=E5=A4=B1=E8=B4=A5=E5=85=B3=E9=97=AD=E4=B8=8E=20legacy=20?= =?UTF-8?q?=E7=94=A8=E4=BE=8B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - P2-2:命名行的账本订阅与恢复读合并为同一次 effect 的「先订阅、后读、只读一次」(与素材队列先订阅后派发同口径),并在「生成任务」面板打开且**确有在途命名行**时补读一次兜底——多读会改变既有生成任务链路可观测的轮询计数,因此不给没有命名任务的项目加读。新增 2 条 appSurface 用例(错过终态事件仍到终态 / 面板打开补读),并做了「改前红」验证:退回「先读后订阅 + 面板不补读」两条即红。 - P2-3:update_local_project_naming_task 补 project.rename 权限门禁(与 enqueue 同口径)、校验记录 project_id 与项目一致、终态只接受同状态幂等重放(completed→failed 一律 Err 且不写盘);新增 3 条 Rust 用例。 - P2-4:新增失败关闭与 schema 分支用例——超长项目名条件改名 Err 且 manifest/revision 不变、不支持 schemaVersion Err、缺 schemaVersion 按 v1 接受。 - P3-1:legacy v1 兼容用例改为断言兼容性质(字段原样保留),不再引用 phase 文案常量。 - 文档:pitfalls 补「兼容是单向的(旧构建读含命名记录的账本会整份失败,含影响面/触发条件/潜在低成本正向兼容方向)」与「先订阅后读快照」「update 门禁与终态语义」三条边界;decision-log 同步边界与数字。 - 门禁:AGC 全量 1964 passed / 17 skipped(appSurface 200 passed / 9 skipped);cargo asset_generation_task 27 passed、conditional_project_rename_tests 7 passed;agc:typecheck、lint:eslint(0 problems)、check:encoding、check:spacetime-schema、cargo fmt --check、git diff --check 全过。 --- .../src-tauri/src/asset_generation_tasks.rs | 196 ++++++++++++++- .../src-tauri/src/commands.rs | 22 ++ .../src/view/project-development/index.tsx | 142 ++++++----- .../tests/appSurface/home.suite.ts | 235 ++++++++++++++++++ .../shared-memory/decision-log.md | 4 +- docs/project-memory/shared-memory/pitfalls.md | 5 +- 6 files changed, 541 insertions(+), 63 deletions(-) diff --git a/apps/ai-game-creator-shell/src-tauri/src/asset_generation_tasks.rs b/apps/ai-game-creator-shell/src-tauri/src/asset_generation_tasks.rs index cdb03264e..3a1ae2797 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/asset_generation_tasks.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/asset_generation_tasks.rs @@ -569,9 +569,15 @@ pub(crate) fn enqueue_local_project_naming_task( /// /// 语义(命名任务独有的口径,素材任务一律拒绝): /// - 只作用于 `taskType = project-naming` 的记录,且 `task_id` 必须存在; +/// - 权限门禁与 [`enqueue_local_project_naming_task`] 同口径(`project.rename`),且在任何 +/// 读/改账本之前执行; +/// - 记录的 `project_id` 必须与项目 manifest 的 `project_id` 一致:目录换了项目(或身份被 +/// 改写)时一律返回 Err,不写盘; /// - `status` 只接受 `queued` / `running` / `completed` / `failed`; /// - 进入 `running` 时补 `started_at_millis`;进入终态时置 `finished_at_millis`; -/// - **已终态的记录不接受改回非终态**(返回 Err,避免命名结果被一次迟到的排队事件抹掉); +/// - **记录一旦终态,就只接受与其当前状态相同的写入(幂等重放)**;传入与当前状态不同的 +/// `status`(改回非终态,或改写成另一种终态)一律返回 Err 且不写盘——命名结果不能被一次 +/// 迟到的排队/失败事件覆盖; /// - `phase_detail` 传入即覆盖;`error` 只在 `failed` 时保存,非 `failed` 传入会被忽略; /// - live 集合与状态同步:非终态保持登记(在途任务不能被并发 `list` 收口成失败),终态摘除 /// (真中断的任务重启后仍能被收口)。落账失败只回滚本轮真正插入的那条登记。 @@ -586,6 +592,9 @@ pub(crate) fn update_local_project_naming_task( let task_id = asset_generation_task_id(&task_id)?; let status = naming_task_status(&status)?; let root = validated_local_project_directory_path(project_path.trim())?; + // 门禁必须在读/改账本之前;与 `enqueue_local_project_naming_task` 用同一个命令位。 + enforce_project_permission_policy(&root, "project.rename")?; + let project_id = read_existing_manifest_for_project(&root)?.project_id; let _guard = lock_ledger()?; let mut tasks = read_ledger(&root)?; let record = tasks @@ -595,8 +604,19 @@ pub(crate) fn update_local_project_naming_task( if record.task_type != ASSET_GENERATION_TASK_TYPE_PROJECT_NAMING { return Err(format!("生成任务不是项目命名任务:{task_id}")); } - if record.is_terminal() && !is_terminal_asset_generation_status(status) { - return Err(format!("项目命名任务已结束,不能改回非终态:{task_id}")); + // 记录必须属于当前项目:账本是项目内的,但 task_id 仍可能来自另一个项目(或目录被换过)。 + if record.project_id != project_id { + return Err(format!( + "项目命名任务记录不属于该项目:{task_id}(记录 {},当前项目 {project_id})", + record.project_id + )); + } + // 终态一旦落定就是终态:同状态的幂等重放仍允许,任何不同状态都拒绝且不写盘。 + if record.is_terminal() && record.status != status { + return Err(format!( + "项目命名任务已结束,不能再写入不同状态:{task_id}(当前 {},请求 {status})", + record.status + )); } record.status = status.to_string(); if status == ASSET_GENERATION_TASK_STATUS_RUNNING && record.started_at_millis.is_none() { @@ -1180,7 +1200,10 @@ mod asset_generation_task_tests { assert_eq!(record.project_id, "project-legacy"); assert_eq!(record.asset_name, "旧图"); assert_eq!(record.status, ASSET_GENERATION_TASK_STATUS_COMPLETED); - assert_eq!(record.phase_detail, ASSET_GENERATION_TASK_PHASE_COMPLETED); + // 兼容性只保证字段**原样保留**:`phaseDetail` 是 v1 时代后端自己写的字符串,读回必须逐字 + // 不变。它当年恰好等于某个 phase 常量并不构成契约——所以这里对着 fixture 自身的字面量 + // 断言,不引用当前 phase 常量来「证明兼容」。 + assert_eq!(record.phase_detail, "生成已完成。"); assert_eq!(record.output_path.as_deref(), Some("assets/legacy.png")); assert_eq!(record.created_at_millis, 11); assert_eq!(record.started_at_millis, Some(12)); @@ -1190,6 +1213,67 @@ mod asset_generation_task_tests { std::fs::remove_dir_all(&root).ok(); } + /// 不认识的 `schemaVersion` 必须失败关闭:宁可整份读不出来报错,也不能拿旧形状去猜读。 + #[test] + fn ledger_with_an_unsupported_schema_version_fails_closed() { + let root = temp_project_root("schema-unsupported"); + let ledger_path = root.join(ASSET_GENERATION_TASK_LEDGER_RELATIVE_PATH); + std::fs::create_dir_all(ledger_path.parent().expect("ledger parent")).expect("ledger dir"); + std::fs::write( + &ledger_path, + r#"{ + "schemaVersion": "agc-asset-generation-task.v3", + "tasks": [] + }"#, + ) + .expect("write unsupported ledger"); + + let error = read_ledger(&root).expect_err("unsupported schema must fail closed"); + assert!(error.contains("schema 版本不受支持"), "{error}"); + std::fs::remove_dir_all(&root).ok(); + } + + /// 缺失 `schemaVersion` 的老账本按 v1 接受:记录必须原样读回(`taskType` 缺省为素材任务)。 + #[test] + fn ledger_without_a_schema_version_is_accepted_as_v1() { + let root = temp_project_root("schema-missing"); + let ledger_path = root.join(ASSET_GENERATION_TASK_LEDGER_RELATIVE_PATH); + std::fs::create_dir_all(ledger_path.parent().expect("ledger parent")).expect("ledger dir"); + std::fs::write( + &ledger_path, + r#"{ + "tasks": [ + { + "taskId": "task-no-schema", + "projectId": "project-legacy", + "kind": "image", + "assetName": "无版本号旧图", + "status": "failed", + "phaseDetail": "旧版失败文案。", + "createdAtMillis": 21, + "startedAtMillis": 22, + "finishedAtMillis": 23, + "assetId": null, + "error": "旧版错误" + } + ] + }"#, + ) + .expect("write schema-less ledger"); + + let tasks = read_ledger(&root).expect("missing schemaVersion reads as v1"); + assert_eq!(tasks.len(), 1); + assert_eq!(tasks[0].task_id, "task-no-schema"); + assert_eq!( + tasks[0].task_type, + ASSET_GENERATION_TASK_TYPE_ASSET_GENERATION + ); + assert_eq!(tasks[0].kind.as_deref(), Some("image")); + assert_eq!(tasks[0].created_at_millis, 21); + assert_eq!(tasks[0].finished_at_millis, Some(23)); + std::fs::remove_dir_all(&root).ok(); + } + #[test] fn naming_task_enqueue_lands_in_the_shared_ledger_without_an_asset_kind() { let root = initialized_project_root("naming-enqueue"); @@ -1355,6 +1439,110 @@ mod asset_generation_task_tests { std::fs::remove_dir_all(&root).ok(); } + #[test] + fn naming_task_update_requires_the_project_rename_permission() { + let root = initialized_project_root("naming-update-permission"); + let project_path = naming_task_project_path(&root); + enqueue_local_project_naming_task(project_path.clone(), "naming-denied".to_string()) + .expect("enqueue"); + + let mut policy = crate::ProjectPermissionPolicy::default(); + policy.denied_commands.push("project.rename".to_string()); + write_project_permission_policy_at(&root, policy).expect("write permission policy"); + + let error = update_local_project_naming_task( + project_path, + "naming-denied".to_string(), + ASSET_GENERATION_TASK_STATUS_RUNNING.to_string(), + None, + None, + ) + .expect_err("denied permission must fail closed"); + assert!( + error.contains("项目权限策略拒绝执行:project.rename"), + "{error}" + ); + + // 门禁在读写账本之前:账本必须原封不动。 + let stored = read_ledger(&root).expect("read ledger"); + assert_eq!(stored[0].status, ASSET_GENERATION_TASK_STATUS_QUEUED); + remove_live_task_id("naming-denied"); + std::fs::remove_dir_all(&root).ok(); + } + + #[test] + fn naming_task_update_rejects_a_record_from_another_project() { + let root = initialized_project_root("naming-foreign-record"); + let project_path = naming_task_project_path(&root); + enqueue_local_project_naming_task(project_path.clone(), "naming-foreign".to_string()) + .expect("enqueue"); + + // 账本记录的项目身份被改成另一个项目(目录被换过 / task id 来自别的项目)。 + let mut stored = read_ledger(&root).expect("read ledger"); + stored[0].project_id = "another-project".to_string(); + write_ledger(&root, &stored).expect("write ledger"); + + let error = update_local_project_naming_task( + project_path, + "naming-foreign".to_string(), + ASSET_GENERATION_TASK_STATUS_RUNNING.to_string(), + None, + None, + ) + .expect_err("foreign record must fail closed"); + assert!(error.contains("记录不属于该项目"), "{error}"); + + // 拒绝发生在改动之前:状态仍是 queued。 + let stored = read_ledger(&root).expect("read ledger again"); + assert_eq!(stored[0].status, ASSET_GENERATION_TASK_STATUS_QUEUED); + remove_live_task_id("naming-foreign"); + std::fs::remove_dir_all(&root).ok(); + } + + #[test] + fn naming_task_terminal_state_rejects_a_different_status_but_allows_the_same_one() { + let root = initialized_project_root("naming-terminal"); + let project_path = naming_task_project_path(&root); + enqueue_local_project_naming_task(project_path.clone(), "naming-terminal".to_string()) + .expect("enqueue"); + update_local_project_naming_task( + project_path.clone(), + "naming-terminal".to_string(), + ASSET_GENERATION_TASK_STATUS_COMPLETED.to_string(), + Some("命名已完成。".to_string()), + None, + ) + .expect("completed"); + + // 终态 → 另一种终态:必须拒绝且不写盘(旧实现会静默把 completed 改写成 failed)。 + let error = update_local_project_naming_task( + project_path.clone(), + "naming-terminal".to_string(), + ASSET_GENERATION_TASK_STATUS_FAILED.to_string(), + Some("迟到的失败。".to_string()), + Some("迟到的失败".to_string()), + ) + .expect_err("terminal state must not be overwritten"); + assert!(error.contains("不能再写入不同状态"), "{error}"); + let stored = read_ledger(&root).expect("read ledger"); + assert_eq!(stored[0].status, ASSET_GENERATION_TASK_STATUS_COMPLETED); + assert_eq!(stored[0].phase_detail, "命名已完成。"); + assert!(stored[0].error.is_none()); + + // 同状态幂等重放仍允许。 + let replayed = update_local_project_naming_task( + project_path, + "naming-terminal".to_string(), + ASSET_GENERATION_TASK_STATUS_COMPLETED.to_string(), + None, + None, + ) + .expect("same-state replay is allowed"); + assert_eq!(replayed.status, ASSET_GENERATION_TASK_STATUS_COMPLETED); + assert_eq!(replayed.phase_detail, "命名已完成。"); + std::fs::remove_dir_all(&root).ok(); + } + #[test] fn interrupted_naming_task_is_settled_as_failed_with_naming_wording() { let root = initialized_project_root("naming-interrupted"); 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 e98373ab7..09c9c6400 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/commands.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/commands.rs @@ -704,6 +704,28 @@ mod conditional_project_rename_tests { assert_eq!(project_revision(root), revision_before); } + /// 超长名字同样走 `normalize_game_creation_project_name` 的门禁:条件改名必须失败关闭, + /// 且盘上 manifest 与 revision 都不动。 + #[test] + fn conditional_rename_rejects_an_over_long_name_without_touching_the_project() { + let project = fixture_root("conditional-rename-too-long"); + let root = project.path(); + let revision_before = project_revision(root); + + let too_long = "名".repeat(crate::project::GAME_CREATION_PROJECT_NAME_MAX_CHARS + 1); + let error = rename_local_game_project_if_unchanged_at( + root, + "conditional-rename-too-long", + FALLBACK_NAME, + &too_long, + ) + .expect_err("over-long 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_rejects_empty_expectations_without_touching_the_project() { let project = fixture_root("conditional-rename-empty-expectation"); diff --git a/apps/ai-game-creator-shell/src/view/project-development/index.tsx b/apps/ai-game-creator-shell/src/view/project-development/index.tsx index d045dc33b..98d8dda41 100644 --- a/apps/ai-game-creator-shell/src/view/project-development/index.tsx +++ b/apps/ai-game-creator-shell/src/view/project-development/index.tsx @@ -8986,6 +8986,34 @@ export default function ProjectDevelopmentView({ [manifest.assets, resourceGenerationPlaceholders], ); + /** + * 刷新「AI 项目命名」行:从账本重读一次(受控快照读取)。 + * + * 读失败或返回值不是数组时**保留上一次的命名行**——一次 IPC 失败不该把列表清空。 + */ + const refreshProjectNamingTasks = useCallback(async () => { + const invoke = window.__TAURI__?.core?.invoke; + if (!invoke) { + return; + } + try { + const response = await invoke( + 'list_local_project_asset_generations', + { projectPath }, + ); + if (!Array.isArray(response)) { + return; + } + setProjectNamingTasks( + splitLocalProjectGenerationTaskRecords(response).namingRecords.map( + restoreResourceCanvasProjectNamingTask, + ), + ); + } catch { + // 账本读不到:保留上一次的命名行,不猜状态。 + } + }, [projectPath]); + /** * 重开项目时恢复项目内的任务账本。 * @@ -9003,6 +9031,7 @@ export default function ProjectDevelopmentView({ } const projectId = manifest.projectId; let cancelled = false; + let unsubscribeNamingTasks: (() => void) | null = null; setResourceAssetGenerationTasksPanelOpen(false); setProjectNamingTasks([]); resourceAssetGenerationPanelSubmissionRef.current = null; @@ -9016,6 +9045,39 @@ export default function ProjectDevelopmentView({ resourceGenerationDraftRef.current.clear(); resourceAssetGenerationDraftRef.current.clear(); void (async () => { + /** + * **先订阅、再读快照**(与素材队列「先订阅后派发」同一口径)。 + * + * 命名行的推进发生在本组件之外(首页后台命名链路),刷新只能靠「变更事件 + 一次受控快照 + * 读取」;读取是异步的,若先读后订阅,落在这一次 IPC 往返里的终态事件没有消费者、直接丢掉, + * 命名行会永久停在「命名中」。订阅失败也要照读一次,靠面板打开时的补读兜底。 + * + * 订阅与这次恢复读共用同一次 effect:本项目挂载时对账本的读次数保持不变(把命名读并进 + * 这一次恢复读),不改变既有生成任务链路可观测的轮询计数。 + */ + if (canSubscribeTauriEvents()) { + try { + const dispose = await subscribeTauriEvent<{ projectPath?: string }>( + RESOURCE_CANVAS_ASSET_GENERATION_CHANGED_EVENT, + (event) => { + if (cancelled || event.payload?.projectPath !== projectPath) { + return; + } + void refreshProjectNamingTasks(); + }, + ); + if (cancelled) { + dispose(); + return; + } + unsubscribeNamingTasks = dispose; + } catch { + // 订阅不上:退化成「进项目读一次 + 面板打开时重读」。 + } + } + if (cancelled) { + return; + } const reportUnavailable = () => { if (!cancelled) { setResourceWorkbenchNotice( @@ -9057,70 +9119,38 @@ export default function ProjectDevelopmentView({ })(); return () => { cancelled = true; + unsubscribeNamingTasks?.(); }; - }, [manifest.projectId, projectPath]); + }, [manifest.projectId, projectPath, refreshProjectNamingTasks]); /** - * 刷新「AI 项目命名」行:从账本重读一次(受控快照读取)。 + * 面板打开时补一次快照读。 * - * 读失败或返回值不是数组时**保留上一次的命名行**——一次 IPC 失败不该把列表清空。 - */ - const refreshProjectNamingTasks = useCallback(async () => { - const invoke = window.__TAURI__?.core?.invoke; - if (!invoke) { - return; - } - try { - const response = await invoke( - 'list_local_project_asset_generations', - { projectPath }, - ); - if (!Array.isArray(response)) { - return; - } - setProjectNamingTasks( - splitLocalProjectGenerationTaskRecords(response).namingRecords.map( - restoreResourceCanvasProjectNamingTask, - ), - ); - } catch { - // 账本读不到:保留上一次的命名行,不猜状态。 - } - }, [projectPath]); - - /** - * 「AI 项目命名」的推进发生在本组件之外(首页后台命名链路),所以这里只订阅账本变更事件, - * 在事件属于当前项目时做**一次**受控快照读取(团队口径:正式状态变化走事件 + 一次快照读取, - * 不新增固定频率轮询)。素材任务仍按各自既有的账本/队列路径推进,不在这里改。 + * 这是事件之外的兜底:订阅尚未就绪、窗口刚恢复、或用户就是想确认进度时,打开面板就能看到 + * 账本里的真实状态,而不是永久停在「命名中」。 + * + * **只在列表里确实存在一条在途命名行时才读**:没有命名任务的项目(绝大多数)不该因为打开 + * 面板就多打一次账本 IPC——那会改变「轮询计数」这类外部可观测行为(既有生成任务用例正是 + * 按轮询次数推进 mock 的)。依赖的是布尔而不是行数组:读取结果只会让它在 true→false 之间 + * 最多翻转一次,不会自激。 */ + const hasInFlightProjectNamingTask = projectNamingTasks.some( + (task) => task.status !== 'completed' && task.status !== 'failed', + ); useEffect(() => { - if (!canSubscribeTauriEvents() || !projectPath.trim()) { + if ( + !resourceAssetGenerationTasksPanelOpen || + !hasInFlightProjectNamingTask + ) { return undefined; } - let disposed = false; - let unsubscribe: (() => void) | null = null; - void subscribeTauriEvent<{ projectPath?: string }>( - RESOURCE_CANVAS_ASSET_GENERATION_CHANGED_EVENT, - (event) => { - if (disposed || event.payload?.projectPath !== projectPath) { - return; - } - void refreshProjectNamingTasks(); - }, - ) - .then((dispose) => { - if (disposed) { - dispose(); - } else { - unsubscribe = dispose; - } - }) - .catch(() => undefined); - return () => { - disposed = true; - unsubscribe?.(); - }; - }, [projectPath, refreshProjectNamingTasks]); + void refreshProjectNamingTasks(); + return undefined; + }, [ + hasInFlightProjectNamingTask, + resourceAssetGenerationTasksPanelOpen, + refreshProjectNamingTasks, + ]); /** * 「生成任务」面板里点一条已完成任务:复用既有聚焦链定位到它的素材卡。 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 3864f4779..28e65c837 100644 --- a/apps/ai-game-creator-shell/tests/appSurface/home.suite.ts +++ b/apps/ai-game-creator-shell/tests/appSurface/home.suite.ts @@ -1999,6 +1999,241 @@ export function registerHomeProjectCreationTests() { expect(screen.queryByText('GameAgent 项目 1a2b3c4d')).toBeNull(); }); + it('recovers the naming row when the terminal event lands before the subscription is ready', async () => { + const projectPath = '/tmp/home-naming-event-race'; + const fallbackManifest = createGameCreationAppManifest( + 'home-naming-event-race', + 'GameAgent 项目 5f6a7b8c', + ); + const renamedManifest = { ...fallbackManifest, name: '星轨夜航' }; + const ledgerRecord = (status: string, phaseDetail: string) => [ + { + taskId: `project-naming:${fallbackManifest.projectId}`, + projectId: fallbackManifest.projectId, + taskType: 'project-naming', + kind: null, + assetName: 'AI 项目命名', + status, + phaseDetail, + createdAtMillis: 1, + startedAtMillis: 2, + finishedAtMillis: status === 'completed' ? 3 : null, + assetId: null, + error: null, + }, + ]; + let namingSettled = false; + // 订阅注册被闸门卡住:这一段里落地的事件在真实世界里没有订阅者——就是丢了。 + let releaseSubscription: (() => void) | null = null; + let subscriptionReady = false; + let droppedWhileUnsubscribed = 0; + 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, + }; + } + if (command === 'suggest_automatic_project_name') return '星轨夜航'; + if (command === 'rename_local_game_project_if_unchanged') { + namingSettled = true; + return { renamed: true, manifest: renamedManifest, revision: 2 }; + } + if (command === 'get_local_game_manifest') { + return namingSettled ? renamedManifest : fallbackManifest; + } + if (command === 'list_local_project_asset_generations') { + // 进项目那一次读拿到的是「老化快照」(响应在命名落定之前就算好了); + // 订阅就绪之后的读才是账本真相。 + return namingSettled && subscriptionReady + ? ledgerRecord('completed', '已应用 AI 名称「星轨夜航」。') + : ledgerRecord('running', '正在等待 AI 命名结果。'); + } + if (command === 'inspect_local_project_directory') { + return { + projectPath: String(args?.projectPath ?? ''), + exists: true, + isDirectory: true, + isGameCreatorProject: true, + isCocosProject: false, + godotProjectRoot: null, + projectName: renamedManifest.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); + }, + ); + const listen = ( + eventName: string, + handler: (event: { payload: unknown }) => void, + ) => { + if (eventName !== 'game-creator-asset-generation-task-changed') { + return chatHarness.listen(eventName, handler); + } + return new Promise<() => void>((resolve) => { + releaseSubscription = () => { + subscriptionReady = true; + resolve(() => {}); + }; + }); + }; + installTauriRuntime({ core: { invoke }, event: { listen } }); + 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' }); + await screen.findByLabelText('项目开发工作台'); + await waitFor(() => { + expect(invoke).toHaveBeenCalledWith( + 'rename_local_game_project_if_unchanged', + expect.anything(), + ); + }); + + // 次序:读返回(陈旧 running)→ 终态事件(此时没有订阅者,丢掉)→ 订阅才注册。 + droppedWhileUnsubscribed += 1; + await act(async () => { + releaseSubscription?.(); + }); + + // 命名行必须到终态:右上角开关的在途计数归零(旧实现会永久停在「生成任务 · 1」)。 + await waitFor(() => { + expect(droppedWhileUnsubscribed).toBe(1); + expect(screen.getByLabelText('生成任务').textContent).toBe('生成任务'); + }); + }); + + it('re-reads the naming ledger when the generation tasks panel opens', async () => { + const projectPath = '/tmp/home-naming-panel-reread'; + const fallbackManifest = createGameCreationAppManifest( + 'home-naming-panel-reread', + 'GameAgent 项目 6a7b8c9d', + ); + const renamedManifest = { ...fallbackManifest, name: '星轨夜航' }; + const ledgerRecord = (status: string, phaseDetail: string) => [ + { + taskId: `project-naming:${fallbackManifest.projectId}`, + projectId: fallbackManifest.projectId, + taskType: 'project-naming', + kind: null, + assetName: 'AI 项目命名', + status, + phaseDetail, + createdAtMillis: 1, + startedAtMillis: 2, + finishedAtMillis: status === 'completed' ? 3 : null, + assetId: null, + error: null, + }, + ]; + let ledgerSettled = false; + 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, + }; + } + if (command === 'suggest_automatic_project_name') return '星轨夜航'; + if (command === 'rename_local_game_project_if_unchanged') { + return { renamed: true, manifest: renamedManifest, revision: 2 }; + } + if (command === 'get_local_game_manifest') return renamedManifest; + if (command === 'list_local_project_asset_generations') { + // 账本真相由 `ledgerSettled` 控制:打开面板之前它是陈旧快照, + // 之后会重读一次拿到终态(本用例里订阅直接失败,只剩这条兜底)。 + return ledgerSettled + ? ledgerRecord('completed', '已应用 AI 名称「星轨夜航」。') + : ledgerRecord('running', '正在等待 AI 命名结果。'); + } + if (command === 'inspect_local_project_directory') { + return { + projectPath: String(args?.projectPath ?? ''), + exists: true, + isDirectory: true, + isGameCreatorProject: true, + isCocosProject: false, + godotProjectRoot: null, + projectName: renamedManifest.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); + }, + ); + const listen = ( + eventName: string, + handler: (event: { payload: unknown }) => void, + ) => { + if (eventName === 'game-creator-asset-generation-task-changed') { + // 订阅失败:这条链路只剩「面板打开时补读」这一个兜底。 + return Promise.reject(new Error('subscription unavailable')); + } + return chatHarness.listen(eventName, handler); + }; + installTauriRuntime({ core: { invoke }, event: { listen } }); + 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' }); + await screen.findByLabelText('项目开发工作台'); + // 陈旧快照下命名行还在跑:开关上带着在途计数。 + await waitFor(() => { + expect(screen.getByLabelText('生成任务').textContent).toContain( + '生成任务 · 1', + ); + }); + // 账本随后落定,但(订阅失败的场景里)这条链路收不到任何事件。 + ledgerSettled = true; + + fireEvent.click(screen.getByLabelText('生成任务')); + // 打开面板补一次读:行必须变成账本里的终态,而不是永远停在「命名中」。 + expect( + await screen.findByText('已应用 AI 名称「星轨夜航」。'), + ).not.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/docs/project-memory/shared-memory/decision-log.md b/docs/project-memory/shared-memory/decision-log.md index 6cd442271..e4ad91b0e 100644 --- a/docs/project-memory/shared-memory/decision-log.md +++ b/docs/project-memory/shared-memory/decision-log.md @@ -5,9 +5,9 @@ - 背景:首页「开启创作」原先串行执行「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 不符、名称与现状相同一律跳过;空 `expectedProjectId` / `expectedName`、空名 / 控制字符 / 超长一律失败关闭。 - 决策(纳入「生成任务」体系):自动命名做成**项目工作台那条既有「生成任务」列表里的一条后台任务**,而不只是页面里的一个 promise。账本记录加 `taskType`(`asset-generation` / `project-naming`,缺省 `asset-generation`)、`kind` 变可选,schema 升 `agc-asset-generation-task.v2`(读取同时接受 v1/v2,v1 记录按素材任务读回);新增 `enqueue_local_project_naming_task`(排队中)/ `update_local_project_naming_task`(命名中 → 已完成/失败),展示名固定「AI 项目命名」,与素材生成共用同一份账本、同一个变更事件与同一个侧栏。状态流转:排队中 → 命名中 → 已完成(已应用 AI 名称 / 建议名与现状一致 / 用户已手动改名而跳过)或失败(无可用名称 / 自动改名失败,均保留兜底名)。命名任务由命名链路推进、**不进 live 集合就会被中断收口误判**,所以 enqueue 写账本前登记 live、update 终态写盘后摘除(中断残留仍按命名口径收口为失败)。前端按 `taskType` 把命名记录从素材任务分支里剔除,命名行不渲染缩略图/提示词/派发与定位动作,结论只在终态显示。 -- 边界:改名是簿记写入,与既有 `rename_local_game_project` 同口径**不推进项目 revision**(推 revision 会让运行时验证凭证无故漂移);返回的 `revision` 是当时盘上的值。后台改名结果写「当前项目上下文」与「最近项目行重检」必须等建项主体收尾(`entrySettled`):AI 比进项目更快时直接写上下文会被随后的 `enterProjectDevelopment` 用兜底名覆盖,最近项目行也要等进项目登记过才会被重检;写入前再过壳的生命周期守卫(`mounted` + 代次),关窗/卸载后只保留已落盘的改名,不写 UI 投影。账本是**展示旁路**:`enqueue` / `update` 失败只写诊断日志,绝不影响建项、命名与首轮创作;「做方案」与手动选目录建项不发起自动命名。本次未改共享契约(`packages/shared/**`)、server-rs 与任何 SpacetimeDB schema/HTTP 路由。 +- 边界:改名是簿记写入,与既有 `rename_local_game_project` 同口径**不推进项目 revision**(推 revision 会让运行时验证凭证无故漂移);返回的 `revision` 是当时盘上的值。后台改名结果写「当前项目上下文」与「最近项目行重检」必须等建项主体收尾(`entrySettled`):AI 比进项目更快时直接写上下文会被随后的 `enterProjectDevelopment` 用兜底名覆盖,最近项目行也要等进项目登记过才会被重检;写入前再过壳的生命周期守卫(`mounted` + 代次),关窗/卸载后只保留已落盘的改名,不写 UI 投影。账本是**展示旁路**:`enqueue` / `update` 失败只写诊断日志,绝不影响建项、命名与首轮创作;「做方案」与手动选目录建项不发起自动命名。本次未改共享契约(`packages/shared/**`)、server-rs 与任何 SpacetimeDB schema/HTTP 路由。条件改名的 `expectedProjectId` / `expectedName` 为空时按仓库同类入口口径失败关闭;`update_local_project_naming_task` 与 enqueue 共用 `project.rename` 权限位、校验记录归属,且终态只接受同状态幂等重放。**兼容性写成显式边界:兼容是单向的**——新构建读 v1 账本 OK;旧构建读到含命名记录(`kind: null`)的账本会整份解析失败(面板报读失败、同批在途素材任务被按中断收口)。前端刷新必须先订阅事件再读快照,并在面板打开时补读一次兜底。 - 影响范围:`apps/ai-game-creator-shell/src-tauri/src/{commands.rs,desktop.rs,asset_generation_tasks.rs,asset_generation_tasks/runtime.rs}`、`apps/ai-game-creator-shell/src/{app/types.ts,features/app-shell/{useHomeProjectCreation.ts,WorkspaceLauncher.tsx},features/resource-canvas/{resourceCanvasAssetGenerationTaskModel.ts,ResourceCanvasAssetGenerationTasksPanelView.tsx},view/project-development/index.tsx}`、`apps/ai-game-creator-shell/tests/{homeProjectNamingAsync.test.tsx,projectNamingGenerationTaskRow.test.tsx,appSurface/home.suite.ts}`。 -- 验证:`npx vitest run apps/ai-game-creator-shell/tests/homeProjectNamingAsync.test.tsx`(12 passed,含「命名请求永不返回仍进工作区」「AI 结果先于进项目落定仍不被兜底名覆盖」「手动改名不被覆盖且任务按已完成+已跳过收口」「非法/空响应 → 任务 failed 且保留兜底名」「做方案不发起命名」「切到别的工作区不被劫持」「卸载/pagehide 后不写上下文」,并断言入队→命名中→终态的账本推进序列);`projectNamingGenerationTaskRow.test.tsx`(3 passed:固定展示名/不渲染缩略图提示词定位、终态才显示结论、失败徽章与原因 + 在途计数);AGC 全量 `npm run test -- apps/ai-game-creator-shell/tests`(1925 passed / 17 skipped);`cargo test --features=cocos-editor-execute,unity-editor-execute,godot-editor-execute asset_generation_task`(22 passed,含 `naming_task_stays_in_flight_across_ledger_reads_until_terminal` 与 `legacy_v1_ledger_reads_back_with_the_default_asset_generation_task_type`)与 `conditional_project_rename_tests`(6 passed);`npm run agc:typecheck`(含 `check:tests:types`)、`cargo fmt --check`、`npm run check:encoding`、`git diff --check`。 +- 验证:`npx vitest run apps/ai-game-creator-shell/tests/homeProjectNamingAsync.test.tsx`(12 passed,含「命名请求永不返回仍进工作区」「AI 结果先于进项目落定仍不被兜底名覆盖」「手动改名不被覆盖且任务按已完成+已跳过收口」「非法/空响应 → 任务 failed 且保留兜底名」「做方案不发起命名」「切到别的工作区不被劫持」「卸载/pagehide 后不写上下文」,并断言入队→命名中→终态的账本推进序列);`projectNamingGenerationTaskRow.test.tsx`(3 passed:固定展示名/不渲染缩略图提示词定位、终态才显示结论、失败徽章与原因 + 在途计数);AGC 全量 `npm run test -- apps/ai-game-creator-shell/tests`(1964 passed / 17 skipped);`cargo test --features=cocos-editor-execute,unity-editor-execute,godot-editor-execute asset_generation_task`(27 passed,含 `naming_task_stays_in_flight_across_ledger_reads_until_terminal`、`naming_task_update_requires_the_project_rename_permission`、`naming_task_update_rejects_a_record_from_another_project`、`naming_task_terminal_state_rejects_a_different_status_but_allows_the_same_one`、`ledger_with_an_unsupported_schema_version_fails_closed`、`ledger_without_a_schema_version_is_accepted_as_v1`)与 `conditional_project_rename_tests`(7 passed,含超长名失败关闭);`npm run agc:typecheck`(含 `check:tests:types`)、`cargo fmt --check`、`npm run check:encoding`、`git diff --check`。 ## 2026-10-03 AGC 发布版本标签改为由工程内部版本派生,取代「用户可编辑标签」口径 - 背景:用户实机验收指出发布面板「项目版本」显示 v6,而 AGC 工程内部只有 4 条正式版本记录(资源总览「项目版本」栏目 4 张卡,顶栏「智能体修订」下拉同样只有这 4 条)。核实:面板值来自本地清单 `manifest.projectVersion` 这个可编辑标量,它被三条链路反复钉到**平台** `game_distribution_version.version_number` 上——发布成功回写(`apps/ai-game-creator-shell/src-tauri/src/game_distribution_publish.rs:1531-1535`)、打开面板回读绑定回填(`:536-541`)、用户手改(`:944-965`);而 `manifest.versions` 从头到尾不参与该值。`publicationRevision` 只做 CAS,与任何版本号都无推导关系(`module-game-distribution/src/domain.rs:27-40` 的版本号解析只比 `max_existing` 与 `requested`)。 diff --git a/docs/project-memory/shared-memory/pitfalls.md b/docs/project-memory/shared-memory/pitfalls.md index 4f429d2c1..ff263e55f 100644 --- a/docs/project-memory/shared-memory/pitfalls.md +++ b/docs/project-memory/shared-memory/pitfalls.md @@ -8,7 +8,10 @@ - **根因**:账本的中断收口判据是「非终态 + 不在本进程 live 集合」= 上次运行的残留。素材生成任务由 Rust 后台任务在 `start` 里先登记 live 再落账本;项目命名任务由前端驱动(`enqueue` / `update` 两条 IPC),没有登记 live,于是任何一次 `list_local_project_asset_generations` 都把它当成残留收口。 - **处理(现行口径)**:账本记录加 `taskType`(`asset-generation` / `project-naming`,缺省 `asset-generation`)、`kind` 变可选;schema 升 `agc-asset-generation-task.v2`,读取同时接受 v1 与 v2(v1 记录按素材任务读回)。`enqueue_local_project_naming_task` 写账本前登记 live(写盘失败只回滚本轮插入的那条),`update_local_project_naming_task` 在非终态时幂等登记、终态写盘成功后摘除。命名没有素材可交叉核对,中断收口一律 failed 且文案与素材口径区分(不含「目标素材」)。 - **边界**:命名任务的结论文案(已应用 / 用户已改名跳过 / 失败保留兜底名)由命名链路给出;账本只是展示旁路——`enqueue` / `update` 失败只写诊断日志,绝不影响建项、命名与首轮创作。前端按 `taskType` 把命名记录从素材任务列表里剔除(素材分支会按 `kind` 解析),命名行固定展示名「AI 项目命名」,只在终态显示结论。 -- **判据/取证**:Rust `cargo test … asset_generation_task` 22 条(含 `naming_task_stays_in_flight_across_ledger_reads_until_terminal`、`legacy_v1_ledger_reads_back_with_the_default_asset_generation_task_type`、`interrupted_naming_task_is_settled_as_failed_with_naming_wording`);前端 `homeProjectNamingAsync.test.tsx`(入队→running→终态的推进序列)、`projectNamingGenerationTaskRow.test.tsx`(行渲染)、appSurface 的「syncs the workbench title and recent project list with the AI name after creation」(建项后行可见 + 事件驱动刷新出终态结论)。 +- **边界(兼容是单向的,必须写清)**:**新构建读旧账本 OK,旧构建读新账本会失败**——命名记录写 `"kind": null`,而旧构建(`AssetGenerationTaskRecord.kind` 必填且 `read_ledger` 不校验 schema)解析含命名记录的账本会**整份失败** → 面板报「生成任务列表读取失败」,且同一次读里在途的素材任务会被按「上次运行中断」收口为失败。触发条件只有「同一项目先被新构建写过命名任务、之后又被旧构建打开(降级或新旧混跑)」。潜在的低成本正向兼容方向(**未实施,需先确认**):命名记录也写一个合法 `kind`(例如 `unknown`)只靠 `taskType` 区分——旧构建会把它渲染成一张名为「AI 项目命名」的素材任务卡,但账本能解析、不会误伤在途素材任务。 +- **边界(前端读快照的时序)**:命名行的推进发生在本组件之外,刷新只能靠「变更事件 + 一次受控快照读取」。**必须先订阅再读**(与素材队列「先订阅后派发」同口径):先读后订阅时,落在这一次 IPC 往返里的终态事件没有消费者、直接丢掉,行会永久停在「命名中」;订阅失败时退化为「进项目读一次 + 面板打开时补读」。补读兜底是必须的(订阅不可用、窗口刚恢复、用户想确认进度)。 +- **边界(update 的门禁与终态语义)**:`update_local_project_naming_task` 与 `enqueue` 共用 `project.rename` 权限位,并校验记录 `project_id` 与项目一致;记录一旦终态只接受**同状态幂等重放**,改写成另一种状态(含 `completed → failed`)一律 Err 且不写盘——否则一次迟到的排队/失败事件会覆盖已落定的命名结论。 +- **判据/取证**:Rust `cargo test … asset_generation_task` 27 条(含 `naming_task_stays_in_flight_across_ledger_reads_until_terminal`、`naming_task_update_requires_the_project_rename_permission`、`naming_task_update_rejects_a_record_from_another_project`、`naming_task_terminal_state_rejects_a_different_status_but_allows_the_same_one`、`ledger_with_an_unsupported_schema_version_fails_closed`、`ledger_without_a_schema_version_is_accepted_as_v1`)与 `conditional_project_rename_tests` 7 条(含超长名失败关闭);前端 `homeProjectNamingAsync.test.tsx`(入队→running→终态的推进序列)、`projectNamingGenerationTaskRow.test.tsx`(行渲染)、appSurface 的「syncs the workbench title and recent project list with the AI name after creation」「recovers the naming row when the terminal event lands before the subscription is ready」「re-reads the naming ledger when the generation tasks panel opens」(后两条已做「改前红」验证:退回「先读后订阅 / 面板打开不补读」两条即红)。 - **关联**:`apps/ai-game-creator-shell/src-tauri/src/asset_generation_tasks.rs`、`apps/ai-game-creator-shell/src/features/app-shell/useHomeProjectCreation.ts`、`apps/ai-game-creator-shell/src/features/resource-canvas/resourceCanvasAssetGenerationTaskModel.ts`、`apps/ai-game-creator-shell/src/view/project-development/index.tsx`。 ## 2026-10-03 后台异步回填被「进项目」覆盖:AI 项目名只在兜底名仍成立时改 -- 2.52.0 From a299252f2ae9489d6073794acd8d0c6dc4af0cae Mon Sep 17 00:00:00 2001 From: Suzumiya Date: Mon, 5 Oct 2026 15:51:41 +0800 Subject: [PATCH 7/7] =?UTF-8?q?=E6=94=B6=E5=8F=A3=20#599=20=E8=AF=84?= =?UTF-8?q?=E5=AE=A1=EF=BC=9A=E6=96=87=E6=A1=A3=E8=A1=A5=E9=9D=A2=E6=9D=BF?= =?UTF-8?q?=E8=A1=A5=E8=AF=BB=E7=9A=84=E9=97=A8=E6=8E=A7=E6=9D=A1=E4=BB=B6?= =?UTF-8?q?=E3=80=81PR=20=E6=AD=A3=E6=96=87=E7=94=A8=E4=BE=8B=E6=95=B0?= =?UTF-8?q?=E5=AD=97=E4=B8=8E=E7=9C=9F=E5=AE=9E=E8=AE=A1=E6=95=B0=E5=AF=B9?= =?UTF-8?q?=E9=BD=90?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 文档口径对齐:pitfalls 的「前端读快照的时序」边界与 decision-log 同段补上门控条件——面板打开时**仅当列表里确实存在在途命名行时才补读**(不给没有命名任务的项目多打一次账本 IPC,避免改变「轮询计数」这类外部可观测行为);不再写成「无条件补读」。 - PR #606 正文数字统一为实测值:测试节 asset_generation_task 22 例 → 27 例、conditional_project_rename_tests 6 例 → 7 例(并补上新用例名),验证节全量 1934 → 1964 passed / 17 skipped,合并段补上 76cd1f057(merge f47a8f305)。 - 用例计数命令与输出:cargo test … asset_generation_task → running 27 tests / 27 passed;cargo test … conditional_project_rename_tests → running 7 tests / 7 passed;静态计数(按测试模块内 #[test] 计数)同为 27 / 7;前端 npx vitest run homeProjectNamingAsync projectNamingGenerationTaskRow → 12 + 3 passed,appSurface.test.ts → 209 tests(200 passed / 9 skipped)。 - 本轮同时把分支合并到最新 origin/master tip 76cd1f057(merge f47a8f305,含 #608 Direct 交付合同重构与临时文档清理)——与本文档/hunk 无冲突,合并后 git merge-base --is-ancestor origin/master HEAD 返回 0。 - 未改动项(评审判定非阻塞):useHomeProjectCreation.ts 的改名 revision 回写沿用既有改名路径同形写法,本轮无改动。 - 门禁:cargo 定向 27 + 7、cargo fmt --check、agc:typecheck、lint:eslint(0 problems)、check:encoding、check:spacetime-schema(91 表)、git diff --check 全过;前端命名/面板 15 passed、appSurface 200 passed / 9 skipped。 --- docs/project-memory/shared-memory/decision-log.md | 2 +- docs/project-memory/shared-memory/pitfalls.md | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/project-memory/shared-memory/decision-log.md b/docs/project-memory/shared-memory/decision-log.md index 5aaeb5e97..c2e463f3f 100644 --- a/docs/project-memory/shared-memory/decision-log.md +++ b/docs/project-memory/shared-memory/decision-log.md @@ -5,7 +5,7 @@ - 背景:首页「开启创作」原先串行执行「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 不符、名称与现状相同一律跳过;空 `expectedProjectId` / `expectedName`、空名 / 控制字符 / 超长一律失败关闭。 - 决策(纳入「生成任务」体系):自动命名做成**项目工作台那条既有「生成任务」列表里的一条后台任务**,而不只是页面里的一个 promise。账本记录加 `taskType`(`asset-generation` / `project-naming`,缺省 `asset-generation`)、`kind` 变可选,schema 升 `agc-asset-generation-task.v2`(读取同时接受 v1/v2,v1 记录按素材任务读回);新增 `enqueue_local_project_naming_task`(排队中)/ `update_local_project_naming_task`(命名中 → 已完成/失败),展示名固定「AI 项目命名」,与素材生成共用同一份账本、同一个变更事件与同一个侧栏。状态流转:排队中 → 命名中 → 已完成(已应用 AI 名称 / 建议名与现状一致 / 用户已手动改名而跳过)或失败(无可用名称 / 自动改名失败,均保留兜底名)。命名任务由命名链路推进、**不进 live 集合就会被中断收口误判**,所以 enqueue 写账本前登记 live、update 终态写盘后摘除(中断残留仍按命名口径收口为失败)。前端按 `taskType` 把命名记录从素材任务分支里剔除,命名行不渲染缩略图/提示词/派发与定位动作,结论只在终态显示。 -- 边界:改名是簿记写入,与既有 `rename_local_game_project` 同口径**不推进项目 revision**(推 revision 会让运行时验证凭证无故漂移);返回的 `revision` 是当时盘上的值。后台改名结果写「当前项目上下文」与「最近项目行重检」必须等建项主体收尾(`entrySettled`):AI 比进项目更快时直接写上下文会被随后的 `enterProjectDevelopment` 用兜底名覆盖,最近项目行也要等进项目登记过才会被重检;写入前再过壳的生命周期守卫(`mounted` + 代次),关窗/卸载后只保留已落盘的改名,不写 UI 投影。账本是**展示旁路**:`enqueue` / `update` 失败只写诊断日志,绝不影响建项、命名与首轮创作;「做方案」与手动选目录建项不发起自动命名。本次未改共享契约(`packages/shared/**`)、server-rs 与任何 SpacetimeDB schema/HTTP 路由。条件改名的 `expectedProjectId` / `expectedName` 为空时按仓库同类入口口径失败关闭;`update_local_project_naming_task` 与 enqueue 共用 `project.rename` 权限位、校验记录归属,且终态只接受同状态幂等重放。**兼容性写成显式边界:兼容是单向的**——新构建读 v1 账本 OK;旧构建读到含命名记录(`kind: null`)的账本会整份解析失败(面板报读失败、同批在途素材任务被按中断收口)。前端刷新必须先订阅事件再读快照,并在面板打开时补读一次兜底。 +- 边界:改名是簿记写入,与既有 `rename_local_game_project` 同口径**不推进项目 revision**(推 revision 会让运行时验证凭证无故漂移);返回的 `revision` 是当时盘上的值。后台改名结果写「当前项目上下文」与「最近项目行重检」必须等建项主体收尾(`entrySettled`):AI 比进项目更快时直接写上下文会被随后的 `enterProjectDevelopment` 用兜底名覆盖,最近项目行也要等进项目登记过才会被重检;写入前再过壳的生命周期守卫(`mounted` + 代次),关窗/卸载后只保留已落盘的改名,不写 UI 投影。账本是**展示旁路**:`enqueue` / `update` 失败只写诊断日志,绝不影响建项、命名与首轮创作;「做方案」与手动选目录建项不发起自动命名。本次未改共享契约(`packages/shared/**`)、server-rs 与任何 SpacetimeDB schema/HTTP 路由。条件改名的 `expectedProjectId` / `expectedName` 为空时按仓库同类入口口径失败关闭;`update_local_project_naming_task` 与 enqueue 共用 `project.rename` 权限位、校验记录归属,且终态只接受同状态幂等重放。**兼容性写成显式边界:兼容是单向的**——新构建读 v1 账本 OK;旧构建读到含命名记录(`kind: null`)的账本会整份解析失败(面板报读失败、同批在途素材任务被按中断收口)。前端刷新必须先订阅事件再读快照,并在面板打开时补读一次兜底(**门控**:仅当列表里确实存在在途命名行时才补读,避免给没有命名任务的项目多打一次账本 IPC)。 - 影响范围:`apps/ai-game-creator-shell/src-tauri/src/{commands.rs,desktop.rs,asset_generation_tasks.rs,asset_generation_tasks/runtime.rs}`、`apps/ai-game-creator-shell/src/{app/types.ts,features/app-shell/{useHomeProjectCreation.ts,WorkspaceLauncher.tsx},features/resource-canvas/{resourceCanvasAssetGenerationTaskModel.ts,ResourceCanvasAssetGenerationTasksPanelView.tsx},view/project-development/index.tsx}`、`apps/ai-game-creator-shell/tests/{homeProjectNamingAsync.test.tsx,projectNamingGenerationTaskRow.test.tsx,appSurface/home.suite.ts}`。 - 验证:`npx vitest run apps/ai-game-creator-shell/tests/homeProjectNamingAsync.test.tsx`(12 passed,含「命名请求永不返回仍进工作区」「AI 结果先于进项目落定仍不被兜底名覆盖」「手动改名不被覆盖且任务按已完成+已跳过收口」「非法/空响应 → 任务 failed 且保留兜底名」「做方案不发起命名」「切到别的工作区不被劫持」「卸载/pagehide 后不写上下文」,并断言入队→命名中→终态的账本推进序列);`projectNamingGenerationTaskRow.test.tsx`(3 passed:固定展示名/不渲染缩略图提示词定位、终态才显示结论、失败徽章与原因 + 在途计数);AGC 全量 `npm run test -- apps/ai-game-creator-shell/tests`(1964 passed / 17 skipped);`cargo test --features=cocos-editor-execute,unity-editor-execute,godot-editor-execute asset_generation_task`(27 passed,含 `naming_task_stays_in_flight_across_ledger_reads_until_terminal`、`naming_task_update_requires_the_project_rename_permission`、`naming_task_update_rejects_a_record_from_another_project`、`naming_task_terminal_state_rejects_a_different_status_but_allows_the_same_one`、`ledger_with_an_unsupported_schema_version_fails_closed`、`ledger_without_a_schema_version_is_accepted_as_v1`)与 `conditional_project_rename_tests`(7 passed,含超长名失败关闭);`npm run agc:typecheck`(含 `check:tests:types`)、`cargo fmt --check`、`npm run check:encoding`、`git diff --check`。 ## 2026-10-03 AGC 发布版本标签改为由工程内部版本派生,取代「用户可编辑标签」口径 diff --git a/docs/project-memory/shared-memory/pitfalls.md b/docs/project-memory/shared-memory/pitfalls.md index ff263e55f..f30bca711 100644 --- a/docs/project-memory/shared-memory/pitfalls.md +++ b/docs/project-memory/shared-memory/pitfalls.md @@ -9,7 +9,7 @@ - **处理(现行口径)**:账本记录加 `taskType`(`asset-generation` / `project-naming`,缺省 `asset-generation`)、`kind` 变可选;schema 升 `agc-asset-generation-task.v2`,读取同时接受 v1 与 v2(v1 记录按素材任务读回)。`enqueue_local_project_naming_task` 写账本前登记 live(写盘失败只回滚本轮插入的那条),`update_local_project_naming_task` 在非终态时幂等登记、终态写盘成功后摘除。命名没有素材可交叉核对,中断收口一律 failed 且文案与素材口径区分(不含「目标素材」)。 - **边界**:命名任务的结论文案(已应用 / 用户已改名跳过 / 失败保留兜底名)由命名链路给出;账本只是展示旁路——`enqueue` / `update` 失败只写诊断日志,绝不影响建项、命名与首轮创作。前端按 `taskType` 把命名记录从素材任务列表里剔除(素材分支会按 `kind` 解析),命名行固定展示名「AI 项目命名」,只在终态显示结论。 - **边界(兼容是单向的,必须写清)**:**新构建读旧账本 OK,旧构建读新账本会失败**——命名记录写 `"kind": null`,而旧构建(`AssetGenerationTaskRecord.kind` 必填且 `read_ledger` 不校验 schema)解析含命名记录的账本会**整份失败** → 面板报「生成任务列表读取失败」,且同一次读里在途的素材任务会被按「上次运行中断」收口为失败。触发条件只有「同一项目先被新构建写过命名任务、之后又被旧构建打开(降级或新旧混跑)」。潜在的低成本正向兼容方向(**未实施,需先确认**):命名记录也写一个合法 `kind`(例如 `unknown`)只靠 `taskType` 区分——旧构建会把它渲染成一张名为「AI 项目命名」的素材任务卡,但账本能解析、不会误伤在途素材任务。 -- **边界(前端读快照的时序)**:命名行的推进发生在本组件之外,刷新只能靠「变更事件 + 一次受控快照读取」。**必须先订阅再读**(与素材队列「先订阅后派发」同口径):先读后订阅时,落在这一次 IPC 往返里的终态事件没有消费者、直接丢掉,行会永久停在「命名中」;订阅失败时退化为「进项目读一次 + 面板打开时补读」。补读兜底是必须的(订阅不可用、窗口刚恢复、用户想确认进度)。 +- **边界(前端读快照的时序)**:命名行的推进发生在本组件之外,刷新只能靠「变更事件 + 一次受控快照读取」。**必须先订阅再读**(与素材队列「先订阅后派发」同口径):先读后订阅时,落在这一次 IPC 往返里的终态事件没有消费者、直接丢掉,行会永久停在「命名中」;订阅失败时退化为「进项目读一次 + 面板打开时补读」。补读兜底必须有,但**有门控**:仅当列表里确实存在一条在途命名行时才读——没有命名任务的项目不该因为打开面板就多打一次账本 IPC,那会改变「轮询计数」这类外部可观测行为(既有生成任务用例正是按轮询次数推进 mock 的)。 - **边界(update 的门禁与终态语义)**:`update_local_project_naming_task` 与 `enqueue` 共用 `project.rename` 权限位,并校验记录 `project_id` 与项目一致;记录一旦终态只接受**同状态幂等重放**,改写成另一种状态(含 `completed → failed`)一律 Err 且不写盘——否则一次迟到的排队/失败事件会覆盖已落定的命名结论。 - **判据/取证**:Rust `cargo test … asset_generation_task` 27 条(含 `naming_task_stays_in_flight_across_ledger_reads_until_terminal`、`naming_task_update_requires_the_project_rename_permission`、`naming_task_update_rejects_a_record_from_another_project`、`naming_task_terminal_state_rejects_a_different_status_but_allows_the_same_one`、`ledger_with_an_unsupported_schema_version_fails_closed`、`ledger_without_a_schema_version_is_accepted_as_v1`)与 `conditional_project_rename_tests` 7 条(含超长名失败关闭);前端 `homeProjectNamingAsync.test.tsx`(入队→running→终态的推进序列)、`projectNamingGenerationTaskRow.test.tsx`(行渲染)、appSurface 的「syncs the workbench title and recent project list with the AI name after creation」「recovers the naming row when the terminal event lands before the subscription is ready」「re-reads the naming ledger when the generation tasks panel opens」(后两条已做「改前红」验证:退回「先读后订阅 / 面板打开不补读」两条即红)。 - **关联**:`apps/ai-game-creator-shell/src-tauri/src/asset_generation_tasks.rs`、`apps/ai-game-creator-shell/src/features/app-shell/useHomeProjectCreation.ts`、`apps/ai-game-creator-shell/src/features/resource-canvas/resourceCanvasAssetGenerationTaskModel.ts`、`apps/ai-game-creator-shell/src/view/project-development/index.tsx`。 -- 2.52.0