Files
Genarrative/apps/ai-game-creator-shell/src/features/app-shell/useHomeProjectCreation.ts
T
kdletters 7092689b36
Project CI / Repository checks (push) Successful in 3m11s
Project CI / Frontend tests (push) Successful in 4m4s
Project CI / Backend tests (push) Successful in 6m14s
Project CI / Native shell tests (push) Failing after 14m20s
收口 AGC 生命周期超时隔离与建项恢复
## 变更内容

在 master(0f829cd25)上用故障注入复现出四类"每走一步都会卡住"的生命周期残留,本轮按"超时后真正隔离并核对底层操作"收口,不再新增 operation 字段:

- 本地会话写入队列从"无限等上一次完成"改为带解围期限的闸门(60s)。Rust 侧 install/clear 本来就按 generation 单调拒绝更旧写入,所以渲染层只需保证新 generation 不被卡死的调用永久挡住;
- generation floor 读取不再把一次瞬时失败缓存成永久失败(原先 `??=` 缓存了已 reject 的 promise,导致同一渲染进程内后续登录/退出全部失败);
- 登录 UI 的 45 秒围栏只放弃等待、不放弃结果:本地运行时确实装好会话时界面跟随进入工作区,且迟到结果不会覆盖更新的登录尝试;
- 首页自动建项从 `deadlineMs: null` 改为 10 分钟兜底期限:到点解围并提示(底层创建继续在后台跑,迟到成功照常进项目),失败时保留 `scope.projectPath`、登记最近项目并新增「打开已创建的工作区」入口;底层创建未返回期间只挡"再建一个",不挡打开已有项目;
- 同步 `project.bootstrap` 权限文案断言、生命周期技术方案状态与 shared pitfalls。

## 验证

- `npm --prefix apps/ai-game-creator-shell run typecheck`(含 skill-pack、check-config)
- `vitest run apps/ai-game-creator-shell/tests/appSurface.test.ts`(428/428,含新增 5 个故障注入回归;`project.bootstrap` 文案断言在此前 master 上确定性失败)
- 定向 `clientHttp`、`clientApi`、`clientOperation`、`recentProjectsModel`、`clientRuntimeErrorBoundary`、`sessionPreview`、`start-dev-stack`、`dev-port`、`start-tauri-dev`(全部通过)
- `npm run check:doc-index`、`npm run check:encoding`、`git diff --check`

原生 Runner/IPC 与真实 Provider 下的同一批时序未执行,本轮结论来自 deterministic surface 与 mock 故障注入。

Co-authored-by: DotCraft <273930855+dotcraft-ai@users.noreply.github.com>
2026-09-14 14:49:45 +08:00

980 lines
32 KiB
TypeScript

import {
type Dispatch,
type SetStateAction,
useCallback,
useRef,
useState,
} from 'react';
import type {
GameCreationAppManifest,
GameCreationAppPreviewState,
} from '../../../../../packages/shared/src/contracts/gameCreationApp';
import { createLocalProjectId } from '../../app/constants';
import { useEscapeToClose } from '../../app/dialogs';
import { resolveTauriInvoke } from '../../app/tauri';
import type {
InitLocalProjectResult,
LauncherImportedAttachment,
LauncherProjectContext,
LocalGameProjectRevisionStatus,
LocalProjectDirectoryStatus,
LocalProjectFileResult,
PendingNonEmptyProject,
ProjectStartMode,
RenameLocalProjectResult,
TauriInvoke,
UploadLocalAssetResult,
} from '../../app/types';
import {
type ClientOperation,
createClientOperation,
transitionClientOperation,
} from '../../services/clientOperation';
import type {
HomeAttachmentDraft,
HomeCreationType,
HomeDraft,
} from '../../view/home';
import { useLauncherHomeDraftStore } from '../../view/home/useHomeDraftStore';
import type { LauncherView } from '../../view/layout';
import type {
ProjectAgentResultSummary,
ProjectAgentRuntimeSummary,
} from '../../view/project-development';
import { projectNameFromPath } from '../agent-runtime';
import {
isAbsoluteProjectPath,
projectPathHasControlCharacter,
} from '../project-summary/projectSummary';
import { resolveSessionPreviewOnProjectOpen } from './sessionPreview';
type UseHomeProjectCreationOptions = {
setStatus: Dispatch<SetStateAction<string>>;
setLauncherView: Dispatch<SetStateAction<LauncherView>>;
setAgentChatProjectPath: Dispatch<SetStateAction<string>>;
rememberRecentWorkspace: (projectPath: string) => void;
};
const APPROVED_GDD_BUILD_PROMPT = [
'请按照附件中的已批准 GDD 开始建造这款游戏。',
'',
'这份 GDD 已覆盖游戏定位与一句话概念、类型与美术方向、游戏支柱、核心循环、目标用户、平台与输入事实、MVP 系统、暂不纳入范围、创作者提示和原型验证项。',
'',
'请先阅读并理解附件中的 fast_gdd.md,以它作为本次建造的主要依据,优先实现其中 MVP 范围内的可运行游戏原型。',
].join('\n');
function createTextAttachmentFile(content: string) {
const file = new File([content], 'fast_gdd.md', {
type: 'text/markdown',
lastModified: Date.now(),
});
if (typeof file.arrayBuffer !== 'function') {
Object.defineProperty(file, 'arrayBuffer', {
value: async () => new TextEncoder().encode(content).buffer,
});
}
return file;
}
async function suggestAutomaticProjectName(
invoke: TauriInvoke,
draft: HomeDraft,
) {
try {
return await invoke<string | null>('suggest_automatic_project_name', {
prompt: draft.prompt,
});
} catch {
// 项目命名是增强能力:配置缺失、Provider 失败或非法输出都回退默认名,
// 不阻断用户真正请求的自动创建和首轮创作。
return null;
}
}
/**
* 自动建项的兜底期限。
*
* 建项要跑脚手架和依赖安装,慢是正常的,所以这不是"失败期限"而是"解围期限":到点后不再让首页
* 入口无限占用工作区闸门(底层创建不会被取消,迟到成功仍会照常进入项目)。没有这个期限时,
* 一次卡死的 `create_automatic_local_game_project` 会让首页之后的打开/新建全部无法进行。
*/
const HOME_CREATION_DEADLINE_MS = 10 * 60_000;
const HOME_CREATION_WATCHDOG_MESSAGE =
'工作区创建超过 10 分钟仍未返回;可先打开其它项目,或在项目列表查看已创建的工作区';
/**
* 建项已经落盘、但没能进入项目时,给首页一个恢复入口(打开已创建的工作区)。
* 只有"已经知道项目路径且当前没有在跑"的阶段才提示,避免和进行中的建项打架。
*/
function resolveRecoverableHomeProjectPath(
operation: ClientOperation<
'home-create',
{ draft: HomeDraft; startMode: ProjectStartMode }
> | null,
) {
if (!operation) return '';
if (
operation.phase !== 'retryable-failure' &&
operation.phase !== 'unknown'
) {
return '';
}
return operation.scope.projectPath ?? '';
}
export function useHomeProjectCreation({
setStatus,
setLauncherView,
setAgentChatProjectPath,
rememberRecentWorkspace,
}: UseHomeProjectCreationOptions) {
const [projectPath, setProjectPathState] = useState('');
const [projectAction, setProjectAction] = useState<
'opening' | 'creating' | null
>(null);
const projectActionRef = useRef(projectAction);
projectActionRef.current = projectAction;
const setProjectPath = useCallback((nextProjectPath: string) => {
setProjectPathState(nextProjectPath);
}, []);
const [currentProjectContext, setCurrentProjectContext] =
useState<LauncherProjectContext | null>(null);
const [activeProjectPreview, setActiveProjectPreview] =
useState<GameCreationAppPreviewState | null>(null);
const [activeProjectAgentRuntimeSummaries, setAgentRuntimeSummaries] =
useState<ProjectAgentRuntimeSummary[]>([]);
const [activeProjectAgentResults, setAgentResults] = useState<
ProjectAgentResultSummary[]
>([]);
const [pendingNonEmptyProject, setPendingNonEmptyProject] =
useState<PendingNonEmptyProject | null>(null);
const resetLauncherHomeDraft = useLauncherHomeDraftStore(
(state) => state.reset,
);
const approvedGddStartInFlightRef = useRef(false);
const [homeCreationOperation, setHomeCreationOperation] =
useState<ClientOperation<
'home-create',
{
draft: HomeDraft;
startMode: ProjectStartMode;
}
> | null>(null);
const homeCreationOperationRef = useRef(homeCreationOperation);
homeCreationOperationRef.current = homeCreationOperation;
function homeCreationIsBusy() {
const phase = homeCreationOperationRef.current?.phase;
return phase === 'network' || phase === 'runner' || phase === 'project';
}
/**
* 进项目流程的代次。
*
* `enterProjectDevelopment` 在写项目上下文之前会 await 一次会话预览核验;项目页的两条
* 入口(打开 / 在项目页新建)由 `projectActionRef` 串行化,但首页新建那条没有同样的闸门,
* 可能和另一次进项目重叠。慢的那一次若后到,会把工作区绑到旧项目上,所以每次进项目自增
* 一次代次,await 回来时已经不是最新代次的结果整体丢弃。
*/
const projectEntryTokenRef = useRef(0);
/**
* 首页自动建项的尝试代次与"底层创建仍未返回"标记。
*
* 看门狗到点后会放开工位闸门(让用户还能打开其它项目),但底层创建仍在跑。此时既不允许
* 并发再建一个项目(会产生重复工作区),也不能让迟到的创建结果强行劫持用户已经打开的别的项目。
*/
const homeCreationAttemptRef = useRef(0);
const homeCreationUnresolvedRef = useRef(false);
function validateProjectPath(nextProjectPath: string) {
const trimmedProjectPath = nextProjectPath.trim();
if (!trimmedProjectPath || !isAbsoluteProjectPath(trimmedProjectPath)) {
setStatus('请提供项目绝对路径');
return null;
}
if (projectPathHasControlCharacter(trimmedProjectPath)) {
setStatus('项目目录不能包含控制字符');
return null;
}
return trimmedProjectPath;
}
async function startGameFromApprovedGdd(nextProjectPath: string) {
if (approvedGddStartInFlightRef.current) {
return;
}
approvedGddStartInFlightRef.current = true;
const invoke = resolveTauriInvoke();
try {
if (!invoke) {
throw new Error('需要在陶泥儿客户端内运行');
}
const projectPath = nextProjectPath.trim();
if (!projectPath) {
throw new Error('当前项目路径无效');
}
setStatus('正在读取已批准 GDD');
const result = await invoke<LocalProjectFileResult>(
'read_local_project_file',
{
projectPath,
relativePath: 'game/fast_gdd.md',
commandId: 'file.read',
},
);
const file = createTextAttachmentFile(result.content);
await createHomeDraftAutomaticallyWithOptions(
{
creationType: 'game',
prompt: APPROVED_GDD_BUILD_PROMPT,
attachments: [
{
id: `approved-gdd-${Date.now().toString(36)}`,
file,
},
],
},
'direct-build',
{ suggestName: false },
);
} finally {
approvedGddStartInFlightRef.current = false;
}
}
async function enterProjectDevelopment(context: LauncherProjectContext) {
const entryToken = (projectEntryTokenRef.current += 1);
/**
* 会话预览只认"内存 registry 里真的还在跑"的那一个(见
* `resolveSessionPreviewOnProjectOpen`)。项目 manifest 里的 `preview` 是**落盘记录**、
* 不是活体:预览服务器是进程内线程、非 Linux 端口还是随机临时端口,客户端退出后记录
* 仍会写着 running,照它自动进运行视图就是给用户一个打不开的运行界面。
*/
const session = await resolveSessionPreviewOnProjectOpen({
invoke: resolveTauriInvoke(),
projectPath: context.projectPath,
recordedPreview: context.manifest.preview ?? null,
});
if (entryToken !== projectEntryTokenRef.current) {
// 更晚的一次进项目已经接管工作区:这一次的结果(预览与项目上下文)全部丢弃,
// 否则慢请求后到会把新项目覆盖回旧项目。
return;
}
setCurrentProjectContext(
session.manifestPreviewPatch
? {
...context,
manifest: {
...context.manifest,
preview: session.manifestPreviewPatch,
},
}
: context,
);
setActiveProjectPreview(session.sessionPreview);
setAgentRuntimeSummaries([]);
setAgentResults([]);
setProjectPath(context.projectPath);
setAgentChatProjectPath(context.projectPath);
setLauncherView('project-development');
rememberRecentWorkspace(context.projectPath);
}
async function readCurrentProjectRevision(
invoke: TauriInvoke,
nextProjectPath: string,
) {
try {
const status = await invoke<LocalGameProjectRevisionStatus>(
'get_local_game_project_revision',
{ projectPath: nextProjectPath },
);
return Number.isSafeInteger(status.revision) && status.revision >= 0
? status.revision
: null;
} catch {
return null;
}
}
async function importHomeAttachments(
invoke: TauriInvoke,
nextProjectPath: string,
attachments: HomeAttachmentDraft[],
) {
const imported: LauncherImportedAttachment[] = [];
for (const attachment of attachments) {
const mediaType = attachment.file.type || 'application/octet-stream';
try {
const bytes = Array.from(
new Uint8Array(await attachment.file.arrayBuffer()),
);
const result = await invoke<UploadLocalAssetResult>(
'upload_local_asset',
{
projectPath: nextProjectPath,
fileName: attachment.file.name,
mediaType,
bytes,
},
);
imported.push({
fileName: attachment.file.name,
mediaType,
localPath: result.localPath,
status: 'imported',
size: attachment.file.size,
});
} catch (error) {
imported.push({
fileName: attachment.file.name,
mediaType,
status: 'failed',
error: error instanceof Error ? error.message : String(error),
size: attachment.file.size,
});
}
}
return imported;
}
async function enterCreatedHomeProject(
invoke: TauriInvoke,
result: InitLocalProjectResult,
creationType: HomeCreationType,
prompt: string,
attachments: HomeAttachmentDraft[],
startMode: ProjectStartMode,
) {
if (startMode === 'planning') {
await invoke('set_design_agent_runtime_mode', {
projectPath: result.projectPath,
activeRuntime: 'design',
});
}
const importedAttachments = await importHomeAttachments(
invoke,
result.projectPath,
attachments,
);
await enterProjectDevelopment({
projectPath: result.projectPath,
projectName:
result.manifest.name || projectNameFromPath(result.projectPath),
projectKind: 'web',
manifest: result.manifest,
projectRevision: await readCurrentProjectRevision(
invoke,
result.projectPath,
),
creationType,
startMode,
initialPrompt:
prompt.trim() ||
(attachments.length > 0
? '用户上传了参考附件,等待后续补充需求。'
: ''),
attachments: importedAttachments,
recentRunStatus: null,
recentRunStopReason: null,
createdAt: Date.now(),
});
resetLauncherHomeDraft();
}
async function createHomeProjectFromDirectory(
nextProjectPath: string,
creationType: HomeCreationType,
prompt: string,
attachments: HomeAttachmentDraft[],
startMode: ProjectStartMode,
skipNonEmptyCheck = false,
) {
const trimmedProjectPath = validateProjectPath(nextProjectPath);
if (!trimmedProjectPath) {
return '项目目录无效';
}
const invoke = resolveTauriInvoke();
if (!invoke) {
setStatus('需要在 Tauri App 内运行');
return '需要在 Tauri App 内运行';
}
setStatus('正在创建项目');
try {
if (!skipNonEmptyCheck) {
const nonEmpty = await invoke<boolean>(
'is_local_project_directory_non_empty',
{ projectPath: trimmedProjectPath },
);
if (nonEmpty) {
setPendingNonEmptyProject({
kind: 'home-create',
projectPath: trimmedProjectPath,
startMode,
draft: { creationType, prompt, attachments },
});
setStatus('目标文件夹不是空的');
return '目标文件夹不是空的,请确认是否继续新建';
}
}
const result = await invoke<InitLocalProjectResult>(
'init_local_game_project',
{
projectPath: trimmedProjectPath,
projectId: createLocalProjectId(),
name: projectNameFromPath(trimmedProjectPath),
},
);
try {
await enterCreatedHomeProject(
invoke,
result,
creationType,
prompt,
attachments,
startMode,
);
setStatus('已创建项目,正在开始智能创作');
} catch (error) {
throw new Error(
`项目已创建;首条需求投递失败:${
error instanceof Error ? error.message : String(error)
}`,
);
}
return '已创建项目并进入项目开发';
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
setStatus(message);
throw error;
}
}
async function createProjectFromProjectPage(
nextProjectPath: string,
skipNonEmptyCheck = false,
) {
if (projectActionRef.current) {
return;
}
const trimmedProjectPath = validateProjectPath(nextProjectPath);
if (!trimmedProjectPath) {
return;
}
const invoke = resolveTauriInvoke();
if (!invoke) {
setStatus('需要在 Tauri App 内运行');
return;
}
projectActionRef.current = 'creating';
setProjectAction('creating');
setStatus('正在创建项目');
try {
if (!skipNonEmptyCheck) {
const nonEmpty = await invoke<boolean>(
'is_local_project_directory_non_empty',
{ projectPath: trimmedProjectPath },
);
if (nonEmpty) {
setPendingNonEmptyProject({
kind: 'project-create',
projectPath: trimmedProjectPath,
});
setStatus('目标文件夹不是空的');
return;
}
}
const result = await invoke<InitLocalProjectResult>(
'init_local_game_project',
{
projectPath: trimmedProjectPath,
projectId: createLocalProjectId(),
name: projectNameFromPath(trimmedProjectPath),
},
);
setStatus('已创建项目');
await enterProjectDevelopment({
projectPath: result.projectPath,
projectName:
result.manifest.name || projectNameFromPath(result.projectPath),
projectKind: 'web',
manifest: result.manifest,
projectRevision: await readCurrentProjectRevision(
invoke,
result.projectPath,
),
creationType: null,
startMode: null,
initialPrompt: '',
attachments: [],
recentRunStatus: null,
recentRunStopReason: null,
createdAt: Date.now(),
});
} catch (error) {
setStatus(error instanceof Error ? error.message : String(error));
} finally {
if (projectActionRef.current === 'creating') {
projectActionRef.current = null;
setProjectAction(null);
}
}
}
async function openProject(nextProjectPath: string, mode: 'open' | 'create') {
if (mode === 'create') {
await createProjectFromProjectPage(nextProjectPath);
return;
}
if (projectActionRef.current) {
return;
}
const trimmedProjectPath = validateProjectPath(nextProjectPath);
if (!trimmedProjectPath) {
return;
}
const invoke = resolveTauriInvoke();
if (!invoke) {
setStatus('需要在 Tauri App 内运行');
return;
}
projectActionRef.current = 'opening';
setProjectAction('opening');
setStatus('正在打开');
try {
const directoryStatus = await invoke<LocalProjectDirectoryStatus>(
'inspect_local_project_directory',
{ projectPath: trimmedProjectPath },
);
if (!directoryStatus.exists) {
setStatus('项目目录不存在');
return;
}
if (!directoryStatus.isDirectory) {
setStatus('项目路径不是文件夹');
return;
}
let projectManifest: GameCreationAppManifest;
if (directoryStatus.isGameCreatorProject) {
projectManifest = await invoke<GameCreationAppManifest>(
'get_local_game_manifest',
{ projectPath: trimmedProjectPath },
);
} else if (directoryStatus.isCocosProject) {
const result = await invoke<InitLocalProjectResult>(
'import_local_cocos_project',
{
projectPath: trimmedProjectPath,
projectId: createLocalProjectId(),
name: projectNameFromPath(trimmedProjectPath),
},
);
projectManifest = result.manifest;
} else if (
directoryStatus.godotProjectRoot !== null &&
directoryStatus.godotProjectRoot !== undefined
) {
const result = await invoke<InitLocalProjectResult>(
'import_local_godot_project',
{
projectPath: trimmedProjectPath,
projectId: createLocalProjectId(),
name: projectNameFromPath(trimmedProjectPath),
},
);
projectManifest = result.manifest;
} else {
setStatus('这不是已初始化的 AI 游戏项目,请使用新建项目。');
return;
}
const runtimeMode = await invoke<{
activeRuntime: 'design' | 'game';
} | null>('get_design_agent_runtime_mode', {
projectPath: trimmedProjectPath,
});
setStatus('已打开项目');
await enterProjectDevelopment({
projectPath: trimmedProjectPath,
projectName:
directoryStatus.projectName ||
projectNameFromPath(trimmedProjectPath),
projectKind: directoryStatus.isCocosProject
? 'cocos'
: directoryStatus.godotProjectRoot !== null &&
directoryStatus.godotProjectRoot !== undefined
? 'godot'
: 'web',
manifest: projectManifest,
projectRevision: await readCurrentProjectRevision(
invoke,
trimmedProjectPath,
),
creationType: null,
startMode: runtimeMode?.activeRuntime === 'design' ? 'planning' : null,
initialPrompt: '',
attachments: [],
recentRunStatus: directoryStatus.recentRunStatus,
recentRunStopReason: directoryStatus.recentRunStopReason,
createdAt: Date.now(),
});
} catch (error) {
setStatus(error instanceof Error ? error.message : String(error));
} finally {
if (projectActionRef.current === 'opening') {
projectActionRef.current = null;
setProjectAction(null);
}
}
}
async function confirmCreateInNonEmptyFolder() {
if (!pendingNonEmptyProject) {
return;
}
const pendingProject = pendingNonEmptyProject;
setPendingNonEmptyProject(null);
try {
if (pendingProject.kind === 'home-create') {
await createHomeProjectFromDirectory(
pendingProject.projectPath,
pendingProject.draft.creationType,
pendingProject.draft.prompt,
pendingProject.draft.attachments,
pendingProject.startMode,
true,
);
return;
}
await createProjectFromProjectPage(pendingProject.projectPath, true);
} catch (error) {
setStatus(error instanceof Error ? error.message : String(error));
}
}
function cancelCreateInNonEmptyFolder() {
setPendingNonEmptyProject(null);
setStatus('已取消');
}
useEscapeToClose(
cancelCreateInNonEmptyFolder,
pendingNonEmptyProject !== null,
);
async function createHomeDraft(
draft: HomeDraft,
startMode: ProjectStartMode,
) {
const invoke = resolveTauriInvoke();
if (!invoke) {
throw new Error('需要在 Tauri App 内运行');
}
const selectedPath = await invoke<string | null>(
'pick_local_project_directory',
projectPath.trim() ? { initialPath: projectPath.trim() } : undefined,
);
if (!selectedPath) {
return '已取消';
}
return createHomeProjectFromDirectory(
selectedPath,
draft.creationType,
draft.prompt,
draft.attachments,
startMode,
);
}
async function createHomeDraftAutomatically(
draft: HomeDraft,
startMode: ProjectStartMode,
) {
return createHomeDraftAutomaticallyWithOptions(draft, startMode, {
// 做方案的首轮还要调用一次策划 Provider;项目名称不是策划输入的
// 前置条件,避免在进入工作区前再额外等待一次模型请求。
suggestName: startMode !== 'planning',
});
}
async function createHomeDraftAutomaticallyWithOptions(
draft: HomeDraft,
startMode: ProjectStartMode,
options: { suggestName: boolean },
) {
if (projectActionRef.current || homeCreationIsBusy()) {
return '已有项目操作进行中,请稍候';
}
if (homeCreationUnresolvedRef.current) {
// 上一次建项的底层调用还没返回(可能已经超过看门狗期限)。此时放行会真的建出第二个
// 工作区,所以只挡"再建一个",不挡打开/查看已有项目。
return '上一次工作区创建仍未返回,请稍候,或重启客户端后再试';
}
const invoke = resolveTauriInvoke();
if (!invoke) {
throw new Error('需要在陶泥儿客户端内运行');
}
const attempt = (homeCreationAttemptRef.current += 1);
const isCurrentAttempt = () => homeCreationAttemptRef.current === attempt;
const operation = createClientOperation(
'home-create',
{ draft, startMode },
{ deadlineMs: HOME_CREATION_DEADLINE_MS, cancellable: false },
);
setHomeCreationOperation(transitionClientOperation(operation, 'network'));
// This action is owned by WorkspaceLauncher rather than HomeView. The
// launcher survives navigation, so unmounting the home page cannot release
// the guard while project creation or first-turn import is still running.
projectActionRef.current = 'creating';
setProjectAction('creating');
homeCreationUnresolvedRef.current = true;
setStatus('正在创建工作区');
let createdProjectPath: string | undefined;
let entryTokenAtWatchdog: number | null = null;
const operationScope = () =>
createdProjectPath ? { scope: { projectPath: createdProjectPath } } : {};
let watchdogId: number | undefined;
/**
* 看门狗到点后**只解围不取消**:首页入口立刻拿回控制权(否则 `await` 不结束,
* 首页按钮会一直禁用),而底层建项继续在后台跑;它真的成功时会照常进入项目。
* 这就是为什么这里用 `return string` 而不是抛错——首页的 catch 会把错误统一压成
* 「创建未完成,请重试」,反而丢掉"只是慢"这个信息。
*/
const watchdog = new Promise<string>((resolve) => {
watchdogId = window.setTimeout(() => {
if (!isCurrentAttempt() || !homeCreationUnresolvedRef.current) return;
entryTokenAtWatchdog = projectEntryTokenRef.current;
setHomeCreationOperation(
transitionClientOperation(operation, 'unknown', operationScope()),
);
setStatus(HOME_CREATION_WATCHDOG_MESSAGE);
if (projectActionRef.current === 'creating') {
projectActionRef.current = null;
setProjectAction(null);
}
resolve(HOME_CREATION_WATCHDOG_MESSAGE);
}, HOME_CREATION_DEADLINE_MS);
});
/** 后台继续跑的建项主体:用户可见的等待由 `watchdog` 兜底,这里只负责最终落定。 */
const creation = (async () => {
try {
const suggestedName = options.suggestName
? await suggestAutomaticProjectName(invoke, draft)
: null;
const result = await invoke<InitLocalProjectResult>(
'create_automatic_local_game_project',
{
name: suggestedName,
planning: startMode === 'planning',
},
);
createdProjectPath = result.projectPath;
setHomeCreationOperation(
transitionClientOperation(operation, 'project', {
scope: { projectPath: result.projectPath },
}),
);
if (
entryTokenAtWatchdog !== null &&
projectEntryTokenRef.current !== entryTokenAtWatchdog
) {
// 看门狗之后用户已经进了别的项目:工作区确实建好了,但不能在此时把工作区切过去。
rememberRecentWorkspace(result.projectPath);
setHomeCreationOperation(
transitionClientOperation(operation, 'retryable-failure', {
scope: { projectPath: result.projectPath },
}),
);
setStatus('工作区已创建;可在项目列表打开');
return '工作区已创建,可从项目列表打开';
}
try {
await enterCreatedHomeProject(
invoke,
result,
draft.creationType,
draft.prompt,
draft.attachments,
startMode,
);
setHomeCreationOperation(
transitionClientOperation(operation, 'success', {
scope: { projectPath: result.projectPath },
}),
);
setStatus('已创建工作区,正在开始智能创作');
return '已创建工作区并进入项目开发';
} catch (error) {
// 项目目录已经建好了:把它登记进最近项目,用户可以直接打开,不必重新建一遍。
rememberRecentWorkspace(result.projectPath);
setHomeCreationOperation(
transitionClientOperation(operation, 'retryable-failure', {
scope: { projectPath: result.projectPath },
}),
);
const message = `工作区已创建;首条需求投递失败:${
error instanceof Error ? error.message : String(error)
}`;
setStatus(message);
throw new Error(message);
}
} catch (error) {
setHomeCreationOperation(
transitionClientOperation(
operation,
'retryable-failure',
operationScope(),
),
);
if (createdProjectPath) {
rememberRecentWorkspace(createdProjectPath);
}
throw error;
} finally {
if (watchdogId !== undefined) {
window.clearTimeout(watchdogId);
}
homeCreationUnresolvedRef.current = false;
if (isCurrentAttempt() && projectActionRef.current === 'creating') {
projectActionRef.current = null;
setProjectAction(null);
}
}
})();
// 后台主体不会因为竞速落定而停止:这里只防止它变成未处理的拒绝。
void creation.catch(() => undefined);
return await Promise.race([creation, watchdog]);
}
async function pickAndOpenProject() {
if (projectActionRef.current) {
return;
}
const invoke = resolveTauriInvoke();
if (!invoke) {
setStatus('需要在 Tauri App 内运行');
return;
}
projectActionRef.current = 'opening';
setProjectAction('opening');
setStatus('正在选择项目');
try {
const selectedPath = await invoke<string | null>(
'pick_local_project_directory',
projectPath.trim() ? { initialPath: projectPath.trim() } : undefined,
);
if (!selectedPath) {
setStatus('已取消');
return;
}
setProjectPath(selectedPath);
projectActionRef.current = null;
setProjectAction(null);
await openProject(selectedPath, 'open');
} catch (error) {
setStatus(error instanceof Error ? error.message : String(error));
} finally {
if (projectActionRef.current === 'opening') {
projectActionRef.current = null;
setProjectAction(null);
}
}
}
async function pickAndCreateProject() {
if (projectActionRef.current) {
return;
}
const invoke = resolveTauriInvoke();
if (!invoke) {
setStatus('需要在 Tauri App 内运行');
return;
}
projectActionRef.current = 'creating';
setProjectAction('creating');
setStatus('正在选择新项目文件夹');
try {
const selectedPath = await invoke<string | null>(
'pick_local_project_directory',
projectPath.trim() ? { initialPath: projectPath.trim() } : undefined,
);
if (!selectedPath) {
setStatus('已取消');
return;
}
setProjectPath(selectedPath);
projectActionRef.current = null;
setProjectAction(null);
await createProjectFromProjectPage(selectedPath);
} catch (error) {
setStatus(error instanceof Error ? error.message : String(error));
} finally {
if (projectActionRef.current === 'creating') {
projectActionRef.current = null;
setProjectAction(null);
}
}
}
async function renameProject(nextProjectPath: string, nextName: string) {
const invoke = resolveTauriInvoke();
if (!invoke) {
throw new Error('需要在 Tauri App 内运行');
}
const result = await invoke<RenameLocalProjectResult>(
'rename_local_game_project',
{
projectPath: nextProjectPath,
name: nextName,
},
);
setCurrentProjectContext((current) =>
current && current.projectPath === nextProjectPath
? {
...current,
projectName: result.manifest.name,
manifest: result.manifest,
projectRevision: result.revision,
}
: current,
);
return result;
}
return {
projectPath,
setProjectPath,
currentProjectContext,
setCurrentProjectContext,
activeProjectPreview,
setActiveProjectPreview,
activeProjectAgentRuntimeSummaries,
setAgentRuntimeSummaries,
activeProjectAgentResults,
setAgentResults,
projectAction,
projectBusy: projectAction !== null,
homeCreationOperation,
homeCreationBusy: homeCreationIsBusy(),
homeCreationRecoverableProjectPath: resolveRecoverableHomeProjectPath(
homeCreationOperation,
),
pendingNonEmptyProject,
resetLauncherHomeDraft,
startGameFromApprovedGdd,
createHomeDraft,
createHomeDraftAutomatically,
openProject,
renameProject,
pickAndOpenProject,
pickAndCreateProject,
confirmCreateInNonEmptyFolder,
cancelCreateInNonEmptyFolder,
};
}
export type HomeProjectCreationController = ReturnType<
typeof useHomeProjectCreation
>;