a554b0c7ca
- 账本 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。
1360 lines
48 KiB
TypeScript
1360 lines
48 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 {
|
||
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
|
||
>;
|