feat(agc): 图片类生成后台化,生成面板可随时关闭并新增「生成任务」进度面板

- 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 记录本次决策
This commit is contained in:
2026-09-14 17:26:04 +08:00
parent 11beb65c36
commit bb7cbae118
16 changed files with 2582 additions and 122 deletions
@@ -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',
File diff suppressed because it is too large Load Diff
@@ -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,
@@ -24,6 +24,12 @@ export type ResourceCanvasAssetGenerationSubmitInput = {
export type ResourceCanvasAssetGenerationPanelViewProps = {
action: ResourceCanvasAssetToolAction;
/**
* 在途阶段文案,**由后端任务账本提供**(`phaseDetail`)。
*
* 面板不自己编进度:派发前是本地排队的「排队中。」,派发后是后端的「正在生成。」之类。
*/
phaseDetail?: string | null;
onSubmit: (input: ResourceCanvasAssetGenerationSubmitInput) => Promise<void>;
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({
<ThemedModal
open
ariaLabel={action.label}
closeOnBackdrop={!submitting}
closeOnEscape={!submitting}
onClose={() => {
if (!submitting) {
onClose();
}
}}
onClose={onClose}
panelClassName="game-approval-dialog game-resource-generation-dialog"
>
<header>
@@ -108,7 +113,6 @@ export function ResourceCanvasAssetGenerationPanelView({
<button
type="button"
aria-label={`关闭${action.label}`}
disabled={submitting}
onClick={onClose}
>
<X size={16} aria-hidden="true" />
@@ -194,14 +198,13 @@ export function ResourceCanvasAssetGenerationPanelView({
<PlatformActionButton
type="button"
tone="secondary"
disabled={submitting}
onClick={onClose}
>
取消
{submitting ? '后台运行并关闭' : '取消'}
</PlatformActionButton>
<PlatformActionButton type="submit" disabled={!canSubmit}>
<Sparkles size={15} aria-hidden="true" />
{submitting ? '生成中…' : action.label}
{submitting ? (phaseDetail ?? '提交中…') : action.label}
</PlatformActionButton>
</div>
</form>
@@ -0,0 +1,132 @@
import { X } from 'lucide-react';
import { useEffect, useMemo, useState } from 'react';
import {
RESOURCE_CANVAS_ASSET_GENERATION_STATUS_LABELS,
type ResourceCanvasAssetGenerationTask,
resourceCanvasAssetGenerationElapsedLabel,
resourceCanvasAssetGenerationTaskElapsedMillis,
resourceCanvasAssetGenerationTaskIsTerminal,
sortResourceCanvasAssetGenerationTasks,
} from './resourceCanvasAssetGenerationTaskModel';
export type ResourceCanvasAssetGenerationTasksPanelViewProps = {
tasks: readonly ResourceCanvasAssetGenerationTask[];
onClose: () => void;
/** 定位到该任务产出的素材卡(宿主复用既有 `pendingResourceFocusRef` 聚焦链)。 */
onFocusTask: (task: ResourceCanvasAssetGenerationTask) => void;
};
/**
* 「生成任务」浮层面板:列出每条图片类生成任务的状态、后端阶段文案、已耗时与素材名。
*
* **它是非模态的**:不铺全屏遮罩、不做焦点陷阱、不进 `isResourceCanvasFloatingPanelOpen` 的
* 遮挡判据——生成在后台跑,面板开着的时候画布必须照样能看能用。这也是它和两块生成浮层
* (提交表单,`ThemedModal` 模态)的关键差别。
*
* 阶段文案直接渲染后端账本给的 `phaseDetail`,面板不自己拼阶段、不做百分比进度。
*/
export function ResourceCanvasAssetGenerationTasksPanelView({
tasks,
onClose,
onFocusTask,
}: ResourceCanvasAssetGenerationTasksPanelViewProps) {
const [nowMillis, setNowMillis] = useState(() => Date.now());
const hasLiveTask = tasks.some(
(task) => !resourceCanvasAssetGenerationTaskIsTerminal(task),
);
const ordered = useMemo(
() => sortResourceCanvasAssetGenerationTasks(tasks),
[tasks],
);
// 已耗时是前端计时(后端只给时间戳):只在还有未终态任务时走秒表,全部收口后停掉。
useEffect(() => {
if (!hasLiveTask) {
return undefined;
}
const timer = setInterval(() => setNowMillis(Date.now()), 1_000);
return () => clearInterval(timer);
}, [hasLiveTask]);
return (
<section
className="platform-theme platform-theme--light fixed inset-x-3 bottom-20 z-40 max-h-[60vh] overflow-auto rounded-2xl border border-black/10 p-3 shadow-lg sm:inset-x-auto sm:right-4 sm:w-96"
style={{
background: 'var(--platform-subpanel-fill)',
color: 'var(--platform-text-strong)',
}}
role="region"
aria-label="生成任务"
>
<header className="flex items-center justify-between gap-2">
<h2 className="text-sm font-black">生成任务</h2>
<button
type="button"
className="grid h-7 w-7 place-items-center rounded-full"
aria-label="关闭生成任务"
onClick={onClose}
>
<X size={16} aria-hidden="true" />
</button>
</header>
{ordered.length === 0 ? (
<p className="mt-2 text-xs" role="status">
还没有生成任务
</p>
) : (
<ul className="mt-2 flex flex-col gap-2">
{ordered.map((task) => {
const focusable =
task.status === 'completed' && Boolean(task.assetId);
return (
<li
key={task.taskId}
className="rounded-xl px-2 py-2"
style={{ background: 'var(--platform-panel-fill)' }}
>
<div className="flex items-start justify-between gap-2">
<strong className="text-xs font-bold">
{task.assetName}
</strong>
<span className="text-[11px] opacity-70">
{task.actionLabel}
</span>
</div>
<div className="mt-1 flex flex-wrap items-center gap-x-2 gap-y-1 text-[11px]">
<span className="rounded-full border px-2 py-0.5">
{RESOURCE_CANVAS_ASSET_GENERATION_STATUS_LABELS[task.status]}
</span>
<span>{task.phaseDetail}</span>
<span className="opacity-70">
{`已耗时 ${resourceCanvasAssetGenerationElapsedLabel(
resourceCanvasAssetGenerationTaskElapsedMillis(
task,
nowMillis,
),
)}`}
</span>
</div>
{task.error ? (
<p className="mt-1 text-[11px]" role="alert">
{task.error}
</p>
) : null}
{focusable ? (
<button
type="button"
className="mt-1 text-[11px] underline"
aria-label={`定位素材 ${task.assetName}`}
onClick={() => onFocusTask(task)}
>
定位到素材
</button>
) : null}
</li>
);
})}
</ul>
)}
</section>
);
}
@@ -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({
<ThemedModal
open
ariaLabel={panelTitle}
closeOnBackdrop={!submitting}
closeOnEscape={!submitting}
onClose={() => {
if (!submitting) {
onClose();
}
}}
onClose={onClose}
panelClassName="game-approval-dialog game-resource-generation-dialog"
>
<header>
@@ -141,7 +139,6 @@ export function ResourceCanvasGenerationPanelView({
<button
type="button"
aria-label={`关闭${panelTitle}`}
disabled={submitting}
onClick={onClose}
>
<X size={16} aria-hidden="true" />
@@ -203,10 +200,9 @@ export function ResourceCanvasGenerationPanelView({
<PlatformActionButton
type="button"
tone="secondary"
disabled={submitting}
onClick={onClose}
>
取消
{submitting ? '后台运行并关闭' : '取消'}
</PlatformActionButton>
{error ? (
<PlatformActionButton type="submit" disabled={submitting}>
@@ -0,0 +1,272 @@
import {
applyLocalProjectAssetGenerationRecords,
type LocalProjectAssetGenerationTaskRecord,
mergeLocalProjectAssetGenerationRecord,
nextResourceCanvasAssetGenerationDispatch,
type ResourceCanvasAssetGenerationTask,
} from './resourceCanvasAssetGenerationTaskModel';
export const RESOURCE_CANVAS_ASSET_GENERATION_POLL_INTERVAL_MILLIS = 2_000;
/**
* 账本里连续多少次找不到这条任务就放弃等待。
*
* 非终态记录一直存在时不能设上限(图片类生成最长 35 分钟),但记录**消失**是完全另一回事
* (账本被删、项目被换掉),继续轮询只会永远转下去。
*/
export const RESOURCE_CANVAS_ASSET_GENERATION_MISSING_RECORD_POLL_LIMIT = 15;
/**
* 一次提交的终局。
*
* `record` 是后端账本里那条终态记录;任务连后端都没进去(IPC 抛错、后端拒绝入参)时为
* `null`,此时 `error` 带着原因。
*/
export type ResourceCanvasAssetGenerationSettlement = {
taskId: string;
/**
* 该任务所属项目。
*
* 宿主必须按它判断「这条终局还属不属于当前打开的项目」:任务可以跨项目切换收尾,拿当前项目
* 的路径去刷新另一个项目的清单是错的。
*/
projectId: string;
status: 'completed' | 'failed';
record: LocalProjectAssetGenerationTaskRecord | null;
error: string | null;
};
export type ResourceCanvasAssetGenerationQueueDeps = {
invoke(command: string, args: Record<string, unknown>): Promise<unknown>;
/**
* 当前项目路径。
*
* 队列实例要跨渲染保持同一份(`draining` 是它的内部状态),所以路径不能靠闭包捕获,
* 只能在派发那一刻从宿主当前值读。
*/
projectPath(): string;
/** 提交前的平台会话刷新(与既有生成入口同一条前置动作)。 */
refreshPlatformSession?: <T>(operation: () => Promise<T>) => Promise<T>;
listTasks(): readonly ResourceCanvasAssetGenerationTask[];
replaceTask(task: ResourceCanvasAssetGenerationTask): void;
onSettled(
settlement: ResourceCanvasAssetGenerationSettlement,
): void | Promise<void>;
pollIntervalMillis?: number;
wait?: (millis: number) => Promise<void>;
nowMillis?: () => number;
};
export type ResourceCanvasAssetGenerationQueue = {
/**
* 入队并推进。
*
* resolve = 该任务已终态(成功);reject = 失败(含后端拒绝入参)。面板据此保留草稿可重试。
* 排在别的在途任务后面的提交会先在本地队列里等,**不发 IPC**(远端图片类生成共用
* single-flight 输出槽,并发提交会在远端 POST 之前被拒)。
*/
submit(task: ResourceCanvasAssetGenerationTask): Promise<void>;
/** 推进已有队列(宿主挂载 / 重开项目恢复任务时调一次)。 */
drain(): void;
};
function errorMessage(error: unknown) {
if (typeof error === 'string' && error.trim()) return error;
if (error instanceof Error && error.message) return error.message;
return '生成素材失败';
}
function isTerminalStatus(status: string) {
return status === 'completed' || status === 'failed';
}
type SettlementWaiter = {
resolve: () => void;
reject: (error: Error) => void;
};
/**
* 本地排队 + 后端任务账本的驱动器。
*
* 顺序上只有一条规则:**下一条必须等上一条终态**;上一条结束(成功或失败)后由同一个循环
* 自动补发。派发之后不再由前端猜进度:状态与阶段文案一律来自
* `list_local_project_asset_generations` 返回的后端记录。
*/
export function createResourceCanvasAssetGenerationQueue(
deps: ResourceCanvasAssetGenerationQueueDeps,
): ResourceCanvasAssetGenerationQueue {
const pollIntervalMillis =
deps.pollIntervalMillis ??
RESOURCE_CANVAS_ASSET_GENERATION_POLL_INTERVAL_MILLIS;
const wait =
deps.wait ??
((millis: number) =>
new Promise<void>((resolve) => {
setTimeout(resolve, millis);
}));
const nowMillis = deps.nowMillis ?? (() => Date.now());
const waiters = new Map<string, SettlementWaiter[]>();
let draining = false;
function settleWaiters(settlement: ResourceCanvasAssetGenerationSettlement) {
const pending = waiters.get(settlement.taskId);
if (!pending) {
return;
}
waiters.delete(settlement.taskId);
for (const waiter of pending) {
if (settlement.status === 'completed') {
waiter.resolve();
} else {
waiter.reject(new Error(settlement.error ?? '生成素材失败'));
}
}
}
function replaceFromRecord(
task: ResourceCanvasAssetGenerationTask,
record: LocalProjectAssetGenerationTaskRecord,
) {
deps.replaceTask(mergeLocalProjectAssetGenerationRecord(task, record));
}
async function dispatch(
task: ResourceCanvasAssetGenerationTask,
): Promise<ResourceCanvasAssetGenerationSettlement> {
const projectPath = deps.projectPath();
// 命令名写成字面量:`scripts/check-config.mjs` 的 invoke 门禁按字符串字面量登记调用方,
// 抽成常量会让这两条 IPC 被判成「没有前端调用方」。
const start = async () =>
(await deps.invoke('start_local_project_asset_generation', {
projectPath,
projectId: task.projectId,
taskId: task.taskId,
kind: task.assetKind,
prompt: task.prompt,
aspectRatio: task.aspectRatio,
imageSize: task.imageSize,
assetName: task.assetName,
outputPath: task.outputPath,
})) as LocalProjectAssetGenerationTaskRecord;
let started: LocalProjectAssetGenerationTaskRecord;
try {
started = await (deps.refreshPlatformSession
? deps.refreshPlatformSession(start)
: start());
} catch (error) {
return {
taskId: task.taskId,
projectId: task.projectId,
status: 'failed',
record: null,
error: errorMessage(error),
};
}
replaceFromRecord(task, started);
let missingRecordPolls = 0;
for (;;) {
const records = (await deps.invoke(
'list_local_project_asset_generations',
{ projectPath },
)) as LocalProjectAssetGenerationTaskRecord[];
const record = records.find((item) => item.taskId === task.taskId);
if (record) {
missingRecordPolls = 0;
replaceFromRecord(task, record);
if (isTerminalStatus(record.status)) {
return {
taskId: task.taskId,
projectId: task.projectId,
status: record.status === 'completed' ? 'completed' : 'failed',
record,
error: record.error,
};
}
} else {
missingRecordPolls += 1;
if (
missingRecordPolls >=
RESOURCE_CANVAS_ASSET_GENERATION_MISSING_RECORD_POLL_LIMIT
) {
return {
taskId: task.taskId,
projectId: task.projectId,
status: 'failed',
record: null,
error: '生成任务账本里已找不到这条任务,已停止等待',
};
}
}
await wait(pollIntervalMillis);
}
}
function markSettled(
settlement: ResourceCanvasAssetGenerationSettlement,
) {
const settled = deps
.listTasks()
.find((task) => task.taskId === settlement.taskId);
if (!settled || isTerminalStatus(settled.status)) {
return;
}
// 连后端都没进去的任务(record === null)也必须收口为终态:留在「已派发但未终态」
// 会让本地队列认为还有在途任务,后面的排队任务永远补发不出去。
deps.replaceTask({
...settled,
status: settlement.status,
phaseDetail:
settlement.record?.phaseDetail ??
`生成失败:${settlement.error ?? '未知原因'}`,
error: settlement.error,
finishedAtMillis: settlement.record?.finishedAtMillis ?? nowMillis(),
});
}
function drain(): void {
if (draining) {
return;
}
draining = true;
void (async () => {
try {
for (;;) {
const next = nextResourceCanvasAssetGenerationDispatch(
deps.listTasks(),
);
if (!next) {
return;
}
deps.replaceTask({ ...next, dispatched: true });
const settlement = await dispatch(next);
markSettled(settlement);
settleWaiters(settlement);
await deps.onSettled(settlement);
}
} finally {
draining = false;
}
})();
}
return {
submit(task) {
deps.replaceTask(task);
const promise = new Promise<void>((resolve, reject) => {
const pending = waiters.get(task.taskId) ?? [];
waiters.set(task.taskId, [...pending, { resolve, reject }]);
});
drain();
return promise;
},
drain,
};
}
/** 用后端记录刷新整个任务列表(重开项目后恢复历史任务时用)。 */
export function mergeResourceCanvasAssetGenerationTasksWithRecords(
tasks: readonly ResourceCanvasAssetGenerationTask[],
records: readonly LocalProjectAssetGenerationTaskRecord[],
): ResourceCanvasAssetGenerationTask[] {
return applyLocalProjectAssetGenerationRecords(tasks, records);
}
@@ -0,0 +1,292 @@
import {
resolveResourceCanvasBottomTools,
resourceCanvasBottomToolActions,
type ResourceCanvasAssetToolAction,
} from './resourceCanvasBottomToolbarModel';
/** 一条生成任务在宿主里的状态。`queued` 包含「本地排队」和「后端排队」两种来源。 */
export type ResourceCanvasAssetGenerationTaskStatus =
| 'queued'
| 'running'
| 'completed'
| 'failed';
/**
* Rust 账本里的一条记录(`list_local_project_asset_generations` 的元素)。
*
* `phaseDetail` **由后端拥有**:前端只渲染这个字符串,不自己拼阶段或造百分比进度。
*/
export type LocalProjectAssetGenerationTaskRecord = {
taskId: string;
projectId: string;
kind: string;
assetName: string;
status: string;
phaseDetail: string;
createdAtMillis: number;
startedAtMillis: number | null;
finishedAtMillis: number | null;
assetId: string | null;
error: string | null;
};
/** 宿主「生成任务」列表里的一条:本地排队信息 + 后端账本记录。 */
export type ResourceCanvasAssetGenerationTask = {
/** 本地任务 id,同时作为提交给后端的 taskId(重开项目后靠它对上账本记录)。 */
taskId: string;
actionId: string;
actionLabel: string;
assetKind: string;
assetName: string;
prompt: string;
aspectRatio: string;
imageSize: string;
outputPath: string | null;
projectId: string;
/** 是否已经把这次提交交给后端。未派发的任务只活在本地队列里。 */
dispatched: boolean;
status: ResourceCanvasAssetGenerationTaskStatus;
phaseDetail: string;
createdAtMillis: number;
startedAtMillis: number | null;
finishedAtMillis: number | null;
assetId: string | null;
error: string | null;
/** 从账本恢复出来的历史任务(本地没有对应的草稿)。 */
restored: boolean;
};
export const RESOURCE_CANVAS_ASSET_GENERATION_STATUS_LABELS: Record<
ResourceCanvasAssetGenerationTaskStatus,
string
> = {
queued: '排队中',
running: '生成中',
completed: '已完成',
failed: '失败',
};
/**
* 本地排队的阶段文案。
*
* 只有**还没交给后端**的那条任务会用到它:单值槽换成任务列表后,第二条提交在前一条终态
* 之前不发 IPC(见 `nextResourceCanvasAssetGenerationDispatch`),所以它在后端账本里不
* 存在,没有后端阶段可渲染。一旦派发,阶段文案一律改由后端 `phaseDetail` 提供。
*/
export const RESOURCE_CANVAS_ASSET_GENERATION_LOCAL_QUEUE_PHASE = '排队中。';
const RESOURCE_CANVAS_ASSET_GENERATION_CATEGORIES = [
'ui-interaction',
'character',
'scene',
] as const;
/**
* 图片类生成 kind → 入口文案。
*
* 从工具栏模型派生而不是另抄一张表:kind 白名单只有一份(`resourceCanvasBottomToolbarModel`),
* 抄第二份就会在加 kind 时漂移。同一 kind 出现在多个入口时取首次出现的那个。
*/
export function resourceCanvasAssetGenerationKindLabel(
kind: string,
): string | null {
for (const category of RESOURCE_CANVAS_ASSET_GENERATION_CATEGORIES) {
for (const tool of resolveResourceCanvasBottomTools(category)) {
for (const action of resourceCanvasBottomToolActions(tool)) {
if (action.route === 'asset' && action.assetKind === kind) {
return action.label;
}
}
}
}
return null;
}
export function resolveResourceCanvasAssetGenerationTaskStatus(
status: string,
): ResourceCanvasAssetGenerationTaskStatus {
switch (status) {
case 'queued':
case 'running':
case 'completed':
return status;
default:
return 'failed';
}
}
export function resourceCanvasAssetGenerationTaskIsTerminal(
task: Pick<ResourceCanvasAssetGenerationTask, 'status'>,
): boolean {
return task.status === 'completed' || task.status === 'failed';
}
/** 新提交的任务:先本地排队,派发之前不进后端账本。 */
export function createResourceCanvasAssetGenerationTask(input: {
taskId: string;
action: ResourceCanvasAssetToolAction;
prompt: string;
assetName: string;
aspectRatio: string;
imageSize: string;
outputPath: string | null;
projectId: string;
nowMillis: number;
}): ResourceCanvasAssetGenerationTask {
return {
taskId: input.taskId,
actionId: input.action.id,
actionLabel: input.action.label,
assetKind: input.action.assetKind,
assetName: input.assetName,
prompt: input.prompt,
aspectRatio: input.aspectRatio,
imageSize: input.imageSize,
outputPath: input.outputPath,
projectId: input.projectId,
dispatched: false,
status: 'queued',
phaseDetail: RESOURCE_CANVAS_ASSET_GENERATION_LOCAL_QUEUE_PHASE,
createdAtMillis: input.nowMillis,
startedAtMillis: null,
finishedAtMillis: null,
assetId: null,
error: null,
restored: false,
};
}
const RESOURCE_CANVAS_ASSET_GENERATION_TASK_LIMIT = 50;
/** 账本记录 → 任务列表里的一条(重开项目后恢复显示)。 */
export function restoreResourceCanvasAssetGenerationTask(
record: LocalProjectAssetGenerationTaskRecord,
): ResourceCanvasAssetGenerationTask {
return {
taskId: record.taskId,
actionId: `restored:${record.kind}`,
actionLabel:
resourceCanvasAssetGenerationKindLabel(record.kind) ?? record.assetName,
assetKind: record.kind,
assetName: record.assetName,
prompt: '',
aspectRatio: '',
imageSize: '',
outputPath: null,
projectId: record.projectId,
dispatched: true,
status: resolveResourceCanvasAssetGenerationTaskStatus(record.status),
phaseDetail: record.phaseDetail,
createdAtMillis: record.createdAtMillis,
startedAtMillis: record.startedAtMillis,
finishedAtMillis: record.finishedAtMillis,
assetId: record.assetId,
error: record.error,
restored: true,
};
}
/**
* 本地任务 + 后端记录 → 下一版任务列表。
*
* 两个方向都要覆盖:本地任务用后端记录刷新状态与阶段;后端有、本地没有的记录(重开项目、
* 或本地列表被清空)恢复成一条历史任务。后端不再返回的本地任务保持原样——记录被账本上限
* 淘汰不该让前端把一条已完成任务抹掉。
*/
export function applyLocalProjectAssetGenerationRecords(
tasks: readonly ResourceCanvasAssetGenerationTask[],
records: readonly LocalProjectAssetGenerationTaskRecord[],
): ResourceCanvasAssetGenerationTask[] {
const recordsByTaskId = new Map(
records.map((record) => [record.taskId, record]),
);
const merged = tasks.map((task) => {
const record = recordsByTaskId.get(task.taskId);
return record ? mergeLocalProjectAssetGenerationRecord(task, record) : task;
});
const knownTaskIds = new Set(tasks.map((task) => task.taskId));
const restored = records
.filter((record) => !knownTaskIds.has(record.taskId))
.map(restoreResourceCanvasAssetGenerationTask);
if (restored.length === 0) {
return merged;
}
return [...merged, ...restored].slice(
-RESOURCE_CANVAS_ASSET_GENERATION_TASK_LIMIT,
);
}
/** 用后端记录刷新一条本地任务:状态、阶段、时间戳、资源 id 与失败原因都以后端为准。 */
export function mergeLocalProjectAssetGenerationRecord(
task: ResourceCanvasAssetGenerationTask,
record: LocalProjectAssetGenerationTaskRecord,
): ResourceCanvasAssetGenerationTask {
return {
...task,
dispatched: true,
status: resolveResourceCanvasAssetGenerationTaskStatus(record.status),
phaseDetail: record.phaseDetail,
startedAtMillis: record.startedAtMillis ?? task.startedAtMillis,
finishedAtMillis: record.finishedAtMillis ?? task.finishedAtMillis,
assetId: record.assetId ?? task.assetId,
error: record.error ?? task.error,
};
}
/**
* 本地排队的派发判据:**同一时刻只允许一条任务在途**。
*
* 远端图片类生成共用 single-flight 输出槽,第二条并发请求会在远端 POST 之前被拒;所以第二条
* 必须停在本地队列里(`dispatched === false`),等前一条终态后再补发。
*
* 「在途」的判据是 `dispatched && 未终态`,而不是 `status === 'running'`:已派发但后端记录还
* 没读回来的那一段(状态仍是 `queued`)同样占着输出槽,漏掉这一档就会并发发出第二条。
*/
export function nextResourceCanvasAssetGenerationDispatch(
tasks: readonly ResourceCanvasAssetGenerationTask[],
): ResourceCanvasAssetGenerationTask | null {
const inFlight = tasks.some(
(task) =>
task.dispatched && !resourceCanvasAssetGenerationTaskIsTerminal(task),
);
if (inFlight) {
return null;
}
return (
tasks.find((task) => !task.dispatched && task.status === 'queued') ?? null
);
}
/** 面板展示顺序:新提交的在上。 */
export function sortResourceCanvasAssetGenerationTasks(
tasks: readonly ResourceCanvasAssetGenerationTask[],
): ResourceCanvasAssetGenerationTask[] {
return [...tasks].sort(
(left, right) =>
right.createdAtMillis - left.createdAtMillis ||
left.taskId.localeCompare(right.taskId),
);
}
/** 已耗时文案:排队中按创建时间算,已结束按结束时间算。 */
export function resourceCanvasAssetGenerationElapsedLabel(
elapsedMillis: number,
): string {
const totalSeconds = Math.max(0, Math.floor(elapsedMillis / 1000));
const minutes = Math.floor(totalSeconds / 60);
const seconds = totalSeconds % 60;
return minutes > 0 ? `${minutes} 分 ${seconds} 秒` : `${seconds} 秒`;
}
export function resourceCanvasAssetGenerationTaskElapsedMillis(
task: Pick<
ResourceCanvasAssetGenerationTask,
'createdAtMillis' | 'finishedAtMillis'
>,
nowMillis: number,
): number {
return Math.max(
0,
(task.finishedAtMillis ?? nowMillis) - task.createdAtMillis,
);
}
@@ -26,6 +26,7 @@ import {
Layers,
LayoutGrid,
ListFilter,
ListChecks,
Maximize2,
Minus,
Music2,
@@ -328,6 +329,18 @@ import {
} from './useProjectResourceCanvasLayout';
import { useProjectResourceCardPreviews } from './useProjectResourceCardPreviews';
import { useProjectResourceSectionHeights } from './useProjectResourceSectionHeights';
import {
createResourceCanvasAssetGenerationQueue,
mergeResourceCanvasAssetGenerationTasksWithRecords,
type ResourceCanvasAssetGenerationQueue,
type ResourceCanvasAssetGenerationSettlement,
} from '../../features/resource-canvas/resourceCanvasAssetGenerationQueue';
import {
createResourceCanvasAssetGenerationTask,
type LocalProjectAssetGenerationTaskRecord,
type ResourceCanvasAssetGenerationTask,
} from '../../features/resource-canvas/resourceCanvasAssetGenerationTaskModel';
import { ResourceCanvasAssetGenerationTasksPanelView } from '../../features/resource-canvas/ResourceCanvasAssetGenerationTasksPanelView';
import { useResourceAssetDeleteFlow } from './useResourceAssetDeleteFlow';
export type {
@@ -388,13 +401,6 @@ type DeriveLocalProjectResourceResult = {
manifest: GameCreationAppManifest;
};
type LocalProjectAssetGenerationResult = {
id: string;
localPath: string;
absolutePath: string;
manifestPath: string;
};
type PendingLocalProjectResourceEdit = {
operationId: string;
editKind: string;
@@ -1556,6 +1562,21 @@ export default function ProjectDevelopmentView({
/** 工具栏图片类入口打开的生成浮层;同一时刻只允许一个。 */
const [resourceAssetGenerationAction, setResourceAssetGenerationAction] =
useState<ResourceCanvasAssetToolAction | null>(null);
/**
* 图片类生成任务的本地队列 + 后端账本视图。
*
* 与 `resourceAssetGenerationAction`(只管「哪块提交表单开着」)分开持有:表单随时可以被关掉,
* 任务必须继续活在这份列表里。所以提交回调不读面板状态,队列也不依赖面板挂载。
*/
const [resourceAssetGenerationTasks, setResourceAssetGenerationTasks] =
useState<ResourceCanvasAssetGenerationTask[]>([]);
const resourceAssetGenerationTasksRef = useRef<
ResourceCanvasAssetGenerationTask[]
>([]);
/** 提交表单当前这次提交对应的任务 id:面板的在途文案要读该任务的后端阶段。 */
const resourceAssetGenerationPanelTaskIdRef = useRef<string | null>(null);
const [resourceAssetGenerationTasksPanelOpen, setResourceAssetGenerationTasksPanelOpen] =
useState(false);
const [resourceBottomToolbarUploading, setResourceBottomToolbarUploading] =
useState(false);
const [resourcePanelNotice, setResourcePanelNotice] = useState('');
@@ -6640,94 +6661,262 @@ export default function ProjectDevelopmentView({
);
/**
* 工具栏图片类入口的生成:`generate_local_project_asset`。
* 生成任务的宿主上下文快照。
*
* 收口口径与既有音频入口一致:结果落盘后由「配对读」取回权威 (revision, 清单) 交给
* `onManifestChange`,走既有 manifest 刷新与资源投影链路;再用 `pendingResourceFocusRef`
* 定位新卡。这里不重算依赖图、不另写布局逻辑。
*
* 失败后不清空面板:用户可以用同一份输入直接重试(本地命令没有请求身份,前端不假装
* 它能幂等重放)。
* 队列实例与提交回调都要跨渲染保持同一份身份(队列的「已有在途任务」标记是它的内部状态),
* 所以项目路径 / 清单回调 / 规范图判据只能从 ref 读当前值,不能被闭包冻在某一帧。
*/
const submitResourceAssetGeneration = useCallback(
async (input: ResourceCanvasAssetGenerationSubmitInput) => {
const action = resourceAssetGenerationAction;
if (!action) {
const resourceAssetGenerationContextRef = useRef({
projectPath,
projectId: manifest.projectId,
hasIconSpecReference,
onManifestChange,
});
resourceAssetGenerationContextRef.current = {
projectPath,
projectId: manifest.projectId,
hasIconSpecReference,
onManifestChange,
};
const replaceResourceAssetGenerationTask = useCallback(
(next: ResourceCanvasAssetGenerationTask) => {
const current = resourceAssetGenerationTasksRef.current;
const nextTasks = current.some((task) => task.taskId === next.taskId)
? current.map((task) => (task.taskId === next.taskId ? next : task))
: [...current, next];
resourceAssetGenerationTasksRef.current = nextTasks;
setResourceAssetGenerationTasks(nextTasks);
},
[],
);
/**
* 一条生成任务收尾后的宿主动作:成功落卡 / 失败给提示。
*
* 后端把生成结果与 manifest 登记都写完才把记录置为终态,所以这里只做「配对读 + 交给
* `onManifestChange`」这条既有链路,再用 `pendingResourceFocusRef` 定位新卡;不重算依赖图、
* 不另写布局逻辑。
*/
const handleResourceAssetGenerationSettlement = useCallback(
async (settlement: ResourceCanvasAssetGenerationSettlement) => {
const context = resourceAssetGenerationContextRef.current;
if (settlement.projectId !== context.projectId) {
// 任务可以在切换项目之后才收尾:这时候拿当前项目的路径去刷新清单是错的,
// 账本已经把结果写在它自己的项目里,这里不再动当前项目的状态。
return;
}
if (settlement.status !== 'completed' || !settlement.record?.assetId) {
setResourceWorkbenchNotice(
`生成素材失败:${settlement.error ?? '未知原因'}`,
);
return;
}
const assetId = settlement.record.assetId;
const invoke = window.__TAURI__?.core?.invoke;
if (!invoke) {
throw new Error('生成素材需要在客户端内执行');
setResourceWorkbenchNotice('生成结果需要在客户端内读取');
return;
}
let fresh: Awaited<
ReturnType<typeof rereadAuthoritativeProjectManifestSnapshot>
> = null;
try {
fresh = await rereadAuthoritativeProjectManifestSnapshot({
projectPath: context.projectPath,
projectId: context.projectId,
readRevision: async () =>
(
await invoke<{ revision: number }>(
'get_local_game_project_revision',
{ projectPath: context.projectPath },
)
).revision,
readManifest: () =>
invoke<GameCreationAppManifest>('get_local_game_manifest', {
projectPath: context.projectPath,
commandId: 'asset.list',
}),
});
} catch (error) {
setResourceWorkbenchNotice(
error instanceof Error ? error.message : String(error),
);
return;
}
const flowId = crypto.randomUUID();
const projectId = manifest.projectId;
const result = await withPlatformSessionRefresh(() =>
invoke<LocalProjectAssetGenerationResult>(
'generate_local_project_asset',
{
projectPath,
kind: input.kind,
prompt: input.prompt,
aspectRatio: input.aspectRatio,
imageSize: input.imageSize,
assetName: input.assetName,
outputPath: resourceCanvasAssetGenerationOutputPath(
action,
hasIconSpecReference,
),
},
),
);
const fresh = await rereadAuthoritativeProjectManifestSnapshot({
projectPath,
projectId,
readRevision: async () =>
(
await invoke<{ revision: number }>(
'get_local_game_project_revision',
{ projectPath },
)
).revision,
readManifest: () =>
invoke<GameCreationAppManifest>('get_local_game_manifest', {
projectPath,
commandId: 'asset.list',
}),
});
if (!fresh) {
throw new Error(
setResourceWorkbenchNotice(
'生成结果已落盘,但清单与版本号未能配对读回,请重新打开项目后确认',
);
return;
}
onManifestChange?.(projectPath, fresh.manifest, {
context.onManifestChange?.(context.projectPath, fresh.manifest, {
projectId: fresh.projectId,
revision: fresh.revision,
source: fresh.source,
commitId: result.id,
commitId: assetId,
});
const flowId = `asset-generation:${settlement.taskId}`;
activeFocusFlowIdRef.current = flowId;
pendingResourceFocusRef.current = {
flowId,
saveAttemptId: result.id,
sessionId: result.id,
draftId: result.id,
commitId: result.id,
projectPath,
saveAttemptId: assetId,
sessionId: assetId,
draftId: assetId,
commitId: assetId,
projectPath: context.projectPath,
projectId: fresh.projectId,
focusGeneration: focusGenerationRef.current,
resourceId: `asset:${result.id}`,
resourceId: `asset:${assetId}`,
completed: false,
};
setResourceWorkbenchNotice('生成资源已保存,正在同步资源与布局…');
setResourceAssetGenerationAction(null);
// 只在「这块表单还是这次提交的」时收面板:用户可能已经关掉它、又打开做第二次提交。
if (resourceAssetGenerationPanelTaskIdRef.current === settlement.taskId) {
resourceAssetGenerationPanelTaskIdRef.current = null;
setResourceAssetGenerationAction(null);
}
},
[
hasIconSpecReference,
manifest.projectId,
onManifestChange,
projectPath,
resourceAssetGenerationAction,
],
[],
);
/**
* 生成任务队列:本地排队 + 后端账本轮询的唯一驱动器。
*
* 队列实例跨渲染保持同一份,「已有在途任务」这个标记才不会因为一次渲染就丢掉。
*/
const resourceAssetGenerationQueueRef =
useRef<ResourceCanvasAssetGenerationQueue | null>(null);
if (resourceAssetGenerationQueueRef.current === null) {
resourceAssetGenerationQueueRef.current =
createResourceCanvasAssetGenerationQueue({
invoke: (command, args) => {
const invoke = window.__TAURI__?.core?.invoke;
if (!invoke) {
return Promise.reject(new Error('生成素材需要在客户端内执行'));
}
return invoke(command, args);
},
projectPath: () =>
resourceAssetGenerationContextRef.current.projectPath,
refreshPlatformSession: withPlatformSessionRefresh,
listTasks: () =>
resourceAssetGenerationTasksRef.current.filter(
(task) =>
task.projectId ===
resourceAssetGenerationContextRef.current.projectId,
),
replaceTask: (next) => replaceResourceAssetGenerationTask(next),
onSettled: (settlement) =>
handleResourceAssetGenerationSettlement(settlement),
});
}
/**
* 提交一条图片类生成任务。
*
* 只做「入队」,生成由队列在后台派发与轮询;面板的 `await` 落在该任务的终局上,所以提交
* 期间关掉面板不等于取消请求,失败也能带着草稿重试。
*/
const submitResourceAssetGeneration = useCallback(
async (
action: ResourceCanvasAssetToolAction,
input: ResourceCanvasAssetGenerationSubmitInput,
) => {
const queue = resourceAssetGenerationQueueRef.current;
if (!queue) {
throw new Error('生成任务队列尚未就绪');
}
const context = resourceAssetGenerationContextRef.current;
const task = createResourceCanvasAssetGenerationTask({
taskId: crypto.randomUUID(),
action,
prompt: input.prompt,
assetName: input.assetName,
aspectRatio: input.aspectRatio,
imageSize: input.imageSize,
outputPath: resourceCanvasAssetGenerationOutputPath(
action,
context.hasIconSpecReference,
),
projectId: context.projectId,
nowMillis: Date.now(),
});
resourceAssetGenerationPanelTaskIdRef.current = task.taskId;
setResourceAssetGenerationTasksPanelOpen(true);
await queue.submit(task);
},
[],
);
/**
* 重开项目时恢复项目内的任务账本。
*
* 账本落在项目内的 `.agent/runtime/asset-generation-tasks/`,所以历史任务(含上次运行中断
* 的那些)在这里回到列表;后端已经把没人推进的记录收口为失败,前端不假装它还在跑。
*/
useEffect(() => {
const invoke = window.__TAURI__?.core?.invoke;
if (!invoke || !projectPath.trim() || !manifest.projectId.trim()) {
return undefined;
}
const projectId = manifest.projectId;
let cancelled = false;
setResourceAssetGenerationTasksPanelOpen(false);
resourceAssetGenerationPanelTaskIdRef.current = null;
void (async () => {
let records: LocalProjectAssetGenerationTaskRecord[];
try {
records = await invoke<LocalProjectAssetGenerationTaskRecord[]>(
'list_local_project_asset_generations',
{ projectPath },
);
} catch {
return;
}
if (cancelled) {
return;
}
const restored = mergeResourceCanvasAssetGenerationTasksWithRecords(
resourceAssetGenerationTasksRef.current.filter(
(task) => task.projectId === projectId,
),
records,
);
resourceAssetGenerationTasksRef.current = restored;
setResourceAssetGenerationTasks(restored);
})();
return () => {
cancelled = true;
};
}, [manifest.projectId, projectPath]);
/** 「生成任务」面板里点一条已完成任务:复用既有聚焦链定位到它的素材卡。 */
const focusResourceAssetGenerationTask = useCallback(
(task: ResourceCanvasAssetGenerationTask) => {
if (!task.assetId) {
return;
}
const context = resourceAssetGenerationContextRef.current;
// 手动定位不能复用自动落卡那条 commitId:`focusedCommitIdsRef` 会把同一个 commitId 记为
// 「已聚焦」,重复点同一条任务就会静默失效,所以这里用一次一点击的 flowId。
const flowId = `asset-generation-focus:${task.taskId}:${Date.now()}`;
activeFocusFlowIdRef.current = flowId;
pendingResourceFocusRef.current = {
flowId,
saveAttemptId: task.assetId,
sessionId: task.assetId,
draftId: task.assetId,
commitId: flowId,
projectPath: context.projectPath,
projectId: context.projectId,
focusGeneration: focusGenerationRef.current,
resourceId: `asset:${task.assetId}`,
completed: false,
};
setResourceWorkbenchNotice('正在定位生成的素材…');
},
[],
);
/** 工具栏「上传」:与资源面板上传同一条「上传 + 配对读清单」链路。 */
@@ -7055,6 +7244,26 @@ export default function ProjectDevelopmentView({
生成素材
</button>
) : null}
{/*
「生成任务」入口:生成在后台跑,面板关掉之后进度、阶段与失败原因都只在这
里可见(非模态浮层,画布照常可用)。
*/}
<button
type="button"
className="game-workbench-resource-panel-button"
aria-label="生成任务"
aria-expanded={resourceAssetGenerationTasksPanelOpen}
onClick={() =>
setResourceAssetGenerationTasksPanelOpen(
(current) => !current,
)
}
>
<ListChecks size={15} aria-hidden="true" />
{resourceAssetGenerationTasks.length > 0
? `生成任务 ${resourceAssetGenerationTasks.length}`
: '生成任务'}
</button>
{pendingResourceEdits.length > 0 ||
pendingResourceEditsLoadState === 'failed' ? (
<button
@@ -8032,10 +8241,34 @@ export default function ProjectDevelopmentView({
{resourceAssetGenerationAction ? (
<ResourceCanvasAssetGenerationPanelView
action={resourceAssetGenerationAction}
onSubmit={submitResourceAssetGeneration}
phaseDetail={
resourceAssetGenerationTasks.find(
(task) =>
task.taskId === resourceAssetGenerationPanelTaskIdRef.current,
)?.phaseDetail ?? null
}
onSubmit={(input) =>
submitResourceAssetGeneration(
resourceAssetGenerationAction,
input,
)
}
onClose={() => setResourceAssetGenerationAction(null)}
/>
) : null}
{/*
「生成任务」面板:非模态。它**不**参与 `isResourceCanvasFloatingPanelOpen` 的模态遮挡
判据——生成在后台跑,面板开着时画布必须照样能看能用。
*/}
{resourceAssetGenerationTasksPanelOpen ? (
<ResourceCanvasAssetGenerationTasksPanelView
tasks={resourceAssetGenerationTasks.filter(
(task) => task.projectId === manifest.projectId,
)}
onClose={() => setResourceAssetGenerationTasksPanelOpen(false)}
onFocusTask={focusResourceAssetGenerationTask}
/>
) : null}
{resourcePanelOpen ? (
<ResourceCanvasPanelView
@@ -237,6 +237,7 @@ function installResourceBookBottomToolbarInvoke(
manifest: GameCreationAppManifest,
) {
const calls: BottomToolbarInvokeCall[] = [];
const generatedTasks = new Map<string, Record<string, unknown>>();
const invoke = vi.fn(
async (command: string, args?: Record<string, unknown>) => {
calls.push({ command, args });
@@ -266,14 +267,29 @@ function installResourceBookBottomToolbarInvoke(
},
};
}
if (command === 'generate_local_project_asset') {
if (command === 'start_local_project_asset_generation') {
// 生成已经后台化:提交这一条只负责入参与落账,进度由 `list_...` 轮询后端账本。
// 桩直接回一条已完成记录,让用例仍然只验证「界面发出的载荷」这一件事。
const kind = invokeStringField(args, 'kind') ?? 'unknown';
return {
id: `generated-${kind}`,
localPath: `assets/canvas-generated/generated-${kind}.png`,
absolutePath: `/tmp/generated-${kind}.png`,
manifestPath: '/tmp/.agent/manifest.json',
const taskId = invokeStringField(args, 'taskId') ?? `task-${kind}`;
const task = {
taskId,
projectId: String(args?.projectId ?? manifest.projectId),
kind,
assetName: invokeStringField(args, 'assetName') ?? '',
status: 'completed',
phaseDetail: '生成已完成。',
createdAtMillis: 1,
startedAtMillis: 1,
finishedAtMillis: 2,
assetId: `generated-${kind}`,
error: null,
};
generatedTasks.set(taskId, task);
return task;
}
if (command === 'list_local_project_asset_generations') {
return [...generatedTasks.values()];
}
if (command === 'derive_local_project_resource') {
const editKind = deriveInputEditKind({ command, args }) ?? 'unknown';
@@ -335,14 +351,20 @@ async function submitBottomToolbarPanel(
);
}
/**
* 一次图片类生成提交的载荷。
*
* 后台化之后「提交」这一步是 `start_local_project_asset_generation`(入参校验 + 落项目内账本),
* 所以载荷断言看的是它;生成本身由 Rust 后台任务跑。
*/
function generateCall(calls: readonly BottomToolbarInvokeCall[], kind: string) {
const call = calls.find(
(entry) =>
entry.command === 'generate_local_project_asset' &&
entry.command === 'start_local_project_asset_generation' &&
invokeStringField(entry.args, 'kind') === kind,
);
if (!call?.args) {
throw new Error(`missing generate_local_project_asset call for ${kind}`);
throw new Error(`missing start_local_project_asset_generation call for ${kind}`);
}
return call.args;
}
@@ -10463,7 +10485,7 @@ export function registerProjectAgentStatusTests() {
expect(commands).toContain('get_local_game_project_revision');
expect(commands).toContain('get_local_game_manifest');
expect(commands.lastIndexOf('get_local_game_manifest')).toBeGreaterThan(
commands.indexOf('generate_local_project_asset'),
commands.indexOf('start_local_project_asset_generation'),
);
// 4) 缺权威规范图时,「生成图标素材 / 生成 UI 设计图」保持可点击并说明原因。
@@ -10478,7 +10500,9 @@ export function registerProjectAgentStatusTests() {
'assets/art-spec.png',
);
expect(
calls.filter((call) => call.command === 'generate_local_project_asset'),
calls.filter(
(call) => call.command === 'start_local_project_asset_generation',
),
).toHaveLength(3);
}, 20_000);
@@ -10583,6 +10607,199 @@ export function registerProjectAgentStatusTests() {
});
}, 20_000);
it('keeps a submitted generation alive after the panel closes, queues the second submission, and shows backend phases', async () => {
const manifest = createGameCreationAppManifest(
'workbench-asset-generation-tasks',
'生成任务项目',
);
manifest.assets = [
{
id: 'tasks-art-spec',
kind: 'icon-spec',
mediaType: 'image/png',
localPath: 'assets/art-spec.png',
source: { kind: 'canvas', resourceId: 'tasks-art-spec-resource' },
},
];
const calls: BottomToolbarInvokeCall[] = [];
const tasks = new Map<string, Record<string, unknown>>();
let listPolls = 0;
const invoke = vi.fn(
async (command: string, args?: Record<string, unknown>) => {
calls.push({ command, args });
if (command === 'read_local_project_resource_graph') {
return resourceGraphForInputs(args);
}
if (command === 'read_local_project_resource_canvas_layout') {
return {
schemaVersion: 'game-creator-resource-layout.v1',
projectId: args?.expectedProjectId,
mode: args?.mode,
revision: 0,
positions: [],
updatedAt: 0,
};
}
if (command === 'update_local_project_resource_canvas_layout') {
return {
status: 'updated',
layout: {
schemaVersion: 'game-creator-resource-layout.v1',
projectId: args?.expectedProjectId,
mode: args?.mode,
revision: 1,
positions: args?.positions,
updatedAt: 1,
},
};
}
if (command === 'start_local_project_asset_generation') {
const kind = invokeStringField(args, 'kind') ?? 'unknown';
const taskId = invokeStringField(args, 'taskId') ?? `task-${tasks.size}`;
const task = {
taskId,
projectId: String(args?.projectId ?? manifest.projectId),
kind,
assetName: invokeStringField(args, 'assetName') ?? '',
status: 'running',
phaseDetail: '正在生成。',
createdAtMillis: tasks.size + 1,
startedAtMillis: tasks.size + 1,
finishedAtMillis: null,
assetId: null,
error: null,
};
tasks.set(taskId, task);
return task;
}
if (command === 'list_local_project_asset_generations') {
listPolls += 1;
// 第三次轮询(约 4 秒)开始收尾:既让「第一条在途 → 第二条排队」可观测,
// 又让整条链路(补发 + 落卡 + 面板收口)在同一次用例里真的跑完。
if (listPolls >= 3) {
for (const [taskId, task] of tasks) {
if (task.status !== 'completed') {
tasks.set(taskId, {
...task,
status: 'completed',
phaseDetail: '生成已完成。',
assetId: `generated-${String(task.kind)}`,
finishedAtMillis: 2,
});
}
}
}
return [...tasks.values()];
}
if (command === 'get_local_game_project_revision') {
return { revision: 7 };
}
if (command === 'get_local_game_manifest') {
return manifest;
}
throw new Error(`unexpected invoke ${command}`);
},
);
window.__TAURI__ = { core: { invoke } };
render(
React.createElement(ProjectDevelopmentView, {
projectName: manifest.name,
projectPath: '/tmp/workbench-asset-generation-tasks',
manifest,
attachments: [],
recentRunStatus: null,
recentRunStopReason: null,
supervisor: React.createElement('div', null, '项目总控'),
onHomeOpen: vi.fn(),
onProjectsOpen: vi.fn(),
}),
);
await openResourceBookCategory('UI 交互');
expect(screen.getByRole('button', { name: '生成任务' })).not.toBeNull();
// 1) 提交第一条;生成在途时面板能关(关闭 ≠ 取消请求)。
fireEvent.click(await screen.findByRole('button', { name: '生成 UI 设计图' }));
const firstPanel = await screen.findByRole('dialog', {
name: '生成 UI 设计图',
});
fireEvent.change(within(firstPanel).getByLabelText('素材名称'), {
target: { value: '第一条设计图' },
});
fireEvent.change(within(firstPanel).getByLabelText('生成提示词'), {
target: { value: '第一条界面' },
});
fireEvent.click(within(firstPanel).getByRole('button', { name: '生成 UI 设计图' }));
fireEvent.click(
await screen.findByRole('button', { name: '后台运行并关闭' }),
);
await waitFor(() =>
expect(
screen.queryByRole('dialog', { name: '生成 UI 设计图' }),
).toBeNull(),
);
// 2) 面板关掉之后任务仍在「生成任务」面板里,阶段文案来自后端记录。
const taskPanel = await screen.findByRole('region', { name: '生成任务' });
expect(within(taskPanel).getByText('第一条设计图')).not.toBeNull();
expect(within(taskPanel).getByText('正在生成。')).not.toBeNull();
expect(within(taskPanel).getByText('生成中')).not.toBeNull();
// 非模态:没有全屏遮罩、没有 aria-modal。
expect(taskPanel.getAttribute('aria-modal')).toBeNull();
expect(document.querySelector('[aria-modal="true"]')).toBeNull();
// 3) 第一条还在途时提交第二条:第二条停在本地队列,生成提交 IPC 仍然只有一次。
fireEvent.click(screen.getByRole('button', { name: '生成 UI 设计图' }));
const secondPanel = await screen.findByRole('dialog', {
name: '生成 UI 设计图',
});
fireEvent.change(within(secondPanel).getByLabelText('素材名称'), {
target: { value: '第二条设计图' },
});
fireEvent.change(within(secondPanel).getByLabelText('生成提示词'), {
target: { value: '第二条界面' },
});
fireEvent.click(within(secondPanel).getByRole('button', { name: '生成 UI 设计图' }));
await within(secondPanel).findByText('排队中。');
expect(
calls.filter(
(call) => call.command === 'start_local_project_asset_generation',
),
).toHaveLength(1);
await waitFor(() =>
expect(within(taskPanel).getByText('第二条设计图')).not.toBeNull(),
);
fireEvent.keyDown(window, { key: 'Escape' });
await waitFor(() =>
expect(
screen.queryByRole('dialog', { name: '生成 UI 设计图' }),
).toBeNull(),
);
// 4) 第一条终态后自动补发第二条,两条都收口为已完成;每条完成各走一次「配对读 + 落卡」。
const manifestReadsBefore = calls.filter(
(call) => call.command === 'get_local_game_manifest',
).length;
await waitFor(
() =>
expect(
calls.filter(
(call) => call.command === 'start_local_project_asset_generation',
),
).toHaveLength(2),
{ timeout: 15_000 },
);
await waitFor(
() => expect(within(taskPanel).getAllByText('已完成')).toHaveLength(2),
{ timeout: 15_000 },
);
expect(within(taskPanel).getAllByText('生成已完成。')).toHaveLength(2);
expect(
calls.filter((call) => call.command === 'get_local_game_manifest').length,
).toBeGreaterThanOrEqual(manifestReadsBefore + 2);
}, 30_000);
it('routes the audio column entries to the existing audio generation chain', async () => {
const manifest = createGameCreationAppManifest(
'workbench-bottom-toolbar-audio',
@@ -0,0 +1,187 @@
// @vitest-environment jsdom
import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { afterEach, describe, expect, test, vi } from 'vitest';
import { ResourceCanvasAssetGenerationPanelView } from '../src/features/resource-canvas/ResourceCanvasAssetGenerationPanelView';
import type { ResourceCanvasAssetToolAction } from '../src/features/resource-canvas/resourceCanvasBottomToolbarModel';
import { ResourceCanvasGenerationPanelView } from '../src/features/resource-canvas/ResourceCanvasGenerationPanelView';
afterEach(() => {
cleanup();
});
const uiPrototypeAction: ResourceCanvasAssetToolAction = {
id: 'generate-ui-prototype',
route: 'asset',
label: '生成 UI 设计图',
assetKind: 'ui-prototype',
audioKind: null,
assetName: 'AI 生成 UI 设计图',
promptPlaceholder: '描述这张界面要承载的玩法与操作',
adjustableDimensions: true,
aspectRatio: '16:9',
imageSize: '1K',
requiresIconSpecReference: true,
writesIconSpecReference: false,
};
/** 永不 resolve 的提交:模拟「生成还要跑 35 分钟」的在途状态。 */
function pendingSubmit() {
return new Promise<void>(() => undefined);
}
async function fillAndSubmitAssetPanel(onSubmit: () => Promise<void>) {
const user = userEvent.setup();
render(
<ResourceCanvasAssetGenerationPanelView
action={uiPrototypeAction}
onSubmit={onSubmit}
onClose={() => undefined}
/>,
);
await user.type(screen.getByLabelText('生成提示词'), '主界面与背包页');
await user.click(screen.getByRole('button', { name: '生成 UI 设计图' }));
return user;
}
describe('图片类生成面板在提交期间可关闭', () => {
test('提交在途时点 × 能关闭面板,且关闭不等于取消请求', async () => {
const onClose = vi.fn();
const onSubmit = vi.fn(pendingSubmit);
const user = userEvent.setup();
render(
<ResourceCanvasAssetGenerationPanelView
action={uiPrototypeAction}
onSubmit={onSubmit}
onClose={onClose}
/>,
);
await user.type(screen.getByLabelText('生成提示词'), '主界面与背包页');
await user.click(screen.getByRole('button', { name: '生成 UI 设计图' }));
await waitFor(() => expect(onSubmit).toHaveBeenCalledTimes(1));
const closeButton = screen.getByRole('button', {
name: '关闭生成 UI 设计图',
}) as HTMLButtonElement;
expect(closeButton.disabled).toBe(false);
await user.click(closeButton);
expect(onClose).toHaveBeenCalledTimes(1);
// 关闭面板只是卸载 view:请求仍在飞,面板不该去「取消」它。
expect(onSubmit).toHaveBeenCalledTimes(1);
});
test('提交在途时 Esc 与点遮罩都能关面板', async () => {
const onClose = vi.fn();
render(
<ResourceCanvasAssetGenerationPanelView
action={uiPrototypeAction}
onSubmit={vi.fn(pendingSubmit)}
onClose={onClose}
/>,
);
const user = userEvent.setup();
await user.type(screen.getByLabelText('生成提示词'), '主界面与背包页');
await user.click(screen.getByRole('button', { name: '生成 UI 设计图' }));
fireEvent.keyDown(window, { key: 'Escape' });
expect(onClose).toHaveBeenCalledTimes(1);
const backdrop = document.querySelector('.fixed.inset-0') as HTMLElement;
fireEvent.pointerDown(backdrop);
fireEvent.pointerUp(backdrop);
fireEvent.click(backdrop);
expect(onClose).toHaveBeenCalledTimes(2);
});
test('提交在途时提供「后台运行并关闭」,文案明说关闭不等于取消', async () => {
const onClose = vi.fn();
const onSubmit = vi.fn(pendingSubmit);
const user = userEvent.setup();
render(
<ResourceCanvasAssetGenerationPanelView
action={uiPrototypeAction}
onSubmit={onSubmit}
onClose={onClose}
/>,
);
await user.type(screen.getByLabelText('生成提示词'), '主界面与背包页');
await user.click(screen.getByRole('button', { name: '生成 UI 设计图' }));
const backgroundButton = screen.getByRole('button', {
name: '后台运行并关闭',
});
await user.click(backgroundButton);
expect(onClose).toHaveBeenCalledTimes(1);
expect(onSubmit).toHaveBeenCalledTimes(1);
});
test('提交失败后草稿仍留在面板里可以直接重试', async () => {
const onSubmit = vi
.fn<() => Promise<void>>()
.mockRejectedValueOnce(new Error('生成素材失败:远端拒绝'));
const user = await fillAndSubmitAssetPanel(onSubmit);
expect((await screen.findByRole('alert')).textContent).toContain(
'生成素材失败:远端拒绝',
);
expect((screen.getByLabelText('生成提示词') as HTMLTextAreaElement).value).toBe(
'主界面与背包页',
);
expect(
(screen.getByLabelText('素材名称') as HTMLInputElement).disabled,
).toBe(false);
await user.click(screen.getByRole('button', { name: '生成 UI 设计图' }));
expect(onSubmit).toHaveBeenCalledTimes(2);
});
});
describe('音频生成面板在提交期间可关闭', () => {
test('提交在途时点 × 能关闭面板,且不改动 pending-edit 账本语义', async () => {
const onClose = vi.fn();
const onSubmit = vi.fn(pendingSubmit);
const user = userEvent.setup();
render(
<ResourceCanvasGenerationPanelView
kinds={['sound-effect']}
initialKind="sound-effect"
onSubmit={onSubmit}
onClose={onClose}
/>,
);
await user.type(screen.getByLabelText('生成提示词'), '木门推开的声音');
await user.click(screen.getByRole('button', { name: '生成音效' }));
await waitFor(() => expect(onSubmit).toHaveBeenCalledTimes(1));
const closeButton = screen.getByRole('button', {
name: '关闭生成音效',
}) as HTMLButtonElement;
expect(closeButton.disabled).toBe(false);
await user.click(closeButton);
expect(onClose).toHaveBeenCalledTimes(1);
expect(onSubmit).toHaveBeenCalledTimes(1);
fireEvent.keyDown(window, { key: 'Escape' });
expect(onClose).toHaveBeenCalledTimes(2);
});
test('提交在途时提供「后台运行并关闭」', async () => {
const onClose = vi.fn();
const user = userEvent.setup();
render(
<ResourceCanvasGenerationPanelView
kinds={['sound-effect']}
initialKind="sound-effect"
onSubmit={vi.fn(pendingSubmit)}
onClose={onClose}
/>,
);
await user.type(screen.getByLabelText('生成提示词'), '木门推开的声音');
await user.click(screen.getByRole('button', { name: '生成音效' }));
await user.click(
screen.getByRole('button', { name: '后台运行并关闭' }),
);
expect(onClose).toHaveBeenCalledTimes(1);
});
});
@@ -0,0 +1,401 @@
import { describe, expect, test, vi } from 'vitest';
import {
createResourceCanvasAssetGenerationQueue,
mergeResourceCanvasAssetGenerationTasksWithRecords,
type ResourceCanvasAssetGenerationQueueDeps,
type ResourceCanvasAssetGenerationSettlement,
} from '../src/features/resource-canvas/resourceCanvasAssetGenerationQueue';
import {
createResourceCanvasAssetGenerationTask,
nextResourceCanvasAssetGenerationDispatch,
RESOURCE_CANVAS_ASSET_GENERATION_LOCAL_QUEUE_PHASE,
type LocalProjectAssetGenerationTaskRecord,
resourceCanvasAssetGenerationElapsedLabel,
resourceCanvasAssetGenerationKindLabel,
type ResourceCanvasAssetGenerationTask,
sortResourceCanvasAssetGenerationTasks,
} from '../src/features/resource-canvas/resourceCanvasAssetGenerationTaskModel';
import type { ResourceCanvasAssetToolAction } from '../src/features/resource-canvas/resourceCanvasBottomToolbarModel';
const uiPrototypeAction: ResourceCanvasAssetToolAction = {
id: 'generate-ui-prototype',
route: 'asset',
label: '生成 UI 设计图',
assetKind: 'ui-prototype',
audioKind: null,
assetName: 'AI 生成 UI 设计图',
promptPlaceholder: '描述这张界面要承载的玩法与操作',
adjustableDimensions: true,
aspectRatio: '16:9',
imageSize: '1K',
requiresIconSpecReference: true,
writesIconSpecReference: false,
};
function localTask(taskId: string, createdAtMillis: number) {
return createResourceCanvasAssetGenerationTask({
taskId,
action: uiPrototypeAction,
prompt: `提示词 ${taskId}`,
assetName: `素材 ${taskId}`,
aspectRatio: '16:9',
imageSize: '1K',
outputPath: null,
projectId: 'project-1',
nowMillis: createdAtMillis,
});
}
function record(
taskId: string,
status: string,
extra: Partial<LocalProjectAssetGenerationTaskRecord> = {},
): LocalProjectAssetGenerationTaskRecord {
return {
taskId,
projectId: 'project-1',
kind: 'ui-prototype',
assetName: `素材 ${taskId}`,
status,
phaseDetail: status === 'running' ? '正在生成。' : '排队中。',
createdAtMillis: 1_000,
startedAtMillis: null,
finishedAtMillis: null,
assetId: null,
error: null,
...extra,
};
}
describe('生成任务模型', () => {
test('新提交的任务先按本地排队呈现,阶段文案不是后端记录', () => {
const task = localTask('task-a', 10);
expect(task.dispatched).toBe(false);
expect(task.status).toBe('queued');
expect(task.phaseDetail).toBe(
RESOURCE_CANVAS_ASSET_GENERATION_LOCAL_QUEUE_PHASE,
);
expect(task.restored).toBe(false);
});
test('有在途任务时下一条不可派发,前一条终态后才轮到它', () => {
const running = {
...localTask('task-a', 10),
dispatched: true,
status: 'running' as const,
};
const queued = localTask('task-b', 20);
expect(nextResourceCanvasAssetGenerationDispatch([running, queued])).toBe(
null,
);
expect(
nextResourceCanvasAssetGenerationDispatch([
{ ...running, status: 'completed' },
queued,
])?.taskId,
).toBe('task-b');
expect(
nextResourceCanvasAssetGenerationDispatch([
{ ...running, status: 'failed' },
queued,
])?.taskId,
).toBe('task-b');
expect(
nextResourceCanvasAssetGenerationDispatch([
{ ...running, status: 'completed' },
]),
).toBeNull();
});
test('已派发但还没终态的任务同样挡住队列(不发第二条)', () => {
const dispatched = { ...localTask('task-a', 10), dispatched: true };
expect(
nextResourceCanvasAssetGenerationDispatch([
dispatched,
localTask('task-b', 20),
]),
).toBeNull();
});
test('后端记录刷新本地任务:状态、阶段、时间戳与资源 id 都以后端为准', () => {
const merged = mergeResourceCanvasAssetGenerationTasksWithRecords(
[localTask('task-a', 10)],
[
record('task-a', 'completed', {
phaseDetail: '生成已完成。',
assetId: 'asset-7',
startedAtMillis: 1_100,
finishedAtMillis: 1_900,
}),
],
);
expect(merged[0]?.status).toBe('completed');
expect(merged[0]?.phaseDetail).toBe('生成已完成。');
expect(merged[0]?.assetId).toBe('asset-7');
expect(merged[0]?.finishedAtMillis).toBe(1_900);
expect(merged[0]?.restored).toBe(false);
});
test('重开项目后按账本恢复历史任务(本地没有草稿也能显示)', () => {
const merged = mergeResourceCanvasAssetGenerationTasksWithRecords(
[],
[
record('task-old', 'completed', {
phaseDetail: '生成已完成。',
assetId: 'asset-1',
finishedAtMillis: 2_000,
}),
],
);
expect(merged).toHaveLength(1);
expect(merged[0]?.taskId).toBe('task-old');
expect(merged[0]?.restored).toBe(true);
expect(merged[0]?.actionLabel).toBe('生成 UI 设计图');
expect(merged[0]?.status).toBe('completed');
});
test('后端没给过的状态按失败收口,展示顺序最新在前', () => {
const merged = mergeResourceCanvasAssetGenerationTasksWithRecords(
[localTask('task-a', 10), localTask('task-b', 20)],
[record('task-a', 'weird-status'), record('task-b', 'running')],
);
expect(merged.find((task) => task.taskId === 'task-a')?.status).toBe(
'failed',
);
expect(
sortResourceCanvasAssetGenerationTasks(merged).map((task) => task.taskId),
).toEqual(['task-b', 'task-a']);
});
test('kind 文案从工具栏模型派生,未知 kind 不编造', () => {
expect(resourceCanvasAssetGenerationKindLabel('ui-prototype')).toBe(
'生成 UI 设计图',
);
expect(resourceCanvasAssetGenerationKindLabel('image')).toBe('生成图片');
expect(resourceCanvasAssetGenerationKindLabel('nope')).toBeNull();
});
test('已耗时文案按分秒呈现', () => {
expect(resourceCanvasAssetGenerationElapsedLabel(12_000)).toBe('12 秒');
expect(resourceCanvasAssetGenerationElapsedLabel(72_000)).toBe('1 分 12 秒');
expect(resourceCanvasAssetGenerationElapsedLabel(-5)).toBe('0 秒');
});
});
type Harness = {
deps: ResourceCanvasAssetGenerationQueueDeps;
invoke: ReturnType<typeof vi.fn>;
tasks: () => readonly ResourceCanvasAssetGenerationTask[];
settlements: ResourceCanvasAssetGenerationSettlement[];
/** 只统计生成提交命令;轮询用的读接口不计入「第二条不许发 IPC」。 */
startCallCount: () => number;
advance: (taskId: string, status: 'completed' | 'failed') => void;
forget: (taskId: string) => void;
startError: Map<string, Error>;
};
/**
* 假后端 + 由轮询次数驱动的确定性脚本。
*
* `wait` 在一次轮询之后被调用,所以「第 N 次轮询之后做什么」就是脚本的粒度,不需要计时器。
*/
function createHarness(waitScript?: (pollCount: number, api: Harness) => void) {
const records = new Map<string, LocalProjectAssetGenerationTaskRecord>();
const startError = new Map<string, Error>();
let tasks: ResourceCanvasAssetGenerationTask[] = [];
const settlements: ResourceCanvasAssetGenerationSettlement[] = [];
let pollCount = 0;
const harness: Harness = {
deps: undefined as unknown as ResourceCanvasAssetGenerationQueueDeps,
invoke: undefined as unknown as ReturnType<typeof vi.fn>,
tasks: () => tasks,
settlements,
startCallCount: () =>
harness.invoke.mock.calls.filter(
([command]) => command === 'start_local_project_asset_generation',
).length,
advance: (taskId, status) => {
records.set(
taskId,
record(taskId, status, {
phaseDetail:
status === 'completed' ? '生成已完成。' : '生成失败:远端拒绝',
assetId: status === 'completed' ? `asset-${taskId}` : null,
error: status === 'completed' ? null : '远端拒绝',
finishedAtMillis: 5_000,
}),
);
},
startError,
forget: (taskId) => {
records.delete(taskId);
},
};
harness.invoke = vi.fn(
async (command: string, args: Record<string, unknown>) => {
if (command === 'start_local_project_asset_generation') {
const taskId = String(args.taskId);
const failure = startError.get(taskId);
if (failure) {
throw failure;
}
const created = record(taskId, 'running', { startedAtMillis: 1_200 });
records.set(taskId, created);
return created;
}
if (command === 'list_local_project_asset_generations') {
pollCount += 1;
return [...records.values()];
}
throw new Error(`未预期的命令:${command}`);
},
);
harness.deps = {
invoke: harness.invoke as ResourceCanvasAssetGenerationQueueDeps['invoke'],
projectPath: () => '/tmp/project',
listTasks: () => tasks,
replaceTask: (task) => {
const index = tasks.findIndex((item) => item.taskId === task.taskId);
tasks =
index >= 0
? tasks.map((item) => (item.taskId === task.taskId ? task : item))
: [...tasks, task];
},
onSettled: (settlement) => {
settlements.push(settlement);
},
wait: async () => {
await Promise.resolve();
waitScript?.(pollCount, harness);
},
nowMillis: () => 9_999,
};
return harness;
}
describe('本地排队驱动器', () => {
test('第一条未完成时提交第二条:第二条仍是排队中且生成提交 IPC 只发了一次', async () => {
let assertedMidFlight = false;
const harness = createHarness((pollCount, api) => {
if (pollCount === 2 && !assertedMidFlight) {
assertedMidFlight = true;
// 第一条还在途:第二条必须停在本地队列里。
expect(api.startCallCount()).toBe(1);
const second = api
.tasks()
.find((task) => task.taskId === 'task-b');
expect(second?.status).toBe('queued');
expect(second?.dispatched).toBe(false);
expect(second?.phaseDetail).toBe(
RESOURCE_CANVAS_ASSET_GENERATION_LOCAL_QUEUE_PHASE,
);
api.advance('task-a', 'completed');
}
if (pollCount === 4) {
api.advance('task-b', 'completed');
}
});
const queue = createResourceCanvasAssetGenerationQueue(harness.deps);
const first = queue.submit(localTask('task-a', 10));
const second = queue.submit(localTask('task-b', 20));
await first;
await second;
expect(assertedMidFlight).toBe(true);
// 第一条终态后自动补发第二条:生成提交 IPC 次数 = 2。
expect(harness.startCallCount()).toBe(2);
expect(harness.settlements.map((item) => [item.taskId, item.status])).toEqual(
[
['task-a', 'completed'],
['task-b', 'completed'],
],
);
expect(
harness.tasks().map((task) => [task.taskId, task.status]),
).toEqual([
['task-a', 'completed'],
['task-b', 'completed'],
]);
});
test('第一条失败也会补发第二条,失败原因按后端记录留在任务上', async () => {
const harness = createHarness((pollCount, api) => {
if (pollCount === 1) {
// 第一条已经派发,此刻收口为失败。
api.advance('task-a', 'failed');
}
if (pollCount === 3) {
api.advance('task-b', 'completed');
}
});
const queue = createResourceCanvasAssetGenerationQueue(harness.deps);
const first = queue.submit(localTask('task-a', 10));
const second = queue.submit(localTask('task-b', 20));
await expect(first).rejects.toThrow('远端拒绝');
await expect(second).resolves.toBeUndefined();
expect(harness.startCallCount()).toBe(2);
const failed = harness.tasks().find((task) => task.taskId === 'task-a');
expect(failed?.status).toBe('failed');
expect(failed?.phaseDetail).toBe('生成失败:远端拒绝');
expect(failed?.error).toBe('远端拒绝');
expect(failed?.finishedAtMillis).toBe(5_000);
expect(
harness.tasks().find((task) => task.taskId === 'task-b')?.assetId,
).toBe('asset-task-b');
});
test('账本里找不到这条任务时不会无限轮询,收口为失败并放行后面的排队任务', async () => {
const harness = createHarness((pollCount, api) => {
if (pollCount === 1) {
// 第一条的记录被账本淘汰 / 项目被换掉:从这一轮起它再也查不到。
harness.startError.set('task-a', new Error('never-used'));
api.forget('task-a');
}
if (pollCount === 20) {
api.advance('task-b', 'completed');
}
});
const queue = createResourceCanvasAssetGenerationQueue(harness.deps);
const first = queue.submit(localTask('task-a', 10));
const second = queue.submit(localTask('task-b', 20));
await expect(first).rejects.toThrow('生成任务账本里已找不到这条任务');
await second;
expect(harness.startCallCount()).toBe(2);
});
test('生成提交 IPC 抛错时任务收口为失败,队列不会卡死', async () => {
const harness = createHarness((pollCount, api) => {
if (pollCount === 2) {
api.advance('task-b', 'completed');
}
});
harness.startError.set(
'task-a',
new Error('项目权限策略拒绝执行:canvas.asset_generate'),
);
const queue = createResourceCanvasAssetGenerationQueue(harness.deps);
const first = queue.submit(localTask('task-a', 10));
const second = queue.submit(localTask('task-b', 20));
await expect(first).rejects.toThrow('项目权限策略拒绝执行');
await second;
const failed = harness.tasks().find((task) => task.taskId === 'task-a');
expect(failed?.status).toBe('failed');
expect(failed?.phaseDetail).toBe(
'生成失败:项目权限策略拒绝执行:canvas.asset_generate',
);
// 提交失败的这一条没有终态记录也要能收口,否则第二条永远补发不出去。
expect(harness.startCallCount()).toBe(2);
expect(harness.settlements[0]).toMatchObject({
taskId: 'task-a',
status: 'failed',
record: null,
});
});
});
@@ -0,0 +1,175 @@
// @vitest-environment jsdom
import { cleanup, render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { afterEach, describe, expect, test, vi } from 'vitest';
import { ResourceCanvasAssetGenerationTasksPanelView } from '../src/features/resource-canvas/ResourceCanvasAssetGenerationTasksPanelView';
import {
createResourceCanvasAssetGenerationTask,
type ResourceCanvasAssetGenerationTask,
} from '../src/features/resource-canvas/resourceCanvasAssetGenerationTaskModel';
import type { ResourceCanvasAssetToolAction } from '../src/features/resource-canvas/resourceCanvasBottomToolbarModel';
afterEach(() => {
cleanup();
});
const uiPrototypeAction: ResourceCanvasAssetToolAction = {
id: 'generate-ui-prototype',
route: 'asset',
label: '生成 UI 设计图',
assetKind: 'ui-prototype',
audioKind: null,
assetName: 'AI 生成 UI 设计图',
promptPlaceholder: '描述这张界面要承载的玩法与操作',
adjustableDimensions: true,
aspectRatio: '16:9',
imageSize: '1K',
requiresIconSpecReference: true,
writesIconSpecReference: false,
};
function task(
patch: Partial<ResourceCanvasAssetGenerationTask> & { taskId: string },
): ResourceCanvasAssetGenerationTask {
return {
...createResourceCanvasAssetGenerationTask({
taskId: patch.taskId,
action: uiPrototypeAction,
prompt: '主界面与背包页',
assetName: '主界面设计图',
aspectRatio: '16:9',
imageSize: '1K',
outputPath: null,
projectId: 'project-1',
nowMillis: 1_000,
}),
...patch,
};
}
describe('「生成任务」面板', () => {
test('是非模态面板:没有全屏遮罩,也没有 aria-modal 与焦点陷阱', () => {
const { container } = render(
<ResourceCanvasAssetGenerationTasksPanelView
tasks={[task({ taskId: 't1' })]}
onClose={() => undefined}
onFocusTask={() => undefined}
/>,
);
expect(screen.getByRole('region', { name: '生成任务' })).not.toBeNull();
expect(container.querySelector('[aria-modal="true"]')).toBeNull();
expect(container.querySelector('.fixed.inset-0')).toBeNull();
});
test('状态、阶段文案、已耗时与素材名逐条呈现,阶段文案来自后端记录', async () => {
render(
<ResourceCanvasAssetGenerationTasksPanelView
tasks={[
task({ taskId: 't1', assetName: '主界面设计图' }),
task({
taskId: 't2',
assetName: '背包界面设计图',
dispatched: true,
status: 'running',
// 前端的任何常量都不会产出「正在处理。」,出现它只可能来自后端记录。
phaseDetail: '正在处理。',
startedAtMillis: 2_000,
}),
task({
taskId: 't3',
assetName: '结算界面设计图',
dispatched: true,
status: 'completed',
phaseDetail: '生成已完成。',
assetId: 'asset-3',
finishedAtMillis: 13_000,
}),
task({
taskId: 't4',
assetName: '设置界面设计图',
dispatched: true,
status: 'failed',
phaseDetail: '生成失败:远端拒绝',
error: '远端拒绝',
}),
]}
onClose={() => undefined}
onFocusTask={() => undefined}
/>,
);
expect(screen.getByText('主界面设计图')).not.toBeNull();
expect(screen.getByText('排队中')).not.toBeNull();
expect(screen.getByText('生成中')).not.toBeNull();
expect(screen.getByText('已完成')).not.toBeNull();
expect(screen.getByText('失败')).not.toBeNull();
expect(screen.getByText('正在处理。')).not.toBeNull();
expect(screen.getByText('生成已完成。')).not.toBeNull();
// 每条任务的阶段只渲染后端给的那一句:面板里没有第二个来源,也没有硬编码的假阶段。
const runningRow = screen.getByText('背包界面设计图').closest('li');
expect(runningRow?.textContent).toContain('正在处理。');
expect(runningRow?.textContent).not.toContain('生成中…');
expect(screen.getByRole('alert').textContent).toBe('远端拒绝');
expect(screen.getByText('生成任务').textContent).toBe('生成任务');
const user = userEvent.setup();
// 已耗时的秒表由前端计时,但只在还有未终态任务时走;这里断言它至少渲染了。
expect(
screen.getAllByText(/^已耗时 \d+ (秒|分 \d+ 秒)$/).length,
).toBeGreaterThan(0);
void user;
});
test('只有已完成且拿到资源 id 的任务能定位到素材卡', async () => {
const onFocusTask = vi.fn();
const user = userEvent.setup();
render(
<ResourceCanvasAssetGenerationTasksPanelView
tasks={[
task({
taskId: 't1',
assetName: '主界面设计图',
dispatched: true,
status: 'completed',
phaseDetail: '生成已完成。',
assetId: 'asset-1',
finishedAtMillis: 5_000,
}),
task({
taskId: 't2',
assetName: '背包界面设计图',
dispatched: true,
status: 'running',
phaseDetail: '正在生成。',
}),
]}
onClose={() => undefined}
onFocusTask={onFocusTask}
/>,
);
expect(screen.queryByRole('button', { name: '定位素材 背包界面设计图' })).toBeNull();
await user.click(screen.getByRole('button', { name: '定位素材 主界面设计图' }));
expect(onFocusTask).toHaveBeenCalledTimes(1);
expect(onFocusTask.mock.calls[0]?.[0]).toMatchObject({
taskId: 't1',
assetId: 'asset-1',
});
});
test('没有任务时给出空状态,关闭按钮始终可用', async () => {
const onClose = vi.fn();
const user = userEvent.setup();
render(
<ResourceCanvasAssetGenerationTasksPanelView
tasks={[]}
onClose={onClose}
onFocusTask={() => undefined}
/>,
);
expect(screen.getByRole('status').textContent).toBe('还没有生成任务');
await user.click(screen.getByRole('button', { name: '关闭生成任务' }));
expect(onClose).toHaveBeenCalledTimes(1);
});
});