7092689b36
## 变更内容 在 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>
980 lines
32 KiB
TypeScript
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
|
|
>;
|