From a554b0c7ca09ea6c58feb4ade8d2979aa1b0fdf7 Mon Sep 17 00:00:00 2001 From: Suzumiya Date: Sun, 4 Oct 2026 16:59:59 +0800 Subject: [PATCH] =?UTF-8?q?=E6=8A=8A=20AI=20=E9=A1=B9=E7=9B=AE=E5=91=BD?= =?UTF-8?q?=E5=90=8D=E6=8E=A5=E8=BF=9B=E3=80=8C=E7=94=9F=E6=88=90=E4=BB=BB?= =?UTF-8?q?=E5=8A=A1=E3=80=8D=E4=BD=93=E7=B3=BB=EF=BC=9A=E8=B4=A6=E6=9C=AC?= =?UTF-8?q?=20v2=20=E4=BB=BB=E5=8A=A1=E7=B1=BB=E5=9E=8B=E3=80=81=E5=91=BD?= =?UTF-8?q?=E5=90=8D=E4=BB=BB=E5=8A=A1=E5=85=A5=E9=98=9F=E4=B8=8E=E6=8E=A8?= =?UTF-8?q?=E8=BF=9B=E3=80=81=E9=9D=A2=E6=9D=BF=E5=91=BD=E5=90=8D=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 执行面