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:
@@ -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,
|
||||
|
||||
+14
-11
@@ -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>
|
||||
|
||||
+132
@@ -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>
|
||||
);
|
||||
}
|
||||
+8
-12
@@ -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}>
|
||||
|
||||
+272
@@ -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);
|
||||
}
|
||||
+292
@@ -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',
|
||||
|
||||
+187
@@ -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);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user