Files
Genarrative/apps/ai-game-creator-shell/src/features/app-shell/useHomeProjectCreation.ts
T
JenkenB 97617608ba
Project CI / AI game creator shell Rust smoke (push) Successful in 1m46s
Project CI / AI game creator shell Rust crates (push) Successful in 1m10s
Project CI / AI game creator shell Rust lane 1/2 (pull_request) Has been cancelled
Project CI / AI game creator shell Rust lane 2/2 (pull_request) Has been cancelled
Project CI / AI game creator shell Rust smoke (pull_request) Has been cancelled
Project CI / AI game creator shell Rust crates (pull_request) Has been cancelled
Project CI / Backend tests (pull_request) Has been cancelled
Project CI / Native shell tests (pull_request) Has been cancelled
Project CI / Frontend tests (pull_request) Has been cancelled
Project CI / Repository checks (pull_request) Has been cancelled
Project CI / AI game creator shell web tests (pull_request) Has been cancelled
Project CI / Backend tests (push) Successful in 4m33s
Project CI / Native shell tests (push) Successful in 6m15s
Project CI / Frontend tests (push) Successful in 2m3s
Project CI / AI game creator shell Rust lane 2/2 (push) Successful in 10m31s
Project CI / AI game creator shell Rust lane 1/2 (push) Failing after 11m48s
Project CI / AI game creator shell web tests (push) Successful in 2m3s
Project CI / Repository checks (push) Successful in 3m31s
修正 Web 预检错误分流
2026-10-01 17:54:53 +08:00

1138 lines
38 KiB
TypeScript

import {
type Dispatch,
type SetStateAction,
useCallback,
useLayoutEffect,
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,
PendingNonEmptyProject,
ProjectStartMode,
RenameLocalProjectResult,
TauriInvoke,
UploadLocalAssetResult,
} from '../../app/types';
import {
beginProjectOpenAnalytics,
type ProjectOpenAnalytics,
recordAnalyticsProjectLeave,
} from '../../services/clientAnalytics';
import {
type ClientOperation,
createClientOperation,
transitionClientOperation,
} from '../../services/clientOperation';
import type {
HomeAttachmentDraft,
HomeCreationType,
HomeDraft,
} from '../../view/home';
import { richTextToPrompt } from '../../view/home/components/RichInputArea/richTextToPrompt';
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 { importDesignFiles } from '../project-workspace/importDesignFiles';
import { clearAclElevationDenials } from './aclElevation';
import {
ensureHomeWebCreationEnvironment,
homeWebPreflightErrorMessage,
} from './homeWebPreflight';
import type { ShowTransientNotice } from './model';
import { resolveSessionPreviewOnProjectOpen } from './sessionPreview';
/** 首页输入框当前的纯文本(Lexical 编辑器状态 -> 文本);没有输入就返回空串。 */
function homeDraftPromptText() {
try {
return richTextToPrompt(useLauncherHomeDraftStore.getState().draft).trim();
} catch {
return '';
}
}
type UseHomeProjectCreationOptions = {
setStatus: Dispatch<SetStateAction<string>>;
/**
* 取消类回显的出口(原生选择器取消、非空文件夹确认弹窗取消)。
*
* 取消是「什么都没发生」:只弹一次浮层,不往页面上写一行常驻状态——那一行留给进行中
* 进度和失败结论。
*/
showTransientNotice: ShowTransientNotice;
setLauncherView: Dispatch<SetStateAction<LauncherView>>;
rememberRecentWorkspace: (projectPath: string) => void;
};
export const DESIGN_ARTIFACTS_BUILD_PROMPT =
'查看当前项目 design_artifacts/ 目录及其子目录下的文档,理解其游戏设计,并按照这些文档将游戏实现出来。';
async function suggestAutomaticProjectName(
invoke: TauriInvoke,
draft: HomeDraft,
) {
try {
return await invoke<string | null>('suggest_automatic_project_name', {
prompt: draft.prompt,
});
} catch {
// 项目命名是增强能力:配置缺失、Provider 失败或非法输出都回退默认名,
// 不阻断用户真正请求的自动创建和首轮创作。
return null;
}
}
/**
* 把浏览器 File 上传进项目并登记为资产,返回带项目相对路径的附件记录。
*
* 首页建项目与右侧对话输入盒共用同一条链路:`upload_local_asset` 写进项目之后,
* 附件才能以「项目路径」形式进入回合附件(绝对路径会被 Rust 侧的附件脱敏规则拒绝)。
*/
export async function uploadLocalFilesAsAttachments(
invoke: TauriInvoke,
nextProjectPath: string,
files: readonly File[],
): Promise<LauncherImportedAttachment[]> {
const imported: LauncherImportedAttachment[] = [];
for (const file of files) {
const mediaType = file.type || 'application/octet-stream';
try {
const bytes = Array.from(new Uint8Array(await file.arrayBuffer()));
const result = await invoke<UploadLocalAssetResult>(
'upload_local_asset',
{
projectPath: nextProjectPath,
fileName: file.name,
mediaType,
bytes,
},
);
imported.push({
fileName: file.name,
mediaType,
localPath: result.localPath,
status: 'imported',
size: file.size,
});
} catch (error) {
imported.push({
fileName: file.name,
mediaType,
status: 'failed',
error: error instanceof Error ? error.message : String(error),
size: file.size,
});
}
}
return imported;
}
/**
* 自动建项的兜底期限。
*
* 建项要跑脚手架和依赖安装,慢是正常的,所以这不是"失败期限"而是"解围期限":到点后不再让首页
* 入口无限占用工作区闸门(底层创建不会被取消,迟到成功仍会照常进入项目)。没有这个期限时,
* 一次卡死的 `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,
showTransientNotice,
setLauncherView,
rememberRecentWorkspace,
}: UseHomeProjectCreationOptions) {
const lifecycleRef = useRef({ mounted: false, generation: 0 });
const analyticsProjectPathRef = useRef<string | null>(null);
const leaveProjectAnalytics = useCallback(() => {
const path = analyticsProjectPathRef.current;
analyticsProjectPathRef.current = null;
const invoke = resolveTauriInvoke();
if (path && invoke) recordAnalyticsProjectLeave(invoke, path);
}, []);
useLayoutEffect(() => {
const lifecycle = lifecycleRef.current;
lifecycle.mounted = true;
const onPageHide = () => {
lifecycle.mounted = false;
lifecycle.generation += 1;
leaveProjectAnalytics();
};
window.addEventListener('pagehide', onPageHide);
return () => {
lifecycle.mounted = false;
lifecycle.generation += 1;
window.removeEventListener('pagehide', onPageHide);
// StrictMode 的同步 setup 重放不是用户离开;真实卸载才清理宿主登记。
queueMicrotask(() => {
if (!lifecycle.mounted) leaveProjectAnalytics();
});
};
}, [leaveProjectAnalytics]);
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 [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 enterProjectDevelopment(
context: LauncherProjectContext,
isCurrent: () => boolean = () => true,
analytics: ProjectOpenAnalytics = null,
) {
if (!lifecycleRef.current.mounted) return;
const generation = lifecycleRef.current.generation;
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 (
!lifecycleRef.current.mounted ||
generation !== lifecycleRef.current.generation ||
entryToken !== projectEntryTokenRef.current ||
!isCurrent()
) {
// 更晚的一次进项目已经接管工作区:这一次的结果(预览与项目上下文)全部丢弃,
// 否则慢请求后到会把新项目覆盖回旧项目。
return;
}
setCurrentProjectContext(
session.manifestPreviewPatch
? {
...context,
manifest: {
...context.manifest,
preview: session.manifestPreviewPatch,
},
}
: context,
);
setActiveProjectPreview(session.sessionPreview);
setAgentRuntimeSummaries([]);
setAgentResults([]);
setProjectPath(context.projectPath);
setLauncherView('project-development');
rememberRecentWorkspace(context.projectPath);
analyticsProjectPathRef.current = context.projectPath;
analytics?.record(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[],
) {
return uploadLocalFilesAsAttachments(
invoke,
nextProjectPath,
attachments.map((attachment) => attachment.file),
);
}
async function enterCreatedHomeProject(
invoke: TauriInvoke,
result: InitLocalProjectResult,
creationType: HomeCreationType,
prompt: string,
attachments: HomeAttachmentDraft[],
startMode: ProjectStartMode,
analytics: ProjectOpenAnalytics,
) {
if (startMode === 'planning') {
await invoke('set_design_agent_runtime_mode', {
projectPath: result.projectPath,
activeRuntime: 'design',
});
}
const fileImportNotice =
startMode === 'planning'
? await importDesignFiles(
invoke,
result.projectPath,
attachments.map((item) => item.file),
)
: undefined;
const importedAttachments =
startMode === 'planning'
? []
: 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 && startMode !== 'planning'
? '用户上传了参考附件,等待后续补充需求。'
: ''),
attachments: importedAttachments,
fileImportNotice,
recentRunStatus: null,
recentRunStopReason: null,
createdAt: Date.now(),
},
undefined,
analytics,
);
resetLauncherHomeDraft();
}
async function createHomeProjectFromDirectory(
nextProjectPath: string,
creationType: HomeCreationType,
prompt: string,
attachments: HomeAttachmentDraft[],
startMode: ProjectStartMode,
skipNonEmptyCheck = false,
analytics?: ProjectOpenAnalytics,
) {
const trimmedProjectPath = validateProjectPath(nextProjectPath);
if (!trimmedProjectPath) {
return '项目目录无效';
}
const invoke = resolveTauriInvoke();
if (!invoke) {
setStatus('需要在 Tauri App 内运行');
return '需要在 Tauri App 内运行';
}
analytics =
analytics === undefined
? beginProjectOpenAnalytics(invoke, 'create')
: analytics;
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 '目标文件夹不是空的,请确认是否继续新建';
}
}
try {
await ensureHomeWebCreationEnvironment(invoke, creationType, startMode);
} catch (error) {
const message = homeWebPreflightErrorMessage(error);
setStatus(message);
return message;
}
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,
analytics,
);
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,
// 首页输入框里已经写好的要求:打开已有项目时不能再丢掉(此前写死空串,
// 用户写的内容既不发首轮也不进对话历史)。
initialPrompt = '',
analytics?: ProjectOpenAnalytics,
) {
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');
analytics =
analytics === undefined
? beginProjectOpenAnalytics(invoke, 'create')
: analytics;
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(),
},
undefined,
analytics,
);
} catch (error) {
setStatus(error instanceof Error ? error.message : String(error));
} finally {
if (projectActionRef.current === 'creating') {
projectActionRef.current = null;
setProjectAction(null);
}
}
}
/**
* 模板库建出的项目:模板文件与项目脚手架已在 Rust 侧一次落盘,
* 这里只负责登记最近项目并走标准进项目通道(含会话预览核验与代次闸门)。
*/
async function enterCreatedTemplateProject(
result: InitLocalProjectResult,
isCurrent: () => boolean = () => true,
analytics: ProjectOpenAnalytics = null,
) {
const invoke = resolveTauriInvoke();
if (!invoke) {
throw new Error('需要在陶泥儿客户端内运行');
}
const projectRevision = await readCurrentProjectRevision(
invoke,
result.projectPath,
);
if (!isCurrent()) return;
await enterProjectDevelopment(
{
projectPath: result.projectPath,
projectName:
result.manifest.name || projectNameFromPath(result.projectPath),
projectKind: 'web',
manifest: result.manifest,
projectRevision,
creationType: null,
startMode: null,
initialPrompt: '',
attachments: [],
recentRunStatus: null,
recentRunStopReason: null,
createdAt: Date.now(),
},
isCurrent,
analytics,
);
}
async function openProject(
nextProjectPath: string,
mode: 'open' | 'create',
analytics?: ProjectOpenAnalytics,
) {
// 打开/新建是明确的用户动作:先解除 Rust 侧的提权拒绝记忆,否则 120s 冷却内
// 首条 inspect_local_project_directory 会直接复用「用户取消」的结果,既不弹 UAC 也打不开。
clearAclElevationDenials();
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');
analytics =
analytics === undefined
? beginProjectOpenAnalytics(invoke, 'recent')
: analytics;
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.isUnityProject) {
const result = await invoke<InitLocalProjectResult>(
'import_local_unity_project',
{
projectPath: trimmedProjectPath,
projectId: createLocalProjectId(),
name: projectNameFromPath(trimmedProjectPath),
},
);
projectManifest = result.manifest;
} 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.isUnityProject
? 'unity'
: 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: homeDraftPromptText(),
attachments: [],
recentRunStatus: directoryStatus.recentRunStatus,
recentRunStopReason: directoryStatus.recentRunStopReason,
createdAt: Date.now(),
},
undefined,
analytics,
);
} 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('');
showTransientNotice('已取消', 'neutral');
}
useEscapeToClose(
cancelCreateInNonEmptyFolder,
pendingNonEmptyProject !== null,
);
async function createHomeDraft(
draft: HomeDraft,
startMode: ProjectStartMode,
) {
const invoke = resolveTauriInvoke();
if (!invoke) {
throw new Error('需要在 Tauri App 内运行');
}
const analytics = beginProjectOpenAnalytics(invoke, 'create');
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,
false,
analytics,
);
}
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 analytics = beginProjectOpenAnalytics(invoke, 'create');
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 {
try {
await ensureHomeWebCreationEnvironment(
invoke,
draft.creationType,
startMode,
);
} catch (error) {
const message = homeWebPreflightErrorMessage(error);
setHomeCreationOperation(
transitionClientOperation(
operation,
'retryable-failure',
operationScope(),
),
);
return message;
}
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,
analytics,
);
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');
const analytics = beginProjectOpenAnalytics(invoke, 'picker');
setStatus('正在选择项目');
try {
const selectedPath = await invoke<string | null>(
'pick_local_project_directory',
projectPath.trim() ? { initialPath: projectPath.trim() } : undefined,
);
if (!selectedPath) {
setStatus('');
showTransientNotice('已取消', 'neutral');
return;
}
setProjectPath(selectedPath);
projectActionRef.current = null;
setProjectAction(null);
await openProject(selectedPath, 'open', analytics);
} 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');
const analytics = beginProjectOpenAnalytics(invoke, 'create');
setStatus('正在选择新项目文件夹');
try {
const selectedPath = await invoke<string | null>(
'pick_local_project_directory',
projectPath.trim() ? { initialPath: projectPath.trim() } : undefined,
);
if (!selectedPath) {
setStatus('');
showTransientNotice('已取消', 'neutral');
return;
}
setProjectPath(selectedPath);
projectActionRef.current = null;
setProjectAction(null);
await createProjectFromProjectPage(
selectedPath,
false,
homeDraftPromptText(),
analytics,
);
} 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 {
leaveProjectAnalytics,
projectPath,
setProjectPath,
currentProjectContext,
setCurrentProjectContext,
activeProjectPreview,
setActiveProjectPreview,
activeProjectAgentRuntimeSummaries,
setAgentRuntimeSummaries,
activeProjectAgentResults,
setAgentResults,
projectAction,
projectBusy: projectAction !== null,
homeCreationOperation,
homeCreationBusy: homeCreationIsBusy(),
homeCreationRecoverableProjectPath: resolveRecoverableHomeProjectPath(
homeCreationOperation,
),
pendingNonEmptyProject,
resetLauncherHomeDraft,
createHomeDraft,
createHomeDraftAutomatically,
openProject,
renameProject,
pickAndOpenProject,
pickAndCreateProject,
enterCreatedTemplateProject,
confirmCreateInNonEmptyFolder,
cancelCreateInNonEmptyFolder,
};
}
export type HomeProjectCreationController = ReturnType<
typeof useHomeProjectCreation
>;