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