补齐画布验收第二批:派生任务进生成任务侧栏、提交中让出草稿与重复提交防护

- 新增 resourceCanvasResourceEditTaskModel:原生资源编辑阶段到面板状态/文案的映射,本地提交记录与原生待办按 operationId 合并,派生任务的终态与耗时判据
- 「生成任务」侧栏同时收派生/修改类任务:与图片类生成任务同一条列表、同一套分栏与状态徽标,多一行提示词,样式沿用既有卡片类
- 快速编辑、生成动画、视频/音效/背景音乐三条派生提交:按下提交当帧写入本地记录并在侧栏可见,成功后收口为已完成、失败带原因并重读原生待办
- 「管理未完成编辑」不再列出正在提交的那一笔草稿,失败后自动回落,提示词与 @ 引用不丢
- 同一张素材存在在途提交时,重新打开面板再提交会被拦下并给出原因,避免重复派生与重复计费
- 侧栏与工具条入口的在途计数把派生任务一并计入
- 新增 tests/resourceCanvasResourceEditTasks.test.ts(9 条);resourceCanvasQuickEditDraft.test.tsx 补两组宿主用例(提交当帧进侧栏并收口、在途重复提交被拦),两处新判据均做过变异验证
- 同步 PRD 派生任务侧栏口径、decision-log 与画布验收待办文档
This commit is contained in:
2026-09-20 23:04:13 +08:00
parent a0d432b63d
commit 5b73082ad0
9 changed files with 1142 additions and 64 deletions
@@ -12,6 +12,12 @@ import {
resourceCanvasAssetGenerationTaskTone,
sortResourceCanvasAssetGenerationTasks,
} from './resourceCanvasAssetGenerationTaskModel';
import {
resourceCanvasResourceEditElapsedLabel,
type ResourceCanvasResourceEditTask,
resourceCanvasResourceEditTaskElapsedMillis,
resourceCanvasResourceEditTaskIsTerminal,
} from './resourceCanvasResourceEditTaskModel';
/** 「已完成」分栏的展示上限:触顶后只提示还有多少条,不无限拉长侧栏。 */
export const RESOURCE_CANVAS_ASSET_GENERATION_DONE_SECTION_LIMIT = 20;
@@ -24,6 +30,14 @@ export const RESOURCE_CANVAS_ASSET_GENERATION_TASKS_LEAVE_MILLIS = 160;
export type ResourceCanvasAssetGenerationTasksPanelViewProps = {
tasks: readonly ResourceCanvasAssetGenerationTask[];
/**
* 派生/修改类任务(快速编辑、生成动画、抠图…)。
*
* 它们不在图片类生成任务的账本里(原生资源编辑账本按 `operationId` 记),但用户眼里都是
* 「我交出去、等着出结果的那件事」,所以进同一个侧栏、同一套分栏;状态与阶段文案各按自己的
* 账本渲染。缺省为空数组:老调用方不传就没有这一段。
*/
resourceEditTasks?: readonly ResourceCanvasResourceEditTask[];
/** 侧栏是否展开;折叠时只留贴边把手。 */
open: boolean;
onToggleOpen: () => void;
@@ -31,7 +45,53 @@ export type ResourceCanvasAssetGenerationTasksPanelViewProps = {
onFocusTask: (task: ResourceCanvasAssetGenerationTask) => void;
};
function taskRow(
/**
* 侧栏的一行。
*
* 两种来源合并成同一条列表:图片类生成任务按后端账本推进,派生/修改任务按原生资源编辑账本
* 推进;排序与分栏只认「提交时间」和「是否终态」,用户不需要知道它们来自两套账本。
*/
type ResourceCanvasGenerationTaskRow =
| {
readonly source: 'asset-generation';
readonly createdAtMillis: number;
readonly task: ResourceCanvasAssetGenerationTask;
}
| {
readonly source: 'resource-edit';
readonly createdAtMillis: number;
readonly task: ResourceCanvasResourceEditTask;
};
function resourceCanvasGenerationTaskRowKey(
row: ResourceCanvasGenerationTaskRow,
): string {
return row.source === 'asset-generation'
? row.task.taskId
: row.task.operationId;
}
function resourceCanvasGenerationTaskRowIsTerminal(
row: ResourceCanvasGenerationTaskRow,
): boolean {
return row.source === 'asset-generation'
? resourceCanvasAssetGenerationTaskIsTerminal(row.task)
: resourceCanvasResourceEditTaskIsTerminal(row.task);
}
function sortResourceCanvasGenerationTaskRows(
rows: readonly ResourceCanvasGenerationTaskRow[],
): ResourceCanvasGenerationTaskRow[] {
return [...rows].sort(
(left, right) =>
right.createdAtMillis - left.createdAtMillis ||
resourceCanvasGenerationTaskRowKey(left).localeCompare(
resourceCanvasGenerationTaskRowKey(right),
),
);
}
function assetGenerationTaskRow(
task: ResourceCanvasAssetGenerationTask,
nowMillis: number,
onFocusTask: (task: ResourceCanvasAssetGenerationTask) => void,
@@ -86,6 +146,79 @@ function taskRow(
);
}
/**
* 派生/修改任务的一行。
*
* 与图片类生成的卡片同一套 class、同一套 tone,只有两点不同:① 没有「定位到素材」——原生账本
* 给的待办记录里没有产物 id,编一个指向不明的跳转不如不给;② 提示词单独一行,用户要能认出手上
* 这行是哪一次修改。
*/
function resourceEditTaskRow(
task: ResourceCanvasResourceEditTask,
nowMillis: number,
) {
const elapsedMillis = resourceCanvasResourceEditTaskElapsedMillis(
task,
nowMillis,
);
return (
<li
key={task.operationId}
className="game-resource-generation-task-card"
data-task-status={task.status}
data-task-source="resource-edit"
>
<div className="game-resource-generation-task-card-title-row">
<strong className="game-resource-generation-task-card-name">
{task.assetName}
</strong>
<span className="game-resource-generation-task-card-action">
{task.actionLabel}
</span>
</div>
<div className="game-resource-generation-task-card-meta">
<span
className="game-resource-generation-task-badge"
data-tone={resourceCanvasAssetGenerationTaskTone(task.status)}
>
{RESOURCE_CANVAS_ASSET_GENERATION_STATUS_LABELS[task.status]}
</span>
<span className="game-resource-generation-task-card-phase">
{task.phaseDetail}
</span>
{elapsedMillis === null ? null : (
<span className="game-resource-generation-task-card-elapsed">
{`已耗时 ${resourceCanvasResourceEditElapsedLabel(elapsedMillis)}`}
</span>
)}
</div>
{task.error ? (
<p className="game-resource-generation-task-card-error" role="alert">
{task.error}
</p>
) : null}
{task.prompt ? (
<p
className="game-resource-generation-task-card-prompt"
title={task.prompt}
>
{task.prompt}
</p>
) : null}
</li>
);
}
function resourceCanvasGenerationTaskRowNode(
row: ResourceCanvasGenerationTaskRow,
nowMillis: number,
onFocusTask: (task: ResourceCanvasAssetGenerationTask) => void,
) {
return row.source === 'asset-generation'
? assetGenerationTaskRow(row.task, nowMillis, onFocusTask)
: resourceEditTaskRow(row.task, nowMillis);
}
/**
* 画布上的「生成任务」侧栏(常驻、可折叠、非模态)。
*
@@ -103,6 +236,7 @@ function taskRow(
*/
export function ResourceCanvasAssetGenerationTasksPanelView({
tasks,
resourceEditTasks = [],
open,
onToggleOpen,
onFocusTask,
@@ -115,20 +249,33 @@ export function ResourceCanvasAssetGenerationTasksPanelView({
const [phase, setPhase] = useState<'idle' | 'entering' | 'leaving'>(
open ? 'entering' : 'idle',
);
const inFlightCount = tasks.filter(
(task) => !resourceCanvasAssetGenerationTaskIsTerminal(task),
).length;
const hasLiveTask = inFlightCount > 0;
const ordered = useMemo(
() => sortResourceCanvasAssetGenerationTasks(tasks),
[tasks],
/**
* 两套账本合成一条列表:图片类生成任务(后端生成账本)在前端本地队列里已按提交时间排序,
* 派生/修改任务(原生资源编辑账本)自带提交时间,这里统一按时间倒序,用户看到的就是
* 「我最近交出去的那几件事」。
*/
const ordered = useMemo<ResourceCanvasGenerationTaskRow[]>(
() =>
sortResourceCanvasGenerationTaskRows([
...sortResourceCanvasAssetGenerationTasks(tasks).map((task) => ({
source: 'asset-generation' as const,
createdAtMillis: task.createdAtMillis,
task,
})),
...resourceEditTasks.map((task) => ({
source: 'resource-edit' as const,
createdAtMillis: task.createdAtMillis,
task,
})),
]),
[resourceEditTasks, tasks],
);
const active = ordered.filter(
(task) => !resourceCanvasAssetGenerationTaskIsTerminal(task),
);
const done = ordered.filter((task) =>
resourceCanvasAssetGenerationTaskIsTerminal(task),
(row) => !resourceCanvasGenerationTaskRowIsTerminal(row),
);
const done = ordered.filter(resourceCanvasGenerationTaskRowIsTerminal);
const inFlightCount = active.length;
const hasLiveTask = inFlightCount > 0;
const visibleDone = done.slice(
0,
RESOURCE_CANVAS_ASSET_GENERATION_DONE_SECTION_LIMIT,
@@ -248,8 +395,12 @@ export function ResourceCanvasAssetGenerationTasksPanelView({
</p>
) : (
<ul className="game-resource-generation-tasks-list">
{rendered.active.map((task) =>
taskRow(task, nowMillis, onFocusTask),
{rendered.active.map((row) =>
resourceCanvasGenerationTaskRowNode(
row,
nowMillis,
onFocusTask,
),
)}
</ul>
)}
@@ -271,8 +422,12 @@ export function ResourceCanvasAssetGenerationTasksPanelView({
) : (
<>
<ul className="game-resource-generation-tasks-list">
{rendered.visibleDone.map((task) =>
taskRow(task, nowMillis, onFocusTask),
{rendered.visibleDone.map((row) =>
resourceCanvasGenerationTaskRowNode(
row,
nowMillis,
onFocusTask,
),
)}
</ul>
{rendered.done.length > rendered.visibleDone.length ? (
@@ -326,6 +326,17 @@
white-space: normal;
}
/* 派生/修改任务多一行提示词:用户要能认出这行是哪一次修改(图片类生成任务没有这一行)。 */
.game-resource-generation-task-card-prompt {
overflow: hidden;
color: var(--platform-text-muted);
font-size: 0.68rem;
font-style: italic;
line-height: 1.35;
text-overflow: ellipsis;
white-space: nowrap;
}
.game-resource-generation-task-locate {
justify-self: start;
border: 0;
@@ -0,0 +1,369 @@
import { formatElapsedDuration } from '../../../../../packages/shared/src/lib/formatElapsedDuration';
import type { ResourceCanvasAssetGenerationTaskStatus } from './resourceCanvasAssetGenerationTaskModel';
/**
* 原生资源编辑账本里的一条**未收口**操作(Rust `list_pending_local_project_resource_edits`)。
*
* `phase` 由 Rust 的 `ResourceEditLedgerPhase` 拥有:前端只做「阶段 → 面板状态」的映射与文案渲染,
* 不自己发明阶段,也不从别的字段反推进度。
*/
export type PendingLocalProjectResourceEdit = {
operationId: string;
editKind: string;
sourceResourceId: string;
assetName: string;
backgroundMode?: string | null;
screenColor?: string | null;
phase: string;
createdAt: number;
};
/**
* 本会话内**由本进程发起**的一次派生/修改提交。
*
* 原生账本要等 Rust 建好 ledger 才读得到,而「点了生成」到那一刻之间界面上一片空白;这份
* 本地记录就是补那一段的:提交当帧就进「生成任务」侧栏,收口时按终态改写。它与原生待办
* 列表按 `operationId` 合并,同一条不会出现两遍。
*/
export type ResourceCanvasResourceEditSubmission = {
operationId: string;
/** 输入区里那句需求,收进侧栏当副标题(与聊天 `@` 同源的原文)。 */
prompt: string;
assetName: string;
/** 入口文案:快速编辑 / 生成动画 / 图片抠图…,由 `pendingResourceEditKindLabel` 出。 */
actionLabel: string;
/**
* 快速编辑草稿键(资源路径);其它入口为 `null`。
*
* 提交期间这一笔草稿不再算「未完成编辑」——用户已经交出去了,恢复入口里不该再多一条
* 点开就重复提交的条目(见 `resourceCanvasQuickEditModel`)。
*/
draftKey: string | null;
createdAtMillis: number;
status: 'running' | 'completed' | 'failed';
error: string | null;
assetId: string | null;
finishedAtMillis: number | null;
};
/** 「生成任务」侧栏里的一条派生/修改任务:原生阶段 + 本地提交记录合并后的呈现单元。 */
export type ResourceCanvasResourceEditTask = {
operationId: string;
assetName: string;
actionLabel: string;
prompt: string;
status: ResourceCanvasAssetGenerationTaskStatus;
phaseDetail: string;
createdAtMillis: number;
finishedAtMillis: number | null;
error: string | null;
assetId: string | null;
/** 从原生账本恢复、本地没有对应提交记录的那一条:耗时不可知,面板不编时间。 */
restored: boolean;
};
/**
* 原生阶段 → 面板文案。
*
* 键逐字对应 Rust `ResourceEditLedgerPhase::as_str()`;措辞沿用恢复入口(「管理未完成编辑」)里
* 已经在用的那一套——同一条操作在两处必须叫同一个名字,否则用户会以为是两件事。未知阶段原样
* 透出阶段名,排障时能一眼看出前端还没跟上的新档,而不是显示一句编造的进度。
*/
export const RESOURCE_CANVAS_RESOURCE_EDIT_PHASE_LABELS: Record<
string,
string
> = {
prepared: '原请求已冻结',
accepted: '远程已受理',
'remote-completed': '远程已完成',
'media-downloaded': '结果已下载',
'remote-failed': '远程明确失败',
'reconciliation-required': '需要人工对账',
committed: '已写入项目',
archived: '已归档',
};
/** 本会话内刚提交、原生账本还没读回来的那一段自述文案。 */
export const RESOURCE_CANVAS_RESOURCE_EDIT_SUBMITTED_PHASE =
'已提交,等待远端受理';
/** 提交成功、本地已经拿到结果的收口文案。 */
export const RESOURCE_CANVAS_RESOURCE_EDIT_COMPLETED_PHASE = '已产出新素材';
/**
* `editKind` → 入口文案。
*
* 与恢复入口(`list_pending_local_project_resource_edits` 那张列表)共用同一个映射:同一条操作
* 在两处显示的入口名必须一致,否则用户会以为它们是两件事。
*/
export function pendingResourceEditKindLabel({
editKind,
backgroundMode,
screenColor,
}: Pick<
PendingLocalProjectResourceEdit,
'editKind' | 'backgroundMode' | 'screenColor'
>) {
if (editKind === 'image') return '图片编辑';
if (editKind === 'background-removal') {
if (backgroundMode === 'complex') return '图片抠图 · 复杂背景';
if (backgroundMode === 'flat') {
const color = screenColor === 'auto' ? '自动背景色' : screenColor;
return `图片抠图 · 平面背景${color ? ` · ${color}` : ''}`;
}
return '图片抠图';
}
if (editKind === 'text') return '文本编辑';
if (editKind === 'agent-result') return '智能体结果编辑';
if (editKind === 'image-reference') return '快速编辑';
if (editKind === 'character-animation') return '生成动画';
if (editKind === 'sound-effect') return '生成音效';
if (editKind === 'background-music') return '生成背景音乐';
if (editKind === 'video') return '生成视频';
return '资源编辑';
}
/**
* 原生阶段 → 面板状态。
*
* `prepared` 是「账本已建、远端请求还没发出」的本地排队档,映射成 `queued` 让面板把它和
* `running` 一起放进「排队/生成中」分栏;两个真失败档(远端失败、需要人工对账)映射成
* `failed`,用户才有可点的定位与可见的失败原因。
*/
export function resourceCanvasResourceEditTaskStatusFromPhase(
phase: string,
): ResourceCanvasAssetGenerationTaskStatus {
switch (phase) {
case 'prepared':
return 'queued';
case 'remote-failed':
case 'reconciliation-required':
return 'failed';
case 'committed':
case 'archived':
return 'completed';
default:
return 'running';
}
}
/** 未知阶段不编文案:原样显示阶段名,排障时能一眼看出前端还没跟上的档。 */
export function resourceCanvasResourceEditPhaseLabel(phase: string): string {
return RESOURCE_CANVAS_RESOURCE_EDIT_PHASE_LABELS[phase] ?? phase;
}
export function resourceCanvasResourceEditTaskIsTerminal(
task: Pick<ResourceCanvasResourceEditTask, 'status'>,
): boolean {
return task.status === 'completed' || task.status === 'failed';
}
export function resourceCanvasResourceEditSubmissionIsLive(
submission: Pick<ResourceCanvasResourceEditSubmission, 'status'>,
): boolean {
return submission.status === 'running';
}
/** 收口判据:这个草稿键上还有一笔没结束的提交。 */
export function resourceCanvasResourceEditSubmissionBlocksDraft(
submissions: readonly ResourceCanvasResourceEditSubmission[],
draftKey: string,
): boolean {
return submissions.some(
(submission) =>
submission.draftKey === draftKey &&
resourceCanvasResourceEditSubmissionIsLive(submission),
);
}
/**
* 记一笔刚发生的提交。
*
* 按 `operationId` 覆盖写入:同一笔重试(身份复用)不该在侧栏里堆出第二条;换了身份则是新的一笔,
* 两条并存本来就是用户看到的真相。
*/
export function beginResourceCanvasResourceEditSubmission(
submissions: readonly ResourceCanvasResourceEditSubmission[],
input: Omit<
ResourceCanvasResourceEditSubmission,
'status' | 'error' | 'assetId' | 'finishedAtMillis'
>,
): ResourceCanvasResourceEditSubmission[] {
const next: ResourceCanvasResourceEditSubmission = {
...input,
status: 'running',
error: null,
assetId: null,
finishedAtMillis: null,
};
return [
...submissions.filter(
(submission) => submission.operationId !== input.operationId,
),
next,
];
}
/** 收口一笔提交:成功给产物 id,失败给原因;两者都写结束时间,侧栏据此停表。 */
export function settleResourceCanvasResourceEditSubmission(
submissions: readonly ResourceCanvasResourceEditSubmission[],
operationId: string,
result:
| { status: 'completed'; assetId: string | null; finishedAtMillis: number }
| { status: 'failed'; error: string; finishedAtMillis: number },
): ResourceCanvasResourceEditSubmission[] {
return submissions.map((submission) =>
submission.operationId === operationId
? result.status === 'completed'
? {
...submission,
status: 'completed',
assetId: result.assetId,
error: null,
finishedAtMillis: result.finishedAtMillis,
}
: {
...submission,
status: 'failed',
assetId: null,
error: result.error,
finishedAtMillis: result.finishedAtMillis,
}
: submission,
);
}
/** 已结束的提交记录没有继续留着的价值:只保留在途的那几笔。 */
export function pruneResourceCanvasResourceEditSubmissions(
submissions: readonly ResourceCanvasResourceEditSubmission[],
): ResourceCanvasResourceEditSubmission[] {
return submissions.filter(resourceCanvasResourceEditSubmissionIsLive);
}
function resourceCanvasResourceEditTaskFromPendingEdit(
edit: PendingLocalProjectResourceEdit,
): ResourceCanvasResourceEditTask {
const status = resourceCanvasResourceEditTaskStatusFromPhase(edit.phase);
return {
operationId: edit.operationId,
assetName: edit.assetName,
actionLabel: pendingResourceEditKindLabel(edit),
prompt: '',
status,
phaseDetail: resourceCanvasResourceEditPhaseLabel(edit.phase),
createdAtMillis: edit.createdAt,
finishedAtMillis: null,
error:
status === 'failed'
? `原生账本停留在「${resourceCanvasResourceEditPhaseLabel(edit.phase)}」阶段`
: null,
assetId: null,
restored: true,
};
}
function resourceCanvasResourceEditTaskFromSubmission(
submission: ResourceCanvasResourceEditSubmission,
pending: PendingLocalProjectResourceEdit | undefined,
): ResourceCanvasResourceEditTask {
const live = resourceCanvasResourceEditSubmissionIsLive(submission);
return {
operationId: submission.operationId,
assetName: submission.assetName,
actionLabel: submission.actionLabel,
prompt: submission.prompt,
status: live
? pending
? resourceCanvasResourceEditTaskStatusFromPhase(pending.phase)
: 'running'
: submission.status,
// 在途期间以原生阶段为准(它才知道远端走到了哪一步);收口后由本地说结果。
phaseDetail: live
? pending
? resourceCanvasResourceEditPhaseLabel(pending.phase)
: RESOURCE_CANVAS_RESOURCE_EDIT_SUBMITTED_PHASE
: submission.status === 'failed'
? submission.error || '资源修改失败'
: RESOURCE_CANVAS_RESOURCE_EDIT_COMPLETED_PHASE,
createdAtMillis: submission.createdAtMillis,
finishedAtMillis: submission.finishedAtMillis,
error: live ? null : submission.error,
assetId: submission.assetId,
restored: false,
};
}
/**
* 原生待办账本 + 本地提交记录 → 侧栏里的派生/修改任务。
*
* 两个方向都要覆盖,与图片类生成任务的合并口径一致:本地提交按 `operationId` 用原生阶段刷新,
* 原生有、本地没有的记录(重开项目、别处提交、本会话已剪掉的历史)恢复成一条;本地已收口、
* 原生已经查不到的那几笔仍留在列表里——用户刚看到的结果不该因为账本收口而从面板上消失。
*/
export function resourceCanvasResourceEditTasks({
submissions,
pendingEdits,
}: {
submissions: readonly ResourceCanvasResourceEditSubmission[];
pendingEdits: readonly PendingLocalProjectResourceEdit[] | null | undefined;
}): ResourceCanvasResourceEditTask[] {
const safePending = Array.isArray(pendingEdits) ? pendingEdits : [];
const pendingByOperationId = new Map(
safePending.map((edit) => [edit.operationId, edit]),
);
const local = submissions.map((submission) =>
resourceCanvasResourceEditTaskFromSubmission(
submission,
pendingByOperationId.get(submission.operationId),
),
);
const knownOperationIds = new Set(
submissions.map((submission) => submission.operationId),
);
const restored = safePending
.filter((edit) => !knownOperationIds.has(edit.operationId))
.map(resourceCanvasResourceEditTaskFromPendingEdit);
return sortResourceCanvasResourceEditTasks([...local, ...restored]);
}
/** 面板展示顺序:新提交的在上。 */
export function sortResourceCanvasResourceEditTasks(
tasks: readonly ResourceCanvasResourceEditTask[],
): ResourceCanvasResourceEditTask[] {
return [...tasks].sort(
(left, right) =>
right.createdAtMillis - left.createdAtMillis ||
left.operationId.localeCompare(right.operationId),
);
}
/**
* 派生/修改任务的已耗时。
*
* 从原生账本恢复的收口记录没有结束时间(Rust 只给 `createdAt`),这里返回 `null`,由面板整格
* 不渲染——宁可不显示,也不拿创建时间冒充耗时。
*/
export function resourceCanvasResourceEditTaskElapsedMillis(
task: Pick<
ResourceCanvasResourceEditTask,
'createdAtMillis' | 'finishedAtMillis' | 'restored'
>,
nowMillis: number,
): number | null {
if (task.restored && task.finishedAtMillis === null) {
return null;
}
return Math.max(
0,
(task.finishedAtMillis ?? nowMillis) - task.createdAtMillis,
);
}
/** 与图片类生成任务共用一套耗时文案(`formatElapsedDuration` 的唯一入口)。 */
export function resourceCanvasResourceEditElapsedLabel(
elapsedMillis: number | null,
): string {
if (elapsedMillis === null) {
return '—';
}
return formatElapsedDuration(elapsedMillis) ?? '—';
}
@@ -218,6 +218,17 @@ import {
restoreResourceQuickEditPanelDraft,
writeResourceQuickEditDraft,
} from '../../features/resource-canvas/resourceCanvasQuickEditModel';
import {
beginResourceCanvasResourceEditSubmission,
type PendingLocalProjectResourceEdit,
pendingResourceEditKindLabel,
RESOURCE_CANVAS_RESOURCE_EDIT_PHASE_LABELS,
type ResourceCanvasResourceEditSubmission,
resourceCanvasResourceEditSubmissionBlocksDraft,
resourceCanvasResourceEditTaskIsTerminal,
resourceCanvasResourceEditTasks,
settleResourceCanvasResourceEditSubmission,
} from '../../features/resource-canvas/resourceCanvasResourceEditTaskModel';
import {
canNormalizeResourceIntoManifestAsset,
projectResourceToCanvasLayer,
@@ -515,17 +526,6 @@ type DeriveLocalProjectResourceResult = {
manifest: GameCreationAppManifest;
};
type PendingLocalProjectResourceEdit = {
operationId: string;
editKind: string;
sourceResourceId: string;
assetName: string;
backgroundMode?: string | null;
screenColor?: string | null;
phase: string;
createdAt: number;
};
/**
*
*
@@ -562,33 +562,15 @@ type ResourceEditServiceIdentityConfirmation = {
expiresAt: number;
};
function pendingResourceEditKindLabel({
editKind,
backgroundMode,
screenColor,
}: PendingLocalProjectResourceEdit) {
if (editKind === 'image') return '图片编辑';
if (editKind === 'background-removal') {
if (backgroundMode === 'complex') return '图片抠图 · 复杂背景';
if (backgroundMode === 'flat') {
const color = screenColor === 'auto' ? '自动背景色' : screenColor;
return `图片抠图 · 平面背景${color ? ` · ${color}` : ''}`;
}
return '图片抠图';
}
if (editKind === 'text') return '文本编辑';
if (editKind === 'agent-result') return '智能体结果编辑';
return '资源编辑';
}
/**
*
*
* `RESOURCE_CANVAS_RESOURCE_EDIT_PHASE_LABELS`
*
*
*/
function pendingResourceEditPhaseLabel(phase: string) {
if (phase === 'prepared') return '原请求已冻结';
if (phase === 'accepted') return '远程已受理';
if (phase === 'remote-completed') return '远程已完成';
if (phase === 'media-downloaded') return '结果已下载';
if (phase === 'remote-failed') return '远程明确失败';
if (phase === 'reconciliation-required') return '需要人工对账';
return '等待恢复';
return RESOURCE_CANVAS_RESOURCE_EDIT_PHASE_LABELS[phase] ?? '等待恢复';
}
function pendingResourceEditCreatedAtLabel(createdAt: number) {
@@ -2166,6 +2148,20 @@ export default function ProjectDevelopmentView({
const [pendingResourceEdits, setPendingResourceEdits] = useState<
PendingLocalProjectResourceEdit[]
>([]);
/**
* ****/
*
* Rust ledger
*
* `operationId`
*/
const [resourceEditSubmissions, setResourceEditSubmissions] = useState<
ResourceCanvasResourceEditSubmission[]
>([]);
const resourceEditSubmissionsRef = useRef<
ResourceCanvasResourceEditSubmission[]
>([]);
resourceEditSubmissionsRef.current = resourceEditSubmissions;
const [pendingResourceEditsLoadState, setPendingResourceEditsLoadState] =
useState<PendingResourceEditsLoadState>('idle');
const [pendingResourceEditsError, setPendingResourceEditsError] =
@@ -4897,6 +4893,57 @@ export default function ProjectDevelopmentView({
};
}, [refreshPendingResourceEdits]);
/**
* /
*
*
*/
const beginResourceEditSubmission = useCallback(
(input: {
operationId: string;
assetName: string;
actionLabel: string;
prompt: string;
draftKey: string | null;
}) => {
setResourceEditSubmissions((current) =>
beginResourceCanvasResourceEditSubmission(current, {
...input,
createdAtMillis: Date.now(),
}),
);
},
[],
);
/**
* /
*
*
*
*/
const settleResourceEditSubmission = useCallback(
(
operationId: string,
result:
| { status: 'completed'; assetId: string | null }
| { status: 'failed'; error: string },
) => {
const finishedAtMillis = Date.now();
setResourceEditSubmissions((current) =>
settleResourceCanvasResourceEditSubmission(
current,
operationId,
result.status === 'completed'
? { status: 'completed', assetId: result.assetId, finishedAtMillis }
: { status: 'failed', error: result.error, finishedAtMillis },
),
);
void refreshPendingResourceEdits();
},
[refreshPendingResourceEdits],
);
const openResourceRecoveryPanel = useCallback(() => {
resourceRecoveryPanelEpochRef.current += 1;
setResourceRecoveryPanelOpen(true);
@@ -7506,14 +7553,24 @@ export default function ProjectDevelopmentView({
/**
* 稿****
* id id 稿
*
* ****
*
* failed稿
*/
const resourceQuickEditDraftEntries = useMemo(
() =>
listResourceQuickEditDraftEntries(
resourceQuickEditDrafts,
canvasResources,
).filter(
(entry) =>
!resourceCanvasResourceEditSubmissionBlocksDraft(
resourceEditSubmissions,
entry.key,
),
),
[canvasResources, resourceQuickEditDrafts],
[canvasResources, resourceEditSubmissions, resourceQuickEditDrafts],
);
/**
@@ -7674,6 +7731,30 @@ export default function ProjectDevelopmentView({
}
return;
}
/*
****
22:05:16 22:05:34
operationId /
稿
*/
if (
resourceCanvasResourceEditSubmissionBlocksDraft(
resourceEditSubmissionsRef.current,
resource.path,
)
) {
setQuickEditPanel((current) =>
current
? {
...current,
status: 'failed',
errorMessage: '这张素材正在修改中,请等它出结果后再提交',
}
: current,
);
return;
}
/*
**** Rust Rust
`normalize_resource_edit_name``resource_editor.rs:802-808`trim / 120
@@ -7741,6 +7822,15 @@ export default function ProjectDevelopmentView({
? { ...current, status: 'generating', errorMessage: undefined }
: current,
);
// 提交当帧就上「生成任务」侧栏:原生账本要等 Rust 建好 ledger 才读得到,中间那段空白
// 正是验收现场看到的「点了生成,生成任务里什么都没有」。
beginResourceEditSubmission({
operationId: request.operationId,
assetName: derivedName,
actionLabel: '快速编辑',
prompt,
draftKey: resource.path,
});
try {
const { sourceResourceId, sourceAssetId, expectedProjectRevision } =
await resolveResourceDeriveSource({
@@ -7802,23 +7892,32 @@ export default function ProjectDevelopmentView({
// 多出一条重复条目。键是源素材路径,正规化换掉卡片 id 也不影响这一笔的归属。
discardResourceQuickEditDraft(resource.path);
resourceQuickEditDraftPathRef.current = null;
settleResourceEditSubmission(request.operationId, {
status: 'completed',
assetId: result.asset.id,
});
setQuickEditPanel(null);
setQuickEditSourceLayer(null);
resourceQuickEditRequestRef.current = null;
setResourceWorkbenchNotice('快速编辑已产出新素材,正在同步资源与布局…');
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
settleResourceEditSubmission(request.operationId, {
status: 'failed',
error: message,
});
setQuickEditPanel((current) =>
current
? {
...current,
status: 'failed',
errorMessage:
error instanceof Error ? error.message : String(error),
errorMessage: message,
}
: current,
);
}
}, [
beginResourceEditSubmission,
discardResourceQuickEditDraft,
manifest,
onManifestChange,
@@ -7827,6 +7926,7 @@ export default function ProjectDevelopmentView({
quickEditSourceLayer,
resolveQuickEditSourceResource,
resolveResourceDeriveSource,
settleResourceEditSubmission,
]);
/**
@@ -7985,6 +8085,14 @@ export default function ProjectDevelopmentView({
? { ...current, status: 'generating', errorMessage: undefined }
: current,
);
// 与快速编辑同口径:这一笔从按下按钮那一刻就进「生成任务」侧栏。
beginResourceEditSubmission({
operationId: request.operationId,
assetName: derivedName,
actionLabel: '生成动画',
prompt,
draftKey: null,
});
try {
const { sourceResourceId, sourceAssetId, expectedProjectRevision } =
await resolveResourceDeriveSource({
@@ -8045,20 +8153,29 @@ export default function ProjectDevelopmentView({
setCharacterAnimationPanel(null);
setCharacterAnimationSourceLayer(null);
resourceCharacterAnimationRequestRef.current = null;
settleResourceEditSubmission(request.operationId, {
status: 'completed',
assetId: result.asset.id,
});
setResourceWorkbenchNotice('生成动画已产出新素材,正在同步资源与布局…');
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
settleResourceEditSubmission(request.operationId, {
status: 'failed',
error: message,
});
setCharacterAnimationPanel((current) =>
current
? {
...current,
status: 'failed',
errorMessage:
error instanceof Error ? error.message : String(error),
errorMessage: message,
}
: current,
);
}
}, [
beginResourceEditSubmission,
canvasResources,
characterAnimationPanel,
characterAnimationSourceLayer,
@@ -8066,6 +8183,7 @@ export default function ProjectDevelopmentView({
onManifestChange,
projectPath,
resolveResourceDeriveSource,
settleResourceEditSubmission,
]);
/**
@@ -8112,6 +8230,14 @@ export default function ProjectDevelopmentView({
const option = resourceCanvasGenerationOption(input.kind);
const actionProject = { projectPath, projectId: manifest.projectId };
const flowId = crypto.randomUUID();
// 与快速编辑 / 生成动画同口径:这一笔从按下按钮那一刻就进「生成任务」侧栏。
beginResourceEditSubmission({
operationId: input.operationId,
assetName: input.assetName,
actionLabel: option.generationLabel,
prompt: input.prompt,
draftKey: null,
});
try {
const status = await invoke<{ revision: number }>(
'get_local_game_project_revision',
@@ -8173,6 +8299,10 @@ export default function ProjectDevelopmentView({
completed: false,
};
setResourceWorkbenchNotice('生成资源已保存,正在同步资源与布局…');
settleResourceEditSubmission(input.operationId, {
status: 'completed',
assetId: result.asset.id,
});
if (placeholder) {
/*
** section******
@@ -8187,24 +8317,28 @@ export default function ProjectDevelopmentView({
}
setResourceGenerationDraft(null);
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
settleResourceEditSubmission(input.operationId, {
status: 'failed',
error: message,
});
/*
沿 operationId
*/
if (placeholder) {
resourceGenerationPlaceholders.failTask(
input.operationId,
error instanceof Error ? error.message : String(error),
);
resourceGenerationPlaceholders.failTask(input.operationId, message);
}
throw error;
}
},
[
beginResourceEditSubmission,
manifest.projectId,
onManifestChange,
projectPath,
resourceGenerationPlaceholders,
settleResourceEditSubmission,
],
);
@@ -8244,12 +8378,29 @@ export default function ProjectDevelopmentView({
activePageCategory,
};
resourceWorkbenchNoticeRef.current = resourceWorkbenchNotice;
/** 入口按钮与折叠把手上显示的在途数量:只数当前项目的未终态任务。 */
/**
* / +
*
* /
*
*/
const resourceCanvasResourceEdits = useMemo(
() =>
resourceCanvasResourceEditTasks({
submissions: resourceEditSubmissions,
pendingEdits: pendingResourceEdits,
}),
[pendingResourceEdits, resourceEditSubmissions],
);
/** 入口按钮与折叠把手上显示的在途数量:只数当前项目的未终态任务(两套账本都算)。 */
const resourceAssetGenerationInFlightCount =
resourceAssetGenerationTasks.filter(
(task) =>
task.projectId === manifest.projectId &&
!resourceCanvasAssetGenerationTaskIsTerminal(task),
).length +
resourceCanvasResourceEdits.filter(
(task) => !resourceCanvasResourceEditTaskIsTerminal(task),
).length;
const replaceResourceAssetGenerationTask = useCallback(
@@ -10630,6 +10781,10 @@ export default function ProjectDevelopmentView({
tasks={resourceAssetGenerationTasks.filter(
(task) => task.projectId === manifest.projectId,
)}
resourceEditTasks={resourceCanvasResourceEdits.filter(
(task) =>
!task.restored || !resourceCanvasResourceEditTaskIsTerminal(task),
)}
open={resourceAssetGenerationTasksPanelOpen}
onToggleOpen={() =>
setResourceAssetGenerationTasksPanelOpen((current) => !current)
@@ -122,6 +122,11 @@ function installInvoke(
createManifest?: () => GameCreationAppManifest;
/** 派生一律失败:用于把面板留在失败态、只验证草稿的去向。 */
failDerive?: boolean;
/**
* 派生挂起门:测试拿住它就能停在「已经提交、结果还没回来」那一段,验证提交期间的界面。
* 不传时派生立即返回,行为与从前一致。
*/
deriveGate?: Promise<void>;
} = {},
) {
const createManifest = options.createManifest ?? createDraftManifest;
@@ -233,6 +238,9 @@ function installInvoke(
if (options.failDerive) {
throw new Error('result-unknown: 测试网络中断');
}
if (options.deriveGate) {
await options.deriveGate;
}
const operationId = String(input.operationId);
const assetId = `edit-${operationId}`;
const asset = {
@@ -705,3 +713,95 @@ describe('快速编辑草稿的保留与恢复', () => {
expect(panelPromptText(reopened)).not.toContain('把夜色改成星空');
});
});
/**
* 验收现场那条「点了生成,生成任务里什么都没有」:派生链路既没进生成任务侧栏,又让恢复入口
* 继续列着同一条草稿,用户据此判断「这次没提交」。这里钉住三件事——提交当帧就进侧栏、提交
* 期间那笔草稿让位、有结果之后收口为已完成。
*/
describe('提交中的快速编辑进「生成任务」侧栏', () => {
it('提交当帧进侧栏并显示阶段与提示词,结果回来后收口为已完成', async () => {
installResizeObserverStub();
let releaseDerive = () => {};
const deriveGate = new Promise<void>((resolve) => {
releaseDerive = resolve;
});
const { deriveCalls } = installInvoke({ deriveGate });
render(React.createElement(DraftWorkbench));
await openResourceBookCategory('待归类');
const panel = await openQuickEditPanel('source-art.png');
await setComposerText(
within(panel).getByLabelText('快速编辑提示词'),
'把嘴改小一点',
);
fireEvent.click(within(panel).getByRole('button', { name: '修改' }));
await waitFor(() => expect(deriveCalls).toHaveLength(1));
// 在途计数把这一笔算进来:入口按钮带上数量,侧栏里能看到素材名、入口文案与提示词。
const entry = await screen.findByRole('button', { name: '生成任务' });
await waitFor(() =>
expect(entry.getAttribute('data-resource-generation-task-count')).toBe(
'1',
),
);
fireEvent.click(entry);
const sidebar = await screen.findByRole('region', { name: '生成任务' });
expect(within(sidebar).getByText('source-art-编辑版')).not.toBeNull();
expect(within(sidebar).getByText('快速编辑')).not.toBeNull();
expect(within(sidebar).getByText('把嘴改小一点')).not.toBeNull();
expect(within(sidebar).getByText('生成中')).not.toBeNull();
expect(within(sidebar).queryByText('还没有生成任务')).toBeNull();
releaseDerive();
await waitFor(() =>
expect(within(sidebar).getByText('已完成')).not.toBeNull(),
);
expect(within(sidebar).getByText('已产出新素材')).not.toBeNull();
});
it('同一张素材还在提交中时换卡再回来提交:拦下且不重复派生', async () => {
installResizeObserverStub();
let releaseDerive = () => {};
const deriveGate = new Promise<void>((resolve) => {
releaseDerive = resolve;
});
const { deriveCalls } = installInvoke({ deriveGate });
render(React.createElement(DraftWorkbench));
await openResourceBookCategory('待归类');
const first = await openQuickEditPanel('source-art.png');
await setComposerText(
within(first).getByLabelText('快速编辑提示词'),
'把嘴改小一点',
);
fireEvent.click(within(first).getByRole('button', { name: '修改' }));
await waitFor(() => expect(deriveCalls).toHaveLength(1));
// 换到另一张卡:浮层互斥把第一张的面板收起来,而第一笔提交仍在后台跑。工具条这时才露出来,
// 所以「已经交出去的那一笔不再算未完成编辑」这条判据只能在这里验(面板开着时工具条被盖住,
// 在那个位置断言会变成永远为真的空断言)。
fireEvent.click(await findResourceSelectButton('source-art-2.png'));
await waitFor(() =>
expect(screen.queryByRole('dialog', { name: '快速编辑图片' })).toBeNull(),
);
await waitFor(() =>
expect(
screen.queryByRole('button', { name: //u }),
).toBeNull(),
);
// 回到第一张:草稿照旧回填,但这一笔已经在跑——再点提交必须被拦下。
const reopened = await openQuickEditPanel('source-art.png');
expect(panelPromptText(reopened)).toContain('把嘴改小一点');
fireEvent.click(within(reopened).getByRole('button', { name: '修改' }));
expect(
await within(reopened).findByText(
'这张素材正在修改中,请等它出结果后再提交',
),
).not.toBeNull();
expect(deriveCalls).toHaveLength(1);
releaseDerive();
});
});
@@ -0,0 +1,269 @@
import { describe, expect, it } from 'vitest';
import {
beginResourceCanvasResourceEditSubmission,
type PendingLocalProjectResourceEdit,
pendingResourceEditKindLabel,
resourceCanvasResourceEditPhaseLabel,
type ResourceCanvasResourceEditSubmission,
resourceCanvasResourceEditSubmissionBlocksDraft,
resourceCanvasResourceEditTaskElapsedMillis,
resourceCanvasResourceEditTaskIsTerminal,
resourceCanvasResourceEditTasks,
resourceCanvasResourceEditTaskStatusFromPhase,
settleResourceCanvasResourceEditSubmission,
} from '../src/features/resource-canvas/resourceCanvasResourceEditTaskModel';
function pendingEdit(
overrides: Partial<PendingLocalProjectResourceEdit> = {},
): PendingLocalProjectResourceEdit {
return {
operationId: 'op-1',
editKind: 'image-reference',
sourceResourceId: 'asset:source-art',
assetName: 'source-art-编辑版',
backgroundMode: null,
screenColor: null,
phase: 'accepted',
createdAt: 1_700_000_000_000,
...overrides,
};
}
function liveSubmission(
overrides: Partial<ResourceCanvasResourceEditSubmission> = {},
): ResourceCanvasResourceEditSubmission {
return {
operationId: 'op-1',
prompt: '把嘴改小一点',
assetName: 'source-art-编辑版',
actionLabel: '快速编辑',
draftKey: 'assets/source-art.png',
createdAtMillis: 1_700_000_000_000,
status: 'running',
error: null,
assetId: null,
finishedAtMillis: null,
...overrides,
};
}
describe('资源派生任务:阶段映射', () => {
it('按原生账本阶段给出面板状态', () => {
expect(resourceCanvasResourceEditTaskStatusFromPhase('prepared')).toBe(
'queued',
);
expect(resourceCanvasResourceEditTaskStatusFromPhase('accepted')).toBe(
'running',
);
expect(
resourceCanvasResourceEditTaskStatusFromPhase('remote-completed'),
).toBe('running');
expect(
resourceCanvasResourceEditTaskStatusFromPhase('media-downloaded'),
).toBe('running');
expect(resourceCanvasResourceEditTaskStatusFromPhase('committed')).toBe(
'completed',
);
expect(resourceCanvasResourceEditTaskStatusFromPhase('archived')).toBe(
'completed',
);
expect(resourceCanvasResourceEditTaskStatusFromPhase('remote-failed')).toBe(
'failed',
);
expect(
resourceCanvasResourceEditTaskStatusFromPhase('reconciliation-required'),
).toBe('failed');
});
it('阶段文案与恢复入口同源,未知阶段原样透出', () => {
expect(resourceCanvasResourceEditPhaseLabel('remote-completed')).toBe(
'远程已完成',
);
expect(resourceCanvasResourceEditPhaseLabel('committed')).toBe(
'已写入项目',
);
// 前端还没跟上的新档:给出阶段名,便于排障时一眼认出。
expect(resourceCanvasResourceEditPhaseLabel('pending-review')).toBe(
'pending-review',
);
});
it('入口文案覆盖派生链路的几种 kind', () => {
expect(pendingResourceEditKindLabel(pendingEdit())).toBe('快速编辑');
expect(
pendingResourceEditKindLabel(
pendingEdit({ editKind: 'character-animation' }),
),
).toBe('生成动画');
expect(
pendingResourceEditKindLabel(
pendingEdit({ editKind: 'background-music' }),
),
).toBe('生成背景音乐');
expect(
pendingResourceEditKindLabel(
pendingEdit({
editKind: 'background-removal',
backgroundMode: 'flat',
screenColor: 'auto',
}),
),
).toBe('图片抠图 · 平面背景 · 自动背景色');
});
});
describe('资源派生任务:本地提交记录', () => {
it('同一 operationId 只留一条,收口后按结果改写', () => {
const begun = beginResourceCanvasResourceEditSubmission([], {
operationId: 'op-1',
assetName: 'source-art-编辑版',
actionLabel: '快速编辑',
prompt: '把嘴改小一点',
draftKey: 'assets/source-art.png',
createdAtMillis: 1_700_000_000_000,
});
expect(begun).toHaveLength(1);
expect(begun[0]?.status).toBe('running');
const rebegun = beginResourceCanvasResourceEditSubmission(begun, {
operationId: 'op-1',
assetName: 'source-art-编辑版',
actionLabel: '快速编辑',
prompt: '把嘴改小一点',
draftKey: 'assets/source-art.png',
createdAtMillis: 1_700_000_001_000,
});
expect(rebegun).toHaveLength(1);
const settled = settleResourceCanvasResourceEditSubmission(
rebegun,
'op-1',
{
status: 'completed',
assetId: 'edit-1',
finishedAtMillis: 1_700_000_120_000,
},
);
expect(settled[0]).toMatchObject({
status: 'completed',
assetId: 'edit-1',
error: null,
});
});
it('失败收口带原因,未完成草稿的提交门随之放行', () => {
const live = beginResourceCanvasResourceEditSubmission([], {
operationId: 'op-1',
assetName: 'source-art-编辑版',
actionLabel: '快速编辑',
prompt: '把嘴改小一点',
draftKey: 'assets/source-art.png',
createdAtMillis: 1_700_000_000_000,
});
expect(
resourceCanvasResourceEditSubmissionBlocksDraft(
live,
'assets/source-art.png',
),
).toBe(true);
// 别的素材不受影响,其它入口(没有草稿键)也不受这条判据牵制。
expect(
resourceCanvasResourceEditSubmissionBlocksDraft(live, 'assets/other.png'),
).toBe(false);
const failed = settleResourceCanvasResourceEditSubmission(live, 'op-1', {
status: 'failed',
error: '登录已失效,请重新登录',
finishedAtMillis: 1_700_000_090_000,
});
expect(
resourceCanvasResourceEditSubmissionBlocksDraft(
failed,
'assets/source-art.png',
),
).toBe(false);
});
});
describe('资源派生任务:两套账本合并', () => {
it('在途期间以后端阶段为准,收口后以本地结果为准', () => {
const submissions = [liveSubmission()];
const inFlight = resourceCanvasResourceEditTasks({
submissions,
pendingEdits: [pendingEdit({ phase: 'media-downloaded' })],
});
expect(inFlight).toHaveLength(1);
expect(inFlight[0]).toMatchObject({
operationId: 'op-1',
status: 'running',
phaseDetail: '结果已下载',
prompt: '把嘴改小一点',
restored: false,
});
const settled = resourceCanvasResourceEditTasks({
submissions: settleResourceCanvasResourceEditSubmission(
submissions,
'op-1',
{
status: 'completed',
assetId: 'edit-1',
finishedAtMillis: 1_700_000_120_000,
},
),
pendingEdits: [],
});
expect(settled[0]).toMatchObject({
status: 'completed',
phaseDetail: '已产出新素材',
assetId: 'edit-1',
});
expect(resourceCanvasResourceEditTaskIsTerminal(settled[0]!)).toBe(true);
});
it('原生有、本地没有的记录恢复成一条待办,但不占用已完成分栏', () => {
const tasks = resourceCanvasResourceEditTasks({
submissions: [],
pendingEdits: [
pendingEdit({ operationId: 'op-9', phase: 'remote-failed' }),
],
});
expect(tasks).toHaveLength(1);
expect(tasks[0]).toMatchObject({
operationId: 'op-9',
status: 'failed',
restored: true,
actionLabel: '快速编辑',
});
expect(tasks[0]?.error).toContain('远程明确失败');
// 恢复记录没有结束时间:不拿创建时间冒充耗时。
expect(
resourceCanvasResourceEditTaskElapsedMillis(tasks[0]!, 1_700_000_500_000),
).toBeNull();
});
it('后端读回非数组时不炸,只按本地提交记录渲染', () => {
const tasks = resourceCanvasResourceEditTasks({
submissions: [liveSubmission()],
pendingEdits: undefined,
});
expect(tasks).toHaveLength(1);
expect(tasks[0]?.phaseDetail).toBe('已提交,等待远端受理');
});
it('新提交的排在前面', () => {
const tasks = resourceCanvasResourceEditTasks({
submissions: [
liveSubmission({
operationId: 'op-new',
createdAtMillis: 1_700_000_200_000,
}),
],
pendingEdits: [
pendingEdit({ operationId: 'op-old', createdAt: 1_700_000_000_000 }),
],
});
expect(tasks.map((task) => task.operationId)).toEqual(['op-new', 'op-old']);
});
});
@@ -1,6 +1,6 @@
# AI 游戏创作项目开发工作台 PRD
更新时间:`2026-09-14`(同步功能画布图片类生成后台化的当前事实:入口 IPC 改为 `start_local_project_asset_generation` + 项目内任务账本 + 本地排队 + 非模态「生成任务」面板,见 §3.10 / §7.9;2026-09-13 新增的功能画布底部工具栏入口矩阵 §3.10 / §7.9,以及右侧 Supervisor 对话气泡、可访问对比度与过程卡布局收口,资源卡预览、分区布局、非破坏性资源编辑、资源替换与 Godot 双根合同保持不变)
更新时间:`2026-09-20`派生/修改类任务并入同一「生成任务」侧栏,见 §3.10;2026-09-14 同步功能画布图片类生成后台化的当前事实:入口 IPC 改为 `start_local_project_asset_generation` + 项目内任务账本 + 本地排队 + 非模态「生成任务」面板,见 §3.10 / §7.9;2026-09-13 新增的功能画布底部工具栏入口矩阵 §3.10 / §7.9,以及右侧 Supervisor 对话气泡、可访问对比度与过程卡布局收口,资源卡预览、分区布局、非破坏性资源编辑、资源替换与 Godot 双根合同保持不变)
## 1. 产品定位
@@ -171,6 +171,7 @@
- 前置规范图:`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 已存在,禁止静默覆盖」),改为生成一张新的普通图标规范资产。这条前端策略不扩接口。
- 音频入口收敛:音频画布的「生成背景音乐 / 生成音效」由工具栏承载,既有「生成素材」浮层入口**只保留生成视频**(视频能力不删,只是不出现在工具栏里)。
- 派生/修改类任务并入同一侧栏(2026-09-20):快速编辑、生成动画、视频 / 音效 / 背景音乐与抠图这类走**资源编辑链路**的任务也进「生成任务」侧栏。数据源 = 原生资源编辑账本 `list_pending_local_project_resource_edits` + **本会话的本地提交记录**,按 `operationId` 合并去重:本地那份负责「按下提交当帧可见」,原生那份负责「重开项目、别处提交也看得见,阶段文案以后端为首」。这类任务不写图片类生成账本(`.agent/runtime/asset-generation-tasks/tasks.json`),也不改原生资源编辑账本 schema。配套两条口径:提交期间该笔不再列进「管理未完成编辑」(失败后自动回落成未完成草稿,提示词不丢);同一张素材还有在途提交时,重开面板再提交会被拦下并给出原因,避免重复派生与重复计费。
- 本轮明确不做:生成视频(保留在既有浮层入口)、宣发素材、生成游戏场景、画布级「选择工具 / 抓手工具」(AGC 现无画布级工具模式)。这五类都**不得**出现在工具栏里。
- 本地 IPC 边界(前端只做本地收窄,不改接口):提示词上限 `32000` 字符、素材名上限 `120` 字符、比例白名单 `1:1 / 2:3 / 3:2 / 9:16 / 16:9`、尺寸白名单 `0.5K / 1K / 2K`
@@ -9031,3 +9031,13 @@ CI 上 `background_agent_runtime_recovers_stale_running_before_pending_task` 在
- 发号顺序固定「先写总号 → 再构建 → 再发渠道清单」,失败不回滚只烧号;统一构建发一次号供 `dev-win` / `dev-mac` 共用,单渠道热修只作用于该渠道。
- 发号收口到 Jenkins Job `Genarrative-Agc-Global-Version-Issue``disableConcurrentBuilds()`;集群无 `lockable-resources`,以写后回读不一致即失败关闭兜底并发)。
- 原渠道高水位逻辑降级为断言:请求号低于本渠道清单版本即失败关闭;`AGC_RELEASE_DRY_RUN` 只预览不烧号。
## 2026-09-20 派生/修改类任务并入「生成任务」侧栏(AGC-039 / AGC-040
- 背景:客户端验收现场,快速编辑提交后画布右上角「生成任务」全程是「还没有生成任务」,同一条草稿又还挂在「管理未完成编辑」里,用户据此判断「点了没生成」;随后同一次修改被重复提交,同一张源图派生了两份一模一样的「-编辑版」。
- 决策(取数):派生/修改类任务(快速编辑、生成动画、视频 / 音效 / 背景音乐、抠图)进图片类生成任务**同一个**「生成任务」侧栏,数据源 = 原生资源编辑账本 `list_pending_local_project_resource_edits`(重开项目、别处提交、阶段文案以后端为首)+ 本会话**本地提交记录**(按下提交当帧即可见),两边按 `operationId` 合并去重。**不改**原生资源编辑账本 schema,也**不**把派生任务写进图片类生成账本 `.agent/runtime/asset-generation-tasks/tasks.json`——两套账本各管各的事实,只在视图层合并。
- 决策(草稿口径):提交进行中的那一笔不再列进「管理未完成编辑」;失败回落自动把它还回未完成列表,提示词与 `@` 引用不丢。
- 决策(重复提交):同一张素材存在在途提交时,重新打开面板再提交会被拦下(可见原因),不重铸 operation 身份。**未做**「同提示词成功后再提交」的去重——那是用户显式重复的付费动作,本批只消除误触来源。
- 理由:用户对「生成」的心智是「交出去就得能看见它在跑」,而不是必须区分两条账本;而防重复必须在**身份重铸之前**拦,等到原生账本判重时已经派生过一次。
- 验证:新增模型用例 9 条、快速编辑两组宿主用例(提交当帧进侧栏并收口 / 在途重复提交被拦),两处新判据做过变异验证;定向 7 个文件 97 条全绿;`tests/appSurface.test.ts` 与改动前基线逐条一致(16 失败均为本分支既有)。
- 待办:远程 CI 与真实客户端验收未跑;`resourceCanvasGenerationTasksSidebarDismiss` 等样式结构用例在本分支既有失败,与本批无关,另行跟踪。
@@ -21,6 +21,14 @@
- 明确保留未改:发送前提醒面板里「AI 润色并发送」遇到原样回包仍直接提交(按用户意图发出去,不做二次拦截)。
- 仍未做:动画生成失败的真实复现与定位、文档全文入口、标签面板查看更多标签、多选卡死,以及卡片全类型重设计、画布内任务浮层、替换交互与聊天引用这批后续需求。
### 2026-09-20 第二批(AGC-039 / AGC-040,待客户端验收)
现场(项目 `gameagent-6e53c9e8`):快速编辑提交后画布右上角「生成任务」全程是「还没有生成任务」,而「管理未完成编辑」里仍挂着同一条草稿——用户据此判断"点了没生成";同一次修改在 22:05:16 与 22:05:34 各派生一次,manifest 多出两份重复的「-编辑版」。
- 交付三条口径(本批一起改):① 派生/修改类任务进「生成任务」侧栏——本地提交记录负责"按下按钮当帧可见",原生 `list_pending_local_project_resource_edits` 负责"重开项目 / 别处提交也看得见、阶段以后端为准",两边按 `operationId` 合并去重;② 提交期间那一笔不再算「未完成编辑」(失败后自动回落,草稿不丢);③ 同一张素材还有在途提交时,重开面板再提交会被拦下并给出原因,避免重复派生。
- 已知未做:成功之后**再次提交**仍会再派生一次(按用户显式重复计费),前端没有做"同提示词去重";原生账本 schema、Rust 门禁与请求载荷都没有改动。
- 本机执行过:新增模型用例 9 条与快速编辑两套新用例(提交当帧进侧栏 / 收口为已完成、在途重复提交被拦),两处新判据都做过变异验证(改回旧实现即红灯);定向 7 个文件 97 条全绿;`tests/appSurface.test.ts` 与本批基线逐条一致(16 失败均为本分支既有,未新增);`npm --prefix apps/ai-game-creator-shell run typecheck` 通过。远程 CI 与真实客户端验收仍未跑。
## 后续需求
- 资源卡顶部独立名称区与全类型统一样式,确认 assetName/用户素材名的正式来源。