From 99fb6c38c1530f6223c724882e0e2339402e65d8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E5=BE=B7=E5=AE=87?= Date: Sun, 20 Sep 2026 23:32:09 +0800 Subject: [PATCH 1/7] =?UTF-8?q?AGC=20=E9=9F=B3=E9=A2=91=E7=94=9F=E6=88=90?= =?UTF-8?q?=E5=B9=B6=E5=85=A5=E5=9B=BE=E7=89=87=E7=B1=BB=E9=82=A3=E4=BB=BD?= =?UTF-8?q?=E5=90=8E=E5=8F=B0=E4=BB=BB=E5=8A=A1=E8=B4=A6=E6=9C=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 原生:`start_local_project_asset_generation` 新增可选入参 `idempotencyKey`,并在音频 kind 上分叉;图片类载荷与分支逐字未改 - 原生:新增音频提交期收口 `prepare_local_project_audio_generation`(kind / 提示词上限 / 素材名 / operation 身份 / 幂等键) - 原生:新增 `run_local_project_audio_generation_at`,在派发时刻读项目 revision 并复用既有音频无源生成实现,不复制生成逻辑 - 原生:新增 `begin_local_project_audio_generation_task` / `run_local_project_audio_generation_task`,音频任务落同一份项目内账本并写 running → completed / failed,跑完没登记素材按失败收口 - 原生:补两条用例——被拒绝的提交零写入账本、音频任务落在同一账本且 kind 正确 - 前端任务模型:新增音频任务与 `idempotencyKey` 字段,恢复出来的历史任务不带它也不承接重试,入口文案扩到音频栏目 - 前端队列:按 kind 分流派发载荷,音频只发任务身份(任务 id 即 operation id)与幂等键 - 前端面板:音频生成面板改为点「生成」同步提交并立即关闭,删除「生成中…」「后台运行并关闭」与输入锁定 - 前端宿主:音频提交改为同步入队并展开「生成任务」侧栏,只有「后端从未受理」才连原草稿与原请求身份重开面板 - 前端清理:删除随本次改动失效的 `resourceCanvasGenerationSourceId` 与宿主里不再使用的 import - 测试:面板 / 队列 / 宿主生命周期 / 落点 / appSurface 改按后台账本口径断言,并补「未受理即时失败重开」用例(已用移除重开逻辑的变异验证其非空) - 文档:PRD §3.10 / §7.9、AGC 底部工具栏入口矩阵、V3 端到端验收 S11a 同步为音频后台化口径 - 文档:新增里程碑与实施计划(含验收证据矩阵),并在 decision-log 记下「音频并入后台任务账本」这条长期约定 --- .../src-tauri/src/asset_generation_tasks.rs | 276 ++++++++++++++- .../src-tauri/src/project/resource_editor.rs | 84 +++++ .../ResourceCanvasGenerationPanelView.tsx | 86 ++--- .../resourceCanvasAssetGenerationQueue.ts | 25 +- .../resourceCanvasAssetGenerationTaskModel.ts | 95 ++++- .../resourceCanvasBottomToolbarModel.ts | 9 +- .../resourceCanvasGenerationModel.ts | 19 +- .../resourceCanvasToolbarModel.ts | 3 +- .../src/view/project-development/index.tsx | 253 +++++++------- .../appSurface/project-development.suite.ts | 60 ++-- ...vasAssetGenerationBackgroundClose.test.tsx | 55 +-- ...resourceCanvasAssetGenerationQueue.test.ts | 51 +++ .../resourceCanvasGenerationEntry.test.tsx | 141 ++++++-- ...urceCanvasGenerationHostLifecycle.test.tsx | 330 ++++++++++++------ .../resourceCanvasGenerationLanding.test.tsx | 72 ++-- ...AI游戏创作】项目开发工作台PRD-2026-07-20.md | 11 +- ...划】AGC音频生成进入后台任务账本-2026-09-20.md | 51 +++ ...碑】AGC音频生成进入后台任务账本-2026-09-20.md | 51 +++ .../shared-memory/decision-log.md | 11 + ...C】栏目画布底部工具栏入口矩阵-2026-09-13.md | 17 +- ...用例】AGC资源工作台V3端到端验收-2026-09-11.md | 2 +- 21 files changed, 1251 insertions(+), 451 deletions(-) create mode 100644 docs/project-memory/plans/【实施计划】AGC音频生成进入后台任务账本-2026-09-20.md create mode 100644 docs/project-memory/plans/【里程碑】AGC音频生成进入后台任务账本-2026-09-20.md 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 f7b1e897d..22806307a 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 @@ -29,7 +29,11 @@ use crate::agent::{ write_agent_runtime_json_sidecar_with_max_bytes, PlatformArtAssetGenerationOptions, }; use crate::commands::prepare_local_project_asset_generation; -use crate::project::{enforce_project_permission_policy, read_existing_manifest_for_project}; +use crate::project::{ + enforce_project_permission_policy, prepare_local_project_audio_generation, + read_existing_manifest_for_project, run_local_project_audio_generation_at, + LocalProjectAudioGenerationRequest, LocalProjectResourceEditKind, +}; use shared_contracts::game_creation_app::GameCreationAppAssetKind; pub(crate) const ASSET_GENERATION_TASK_SCHEMA_VERSION: &str = "agc-asset-generation-task.v1"; @@ -63,6 +67,8 @@ const ASSET_GENERATION_TASK_INTERRUPTED_INCOMPLETE_ERROR: &str = "应用退出时生成任务仍在进行,目标素材未登记"; const ASSET_GENERATION_TASK_INTERRUPTED_UNKNOWN_ERROR: &str = "应用退出时生成任务仍在进行,未能在清单里确认结果"; +/// 音频任务收口:通道跑完但没有登记出素材(`derive` 在有源 / 无源两条路上都必须登记 assets)。 +const ASSET_GENERATION_AUDIO_MISSING_ASSET_ERROR: &str = "生成完成但未登记素材"; /// 一条生成任务的权威记录。字段名与前端一一对应(camelCase)。 #[derive(Clone, Debug, Eq, PartialEq, serde::Serialize, serde::Deserialize)] @@ -376,10 +382,121 @@ async fn run_local_project_asset_generation_task( remove_live_task_id(&task_id); } +/// 走音频无源生成链路的 kind:音效与背景音乐。 +/// +/// 这份判据是「同一命令两种通道」的唯一分叉点:它在白名单里只放这两个成员,其余 kind +/// (含图片类与 `unknown`)一律继续走图片通道的既有收口,不在这一层做兜底猜测。 +fn is_audio_asset_generation_kind(kind: GameCreationAppAssetKind) -> bool { + matches!( + kind, + GameCreationAppAssetKind::SoundEffect | GameCreationAppAssetKind::BackgroundMusic + ) +} + +/// 音频(音效 / 背景音乐)提交:校验入参 → 落**同一份**排队记录 → 返回记录与派发所需的请求。 +/// +/// 与图片类分支的差异只有三处,且都不改变账本形状: +/// 1. 权限沿用既有无源生成链路的 `asset.register`(音频入口在后台化之前就是这条判据); +/// 2. 账本去掉精确落点(音频不指定 `outputPath`); +/// 3. 生成走 `run_local_project_audio_generation_task`(由调用方派发,本函数不 spawn——校验与 +/// 落账必须能在没有异步运行时的测试里单独断言)。 +fn begin_local_project_audio_generation_task( + project_path: &str, + project_id: &str, + task_id: &str, + kind: &str, + prompt: &str, + asset_name: &str, + idempotency_key: &str, +) -> Result< + ( + AssetGenerationTaskRecord, + LocalProjectAudioGenerationRequest, + ), + String, +> { + let request = + prepare_local_project_audio_generation(task_id, kind, prompt, asset_name, idempotency_key)?; + if project_path.trim().is_empty() { + return Err("项目路径不能为空".to_string()); + } + let root = Path::new(project_path.trim()); + enforce_project_permission_policy(root, "asset.register")?; + let asset_kind = match request.edit_kind { + LocalProjectResourceEditKind::BackgroundMusic => GameCreationAppAssetKind::BackgroundMusic, + _ => GameCreationAppAssetKind::SoundEffect, + }; + let record = begin_local_project_asset_generation_task( + root, + project_id, + task_id, + asset_kind, + &request.asset_name, + None, + )?; + Ok((record, request)) +} + +/// 音频后台执行:状态与阶段文案的每一次流转都由这里写账本。 +/// +/// `run_local_project_audio_generation_at` 返回 `Ok(None)` 表示这次生成没有登记出素材:按失败 +/// 收口,不把一条没有 `assetId` 的记录标成「已完成」——那样前端既定位不到素材,也没有原因可看。 +async fn run_local_project_audio_generation_task( + project_path: String, + task_id: String, + request: LocalProjectAudioGenerationRequest, +) { + let root = PathBuf::from(project_path.trim()); + 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 = run_local_project_audio_generation_at(&project_path, &request).await; + match outcome { + Ok(Some(asset_id)) => { + 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(asset_id); + task.finished_at_millis = Some(now_millis()); + task.error = None; + }); + } + Ok(None) => { + let _ = update_task(&root, &task_id, |task| { + task.status = ASSET_GENERATION_TASK_STATUS_FAILED.to_string(); + task.phase_detail = + format!("生成失败:{ASSET_GENERATION_AUDIO_MISSING_ASSET_ERROR}"); + task.error = Some(ASSET_GENERATION_AUDIO_MISSING_ASSET_ERROR.to_string()); + task.finished_at_millis = Some(now_millis()); + }); + } + 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`,本命令不复制任何生成逻辑。 +/// +/// 音频 kind(`sound-effect` / `background-music`)走同一条命令的音频分支:账本、阶段文案、 +/// 中断收口与本地排队全部共用,**只**把「怎么生成」换成既有音频无源生成链路(见 +/// `start_local_project_audio_generation_task`)。图片类载荷口径逐字不变。 #[tauri::command] pub(crate) async fn start_local_project_asset_generation( project_path: String, @@ -397,8 +514,40 @@ pub(crate) async fn start_local_project_asset_generation( // 前端 IPC 字段 `targetCategory`:完成登记时要落盘的正式栏目分类。同样不进任务账本: // 它与引用一样属于「同一次提交的本地落点」,重试由调用方继续用同一个栏目提交。 target_category: Option, + // 前端 IPC 字段 `idempotencyKey`:**音频**生成才带——音频请求身份是一对 operation / 幂等键, + // 重试必须复用同一对,否则就变成第二次付费生成。图片类通道的载荷逐字不变,这个字段对 + // 图片 kind 不参与任何校验。 + idempotency_key: Option, ) -> Result { let task_id = asset_generation_task_id(&task_id)?; + if is_audio_asset_generation_kind(GameCreationAppAssetKind::parse_with_context( + &kind, + "canvas.asset_kind", + )) { + let idempotency_key = idempotency_key.unwrap_or_default(); + if idempotency_key.trim().is_empty() { + return Err("音频生成缺少 idempotencyKey".to_string()); + } + let (record, request) = begin_local_project_audio_generation_task( + &project_path, + &project_id, + &task_id, + &kind, + &prompt, + asset_name.as_deref().unwrap_or_default(), + &idempotency_key, + )?; + // 先登记 live 再派发:`list` 只把「非终态且不 live」的记录判为上次运行的残留。 + if let Ok(mut ids) = live_task_ids().lock() { + ids.insert(task_id.clone()); + } + tauri::async_runtime::spawn(run_local_project_audio_generation_task( + project_path.trim().to_string(), + task_id, + request, + )); + return Ok(record); + } let request = prepare_local_project_asset_generation( &project_path, &kind, @@ -761,4 +910,129 @@ mod asset_generation_task_tests { ); std::fs::remove_dir_all(&root).ok(); } + /// 音频提交的身份与边界:缺幂等键 / 非法 operation / 非法幂等键 / 超限提示词 / 非音频 kind + /// 一律在提交期拒绝,且**不**在账本里留下记录——「点击瞬间就失败」必须是零写入。 + #[test] + fn audio_submission_rejects_invalid_identity_and_prompt_without_touching_the_ledger() { + let root = initialized_project_root("audio-invalid"); + let project_path = root.to_string_lossy().into_owned(); + let operation_id = "0b6f2f9a-0f0f-4b3d-8d0a-5e0f5cef9b21"; + let idempotency_key = "9a1b2c3d-4e5f-4a1b-8c2d-3e4f5a6b7c8d"; + + let error = begin_local_project_audio_generation_task( + &project_path, + "project-1", + operation_id, + "background-music", + "一段平静的钢琴曲", + "新背景音乐", + "", + ) + .expect_err("missing idempotency key"); + assert!(error.contains("idempotencyKey"), "{error}"); + + let error = begin_local_project_audio_generation_task( + &project_path, + "project-1", + "not-a-uuid", + "background-music", + "一段平静的钢琴曲", + "新背景音乐", + idempotency_key, + ) + .expect_err("operation id must be a uuid"); + assert!(error.contains("operationId"), "{error}"); + + let error = begin_local_project_audio_generation_task( + &project_path, + "project-1", + operation_id, + "background-music", + "一段平静的钢琴曲", + "新背景音乐", + "不看幂等键", + ) + .expect_err("idempotency key must be a uuid"); + assert!(error.contains("idempotencyKey"), "{error}"); + + let error = begin_local_project_audio_generation_task( + &project_path, + "project-1", + operation_id, + "background-music", + &"曲".repeat(141), + "新背景音乐", + idempotency_key, + ) + .expect_err("background music prompt limit"); + assert!(error.contains("140"), "{error}"); + + let error = begin_local_project_audio_generation_task( + &project_path, + "project-1", + operation_id, + "audio", + "一段平静的钢琴曲", + "新背景音乐", + idempotency_key, + ) + .expect_err("音频 kind 不是可生成的音频类型"); + assert!(error.contains("音频生成不支持该素材类型"), "{error}"); + + assert!( + list_local_project_asset_generation_tasks(&root) + .expect("list") + .is_empty(), + "被拒绝的提交不得在账本里留下记录" + ); + std::fs::remove_dir_all(&root).ok(); + } + + /// 音频任务的账本记录与图片类共用同一份:kind 是音频 canonical kind,阶段文案由后端拥有, + /// 精确落点为空(音频不指定 outputPath),提示词在提交期就按同一口径归一化。 + #[test] + fn audio_submission_lands_in_the_shared_ledger_with_its_audio_kind() { + let root = initialized_project_root("audio-ledger"); + let project_path = root.to_string_lossy().into_owned(); + let (record, request) = begin_local_project_audio_generation_task( + &project_path, + "project-1", + "0b6f2f9a-0f0f-4b3d-8d0a-5e0f5cef9b21", + "background-music", + " 一段平静的钢琴曲 ", + "新背景音乐", + "9a1b2c3d-4e5f-4a1b-8c2d-3e4f5a6b7c8d", + ) + .expect("background music task"); + assert_eq!(record.kind, 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()); + assert_eq!(record.asset_name, "新背景音乐"); + assert_eq!(request.prompt, "一段平静的钢琴曲"); + assert_eq!( + request.edit_kind, + LocalProjectResourceEditKind::BackgroundMusic + ); + + 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].task_id, record.task_id); + + // 音效走同一条账本,只是落到另一个 canonical kind。 + let (sound_effect, request) = begin_local_project_audio_generation_task( + &project_path, + "project-1", + "1c7a3b8e-2f31-4c6d-9e7a-6b8c0d1e2f34", + "sound-effect", + "木门缓慢推开的吱呀声", + "新音效", + "2d8b4c9f-3a42-4d7e-8f1b-7c9d1e2f3a45", + ) + .expect("sound effect task"); + assert_eq!(sound_effect.kind, GameCreationAppAssetKind::SoundEffect); + assert_eq!(request.edit_kind, LocalProjectResourceEditKind::SoundEffect); + std::fs::remove_dir_all(&root).ok(); + } } diff --git a/apps/ai-game-creator-shell/src-tauri/src/project/resource_editor.rs b/apps/ai-game-creator-shell/src-tauri/src/project/resource_editor.rs index 0a4a7f772..693b34dd9 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/project/resource_editor.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/project/resource_editor.rs @@ -5399,6 +5399,90 @@ pub(crate) async fn resume_local_project_resource_edit_at( .await } +/// 音频(音效 / 背景音乐)无源生成的入参收口。 +/// +/// 与同步派生通道(`derive_local_project_resource`)共用同一份校验:提示词上限按 edit kind +/// 取(背景音乐 140、音效 1900),素材名同口径,`idempotencyKey` 必须是合法 UUID。区别只在 +/// **时机**:后台任务账本的提交必须「校验即返回」,所以这里只收口、不发起生成——生成由派发后 +/// 的后台任务跑(见 `run_local_project_audio_generation_at`)。 +#[derive(Clone, Debug)] +pub(crate) struct LocalProjectAudioGenerationRequest { + pub(crate) operation_id: String, + pub(crate) edit_kind: LocalProjectResourceEditKind, + pub(crate) prompt: String, + pub(crate) asset_name: String, + pub(crate) idempotency_key: String, +} + +/// 音频 kind 的提交期收口:operation 身份、kind、提示词、素材名与幂等键。 +/// +/// kind 只接受 `sound-effect` / `background-music`:其余成员(含图片类)在这里就被拒绝, +/// 不会落一条注定失败的账本记录,也不改图片类入口的载荷口径。 +pub(crate) fn prepare_local_project_audio_generation( + operation_id: &str, + kind: &str, + prompt: &str, + asset_name: &str, + idempotency_key: &str, +) -> Result { + validate_resource_edit_uuid(operation_id, "operationId")?; + let edit_kind = match GameCreationAppAssetKind::parse_with_context(kind, "canvas.asset_kind") { + GameCreationAppAssetKind::SoundEffect => LocalProjectResourceEditKind::SoundEffect, + GameCreationAppAssetKind::BackgroundMusic => LocalProjectResourceEditKind::BackgroundMusic, + _ => return Err(format!("音频生成不支持该素材类型:{}", kind.trim())), + }; + validate_resource_edit_uuid(idempotency_key, "idempotencyKey")?; + let prompt = normalize_resource_edit_prompt(&edit_kind, prompt)?; + let asset_name = normalize_resource_edit_name(asset_name)?; + Ok(LocalProjectAudioGenerationRequest { + operation_id: operation_id.trim().to_string(), + edit_kind, + prompt, + asset_name, + idempotency_key: idempotency_key.trim().to_string(), + }) +} + +/// 后台跑一次音频无源生成,返回产物素材 id。 +/// +/// 生成本身仍走 `derive_local_project_resource_at` 这一条通道(幂等账本、平台请求、下载与 +/// manifest 登记全部复用),这里只做两件账本侧的事:把「提交时刻」无法确定的项目 revision +/// 在派发时刻读成当前值(提交之后用户仍可能编辑项目),以及把产物素材 id 交回任务账本。 +/// 拿不到当前 revision 或 CAS 冲突时按失败返回,不静默重试——静默重试会把这次生成写到用户 +/// 没预期的基线上。 +pub(crate) async fn run_local_project_audio_generation_at( + project_path: &str, + request: &LocalProjectAudioGenerationRequest, +) -> Result, String> { + let project_path = project_path.trim(); + let root = Path::new(project_path); + let manifest = read_existing_manifest_for_project(root)?; + let expected_project_revision = + read_game_creator_agent_runtime_project_revision(root)?.revision; + let result = derive_local_project_resource_at(DeriveLocalProjectResourceInput { + project_path: project_path.to_string(), + expected_project_id: manifest.project_id, + expected_project_revision, + operation_id: request.operation_id.clone(), + idempotency_key: request.idempotency_key.clone(), + edit_kind: request.edit_kind, + generation_mode: LocalProjectResourceGenerationMode::Create, + source_resource_id: format!("create:{}", request.operation_id), + source_asset_id: None, + source_path: None, + source_media_type: Some("audio/mpeg".to_string()), + source_subtype: None, + producer_task_id: None, + source_version_id: None, + prompt: request.prompt.clone(), + asset_name: request.asset_name.clone(), + background_mode: None, + screen_color: None, + }) + .await?; + Ok(result.asset.map(|asset| asset.id)) +} + pub(crate) async fn derive_local_project_resource_at( input: DeriveLocalProjectResourceInput, ) -> Result { 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 de592086d..100b7fa1e 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 @@ -87,7 +87,13 @@ export type ResourceCanvasGenerationPanelViewProps = { prompt: string; assetName: string; }) => void; - onSubmit: (input: ResourceCanvasGenerationSubmitInput) => Promise; + /** + * 提交这次输入:**同步入队**,不等 IPC、不等排队、不等生成结束。 + * + * 生成任务由宿主交给项目内账本(音频与图片类同一份),进度只出现在画布上的「生成任务」 + * 侧栏;面板提交后立即关闭,不持有在途状态。 + */ + onSubmit: (input: ResourceCanvasGenerationSubmitInput) => void; /** * 收起浮层。 * @@ -107,12 +113,6 @@ const RESOURCE_GENERATION_ALL_KIND_ITEMS = label: option.label, })); -function resourceGenerationErrorMessage(error: unknown) { - if (typeof error === 'string' && error.trim()) return error; - if (error instanceof Error && error.message) return error.message; - return '生成素材失败'; -} - /** * 资源画布「生成入口」的浮层面板。 * @@ -120,9 +120,13 @@ function resourceGenerationErrorMessage(error: unknown) { * 不在任何现有面板下面追加内容;提交链路与结果定位由宿主 `index.tsx` 负责, * 面板只持有草稿、类型选择与失败重试状态。 * - * 提交期间**不锁关闭**:× / 遮罩 / Esc / 「后台运行并关闭」四条路径都通。关闭只是把这一份 - * view 卸下来,宿主那条请求继续跑(它是宿主的 `await onSubmit(...)`,不挂在面板生命周期上), - * 所以关闭**不等于**取消;失败时面板仍保留草稿与同一份请求身份可重试。 + * **点「生成」即同步关闭面板**:不等 IPC、不等排队、不等生成结束,用户立刻回到画布;面板里 + * 因此不存在「排队中。」「正在生成。」「提交中…」「后台运行并关闭」这些阶段文案与按钮—— + * 阶段文案的唯一去处是画布上的「生成任务」侧栏。关闭**不等于**取消:任务照常在后台跑完并把 + * 结果写回项目。 + * + * 只有「点击瞬间就失败」(校验 / 权限拒绝 / 提交 IPC 立即报错,即后端从未受理)时,宿主才会 + * 把面板连原草稿与原请求身份带回来,用户可以直接改后重试。 */ export function ResourceCanvasGenerationPanelView({ kinds = RESOURCE_CANVAS_GENERATION_OPTIONS.map((option) => option.kind), @@ -154,11 +158,11 @@ export function ResourceCanvasGenerationPanelView({ const [assetName, setAssetName] = useState( initialDraft?.assetName ?? option.assetName, ); - const [attempted, setAttempted] = useState(false); - const [submitting, setSubmitting] = useState(false); - const [error, setError] = useState(null); - // 占位带来的失败原因优先展示;面板自己这次的失败(`error`)覆盖它。 - const shownError = error ?? initialError ?? null; + /* + 失败原因只有**占位**这一个来源:提交后面板已经关闭,面板实例不持有在途状态,也就不会 + 自己造一份 `error`。重开同一张占位时由宿主把账本里的原因灌回来。 + */ + const shownError = initialError ?? null; /** * 已绑定请求身份的那句提示词(只有带 `request` 重开的失败面板才有)。 * @@ -201,7 +205,6 @@ export function ResourceCanvasGenerationPanelView({ const requestRef = useRef( boundRequest ?? null, ); - const inputLocked = attempted || submitting; /** 收起浮层:把当前草稿交给宿主保存,用户再点开占位卡时接着编辑。 */ const closeWithDraft = () => { draftReleasedRef.current = true; @@ -220,43 +223,34 @@ export function ResourceCanvasGenerationPanelView({ assetName: assetName.trim() || option.assetName, }; - async function submit(event: FormEvent) { + function submit(event: FormEvent) { event.preventDefault(); const normalizedPrompt = prompt.trim(); const normalizedAssetName = assetName.trim(); if ( !normalizedPrompt || !normalizedAssetName || - submitting || // 按钮禁用只是表现:改动原请求提示词的提交在这里也必须被挡住,不能悄悄变成新付费生成。 boundRequestPromptChanged ) { return; } - setAttempted(true); - setSubmitting(true); - setError(null); requestRef.current = resolveResourceEditRequestIdentity( requestRef.current, normalizedPrompt, ); - try { - await onSubmit({ - kind, - operationId: requestRef.current.operationId, - idempotencyKey: requestRef.current.idempotencyKey, - prompt: normalizedPrompt, - assetName: normalizedAssetName, - }); - // 只有**成功**交出这次输入才不再写回草稿;失败时草稿要留给用户切走再切回来的重试。 - draftReleasedRef.current = true; - } catch (submitError) { - setError(resourceGenerationErrorMessage(submitError)); - } finally { - // 成功路径也要收回在飞标记:宿主随后会卸载面板,但组件本身不该在 `onSubmit` 正常 - // resolve 后永久停在「生成中…」;失败时收回标记才能让用户用同一份输入重试。 - setSubmitting(false); - } + // 这次输入被任务接走:卸载时不再往草稿槽里写一份内存副本(失败重开由宿主的提交上下文负责)。 + draftReleasedRef.current = true; + // 点击即关闭:不等 IPC、不等排队、不等生成结束。入参已经带上这次生成的请求身份, + // 「从未被后端受理」的即时失败由宿主连原草稿与原身份把面板带回来。 + onSubmit({ + kind, + operationId: requestRef.current.operationId, + idempotencyKey: requestRef.current.idempotencyKey, + prompt: normalizedPrompt, + assetName: normalizedAssetName, + }); + onClose(); } const panelBody = ( @@ -281,7 +275,6 @@ export function ResourceCanvasGenerationPanelView({ columns="three" gap="sm" size="compact" - disabled={inputLocked} onChange={(nextKind) => { setKind(nextKind); setAssetName(resourceCanvasGenerationOption(nextKind).assetName); @@ -294,7 +287,6 @@ export function ResourceCanvasGenerationPanelView({ setAssetName(event.currentTarget.value)} /> @@ -306,7 +298,6 @@ export function ResourceCanvasGenerationPanelView({ aria-label="生成提示词" rows={6} autoFocus - disabled={inputLocked} maxLength={resourceEditPromptMaxLength(option.editKind)} placeholder={option.promptPlaceholder} value={prompt} @@ -317,7 +308,6 @@ export function ResourceCanvasGenerationPanelView({ subject={`素材生成提示词(${option.label})`} editKind={option.editKind} prompt={prompt} - disabled={inputLocked} applyPrompt={setPrompt} /> {boundRequestPromptChanged ? ( @@ -339,12 +329,12 @@ export function ResourceCanvasGenerationPanelView({ tone="secondary" onClick={closeWithDraft} > - {submitting ? '后台运行并关闭' : '取消'} + 取消 {shownError ? (