From bb7cbae118b3efcddf1579a6495bee9d9bc47d43 Mon Sep 17 00:00:00 2001 From: Suzumiya Date: Mon, 14 Sep 2026 17:26:04 +0800 Subject: [PATCH 1/2] =?UTF-8?q?feat(agc):=20=E5=9B=BE=E7=89=87=E7=B1=BB?= =?UTF-8?q?=E7=94=9F=E6=88=90=E5=90=8E=E5=8F=B0=E5=8C=96=EF=BC=8C=E7=94=9F?= =?UTF-8?q?=E6=88=90=E9=9D=A2=E6=9D=BF=E5=8F=AF=E9=9A=8F=E6=97=B6=E5=85=B3?= =?UTF-8?q?=E9=97=AD=E5=B9=B6=E6=96=B0=E5=A2=9E=E3=80=8C=E7=94=9F=E6=88=90?= =?UTF-8?q?=E4=BB=BB=E5=8A=A1=E3=80=8D=E8=BF=9B=E5=BA=A6=E9=9D=A2=E6=9D=BF?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Rust 新增 asset_generation_tasks.rs:图片类生成拆成「提交即返回任务记录 + 后台 spawn 生成」, 生成本身仍转调既有 generate_platform_art_asset_with_options_at(不复制生成 / 幂等 / 登记逻辑) - 任务账本落项目内 .agent/runtime/asset-generation-tasks/tasks.json(复用 agent runtime sidecar 原语), 阶段文案 phaseDetail 由后端拥有,读取时把上次运行留下的非终态记录收口为「中断失败」,账本上限 50 条 - Rust 新增 IPC start_local_project_asset_generation / list_local_project_asset_generations 并在 main.rs 注册; 同步命令 generate_local_project_asset 保留不动 - 新增前端纯模型 resourceCanvasAssetGenerationTaskModel.ts:任务状态机、在途判据、账本恢复、耗时文案 - 新增 resourceCanvasAssetGenerationQueue.ts:本地排队驱动器,第二条不发提交 IPC、前一条终态后自动补发, 状态与阶段一律以后端账本为准,账本查不到该任务时按上限停止等待 - 两块生成浮层在提交期间放开 × / 遮罩 / Esc,次按钮在途时改为「后台运行并关闭」(关闭不等于取消请求), 图片类面板的在途文案改为渲染后端 phaseDetail - 新增非模态「生成任务」面板(不铺遮罩 / 不做焦点陷阱 / 不进模态遮挡判据),显示状态、后端阶段、 已耗时与素材名,已完成条目复用 pendingResourceFocusRef 聚焦链定位素材卡 - index.tsx 宿主接线:任务列表状态与 ref、提交回调不再读面板状态、按项目恢复任务账本、 工具栏「生成任务」入口按钮与面板渲染点 - check-config.mjs:把已无前端调用方的同步命令 generate_local_project_asset 登记进 native-only 白名单 - 测试:新增「生成面板提交期间可关闭」「本地排队驱动器」「生成任务面板」三组用例; appSurface 生成载荷断言改为 start_local_project_asset_generation + list 轮询桩 - 文档:入口矩阵补 §4a 与验证命令、V3 验收 S11a 判据、decision-log 记录本次决策 --- .../scripts/check-config.mjs | 3 + .../src-tauri/src/asset_generation_tasks.rs | 522 ++++++++++++++++++ .../src-tauri/src/main.rs | 4 + ...ResourceCanvasAssetGenerationPanelView.tsx | 25 +- ...rceCanvasAssetGenerationTasksPanelView.tsx | 132 +++++ .../ResourceCanvasGenerationPanelView.tsx | 20 +- .../resourceCanvasAssetGenerationQueue.ts | 272 +++++++++ .../resourceCanvasAssetGenerationTaskModel.ts | 292 ++++++++++ .../src/view/project-development/index.tsx | 377 ++++++++++--- .../appSurface/project-development.suite.ts | 237 +++++++- ...vasAssetGenerationBackgroundClose.test.tsx | 187 +++++++ ...resourceCanvasAssetGenerationQueue.test.ts | 401 ++++++++++++++ ...ceCanvasAssetGenerationTasksPanel.test.tsx | 175 ++++++ .../shared-memory/decision-log.md | 11 + ...C】栏目画布底部工具栏入口矩阵-2026-09-13.md | 42 +- ...用例】AGC资源工作台V3端到端验收-2026-09-11.md | 4 +- 16 files changed, 2582 insertions(+), 122 deletions(-) create mode 100644 apps/ai-game-creator-shell/src-tauri/src/asset_generation_tasks.rs create mode 100644 apps/ai-game-creator-shell/src/features/resource-canvas/ResourceCanvasAssetGenerationTasksPanelView.tsx create mode 100644 apps/ai-game-creator-shell/src/features/resource-canvas/resourceCanvasAssetGenerationQueue.ts create mode 100644 apps/ai-game-creator-shell/src/features/resource-canvas/resourceCanvasAssetGenerationTaskModel.ts create mode 100644 apps/ai-game-creator-shell/tests/resourceCanvasAssetGenerationBackgroundClose.test.tsx create mode 100644 apps/ai-game-creator-shell/tests/resourceCanvasAssetGenerationQueue.test.ts create mode 100644 apps/ai-game-creator-shell/tests/resourceCanvasAssetGenerationTasksPanel.test.tsx diff --git a/apps/ai-game-creator-shell/scripts/check-config.mjs b/apps/ai-game-creator-shell/scripts/check-config.mjs index efa6f62be..b167a6f44 100644 --- a/apps/ai-game-creator-shell/scripts/check-config.mjs +++ b/apps/ai-game-creator-shell/scripts/check-config.mjs @@ -111,6 +111,9 @@ const allowedUncalledTauriCommands = [ 'chat_with_game_creator_agent', 'check_ui_editor_font_glyph_coverage', 'create_ui_design_resource', + // 图片类生成的同步变体:GUI 已改为 `start_local_project_asset_generation` + 项目内任务账本 + // (提交即返回、后台生成),这条命令保留给 Rust 侧与既有单测,不再有前端调用方。 + 'generate_local_project_asset', 'open_game_creator_launcher_window', 'open_game_creator_workspace_window', 'read_direct_project_conversation', 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 new file mode 100644 index 000000000..1c662207f --- /dev/null +++ b/apps/ai-game-creator-shell/src-tauri/src/asset_generation_tasks.rs @@ -0,0 +1,522 @@ +//! 栏目画布「图片类素材生成」的**后台任务账本**。 +//! +//! 背景:`generate_local_project_asset` 一次调用最长要等 35 分钟,面板必须能在提交后立刻关闭, +//! 任务本身继续在后台跑完并把结果写回项目。所以生成不再由「一次 IPC 同步等待」承载,而是: +//! +//! 1. `start_local_project_asset_generation` 校验入参、落一条 `queued` 记录、立刻返回记录; +//! 2. 真正生成在 `tauri::async_runtime::spawn` 出来的后台任务里跑,复用既有 +//! `generate_platform_art_asset_with_options_at`(幂等账本、计费、manifest 登记、本地预览 +//! 全部还是那一条通道,这里不复制任何生成逻辑); +//! 3. `list_local_project_asset_generations` 读回账本,前端轮询它拿状态与阶段文案。 +//! +//! **阶段文案由本模块拥有**(`phase_detail`):前端只渲染后端给的字符串,不自己造百分比或 +//! 假阶段。这也是「进度可见」这条验收判据的落点。 +//! +//! 账本落在**项目内** `.agent/runtime/asset-generation-tasks/tasks.json`(复用既有 agent runtime +//! sidecar 读写原语:临时文件 + rename 替换),所以重开项目后仍能看到历史任务。进程重启时 +//! 还在 `queued` / `running` 的记录不可能再有人推进,读账本时按「上次运行中断」收口为失败, +//! 不假装它还在跑。 + +use std::collections::BTreeSet; +use std::path::{Path, PathBuf}; +use std::sync::{Mutex, OnceLock}; +use std::time::{SystemTime, UNIX_EPOCH}; + +use crate::agent::{ + generate_platform_art_asset_with_options_at, read_agent_runtime_json_sidecar_with_max_bytes, + write_agent_runtime_json_sidecar_with_max_bytes, PlatformArtAssetGenerationOptions, +}; +use crate::commands::prepare_local_project_asset_generation; +use crate::project::enforce_project_permission_policy; + +pub(crate) const ASSET_GENERATION_TASK_SCHEMA_VERSION: &str = "agc-asset-generation-task.v1"; +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; +/// 账本保留的记录上限:只留最近的任务,旧记录按时间淘汰,避免账本无限增长。 +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"; + +const ASSET_GENERATION_TASK_PHASE_QUEUED: &str = "排队中。"; +const ASSET_GENERATION_TASK_PHASE_RUNNING: &str = "正在生成。"; +const ASSET_GENERATION_TASK_PHASE_COMPLETED: &str = "生成已完成。"; +const ASSET_GENERATION_TASK_PHASE_INTERRUPTED: &str = "上次运行中断,生成未完成。"; +const ASSET_GENERATION_TASK_INTERRUPTED_ERROR: &str = "应用退出时生成任务仍在进行"; + +/// 一条生成任务的权威记录。字段名与前端一一对应(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: String, + pub(crate) asset_name: String, + pub(crate) status: String, + /// 阶段文案:**由后端拥有**,前端只渲染。 + pub(crate) phase_detail: String, + pub(crate) created_at_millis: u64, + pub(crate) started_at_millis: Option, + pub(crate) finished_at_millis: Option, + /// 生成成功时的 manifest 资源 id,前端据此定位新卡。 + pub(crate) asset_id: Option, + pub(crate) error: Option, +} + +impl AssetGenerationTaskRecord { + fn is_terminal(&self) -> bool { + matches!( + self.status.as_str(), + ASSET_GENERATION_TASK_STATUS_COMPLETED | ASSET_GENERATION_TASK_STATUS_FAILED + ) + } +} + +#[derive(Clone, Debug, serde::Serialize, serde::Deserialize)] +#[serde(rename_all = "camelCase")] +struct AssetGenerationTaskLedger { + #[serde(default)] + schema_version: String, + #[serde(default)] + tasks: Vec, +} + +/// 账本读写与「本进程还活着哪些任务」共用一把锁:一次读-改-写必须是原子的,否则两个后台 +/// 任务同时收尾会互相覆盖。 +static ASSET_GENERATION_TASK_LOCK: Mutex<()> = Mutex::new(()); +static ASSET_GENERATION_TASK_LIVE_IDS: OnceLock>> = OnceLock::new(); + +fn live_task_ids() -> &'static Mutex> { + ASSET_GENERATION_TASK_LIVE_IDS.get_or_init(|| Mutex::new(BTreeSet::new())) +} + +fn now_millis() -> u64 { + SystemTime::now() + .duration_since(UNIX_EPOCH) + .map(|elapsed| elapsed.as_millis() as u64) + .unwrap_or_default() +} + +fn lock_ledger() -> Result, String> { + ASSET_GENERATION_TASK_LOCK + .lock() + .map_err(|_| "生成任务账本锁已损坏".to_string()) +} + +fn read_ledger(root: &Path) -> Result, String> { + let ledger = read_agent_runtime_json_sidecar_with_max_bytes::( + root, + ASSET_GENERATION_TASK_LEDGER_RELATIVE_PATH, + "生成任务账本", + ASSET_GENERATION_TASK_LEDGER_MAX_BYTES, + )?; + Ok(ledger.map(|ledger| ledger.tasks).unwrap_or_default()) +} + +fn write_ledger(root: &Path, tasks: &[AssetGenerationTaskRecord]) -> Result<(), String> { + let ledger = AssetGenerationTaskLedger { + schema_version: ASSET_GENERATION_TASK_SCHEMA_VERSION.to_string(), + tasks: tasks.to_vec(), + }; + write_agent_runtime_json_sidecar_with_max_bytes( + root, + ASSET_GENERATION_TASK_LEDGER_RELATIVE_PATH, + "生成任务账本", + &ledger, + ASSET_GENERATION_TASK_LEDGER_MAX_BYTES, + ) +} + +/// 只保留最近 `ASSET_GENERATION_TASK_LEDGER_MAX_RECORDS` 条:按创建时间排序取尾部。 +fn trim_ledger(tasks: &mut Vec) { + if tasks.len() <= ASSET_GENERATION_TASK_LEDGER_MAX_RECORDS { + return; + } + tasks.sort_by(|left, right| { + left.created_at_millis + .cmp(&right.created_at_millis) + .then_with(|| left.task_id.cmp(&right.task_id)) + }); + let overflow = tasks.len() - ASSET_GENERATION_TASK_LEDGER_MAX_RECORDS; + tasks.drain(0..overflow); +} + +/// 进程重启后把没人推进的记录收口为失败,并把这次修复写回账本。 +/// +/// 判据是「本进程的 live 集合里没有它」:本进程派发的任务在 `start` 里先登记 live 再落账本, +/// 所以账本里非终态且不 live 的记录只可能来自上一次运行。 +fn repair_interrupted_tasks(tasks: &mut [AssetGenerationTaskRecord]) -> bool { + let live = live_task_ids() + .lock() + .map(|ids| ids.clone()) + .unwrap_or_default(); + let mut repaired = false; + for task in tasks.iter_mut() { + if task.is_terminal() || live.contains(&task.task_id) { + continue; + } + task.status = ASSET_GENERATION_TASK_STATUS_FAILED.to_string(); + task.phase_detail = ASSET_GENERATION_TASK_PHASE_INTERRUPTED.to_string(); + task.error = Some(ASSET_GENERATION_TASK_INTERRUPTED_ERROR.to_string()); + task.finished_at_millis = Some(now_millis()); + repaired = true; + } + repaired +} + +/// 读账本:顺带把「上次运行中断」的任务收口为失败。 +pub(crate) fn list_local_project_asset_generation_tasks( + root: &Path, +) -> Result, String> { + let _guard = lock_ledger()?; + let mut tasks = read_ledger(root)?; + if repair_interrupted_tasks(&mut tasks) { + write_ledger(root, &tasks)?; + } + Ok(tasks) +} + +fn update_task( + root: &Path, + task_id: &str, + mutate: F, +) -> Result +where + F: FnOnce(&mut AssetGenerationTaskRecord), +{ + 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}"))?; + mutate(record); + let snapshot = record.clone(); + write_ledger(root, &tasks)?; + Ok(snapshot) +} + +/// 校验前端给的本地任务 id:它同时是幂等身份,必须是非空单行短字符串。 +fn asset_generation_task_id(task_id: &str) -> Result { + let task_id = task_id.trim(); + if task_id.is_empty() { + return Err("生成任务 id 不能为空".to_string()); + } + if task_id.chars().count() > ASSET_GENERATION_TASK_ID_MAX_CHARS + || task_id.chars().any(char::is_control) + { + return Err("生成任务 id 超出安全边界".to_string()); + } + Ok(task_id.to_string()) +} + +/// 落一条排队记录。调用方保证 task id 在本进程内唯一。 +pub(crate) fn begin_local_project_asset_generation_task( + root: &Path, + project_id: &str, + task_id: &str, + task_kind: &str, + asset_name: &str, +) -> Result { + let record = AssetGenerationTaskRecord { + task_id: task_id.to_string(), + project_id: project_id.trim().to_string(), + kind: task_kind.to_string(), + asset_name: asset_name.to_string(), + status: ASSET_GENERATION_TASK_STATUS_QUEUED.to_string(), + phase_detail: ASSET_GENERATION_TASK_PHASE_QUEUED.to_string(), + created_at_millis: now_millis(), + started_at_millis: None, + finished_at_millis: None, + asset_id: None, + error: None, + }; + let _guard = lock_ledger()?; + let mut tasks = read_ledger(root)?; + if let Some(existing) = tasks.iter().find(|task| task.task_id == record.task_id) { + if !existing.is_terminal() { + return Err(format!("生成任务 id 已在进行中:{}", record.task_id)); + } + } + tasks.retain(|task| task.task_id != record.task_id); + tasks.push(record.clone()); + trim_ledger(&mut tasks); + write_ledger(root, &tasks)?; + Ok(record) +} + +fn remove_live_task_id(task_id: &str) { + if let Ok(mut ids) = live_task_ids().lock() { + ids.remove(task_id); + } +} + +/// 后台执行:状态与阶段文案的每一次流转都由这里写账本。 +async fn run_local_project_asset_generation_task( + root: PathBuf, + task_id: String, + prompt: String, + options: PlatformArtAssetGenerationOptions, +) { + if update_task(&root, &task_id, |task| { + task.status = ASSET_GENERATION_TASK_STATUS_RUNNING.to_string(); + task.phase_detail = ASSET_GENERATION_TASK_PHASE_RUNNING.to_string(); + task.started_at_millis = Some(now_millis()); + }) + .is_err() + { + remove_live_task_id(&task_id); + return; + } + let outcome = generate_platform_art_asset_with_options_at(&root, &prompt, &[], &options).await; + match outcome { + Ok(generated) => { + let _ = update_task(&root, &task_id, |task| { + task.status = ASSET_GENERATION_TASK_STATUS_COMPLETED.to_string(); + task.phase_detail = ASSET_GENERATION_TASK_PHASE_COMPLETED.to_string(); + task.asset_id = Some(generated.asset.id.clone()); + task.finished_at_millis = Some(now_millis()); + task.error = None; + }); + } + Err(error) => { + let _ = update_task(&root, &task_id, |task| { + task.status = ASSET_GENERATION_TASK_STATUS_FAILED.to_string(); + task.phase_detail = format!("生成失败:{error}"); + task.error = Some(error.clone()); + task.finished_at_millis = Some(now_millis()); + }); + } + } + remove_live_task_id(&task_id); +} + +/// 提交即返回:校验入参 → 落排队记录 → 派发后台任务 → 返回记录。 +/// +/// 入参收口完全复用 `prepare_local_project_asset_generation`(与同步命令同一份白名单与边界), +/// 生成本身仍是 `generate_platform_art_asset_with_options_at`,本命令不复制任何生成逻辑。 +#[tauri::command] +pub(crate) async fn start_local_project_asset_generation( + project_path: String, + project_id: String, + task_id: String, + kind: String, + prompt: String, + aspect_ratio: Option, + image_size: Option, + asset_name: Option, + output_path: Option, +) -> Result { + let task_id = asset_generation_task_id(&task_id)?; + let request = prepare_local_project_asset_generation( + &project_path, + &kind, + &prompt, + aspect_ratio.as_deref(), + image_size.as_deref(), + asset_name.as_deref(), + output_path.as_deref(), + )?; + enforce_project_permission_policy(&request.root, "canvas.asset_generate")?; + enforce_project_permission_policy(&request.root, "asset.register")?; + let asset_label = request.options.asset_label.clone(); + let asset_kind = request.options.asset_kind.clone(); + let record = begin_local_project_asset_generation_task( + &request.root, + &project_id, + &task_id, + &asset_kind, + &asset_label, + )?; + // 先登记 live 再派发:`list` 只把「非终态且不 live」的记录判为上次运行的残留。 + if let Ok(mut ids) = live_task_ids().lock() { + ids.insert(task_id.clone()); + } + let root = request.root.clone(); + tauri::async_runtime::spawn(run_local_project_asset_generation_task( + root, + task_id, + request.prompt, + request.options, + )); + Ok(record) +} + +/// 读回项目内账本(重开项目后仍能看到历史任务)。 +#[tauri::command] +pub(crate) fn list_local_project_asset_generations( + project_path: String, +) -> Result, String> { + let project_path = project_path.trim(); + if project_path.is_empty() { + return Err("项目路径不能为空".to_string()); + } + let root = Path::new(project_path); + enforce_project_permission_policy(root, "asset.read")?; + list_local_project_asset_generation_tasks(root) +} + +#[cfg(test)] +mod asset_generation_task_tests { + use super::*; + + fn temp_project_root(label: &str) -> PathBuf { + let nonce = SystemTime::now() + .duration_since(UNIX_EPOCH) + .map(|elapsed| elapsed.as_nanos()) + .unwrap_or_default(); + let root = std::env::temp_dir().join(format!( + "agc-asset-generation-tasks-{label}-{}-{nonce}", + std::process::id() + )); + std::fs::create_dir_all(&root).expect("temp project root"); + root + } + + fn begin(root: &Path, task_id: &str) -> AssetGenerationTaskRecord { + begin_local_project_asset_generation_task(root, "project-1", task_id, "image", "AI 图") + .expect("begin task") + } + + #[test] + fn started_task_is_queued_with_backend_owned_phase_detail() { + let root = temp_project_root("queued"); + let record = begin(&root, "task-queued"); + 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.asset_name, "AI 图"); + assert!(record.started_at_millis.is_none()); + assert!(record.asset_id.is_none()); + let listed = list_local_project_asset_generation_tasks(&root).expect("list"); + assert_eq!(listed.len(), 1); + assert_eq!(listed[0].task_id, "task-queued"); + std::fs::remove_dir_all(&root).ok(); + } + + #[test] + fn ledger_lives_in_project_so_tasks_survive_a_reopen() { + let root = temp_project_root("durable"); + begin(&root, "task-durable"); + let ledger = root.join(ASSET_GENERATION_TASK_LEDGER_RELATIVE_PATH); + assert!(ledger.is_file(), "账本必须落在项目内的相对路径上"); + let reopened = list_local_project_asset_generation_tasks(&root).expect("reopen"); + assert_eq!(reopened.len(), 1); + assert_eq!(reopened[0].task_id, "task-durable"); + std::fs::remove_dir_all(&root).ok(); + } + + #[test] + fn restarting_the_process_settles_unfinished_tasks_as_interrupted() { + let root = temp_project_root("interrupted"); + begin(&root, "task-interrupted"); + // 模拟「上一条进程留下的非终态记录」:live 集合里没有它(本测试进程从未 start 过它)。 + let listed = list_local_project_asset_generation_tasks(&root).expect("list"); + assert_eq!(listed[0].status, ASSET_GENERATION_TASK_STATUS_FAILED); + assert_eq!( + listed[0].phase_detail, + ASSET_GENERATION_TASK_PHASE_INTERRUPTED + ); + assert_eq!( + listed[0].error.as_deref(), + Some(ASSET_GENERATION_TASK_INTERRUPTED_ERROR) + ); + assert!(listed[0].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(); + } + + #[test] + fn live_tasks_are_not_settled_while_they_are_still_running() { + let root = temp_project_root("live"); + begin(&root, "task-live"); + live_task_ids() + .lock() + .expect("live ids") + .insert("task-live".to_string()); + let listed = list_local_project_asset_generation_tasks(&root).expect("list"); + assert_eq!(listed[0].status, ASSET_GENERATION_TASK_STATUS_QUEUED); + remove_live_task_id("task-live"); + let settled = list_local_project_asset_generation_tasks(&root).expect("list settled"); + assert_eq!(settled[0].status, ASSET_GENERATION_TASK_STATUS_FAILED); + std::fs::remove_dir_all(&root).ok(); + } + + #[test] + fn completing_a_task_records_the_manifest_asset_id_and_keeps_it() { + let root = temp_project_root("completed"); + begin(&root, "task-completed"); + let updated = update_task(&root, "task-completed", |task| { + task.status = ASSET_GENERATION_TASK_STATUS_COMPLETED.to_string(); + task.phase_detail = ASSET_GENERATION_TASK_PHASE_COMPLETED.to_string(); + task.asset_id = Some("asset-9".to_string()); + task.finished_at_millis = Some(now_millis()); + }) + .expect("complete task"); + assert_eq!(updated.asset_id.as_deref(), Some("asset-9")); + let listed = list_local_project_asset_generation_tasks(&root).expect("list"); + assert_eq!(listed[0].status, ASSET_GENERATION_TASK_STATUS_COMPLETED); + assert_eq!(listed[0].asset_id.as_deref(), Some("asset-9")); + std::fs::remove_dir_all(&root).ok(); + } + + #[test] + fn a_second_task_with_the_same_id_is_rejected_while_the_first_is_in_flight() { + let root = temp_project_root("duplicate"); + begin(&root, "task-dup"); + let error = begin_local_project_asset_generation_task( + &root, + "project-1", + "task-dup", + "image", + "AI 图", + ) + .expect_err("duplicate in-flight task"); + assert_eq!(error, "生成任务 id 已在进行中:task-dup"); + std::fs::remove_dir_all(&root).ok(); + } + + #[test] + fn task_ids_are_bounded_single_line_values() { + assert_eq!( + asset_generation_task_id(" ").expect_err("empty id"), + "生成任务 id 不能为空" + ); + assert_eq!( + asset_generation_task_id("带\n换行").expect_err("control char"), + "生成任务 id 超出安全边界" + ); + assert_eq!( + asset_generation_task_id(&"x".repeat(ASSET_GENERATION_TASK_ID_MAX_CHARS + 1)) + .expect_err("oversized id"), + "生成任务 id 超出安全边界" + ); + assert_eq!( + asset_generation_task_id(" task-1 ").expect("trimmed id"), + "task-1" + ); + } + + #[test] + fn ledger_keeps_only_the_most_recent_records() { + let root = temp_project_root("trim"); + for index in 0..(ASSET_GENERATION_TASK_LEDGER_MAX_RECORDS + 5) { + begin(&root, &format!("task-{index:03}")); + } + let listed = list_local_project_asset_generation_tasks(&root).expect("list"); + assert_eq!(listed.len(), ASSET_GENERATION_TASK_LEDGER_MAX_RECORDS); + assert!( + listed.iter().any(|task| task.task_id + == format!("task-{:03}", ASSET_GENERATION_TASK_LEDGER_MAX_RECORDS + 4)), + "最新一条必须保留" + ); + std::fs::remove_dir_all(&root).ok(); + } +} diff --git a/apps/ai-game-creator-shell/src-tauri/src/main.rs b/apps/ai-game-creator-shell/src-tauri/src/main.rs index 3813a0eb6..20ff00446 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/main.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/main.rs @@ -244,6 +244,7 @@ macro_rules! app_log { // 用 #[cfg] 编译期门控:仅开发(debug)且非测试构建编入;生产 release 与 cargo test 下整体剔除。 mod agent; mod agent_native_tools; +mod asset_generation_tasks; mod assets; mod browser; mod builtin_plugins; @@ -289,6 +290,7 @@ mod windows; use agent::*; use agent_native_tools::*; +use asset_generation_tasks::*; use assets::*; use browser::*; use cli::*; @@ -2697,6 +2699,8 @@ fn main() { ensure_ui_design_resource_for_prototype, generate_platform_art_asset, generate_local_project_asset, + start_local_project_asset_generation, + list_local_project_asset_generations, open_canvas_project, get_game_creation_agent_capabilities, get_limited_local_commands, diff --git a/apps/ai-game-creator-shell/src/features/resource-canvas/ResourceCanvasAssetGenerationPanelView.tsx b/apps/ai-game-creator-shell/src/features/resource-canvas/ResourceCanvasAssetGenerationPanelView.tsx index 89402805e..d9443353b 100644 --- a/apps/ai-game-creator-shell/src/features/resource-canvas/ResourceCanvasAssetGenerationPanelView.tsx +++ b/apps/ai-game-creator-shell/src/features/resource-canvas/ResourceCanvasAssetGenerationPanelView.tsx @@ -24,6 +24,12 @@ export type ResourceCanvasAssetGenerationSubmitInput = { export type ResourceCanvasAssetGenerationPanelViewProps = { action: ResourceCanvasAssetToolAction; + /** + * 在途阶段文案,**由后端任务账本提供**(`phaseDetail`)。 + * + * 面板不自己编进度:派发前是本地排队的「排队中。」,派发后是后端的「正在生成。」之类。 + */ + phaseDetail?: string | null; onSubmit: (input: ResourceCanvasAssetGenerationSubmitInput) => Promise; onClose: () => void; }; @@ -41,6 +47,10 @@ function assetGenerationErrorMessage(error: unknown) { * 形态是独立弹层(`ThemedModal`,与既有「生成素材」面板同一套宿主 chrome),不在任何现有 * 面板下面追加内容;提交链路与结果定位由宿主 `index.tsx` 负责,面板只持有草稿与失败状态。 * + * 生成已经在后端后台跑(`start_local_project_asset_generation` + 项目内任务账本),所以 + * **提交期间面板必须能关**:× / 遮罩 / Esc / 「后台运行并关闭」四条路径都通,关闭只是把这一份 + * view 卸下来,请求继续在后台跑完并把结果写回项目——关闭**不等于**取消。 + * * 比例 / 尺寸选项来自网页端美术画布的纯模型(`ImageCanvasGenerationModel.ts`)经本地 IPC * 白名单收窄后的子集:网页端面板会渲染 `4:3`,而本地通道明确拒绝它,照搬就是一个点了必 * 失败的选项。模型档位不由本面板提供——本地 IPC 没有 `model` 入参,渲染一个改不了请求的 @@ -48,6 +58,7 @@ function assetGenerationErrorMessage(error: unknown) { */ export function ResourceCanvasAssetGenerationPanelView({ action, + phaseDetail, onSubmit, onClose, }: ResourceCanvasAssetGenerationPanelViewProps) { @@ -92,13 +103,7 @@ export function ResourceCanvasAssetGenerationPanelView({ { - if (!submitting) { - onClose(); - } - }} + onClose={onClose} panelClassName="game-approval-dialog game-resource-generation-dialog" >
@@ -108,7 +113,6 @@ export function ResourceCanvasAssetGenerationPanelView({ +
+ {ordered.length === 0 ? ( +

+ 还没有生成任务 +

+ ) : ( +
    + {ordered.map((task) => { + const focusable = + task.status === 'completed' && Boolean(task.assetId); + return ( +
  • +
    + + {task.assetName} + + + {task.actionLabel} + +
    +
    + + {RESOURCE_CANVAS_ASSET_GENERATION_STATUS_LABELS[task.status]} + + {task.phaseDetail} + + {`已耗时 ${resourceCanvasAssetGenerationElapsedLabel( + resourceCanvasAssetGenerationTaskElapsedMillis( + task, + nowMillis, + ), + )}`} + +
    + {task.error ? ( +

    + {task.error} +

    + ) : null} + {focusable ? ( + + ) : null} +
  • + ); + })} +
+ )} + + ); +} diff --git a/apps/ai-game-creator-shell/src/features/resource-canvas/ResourceCanvasGenerationPanelView.tsx b/apps/ai-game-creator-shell/src/features/resource-canvas/ResourceCanvasGenerationPanelView.tsx index 5d2ff8ddb..ad3eaad36 100644 --- a/apps/ai-game-creator-shell/src/features/resource-canvas/ResourceCanvasGenerationPanelView.tsx +++ b/apps/ai-game-creator-shell/src/features/resource-canvas/ResourceCanvasGenerationPanelView.tsx @@ -58,6 +58,10 @@ function resourceGenerationErrorMessage(error: unknown) { * 形态是独立弹层(`ThemedModal`,与资源面板 / 分类面板同一套宿主 chrome), * 不在任何现有面板下面追加内容;提交链路与结果定位由宿主 `index.tsx` 负责, * 面板只持有草稿、类型选择与失败重试状态。 + * + * 提交期间**不锁关闭**:× / 遮罩 / Esc / 「后台运行并关闭」四条路径都通。关闭只是把这一份 + * view 卸下来,宿主那条请求继续跑(它是宿主的 `await onSubmit(...)`,不挂在面板生命周期上), + * 所以关闭**不等于**取消;失败时面板仍保留草稿与同一份请求身份可重试。 */ export function ResourceCanvasGenerationPanelView({ kinds = RESOURCE_CANVAS_GENERATION_OPTIONS.map((option) => option.kind), @@ -115,8 +119,8 @@ export function ResourceCanvasGenerationPanelView({ } catch (submitError) { setError(resourceGenerationErrorMessage(submitError)); } finally { - // 成功路径也要收回在飞标记:宿主现在还靠卸载面板兜底,但组件本身不该 - // 在 `onSubmit` 正常 resolve 后永久停在「生成中…」并把关闭路径全部锁住。 + // 成功路径也要收回在飞标记:宿主随后会卸载面板,但组件本身不该在 `onSubmit` 正常 + // resolve 后永久停在「生成中…」;失败时收回标记才能让用户用同一份输入重试。 setSubmitting(false); } } @@ -125,13 +129,7 @@ export function ResourceCanvasGenerationPanelView({ { - if (!submitting) { - onClose(); - } - }} + onClose={onClose} panelClassName="game-approval-dialog game-resource-generation-dialog" >
@@ -141,7 +139,6 @@ export function ResourceCanvasGenerationPanelView({ ) : null} + {/* + 「生成任务」入口:生成在后台跑,面板关掉之后进度、阶段与失败原因都只在这 + 里可见(非模态浮层,画布照常可用)。 + */} + {pendingResourceEdits.length > 0 || pendingResourceEditsLoadState === 'failed' ? (