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..0fd3f96d6 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)] @@ -336,6 +342,21 @@ fn remove_live_task_id(task_id: &str) { } } +/// 登记一条「本进程正在推的任务」,返回 true 表示这次确实插入了新的 id。 +/// +/// 顺序是硬约束:**先登记 live 再落账本**。`list` 只把「非终态且不 live」的记录判为上次运行的 +/// 残留,反过来先落账本就会留出一个窗口——并发 `list` 会在窗口里把刚排队的任务收口成失败, +/// 前端随即看到一条本不存在的失败记录。 +/// +/// 返回值专给「落账失败要回滚」用:只有真插入过的一方才有资格回滚,否则会把**同 id 那个正在 +/// 运行的任务**的 live 登记一起删掉(随后 `list` 就会把它谎报成上次运行的中断残留)。 +fn register_live_task_id(task_id: &str) -> bool { + live_task_ids() + .lock() + .map(|mut ids| ids.insert(task_id.to_string())) + .unwrap_or(false) +} + /// 后台执行:状态与阶段文案的每一次流转都由这里写账本。 async fn run_local_project_asset_generation_task( root: PathBuf, @@ -376,10 +397,124 @@ 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")?; + // 显式列全两个音频成员:这里**不做** `_ =>` 兜底——账本的 `kind` 是前端的唯一条目身份, + // 新增音频变体(或上游白名单被放宽)时必须在这里大声失败,而不是把它静默记成音效。 + let asset_kind = match request.edit_kind { + LocalProjectResourceEditKind::BackgroundMusic => GameCreationAppAssetKind::BackgroundMusic, + LocalProjectResourceEditKind::SoundEffect => GameCreationAppAssetKind::SoundEffect, + other => return Err(format!("音频生成不支持该素材类型:{other:?}")), + }; + 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 +532,47 @@ 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()); + } + // 先登记 live 再落账本:窗口期里并发 `list` 不许把这条排队记录判成上次运行的残留。 + let live_registered = register_live_task_id(&task_id); + let (record, request) = match begin_local_project_audio_generation_task( + &project_path, + &project_id, + &task_id, + &kind, + &prompt, + asset_name.as_deref().unwrap_or_default(), + &idempotency_key, + ) { + Ok(pair) => pair, + Err(error) => { + // 校验不过 / 同 id 已在跑 / 账本写不进去:这一轮什么都没派发,撤掉自己的登记。 + if live_registered { + remove_live_task_id(&task_id); + } + return Err(error); + } + }; + 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, @@ -414,18 +588,24 @@ pub(crate) async fn start_local_project_asset_generation( 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( + // 与音频分支同一条顺序约束:先登记 live 再落账本,中间不留「排队但还不 live」的窗口。 + let live_registered = register_live_task_id(&task_id); + let record = match begin_local_project_asset_generation_task( &request.root, &project_id, &task_id, asset_kind, &asset_label, request.options.output_path.as_deref(), - )?; - // 先登记 live 再派发:`list` 只把「非终态且不 live」的记录判为上次运行的残留。 - if let Ok(mut ids) = live_task_ids().lock() { - ids.insert(task_id.clone()); - } + ) { + Ok(record) => record, + Err(error) => { + if live_registered { + remove_live_task_id(&task_id); + } + return Err(error); + } + }; let root = request.root.clone(); tauri::async_runtime::spawn(run_local_project_asset_generation_task( root, @@ -690,6 +870,49 @@ mod asset_generation_task_tests { std::fs::remove_dir_all(&root).ok(); } + /// 落账失败要回滚的是「**本轮**插入的那条登记」,不是「这个 id」。 + /// + /// 同 id 已经在跑时,`start` 的第二轮不会插入新登记;这时如果按 id 回滚,就会把正在跑的 + /// 那条任务的 live 登记一起删掉,`list` 随后把它谎报成上次运行的中断残留。 + #[test] + fn a_failed_ledger_write_only_takes_back_the_live_registration_it_inserted() { + let root = temp_project_root("live-rollback"); + // 第一次提交:先登记 live,再落账(真实链路里紧接着 spawn)。 + assert!( + register_live_task_id("task-in-flight"), + "首次登记必须报告为「本轮插入」" + ); + begin(&root, "task-in-flight"); + + // 第二次提交(同 id):这一轮没有插入新登记,落账也会因「已在进行中」被拒。 + let live_registered = register_live_task_id("task-in-flight"); + assert!( + !live_registered, + "同 id 已在 live 集合里时,本轮不得报告为「本轮插入」" + ); + let error = begin_local_project_asset_generation_task( + &root, + "project-1", + "task-in-flight", + GameCreationAppAssetKind::Image, + "AI 图", + None, + ) + .expect_err("duplicate in-flight task"); + assert_eq!(error, "生成任务 id 已在进行中:task-in-flight"); + if live_registered { + remove_live_task_id("task-in-flight"); + } + + // 正在跑的任务仍是 live:`list` 不得把它收口成失败。 + let listed = list_local_project_asset_generation_tasks(&root).expect("list"); + assert_eq!(listed[0].status, ASSET_GENERATION_TASK_STATUS_QUEUED); + assert_eq!(listed[0].phase_detail, ASSET_GENERATION_TASK_PHASE_QUEUED); + + remove_live_task_id("task-in-flight"); + 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"); @@ -761,4 +984,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..89a57806f 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,94 @@ 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 冲突时按失败返回,不静默重试——静默重试会把这次生成写到用户 +/// 没预期的基线上。 +/// +/// `generation_mode: Create` 下源快照的媒体类型由 `edit_kind` 推出(音频恒为 `audio/mpeg`), +/// 所以这里固定传 `None`:这条通道根本不读 `input.source_media_type`,填一个值只会让读者 +/// 以为它对生成有影响。 +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: None, + 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/ResourceCanvasAssetGenerationPanelView.tsx b/apps/ai-game-creator-shell/src/features/resource-canvas/ResourceCanvasAssetGenerationPanelView.tsx index 520299e8b..50b687831 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 @@ -368,6 +368,12 @@ export function ResourceCanvasAssetGenerationPanelView({ return; } setError(null); + /* + TODO(resource-generation-panel-contract): 与 `ResourceCanvasGenerationPanelView` 是同一份契约 + 问题——`onSubmit` 只返回 `void`,宿主同步拒绝时(今天只剩「队列未就绪」这条防御性分支)这里 + 已经置位 `released` 并关掉面板,草稿没人接走。修法同那一侧的 TODO:`onSubmit` 回报受理结果, + 只有受理成功才置位并 `onClose()`。 + */ // 这次输入被任务接走:卸载时不再写回草稿槽(失败重开由宿主的提交上下文负责)。 draftReleasedRef.current = true; // 点击即关闭:不等 IPC、不等排队、不等生成结束。失败要不要把面板带回来由宿主决定 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..593f3cdff 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,41 @@ 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); - } + /* + TODO(resource-generation-panel-contract): `onSubmit` 现在只返回 `void`,所以这里只能「先置位 + `released`、再无脑关闭」;宿主那几条同步拒绝的分支(占位已不在画布 / 已在跑别的 operation) + 只给一条提示条,这次输入没人接走、草稿也跟着丢了。修法:让 `onSubmit` 回报受理结果 + (`accepted: boolean`),只有受理成功才置位并 `onClose()`。同一份契约落在 + `ResourceCanvasAssetGenerationPanelView` 与两个宿主调用点上——要改就一起改。 + */ + // 这次输入被任务接走:卸载时不再往草稿槽里写一份内存副本(失败重开由宿主的提交上下文负责)。 + draftReleasedRef.current = true; + // 点击即关闭:不等 IPC、不等排队、不等生成结束。入参已经带上这次生成的请求身份, + // 「从未被后端受理」的即时失败由宿主连原草稿与原身份把面板带回来。 + onSubmit({ + kind, + operationId: requestRef.current.operationId, + idempotencyKey: requestRef.current.idempotencyKey, + prompt: normalizedPrompt, + assetName: normalizedAssetName, + }); + onClose(); } const panelBody = ( @@ -281,7 +282,6 @@ export function ResourceCanvasGenerationPanelView({ columns="three" gap="sm" size="compact" - disabled={inputLocked} onChange={(nextKind) => { setKind(nextKind); setAssetName(resourceCanvasGenerationOption(nextKind).assetName); @@ -294,7 +294,6 @@ export function ResourceCanvasGenerationPanelView({ setAssetName(event.currentTarget.value)} /> @@ -306,7 +305,6 @@ export function ResourceCanvasGenerationPanelView({ aria-label="生成提示词" rows={6} autoFocus - disabled={inputLocked} maxLength={resourceEditPromptMaxLength(option.editKind)} placeholder={option.promptPlaceholder} value={prompt} @@ -317,7 +315,6 @@ export function ResourceCanvasGenerationPanelView({ subject={`素材生成提示词(${option.label})`} editKind={option.editKind} prompt={prompt} - disabled={inputLocked} applyPrompt={setPrompt} /> {boundRequestPromptChanged ? ( @@ -339,12 +336,12 @@ export function ResourceCanvasGenerationPanelView({ tone="secondary" onClick={closeWithDraft} > - {submitting ? '后台运行并关闭' : '取消'} + 取消 {shownError ? (