d89e21ffd5
- 入口位置:首页模板库推荐位之后新增一节「从平台作品开始创作」。与模板库同属「从现成内容 起步」,但这条拿的是平台作品的已构建成品(可玩参考 + 素材),不是可编辑工程,因此单独成节 而不是塞进模板库;未登录用户进不到首页,服务端也确实对任何登录用户开放取件,无需额外灰度。 - 交互:输入平台作品 ID 或作品链接 → 解析出作品 ID(裸 ID / `?id=` / `?gameId=` / `/games/<id>`, 也容忍不带 scheme 的粘贴;解析不出就本地拦截、不发请求)→ 调 `create_local_project_from_platform_game`(name/planning 走默认,与模板链路一致)→ 成功后复用模板那条「原生已落盘 → 登记最近项目并进入工作区」通道进项目。 - 三类结果:① 未登录/登录过期(`authentication-required:`)→「请重新登录后重试」; ② 403 未开放授权(`permission-denied:`)与 409 未公开/已下架(`fork-source-not-available:`) 按服务端文案分别展示,且都不建项目、按钮可重试;③ 成功展示「改编自平台作品 <gameId>」 与 `reference/<gameId>/` 参考产物位置,让来源事实可见。 - 为此给取件的 404/409 补了与 401/403 同族的稳定前缀(`fork-source-not-found:` / `fork-source-not-available:`):渲染层按前缀精确分类,不做文案子串猜测。 - 加载态:取件期间按钮禁用 + 明确提示「下载、校验、解压需要几分钟」,并用 ref 守卫挡住同一 tick 的重复提交(只认最后一次请求)。 - 文案严守产品口径:拿到的是「已构建成品」= 可玩参考 + 素材,不能直接再次发布,需在新工程里 改造后发布;不出现「一键复刻工程」这类说法。 - 门禁登记:命令已接回 App 入口,因此从 `scripts/check-config.mjs` 的 native-only 白名单移除 (该脚本要求「已接入」与「未接入」两个集合互斥)。 - 测试:新增 `tests/platformGameFork.test.tsx`(纯解析/失败分类 + 组件:空与非法输入不发请求、 403/409/401 各自提示且不建项目、加载态与重复提交、成功时命令参数与「改编自」展示)。
1043 lines
39 KiB
TypeScript
1043 lines
39 KiB
TypeScript
import {
|
||
Fragment,
|
||
useCallback,
|
||
useEffect,
|
||
useLayoutEffect,
|
||
useRef,
|
||
useState,
|
||
} from 'react';
|
||
import { createPortal } from 'react-dom';
|
||
|
||
import { launcherNotifications } from '../../app/constants';
|
||
import { closeDialogOnEscape } from '../../app/dialogs';
|
||
import { resolveTauriInvoke } from '../../app/tauri';
|
||
import type { LocalGameProjectRevisionStatus } from '../../app/types';
|
||
import {
|
||
useWindowChrome,
|
||
WINDOW_CHROME_DEFAULT_TITLE,
|
||
} from '../../components/windowChromeContext';
|
||
import {
|
||
canSubscribeTauriEvents,
|
||
subscribeTauriEvent,
|
||
} from '../../services/tauriEventSubscription';
|
||
import HomeView from '../../view/home';
|
||
import { type LauncherView, Sidebar } from '../../view/layout';
|
||
import ProjectDevelopmentView from '../../view/project-development';
|
||
import {
|
||
createProjectManifestMergeState,
|
||
describeProjectManifestMergeRejection,
|
||
mergeProjectManifestSnapshot,
|
||
type ProjectManifestMergeRecoveryStage,
|
||
type ProjectManifestMergeRejectionDecision,
|
||
projectManifestMergeRejectionDecision,
|
||
type ProjectManifestMergeState,
|
||
type ProjectManifestSnapshot,
|
||
type ProjectManifestSnapshotMetadata,
|
||
type ProjectManifestSnapshotSource,
|
||
rereadAuthoritativeProjectManifestSnapshot,
|
||
} from '../../view/project-development/projectResourceLiveUpdateModel';
|
||
import TemplateLibraryView from '../../view/template-library';
|
||
import { useDirectActiveTurns } from '../agent-runtime/directActiveTurns';
|
||
import { projectPathsMatchForInvalidation } from '../project-summary/projectPath';
|
||
import { RuntimeConfigDialog } from '../runtime-config/RuntimeConfigDialog';
|
||
import { useTemplateLibrary } from '../template-library/useTemplateLibrary';
|
||
import { AccountReferralDialogs } from './AccountReferral';
|
||
import { AccountWalletBar, AccountWalletDialogs } from './AccountWallet';
|
||
import {
|
||
type LauncherNotice,
|
||
LauncherNoticeToast,
|
||
} from './LauncherNoticeToast';
|
||
import type {
|
||
ProjectRunNotice,
|
||
ShowTransientNotice,
|
||
WorkspaceLauncherShellProps,
|
||
} from './model';
|
||
import { NonEmptyProjectDialog, ProjectsPage } from './ProjectCreation';
|
||
import { useAccountReferral } from './useAccountReferral';
|
||
import { useAccountWallet } from './useAccountWallet';
|
||
import {
|
||
DESIGN_ARTIFACTS_BUILD_PROMPT,
|
||
useHomeProjectCreation,
|
||
} from './useHomeProjectCreation';
|
||
import { useProjectSnapshotWorkspace } from './useProjectSnapshotWorkspace';
|
||
import { useRecentProjects } from './useRecentProjects';
|
||
|
||
export function WorkspaceLauncherShell({
|
||
currentUser,
|
||
onLogout,
|
||
initialView = 'home',
|
||
ProjectChat,
|
||
}: WorkspaceLauncherShellProps) {
|
||
const {
|
||
isWindowChrome,
|
||
setTitle: setWindowTitle,
|
||
walletSlot,
|
||
setActiveProjectRuns,
|
||
} = useWindowChrome();
|
||
const accountWallet = useAccountWallet(currentUser.id);
|
||
const accountReferral = useAccountReferral(currentUser.id);
|
||
const [status, setStatus] = useState('');
|
||
const recentProjects = useRecentProjects(setStatus);
|
||
const { recentProjectRows, rememberRecentWorkspace } = recentProjects;
|
||
const [launcherView, setLauncherView] = useState<LauncherView>(initialView);
|
||
const [runtimeConfigOpen, setRuntimeConfigOpen] = useState(false);
|
||
const [playRequest, setPlayRequest] = useState<{
|
||
projectPath: string;
|
||
requestId: number;
|
||
} | null>(null);
|
||
const playRequestIdRef = useRef(0);
|
||
const [launcherNotice, setLauncherNotice] = useState<{
|
||
title: string;
|
||
message: string;
|
||
} | null>(null);
|
||
/**
|
||
* 外壳级一次性浮层提示(运行 / 预览反馈、取消类回显)。
|
||
*
|
||
* `id` 每次自增,保证同一句文案连续触发时也会重新弹一次。
|
||
*/
|
||
const [notice, setNotice] = useState<LauncherNotice | null>(null);
|
||
const noticeIdRef = useRef(0);
|
||
const showTransientNotice = useCallback<ShowTransientNotice>(
|
||
(message, tone) => {
|
||
noticeIdRef.current += 1;
|
||
setNotice({
|
||
id: noticeIdRef.current,
|
||
message,
|
||
...(tone ? { tone } : {}),
|
||
});
|
||
},
|
||
[],
|
||
);
|
||
/**
|
||
* 状态栏退役后的粘性浮层:原来写在首页输入框下方 / 项目组页面头部那一行的状态,
|
||
* 现在原样走浮层——语义不变(`status` 被替换或清空时才换掉 / 消失),只是不再占页面。
|
||
*
|
||
* 只在这两个页面显示:原来那一行也只长在它们上面。进了项目工作台、模板库就不再飘在界面上,
|
||
* 状态本身留着,回到首页或项目组时照旧显示。
|
||
*/
|
||
const statusNotice: LauncherNotice | null = (() => {
|
||
if (launcherView !== 'home' && launcherView !== 'projects') {
|
||
return null;
|
||
}
|
||
const message =
|
||
launcherView === 'projects' && recentProjects.recentWorkspaceRefreshing
|
||
? '正在检查项目状态'
|
||
: status;
|
||
return message ? { id: 0, message, sticky: true } : null;
|
||
})();
|
||
// 「做成游戏」切换记录按项目上下文(路径 + createdAt)定位。运行模式必须随
|
||
// currentProjectContext 同步派生,不能靠 effect 后置修正:首帧挂错 lane 会先
|
||
// 以游戏运行时挂载并消耗首轮 claim,重挂后的策划实例再也发不出首轮。
|
||
const [gameRuntimeSwitch, setGameRuntimeSwitch] = useState<{
|
||
projectPath: string;
|
||
createdAt: number;
|
||
} | null>(null);
|
||
const homeProject = useHomeProjectCreation({
|
||
setStatus,
|
||
showTransientNotice,
|
||
setLauncherView,
|
||
rememberRecentWorkspace,
|
||
refreshRecentWorkspace: recentProjects.refreshRecentWorkspace,
|
||
});
|
||
const templateLibrary = useTemplateLibrary({
|
||
userId: currentUser.id,
|
||
onProjectCreated: async (result, isCurrent, analytics) => {
|
||
await homeProject.enterCreatedTemplateProject(
|
||
result,
|
||
isCurrent,
|
||
analytics,
|
||
);
|
||
},
|
||
});
|
||
useEffect(() => {
|
||
if (!templateLibrary.enabled && launcherView === 'template-library') {
|
||
setLauncherView('home');
|
||
}
|
||
}, [templateLibrary.enabled, launcherView]);
|
||
const {
|
||
projectPath,
|
||
setProjectPath,
|
||
currentProjectContext,
|
||
setCurrentProjectContext,
|
||
activeProjectPreview,
|
||
setActiveProjectPreview,
|
||
activeProjectAgentResults,
|
||
setAgentResults: setActiveProjectAgentResults,
|
||
resetLauncherHomeDraft,
|
||
createHomeDraftAutomatically,
|
||
openProject,
|
||
homeCreationBusy,
|
||
homeCreationRecoverableProjectPath,
|
||
} = homeProject;
|
||
useProjectSnapshotWorkspace(
|
||
launcherView === 'project-development'
|
||
? (currentProjectContext?.projectPath ?? null)
|
||
: null,
|
||
);
|
||
const directInvoke = resolveTauriInvoke();
|
||
const { activeTurns, snapshotReadFailed } = useDirectActiveTurns({
|
||
invoke: directInvoke,
|
||
enabled: true,
|
||
});
|
||
const switchedToGameRuntime =
|
||
gameRuntimeSwitch !== null &&
|
||
currentProjectContext !== null &&
|
||
gameRuntimeSwitch.projectPath === currentProjectContext.projectPath &&
|
||
gameRuntimeSwitch.createdAt === currentProjectContext.createdAt;
|
||
const planningStartMode =
|
||
currentProjectContext?.startMode === 'planning' && !switchedToGameRuntime;
|
||
const agentRuntimeMode: 'design' | 'game' = planningStartMode
|
||
? 'design'
|
||
: 'game';
|
||
const omitOriginalPlanningTurnInputs = switchedToGameRuntime;
|
||
const activeProjectContextRef = useRef(currentProjectContext);
|
||
const manifestMergeRef = useRef<ProjectManifestMergeState | null>(null);
|
||
activeProjectContextRef.current = currentProjectContext;
|
||
/**
|
||
* C7 当前游戏版本:唯一一份版本状态,同时喂给资源画布(「当前使用」高亮)与
|
||
* `@` 面板(「当前版本素材」页签)。`null` 表示回退到 manifest 中最新的版本。
|
||
*/
|
||
const [activeVersionId, setActiveVersionId] = useState<string | null>(null);
|
||
|
||
useEffect(() => {
|
||
const nextTitle =
|
||
launcherView === 'project-development'
|
||
? currentProjectContext?.projectName.trim()
|
||
: '';
|
||
setWindowTitle(nextTitle || WINDOW_CHROME_DEFAULT_TITLE);
|
||
return () => setWindowTitle(WINDOW_CHROME_DEFAULT_TITLE);
|
||
}, [currentProjectContext?.projectName, launcherView, setWindowTitle]);
|
||
|
||
useEffect(() => {
|
||
const current = activeProjectContextRef.current;
|
||
manifestMergeRef.current =
|
||
current?.projectRevision === null || !current
|
||
? null
|
||
: createProjectManifestMergeState({
|
||
projectPath: current.projectPath,
|
||
projectId: current.manifest.projectId,
|
||
revision: current.projectRevision,
|
||
manifest: current.manifest,
|
||
source: 'initial',
|
||
});
|
||
}, [
|
||
currentProjectContext?.createdAt,
|
||
currentProjectContext?.projectName,
|
||
currentProjectContext?.projectPath,
|
||
]);
|
||
|
||
// 换项目时回到「最新版本」,不把上一个项目的 versionId 带过去。
|
||
useEffect(() => {
|
||
setActiveVersionId(null);
|
||
}, [currentProjectContext?.projectPath]);
|
||
|
||
// 选中的版本已经不在了(例如随素材一并删除)时清回「最新版本」,
|
||
// 否则版本入口会显示最新版本、而资源卡按空态不高亮,两边口径对不上。
|
||
const projectVersionIds = (currentProjectContext?.manifest.versions ?? [])
|
||
.map((version) => version.versionId)
|
||
.join('\u0000');
|
||
useEffect(() => {
|
||
if (!activeVersionId) return;
|
||
if (projectVersionIds.split('\u0000').includes(activeVersionId)) return;
|
||
setActiveVersionId(null);
|
||
}, [activeVersionId, projectVersionIds]);
|
||
|
||
/**
|
||
* 被 CAS 拒收的清单快照提示。
|
||
*
|
||
* 拒收此前是 `return` 一行静默丢弃:用户看到"生成/上传成功"但画布上没有新卡,
|
||
* 资源区提示永远停在"正在等待 manifest 投影"。这里把拒收变成一条可见状态,
|
||
* `data-manifest-merge-*` 同时作为排障观察点。
|
||
*/
|
||
const [manifestMergeNotice, setManifestMergeNotice] = useState<{
|
||
decision: ProjectManifestMergeRejectionDecision;
|
||
stage: ProjectManifestMergeRecoveryStage;
|
||
source: ProjectManifestSnapshotSource;
|
||
projectId: string;
|
||
heldRevision: number;
|
||
snapshotRevision: number;
|
||
} | null>(null);
|
||
/**
|
||
* 同一项目只允许一条恢复在飞。
|
||
*
|
||
* 拒收往往成串到达(一条命令 + 总控一次推送),每条都去重读磁盘会把读取槽打满,
|
||
* 也会让提示在几个阶段之间来回抖。换项目时提示与在飞标记一起清掉。
|
||
*/
|
||
const manifestMergeRecoveryInFlightRef = useRef<string | null>(null);
|
||
/**
|
||
* 上一次观察到的项目路径,用来区分"真的换项目"和"首次进入项目"。
|
||
*
|
||
* 首次进入项目也要跑这个 effect(依赖从 `undefined` 变成路径),但那一刻**没有上一个
|
||
* 项目的提示要清**;如果无条件 `setManifestMergeNotice(null)`,就会把「打开项目这一提交
|
||
* 里刚落下的拒收提示」一起清掉 —— 而资产命令在打开项目后马上推送清单快照正是主场景,
|
||
* 清掉就等于这条提示时有时无。所以只有真的从 A 项目切到 B 项目(或关掉项目)才清。
|
||
*/
|
||
const manifestMergeNoticeScopeRef = useRef<string | null>(null);
|
||
|
||
// 打开项目由创建流程提供,其函数引用随渲染变化;标题栏只持有稳定的转发入口,
|
||
// 否则发布 Context 会再次触发工作台 effect,形成发布/清理循环。
|
||
const openProjectRef = useRef(openProject);
|
||
useLayoutEffect(() => {
|
||
openProjectRef.current = openProject;
|
||
}, [openProject]);
|
||
const openActiveProject = useCallback(
|
||
(nextProjectPath: string) => {
|
||
setProjectPath(nextProjectPath);
|
||
void openProjectRef.current(nextProjectPath, 'open');
|
||
},
|
||
[setProjectPath],
|
||
);
|
||
|
||
/**
|
||
* 项目卡片面板发布给窗口标题栏的回调必须走 ref。
|
||
*
|
||
* `openProject` 来自 `useHomeProjectCreation` 的普通函数(每次渲染都是新身份),
|
||
* 所以 `openActiveProject` 的引用每渲染都变;如果它进 effect 依赖,就会变成
|
||
* 「effect 每渲染重跑 → cleanup/setActiveProjectRuns 改 WindowChrome 状态 → 重新渲染」
|
||
* 的无限 setState 循环(React 报 `Maximum update depth exceeded`)。
|
||
* 这里只让 effect 依赖真正的数据,回调通过 ref 取最新实现。
|
||
*/
|
||
const openActiveProjectRef = useRef(openActiveProject);
|
||
openActiveProjectRef.current = openActiveProject;
|
||
|
||
useEffect(() => {
|
||
/**
|
||
* 只在项目视图发布"当前项目"身份。
|
||
*
|
||
* `currentProjectContext` 是会话级状态:离开工作台(首页 / 项目组 / 模板库)不会被清空,
|
||
* 而活动回合快照是全局轮询。若照搬上下文,回首页后标签会一直是「当前项目:<上次打开的项目>」,
|
||
* 徽标数字却属于别的项目;和同文件里按 `launcherView` 门控的窗口标题 effect 也对不上。
|
||
*/
|
||
const inProjectView = launcherView === 'project-development';
|
||
setActiveProjectRuns({
|
||
activeTurns,
|
||
currentProjectPath: inProjectView
|
||
? (currentProjectContext?.projectPath ?? null)
|
||
: null,
|
||
currentProjectName: inProjectView
|
||
? (currentProjectContext?.projectName ?? null)
|
||
: null,
|
||
readFailed: snapshotReadFailed,
|
||
onOpenProject: (projectPath: string) =>
|
||
openActiveProjectRef.current(projectPath),
|
||
});
|
||
}, [
|
||
activeTurns,
|
||
currentProjectContext?.projectName,
|
||
currentProjectContext?.projectPath,
|
||
launcherView,
|
||
setActiveProjectRuns,
|
||
snapshotReadFailed,
|
||
]);
|
||
useEffect(() => () => setActiveProjectRuns(null), [setActiveProjectRuns]);
|
||
|
||
useEffect(() => {
|
||
const projectPath = currentProjectContext?.projectPath ?? null;
|
||
const previousProjectPath = manifestMergeNoticeScopeRef.current;
|
||
manifestMergeNoticeScopeRef.current = projectPath;
|
||
if (projectPath !== null && previousProjectPath === null) {
|
||
return;
|
||
}
|
||
manifestMergeRecoveryInFlightRef.current = null;
|
||
setManifestMergeNotice(null);
|
||
}, [currentProjectContext?.projectPath]);
|
||
|
||
const recoverRejectedManifestSnapshot = useCallback(
|
||
async (input: {
|
||
projectPath: string;
|
||
projectId: string;
|
||
decision: ProjectManifestMergeRejectionDecision;
|
||
source: ProjectManifestSnapshotSource;
|
||
heldRevision: number;
|
||
snapshotRevision: number;
|
||
}) => {
|
||
/**
|
||
* 只改「这一次恢复对应」的那条提示。
|
||
*
|
||
* 恢复在飞期间可能又到了一次拒收:`applyManifestSnapshot` 会无条件把提示换成新的
|
||
* 那一对 revision(同项目的第二次恢复被跳过),此时把本次恢复的 `recovered` /
|
||
* `unresolved` 盖上去,就是在报告一件从未重读过的事实。
|
||
*/
|
||
const patchStage = (stage: ProjectManifestMergeRecoveryStage) =>
|
||
setManifestMergeNotice((current) =>
|
||
current &&
|
||
current.projectId === input.projectId &&
|
||
current.heldRevision === input.heldRevision &&
|
||
current.snapshotRevision === input.snapshotRevision
|
||
? { ...current, stage }
|
||
: current,
|
||
);
|
||
const invoke = resolveTauriInvoke();
|
||
if (!invoke) {
|
||
patchStage('unresolved');
|
||
return;
|
||
}
|
||
manifestMergeRecoveryInFlightRef.current = input.projectPath;
|
||
patchStage('recovering');
|
||
try {
|
||
const fresh = await rereadAuthoritativeProjectManifestSnapshot({
|
||
projectPath: input.projectPath,
|
||
projectId: input.projectId,
|
||
readRevision: async () => {
|
||
const status = await invoke<LocalGameProjectRevisionStatus>(
|
||
'get_local_game_project_revision',
|
||
{ projectPath: input.projectPath },
|
||
);
|
||
return status.revision;
|
||
},
|
||
readManifest: () =>
|
||
invoke<NonNullable<typeof currentProjectContext>['manifest']>(
|
||
'get_local_game_manifest',
|
||
{ projectPath: input.projectPath, commandId: 'asset.list' },
|
||
),
|
||
});
|
||
const held = manifestMergeRef.current;
|
||
const active = activeProjectContextRef.current;
|
||
if (
|
||
!fresh ||
|
||
!held ||
|
||
(active && active.projectPath !== fresh.projectPath)
|
||
) {
|
||
patchStage('unresolved');
|
||
return;
|
||
}
|
||
// 重读期间已经有更新的快照被接受时绝不回退 revision:宁可报"未解决",
|
||
// 也不能把状态挪回旧版本(那会绕开 CAS 保护本身)。
|
||
//
|
||
// 注意这里**重读是成功的**(`fresh` 非空),只是这一对不能被采用。所以
|
||
// `unresolved` 阶段的文案说的是"未能按磁盘清单重新对齐",不是"读取失败"——
|
||
// 后者只对 `!fresh` 与 catch 两条路径成立,写成读失败会误报。
|
||
if (fresh.revision < held.revision) {
|
||
patchStage('unresolved');
|
||
return;
|
||
}
|
||
manifestMergeRef.current = createProjectManifestMergeState(fresh);
|
||
setCurrentProjectContext((current) =>
|
||
current && current.projectPath === fresh.projectPath
|
||
? {
|
||
...current,
|
||
manifest: fresh.manifest,
|
||
projectRevision: fresh.revision,
|
||
}
|
||
: current,
|
||
);
|
||
patchStage('recovered');
|
||
} catch (error) {
|
||
console.warn('[manifest-merge] 拒收后强制重读清单失败', error);
|
||
patchStage('unresolved');
|
||
} finally {
|
||
if (manifestMergeRecoveryInFlightRef.current === input.projectPath) {
|
||
manifestMergeRecoveryInFlightRef.current = null;
|
||
}
|
||
}
|
||
},
|
||
[setCurrentProjectContext],
|
||
);
|
||
|
||
const applyManifestSnapshot = useCallback(
|
||
(snapshot: ProjectManifestSnapshot) => {
|
||
const current = activeProjectContextRef.current;
|
||
if (
|
||
!current ||
|
||
current.projectPath !== snapshot.projectPath ||
|
||
current.manifest.projectId !== snapshot.projectId
|
||
) {
|
||
return;
|
||
}
|
||
let previous = manifestMergeRef.current;
|
||
if (
|
||
!previous ||
|
||
previous.projectPath !== current.projectPath ||
|
||
previous.projectId !== current.manifest.projectId
|
||
) {
|
||
previous =
|
||
current.projectRevision === null
|
||
? null
|
||
: createProjectManifestMergeState({
|
||
projectPath: current.projectPath,
|
||
projectId: current.manifest.projectId,
|
||
revision: current.projectRevision,
|
||
manifest: current.manifest,
|
||
source: 'initial',
|
||
});
|
||
}
|
||
if (!previous) {
|
||
manifestMergeRef.current = createProjectManifestMergeState(snapshot);
|
||
} else {
|
||
const merged = mergeProjectManifestSnapshot(previous, snapshot);
|
||
manifestMergeRef.current = merged.state;
|
||
if (merged.decision !== 'accepted') {
|
||
// CAS 拒收语义保持不动,但拒收必须留下痕迹:此前这里直接 return,
|
||
// 用户侧表现为"命令成功、画布上没有新卡、提示永远停在等待投影"。
|
||
console.warn(
|
||
`[manifest-merge] 清单快照被拒收 decision=${merged.decision} source=${snapshot.source} snapshotRevision=${snapshot.revision} heldRevision=${merged.state.revision}`,
|
||
);
|
||
const rejection = projectManifestMergeRejectionDecision(
|
||
merged.decision,
|
||
);
|
||
if (rejection) {
|
||
setManifestMergeNotice({
|
||
decision: rejection,
|
||
stage: 'recovering',
|
||
source: snapshot.source,
|
||
projectId: snapshot.projectId,
|
||
heldRevision: merged.state.revision,
|
||
snapshotRevision: snapshot.revision,
|
||
});
|
||
if (
|
||
manifestMergeRecoveryInFlightRef.current !== snapshot.projectPath
|
||
) {
|
||
void recoverRejectedManifestSnapshot({
|
||
projectPath: snapshot.projectPath,
|
||
projectId: snapshot.projectId,
|
||
decision: rejection,
|
||
source: snapshot.source,
|
||
heldRevision: merged.state.revision,
|
||
snapshotRevision: snapshot.revision,
|
||
});
|
||
}
|
||
}
|
||
return;
|
||
}
|
||
}
|
||
setCurrentProjectContext((active) =>
|
||
active &&
|
||
active.projectPath === snapshot.projectPath &&
|
||
active.manifest.projectId === snapshot.projectId
|
||
? {
|
||
...active,
|
||
manifest: snapshot.manifest,
|
||
projectRevision: snapshot.revision,
|
||
}
|
||
: active,
|
||
);
|
||
},
|
||
[recoverRejectedManifestSnapshot, setCurrentProjectContext],
|
||
);
|
||
|
||
const syncActiveProjectManifest = useCallback(
|
||
(
|
||
sourceProjectPath: string,
|
||
manifest: NonNullable<typeof currentProjectContext>['manifest'],
|
||
metadata?: ProjectManifestSnapshotMetadata,
|
||
) => {
|
||
const current = activeProjectContextRef.current;
|
||
if (!current || current.projectPath !== sourceProjectPath) {
|
||
return;
|
||
}
|
||
if (metadata) {
|
||
applyManifestSnapshot({
|
||
projectPath: sourceProjectPath,
|
||
manifest,
|
||
...metadata,
|
||
});
|
||
return;
|
||
}
|
||
const invoke = resolveTauriInvoke();
|
||
if (!invoke) {
|
||
if (current.projectRevision === null) {
|
||
setCurrentProjectContext((active) =>
|
||
active?.projectPath === sourceProjectPath
|
||
? { ...active, manifest }
|
||
: active,
|
||
);
|
||
}
|
||
return;
|
||
}
|
||
void invoke<LocalGameProjectRevisionStatus>(
|
||
'get_local_game_project_revision',
|
||
{ projectPath: sourceProjectPath },
|
||
)
|
||
.then((status) => {
|
||
applyManifestSnapshot({
|
||
projectPath: sourceProjectPath,
|
||
projectId: manifest.projectId,
|
||
revision: status.revision,
|
||
manifest,
|
||
source: 'chat',
|
||
});
|
||
})
|
||
.catch(() => {
|
||
if (activeProjectContextRef.current?.projectRevision === null) {
|
||
setCurrentProjectContext((active) =>
|
||
active?.projectPath === sourceProjectPath
|
||
? { ...active, manifest }
|
||
: active,
|
||
);
|
||
}
|
||
});
|
||
},
|
||
[applyManifestSnapshot, setCurrentProjectContext],
|
||
);
|
||
|
||
/**
|
||
* 运行态(Runtime 回合、素材命令)把「清单已变」推给壳时,壳自己把清单重读回来。
|
||
*
|
||
* 只做一次配对读:`revision → 清单 → revision`,三次读对不上就放弃本次,等下一次
|
||
* 事件或回合结束。事件**不**触发重新检查项目目录,所以画布上的资源刷新不会让工作台
|
||
* 再走一遍打开项目的流程。
|
||
*/
|
||
const refreshActiveProjectManifestFromDisk = useCallback(
|
||
async (targetProjectPath: string, targetProjectId: string) => {
|
||
const invoke = resolveTauriInvoke();
|
||
if (!invoke) {
|
||
return;
|
||
}
|
||
try {
|
||
const fresh = await rereadAuthoritativeProjectManifestSnapshot({
|
||
projectPath: targetProjectPath,
|
||
projectId: targetProjectId,
|
||
readRevision: async () => {
|
||
const status = await invoke<LocalGameProjectRevisionStatus>(
|
||
'get_local_game_project_revision',
|
||
{ projectPath: targetProjectPath },
|
||
);
|
||
return status.revision;
|
||
},
|
||
readManifest: () =>
|
||
invoke<NonNullable<typeof currentProjectContext>['manifest']>(
|
||
'get_local_game_manifest',
|
||
{ projectPath: targetProjectPath },
|
||
),
|
||
});
|
||
if (fresh) {
|
||
applyManifestSnapshot({ ...fresh, source: 'asset-event' });
|
||
}
|
||
} catch (error) {
|
||
console.warn('[manifest-merge] 清单失效事件的重读失败', error);
|
||
}
|
||
},
|
||
[applyManifestSnapshot],
|
||
);
|
||
|
||
useEffect(() => {
|
||
if (!canSubscribeTauriEvents()) {
|
||
return;
|
||
}
|
||
let disposed = false;
|
||
let unsubscribe: (() => void) | null = null;
|
||
void subscribeTauriEvent<{ projectPath?: string }>(
|
||
'game-creator-manifest-invalidated',
|
||
(event) => {
|
||
const active = activeProjectContextRef.current;
|
||
if (
|
||
!active ||
|
||
!projectPathsMatchForInvalidation(
|
||
event.payload?.projectPath ?? '',
|
||
active.projectPath,
|
||
)
|
||
) {
|
||
return;
|
||
}
|
||
void refreshActiveProjectManifestFromDisk(
|
||
active.projectPath,
|
||
active.manifest.projectId,
|
||
);
|
||
},
|
||
)
|
||
.then((release) => {
|
||
if (disposed) {
|
||
release();
|
||
return;
|
||
}
|
||
unsubscribe = release;
|
||
})
|
||
.catch(() => {
|
||
// 事件订阅不可用时,清单仍在素材命令与回合结束后刷新。
|
||
});
|
||
return () => {
|
||
disposed = true;
|
||
unsubscribe?.();
|
||
};
|
||
}, [refreshActiveProjectManifestFromDisk]);
|
||
|
||
const requestCurrentProjectPlay = useCallback((nextProjectPath: string) => {
|
||
playRequestIdRef.current += 1;
|
||
setPlayRequest({
|
||
projectPath: nextProjectPath,
|
||
requestId: playRequestIdRef.current,
|
||
});
|
||
}, []);
|
||
|
||
const handlePlayRequestHandled = useCallback((requestId: number) => {
|
||
setPlayRequest((current) =>
|
||
current?.requestId === requestId ? null : current,
|
||
);
|
||
}, []);
|
||
|
||
function navigateLauncher(view: LauncherView) {
|
||
if (
|
||
launcherView === 'project-development' &&
|
||
view !== launcherView &&
|
||
currentProjectContext
|
||
) {
|
||
homeProject.leaveProjectAnalytics();
|
||
}
|
||
setLauncherView(view);
|
||
}
|
||
|
||
const handleRunNotice = useCallback(
|
||
(notice: ProjectRunNotice) => {
|
||
showTransientNotice(notice.message, notice.tone);
|
||
},
|
||
[showTransientNotice],
|
||
);
|
||
|
||
function showLauncherNotice(title: string) {
|
||
setLauncherNotice({
|
||
title,
|
||
message: `${title}正在接入中,当前版本会先保留入口。`,
|
||
});
|
||
}
|
||
|
||
/**
|
||
* 策划文件导入工作区是「写进项目目录」的动作,导入期间切游戏运行态会读到半份工作区;
|
||
* 这里只记导入中的项目,切运行时先挡一次。
|
||
*/
|
||
const designImportProjectRef = useRef<string | null>(null);
|
||
const handleDesignFilesImportingChange = useCallback(
|
||
(projectPath: string, importing: boolean) => {
|
||
if (importing) designImportProjectRef.current = projectPath;
|
||
else if (designImportProjectRef.current === projectPath)
|
||
designImportProjectRef.current = null;
|
||
},
|
||
[],
|
||
);
|
||
|
||
async function switchToGameRuntime(nextProjectPath: string) {
|
||
if (designImportProjectRef.current === nextProjectPath) {
|
||
setLauncherNotice({
|
||
title: '正在导入文件',
|
||
message: '文件导入完成后,再做成游戏。',
|
||
});
|
||
return;
|
||
}
|
||
const invoke = resolveTauriInvoke();
|
||
if (!invoke) throw new Error('需要在陶泥儿客户端内运行');
|
||
await invoke('set_design_agent_runtime_mode', {
|
||
projectPath: nextProjectPath,
|
||
activeRuntime: 'game',
|
||
});
|
||
setGameRuntimeSwitch({
|
||
projectPath: nextProjectPath,
|
||
createdAt: currentProjectContext?.createdAt ?? 0,
|
||
});
|
||
}
|
||
|
||
const currentHelpTitle =
|
||
launcherView === 'guide'
|
||
? '使用指南'
|
||
: launcherView === 'contact'
|
||
? '联系我们'
|
||
: '最新动态';
|
||
const currentHelpItems =
|
||
launcherView === 'guide'
|
||
? ['创建游戏项目', '上传参考素材', '选择审批档位', '生成试玩原型']
|
||
: launcherView === 'contact'
|
||
? ['产品反馈', '商务合作', '账号与充值支持']
|
||
: [
|
||
'GameAgent V1.0 首页改版',
|
||
'项目工作区和 Agent 会话建设中',
|
||
'美术生成将接入平台 API',
|
||
];
|
||
|
||
return (
|
||
<main
|
||
className="launcher-shell platform-theme platform-theme--light"
|
||
aria-label="GameAgent 客户端"
|
||
>
|
||
<Sidebar
|
||
activeView={launcherView}
|
||
templateLibraryEnabled={templateLibrary.enabled}
|
||
currentUser={currentUser}
|
||
onLogout={() => {
|
||
homeProject.leaveProjectAnalytics();
|
||
resetLauncherHomeDraft();
|
||
accountWallet.resetWalletBalance();
|
||
onLogout();
|
||
}}
|
||
onNoticeRequest={showLauncherNotice}
|
||
onRechargeRequest={accountWallet.openRecharge}
|
||
onReferralRequest={(panel) =>
|
||
panel === 'invite'
|
||
? accountReferral.openInvite()
|
||
: accountReferral.openCommunity()
|
||
}
|
||
onRuntimeConfigOpen={() => setRuntimeConfigOpen(true)}
|
||
onViewChange={navigateLauncher}
|
||
/>
|
||
<section
|
||
className={
|
||
launcherNotifications.length > 0
|
||
? 'launcher-main launcher-main-with-promo'
|
||
: 'launcher-main'
|
||
}
|
||
>
|
||
{launcherNotifications.length > 0 ? (
|
||
<header className="launcher-promo" aria-label="通知">
|
||
{launcherNotifications.map((notification) => (
|
||
<Fragment key={`${notification.label}:${notification.detail}`}>
|
||
<strong>{notification.label}</strong>
|
||
<span>{notification.detail}</span>
|
||
<button type="button">{notification.actionLabel}</button>
|
||
</Fragment>
|
||
))}
|
||
</header>
|
||
) : null}
|
||
|
||
{launcherView === 'home' ? (
|
||
<HomeView
|
||
hasPromo={launcherNotifications.length > 0}
|
||
onStatusChange={setStatus}
|
||
recentProjectRows={recentProjectRows}
|
||
onCreateDraftAutomatically={createHomeDraftAutomatically}
|
||
creationBusy={homeCreationBusy}
|
||
recoverableCreatedProjectPath={homeCreationRecoverableProjectPath}
|
||
onProjectsOpen={() => navigateLauncher('projects')}
|
||
onProjectOpen={(path) => {
|
||
setProjectPath(path);
|
||
void openProject(path, 'open');
|
||
}}
|
||
onProjectPick={() => void homeProject.pickAndOpenProject()}
|
||
templateRecommendations={templateLibrary.templates}
|
||
templateLibraryEnabled={templateLibrary.enabled}
|
||
templateLibraryLoading={
|
||
templateLibrary.status === 'loading' ||
|
||
templateLibrary.status === 'idle'
|
||
}
|
||
templateLibraryError={templateLibrary.error}
|
||
onTemplateLibraryOpen={() => setLauncherView('template-library')}
|
||
// 平台作品改编建出的项目与模板项目一样「原生已落盘」,复用同一条进项目通道;
|
||
// 项目来源事实(改编自哪个作品)由命令写入 .agent/fork-source.json,首页负责展示。
|
||
onPlatformGameProjectCreated={async (
|
||
result,
|
||
isCurrent,
|
||
analytics,
|
||
) => {
|
||
await homeProject.enterCreatedTemplateProject(
|
||
result,
|
||
isCurrent,
|
||
analytics,
|
||
);
|
||
}}
|
||
/>
|
||
) : launcherView === 'projects' ? (
|
||
<ProjectsPage
|
||
homeProject={homeProject}
|
||
recentProjects={recentProjects}
|
||
/>
|
||
) : launcherView === 'template-library' && templateLibrary.enabled ? (
|
||
<TemplateLibraryView
|
||
controller={templateLibrary}
|
||
onBack={() => setLauncherView('home')}
|
||
/>
|
||
) : launcherView === 'project-development' && currentProjectContext ? (
|
||
<>
|
||
{currentProjectContext.fileImportNotice ? (
|
||
<div className="launcher-manifest-merge-notices">
|
||
<div className="game-resource-live-notice" role="status">
|
||
<span>{currentProjectContext.fileImportNotice}</span>
|
||
<button
|
||
type="button"
|
||
onClick={() =>
|
||
setCurrentProjectContext((current) =>
|
||
current
|
||
? { ...current, fileImportNotice: undefined }
|
||
: current,
|
||
)
|
||
}
|
||
>
|
||
知道了
|
||
</button>
|
||
</div>
|
||
</div>
|
||
) : null}
|
||
{manifestMergeNotice ? (
|
||
<div className="launcher-manifest-merge-notices">
|
||
<div
|
||
className="game-resource-live-notice"
|
||
role="status"
|
||
data-manifest-merge-decision={manifestMergeNotice.decision}
|
||
data-manifest-merge-stage={manifestMergeNotice.stage}
|
||
data-manifest-merge-source={manifestMergeNotice.source}
|
||
data-manifest-merge-held-revision={
|
||
manifestMergeNotice.heldRevision
|
||
}
|
||
data-manifest-merge-snapshot-revision={
|
||
manifestMergeNotice.snapshotRevision
|
||
}
|
||
>
|
||
<span>
|
||
{describeProjectManifestMergeRejection(
|
||
manifestMergeNotice.decision,
|
||
manifestMergeNotice.stage,
|
||
)}
|
||
</span>
|
||
{manifestMergeNotice.stage === 'recovering' ? null : (
|
||
<button
|
||
type="button"
|
||
onClick={() =>
|
||
void recoverRejectedManifestSnapshot({
|
||
projectPath: currentProjectContext.projectPath,
|
||
projectId: manifestMergeNotice.projectId,
|
||
decision: manifestMergeNotice.decision,
|
||
source: manifestMergeNotice.source,
|
||
heldRevision: manifestMergeNotice.heldRevision,
|
||
snapshotRevision:
|
||
manifestMergeNotice.snapshotRevision,
|
||
})
|
||
}
|
||
>
|
||
重新读取清单
|
||
</button>
|
||
)}
|
||
<button
|
||
type="button"
|
||
onClick={() => setManifestMergeNotice(null)}
|
||
>
|
||
知道了
|
||
</button>
|
||
</div>
|
||
</div>
|
||
) : null}
|
||
<ProjectDevelopmentView
|
||
agentDockVisible={false}
|
||
projectName={currentProjectContext.projectName}
|
||
projectPath={currentProjectContext.projectPath}
|
||
manifest={currentProjectContext.manifest}
|
||
attachments={currentProjectContext.attachments}
|
||
recentRunStatus={currentProjectContext.recentRunStatus}
|
||
recentRunStopReason={currentProjectContext.recentRunStopReason}
|
||
preview={activeProjectPreview}
|
||
agentResults={activeProjectAgentResults}
|
||
planningStartMode={planningStartMode}
|
||
activeVersionId={activeVersionId}
|
||
onActiveVersionChange={setActiveVersionId}
|
||
onPlay={() =>
|
||
requestCurrentProjectPlay(currentProjectContext.projectPath)
|
||
}
|
||
onMakeGame={() =>
|
||
void switchToGameRuntime(currentProjectContext.projectPath)
|
||
}
|
||
onRevealProjectDirectory={() =>
|
||
recentProjects.handleRevealProjectDirectory(
|
||
currentProjectContext.projectPath,
|
||
)
|
||
}
|
||
onNotice={handleRunNotice}
|
||
onManifestChange={syncActiveProjectManifest}
|
||
onHomeOpen={() => navigateLauncher('home')}
|
||
onProjectsOpen={() => navigateLauncher('projects')}
|
||
chat={
|
||
<ProjectChat
|
||
key={`${currentProjectContext.projectPath}:${agentRuntimeMode}`}
|
||
initialProjectPath={currentProjectContext.projectPath}
|
||
initialProjectManifest={currentProjectContext.manifest}
|
||
initialProjectKind={currentProjectContext.projectKind}
|
||
initialPlanningPrompt={
|
||
switchedToGameRuntime
|
||
? DESIGN_ARTIFACTS_BUILD_PROMPT
|
||
: !omitOriginalPlanningTurnInputs
|
||
? currentProjectContext.initialPrompt
|
||
: ''
|
||
}
|
||
initialCreationType={
|
||
switchedToGameRuntime
|
||
? 'game'
|
||
: !omitOriginalPlanningTurnInputs
|
||
? currentProjectContext.creationType
|
||
: null
|
||
}
|
||
initialAttachments={
|
||
!omitOriginalPlanningTurnInputs
|
||
? currentProjectContext.attachments
|
||
: []
|
||
}
|
||
initialPlanningPromptClaimScope={
|
||
switchedToGameRuntime ? 'approved-design-build' : ''
|
||
}
|
||
planningStartMode={planningStartMode}
|
||
playRequest={playRequest}
|
||
onPlayRequestHandled={handlePlayRequestHandled}
|
||
onManifestChange={syncActiveProjectManifest}
|
||
onPreviewChange={setActiveProjectPreview}
|
||
onRunNotice={handleRunNotice}
|
||
onAgentResultsChange={setActiveProjectAgentResults}
|
||
onDesignFilesImportingChange={
|
||
handleDesignFilesImportingChange
|
||
}
|
||
onSwitchToGameRuntime={switchToGameRuntime}
|
||
/>
|
||
}
|
||
/>
|
||
</>
|
||
) : (
|
||
<section className="launcher-page launcher-help-page">
|
||
<header>
|
||
<div>
|
||
<h1>{currentHelpTitle}</h1>
|
||
<p>陶泥儿 GameAgent</p>
|
||
</div>
|
||
</header>
|
||
<div className="launcher-help-cards">
|
||
{currentHelpItems.map((item) => (
|
||
<article key={item}>{item}</article>
|
||
))}
|
||
</div>
|
||
</section>
|
||
)}
|
||
</section>
|
||
{runtimeConfigOpen ? (
|
||
<RuntimeConfigDialog
|
||
projectPath={currentProjectContext?.projectPath || projectPath}
|
||
onClose={() => setRuntimeConfigOpen(false)}
|
||
/>
|
||
) : null}
|
||
<AccountWalletDialogs controller={accountWallet} />
|
||
<AccountReferralDialogs controller={accountReferral} />
|
||
{walletSlot ? (
|
||
createPortal(
|
||
<AccountWalletBar controller={accountWallet} />,
|
||
walletSlot,
|
||
)
|
||
) : isWindowChrome ? null : (
|
||
<AccountWalletBar controller={accountWallet} />
|
||
)}
|
||
<LauncherNoticeToast
|
||
notice={notice ?? statusNotice}
|
||
onDismiss={() => setNotice(null)}
|
||
/>
|
||
{launcherNotice ? (
|
||
<div
|
||
className="launcher-dialog-backdrop"
|
||
role="presentation"
|
||
onMouseDown={(event) => {
|
||
if (event.target === event.currentTarget) {
|
||
setLauncherNotice(null);
|
||
}
|
||
}}
|
||
>
|
||
<section
|
||
aria-labelledby="launcher-notice-title"
|
||
aria-modal="true"
|
||
className="launcher-dialog"
|
||
role="dialog"
|
||
onKeyDown={(event) =>
|
||
closeDialogOnEscape(event, () => setLauncherNotice(null))
|
||
}
|
||
>
|
||
<h2 id="launcher-notice-title">{launcherNotice.title}</h2>
|
||
<p>{launcherNotice.message}</p>
|
||
<div className="launcher-dialog-actions">
|
||
<button type="button" onClick={() => setLauncherNotice(null)}>
|
||
知道了
|
||
</button>
|
||
</div>
|
||
</section>
|
||
</div>
|
||
) : null}
|
||
<NonEmptyProjectDialog controller={homeProject} />
|
||
</main>
|
||
);
|
||
}
|