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);
|
||||
});
|
||||
});
|
||||
@@ -8653,3 +8653,14 @@ CI 上 `background_agent_runtime_recovers_stale_running_before_pending_task` 在
|
||||
- 决策:复用判据收窄为**同一条写调用链(同一线程)重入**——按锁路径登记真实持锁线程,只有当前线程就是持锁线程时才返回 advisory guard;本进程其它线程的争用继续走有界等待与终态占用。自主游戏构建流水线的并行专家动作豁免保持不变;跨进程占用、残留回收、权限分类、等待预算和错误文案不变。
|
||||
- 边界:锁定这些不变量的既有用例(`project_tools` / `command_runtime` / `parallel_actions` / `runtime_state` / `response_stream` / `direct_tool_bridge` / `ui_editor::persistence`)不得为了让锁语义通过而改写;用「同线程自持锁」模拟「另一个写者」的两条用例改为**在另一条线程持锁**,断言语义不变。同进程跨线程重入(持锁链在 `await` / `spawn_blocking` 后于其它线程再取锁)仍会等满预算,出现现场时按 2026-08-27 的既有处置改用 `*_locked` 入口,不放宽判据。
|
||||
- 关联文档:[项目客户端占用锁收敛里程碑](../plans/【里程碑】项目客户端占用锁收敛-2026-09-14.md)、[踩坑记录](pitfalls.md)。
|
||||
|
||||
## 2026-09-14 AGC 图片类生成后台化:提交即返回 + 项目内任务账本 + 本地排队
|
||||
|
||||
- 背景:栏目画布图片类入口原先是一条同步 IPC `generate_local_project_asset`,一次调用最长等 35 分钟;提交期间两块生成浮层把 × / 遮罩 / Esc /「取消」全部锁死,用户既关不掉面板也看不到进度。远端图片类生成共用 single-flight 输出槽(`standalone_platform_art_generation_runtime_context` 按 `outputPath` 派生 `run_id`),并发提交会在远端 POST 之前被拒。
|
||||
- 决策(后台化):新增 `start_local_project_asset_generation`(校验入参 → 落 `queued` 记录 → `tauri::async_runtime::spawn` 派发 → 返回任务记录)与 `list_local_project_asset_generations`(读回账本)。生成本身仍转调既有 `generate_platform_art_asset_with_options_at`(幂等账本、计费、下载校验、manifest/revision 登记、本地预览都不复制),同步命令 `generate_local_project_asset` **保留不动**。
|
||||
- 决策(账本落项目内):`.agent/runtime/asset-generation-tasks/tasks.json`,复用既有 agent runtime sidecar 读写原语(临时文件 + rename);非终态且不在本进程 live 集合里的记录在读取时收口为「上次运行中断」失败,不假装它还在跑;账本上限 50 条按创建时间淘汰。
|
||||
- 决策(阶段文案归后端):`phaseDetail` 由 Rust 拥有(「排队中。」/「正在生成。」/「生成已完成。」/失败原因),前端面板与「生成任务」面板只渲染该字符串,不拼阶段、不做百分比。后端目前没有可播报的中间阶段(生成通道不暴露 job 的远端 `phaseDetail`),所以不伪造「正在处理。」这类前端文案。
|
||||
- 决策(本地排队):第二条提交停在**前端本地队列**(`dispatched=false`,不调用提交 IPC,阶段显示本地排队的「排队中。」),第一条终态后由同一条循环自动补发。判据是「存在 `dispatched && 未终态` 的任务时不派发下一条」——后端 single-flight 拒绝并发,前端排队是它的对应设计而不是重复实现。
|
||||
- 决策(面板可关 + 非模态任务面板):两块生成浮层在提交期间放开 × / 遮罩 / Esc,提交按钮旁给「后台运行并关闭」;**关闭 ≠ 取消**(表单的 `await` 挂在该任务的终局上,不是面板生命周期)。新增「生成任务」非模态浮层(不铺遮罩、不做焦点陷阱、**不进** `isResourceCanvasFloatingPanelOpen` / `resourceCanvasHostGenerationPanelOpen` 遮挡判据),入口按钮 `aria-label="生成任务"`;已完成的条目按 `assetId` 复用既有 `pendingResourceFocusRef` 聚焦链定位素材卡。
|
||||
- 影响范围:新增 `apps/ai-game-creator-shell/src-tauri/src/asset_generation_tasks.rs`(+ `main.rs` 注册)、`src/features/resource-canvas/{resourceCanvasAssetGenerationTaskModel.ts,resourceCanvasAssetGenerationQueue.ts,ResourceCanvasAssetGenerationTasksPanelView.tsx}`;改动 `ResourceCanvasAssetGenerationPanelView.tsx` / `ResourceCanvasGenerationPanelView.tsx` / `src/view/project-development/index.tsx`;测试改动 `tests/{resourceCanvasAssetGenerationBackgroundClose.test.tsx,resourceCanvasAssetGenerationQueue.test.ts,resourceCanvasAssetGenerationTasksPanel.test.tsx}`(新增)与 `tests/appSurface/project-development.suite.ts`(把「每个入口一次 `generate_local_project_asset`」改成 `start_local_project_asset_generation` + `list_...` 轮询桩,载荷断言逐字不变)。**未动**:external v1 / OpenAPI、`packages/`、SpacetimeDB、音频入口的 pending-edit 账本语义、生成参数与 IPC 载荷字段名。
|
||||
- 关联文档:`docs/technical/【AGC】栏目画布底部工具栏入口矩阵-2026-09-13.md`(§4 / §4a / §8)、`docs/technical/【测试用例】AGC资源工作台V3端到端验收-2026-09-11.md`(S11a / §7.3)。
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
|
||||
## 1. 一句话
|
||||
|
||||
功能画布 = 资源栏目页;栏目页左下角渲染一条由栏目 `category` 决定的工具栏,图片类入口走本地 `generate_local_project_asset`,音频入口复用既有无源生成链路,上传复用 `upload_local_asset`;生成 / 上传成功后一律走既有 manifest 刷新与资源定位。
|
||||
功能画布 = 资源栏目页;栏目页左下角渲染一条由栏目 `category` 决定的工具栏,图片类入口走本地 `start_local_project_asset_generation`(提交即返回、后台生成),音频入口复用既有无源生成链路,上传复用 `upload_local_asset`;生成 / 上传成功后一律走既有 manifest 刷新与资源定位。
|
||||
|
||||
## 2. 入口矩阵(事实源)
|
||||
|
||||
@@ -31,20 +31,26 @@
|
||||
|
||||
## 4. 请求载荷映射
|
||||
|
||||
| 入口 | IPC | 载荷要点 |
|
||||
| --------------------- | ------------------------------- | ------------------------------------------------------------------------------------------- |
|
||||
| 生成图片 | `generate_local_project_asset` | `kind: 'image'`,比例 / 尺寸可调,默认 `1:1 · 1K` |
|
||||
| 生成角色形象 | 同上 | `kind: 'character'`,默认 `1:1 · 1K` |
|
||||
| 生成规范 → 图标规范 | 同上 | `kind: 'icon-spec'`,固定 `1:1 · 1K`;缺权威规范图时 `outputPath: 'assets/art-spec.png'` |
|
||||
| 生成规范 → 角色规范 | 同上 | `kind: 'spec'`,固定 `1:1 · 1K` |
|
||||
| 生成规范 → 自定义规范 | 同上 | `kind: 'spec'`,固定 `1:1 · 1K` |
|
||||
| 生成图标素材 | 同上 | `kind: 'art-spritesheet'`,默认 `1:1 · 1K`;前置:已登记 `assets/art-spec.png` |
|
||||
| 生成 UI 设计图 | 同上 | `kind: 'ui-prototype'`,默认 `16:9 · 1K`;前置同上 |
|
||||
| 生成背景音乐 | `derive_local_project_resource` | `editKind: 'background-music'`、`generationMode: 'create'`、`sourceMediaType: 'audio/mpeg'` |
|
||||
| 生成音效 | `derive_local_project_resource` | `editKind: 'sound-effect'`、其余同上 |
|
||||
| 上传 | `upload_local_asset` | `{ projectPath, fileName, mediaType, bytes }` |
|
||||
| 入口 | IPC | 载荷要点 |
|
||||
| --------------------- | --------------------------------- | ------------------------------------------------------------------------------------------- |
|
||||
| 生成图片 | `start_local_project_asset_generation` | `kind: 'image'`,比例 / 尺寸可调,默认 `1:1 · 1K` |
|
||||
| 生成角色形象 | 同上 | `kind: 'character'`,默认 `1:1 · 1K` |
|
||||
| 生成规范 → 图标规范 | 同上 | `kind: 'icon-spec'`,固定 `1:1 · 1K`;缺权威规范图时 `outputPath: 'assets/art-spec.png'` |
|
||||
| 生成规范 → 角色规范 | 同上 | `kind: 'spec'`,固定 `1:1 · 1K` |
|
||||
| 生成规范 → 自定义规范 | 同上 | `kind: 'spec'`,固定 `1:1 · 1K` |
|
||||
| 生成图标素材 | 同上 | `kind: 'art-spritesheet'`,默认 `1:1 · 1K`;前置:已登记 `assets/art-spec.png` |
|
||||
| 生成 UI 设计图 | 同上 | `kind: 'ui-prototype'`,默认 `16:9 · 1K`;前置同上 |
|
||||
| 生成背景音乐 | `derive_local_project_resource` | `editKind: 'background-music'`、`generationMode: 'create'`、`sourceMediaType: 'audio/mpeg'` |
|
||||
| 生成音效 | `derive_local_project_resource` | `editKind: 'sound-effect'`、其余同上 |
|
||||
| 上传 | `upload_local_asset` | `{ projectPath, fileName, mediaType, bytes }` |
|
||||
|
||||
`generate_local_project_asset` 的完整参数是 `{ projectPath, kind, prompt, aspectRatio, imageSize, assetName, outputPath }`(Rust `src-tauri/src/commands.rs` 的 `generate_local_project_asset`,返回 `{ id, localPath, absolutePath, manifestPath }`)。
|
||||
`start_local_project_asset_generation` 的完整参数是 `{ projectPath, projectId, taskId, kind, prompt, aspectRatio, imageSize, assetName, outputPath }`(Rust `src-tauri/src/asset_generation_tasks.rs`),**提交即返回**一条任务记录(`taskId / status / phaseDetail / assetId / error` 等);生成在 `tauri::async_runtime::spawn` 出来的后台任务里跑,账本落在项目内 `.agent/runtime/asset-generation-tasks/tasks.json`,进度由 `list_local_project_asset_generations` 读回。截图面板不再「等生成结束」,所以提交期间 × / 遮罩 / Esc /「后台运行并关闭」都能关面板(关闭 ≠ 取消)。
|
||||
|
||||
## 4a. 本地排队与「生成任务」面板
|
||||
|
||||
- **本地排队**:远端图片类生成共用 single-flight 输出槽(`standalone_platform_art_generation_runtime_context` 按 `outputPath` 派生 `run_id`),并发提交会在远端 POST 之前被拒。所以第二条提交停在**前端本地队列**里(不调用提交 IPC,阶段显示本地排队的「排队中。」),第一条终态后由同一条循环自动补发;判据是「存在 `dispatched && 未终态` 的任务时不派发下一条」。
|
||||
- **阶段文案只来自后端**:派发之后面板与任务列表渲染的都是后端账本给的 `phaseDetail`(「排队中。」/「正在生成。」/「生成已完成。」/失败原因),前端不拼阶段、不做百分比。
|
||||
- **「生成任务」面板是非模态浮层**:不铺全屏遮罩、不做焦点陷阱,**不参与** `isResourceCanvasFloatingPanelOpen` / `resourceCanvasHostGenerationPanelOpen` 的遮挡判据;入口按钮在 `index.tsx` 工具栏区(`aria-label="生成任务"`)。已完成任务可用「定位到素材」复用既有 `pendingResourceFocusRef` 聚焦链。
|
||||
|
||||
## 5. 参数口径与复用程度
|
||||
|
||||
@@ -71,12 +77,18 @@
|
||||
npx vitest run apps/ai-game-creator-shell/tests/appSurface.test.ts
|
||||
npx vitest run apps/ai-game-creator-shell/tests/resourceCanvasBottomToolbar.test.tsx
|
||||
npx vitest run apps/ai-game-creator-shell/tests/resourceCanvasGenerationEntry.test.tsx
|
||||
npx vitest run apps/ai-game-creator-shell/tests/resourceCanvasAssetGenerationBackgroundClose.test.tsx
|
||||
npx vitest run apps/ai-game-creator-shell/tests/resourceCanvasAssetGenerationQueue.test.ts
|
||||
npx vitest run apps/ai-game-creator-shell/tests/resourceCanvasAssetGenerationTasksPanel.test.tsx
|
||||
npx vitest run apps/ai-game-creator-shell/tests/projectResourceLiveIntegration.test.tsx
|
||||
npm run agc:typecheck
|
||||
npm run check:encoding
|
||||
git diff --check
|
||||
cargo test -p genarrative-ai-game-creator-shell asset_generation_task
|
||||
```
|
||||
|
||||
- 模型层:四个栏目的工具集与顺序、未命中栏目返回空、「所有资源」与 `null` 返回空、二级菜单分流、比例 / 尺寸白名单收窄、前置判据、不可用原因、`outputPath` 策略。
|
||||
- 组件层:工具栏渲染与二级菜单开合、不可用入口可点击 + 原因可关闭、上传回调。
|
||||
- AppSurface 层:进入栏目出现工具栏 / 回总览与展开态消失、每个图片类入口的 `generate_local_project_asset` 载荷、前置缺失可点击说明且零请求、音频入口走 `derive_local_project_resource`、上传走 `upload_local_asset` + 配对读清单、工具栏与右下 Dock 的几何契约。
|
||||
- 生成任务层:`resourceCanvasAssetGenerationTaskModel`(状态机 / 在途判据 / 恢复 / 耗时文案)、`resourceCanvasAssetGenerationQueue`(第二条不发提交 IPC、前一条终态后自动补发、失败与账本丢失都能收口)、`ResourceCanvasAssetGenerationTasksPanelView`(非模态、阶段文案来自后端记录、按 `assetId` 定位)、两块生成浮层在提交期间可关闭、「后台运行并关闭」文案。
|
||||
- Rust 层:`asset_generation_tasks` 的账本落项目内、排队阶段文案由后端拥有、进程重启把无人推进的记录收口为中断失败、账本上限与 task id 边界。
|
||||
- AppSurface 层:进入栏目出现工具栏 / 回总览与展开态消失、每个图片类入口的 `start_local_project_asset_generation` 载荷、前置缺失可点击说明且零请求、音频入口走 `derive_local_project_resource`、上传走 `upload_local_asset` + 配对读清单、生成面板关闭后任务仍在「生成任务」面板且第二条按本地排队补发、工具栏与右下 Dock 的几何契约。
|
||||
|
||||
@@ -57,7 +57,7 @@
|
||||
| **S15** 删除 | 工具条 / 标签面板「删除」→ 确认弹窗 | 三分支:① 无引用→直接删;② 被引用未勾选→只删素材、版本保留**悬空绑定**、界面不合成幽灵资源卡;③ 勾选→素材与该批版本在**同一次 manifest 写入**内一起删 | L413(版本只追加的唯一例外)、L532–533;#309 C5 | 弹窗 `ariaLabel="确认删除资源"`;被引用时出现 `被 N 个游戏版本使用` + 版本列表 + checkbox `把相关游戏版本一并删除`;无引用时该 body 整块不渲染 | **只摘登记、不删磁盘文件**;读引用命令精确名是 `read_local_project_asset_references` |
|
||||
| **S15a** 替换素材(含点选替换) | 选中被**当前版本**绑定的素材 → 工具条「替换素材」→ 候选弹窗(弹窗内可筛可选);再点 footer「点选替换」→ 弹窗关闭、进入画布点选态 → 在画布上直接点目标素材 | 弹窗确认与点选是**同一条**写入链路(`replace_local_project_version_resource`,载荷逐字一致):改该版本绑定、不建新版本;点选态下合法目标直接提交并自动退出点选态,非法目标零写入、**留在**点选态并在提示条上说明原因 | PRD §5.3 / §7.8 第 8 条;#309「直接替换」口径 | 点选态判据:候选弹窗 `role="dialog"` 已卸载;画布出现 `.game-resource-canvas-pick-hint`,文案含「在画布上点选要替换成的素材」与「点击空白处不会退出」,并有「取消」按钮;非法目标后提示条仍在且出现 `role="alert"`(「替换素材不兼容:分类不同」/「替换素材与源素材相同」/「替换素材未登记或已被删除」);点选期间卡片 `aria-pressed` 不变(单击只用于点选)、源素材选中与工具条不因点空白或点非法目标而丢;Esc 退出点选(画布全局 Esc 的清选中不随之触发);点选态下滚轮平移与缩放照常可用 | 点选只能点**当前画布上可见**的卡:目标在别的栏目时先切栏目(点选会话跨栏目存活,不因「收起资源」或切栏目结束);空白点击不退出、也不清画布选中 |
|
||||
|
||||
| **S11a** 栏目画布底部工具栏 | 进「UI 交互 / 角色与对象 / 场景与环境 / 音频」任一栏目 → 点左下角工具栏里的入口生成 → 「收起资源」回总览看工具栏消失 | 工具栏只在矩阵四个栏目(功能画布)渲染;图片类入口走 `generate_local_project_asset`,音频入口复用既有无源生成链路,上传复用 `upload_local_asset`;生成 / 上传成功后走既有 manifest 刷新与资源定位 | PRD §3.10、§7.9 | DOM 判据 `[data-resource-bottom-toolbar="<category>"]`(资源总览、「所有资源」展开态、文档、待归类、项目版本都**没有**这个节点);工具栏是 `.game-resource-book-manager` 的直接子节点(`closest('.game-resource-book-scene')` 为 `null`);每个入口一次 `generate_local_project_asset`,载荷逐字为 `{ projectPath, kind, prompt, aspectRatio, imageSize, assetName, outputPath }`(`kind` 映射见 PRD §7.9 第 3 条);写入命令之后有 `get_local_game_project_revision` + `get_local_game_manifest` 的配对读;缺 `assets/art-spec.png` 时「生成图标素材 / 生成 UI 设计图」仍可点击(`aria-disabled="true"` 但**不是**原生 disabled)并给出含该路径的 `role="alert"` 原因、零生成请求;音频入口面板标题即「生成背景音乐」/「生成音效」且没有类型选择器 | ① 本地通道没有 `model` / `specType` / `replaceExisting` 入参:面板不渲染模型选择器,角色规范与自定义规范共用 `spec` 通道(靠 `assetName` 与提示词区分),「图标规范」在项目已有权威规范图时不再指向 `assets/art-spec.png`(否则会被 Rust 的防覆盖校验硬拒);② 本轮不做生成视频 / 宣发素材 / 生成游戏场景 / 选择工具 / 抓手工具;③ 既有「生成素材」浮层入口只保留生成视频,音频入口只在音频栏目工具栏出现 |
|
||||
| **S11a** 栏目画布底部工具栏 | 进「UI 交互 / 角色与对象 / 场景与环境 / 音频」任一栏目 → 点左下角工具栏里的入口生成 → 「收起资源」回总览看工具栏消失 | 工具栏只在矩阵四个栏目(功能画布)渲染;图片类入口走 `start_local_project_asset_generation`(提交即返回、生成在后台跑),音频入口复用既有无源生成链路,上传复用 `upload_local_asset`;生成 / 上传成功后走既有 manifest 刷新与资源定位 | PRD §3.10、§7.9 | DOM 判据 `[data-resource-bottom-toolbar="<category>"]`(资源总览、「所有资源」展开态、文档、待归类、项目版本都**没有**这个节点);工具栏是 `.game-resource-book-manager` 的直接子节点(`closest('.game-resource-book-scene')` 为 `null`);每个入口一次 `start_local_project_asset_generation`,载荷逐字为 `{ projectPath, projectId, taskId, kind, prompt, aspectRatio, imageSize, assetName, outputPath }`(`kind` 映射见 PRD §7.9 第 3 条;`taskId` 是前端每次提交新铸的本地任务 id);该命令提交即返回,之后有 `list_local_project_asset_generations` 轮询与 `get_local_game_project_revision` + `get_local_game_manifest` 的配对读;提交期间点 × / 遮罩 / Esc /「后台运行并关闭」任一都能关面板且请求继续(画布立即恢复可交互),关闭后任务仍出现在「生成任务」面板(入口按钮 `aria-label="生成任务"`,非模态浮层、无 `aria-modal`)并显示后端 `phaseDetail`;第一条未终态时提交第二条 → 第二条显示「排队中。」且生成提交 IPC 次数仍为 1,第一条终态后自动补发(次数变 2);缺 `assets/art-spec.png` 时「生成图标素材 / 生成 UI 设计图」仍可点击(`aria-disabled="true"` 但**不是**原生 disabled)并给出含该路径的 `role="alert"` 原因、零生成请求;音频入口面板标题即「生成背景音乐」/「生成音效」且没有类型选择器 | ① 本地通道没有 `model` / `specType` / `replaceExisting` 入参:面板不渲染模型选择器,角色规范与自定义规范共用 `spec` 通道(靠 `assetName` 与提示词区分),「图标规范」在项目已有权威规范图时不再指向 `assets/art-spec.png`(否则会被 Rust 的防覆盖校验硬拒);② 本轮不做生成视频 / 宣发素材 / 生成游戏场景 / 选择工具 / 抓手工具;③ 既有「生成素材」浮层入口只保留生成视频,音频入口只在音频栏目工具栏出现 |
|
||||
|
||||
### D 阶段 · 版本与运行
|
||||
|
||||
@@ -238,7 +238,7 @@ api-server 是否本次重启:□ 是 □ 否
|
||||
### 7.3 已知未做 / 已取消(不要报成缺陷)
|
||||
|
||||
1. **C6 的候选素材 / 替换关系 / 替换队列 / Agent 审核 / 批量提交整条取消**(#309)。PRD §5.3 的 `ProjectVersionResourceReplacement` 与 §3.2 的「创建下一迭代版本」**当前不实现**(2026-09-11 用户 DDL 口径「替换这块先做成直接替换」),但**保留为未来合同**:今天的实现是「改该版本的绑定,不建新版本」,验收见 PRD §7.8。
|
||||
2. **画布生成入口分为两条**(2026-09-13 起):既有「生成素材」浮层入口只保留视频;音频(背景音乐 / 音效)与图片类(生成图片 / 生成规范 / 生成角色形象 / 生成图标素材 / 生成 UI 设计图)改由栏目画布左下角的底部工具栏承载,走本地 `generate_local_project_asset`。工具栏之外的其它无源生成类型仍会直接报"当前资源类型不支持无源生成",这是设计而非缺陷。见 PRD §3.10 / §7.9 与下面的 S11a。
|
||||
2. **画布生成入口分为两条**(2026-09-13 起):既有「生成素材」浮层入口只保留视频;音频(背景音乐 / 音效)与图片类(生成图片 / 生成规范 / 生成角色形象 / 生成图标素材 / 生成 UI 设计图)改由栏目画布左下角的底部工具栏承载,走本地 `start_local_project_asset_generation`(提交即返回、生成在后台跑完写回项目,任务账本在项目内 `.agent/runtime/asset-generation-tasks/`)。工具栏之外的其它无源生成类型仍会直接报"当前资源类型不支持无源生成",这是设计而非缺陷。见 PRD §3.10 / §7.9 与下面的 S11a。
|
||||
3. **工具条 7 个动作按 opt-in 不渲染**:重绘 / 裁剪扩图 / 去背景 / 像素完美 / 切图集 / 提取 UI 素材 / 角色动画;只有 `快速编辑` 与 `下载` 真渲染(宿主另附加 `UI 编辑器` / `编辑标签` / `重命名`,以及**只在素材被当前版本绑定时**出现的 `替换素材`)。
|
||||
4. **C7 只做记录层 + UI 层**:切换版本即重载当前预览;版本化资源解析机制未实现。资源替换同样不做运行时资源重映射:改绑定只改「这个版本用哪些素材」的记录,运行画面要按游戏自身引用的资源路径渲染。
|
||||
5. **「首轮进度投影」无编码级判据**,可见性由底部 Agent 状态栏承载。
|
||||
|
||||
Reference in New Issue
Block a user