fix(agc): 生成任务读门禁用合法 command id、中断任务按清单核对不谎报未完成,并同步注释事实

- asset_generation_tasks.rs:`list_local_project_asset_generations` 的读门禁 command id 由 `asset.read`
  改为契约里真实存在的 `asset.list`(`asset.read` 不在命令表里,写进 `denied_commands` 也不可能命中
  → 原门禁恒不生效);补用例把 `asset.list` 放进项目策略 `denied_commands` 后读账本必须被拒
- asset_generation_tasks.rs:中断任务收口改为与 manifest 交叉核对(新增账本字段 `output_path`,serde 默认值
  兼容旧账本):能找到本次请求的精确落点 → 按事实收口为已完成并带上 assetId;落点存在但清单里没有
  → 报「目标素材未登记,生成未完成」;没有精确落点或清单读不到 → 只报「状态未知(可能已完成)」,不再
  对「素材已登记、账本还停在 running」的窗口谎报「生成未完成」
- asset_generation_tasks.rs:补四条用例(已登记→已完成、未登记→未完成、无落点→状态未知、读门禁可拒绝),
  并把既有的两条中断用例按新语义改为断言「状态未知」
- resourceCanvasBottomToolbarModel.ts:两处注释的入口命令名改为 `start_local_project_asset_generation`
- check-config.mjs:`generate_local_project_asset` 白名单注释改为「没有生产调用方,只有 Rust 集成测试与
  commands.rs 单测在调,待后续批次删除或改为转调 start_local_project_asset_generation」
- resourceCanvasAssetGenerationTaskModel.ts / resourceCanvasAssetGenerationQueue.ts:把「远端 single-flight
  输出槽会拒绝并发」这个已不成立的排队理由改为当前事实——本地 durable 输出槽已按精确动作指纹分槽,
  本批仍按「同一时刻只派发一条」排队是**提交节流**,真并行派发需要并发收口设计,留待下一批
This commit is contained in:
2026-09-14 18:13:12 +08:00
parent 4fff733516
commit cdc0e3d163
5 changed files with 269 additions and 31 deletions
@@ -112,7 +112,9 @@ const allowedUncalledTauriCommands = [
'check_ui_editor_font_glyph_coverage',
'create_ui_design_resource',
// 图片类生成的同步变体:GUI 已改为 `start_local_project_asset_generation` + 项目内任务账本
// (提交即返回、后台生成)这条命令保留给 Rust 侧与既有单测,不再有前端调用方。
// (提交即返回、后台生成)这条命令**没有生产调用方**,只有 Rust 集成测试
// `src/tests/project.rs`)与 `commands.rs` 单测在调;待后续批次删除,或改为转调
// `start_local_project_asset_generation`。
'generate_local_project_asset',
'open_game_creator_launcher_window',
'open_game_creator_workspace_window',
@@ -14,10 +14,12 @@
//!
//! 账本落在**项目内** `.agent/runtime/asset-generation-tasks/tasks.json`(复用既有 agent runtime
//! sidecar 读写原语:临时文件 + rename 替换),所以重开项目后仍能看到历史任务。进程重启时
//! 还在 `queued` / `running` 的记录不可能再有人推进,读账本时按「上次运行中断」收口为失败,
//! 不假装它还在跑。
//! 还在 `queued` / `running` 的记录不可能再有人推进,读账本时按「上次运行中断」收口;收口结论
//! **要跟 manifest 交叉核对**:能在清单里找到这次请求的目标素材就按已完成收口(生成通道是先写
//! manifest 再返回的,所以「素材已登记、账本还停在 running」的窗口里崩溃是真会发生的),核不了
//! 就不把话说死,不假装它还在跑、也不谎报「生成未完成」。
use std::collections::BTreeSet;
use std::collections::{BTreeMap, BTreeSet};
use std::path::{Path, PathBuf};
use std::sync::{Mutex, OnceLock};
use std::time::{SystemTime, UNIX_EPOCH};
@@ -27,7 +29,7 @@ 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;
use crate::project::{enforce_project_permission_policy, read_existing_manifest_for_project};
pub(crate) const ASSET_GENERATION_TASK_SCHEMA_VERSION: &str = "agc-asset-generation-task.v1";
pub(crate) const ASSET_GENERATION_TASK_LEDGER_RELATIVE_PATH: &str =
@@ -45,8 +47,21 @@ 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 = "应用退出时生成任务仍在进行";
/// 中断收口:清单里已经能查到这次请求的目标素材 → 按事实收口为已完成。
const ASSET_GENERATION_TASK_PHASE_INTERRUPTED_SETTLED: &str =
"上次运行中断,但目标素材已登记(已完成)。";
/// 中断收口:请求指定了精确落点,而清单里没有该素材 → 这次写入确实没落地。
const ASSET_GENERATION_TASK_PHASE_INTERRUPTED_INCOMPLETE: &str =
"上次运行中断,目标素材未登记,生成未完成。";
/// 中断收口:没有可核对的目标标识(或清单读不到)→ 不下结论。
const ASSET_GENERATION_TASK_PHASE_INTERRUPTED_UNKNOWN: &str =
"上次运行中断,状态未知(可能已完成)。";
const ASSET_GENERATION_TASK_INTERRUPTED_SETTLED_ERROR: &str =
"应用退出时生成任务仍在进行,已按清单确认目标素材登记";
const ASSET_GENERATION_TASK_INTERRUPTED_INCOMPLETE_ERROR: &str =
"应用退出时生成任务仍在进行,目标素材未登记";
const ASSET_GENERATION_TASK_INTERRUPTED_UNKNOWN_ERROR: &str =
"应用退出时生成任务仍在进行,未能在清单里确认结果";
/// 一条生成任务的权威记录。字段名与前端一一对应(camelCase)。
#[derive(Clone, Debug, Eq, PartialEq, serde::Serialize, serde::Deserialize)]
@@ -59,6 +74,13 @@ pub(crate) struct AssetGenerationTaskRecord {
pub(crate) status: String,
/// 阶段文案:**由后端拥有**,前端只渲染。
pub(crate) phase_detail: String,
/// 这次请求指定的精确落点(`outputPath`)。
///
/// 它是中断收口时**唯一可核对的目标标识**:有它就能拿 manifest 的 `localPath` 做精确匹配,
/// 判出「素材已登记 → 已完成」还是「落点没有素材 → 未落地」。没有它(绝大多数入口不指定
/// 落点)就只能报「状态未知」。旧账本没有这个字段,按 `None` 读。
#[serde(default)]
pub(crate) output_path: Option<String>,
pub(crate) created_at_millis: u64,
pub(crate) started_at_millis: Option<u64>,
pub(crate) finished_at_millis: Option<u64>,
@@ -145,11 +167,45 @@ fn trim_ledger(tasks: &mut Vec<AssetGenerationTaskRecord>) {
tasks.drain(0..overflow);
}
/// 进程重启后把没人推进的记录收口为失败,并把这次修复写回账本
/// manifest 的 `localPath` → 资产 id
///
/// 只用来做**精确落点**的交叉核对,所以按同一个口径归一化两侧的路径串(去空白、反斜杠折成
/// 正斜杠);不做模糊匹配、不按素材名猜,避免把另一次生成的产物算到这条任务上。
fn registered_asset_ids_by_local_path(root: &Path) -> Option<BTreeMap<String, String>> {
let manifest = read_existing_manifest_for_project(root).ok()?;
Some(
manifest
.assets
.iter()
.map(|asset| (normalize_local_path(&asset.local_path), asset.id.clone()))
.collect(),
)
}
fn normalize_local_path(path: &str) -> String {
path.trim().replace('\\', "/")
}
/// 中断收口用到的 manifest 交叉核对口径。
///
/// `None` = 这次读不到清单(项目还没初始化 / 读盘失败),此时任何记录都不能下结论。
type RegisteredAssetIdsByLocalPath = Option<BTreeMap<String, String>>;
/// 进程重启后把没人推进的记录收口,并把这次修复写回账本。
///
/// 判据是「本进程的 live 集合里没有它」:本进程派发的任务在 `start` 里先登记 live 再落账本,
/// 所以账本里非终态且不 live 的记录只可能来自上一次运行。
fn repair_interrupted_tasks(tasks: &mut [AssetGenerationTaskRecord]) -> bool {
///
/// **收口结论要跟清单核对**:生成通道是先写 manifest 再返回的,所以「素材已经登记、账本还停在
/// `running`」的窗口里崩溃是真会发生的;只看 live 集合会把这种任务谎报成「生成未完成」。三种结论:
///
/// - 请求指定了精确落点、且清单里已有该落点 → 按事实收口为**已完成**(带上 assetId);
/// - 请求指定了精确落点、清单里没有 → 这次写入确实没落地,收口为失败并说明「目标素材未登记」;
/// - 没有精确落点(或清单读不到)→ 收口为失败但**不下结论**,文案是「状态未知(可能已完成)」。
fn repair_interrupted_tasks(
tasks: &mut [AssetGenerationTaskRecord],
registered: &RegisteredAssetIdsByLocalPath,
) -> bool {
let live = live_task_ids()
.lock()
.map(|ids| ids.clone())
@@ -159,22 +215,44 @@ fn repair_interrupted_tasks(tasks: &mut [AssetGenerationTaskRecord]) -> bool {
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());
let registered_asset = task
.output_path
.as_deref()
.map(normalize_local_path)
.filter(|path| !path.is_empty())
.and_then(|path| registered.as_ref().map(|assets| assets.get(&path).cloned()));
match registered_asset {
Some(Some(asset_id)) => {
task.status = ASSET_GENERATION_TASK_STATUS_COMPLETED.to_string();
task.phase_detail = ASSET_GENERATION_TASK_PHASE_INTERRUPTED_SETTLED.to_string();
task.asset_id = Some(asset_id);
task.error = Some(ASSET_GENERATION_TASK_INTERRUPTED_SETTLED_ERROR.to_string());
}
Some(None) => {
task.status = ASSET_GENERATION_TASK_STATUS_FAILED.to_string();
task.phase_detail = ASSET_GENERATION_TASK_PHASE_INTERRUPTED_INCOMPLETE.to_string();
task.error = Some(ASSET_GENERATION_TASK_INTERRUPTED_INCOMPLETE_ERROR.to_string());
}
None => {
task.status = ASSET_GENERATION_TASK_STATUS_FAILED.to_string();
task.phase_detail = ASSET_GENERATION_TASK_PHASE_INTERRUPTED_UNKNOWN.to_string();
task.error = Some(ASSET_GENERATION_TASK_INTERRUPTED_UNKNOWN_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<Vec<AssetGenerationTaskRecord>, String> {
let _guard = lock_ledger()?;
let mut tasks = read_ledger(root)?;
if repair_interrupted_tasks(&mut tasks) {
let registered = registered_asset_ids_by_local_path(root);
if repair_interrupted_tasks(&mut tasks, &registered) {
write_ledger(root, &tasks)?;
}
Ok(tasks)
@@ -221,6 +299,7 @@ pub(crate) fn begin_local_project_asset_generation_task(
task_id: &str,
task_kind: &str,
asset_name: &str,
output_path: Option<&str>,
) -> Result<AssetGenerationTaskRecord, String> {
let record = AssetGenerationTaskRecord {
task_id: task_id.to_string(),
@@ -229,6 +308,7 @@ pub(crate) fn begin_local_project_asset_generation_task(
asset_name: asset_name.to_string(),
status: ASSET_GENERATION_TASK_STATUS_QUEUED.to_string(),
phase_detail: ASSET_GENERATION_TASK_PHASE_QUEUED.to_string(),
output_path: output_path.map(str::to_string),
created_at_millis: now_millis(),
started_at_millis: None,
finished_at_millis: None,
@@ -331,6 +411,7 @@ pub(crate) async fn start_local_project_asset_generation(
&task_id,
&asset_kind,
&asset_label,
request.options.output_path.as_deref(),
)?;
// 先登记 live 再派发:`list` 只把「非终态且不 live」的记录判为上次运行的残留。
if let Ok(mut ids) = live_task_ids().lock() {
@@ -356,13 +437,22 @@ pub(crate) fn list_local_project_asset_generations(
return Err("项目路径不能为空".to_string());
}
let root = Path::new(project_path);
enforce_project_permission_policy(root, "asset.read")?;
// 与相邻的 manifest 读命令同口径:合法的读 command id 是 `asset.list``asset.read` 不在
// 契约的 command 列表里,写进 `denied_commands` 也不可能命中 → 门禁恒不生效)。
enforce_project_permission_policy(root, "asset.list")?;
list_local_project_asset_generation_tasks(root)
}
#[cfg(test)]
mod asset_generation_task_tests {
use super::*;
use crate::assets::register_local_asset_at;
use crate::project::{
init_local_game_project_at, read_manifest_for_project, write_project_permission_policy_at,
};
use shared_contracts::game_creation_app::{
GameCreationAppAssetSource, GameCreationAppAssetSourceKind,
};
fn temp_project_root(label: &str) -> PathBuf {
let nonce = SystemTime::now()
@@ -377,9 +467,76 @@ mod asset_generation_task_tests {
root
}
/// 已初始化的项目:中断收口要读 manifest 做交叉核对,所以这几条用例必须有真清单。
fn initialized_project_root(label: &str) -> PathBuf {
let root = temp_project_root(label);
init_local_game_project_at(&root, "project-1", "生成任务账本测试").expect("init project");
root
}
/// 登记一个素材到 manifest 并返回它的资产 id(中断收口按 `localPath` 精确匹配)。
fn register_fixture_asset(root: &Path, local_path: &str) -> String {
let absolute_path = root.join(local_path);
if let Some(parent) = absolute_path.parent() {
std::fs::create_dir_all(parent).expect("create fixture asset parent");
}
std::fs::write(&absolute_path, b"png-bytes").expect("write fixture asset");
register_local_asset_at(
root,
local_path,
"icon-spec",
"image/png",
"generated",
GameCreationAppAssetSource {
kind: GameCreationAppAssetSourceKind::Generated,
canvas_project_id: None,
resource_id: None,
asset_object_id: None,
task_id: Some("fixture-generation".to_string()),
prompt: None,
model: None,
generation_route: None,
generation_kind: None,
reference_resource_ids: Vec::new(),
},
)
.expect("register fixture asset");
read_manifest_for_project(root)
.expect("read manifest after registration")
.assets
.iter()
.find(|asset| asset.local_path == local_path)
.expect("registered asset is present")
.id
.clone()
}
fn begin(root: &Path, task_id: &str) -> AssetGenerationTaskRecord {
begin_local_project_asset_generation_task(root, "project-1", task_id, "image", "AI 图")
.expect("begin task")
begin_local_project_asset_generation_task(
root,
"project-1",
task_id,
"image",
"AI 图",
None,
)
.expect("begin task")
}
fn begin_with_output(
root: &Path,
task_id: &str,
output_path: &str,
) -> AssetGenerationTaskRecord {
begin_local_project_asset_generation_task(
root,
"project-1",
task_id,
"icon-spec",
"图标规范",
Some(output_path),
)
.expect("begin task with output path")
}
#[test]
@@ -412,19 +569,20 @@ mod asset_generation_task_tests {
}
#[test]
fn restarting_the_process_settles_unfinished_tasks_as_interrupted() {
let root = temp_project_root("interrupted");
fn restarting_the_process_reports_unknown_state_when_the_result_cannot_be_checked() {
// 项目没初始化 → 读不到 manifest → 没有可核对的目标标识:不许断言「生成未完成」。
let root = temp_project_root("interrupted-unknown");
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
ASSET_GENERATION_TASK_PHASE_INTERRUPTED_UNKNOWN
);
assert_eq!(
listed[0].error.as_deref(),
Some(ASSET_GENERATION_TASK_INTERRUPTED_ERROR)
Some(ASSET_GENERATION_TASK_INTERRUPTED_UNKNOWN_ERROR)
);
assert!(listed[0].finished_at_millis.is_some());
// 修复要写回账本,第二次读到的仍是同一条终态记录。
@@ -433,6 +591,76 @@ mod asset_generation_task_tests {
std::fs::remove_dir_all(&root).ok();
}
#[test]
fn interrupted_task_whose_output_is_already_registered_settles_as_completed() {
// 生成通道先写 manifest 再返回,所以「素材已登记、账本还停在 running」的窗口里崩溃是
// 真会发生的:这种任务必须按事实收口为已完成,而不是谎报「生成未完成」。
let root = initialized_project_root("interrupted-settled");
let asset_id = register_fixture_asset(&root, "assets/art-spec.png");
begin_with_output(&root, "task-settled", "assets/art-spec.png");
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].phase_detail,
ASSET_GENERATION_TASK_PHASE_INTERRUPTED_SETTLED
);
assert_eq!(listed[0].asset_id.as_deref(), Some(asset_id.as_str()));
assert!(listed[0].finished_at_millis.is_some());
std::fs::remove_dir_all(&root).ok();
}
#[test]
fn interrupted_task_whose_output_was_never_registered_is_reported_as_incomplete() {
let root = initialized_project_root("interrupted-incomplete");
begin_with_output(&root, "task-incomplete", "assets/never-written.png");
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_INCOMPLETE
);
assert_eq!(
listed[0].error.as_deref(),
Some(ASSET_GENERATION_TASK_INTERRUPTED_INCOMPLETE_ERROR)
);
assert!(listed[0].asset_id.is_none());
std::fs::remove_dir_all(&root).ok();
}
#[test]
fn interrupted_task_without_an_output_slot_never_claims_the_generation_failed() {
// 自动落点(绝大多数入口):清单里无法精确定位这次请求的产物 → 只报状态未知。
let root = initialized_project_root("interrupted-no-output");
register_fixture_asset(&root, "assets/art-spec.png");
begin(&root, "task-no-output");
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_UNKNOWN
);
assert!(listed[0].asset_id.is_none());
std::fs::remove_dir_all(&root).ok();
}
#[test]
fn permission_policy_can_deny_the_generation_task_ledger_read() {
// 读门禁必须用契约里真实存在的 command id`asset.list`)。写成 `asset.read` 这种不在
// 命令表里的名字时,`denied_commands` 永远不可能命中 → 门禁恒不生效。
let root = initialized_project_root("denied-read");
let mut policy = crate::ProjectPermissionPolicy::default();
policy.denied_commands.push("asset.list".to_string());
write_project_permission_policy_at(&root, policy).expect("write permission policy");
let error = list_local_project_asset_generations(root.to_string_lossy().into_owned())
.expect_err("denied ledger read must fail closed");
assert_eq!(error, "项目权限策略拒绝执行:asset.list");
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");
@@ -446,6 +674,10 @@ mod asset_generation_task_tests {
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);
assert_eq!(
settled[0].phase_detail,
ASSET_GENERATION_TASK_PHASE_INTERRUPTED_UNKNOWN
);
std::fs::remove_dir_all(&root).ok();
}
@@ -477,6 +709,7 @@ mod asset_generation_task_tests {
"task-dup",
"image",
"AI 图",
None,
)
.expect_err("duplicate in-flight task");
assert_eq!(error, "生成任务 id 已在进行中:task-dup");
@@ -66,8 +66,10 @@ export type ResourceCanvasAssetGenerationQueue = {
* 入队并推进。
*
* resolve = 该任务已终态(成功);reject = 失败(含后端拒绝入参)。面板据此保留草稿可重试。
* 排在别的在途任务后面的提交会先在本地队列里等,**不发 IPC**(远端图片类生成共用
* single-flight 输出槽,并发提交会在远端 POST 之前被拒)。
* 排在别的在途任务后面的提交会先在本地队列里等,**不发 IPC**——这是本批的**提交节流**
* AGC 本地 durable 输出槽已按精确动作指纹分槽,不同 prompt / 素材名可以同时在途,所以排队
* 不再是「远端会拒绝并发」的被迫行为;真并行派发需要并发收口设计(配对读 + manifest CAS +
* 聚焦意图互不覆盖),留待下一批。
*/
submit(task: ResourceCanvasAssetGenerationTask): Promise<void>;
/** 推进已有队列(宿主挂载 / 重开项目恢复任务时调一次)。 */
@@ -92,8 +94,8 @@ type SettlementWaiter = {
/**
* 本地排队 + 后端任务账本的驱动器。
*
* 顺序上只有一条规则:**下一条必须等上一条终态**;上一条结束(成功或失败)后由同一个循环
* 自动补发。派发之后不再由前端猜进度:状态与阶段文案一律来自
* 顺序上只有一条规则:**下一条必须等上一条终态**(本批的提交节流,见 `submit`);上一条结束
* (成功或失败)后由同一个循环自动补发。派发之后不再由前端猜进度:状态与阶段文案一律来自
* `list_local_project_asset_generations` 返回的后端记录。
*/
export function createResourceCanvasAssetGenerationQueue(
@@ -253,13 +253,14 @@ export function mergeLocalProjectAssetGenerationRecord(
}
/**
* 本地排队的派发判据:**同一时刻只允许一条任务在途**。
* 本地排队的派发判据:**同一时刻只派发一条任务**(本批的提交节流)
*
* 远端图片类生成共用 single-flight 输出槽,第二条并发请求会在远端 POST 之前被拒;所以第二
* 必须停在本地队列里(`dispatched === false`),等前一条终态后再补发。
* AGC 本地 durable 输出槽已按**精确动作指纹**分槽,不同 prompt / 素材名可以同时在途,所以这
* 判据**不再是「远端会拒绝并发」的被迫排队**;本批前端仍按「同一时刻只派发一条」排队,作为提交
* 节流。真并行派发需要并发收口设计(配对读 + manifest CAS + 聚焦意图互不覆盖),留待下一批。
*
* 「在途」的判据是 `dispatched && 未终态`,而不是 `status === 'running'`:已派发但后端记录还
* 没读回来的那一段(状态仍是 `queued`)同样占着输出槽,漏掉这一档就会并发发出第二条。
* 没读回来的那一段(状态仍是 `queued`)同样算在途,漏掉这一档就会提前放出第二条。
*/
export function nextResourceCanvasAssetGenerationDispatch(
tasks: readonly ResourceCanvasAssetGenerationTask[],
@@ -21,7 +21,7 @@ import type { ResourceCanvasGenerationKind } from './resourceCanvasGenerationMod
*/
/**
* `generate_local_project_asset` 放行的无源生成类型。
* `start_local_project_asset_generation` 放行的无源生成类型。
*
* 与 Rust `PLATFORM_ART_ASSET_GENERATION_KINDS` 一一对应(`publication-material` 属宣发
* 素材,本轮不做,因此不在这里)。`spec` / `icon-spec` 都会在 Rust 侧收口到 `icon-spec`
@@ -72,7 +72,7 @@ type ResourceCanvasBottomToolActionBase = {
imageSize: string;
};
/** 图片类生成入口:走 `generate_local_project_asset`。 */
/** 图片类生成入口:走 `start_local_project_asset_generation`(提交即返回、后台生成)。 */
export type ResourceCanvasAssetToolAction =
ResourceCanvasBottomToolActionBase & {
route: 'asset';