Files
Genarrative/apps/ai-game-creator-shell/src/view/project-development/projectResourceLiveUpdateModel.ts
T
suzmii d1581892d7 修复 PR #316 review(前端 TS/TSX):资源工作台与资源画布
- 素材重命名(跨范围契约):`rename_local_project_asset` 现在要求 `expectedProjectId` + `expectedProjectRevision`,前端按删除 / 分类保存同一套 CAS 口径补齐——先 `get_local_game_project_revision` 读当前 revision 再连同项目身份提交,不用可能过期的本地缓存值
- 失败文案统一:新增 `projectAssetCommandErrorMessage`,把 `project-identity-conflict` / `project-revision-conflict` 翻成用户可读中文,重命名 / 删除 / 分类保存三条链路共用一份映射,其余错误原样透出
- index.tsx:资源面板下载按 `resolveDownloadablePanelEntries` 过滤,版本卡这类合成 path 的条目不再进入落盘链路
- index.tsx:`characterAnimationPanel` 纳入画布浮层开关判据(抽 `resolveResourceCanvasFloatingPanelOpen`),「生成动画」面板与快速编辑 / 信息浮层共用同一条「点外部 / Esc 关闭」规则
- index.tsx:去掉 `ResourceBookScene` 上重复的 `onWheel`,滚轮只由 manager 上的原生 `passive:false` 监听处理,平移 / 缩放不再被翻倍
- index.tsx:快速编辑的源资源在层 id 命中不到时回落到已正规化的 `asset:<id>`,重试不再静默什么都不做;取不到时给出可见失败
- ResourceCanvasPanelView.tsx:下载按钮按可下载条数判可用、不再在上传中显示下载转圈;上传中禁掉 × / Esc / 遮罩三条关闭路径;预览占位文案抽 helper
- GameRunVersionPicker.tsx:portal 菜单在滚动 / 缩放后重算位置;外部点击判定沿用共享 `useImageCanvasFloatingOptionDismiss` + `menuRef` 边界(review 该条已修,保留现状并补位置用例)
- ResourceCanvasGenerationPanelView.tsx:`submitting` 改 `finally` 收回,成功路径不再永久锁住面板
- ResourceAssetDeleteDialog.tsx / ResourceRenameDialog.tsx / ResourceClassificationPanel.tsx:在飞时 `closeOnEscape` / `closeOnBackdrop` 与头部 × 一起挡住
- resourceEditModel.ts / resourceCanvasToolbarModel.ts / useProjectResourceCardPreviews.ts / projectResourceLiveUpdateModel.ts:抽 `resourceBaseName`、`canonicalProjectedResourceMediaType` 参数收窄去掉强转、删未使用参数、补「IO 失败会 reject」的签名说明
- resourceCanvasHistoryModel.ts:`resourceCanvasSnapshotsEqual` 不再比较不可恢复的 `manuallyPlaced`,消除「压进历史但撤销是空操作」
- resourceCanvasSectionMapping.ts:删掉从未接线、且注释与实现相矛盾的 `LEGACY_RESOURCE_CANVAS_SECTION_FALLBACK`
- resourceCanvasChrome.css:`.game-resource-panel-grid` 补 `flex: 1 1 auto; min-height: 0`,卡片网格成为真正的滚动区
- 用例:重命名 CAS 载荷与读序、CAS 拒绝文案、版本卡下载门禁、上传中关闭路径、浮层判据、菜单跟随滚动、分区映射
2026-09-12 20:49:03 +08:00

387 lines
13 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import type { GameCreationAppManifest } from '../../../../../packages/shared/src/contracts/gameCreationApp';
export type ProjectManifestSnapshotSource =
| 'initial'
| 'supervisor'
| 'asset-command'
| 'asset-event'
| 'poll';
export type ProjectManifestSnapshot = {
projectPath: string;
projectId: string;
revision: number;
manifest: GameCreationAppManifest;
source: ProjectManifestSnapshotSource;
commitId?: string;
eventId?: string;
};
export type ProjectManifestSnapshotMetadata = Omit<
ProjectManifestSnapshot,
'projectPath' | 'manifest'
>;
export type ProjectManifestMergeState = {
projectPath: string;
projectId: string;
revision: number;
manifestFingerprint: string;
casFingerprint: string;
seenCommitRevisions: string[];
seenEventIds: string[];
};
export type ProjectManifestMergeDecision =
| 'accepted'
| 'duplicate'
| 'stale-revision'
| 'scope-mismatch'
| 'revision-conflict';
const MAX_DEDUPE_IDENTITIES = 256;
function appendBounded(values: string[], value: string | undefined) {
if (!value || values.includes(value)) {
return values;
}
return [...values, value].slice(-MAX_DEDUPE_IDENTITIES);
}
function manifestFingerprint(manifest: GameCreationAppManifest) {
return JSON.stringify(manifest);
}
/**
* CAS 的判据面:**只认资源与版本**。
*
* 权威契约在 `project/manifest.rs::append_agent_game_iteration_version_at`:revision 是跨
* 界面排序边界,它保护的对象是「这一版项目用了哪些资源、有哪些正式版本」。清单里还有一类
* **非 CAS 账目状态**——`preview` 的运行地址与端口、任务进度、项目名。它们由
* `record_preview_state` / `record_command_run` / `rename_local_game_project` 这类簿记写入
* 直接落盘而**刻意不推 revision**(预览起停不是源码变更,推 revision 会让运行时的验证凭证
* 无故漂移)。
*
* 所以判据面必须与契约一致:拿整份清单 JSON 当判据,会把每一次预览起停都判成「同一版本号上
* 的清单内容不一致」,用户看到「资源清单更新被拒收」,而真正要紧的新资源与新版本反倒被这条
* 假冲突挡在画布之外。
*/
function manifestCasFingerprint(manifest: GameCreationAppManifest) {
return JSON.stringify({
assets: manifest.assets ?? [],
versions: manifest.versions ?? [],
});
}
function commitRevisionIdentity(snapshot: ProjectManifestSnapshot) {
return snapshot.commitId
? `${snapshot.projectId}:${snapshot.commitId}:${snapshot.revision}`
: undefined;
}
function withDedupeIdentities(
state: ProjectManifestMergeState,
snapshot: ProjectManifestSnapshot,
) {
return {
...state,
seenCommitRevisions: appendBounded(
state.seenCommitRevisions,
commitRevisionIdentity(snapshot),
),
seenEventIds: appendBounded(state.seenEventIds, snapshot.eventId),
};
}
export function createProjectManifestMergeState(
snapshot: ProjectManifestSnapshot,
): ProjectManifestMergeState {
return withDedupeIdentities(
{
projectPath: snapshot.projectPath,
projectId: snapshot.projectId,
revision: snapshot.revision,
manifestFingerprint: manifestFingerprint(snapshot.manifest),
casFingerprint: manifestCasFingerprint(snapshot.manifest),
seenCommitRevisions: [],
seenEventIds: [],
},
snapshot,
);
}
export function mergeProjectManifestSnapshot(
current: ProjectManifestMergeState,
snapshot: ProjectManifestSnapshot,
): {
decision: ProjectManifestMergeDecision;
state: ProjectManifestMergeState;
} {
if (
snapshot.projectPath !== current.projectPath ||
snapshot.projectId !== current.projectId ||
snapshot.manifest.projectId !== snapshot.projectId
) {
return { decision: 'scope-mismatch', state: current };
}
if (snapshot.revision < current.revision) {
return { decision: 'stale-revision', state: current };
}
const fingerprint = manifestFingerprint(snapshot.manifest);
const casFingerprint = manifestCasFingerprint(snapshot.manifest);
if (snapshot.revision === current.revision) {
// 同一版本号上「资源或版本」变了:这是真的 CAS 冲突——有写点改了被保护的内容却没推进
// revision,必须继续拒收并让用户看见,否则客户端会拿着一份无法定位内容的清单渲染。
if (casFingerprint !== current.casFingerprint) {
return { decision: 'revision-conflict', state: current };
}
if (fingerprint === current.manifestFingerprint) {
return {
decision: 'duplicate',
state: withDedupeIdentities(current, snapshot),
};
}
// 同一版本号、资源与版本一致,只有非 CAS 账目状态(预览地址/端口、任务进度、项目名)
// 不同:内容必须落地,revision 既不推进也不回退。
return {
decision: 'accepted',
state: withDedupeIdentities(
{ ...current, manifestFingerprint: fingerprint },
snapshot,
),
};
}
return {
decision: 'accepted',
state: withDedupeIdentities(
{
...current,
revision: snapshot.revision,
manifestFingerprint: fingerprint,
casFingerprint,
},
snapshot,
),
};
}
/**
* 会被用户看见的拒收决策。
*
* `mergeProjectManifestSnapshot` 的五个决策里只有这两个代表「本条快照被 CAS 挡下、
* 手上的清单继续用旧的」:`stale-revision` 是收到的快照版本比手上的旧,
* `revision-conflict` 是同一版本号上内容不一致。两者都会让新提交的资源进不了画布,
* 所以必须可见且可恢复。
*
* `scope-mismatch` **不在其列**:它表示这条快照根本不属于当前项目(上游串了项目),
* 对用户没有可执行的语义,只记日志、不弹提示。
*/
export type ProjectManifestMergeRejectionDecision =
| 'stale-revision'
| 'revision-conflict';
export function projectManifestMergeRejectionDecision(
decision: ProjectManifestMergeDecision,
): ProjectManifestMergeRejectionDecision | null {
return decision === 'stale-revision' || decision === 'revision-conflict'
? decision
: null;
}
/** 拒收之后的恢复阶段,直接决定提示文案与按钮可用性。 */
export type ProjectManifestMergeRecoveryStage =
| 'recovering'
| 'recovered'
| 'unresolved';
export function describeProjectManifestMergeRejection(
decision: ProjectManifestMergeRejectionDecision,
stage: ProjectManifestMergeRecoveryStage,
): string {
const cause =
decision === 'revision-conflict'
? '同一版本号上的清单内容不一致'
: '收到的是旧版本的清单快照';
if (stage === 'recovering') {
return `资源清单更新被拒收(${cause}),正在重新读取磁盘清单…`;
}
if (stage === 'recovered') {
return `资源清单更新被拒收(${cause}),已按磁盘清单重新对齐`;
}
// `unresolved` 不等于"读盘失败":重读**可能已经成功**,只是拿到的那一对
// `(revision, 清单)` 按 CAS 判据不能被采用(版本号比手上的旧,或读到的是撕裂的一对)。
// 所以这里说"未能按磁盘清单重新对齐" —— 它对所有进入 `unresolved` 的路径都成立,
// 而"重新读取磁盘清单失败"只对其中一条成立,是一条会误导用户的假陈述。
return `资源清单更新被拒收(${cause}),未能按磁盘清单重新对齐,请重新打开项目`;
}
export type ProjectManifestRereadInput = {
projectPath: string;
projectId: string;
readRevision: () => Promise<number>;
readManifest: () => Promise<GameCreationAppManifest>;
};
/**
* 拒收之后的恢复手段:**重读磁盘真相**,而不是把被拒的快照硬收下(那等于放宽 CAS)。
*
* 为什么重读能解:拒收的成因是快照自带的版本号不足以定位内容(典型是资产命令把
* **写盘之前**读到的 revision 贴在了**写盘之后**读到的清单上),于是"同一版本号、
* 内容不同"被 CAS 挡下。此时唯一可靠的解法是回到磁盘问一次当前 `(revision, 清单)`,
* 用它重建 merge 状态。
*
* 两次读 revision 夹一次读清单:中间有写盘落地就说明读到的是撕裂的一对,
* 直接放弃本次恢复(下一次快照会带上新的 revision),不猜、不凑。
*
* 返回 `null` **只表示读到了、但不能采用**(revision 非法、projectId 不一致、
* 两次 revision 对不上)。IO / IPC 失败会照旧抛出:调用方要能把"读失败"与
* "读到但不可采用"分开呈现(例如上传成功后配对读失败必须报错,而不是静默不同步)。
*/
export async function rereadAuthoritativeProjectManifestSnapshot(
input: ProjectManifestRereadInput,
): Promise<ProjectManifestSnapshot | null> {
const revision = await input.readRevision();
if (!Number.isSafeInteger(revision) || revision < 0) {
return null;
}
const manifest = await input.readManifest();
if (manifest.projectId !== input.projectId) {
return null;
}
const confirmedRevision = await input.readRevision();
if (confirmedRevision !== revision) {
return null;
}
return {
projectPath: input.projectPath,
projectId: input.projectId,
revision,
manifest,
source: 'poll',
};
}
/**
* 素材上传之后的清单快照:**上传动作与"配对读"必须由同一个函数拥有**。
*
* ⚠️ 不许把「上传**前**读到的 revision」贴在「上传**后**读到的清单」上。上传本身会推进
* 项目 revision(Rust 侧 `advance_agent_runtime_project_revision_locked`),于是这一对是
* 撕裂的:`mergeProjectManifestSnapshot` 看到"同一版本号、内容不同" ⇒ 判 `revision-conflict`
* ⇒ 用户每上传一次素材就先看到一条「资源清单更新被拒收(同一版本号上的清单内容不一致)」。
*
* 唯一正确的形状是 [`rereadAuthoritativeProjectManifestSnapshot`]:它**在同一次读里配对**
* revision 与清单,并再读一次 revision 确认没被写盘插队。把上传与这次配对读放在同一个
* 函数里,是为了让"先读 revision 再上传"这种错配**不可能**再被写出来 —— 调用方拿不到
* 中间那个 revision,也就没有机会把它贴错。
*/
export async function uploadProjectAssetFilesAndReadSnapshot(input: {
projectPath: string;
projectId: string;
commitId: string;
files: readonly {
fileName: string;
mediaType: string;
bytes: number[];
}[];
invoke<T>(command: string, args: Record<string, unknown>): Promise<T>;
}): Promise<ProjectManifestSnapshot | null> {
for (const file of input.files) {
await input.invoke('upload_local_asset', {
projectPath: input.projectPath,
fileName: file.fileName,
mediaType: file.mediaType,
bytes: file.bytes,
});
}
const fresh = await rereadAuthoritativeProjectManifestSnapshot({
projectPath: input.projectPath,
projectId: input.projectId,
readRevision: async () => {
const status = await input.invoke<{ revision: number }>(
'get_local_game_project_revision',
{ projectPath: input.projectPath },
);
return status.revision;
},
readManifest: () =>
input.invoke<GameCreationAppManifest>('get_local_game_manifest', {
projectPath: input.projectPath,
commandId: 'asset.list',
}),
});
return fresh
? { ...fresh, source: 'asset-command', commitId: input.commitId }
: null;
}
export type ResourceFocusIntent = {
flowId: string;
saveAttemptId: string;
sessionId: string;
draftId: string;
commitId: string;
projectPath: string;
projectId: string;
focusGeneration: number;
resourceId: string | null;
completed: boolean;
};
export type ResourceFocusResolution =
| 'invalid'
| 'wait-projection'
| 'wait-layout'
| 'hidden'
| 'wait-dom'
| 'focus'
| 'completed';
export function resolveResourceFocusIntent(
intent: ResourceFocusIntent,
input: {
projectPath: string;
projectId: string;
flowId: string | null;
focusGeneration: number;
projected: boolean;
dependencyLayoutSettled: boolean;
dependencyPositioned: boolean;
typeLayoutSettled: boolean;
typePositioned: boolean;
visible: boolean;
domRendered: boolean;
},
): ResourceFocusResolution {
if (intent.completed) {
return 'completed';
}
if (
intent.projectPath !== input.projectPath ||
intent.projectId !== input.projectId ||
intent.flowId !== input.flowId ||
intent.focusGeneration !== input.focusGeneration
) {
return 'invalid';
}
if (!intent.resourceId || !input.projected) {
return 'wait-projection';
}
if (
!input.dependencyLayoutSettled ||
!input.typeLayoutSettled ||
!input.dependencyPositioned ||
!input.typePositioned
) {
return 'wait-layout';
}
if (!input.visible) {
return 'hidden';
}
if (!input.domRendered) {
return 'wait-dom';
}
return 'focus';
}