Merge branch 'feat/agc-generation-task-progress' into fix/agc-canvas-acceptance-batch

This commit is contained in:
2026-09-14 17:31:32 +08:00
17 changed files with 2591 additions and 129 deletions
@@ -111,6 +111,9 @@ const allowedUncalledTauriCommands = [
'chat_with_game_creator_agent',
'check_ui_editor_font_glyph_coverage',
'create_ui_design_resource',
// 图片类生成的同步变体:GUI 已改为 `start_local_project_asset_generation` + 项目内任务账本
// (提交即返回、后台生成),这条命令保留给 Rust 侧与既有单测,不再有前端调用方。
'generate_local_project_asset',
'open_game_creator_launcher_window',
'open_game_creator_workspace_window',
'read_direct_project_conversation',
File diff suppressed because it is too large Load Diff
@@ -244,6 +244,7 @@ macro_rules! app_log {
// 用 #[cfg] 编译期门控:仅开发(debug)且非测试构建编入;生产 release 与 cargo test 下整体剔除。
mod agent;
mod agent_native_tools;
mod asset_generation_tasks;
mod assets;
mod browser;
mod builtin_plugins;
@@ -289,6 +290,7 @@ mod windows;
use agent::*;
use agent_native_tools::*;
use asset_generation_tasks::*;
use assets::*;
use browser::*;
use cli::*;
@@ -2697,6 +2699,8 @@ fn main() {
ensure_ui_design_resource_for_prototype,
generate_platform_art_asset,
generate_local_project_asset,
start_local_project_asset_generation,
list_local_project_asset_generations,
open_canvas_project,
get_game_creation_agent_capabilities,
get_limited_local_commands,
@@ -24,6 +24,12 @@ export type ResourceCanvasAssetGenerationSubmitInput = {
export type ResourceCanvasAssetGenerationPanelViewProps = {
action: ResourceCanvasAssetToolAction;
/**
* 在途阶段文案,**由后端任务账本提供**(`phaseDetail`)。
*
* 面板不自己编进度:派发前是本地排队的「排队中。」,派发后是后端的「正在生成。」之类。
*/
phaseDetail?: string | null;
onSubmit: (input: ResourceCanvasAssetGenerationSubmitInput) => Promise<void>;
onClose: () => void;
};
@@ -41,6 +47,10 @@ function assetGenerationErrorMessage(error: unknown) {
* 形态是独立弹层(`ThemedModal`,与既有「生成素材」面板同一套宿主 chrome),不在任何现有
* 面板下面追加内容;提交链路与结果定位由宿主 `index.tsx` 负责,面板只持有草稿与失败状态。
*
* 生成已经在后端后台跑(`start_local_project_asset_generation` + 项目内任务账本),所以
* **提交期间面板必须能关**:× / 遮罩 / Esc / 「后台运行并关闭」四条路径都通,关闭只是把这一份
* view 卸下来,请求继续在后台跑完并把结果写回项目——关闭**不等于**取消。
*
* 比例 / 尺寸选项来自网页端美术画布的纯模型(`ImageCanvasGenerationModel.ts`)经本地 IPC
* 白名单收窄后的子集:网页端面板会渲染 `4:3`,而本地通道明确拒绝它,照搬就是一个点了必
* 失败的选项。模型档位不由本面板提供——本地 IPC 没有 `model` 入参,渲染一个改不了请求的
@@ -48,6 +58,7 @@ function assetGenerationErrorMessage(error: unknown) {
*/
export function ResourceCanvasAssetGenerationPanelView({
action,
phaseDetail,
onSubmit,
onClose,
}: ResourceCanvasAssetGenerationPanelViewProps) {
@@ -92,13 +103,7 @@ export function ResourceCanvasAssetGenerationPanelView({
<ThemedModal
open
ariaLabel={action.label}
closeOnBackdrop={!submitting}
closeOnEscape={!submitting}
onClose={() => {
if (!submitting) {
onClose();
}
}}
onClose={onClose}
panelClassName="game-approval-dialog game-resource-generation-dialog"
>
<header>
@@ -108,7 +113,6 @@ export function ResourceCanvasAssetGenerationPanelView({
<button
type="button"
aria-label={`关闭${action.label}`}
disabled={submitting}
onClick={onClose}
>
<X size={16} aria-hidden="true" />
@@ -194,14 +198,13 @@ export function ResourceCanvasAssetGenerationPanelView({
<PlatformActionButton
type="button"
tone="secondary"
disabled={submitting}
onClick={onClose}
>
{submitting ? '后台运行并关闭' : '取消'}
</PlatformActionButton>
<PlatformActionButton type="submit" disabled={!canSubmit}>
<Sparkles size={15} aria-hidden="true" />
{submitting ? '生成中…' : action.label}
{submitting ? (phaseDetail ?? '提交中…') : action.label}
</PlatformActionButton>
</div>
</form>
@@ -0,0 +1,132 @@
import { X } from 'lucide-react';
import { useEffect, useMemo, useState } from 'react';
import {
RESOURCE_CANVAS_ASSET_GENERATION_STATUS_LABELS,
type ResourceCanvasAssetGenerationTask,
resourceCanvasAssetGenerationElapsedLabel,
resourceCanvasAssetGenerationTaskElapsedMillis,
resourceCanvasAssetGenerationTaskIsTerminal,
sortResourceCanvasAssetGenerationTasks,
} from './resourceCanvasAssetGenerationTaskModel';
export type ResourceCanvasAssetGenerationTasksPanelViewProps = {
tasks: readonly ResourceCanvasAssetGenerationTask[];
onClose: () => void;
/** 定位到该任务产出的素材卡(宿主复用既有 `pendingResourceFocusRef` 聚焦链)。 */
onFocusTask: (task: ResourceCanvasAssetGenerationTask) => void;
};
/**
* 「生成任务」浮层面板:列出每条图片类生成任务的状态、后端阶段文案、已耗时与素材名。
*
* **它是非模态的**:不铺全屏遮罩、不做焦点陷阱、不进 `isResourceCanvasFloatingPanelOpen` 的
* 遮挡判据——生成在后台跑,面板开着的时候画布必须照样能看能用。这也是它和两块生成浮层
* (提交表单,`ThemedModal` 模态)的关键差别。
*
* 阶段文案直接渲染后端账本给的 `phaseDetail`,面板不自己拼阶段、不做百分比进度。
*/
export function ResourceCanvasAssetGenerationTasksPanelView({
tasks,
onClose,
onFocusTask,
}: ResourceCanvasAssetGenerationTasksPanelViewProps) {
const [nowMillis, setNowMillis] = useState(() => Date.now());
const hasLiveTask = tasks.some(
(task) => !resourceCanvasAssetGenerationTaskIsTerminal(task),
);
const ordered = useMemo(
() => sortResourceCanvasAssetGenerationTasks(tasks),
[tasks],
);
// 已耗时是前端计时(后端只给时间戳):只在还有未终态任务时走秒表,全部收口后停掉。
useEffect(() => {
if (!hasLiveTask) {
return undefined;
}
const timer = setInterval(() => setNowMillis(Date.now()), 1_000);
return () => clearInterval(timer);
}, [hasLiveTask]);
return (
<section
className="platform-theme platform-theme--light fixed inset-x-3 bottom-20 z-40 max-h-[60vh] overflow-auto rounded-2xl border border-black/10 p-3 shadow-lg sm:inset-x-auto sm:right-4 sm:w-96"
style={{
background: 'var(--platform-subpanel-fill)',
color: 'var(--platform-text-strong)',
}}
role="region"
aria-label="生成任务"
>
<header className="flex items-center justify-between gap-2">
<h2 className="text-sm font-black"></h2>
<button
type="button"
className="grid h-7 w-7 place-items-center rounded-full"
aria-label="关闭生成任务"
onClick={onClose}
>
<X size={16} aria-hidden="true" />
</button>
</header>
{ordered.length === 0 ? (
<p className="mt-2 text-xs" role="status">
</p>
) : (
<ul className="mt-2 flex flex-col gap-2">
{ordered.map((task) => {
const focusable =
task.status === 'completed' && Boolean(task.assetId);
return (
<li
key={task.taskId}
className="rounded-xl px-2 py-2"
style={{ background: 'var(--platform-panel-fill)' }}
>
<div className="flex items-start justify-between gap-2">
<strong className="text-xs font-bold">
{task.assetName}
</strong>
<span className="text-[11px] opacity-70">
{task.actionLabel}
</span>
</div>
<div className="mt-1 flex flex-wrap items-center gap-x-2 gap-y-1 text-[11px]">
<span className="rounded-full border px-2 py-0.5">
{RESOURCE_CANVAS_ASSET_GENERATION_STATUS_LABELS[task.status]}
</span>
<span>{task.phaseDetail}</span>
<span className="opacity-70">
{`已耗时 ${resourceCanvasAssetGenerationElapsedLabel(
resourceCanvasAssetGenerationTaskElapsedMillis(
task,
nowMillis,
),
)}`}
</span>
</div>
{task.error ? (
<p className="mt-1 text-[11px]" role="alert">
{task.error}
</p>
) : null}
{focusable ? (
<button
type="button"
className="mt-1 text-[11px] underline"
aria-label={`定位素材 ${task.assetName}`}
onClick={() => onFocusTask(task)}
>
</button>
) : null}
</li>
);
})}
</ul>
)}
</section>
);
}
@@ -58,6 +58,10 @@ function resourceGenerationErrorMessage(error: unknown) {
* 形态是独立弹层(`ThemedModal`,与资源面板 / 分类面板同一套宿主 chrome),
* 不在任何现有面板下面追加内容;提交链路与结果定位由宿主 `index.tsx` 负责,
* 面板只持有草稿、类型选择与失败重试状态。
*
* 提交期间**不锁关闭**:× / 遮罩 / Esc / 「后台运行并关闭」四条路径都通。关闭只是把这一份
* view 卸下来,宿主那条请求继续跑(它是宿主的 `await onSubmit(...)`,不挂在面板生命周期上),
* 所以关闭**不等于**取消;失败时面板仍保留草稿与同一份请求身份可重试。
*/
export function ResourceCanvasGenerationPanelView({
kinds = RESOURCE_CANVAS_GENERATION_OPTIONS.map((option) => option.kind),
@@ -115,8 +119,8 @@ export function ResourceCanvasGenerationPanelView({
} catch (submitError) {
setError(resourceGenerationErrorMessage(submitError));
} finally {
// 成功路径也要收回在飞标记:宿主现在还靠卸载面板兜底,但组件本身不该
// 在 `onSubmit` 正常 resolve 后永久停在「生成中…」并把关闭路径全部锁住
// 成功路径也要收回在飞标记:宿主随后会卸载面板,但组件本身不该在 `onSubmit` 正常
// resolve 后永久停在「生成中…」;失败时收回标记才能让用户用同一份输入重试
setSubmitting(false);
}
}
@@ -125,13 +129,7 @@ export function ResourceCanvasGenerationPanelView({
<ThemedModal
open
ariaLabel={panelTitle}
closeOnBackdrop={!submitting}
closeOnEscape={!submitting}
onClose={() => {
if (!submitting) {
onClose();
}
}}
onClose={onClose}
panelClassName="game-approval-dialog game-resource-generation-dialog"
>
<header>
@@ -141,7 +139,6 @@ export function ResourceCanvasGenerationPanelView({
<button
type="button"
aria-label={`关闭${panelTitle}`}
disabled={submitting}
onClick={onClose}
>
<X size={16} aria-hidden="true" />
@@ -203,10 +200,9 @@ export function ResourceCanvasGenerationPanelView({
<PlatformActionButton
type="button"
tone="secondary"
disabled={submitting}
onClick={onClose}
>
{submitting ? '后台运行并关闭' : '取消'}
</PlatformActionButton>
{error ? (
<PlatformActionButton type="submit" disabled={submitting}>
@@ -0,0 +1,272 @@
import {
applyLocalProjectAssetGenerationRecords,
type LocalProjectAssetGenerationTaskRecord,
mergeLocalProjectAssetGenerationRecord,
nextResourceCanvasAssetGenerationDispatch,
type ResourceCanvasAssetGenerationTask,
} from './resourceCanvasAssetGenerationTaskModel';
export const RESOURCE_CANVAS_ASSET_GENERATION_POLL_INTERVAL_MILLIS = 2_000;
/**
* 账本里连续多少次找不到这条任务就放弃等待。
*
* 非终态记录一直存在时不能设上限(图片类生成最长 35 分钟),但记录**消失**是完全另一回事
* (账本被删、项目被换掉),继续轮询只会永远转下去。
*/
export const RESOURCE_CANVAS_ASSET_GENERATION_MISSING_RECORD_POLL_LIMIT = 15;
/**
* 一次提交的终局。
*
* `record` 是后端账本里那条终态记录;任务连后端都没进去(IPC 抛错、后端拒绝入参)时为
* `null`,此时 `error` 带着原因。
*/
export type ResourceCanvasAssetGenerationSettlement = {
taskId: string;
/**
* 该任务所属项目。
*
* 宿主必须按它判断「这条终局还属不属于当前打开的项目」:任务可以跨项目切换收尾,拿当前项目
* 的路径去刷新另一个项目的清单是错的。
*/
projectId: string;
status: 'completed' | 'failed';
record: LocalProjectAssetGenerationTaskRecord | null;
error: string | null;
};
export type ResourceCanvasAssetGenerationQueueDeps = {
invoke(command: string, args: Record<string, unknown>): Promise<unknown>;
/**
* 当前项目路径。
*
* 队列实例要跨渲染保持同一份(`draining` 是它的内部状态),所以路径不能靠闭包捕获,
* 只能在派发那一刻从宿主当前值读。
*/
projectPath(): string;
/** 提交前的平台会话刷新(与既有生成入口同一条前置动作)。 */
refreshPlatformSession?: <T>(operation: () => Promise<T>) => Promise<T>;
listTasks(): readonly ResourceCanvasAssetGenerationTask[];
replaceTask(task: ResourceCanvasAssetGenerationTask): void;
onSettled(
settlement: ResourceCanvasAssetGenerationSettlement,
): void | Promise<void>;
pollIntervalMillis?: number;
wait?: (millis: number) => Promise<void>;
nowMillis?: () => number;
};
export type ResourceCanvasAssetGenerationQueue = {
/**
* 入队并推进。
*
* resolve = 该任务已终态(成功);reject = 失败(含后端拒绝入参)。面板据此保留草稿可重试。
* 排在别的在途任务后面的提交会先在本地队列里等,**不发 IPC**(远端图片类生成共用
* single-flight 输出槽,并发提交会在远端 POST 之前被拒)。
*/
submit(task: ResourceCanvasAssetGenerationTask): Promise<void>;
/** 推进已有队列(宿主挂载 / 重开项目恢复任务时调一次)。 */
drain(): void;
};
function errorMessage(error: unknown) {
if (typeof error === 'string' && error.trim()) return error;
if (error instanceof Error && error.message) return error.message;
return '生成素材失败';
}
function isTerminalStatus(status: string) {
return status === 'completed' || status === 'failed';
}
type SettlementWaiter = {
resolve: () => void;
reject: (error: Error) => void;
};
/**
* 本地排队 + 后端任务账本的驱动器。
*
* 顺序上只有一条规则:**下一条必须等上一条终态**;上一条结束(成功或失败)后由同一个循环
* 自动补发。派发之后不再由前端猜进度:状态与阶段文案一律来自
* `list_local_project_asset_generations` 返回的后端记录。
*/
export function createResourceCanvasAssetGenerationQueue(
deps: ResourceCanvasAssetGenerationQueueDeps,
): ResourceCanvasAssetGenerationQueue {
const pollIntervalMillis =
deps.pollIntervalMillis ??
RESOURCE_CANVAS_ASSET_GENERATION_POLL_INTERVAL_MILLIS;
const wait =
deps.wait ??
((millis: number) =>
new Promise<void>((resolve) => {
setTimeout(resolve, millis);
}));
const nowMillis = deps.nowMillis ?? (() => Date.now());
const waiters = new Map<string, SettlementWaiter[]>();
let draining = false;
function settleWaiters(settlement: ResourceCanvasAssetGenerationSettlement) {
const pending = waiters.get(settlement.taskId);
if (!pending) {
return;
}
waiters.delete(settlement.taskId);
for (const waiter of pending) {
if (settlement.status === 'completed') {
waiter.resolve();
} else {
waiter.reject(new Error(settlement.error ?? '生成素材失败'));
}
}
}
function replaceFromRecord(
task: ResourceCanvasAssetGenerationTask,
record: LocalProjectAssetGenerationTaskRecord,
) {
deps.replaceTask(mergeLocalProjectAssetGenerationRecord(task, record));
}
async function dispatch(
task: ResourceCanvasAssetGenerationTask,
): Promise<ResourceCanvasAssetGenerationSettlement> {
const projectPath = deps.projectPath();
// 命令名写成字面量:`scripts/check-config.mjs` 的 invoke 门禁按字符串字面量登记调用方,
// 抽成常量会让这两条 IPC 被判成「没有前端调用方」。
const start = async () =>
(await deps.invoke('start_local_project_asset_generation', {
projectPath,
projectId: task.projectId,
taskId: task.taskId,
kind: task.assetKind,
prompt: task.prompt,
aspectRatio: task.aspectRatio,
imageSize: task.imageSize,
assetName: task.assetName,
outputPath: task.outputPath,
})) as LocalProjectAssetGenerationTaskRecord;
let started: LocalProjectAssetGenerationTaskRecord;
try {
started = await (deps.refreshPlatformSession
? deps.refreshPlatformSession(start)
: start());
} catch (error) {
return {
taskId: task.taskId,
projectId: task.projectId,
status: 'failed',
record: null,
error: errorMessage(error),
};
}
replaceFromRecord(task, started);
let missingRecordPolls = 0;
for (;;) {
const records = (await deps.invoke(
'list_local_project_asset_generations',
{ projectPath },
)) as LocalProjectAssetGenerationTaskRecord[];
const record = records.find((item) => item.taskId === task.taskId);
if (record) {
missingRecordPolls = 0;
replaceFromRecord(task, record);
if (isTerminalStatus(record.status)) {
return {
taskId: task.taskId,
projectId: task.projectId,
status: record.status === 'completed' ? 'completed' : 'failed',
record,
error: record.error,
};
}
} else {
missingRecordPolls += 1;
if (
missingRecordPolls >=
RESOURCE_CANVAS_ASSET_GENERATION_MISSING_RECORD_POLL_LIMIT
) {
return {
taskId: task.taskId,
projectId: task.projectId,
status: 'failed',
record: null,
error: '生成任务账本里已找不到这条任务,已停止等待',
};
}
}
await wait(pollIntervalMillis);
}
}
function markSettled(
settlement: ResourceCanvasAssetGenerationSettlement,
) {
const settled = deps
.listTasks()
.find((task) => task.taskId === settlement.taskId);
if (!settled || isTerminalStatus(settled.status)) {
return;
}
// 连后端都没进去的任务(record === null)也必须收口为终态:留在「已派发但未终态」
// 会让本地队列认为还有在途任务,后面的排队任务永远补发不出去。
deps.replaceTask({
...settled,
status: settlement.status,
phaseDetail:
settlement.record?.phaseDetail ??
`生成失败:${settlement.error ?? '未知原因'}`,
error: settlement.error,
finishedAtMillis: settlement.record?.finishedAtMillis ?? nowMillis(),
});
}
function drain(): void {
if (draining) {
return;
}
draining = true;
void (async () => {
try {
for (;;) {
const next = nextResourceCanvasAssetGenerationDispatch(
deps.listTasks(),
);
if (!next) {
return;
}
deps.replaceTask({ ...next, dispatched: true });
const settlement = await dispatch(next);
markSettled(settlement);
settleWaiters(settlement);
await deps.onSettled(settlement);
}
} finally {
draining = false;
}
})();
}
return {
submit(task) {
deps.replaceTask(task);
const promise = new Promise<void>((resolve, reject) => {
const pending = waiters.get(task.taskId) ?? [];
waiters.set(task.taskId, [...pending, { resolve, reject }]);
});
drain();
return promise;
},
drain,
};
}
/** 用后端记录刷新整个任务列表(重开项目后恢复历史任务时用)。 */
export function mergeResourceCanvasAssetGenerationTasksWithRecords(
tasks: readonly ResourceCanvasAssetGenerationTask[],
records: readonly LocalProjectAssetGenerationTaskRecord[],
): ResourceCanvasAssetGenerationTask[] {
return applyLocalProjectAssetGenerationRecords(tasks, records);
}
@@ -0,0 +1,292 @@
import {
resolveResourceCanvasBottomTools,
resourceCanvasBottomToolActions,
type ResourceCanvasAssetToolAction,
} from './resourceCanvasBottomToolbarModel';
/** 一条生成任务在宿主里的状态。`queued` 包含「本地排队」和「后端排队」两种来源。 */
export type ResourceCanvasAssetGenerationTaskStatus =
| 'queued'
| 'running'
| 'completed'
| 'failed';
/**
* Rust 账本里的一条记录(`list_local_project_asset_generations` 的元素)。
*
* `phaseDetail` **由后端拥有**:前端只渲染这个字符串,不自己拼阶段或造百分比进度。
*/
export type LocalProjectAssetGenerationTaskRecord = {
taskId: string;
projectId: string;
kind: string;
assetName: string;
status: string;
phaseDetail: string;
createdAtMillis: number;
startedAtMillis: number | null;
finishedAtMillis: number | null;
assetId: string | null;
error: string | null;
};
/** 宿主「生成任务」列表里的一条:本地排队信息 + 后端账本记录。 */
export type ResourceCanvasAssetGenerationTask = {
/** 本地任务 id,同时作为提交给后端的 taskId(重开项目后靠它对上账本记录)。 */
taskId: string;
actionId: string;
actionLabel: string;
assetKind: string;
assetName: string;
prompt: string;
aspectRatio: string;
imageSize: string;
outputPath: string | null;
projectId: string;
/** 是否已经把这次提交交给后端。未派发的任务只活在本地队列里。 */
dispatched: boolean;
status: ResourceCanvasAssetGenerationTaskStatus;
phaseDetail: string;
createdAtMillis: number;
startedAtMillis: number | null;
finishedAtMillis: number | null;
assetId: string | null;
error: string | null;
/** 从账本恢复出来的历史任务(本地没有对应的草稿)。 */
restored: boolean;
};
export const RESOURCE_CANVAS_ASSET_GENERATION_STATUS_LABELS: Record<
ResourceCanvasAssetGenerationTaskStatus,
string
> = {
queued: '排队中',
running: '生成中',
completed: '已完成',
failed: '失败',
};
/**
* 本地排队的阶段文案。
*
* 只有**还没交给后端**的那条任务会用到它:单值槽换成任务列表后,第二条提交在前一条终态
* 之前不发 IPC(见 `nextResourceCanvasAssetGenerationDispatch`),所以它在后端账本里不
* 存在,没有后端阶段可渲染。一旦派发,阶段文案一律改由后端 `phaseDetail` 提供。
*/
export const RESOURCE_CANVAS_ASSET_GENERATION_LOCAL_QUEUE_PHASE = '排队中。';
const RESOURCE_CANVAS_ASSET_GENERATION_CATEGORIES = [
'ui-interaction',
'character',
'scene',
] as const;
/**
* 图片类生成 kind → 入口文案。
*
* 从工具栏模型派生而不是另抄一张表:kind 白名单只有一份(`resourceCanvasBottomToolbarModel`),
* 抄第二份就会在加 kind 时漂移。同一 kind 出现在多个入口时取首次出现的那个。
*/
export function resourceCanvasAssetGenerationKindLabel(
kind: string,
): string | null {
for (const category of RESOURCE_CANVAS_ASSET_GENERATION_CATEGORIES) {
for (const tool of resolveResourceCanvasBottomTools(category)) {
for (const action of resourceCanvasBottomToolActions(tool)) {
if (action.route === 'asset' && action.assetKind === kind) {
return action.label;
}
}
}
}
return null;
}
export function resolveResourceCanvasAssetGenerationTaskStatus(
status: string,
): ResourceCanvasAssetGenerationTaskStatus {
switch (status) {
case 'queued':
case 'running':
case 'completed':
return status;
default:
return 'failed';
}
}
export function resourceCanvasAssetGenerationTaskIsTerminal(
task: Pick<ResourceCanvasAssetGenerationTask, 'status'>,
): boolean {
return task.status === 'completed' || task.status === 'failed';
}
/** 新提交的任务:先本地排队,派发之前不进后端账本。 */
export function createResourceCanvasAssetGenerationTask(input: {
taskId: string;
action: ResourceCanvasAssetToolAction;
prompt: string;
assetName: string;
aspectRatio: string;
imageSize: string;
outputPath: string | null;
projectId: string;
nowMillis: number;
}): ResourceCanvasAssetGenerationTask {
return {
taskId: input.taskId,
actionId: input.action.id,
actionLabel: input.action.label,
assetKind: input.action.assetKind,
assetName: input.assetName,
prompt: input.prompt,
aspectRatio: input.aspectRatio,
imageSize: input.imageSize,
outputPath: input.outputPath,
projectId: input.projectId,
dispatched: false,
status: 'queued',
phaseDetail: RESOURCE_CANVAS_ASSET_GENERATION_LOCAL_QUEUE_PHASE,
createdAtMillis: input.nowMillis,
startedAtMillis: null,
finishedAtMillis: null,
assetId: null,
error: null,
restored: false,
};
}
const RESOURCE_CANVAS_ASSET_GENERATION_TASK_LIMIT = 50;
/** 账本记录 → 任务列表里的一条(重开项目后恢复显示)。 */
export function restoreResourceCanvasAssetGenerationTask(
record: LocalProjectAssetGenerationTaskRecord,
): ResourceCanvasAssetGenerationTask {
return {
taskId: record.taskId,
actionId: `restored:${record.kind}`,
actionLabel:
resourceCanvasAssetGenerationKindLabel(record.kind) ?? record.assetName,
assetKind: record.kind,
assetName: record.assetName,
prompt: '',
aspectRatio: '',
imageSize: '',
outputPath: null,
projectId: record.projectId,
dispatched: true,
status: resolveResourceCanvasAssetGenerationTaskStatus(record.status),
phaseDetail: record.phaseDetail,
createdAtMillis: record.createdAtMillis,
startedAtMillis: record.startedAtMillis,
finishedAtMillis: record.finishedAtMillis,
assetId: record.assetId,
error: record.error,
restored: true,
};
}
/**
* 本地任务 + 后端记录 → 下一版任务列表。
*
* 两个方向都要覆盖:本地任务用后端记录刷新状态与阶段;后端有、本地没有的记录(重开项目、
* 或本地列表被清空)恢复成一条历史任务。后端不再返回的本地任务保持原样——记录被账本上限
* 淘汰不该让前端把一条已完成任务抹掉。
*/
export function applyLocalProjectAssetGenerationRecords(
tasks: readonly ResourceCanvasAssetGenerationTask[],
records: readonly LocalProjectAssetGenerationTaskRecord[],
): ResourceCanvasAssetGenerationTask[] {
const recordsByTaskId = new Map(
records.map((record) => [record.taskId, record]),
);
const merged = tasks.map((task) => {
const record = recordsByTaskId.get(task.taskId);
return record ? mergeLocalProjectAssetGenerationRecord(task, record) : task;
});
const knownTaskIds = new Set(tasks.map((task) => task.taskId));
const restored = records
.filter((record) => !knownTaskIds.has(record.taskId))
.map(restoreResourceCanvasAssetGenerationTask);
if (restored.length === 0) {
return merged;
}
return [...merged, ...restored].slice(
-RESOURCE_CANVAS_ASSET_GENERATION_TASK_LIMIT,
);
}
/** 用后端记录刷新一条本地任务:状态、阶段、时间戳、资源 id 与失败原因都以后端为准。 */
export function mergeLocalProjectAssetGenerationRecord(
task: ResourceCanvasAssetGenerationTask,
record: LocalProjectAssetGenerationTaskRecord,
): ResourceCanvasAssetGenerationTask {
return {
...task,
dispatched: true,
status: resolveResourceCanvasAssetGenerationTaskStatus(record.status),
phaseDetail: record.phaseDetail,
startedAtMillis: record.startedAtMillis ?? task.startedAtMillis,
finishedAtMillis: record.finishedAtMillis ?? task.finishedAtMillis,
assetId: record.assetId ?? task.assetId,
error: record.error ?? task.error,
};
}
/**
* 本地排队的派发判据:**同一时刻只允许一条任务在途**。
*
* 远端图片类生成共用 single-flight 输出槽,第二条并发请求会在远端 POST 之前被拒;所以第二条
* 必须停在本地队列里(`dispatched === false`),等前一条终态后再补发。
*
* 「在途」的判据是 `dispatched && 未终态`,而不是 `status === 'running'`:已派发但后端记录还
* 没读回来的那一段(状态仍是 `queued`)同样占着输出槽,漏掉这一档就会并发发出第二条。
*/
export function nextResourceCanvasAssetGenerationDispatch(
tasks: readonly ResourceCanvasAssetGenerationTask[],
): ResourceCanvasAssetGenerationTask | null {
const inFlight = tasks.some(
(task) =>
task.dispatched && !resourceCanvasAssetGenerationTaskIsTerminal(task),
);
if (inFlight) {
return null;
}
return (
tasks.find((task) => !task.dispatched && task.status === 'queued') ?? null
);
}
/** 面板展示顺序:新提交的在上。 */
export function sortResourceCanvasAssetGenerationTasks(
tasks: readonly ResourceCanvasAssetGenerationTask[],
): ResourceCanvasAssetGenerationTask[] {
return [...tasks].sort(
(left, right) =>
right.createdAtMillis - left.createdAtMillis ||
left.taskId.localeCompare(right.taskId),
);
}
/** 已耗时文案:排队中按创建时间算,已结束按结束时间算。 */
export function resourceCanvasAssetGenerationElapsedLabel(
elapsedMillis: number,
): string {
const totalSeconds = Math.max(0, Math.floor(elapsedMillis / 1000));
const minutes = Math.floor(totalSeconds / 60);
const seconds = totalSeconds % 60;
return minutes > 0 ? `${minutes}${seconds}` : `${seconds}`;
}
export function resourceCanvasAssetGenerationTaskElapsedMillis(
task: Pick<
ResourceCanvasAssetGenerationTask,
'createdAtMillis' | 'finishedAtMillis'
>,
nowMillis: number,
): number {
return Math.max(
0,
(task.finishedAtMillis ?? nowMillis) - task.createdAtMillis,
);
}
@@ -26,6 +26,7 @@ import {
Layers,
LayoutGrid,
ListFilter,
ListChecks,
Maximize2,
Minus,
Music2,
@@ -330,6 +331,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 {
@@ -390,13 +403,6 @@ type DeriveLocalProjectResourceResult = {
manifest: GameCreationAppManifest;
};
type LocalProjectAssetGenerationResult = {
id: string;
localPath: string;
absolutePath: string;
manifestPath: string;
};
type PendingLocalProjectResourceEdit = {
operationId: string;
editKind: string;
@@ -1570,6 +1576,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('');
@@ -6775,94 +6796,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('正在定位生成的素材…');
},
[],
);
/** 工具栏「上传」:与资源面板上传同一条「上传 + 配对读清单」链路。 */
@@ -7190,6 +7379,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
@@ -8214,10 +8423,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;
}
@@ -10465,7 +10487,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 设计图」保持可点击并说明原因。
@@ -10480,7 +10502,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);
@@ -10585,6 +10609,199 @@ export function registerProjectAgentStatusTests() {
});
}, 20_000);
it('keeps a submitted generation alive after the panel closes, queues the second submission, and shows backend phases', async () => {
const manifest = createGameCreationAppManifest(
'workbench-asset-generation-tasks',
'生成任务项目',
);
manifest.assets = [
{
id: 'tasks-art-spec',
kind: 'icon-spec',
mediaType: 'image/png',
localPath: 'assets/art-spec.png',
source: { kind: 'canvas', resourceId: 'tasks-art-spec-resource' },
},
];
const calls: BottomToolbarInvokeCall[] = [];
const tasks = new Map<string, Record<string, unknown>>();
let listPolls = 0;
const invoke = vi.fn(
async (command: string, args?: Record<string, unknown>) => {
calls.push({ command, args });
if (command === 'read_local_project_resource_graph') {
return resourceGraphForInputs(args);
}
if (command === 'read_local_project_resource_canvas_layout') {
return {
schemaVersion: 'game-creator-resource-layout.v1',
projectId: args?.expectedProjectId,
mode: args?.mode,
revision: 0,
positions: [],
updatedAt: 0,
};
}
if (command === 'update_local_project_resource_canvas_layout') {
return {
status: 'updated',
layout: {
schemaVersion: 'game-creator-resource-layout.v1',
projectId: args?.expectedProjectId,
mode: args?.mode,
revision: 1,
positions: args?.positions,
updatedAt: 1,
},
};
}
if (command === 'start_local_project_asset_generation') {
const kind = invokeStringField(args, 'kind') ?? 'unknown';
const taskId = invokeStringField(args, 'taskId') ?? `task-${tasks.size}`;
const task = {
taskId,
projectId: String(args?.projectId ?? manifest.projectId),
kind,
assetName: invokeStringField(args, 'assetName') ?? '',
status: 'running',
phaseDetail: '正在生成。',
createdAtMillis: tasks.size + 1,
startedAtMillis: tasks.size + 1,
finishedAtMillis: null,
assetId: null,
error: null,
};
tasks.set(taskId, task);
return task;
}
if (command === 'list_local_project_asset_generations') {
listPolls += 1;
// 第三次轮询(约 4 秒)开始收尾:既让「第一条在途 → 第二条排队」可观测,
// 又让整条链路(补发 + 落卡 + 面板收口)在同一次用例里真的跑完。
if (listPolls >= 3) {
for (const [taskId, task] of tasks) {
if (task.status !== 'completed') {
tasks.set(taskId, {
...task,
status: 'completed',
phaseDetail: '生成已完成。',
assetId: `generated-${String(task.kind)}`,
finishedAtMillis: 2,
});
}
}
}
return [...tasks.values()];
}
if (command === 'get_local_game_project_revision') {
return { revision: 7 };
}
if (command === 'get_local_game_manifest') {
return manifest;
}
throw new Error(`unexpected invoke ${command}`);
},
);
window.__TAURI__ = { core: { invoke } };
render(
React.createElement(ProjectDevelopmentView, {
projectName: manifest.name,
projectPath: '/tmp/workbench-asset-generation-tasks',
manifest,
attachments: [],
recentRunStatus: null,
recentRunStopReason: null,
supervisor: React.createElement('div', null, '项目总控'),
onHomeOpen: vi.fn(),
onProjectsOpen: vi.fn(),
}),
);
await openResourceBookCategory('UI 交互');
expect(screen.getByRole('button', { name: '生成任务' })).not.toBeNull();
// 1) 提交第一条;生成在途时面板能关(关闭 ≠ 取消请求)。
fireEvent.click(await screen.findByRole('button', { name: '生成 UI 设计图' }));
const firstPanel = await screen.findByRole('dialog', {
name: '生成 UI 设计图',
});
fireEvent.change(within(firstPanel).getByLabelText('素材名称'), {
target: { value: '第一条设计图' },
});
fireEvent.change(within(firstPanel).getByLabelText('生成提示词'), {
target: { value: '第一条界面' },
});
fireEvent.click(within(firstPanel).getByRole('button', { name: '生成 UI 设计图' }));
fireEvent.click(
await screen.findByRole('button', { name: '后台运行并关闭' }),
);
await waitFor(() =>
expect(
screen.queryByRole('dialog', { name: '生成 UI 设计图' }),
).toBeNull(),
);
// 2) 面板关掉之后任务仍在「生成任务」面板里,阶段文案来自后端记录。
const taskPanel = await screen.findByRole('region', { name: '生成任务' });
expect(within(taskPanel).getByText('第一条设计图')).not.toBeNull();
expect(within(taskPanel).getByText('正在生成。')).not.toBeNull();
expect(within(taskPanel).getByText('生成中')).not.toBeNull();
// 非模态:没有全屏遮罩、没有 aria-modal。
expect(taskPanel.getAttribute('aria-modal')).toBeNull();
expect(document.querySelector('[aria-modal="true"]')).toBeNull();
// 3) 第一条还在途时提交第二条:第二条停在本地队列,生成提交 IPC 仍然只有一次。
fireEvent.click(screen.getByRole('button', { name: '生成 UI 设计图' }));
const secondPanel = await screen.findByRole('dialog', {
name: '生成 UI 设计图',
});
fireEvent.change(within(secondPanel).getByLabelText('素材名称'), {
target: { value: '第二条设计图' },
});
fireEvent.change(within(secondPanel).getByLabelText('生成提示词'), {
target: { value: '第二条界面' },
});
fireEvent.click(within(secondPanel).getByRole('button', { name: '生成 UI 设计图' }));
await within(secondPanel).findByText('排队中。');
expect(
calls.filter(
(call) => call.command === 'start_local_project_asset_generation',
),
).toHaveLength(1);
await waitFor(() =>
expect(within(taskPanel).getByText('第二条设计图')).not.toBeNull(),
);
fireEvent.keyDown(window, { key: 'Escape' });
await waitFor(() =>
expect(
screen.queryByRole('dialog', { name: '生成 UI 设计图' }),
).toBeNull(),
);
// 4) 第一条终态后自动补发第二条,两条都收口为已完成;每条完成各走一次「配对读 + 落卡」。
const manifestReadsBefore = calls.filter(
(call) => call.command === 'get_local_game_manifest',
).length;
await waitFor(
() =>
expect(
calls.filter(
(call) => call.command === 'start_local_project_asset_generation',
),
).toHaveLength(2),
{ timeout: 15_000 },
);
await waitFor(
() => expect(within(taskPanel).getAllByText('已完成')).toHaveLength(2),
{ timeout: 15_000 },
);
expect(within(taskPanel).getAllByText('生成已完成。')).toHaveLength(2);
expect(
calls.filter((call) => call.command === 'get_local_game_manifest').length,
).toBeGreaterThanOrEqual(manifestReadsBefore + 2);
}, 30_000);
it('routes the audio column entries to the existing audio generation chain', async () => {
const manifest = createGameCreationAppManifest(
'workbench-bottom-toolbar-audio',
@@ -0,0 +1,187 @@
// @vitest-environment jsdom
import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { afterEach, describe, expect, test, vi } from 'vitest';
import { ResourceCanvasAssetGenerationPanelView } from '../src/features/resource-canvas/ResourceCanvasAssetGenerationPanelView';
import type { ResourceCanvasAssetToolAction } from '../src/features/resource-canvas/resourceCanvasBottomToolbarModel';
import { ResourceCanvasGenerationPanelView } from '../src/features/resource-canvas/ResourceCanvasGenerationPanelView';
afterEach(() => {
cleanup();
});
const uiPrototypeAction: ResourceCanvasAssetToolAction = {
id: 'generate-ui-prototype',
route: 'asset',
label: '生成 UI 设计图',
assetKind: 'ui-prototype',
audioKind: null,
assetName: 'AI 生成 UI 设计图',
promptPlaceholder: '描述这张界面要承载的玩法与操作',
adjustableDimensions: true,
aspectRatio: '16:9',
imageSize: '1K',
requiresIconSpecReference: true,
writesIconSpecReference: false,
};
/** 永不 resolve 的提交:模拟「生成还要跑 35 分钟」的在途状态。 */
function pendingSubmit() {
return new Promise<void>(() => undefined);
}
async function fillAndSubmitAssetPanel(onSubmit: () => Promise<void>) {
const user = userEvent.setup();
render(
<ResourceCanvasAssetGenerationPanelView
action={uiPrototypeAction}
onSubmit={onSubmit}
onClose={() => undefined}
/>,
);
await user.type(screen.getByLabelText('生成提示词'), '主界面与背包页');
await user.click(screen.getByRole('button', { name: '生成 UI 设计图' }));
return user;
}
describe('图片类生成面板在提交期间可关闭', () => {
test('提交在途时点 × 能关闭面板,且关闭不等于取消请求', async () => {
const onClose = vi.fn();
const onSubmit = vi.fn(pendingSubmit);
const user = userEvent.setup();
render(
<ResourceCanvasAssetGenerationPanelView
action={uiPrototypeAction}
onSubmit={onSubmit}
onClose={onClose}
/>,
);
await user.type(screen.getByLabelText('生成提示词'), '主界面与背包页');
await user.click(screen.getByRole('button', { name: '生成 UI 设计图' }));
await waitFor(() => expect(onSubmit).toHaveBeenCalledTimes(1));
const closeButton = screen.getByRole('button', {
name: '关闭生成 UI 设计图',
}) as HTMLButtonElement;
expect(closeButton.disabled).toBe(false);
await user.click(closeButton);
expect(onClose).toHaveBeenCalledTimes(1);
// 关闭面板只是卸载 view:请求仍在飞,面板不该去「取消」它。
expect(onSubmit).toHaveBeenCalledTimes(1);
});
test('提交在途时 Esc 与点遮罩都能关面板', async () => {
const onClose = vi.fn();
render(
<ResourceCanvasAssetGenerationPanelView
action={uiPrototypeAction}
onSubmit={vi.fn(pendingSubmit)}
onClose={onClose}
/>,
);
const user = userEvent.setup();
await user.type(screen.getByLabelText('生成提示词'), '主界面与背包页');
await user.click(screen.getByRole('button', { name: '生成 UI 设计图' }));
fireEvent.keyDown(window, { key: 'Escape' });
expect(onClose).toHaveBeenCalledTimes(1);
const backdrop = document.querySelector('.fixed.inset-0') as HTMLElement;
fireEvent.pointerDown(backdrop);
fireEvent.pointerUp(backdrop);
fireEvent.click(backdrop);
expect(onClose).toHaveBeenCalledTimes(2);
});
test('提交在途时提供「后台运行并关闭」,文案明说关闭不等于取消', async () => {
const onClose = vi.fn();
const onSubmit = vi.fn(pendingSubmit);
const user = userEvent.setup();
render(
<ResourceCanvasAssetGenerationPanelView
action={uiPrototypeAction}
onSubmit={onSubmit}
onClose={onClose}
/>,
);
await user.type(screen.getByLabelText('生成提示词'), '主界面与背包页');
await user.click(screen.getByRole('button', { name: '生成 UI 设计图' }));
const backgroundButton = screen.getByRole('button', {
name: '后台运行并关闭',
});
await user.click(backgroundButton);
expect(onClose).toHaveBeenCalledTimes(1);
expect(onSubmit).toHaveBeenCalledTimes(1);
});
test('提交失败后草稿仍留在面板里可以直接重试', async () => {
const onSubmit = vi
.fn<() => Promise<void>>()
.mockRejectedValueOnce(new Error('生成素材失败:远端拒绝'));
const user = await fillAndSubmitAssetPanel(onSubmit);
expect((await screen.findByRole('alert')).textContent).toContain(
'生成素材失败:远端拒绝',
);
expect((screen.getByLabelText('生成提示词') as HTMLTextAreaElement).value).toBe(
'主界面与背包页',
);
expect(
(screen.getByLabelText('素材名称') as HTMLInputElement).disabled,
).toBe(false);
await user.click(screen.getByRole('button', { name: '生成 UI 设计图' }));
expect(onSubmit).toHaveBeenCalledTimes(2);
});
});
describe('音频生成面板在提交期间可关闭', () => {
test('提交在途时点 × 能关闭面板,且不改动 pending-edit 账本语义', async () => {
const onClose = vi.fn();
const onSubmit = vi.fn(pendingSubmit);
const user = userEvent.setup();
render(
<ResourceCanvasGenerationPanelView
kinds={['sound-effect']}
initialKind="sound-effect"
onSubmit={onSubmit}
onClose={onClose}
/>,
);
await user.type(screen.getByLabelText('生成提示词'), '木门推开的声音');
await user.click(screen.getByRole('button', { name: '生成音效' }));
await waitFor(() => expect(onSubmit).toHaveBeenCalledTimes(1));
const closeButton = screen.getByRole('button', {
name: '关闭生成音效',
}) as HTMLButtonElement;
expect(closeButton.disabled).toBe(false);
await user.click(closeButton);
expect(onClose).toHaveBeenCalledTimes(1);
expect(onSubmit).toHaveBeenCalledTimes(1);
fireEvent.keyDown(window, { key: 'Escape' });
expect(onClose).toHaveBeenCalledTimes(2);
});
test('提交在途时提供「后台运行并关闭」', async () => {
const onClose = vi.fn();
const user = userEvent.setup();
render(
<ResourceCanvasGenerationPanelView
kinds={['sound-effect']}
initialKind="sound-effect"
onSubmit={vi.fn(pendingSubmit)}
onClose={onClose}
/>,
);
await user.type(screen.getByLabelText('生成提示词'), '木门推开的声音');
await user.click(screen.getByRole('button', { name: '生成音效' }));
await user.click(
screen.getByRole('button', { name: '后台运行并关闭' }),
);
expect(onClose).toHaveBeenCalledTimes(1);
});
});
@@ -0,0 +1,401 @@
import { describe, expect, test, vi } from 'vitest';
import {
createResourceCanvasAssetGenerationQueue,
mergeResourceCanvasAssetGenerationTasksWithRecords,
type ResourceCanvasAssetGenerationQueueDeps,
type ResourceCanvasAssetGenerationSettlement,
} from '../src/features/resource-canvas/resourceCanvasAssetGenerationQueue';
import {
createResourceCanvasAssetGenerationTask,
nextResourceCanvasAssetGenerationDispatch,
RESOURCE_CANVAS_ASSET_GENERATION_LOCAL_QUEUE_PHASE,
type LocalProjectAssetGenerationTaskRecord,
resourceCanvasAssetGenerationElapsedLabel,
resourceCanvasAssetGenerationKindLabel,
type ResourceCanvasAssetGenerationTask,
sortResourceCanvasAssetGenerationTasks,
} from '../src/features/resource-canvas/resourceCanvasAssetGenerationTaskModel';
import type { ResourceCanvasAssetToolAction } from '../src/features/resource-canvas/resourceCanvasBottomToolbarModel';
const uiPrototypeAction: ResourceCanvasAssetToolAction = {
id: 'generate-ui-prototype',
route: 'asset',
label: '生成 UI 设计图',
assetKind: 'ui-prototype',
audioKind: null,
assetName: 'AI 生成 UI 设计图',
promptPlaceholder: '描述这张界面要承载的玩法与操作',
adjustableDimensions: true,
aspectRatio: '16:9',
imageSize: '1K',
requiresIconSpecReference: true,
writesIconSpecReference: false,
};
function localTask(taskId: string, createdAtMillis: number) {
return createResourceCanvasAssetGenerationTask({
taskId,
action: uiPrototypeAction,
prompt: `提示词 ${taskId}`,
assetName: `素材 ${taskId}`,
aspectRatio: '16:9',
imageSize: '1K',
outputPath: null,
projectId: 'project-1',
nowMillis: createdAtMillis,
});
}
function record(
taskId: string,
status: string,
extra: Partial<LocalProjectAssetGenerationTaskRecord> = {},
): LocalProjectAssetGenerationTaskRecord {
return {
taskId,
projectId: 'project-1',
kind: 'ui-prototype',
assetName: `素材 ${taskId}`,
status,
phaseDetail: status === 'running' ? '正在生成。' : '排队中。',
createdAtMillis: 1_000,
startedAtMillis: null,
finishedAtMillis: null,
assetId: null,
error: null,
...extra,
};
}
describe('生成任务模型', () => {
test('新提交的任务先按本地排队呈现,阶段文案不是后端记录', () => {
const task = localTask('task-a', 10);
expect(task.dispatched).toBe(false);
expect(task.status).toBe('queued');
expect(task.phaseDetail).toBe(
RESOURCE_CANVAS_ASSET_GENERATION_LOCAL_QUEUE_PHASE,
);
expect(task.restored).toBe(false);
});
test('有在途任务时下一条不可派发,前一条终态后才轮到它', () => {
const running = {
...localTask('task-a', 10),
dispatched: true,
status: 'running' as const,
};
const queued = localTask('task-b', 20);
expect(nextResourceCanvasAssetGenerationDispatch([running, queued])).toBe(
null,
);
expect(
nextResourceCanvasAssetGenerationDispatch([
{ ...running, status: 'completed' },
queued,
])?.taskId,
).toBe('task-b');
expect(
nextResourceCanvasAssetGenerationDispatch([
{ ...running, status: 'failed' },
queued,
])?.taskId,
).toBe('task-b');
expect(
nextResourceCanvasAssetGenerationDispatch([
{ ...running, status: 'completed' },
]),
).toBeNull();
});
test('已派发但还没终态的任务同样挡住队列(不发第二条)', () => {
const dispatched = { ...localTask('task-a', 10), dispatched: true };
expect(
nextResourceCanvasAssetGenerationDispatch([
dispatched,
localTask('task-b', 20),
]),
).toBeNull();
});
test('后端记录刷新本地任务:状态、阶段、时间戳与资源 id 都以后端为准', () => {
const merged = mergeResourceCanvasAssetGenerationTasksWithRecords(
[localTask('task-a', 10)],
[
record('task-a', 'completed', {
phaseDetail: '生成已完成。',
assetId: 'asset-7',
startedAtMillis: 1_100,
finishedAtMillis: 1_900,
}),
],
);
expect(merged[0]?.status).toBe('completed');
expect(merged[0]?.phaseDetail).toBe('生成已完成。');
expect(merged[0]?.assetId).toBe('asset-7');
expect(merged[0]?.finishedAtMillis).toBe(1_900);
expect(merged[0]?.restored).toBe(false);
});
test('重开项目后按账本恢复历史任务(本地没有草稿也能显示)', () => {
const merged = mergeResourceCanvasAssetGenerationTasksWithRecords(
[],
[
record('task-old', 'completed', {
phaseDetail: '生成已完成。',
assetId: 'asset-1',
finishedAtMillis: 2_000,
}),
],
);
expect(merged).toHaveLength(1);
expect(merged[0]?.taskId).toBe('task-old');
expect(merged[0]?.restored).toBe(true);
expect(merged[0]?.actionLabel).toBe('生成 UI 设计图');
expect(merged[0]?.status).toBe('completed');
});
test('后端没给过的状态按失败收口,展示顺序最新在前', () => {
const merged = mergeResourceCanvasAssetGenerationTasksWithRecords(
[localTask('task-a', 10), localTask('task-b', 20)],
[record('task-a', 'weird-status'), record('task-b', 'running')],
);
expect(merged.find((task) => task.taskId === 'task-a')?.status).toBe(
'failed',
);
expect(
sortResourceCanvasAssetGenerationTasks(merged).map((task) => task.taskId),
).toEqual(['task-b', 'task-a']);
});
test('kind 文案从工具栏模型派生,未知 kind 不编造', () => {
expect(resourceCanvasAssetGenerationKindLabel('ui-prototype')).toBe(
'生成 UI 设计图',
);
expect(resourceCanvasAssetGenerationKindLabel('image')).toBe('生成图片');
expect(resourceCanvasAssetGenerationKindLabel('nope')).toBeNull();
});
test('已耗时文案按分秒呈现', () => {
expect(resourceCanvasAssetGenerationElapsedLabel(12_000)).toBe('12 秒');
expect(resourceCanvasAssetGenerationElapsedLabel(72_000)).toBe('1 分 12 秒');
expect(resourceCanvasAssetGenerationElapsedLabel(-5)).toBe('0 秒');
});
});
type Harness = {
deps: ResourceCanvasAssetGenerationQueueDeps;
invoke: ReturnType<typeof vi.fn>;
tasks: () => readonly ResourceCanvasAssetGenerationTask[];
settlements: ResourceCanvasAssetGenerationSettlement[];
/** 只统计生成提交命令;轮询用的读接口不计入「第二条不许发 IPC」。 */
startCallCount: () => number;
advance: (taskId: string, status: 'completed' | 'failed') => void;
forget: (taskId: string) => void;
startError: Map<string, Error>;
};
/**
* 假后端 + 由轮询次数驱动的确定性脚本。
*
* `wait` 在一次轮询之后被调用,所以「第 N 次轮询之后做什么」就是脚本的粒度,不需要计时器。
*/
function createHarness(waitScript?: (pollCount: number, api: Harness) => void) {
const records = new Map<string, LocalProjectAssetGenerationTaskRecord>();
const startError = new Map<string, Error>();
let tasks: ResourceCanvasAssetGenerationTask[] = [];
const settlements: ResourceCanvasAssetGenerationSettlement[] = [];
let pollCount = 0;
const harness: Harness = {
deps: undefined as unknown as ResourceCanvasAssetGenerationQueueDeps,
invoke: undefined as unknown as ReturnType<typeof vi.fn>,
tasks: () => tasks,
settlements,
startCallCount: () =>
harness.invoke.mock.calls.filter(
([command]) => command === 'start_local_project_asset_generation',
).length,
advance: (taskId, status) => {
records.set(
taskId,
record(taskId, status, {
phaseDetail:
status === 'completed' ? '生成已完成。' : '生成失败:远端拒绝',
assetId: status === 'completed' ? `asset-${taskId}` : null,
error: status === 'completed' ? null : '远端拒绝',
finishedAtMillis: 5_000,
}),
);
},
startError,
forget: (taskId) => {
records.delete(taskId);
},
};
harness.invoke = vi.fn(
async (command: string, args: Record<string, unknown>) => {
if (command === 'start_local_project_asset_generation') {
const taskId = String(args.taskId);
const failure = startError.get(taskId);
if (failure) {
throw failure;
}
const created = record(taskId, 'running', { startedAtMillis: 1_200 });
records.set(taskId, created);
return created;
}
if (command === 'list_local_project_asset_generations') {
pollCount += 1;
return [...records.values()];
}
throw new Error(`未预期的命令:${command}`);
},
);
harness.deps = {
invoke: harness.invoke as ResourceCanvasAssetGenerationQueueDeps['invoke'],
projectPath: () => '/tmp/project',
listTasks: () => tasks,
replaceTask: (task) => {
const index = tasks.findIndex((item) => item.taskId === task.taskId);
tasks =
index >= 0
? tasks.map((item) => (item.taskId === task.taskId ? task : item))
: [...tasks, task];
},
onSettled: (settlement) => {
settlements.push(settlement);
},
wait: async () => {
await Promise.resolve();
waitScript?.(pollCount, harness);
},
nowMillis: () => 9_999,
};
return harness;
}
describe('本地排队驱动器', () => {
test('第一条未完成时提交第二条:第二条仍是排队中且生成提交 IPC 只发了一次', async () => {
let assertedMidFlight = false;
const harness = createHarness((pollCount, api) => {
if (pollCount === 2 && !assertedMidFlight) {
assertedMidFlight = true;
// 第一条还在途:第二条必须停在本地队列里。
expect(api.startCallCount()).toBe(1);
const second = api
.tasks()
.find((task) => task.taskId === 'task-b');
expect(second?.status).toBe('queued');
expect(second?.dispatched).toBe(false);
expect(second?.phaseDetail).toBe(
RESOURCE_CANVAS_ASSET_GENERATION_LOCAL_QUEUE_PHASE,
);
api.advance('task-a', 'completed');
}
if (pollCount === 4) {
api.advance('task-b', 'completed');
}
});
const queue = createResourceCanvasAssetGenerationQueue(harness.deps);
const first = queue.submit(localTask('task-a', 10));
const second = queue.submit(localTask('task-b', 20));
await first;
await second;
expect(assertedMidFlight).toBe(true);
// 第一条终态后自动补发第二条:生成提交 IPC 次数 = 2。
expect(harness.startCallCount()).toBe(2);
expect(harness.settlements.map((item) => [item.taskId, item.status])).toEqual(
[
['task-a', 'completed'],
['task-b', 'completed'],
],
);
expect(
harness.tasks().map((task) => [task.taskId, task.status]),
).toEqual([
['task-a', 'completed'],
['task-b', 'completed'],
]);
});
test('第一条失败也会补发第二条,失败原因按后端记录留在任务上', async () => {
const harness = createHarness((pollCount, api) => {
if (pollCount === 1) {
// 第一条已经派发,此刻收口为失败。
api.advance('task-a', 'failed');
}
if (pollCount === 3) {
api.advance('task-b', 'completed');
}
});
const queue = createResourceCanvasAssetGenerationQueue(harness.deps);
const first = queue.submit(localTask('task-a', 10));
const second = queue.submit(localTask('task-b', 20));
await expect(first).rejects.toThrow('远端拒绝');
await expect(second).resolves.toBeUndefined();
expect(harness.startCallCount()).toBe(2);
const failed = harness.tasks().find((task) => task.taskId === 'task-a');
expect(failed?.status).toBe('failed');
expect(failed?.phaseDetail).toBe('生成失败:远端拒绝');
expect(failed?.error).toBe('远端拒绝');
expect(failed?.finishedAtMillis).toBe(5_000);
expect(
harness.tasks().find((task) => task.taskId === 'task-b')?.assetId,
).toBe('asset-task-b');
});
test('账本里找不到这条任务时不会无限轮询,收口为失败并放行后面的排队任务', async () => {
const harness = createHarness((pollCount, api) => {
if (pollCount === 1) {
// 第一条的记录被账本淘汰 / 项目被换掉:从这一轮起它再也查不到。
harness.startError.set('task-a', new Error('never-used'));
api.forget('task-a');
}
if (pollCount === 20) {
api.advance('task-b', 'completed');
}
});
const queue = createResourceCanvasAssetGenerationQueue(harness.deps);
const first = queue.submit(localTask('task-a', 10));
const second = queue.submit(localTask('task-b', 20));
await expect(first).rejects.toThrow('生成任务账本里已找不到这条任务');
await second;
expect(harness.startCallCount()).toBe(2);
});
test('生成提交 IPC 抛错时任务收口为失败,队列不会卡死', async () => {
const harness = createHarness((pollCount, api) => {
if (pollCount === 2) {
api.advance('task-b', 'completed');
}
});
harness.startError.set(
'task-a',
new Error('项目权限策略拒绝执行:canvas.asset_generate'),
);
const queue = createResourceCanvasAssetGenerationQueue(harness.deps);
const first = queue.submit(localTask('task-a', 10));
const second = queue.submit(localTask('task-b', 20));
await expect(first).rejects.toThrow('项目权限策略拒绝执行');
await second;
const failed = harness.tasks().find((task) => task.taskId === 'task-a');
expect(failed?.status).toBe('failed');
expect(failed?.phaseDetail).toBe(
'生成失败:项目权限策略拒绝执行:canvas.asset_generate',
);
// 提交失败的这一条没有终态记录也要能收口,否则第二条永远补发不出去。
expect(harness.startCallCount()).toBe(2);
expect(harness.settlements[0]).toMatchObject({
taskId: 'task-a',
status: 'failed',
record: null,
});
});
});
@@ -0,0 +1,175 @@
// @vitest-environment jsdom
import { cleanup, render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { afterEach, describe, expect, test, vi } from 'vitest';
import { ResourceCanvasAssetGenerationTasksPanelView } from '../src/features/resource-canvas/ResourceCanvasAssetGenerationTasksPanelView';
import {
createResourceCanvasAssetGenerationTask,
type ResourceCanvasAssetGenerationTask,
} from '../src/features/resource-canvas/resourceCanvasAssetGenerationTaskModel';
import type { ResourceCanvasAssetToolAction } from '../src/features/resource-canvas/resourceCanvasBottomToolbarModel';
afterEach(() => {
cleanup();
});
const uiPrototypeAction: ResourceCanvasAssetToolAction = {
id: 'generate-ui-prototype',
route: 'asset',
label: '生成 UI 设计图',
assetKind: 'ui-prototype',
audioKind: null,
assetName: 'AI 生成 UI 设计图',
promptPlaceholder: '描述这张界面要承载的玩法与操作',
adjustableDimensions: true,
aspectRatio: '16:9',
imageSize: '1K',
requiresIconSpecReference: true,
writesIconSpecReference: false,
};
function task(
patch: Partial<ResourceCanvasAssetGenerationTask> & { taskId: string },
): ResourceCanvasAssetGenerationTask {
return {
...createResourceCanvasAssetGenerationTask({
taskId: patch.taskId,
action: uiPrototypeAction,
prompt: '主界面与背包页',
assetName: '主界面设计图',
aspectRatio: '16:9',
imageSize: '1K',
outputPath: null,
projectId: 'project-1',
nowMillis: 1_000,
}),
...patch,
};
}
describe('「生成任务」面板', () => {
test('是非模态面板:没有全屏遮罩,也没有 aria-modal 与焦点陷阱', () => {
const { container } = render(
<ResourceCanvasAssetGenerationTasksPanelView
tasks={[task({ taskId: 't1' })]}
onClose={() => undefined}
onFocusTask={() => undefined}
/>,
);
expect(screen.getByRole('region', { name: '生成任务' })).not.toBeNull();
expect(container.querySelector('[aria-modal="true"]')).toBeNull();
expect(container.querySelector('.fixed.inset-0')).toBeNull();
});
test('状态、阶段文案、已耗时与素材名逐条呈现,阶段文案来自后端记录', async () => {
render(
<ResourceCanvasAssetGenerationTasksPanelView
tasks={[
task({ taskId: 't1', assetName: '主界面设计图' }),
task({
taskId: 't2',
assetName: '背包界面设计图',
dispatched: true,
status: 'running',
// 前端的任何常量都不会产出「正在处理。」,出现它只可能来自后端记录。
phaseDetail: '正在处理。',
startedAtMillis: 2_000,
}),
task({
taskId: 't3',
assetName: '结算界面设计图',
dispatched: true,
status: 'completed',
phaseDetail: '生成已完成。',
assetId: 'asset-3',
finishedAtMillis: 13_000,
}),
task({
taskId: 't4',
assetName: '设置界面设计图',
dispatched: true,
status: 'failed',
phaseDetail: '生成失败:远端拒绝',
error: '远端拒绝',
}),
]}
onClose={() => undefined}
onFocusTask={() => undefined}
/>,
);
expect(screen.getByText('主界面设计图')).not.toBeNull();
expect(screen.getByText('排队中')).not.toBeNull();
expect(screen.getByText('生成中')).not.toBeNull();
expect(screen.getByText('已完成')).not.toBeNull();
expect(screen.getByText('失败')).not.toBeNull();
expect(screen.getByText('正在处理。')).not.toBeNull();
expect(screen.getByText('生成已完成。')).not.toBeNull();
// 每条任务的阶段只渲染后端给的那一句:面板里没有第二个来源,也没有硬编码的假阶段。
const runningRow = screen.getByText('背包界面设计图').closest('li');
expect(runningRow?.textContent).toContain('正在处理。');
expect(runningRow?.textContent).not.toContain('生成中…');
expect(screen.getByRole('alert').textContent).toBe('远端拒绝');
expect(screen.getByText('生成任务').textContent).toBe('生成任务');
const user = userEvent.setup();
// 已耗时的秒表由前端计时,但只在还有未终态任务时走;这里断言它至少渲染了。
expect(
screen.getAllByText(/^已耗时 \d+ (秒|分 \d+ 秒)$/).length,
).toBeGreaterThan(0);
void user;
});
test('只有已完成且拿到资源 id 的任务能定位到素材卡', async () => {
const onFocusTask = vi.fn();
const user = userEvent.setup();
render(
<ResourceCanvasAssetGenerationTasksPanelView
tasks={[
task({
taskId: 't1',
assetName: '主界面设计图',
dispatched: true,
status: 'completed',
phaseDetail: '生成已完成。',
assetId: 'asset-1',
finishedAtMillis: 5_000,
}),
task({
taskId: 't2',
assetName: '背包界面设计图',
dispatched: true,
status: 'running',
phaseDetail: '正在生成。',
}),
]}
onClose={() => undefined}
onFocusTask={onFocusTask}
/>,
);
expect(screen.queryByRole('button', { name: '定位素材 背包界面设计图' })).toBeNull();
await user.click(screen.getByRole('button', { name: '定位素材 主界面设计图' }));
expect(onFocusTask).toHaveBeenCalledTimes(1);
expect(onFocusTask.mock.calls[0]?.[0]).toMatchObject({
taskId: 't1',
assetId: 'asset-1',
});
});
test('没有任务时给出空状态,关闭按钮始终可用', async () => {
const onClose = vi.fn();
const user = userEvent.setup();
render(
<ResourceCanvasAssetGenerationTasksPanelView
tasks={[]}
onClose={onClose}
onFocusTask={() => undefined}
/>,
);
expect(screen.getByRole('status').textContent).toBe('还没有生成任务');
await user.click(screen.getByRole('button', { name: '关闭生成任务' }));
expect(onClose).toHaveBeenCalledTimes(1);
});
});
@@ -1,6 +1,6 @@
# AI 游戏创作项目开发工作台 PRD
更新时间:`2026-09-13`(新增功能画布底部工具栏入口矩阵 §3.10 / §7.9右侧 Supervisor 对话气泡、可访问对比度与过程卡布局收口,以及资源卡预览、分区布局、非破坏性资源编辑、资源替换与 Godot 双根合同保持不变)
更新时间:`2026-09-14`同步功能画布图片类生成后台化的当前事实:入口 IPC 改为 `start_local_project_asset_generation` + 项目内任务账本 + 本地排队 + 非模态「生成任务」面板,见 §3.10 / §7.92026-09-13 新增功能画布底部工具栏入口矩阵 §3.10 / §7.9,以及右侧 Supervisor 对话气泡、可访问对比度与过程卡布局收口,资源卡预览、分区布局、非破坏性资源编辑、资源替换与 Godot 双根合同保持不变)
## 1. 产品定位
@@ -142,7 +142,7 @@
### 3.10 功能画布底部工具栏(入口矩阵)
实现状态(2026-09-13):功能画布 = 资源栏目页(`resourceBookState.view === 'child'`)。栏目页左下角的底部工具栏按 manifest 资产的**功能分类** `category` 分流,矩阵由用户 2026-09-13 拍板,工具项顺序即下表顺序(最右一项为「上传」)。
实现状态(2026-09-132026-09-14 图片类生成后台化:提交即返回 + 项目内任务账本 + 本地排队 + 非模态「生成任务」面板):功能画布 = 资源栏目页(`resourceBookState.view === 'child'`)。栏目页左下角的底部工具栏按 manifest 资产的**功能分类** `category` 分流,矩阵由用户 2026-09-13 拍板,工具项顺序即下表顺序(最右一项为「上传」)。
| 栏目 | 工具栏里的生成入口 |
| --- | --- |
@@ -154,11 +154,12 @@
- 位置与层级:画布左下角(`left: 14px; bottom: 14px; z-index: 40`)。右下角是既有的缩放 / 撤销 Dock(`right: 14px; bottom: 14px`),左下角是画布上唯一两者都不占的稳定空位。工具栏是管理区 `.game-resource-book-manager` 的**直接子节点**、与画本场景并列,不进带 `scale()` 的场景层;二级菜单与「入口不可用原因」都贴着工具栏上沿弹出,不做内嵌内容。
- 外壳用共享 chrome`packages/image-canvas-react``CanvasToolbar / CanvasToolbarGroup / CanvasChromeButton`),样式落在 AGC 的 `resourceCanvasChrome.css`;共享包只承接通用表现,不含业务规则。
- 接线:图片类入口走本地 IPC `generate_local_project_asset``kind``image / character / spec / icon-spec / ui-prototype / art-spritesheet`);音频入口复用既有的无源生成链路 `derive_local_project_resource``generationMode: 'create'``editKind` = `sound-effect` / `background-music`);「上传」复用 `upload_local_asset`。生成 / 上传成功后一律用「配对读 `(revision, manifest)`」交给 `onManifestChange`,走既有 manifest 刷新与资源投影链路,不重算依赖图、不另写布局。
- 面板形态:独立浮层(`ThemedModal`),**不在当前面板下面追加内容**;面板内不写功能说明或规则解释文案
- 接线:图片类入口走本地 IPC `start_local_project_asset_generation``kind``image / character / spec / icon-spec / ui-prototype / art-spritesheet`;**提交即返回任务记录**,生成由 Rust 后台任务跑完写回项目,进度用 `list_local_project_asset_generations` 读回项目内账本 `.agent/runtime/asset-generation-tasks/tasks.json`);音频入口复用既有的无源生成链路 `derive_local_project_resource``generationMode: 'create'``editKind` = `sound-effect` / `background-music`);「上传」复用 `upload_local_asset`。生成 / 上传成功后一律用「配对读 `(revision, manifest)`」交给 `onManifestChange`,走既有 manifest 刷新与资源投影链路,不重算依赖图、不另写布局。
- 本地排队与进度可见:同项目图片类生成共用 single-flight 输出槽,所以第一条未终态时第二条提交停在**前端本地队列**里(不调用提交 IPC,显示本地排队的「排队中。」),前一条终态后自动补发;任务状态与阶段文案(后端 `phaseDetail`)由任务账本提供,前端不拼阶段、不做百分比。工具栏另给一个**非模态**「生成任务」浮层(入口按钮 `aria-label="生成任务"`,不铺全屏遮罩、不做焦点陷阱、不参与模态遮挡判据)列出每条任务的状态 / 阶段 / 已耗时 / 素材名,已完成条目可点击定位到对应素材卡(复用既有 `pendingResourceFocusRef` 聚焦链)
- 面板形态:独立浮层(`ThemedModal`),**不在当前面板下面追加内容**;面板内不写功能说明或规则解释文案。生成在后台跑,所以**提交期间面板不锁关闭**:× / 遮罩 / Esc /「后台运行并关闭」四条路径都通;关闭只是收起表单,**不等于取消请求**(请求挂在任务上,不挂在面板生命周期上);失败仍保留草稿可重试。
- 参数口径:比例 / 尺寸选项来自网页端美术画布的纯模型(`src/components/image-editor/ImageCanvasGenerationModel.ts`),并按本地 IPC 白名单收窄(本地通道明确拒绝 `4:3`);默认档 `1:1 · 1K`,生成 UI 设计图沿用网页端 UI 设计面板的默认 `16:9 · 1K`。本地 IPC 没有 `model` 入参,因此面板**不渲染模型选择器**(渲染一个改不了请求的控件就是假控件)。
- 规范类和固定档:生成规范下的图标规范 / 角色规范 / 自定义规范都是固定档——只读展示当前规格,不给点了不生效的比例控件。本地 IPC 只有一条规范通道(`spec` 在 Rust 侧收口到 `icon-spec`),**没有 `specType` 入参**,因此角色规范与自定义规范共用该通道,靠素材名与提示词区分。
- 前置规范图:`generate_local_project_asset``ui-prototype``art-spritesheet` 要求项目里已有登记并绑定当前账号的 `assets/art-spec.png``kind: icon-spec`、图片媒体类型、来源为画布)。缺前置时对应入口**保持可点击**并给出可执行的原因与指路(仓库口径:视觉不可用但可点击说明原因,不用原生 `disabled`),原因文案包含 `assets/art-spec.png` 与「生成规范 → 图标规范」。
- 前置规范图:`start_local_project_asset_generation``ui-prototype``art-spritesheet` 要求项目里已有登记并绑定当前账号的 `assets/art-spec.png``kind: icon-spec`、图片媒体类型、来源为画布)。**后台化没有改这条前置判据**:Rust 侧仍是同一口径,前端 `projectHasIconSpecReference` 仍与它逐字对齐,缺前置时对应入口**保持可点击**并给出可执行的原因与指路(仓库口径:视觉不可用但可点击说明原因,不用原生 `disabled`),原因文案包含 `assets/art-spec.png` 与「生成规范 → 图标规范」。
- 权威规范图由工具栏自身可满足:「图标规范」入口在项目尚无权威规范图时按 `assets/art-spec.png` 落盘;已有权威规范图时**不再传 `outputPath`**Rust 侧 `replace_existing` 固定为 `false`,指向已存在文件会被硬拒绝「outputPath 已存在,禁止静默覆盖」),改为生成一张新的普通图标规范资产。这条前端策略不扩接口。
- 音频入口收敛:音频画布的「生成背景音乐 / 生成音效」由工具栏承载,既有「生成素材」浮层入口**只保留生成视频**(视频能力不删,只是不出现在工具栏里)。
- 本轮明确不做:生成视频(保留在既有浮层入口)、宣发素材、生成游戏场景、画布级「选择工具 / 抓手工具」(AGC 现无画布级工具模式)。这五类都**不得**出现在工具栏里。
@@ -652,11 +653,12 @@ type ProjectAgentMudPointAttribution = {
1. 工具栏只在资源栏目页且分类命中矩阵四个栏目时渲染;资源总览、「所有资源」展开态、文档 / 待归类 / 项目版本都不渲染。稳定 DOM 判据 `[data-resource-bottom-toolbar="<category>"]`;总览与「所有资源」展开态下该节点必须不存在。
2. 工具栏是管理区 `.game-resource-book-manager` 的直接子节点、与画本场景并列(`closest('.game-resource-book-scene')``null`),且与右下角 Dock 互不抢位(左下角锚定:`left` 有值、`right` 无声明;Dock 相反)。二级菜单只在展开时出现,展开时可被外部点击 / Esc 收起。
3. 每个图片类入口一次提交**一条** `generate_local_project_asset`,载荷字段逐字为 `{ projectPath, kind, prompt, aspectRatio, imageSize, assetName, outputPath }``kind` 映射:生成图片 `image`、生成角色形象 `character`、图标规范 `icon-spec`、角色规范 / 自定义规范 `spec`、生成图标素材 `art-spritesheet`、生成 UI 设计图 `ui-prototype`
3. 每个图片类入口一次提交**一条** `start_local_project_asset_generation`,载荷字段逐字为 `{ projectPath, projectId, taskId, kind, prompt, aspectRatio, imageSize, assetName, outputPath }`——**原同步命令的生成载荷字段名与取值口径逐字不变**(`kind / prompt / aspectRatio / imageSize / assetName / outputPath` 全部原样搬过来),只多了任务身份 `projectId` 与前端每次提交新铸的本地 `taskId``kind` 映射:生成图片 `image`、生成角色形象 `character`、图标规范 `icon-spec`、角色规范 / 自定义规范 `spec`、生成图标素材 `art-spritesheet`、生成 UI 设计图 `ui-prototype`命令**提交即返回**一条任务记录,生成在 Rust 后台任务里跑,状态与阶段由 `list_local_project_asset_generations` 从项目内账本读回。
4. 比例 / 尺寸选项**不含**本地通道拒绝的 `4:3`;比例与尺寸的切换都进入载荷;固定档规范入口只读展示当前规格(如 `1:1·1K`),不渲染可点比例控件。
5. 缺权威规范图时「生成图标素材 / 生成 UI 设计图」保持可点击、给出含 `assets/art-spec.png` 的原因说明,且**不发出任何生成请求**;「图标规范」此时把 `outputPath` 指向 `assets/art-spec.png`;项目已有权威规范图时 `outputPath``null`(两种状态各一条用例)。
6. 生成 / 上传成功后走既有刷新路径:`get_local_game_project_revision` + `get_local_game_manifest` 的配对读发生在写入命令之后,结果交给 `onManifestChange`;音频入口提交的是 `derive_local_project_resource``editKind` = `sound-effect` / `background-music``generationMode: 'create'``sourceMediaType: 'audio/mpeg'`)。
6. 生成 / 上传成功后走既有刷新路径:`get_local_game_project_revision` + `get_local_game_manifest` 的配对读发生在**该任务收口之后**(提交命令不再等待生成,配对读由任务终态驱动,顺序仍在写入之后,结果交给 `onManifestChange`;音频入口提交的是 `derive_local_project_resource``editKind` = `sound-effect` / `background-music``generationMode: 'create'``sourceMediaType: 'audio/mpeg'`)。
7. 既有「生成素材」浮层入口只呈现视频:面板不再渲染音效 / 背景音乐类型选择器(单类型入口没有类型选择器),音频入口只出现在音频栏目工具栏。
8. 生成期间可退出与本地排队(2026-09-14):提交后点 × / 遮罩 / Esc /「后台运行并关闭」任一都能关面板,画布立即恢复可交互,关闭不取消请求;面板关闭后任务仍在「生成任务」面板(非模态浮层,无 `aria-modal`)里显示后端 `phaseDetail`;第一条未终态时提交第二条 → 第二条显示「排队中。」且生成提交 IPC 次数仍为 1,第一条终态后自动补发(次数为 2);两条各自完成后各走一次「配对读 + 落卡」,manifest revision 单调、不被后到者覆盖。
## 8. 非目标
@@ -8674,3 +8674,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 阶段 · 版本与运行
@@ -246,7 +246,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 状态栏承载。