From 5b73082ad0f2409f34fdfc893aacc85ac8bc723a Mon Sep 17 00:00:00 2001 From: Suzumiya Date: Sun, 20 Sep 2026 23:04:13 +0800 Subject: [PATCH] =?UTF-8?q?=E8=A1=A5=E9=BD=90=E7=94=BB=E5=B8=83=E9=AA=8C?= =?UTF-8?q?=E6=94=B6=E7=AC=AC=E4=BA=8C=E6=89=B9=EF=BC=9A=E6=B4=BE=E7=94=9F?= =?UTF-8?q?=E4=BB=BB=E5=8A=A1=E8=BF=9B=E7=94=9F=E6=88=90=E4=BB=BB=E5=8A=A1?= =?UTF-8?q?=E4=BE=A7=E6=A0=8F=E3=80=81=E6=8F=90=E4=BA=A4=E4=B8=AD=E8=AE=A9?= =?UTF-8?q?=E5=87=BA=E8=8D=89=E7=A8=BF=E4=B8=8E=E9=87=8D=E5=A4=8D=E6=8F=90?= =?UTF-8?q?=E4=BA=A4=E9=98=B2=E6=8A=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 新增 resourceCanvasResourceEditTaskModel:原生资源编辑阶段到面板状态/文案的映射,本地提交记录与原生待办按 operationId 合并,派生任务的终态与耗时判据 - 「生成任务」侧栏同时收派生/修改类任务:与图片类生成任务同一条列表、同一套分栏与状态徽标,多一行提示词,样式沿用既有卡片类 - 快速编辑、生成动画、视频/音效/背景音乐三条派生提交:按下提交当帧写入本地记录并在侧栏可见,成功后收口为已完成、失败带原因并重读原生待办 - 「管理未完成编辑」不再列出正在提交的那一笔草稿,失败后自动回落,提示词与 @ 引用不丢 - 同一张素材存在在途提交时,重新打开面板再提交会被拦下并给出原因,避免重复派生与重复计费 - 侧栏与工具条入口的在途计数把派生任务一并计入 - 新增 tests/resourceCanvasResourceEditTasks.test.ts(9 条);resourceCanvasQuickEditDraft.test.tsx 补两组宿主用例(提交当帧进侧栏并收口、在途重复提交被拦),两处新判据均做过变异验证 - 同步 PRD 派生任务侧栏口径、decision-log 与画布验收待办文档 --- ...rceCanvasAssetGenerationTasksPanelView.tsx | 187 ++++++++- ...ourceCanvasAssetGenerationTasksSidebar.css | 11 + .../resourceCanvasResourceEditTaskModel.ts | 369 ++++++++++++++++++ .../src/view/project-development/index.tsx | 249 +++++++++--- .../resourceCanvasQuickEditDraft.test.tsx | 100 +++++ .../resourceCanvasResourceEditTasks.test.ts | 269 +++++++++++++ ...AI游戏创作】项目开发工作台PRD-2026-07-20.md | 3 +- .../shared-memory/decision-log.md | 10 + .../【待办】画布验收后续修复-2026-09-18.md | 8 + 9 files changed, 1142 insertions(+), 64 deletions(-) create mode 100644 apps/ai-game-creator-shell/src/features/resource-canvas/resourceCanvasResourceEditTaskModel.ts create mode 100644 apps/ai-game-creator-shell/tests/resourceCanvasResourceEditTasks.test.ts 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({

    ) : ( )} @@ -271,8 +422,12 @@ export function ResourceCanvasAssetGenerationTasksPanelView({ ) : ( <> {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/用户素材名的正式来源。