Files
Genarrative/apps/ai-game-creator-shell/src/view/project-development/projectResourceLiveUpdateModel.ts
T
k88936 876529e668 删除 Project Supervisor 前端链路并将项目对话收敛到 DirectProject 与立项策划
- 删除 ProjectSupervisorView、SupervisorChatOnlyView、ProjectWorkspaceChatPane、AgentConversationOverlay、DeveloperProjectPanels、DeveloperRuntimePanels 与 features/agent-runtime/panels.tsx
- 删除 Supervisor 独立调试窗口:windows.rs 的 supervisor_chat_window_url / open_project_supervisor_chat_window、main.rs 的 invoke 注册、?supervisor-chat 与 ?agent-chat 前端入口、developer.json capability 和对应 Rust 用例
- 删除工作台壳的开发者 Agent 面板:DeveloperAgentPanel、useDeveloperAgentPanel、useDeveloperAgentState、developerAgentControls
- App.tsx 删除只服务退役面板的 state/ref/effect/handler(agentConversation*、文件/记忆/资产/画板/预览面板处理、commandLog、llmConfigStatus、editorBaseUrl、回放历史与 trace 面板状态、selectedAgent 等)以及由此产生的空分支,并把 requestRuntimeConfigOpen 接回本地 RuntimeConfigDialog
- 立项策划独立成模块:Design Agent 与 Planning V2 的容器和表现移到 view/project-development/planning/(PlanningChatView、PlanningUserInputCard、GddApprovalCard、DesignAgentSurface、PlanningLaneRuntimeStrip、planningLane、planningSessionV2、planningSessionContract)
- 改名到中性概念:ProjectSupervisorComponentProps→ProjectChatComponentProps、WorkspaceLauncherShellProps.ProjectSupervisor→ProjectChat、ProjectDevelopmentView.supervisor→chat、orchestrationMode→agentDockVisible、initialSupervisorMessageClaims→initialTurnClaims、ProjectManifestSnapshotSource 'supervisor'→'chat'、CSS project-supervisor-*/project-planning-*→project-chat-*
- 删除 PROJECT_SUPERVISOR_AGENT_ID 与 PROJECT_SUPERVISOR_PLAN_SOURCE 常量
- 工作台壳自己订阅 game-creator-manifest-invalidated,用 revision→清单→revision 配对读(source: asset-event)并入 currentProjectContext,且不重新检查项目目录;测试夹具改为按监听器集合广播该事件
- 删除只钉住退役界面的 appSurface 用例(旧调试窗口、开发者面板、Agent 对话浮层、运行历史面板)
- 同步文档:ADR、DirectProject 聊天模块抽离实施计划与里程碑、Provider 推理里程碑、策划会话 RuntimeV2、AI 游戏创作实施计划,并在 decision-log 新增 2026-09-19 条
- 删除随退役面板失去调用方的模块与导出:projectSummaryCommands.ts(1054 行旧 Supervisor 斜杠命令处理器)、AGENT_RUN_HISTORY_* 常量、agent-runtime/model.ts 与 project-summary/agentPresentation.ts 里的死导出,以及 agentRunTrace / memoryCommands / projectCommandPolicy 中无调用方的校验与解析函数
- 更新 scripts/check-config.mjs 与 scripts/check-native-shells.mjs 的窗口与命令守卫
2026-09-19 21:59:44 +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'
| 'chat'
| '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';
}