Files
Genarrative/apps/ai-game-creator-shell/src/features/app-shell/useHomeProjectCreation.ts
T
suzmii a554b0c7ca 把 AI 项目命名接进「生成任务」体系:账本 v2 任务类型、命名任务入队与推进、面板命名行
- 账本 v2:记录新增 taskType(asset-generation / project-naming,缺省 asset-generation,旧壳按素材任务读回),kind 改为可选;schema 升 agc-asset-generation-task.v2,读取同时接受 v1 与 v2。
- 新增 enqueue_local_project_naming_task / update_local_project_naming_task:命名任务入队时登记 live、终态写盘后摘除(写盘失败只回滚本轮插入),中断残留按命名口径收口为 failed(文案不含「目标素材」)。
- 首页命名链路改为推进这条任务:入队 → 命名中 → 已完成(已应用 AI 名称 / 建议名与现状一致 / 用户已手动改名而跳过)或失败(无可用名称 / 自动改名失败,均保留兜底名);账本只作展示旁路,推进失败只写诊断日志。
- 前端模型与面板支持命名行:按 taskType 把命名记录从素材任务分支剔除,命名行固定展示名「AI 项目命名」、只在终态显示结论、不渲染缩略图/提示词/定位动作;命令名写字面量以满足 check-config 静态枚举门禁。
- 工作台订阅账本变更事件做一次受控快照读取刷新命名行(不新增固定频率轮询);另修:命名行的 useLiveNow 收进独立组件,避免行种类变化导致 hooks 数量变化。
- 测试:homeProjectNamingAsync 12 例(新增入队→命名中→终态推进序列断言)、projectNamingGenerationTaskRow 3 例、appSurface 建项后命名行可见 + 事件刷新出终态结论 + 标题/列表同步;Rust asset_generation_task 22 例(含 naming_task_stays_in_flight_across_ledger_reads_until_terminal、legacy_v1_ledger_reads_back_*)。
- 文档:decision-log 改写为「生成任务体系里的后台任务」、pitfalls 新增 taskType/live 集合条目、实施计划专题补任务接入口径。
- 门禁:AGC 全量 1925 passed / 17 skipped;cargo 定向 22 + 6;npm run agc:typecheck(含 check:tests:types 与 check-config);cargo fmt --check;check:encoding;git diff --check。
2026-10-04 16:59:59 +08:00

1360 lines
48 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import {
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 {
ConditionalRenameLocalProjectResult,
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 { appendWebviewLog } from '../../services/errorReporting';
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 } 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 {
createHomeCreationFailure,
type HomeCreationFailure,
} from './homeCreationFailure';
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;
/**
* 最近项目行的名称来自宿主目录重检(`inspect_local_project_directory` 读 manifest),
* 不是内存投影:后台 AI 改名成功后必须显式重检这一行,列表才会同步到新名字。
*/
refreshRecentWorkspace?: (projectPath: string) => Promise<void>;
};
export const DESIGN_ARTIFACTS_BUILD_PROMPT =
'查看当前项目 design_artifacts/ 目录及其子目录下的文档,理解其游戏设计,并按照这些文档将游戏实现出来。';
/**
* 发起一次 AI 项目命名请求并返回其结果 Promise。
*
* 调用方**不要 await 它再建项目**:命名只是增强能力,工作区创建、附件导入和首轮创作都不依赖它。
* 这里返回的 Promise 由后台改名任务消费(见 `applyAutomaticProjectName`);配置缺失、Provider
* 失败、超时或非法输出都解析为 `null`,由调用方保留兜底名称。
*/
async function suggestAutomaticProjectName(
invoke: TauriInvoke,
draft: HomeDraft,
) {
try {
return await invoke<string | null>('suggest_automatic_project_name', {
prompt: draft.prompt,
});
} catch {
// 项目命名是增强能力:配置缺失、Provider 失败或非法输出都回退默认名,
// 不阻断用户真正请求的自动创建和首轮创作。
return null;
}
}
/** 「AI 项目命名」任务的结论文案:结论只在这条链路里,所以文案由命名链路给定。 */
const PROJECT_NAMING_FAILED_PHASE =
'AI 命名请求失败或没有返回可用名称,已保留兜底名。';
const PROJECT_NAMING_UNCHANGED_PHASE = 'AI 建议名与当前名称一致,无需改名。';
const PROJECT_NAMING_RENAME_FAILED_PHASE = '自动改名失败,已保留兜底名。';
const PROJECT_NAMING_SKIPPED_PHASE =
'当前名称已不是本次创建的兜底名(用户已手动改名),已跳过 AI 命名。';
/**
* 推进「生成任务」列表里的那条「AI 项目命名」任务。
*
* 账本是**展示旁路**:旧壳没有这两条命令、账本写盘失败或权限拒绝都不该影响命名与建项,
* 所以失败只写诊断日志、绝不向上抛。
*
* 两条命令名都写成**字面量**:渲染层的 invoke 目标必须能被 `scripts/check-config.mjs` 静态枚举
* (命令名走变量会让门禁报「App invoke 缺命令」)。
*/
async function enqueueProjectNamingTask(
invoke: TauriInvoke,
input: { projectPath: string; taskId: string },
): Promise<void> {
try {
await invoke('enqueue_local_project_naming_task', input);
} catch (error) {
appendWebviewLog('warn', [
`[home-project-name] enqueue_local_project_naming_task: ${
error instanceof Error ? error.message : String(error)
}`,
]);
}
}
async function updateProjectNamingTask(
invoke: TauriInvoke,
input: {
projectPath: string;
taskId: string;
status: 'running' | 'completed' | 'failed';
phaseDetail?: string;
},
): Promise<void> {
try {
await invoke('update_local_project_naming_task', input);
} catch (error) {
appendWebviewLog('warn', [
`[home-project-name] update_local_project_naming_task: ${
error instanceof Error ? error.message : String(error)
}`,
]);
}
}
/**
* 把浏览器 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,
refreshRecentWorkspace,
}: 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 [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);
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();
}
/**
* 后台把 AI 建议名落到已创建的工作区。
*
* 这条链路同时是「生成任务」列表里那条「AI 项目命名」任务的推进者:
* `enqueue_local_project_naming_task`(排队中)→ `update_…(running)` → 终态
* (completed / failed)。任务行与素材生成共用同一份账本、同一个变更事件与同一个侧栏,
* 状态流转对用户可见;账本推进失败只写诊断日志,绝不影响命名与建项本身。
*
* 与建项并行发起(`naming` 在建项前就已经打出请求);改名落盘本身不等任何东西,
* 但「项目上下文」与「最近项目行」这两项都建立在进项目之后:AI 比进项目更快时直接写上下文
* 会被随后的 `enterProjectDevelopment` 用兜底名覆盖,而最近项目行也只有进项目登记过才会被
* 重检。所以它们等 `entrySettled`(建项主体收尾,含进项目成功/失败/让位于别的项目)。
* - 宿主侧条件改名要求「项目 ID 相同」且「当前名称仍是这次创建时的兜底名称」;用户已手动改名时
* 返回 `renamed: false`,这里整体跳过、绝不覆盖用户输入,任务按「已完成(已跳过)」收口。
* - AI 返回空/非法/失败(Promise 解析为 null)时保持兜底名称,任务收口为 failed。
*/
async function applyAutomaticProjectName(
invoke: TauriInvoke,
naming: Promise<string | null>,
created: InitLocalProjectResult,
entrySettled: Promise<void>,
) {
const generation = lifecycleRef.current.generation;
const namingTask = {
projectPath: created.projectPath,
// 一个项目一条命名任务:重开后账本里读到的还是同一行,不会越攒越多。
taskId: `project-naming:${created.manifest.projectId}`,
};
await enqueueProjectNamingTask(invoke, namingTask);
await updateProjectNamingTask(invoke, { ...namingTask, status: 'running' });
let suggested: string | null = null;
try {
suggested = (await naming)?.trim() || null;
} catch {
suggested = null;
}
// 没有可用的建议名:保留兜底名,任务按失败收口(AI 失败 / 超时 / 非法内容同一条路径)。
if (!suggested) {
await updateProjectNamingTask(invoke, {
...namingTask,
status: 'failed',
phaseDetail: PROJECT_NAMING_FAILED_PHASE,
});
return;
}
// 建议名就是兜底名:没有可改的,别白跑一次写操作,按完成收口并说明原因。
if (suggested === created.manifest.name) {
await updateProjectNamingTask(invoke, {
...namingTask,
status: 'completed',
phaseDetail: PROJECT_NAMING_UNCHANGED_PHASE,
});
return;
}
let outcome: ConditionalRenameLocalProjectResult;
try {
outcome = await invoke<ConditionalRenameLocalProjectResult>(
'rename_local_game_project_if_unchanged',
{
projectPath: created.projectPath,
expectedProjectId: created.manifest.projectId,
expectedName: created.manifest.name,
name: suggested,
},
);
} catch (error) {
appendWebviewLog('warn', [
`[home-project-name] ${error instanceof Error ? error.message : String(error)}`,
]);
await updateProjectNamingTask(invoke, {
...namingTask,
status: 'failed',
phaseDetail: PROJECT_NAMING_RENAME_FAILED_PHASE,
});
return;
}
if (!outcome?.renamed) {
// 用户在结果回来之前已经手动改过名(或项目身份已变):跳过,并如实说明跳过原因。
await updateProjectNamingTask(invoke, {
...namingTask,
status: 'completed',
phaseDetail: PROJECT_NAMING_SKIPPED_PHASE,
});
return;
}
await updateProjectNamingTask(invoke, {
...namingTask,
status: 'completed',
phaseDetail: `已应用 AI 名称「${outcome.manifest.name}」。`,
});
await entrySettled;
/**
* 等待期间壳可能已经被卸载(关窗 / 切走 / StrictMode 重放)。
*
* 落盘的改名是项目级事实(换台机器打开也是 AI 名),照旧保留;但「当前项目上下文」与
* 「最近项目行重检」都是本壳的 UI 投影,卸载后一律不写——与 `enterProjectDevelopment`
* 的守卫同口径(`mounted` + 代次),否则迟到的命名结果会写进已经作废的壳。
*/
if (
!lifecycleRef.current.mounted ||
generation !== lifecycleRef.current.generation
) {
return;
}
setCurrentProjectContext((current) =>
current && current.projectPath === created.projectPath
? {
...current,
projectName: outcome.manifest.name,
manifest: outcome.manifest,
projectRevision: outcome.revision,
}
: current,
);
try {
await refreshRecentWorkspace?.(created.projectPath);
} catch {
// 最近项目行是辅助投影:重检失败不影响已经生效的改名与工作台标题。
}
}
/**
* 创建失败的统一收口。
*
* 用户可见文案由 `createHomeCreationFailure` 按真实原因算出(宿主机器码 / 调用通道失联 /
* 专用未分类兜底),宿主原始文本只写进 `application.log` 作为证据,不渲染到首页状态区。
*/
function failHomeCreation(
error: unknown,
projectCreated: boolean,
): HomeCreationFailure {
const failure = createHomeCreationFailure(error, { projectCreated });
if (failure.diagnostic) {
appendWebviewLog('error', [`[home-creation] ${failure.diagnostic}`]);
}
setStatus(failure.userMessage);
return failure;
}
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('正在创建项目');
// 兜底文案必须区分「还没建」和「建好了但没进去」:只有 init 成功后才置位。
let createdProjectPath: string | null = null;
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),
},
);
createdProjectPath = result.projectPath;
// 这里失败时项目目录已经建好:文案由外层按「已建好」分类,不拼底层文本,
// 也不指认具体是哪一步失败(运行时切换、附件导入、进入工作台都可能抛)。
await enterCreatedHomeProject(
invoke,
result,
creationType,
prompt,
attachments,
startMode,
analytics,
);
setStatus('已创建项目,正在开始智能创作');
return '已创建项目并进入项目开发';
} catch (error) {
// 目录已经建好时保留「已创建」这一事实,避免用户重做一遍。
throw failHomeCreation(error, createdProjectPath !== null);
}
}
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, {
// 判据就是 `ProjectStartMode`:'planning' 覆盖「做方案」和勾了「策划补全」的「做游戏」
// (见 view/home/homeStartMode.ts),这一档只调策划 Provider,项目命名对它没有价值;
// 只有 'direct-build' 才发起自动项目命名请求。
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);
});
/**
* 「建项主体收尾」信号(进项目成功、进项目失败、或让位于别的项目都算)。
*
* 后台 AI 改名可能比进项目更快:那时工作区上下文还没建立,直接写上下文会被随后的
* `enterProjectDevelopment` 覆盖回兜底名,最近项目行也还没被登记进列表。改名落盘照旧立即执行,
* 只有这两项依赖进项目结果的更新等这枚信号。
*/
let settleEntry!: () => void;
const entrySettled = new Promise<void>((resolve) => {
settleEntry = resolve;
});
/** 后台继续跑的建项主体:用户可见的等待由 `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;
}
// 命名与建项并行:先把命名请求打出去,再建工作区,两者互不等待。命名结果由后台任务
// 落定(见 `applyAutomaticProjectName`),用户不必为了一个名字多等一次模型请求。
const automaticName = options.suggestName
? suggestAutomaticProjectName(invoke, draft)
: null;
const result = await invoke<InitLocalProjectResult>(
'create_automatic_local_game_project',
{
// 不传名:走宿主兜底名("GameAgent 项目 <id>" / "策划项目 <id>"),
// AI 建议名回来后再按「名称仍是兜底名」的条件改名。
name: null,
planning: startMode === 'planning',
},
);
createdProjectPath = result.projectPath;
if (automaticName) {
// 纯后台任务:进入工作区、导入附件与首轮创作都不等它。
void applyAutomaticProjectName(
invoke,
automaticName,
result,
entrySettled,
).catch(() => undefined);
}
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 },
}),
);
throw error;
}
} catch (error) {
setHomeCreationOperation(
transitionClientOperation(
operation,
'retryable-failure',
operationScope(),
),
);
if (createdProjectPath) {
rememberRecentWorkspace(createdProjectPath);
}
throw failHomeCreation(error, Boolean(createdProjectPath));
} finally {
// 进项目(或放弃进项目)已经尘埃落定,后台改名可以安全地写上下文与最近项目。
settleEntry();
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,
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
>;