diff --git a/apps/ai-game-creator-shell/src/features/resource-canvas/ResourceCanvasAssetGenerationTasksPanelView.tsx b/apps/ai-game-creator-shell/src/features/resource-canvas/ResourceCanvasAssetGenerationTasksPanelView.tsx
index ed1a33d0b..0a6cc43ff 100644
--- a/apps/ai-game-creator-shell/src/features/resource-canvas/ResourceCanvasAssetGenerationTasksPanelView.tsx
+++ b/apps/ai-game-creator-shell/src/features/resource-canvas/ResourceCanvasAssetGenerationTasksPanelView.tsx
@@ -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 (
+
+
+
+ {task.assetName}
+
+
+ {task.actionLabel}
+
+
+
+
+ {RESOURCE_CANVAS_ASSET_GENERATION_STATUS_LABELS[task.status]}
+
+
+ {task.phaseDetail}
+
+ {elapsedMillis === null ? null : (
+
+ {`已耗时 ${resourceCanvasResourceEditElapsedLabel(elapsedMillis)}`}
+
+ )}
+
+ {task.error ? (
+
+ {task.error}
+
+ ) : null}
+ {task.prompt ? (
+
+ {task.prompt}
+
+ ) : null}
+
+ );
+}
+
+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(
+ () =>
+ 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({
) : (
- {rendered.active.map((task) =>
- taskRow(task, nowMillis, onFocusTask),
+ {rendered.active.map((row) =>
+ resourceCanvasGenerationTaskRowNode(
+ row,
+ nowMillis,
+ onFocusTask,
+ ),
)}
)}
@@ -271,8 +422,12 @@ export function ResourceCanvasAssetGenerationTasksPanelView({
) : (
<>
- {rendered.visibleDone.map((task) =>
- taskRow(task, nowMillis, onFocusTask),
+ {rendered.visibleDone.map((row) =>
+ resourceCanvasGenerationTaskRowNode(
+ row,
+ nowMillis,
+ onFocusTask,
+ ),
)}
{rendered.done.length > rendered.visibleDone.length ? (
diff --git a/apps/ai-game-creator-shell/src/features/resource-canvas/resourceCanvasAssetGenerationTasksSidebar.css b/apps/ai-game-creator-shell/src/features/resource-canvas/resourceCanvasAssetGenerationTasksSidebar.css
index 25b7d4e89..56abc9d1c 100644
--- a/apps/ai-game-creator-shell/src/features/resource-canvas/resourceCanvasAssetGenerationTasksSidebar.css
+++ b/apps/ai-game-creator-shell/src/features/resource-canvas/resourceCanvasAssetGenerationTasksSidebar.css
@@ -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;
diff --git a/apps/ai-game-creator-shell/src/features/resource-canvas/resourceCanvasResourceEditTaskModel.ts b/apps/ai-game-creator-shell/src/features/resource-canvas/resourceCanvasResourceEditTaskModel.ts
new file mode 100644
index 000000000..654f70123
--- /dev/null
+++ b/apps/ai-game-creator-shell/src/features/resource-canvas/resourceCanvasResourceEditTaskModel.ts
@@ -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,
+): boolean {
+ return task.status === 'completed' || task.status === 'failed';
+}
+
+export function resourceCanvasResourceEditSubmissionIsLive(
+ submission: Pick,
+): 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) ?? '—';
+}
diff --git a/apps/ai-game-creator-shell/src/view/project-development/index.tsx b/apps/ai-game-creator-shell/src/view/project-development/index.tsx
index ffd8b4724..257db1344 100644
--- a/apps/ai-game-creator-shell/src/view/project-development/index.tsx
+++ b/apps/ai-game-creator-shell/src/view/project-development/index.tsx
@@ -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('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)
diff --git a/apps/ai-game-creator-shell/tests/resourceCanvasQuickEditDraft.test.tsx b/apps/ai-game-creator-shell/tests/resourceCanvasQuickEditDraft.test.tsx
index 48a8143a1..b7c00c4ce 100644
--- a/apps/ai-game-creator-shell/tests/resourceCanvasQuickEditDraft.test.tsx
+++ b/apps/ai-game-creator-shell/tests/resourceCanvasQuickEditDraft.test.tsx
@@ -122,6 +122,11 @@ function installInvoke(
createManifest?: () => GameCreationAppManifest;
/** 派生一律失败:用于把面板留在失败态、只验证草稿的去向。 */
failDerive?: boolean;
+ /**
+ * 派生挂起门:测试拿住它就能停在「已经提交、结果还没回来」那一段,验证提交期间的界面。
+ * 不传时派生立即返回,行为与从前一致。
+ */
+ deriveGate?: Promise;
} = {},
) {
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((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((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();
+ });
+});
diff --git a/apps/ai-game-creator-shell/tests/resourceCanvasResourceEditTasks.test.ts b/apps/ai-game-creator-shell/tests/resourceCanvasResourceEditTasks.test.ts
new file mode 100644
index 000000000..523757579
--- /dev/null
+++ b/apps/ai-game-creator-shell/tests/resourceCanvasResourceEditTasks.test.ts
@@ -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 {
+ 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 {
+ 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']);
+ });
+});
diff --git a/docs/prd/【AI游戏创作】项目开发工作台PRD-2026-07-20.md b/docs/prd/【AI游戏创作】项目开发工作台PRD-2026-07-20.md
index b5badc5ff..72fd548bf 100644
--- a/docs/prd/【AI游戏创作】项目开发工作台PRD-2026-07-20.md
+++ b/docs/prd/【AI游戏创作】项目开发工作台PRD-2026-07-20.md
@@ -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`。
diff --git a/docs/project-memory/shared-memory/decision-log.md b/docs/project-memory/shared-memory/decision-log.md
index dccffb935..a234f9a08 100644
--- a/docs/project-memory/shared-memory/decision-log.md
+++ b/docs/project-memory/shared-memory/decision-log.md
@@ -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` 等样式结构用例在本分支既有失败,与本批无关,另行跟踪。
diff --git a/docs/project-memory/todos/【待办】画布验收后续修复-2026-09-18.md b/docs/project-memory/todos/【待办】画布验收后续修复-2026-09-18.md
index 4cea444fc..fccf1316f 100644
--- a/docs/project-memory/todos/【待办】画布验收后续修复-2026-09-18.md
+++ b/docs/project-memory/todos/【待办】画布验收后续修复-2026-09-18.md
@@ -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/用户素材名的正式来源。