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
1138 lines
38 KiB
TypeScript
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
|
|
>;
|