Files
Genarrative/apps/ai-game-creator-shell/src/features/app-shell/WorkspaceLauncher.tsx
T
suzmii d89e21ffd5 feat(游戏共创): 首页新增「从平台作品开始创作」入口(唯一可点的客户端路径)
- 入口位置:首页模板库推荐位之后新增一节「从平台作品开始创作」。与模板库同属「从现成内容
  起步」,但这条拿的是平台作品的已构建成品(可玩参考 + 素材),不是可编辑工程,因此单独成节
  而不是塞进模板库;未登录用户进不到首页,服务端也确实对任何登录用户开放取件,无需额外灰度。
- 交互:输入平台作品 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 各自提示且不建项目、加载态与重复提交、成功时命令参数与「改编自」展示)。
2026-10-05 17:30:47 +08:00

1043 lines
39 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 {
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>
);
}