be370cc615
Project CI / AI game creator shell Rust shard 1/4 (push) Successful in 7m12s
Project CI / AI game creator shell Rust shard 2/4 (push) Successful in 7m17s
Project CI / AI game creator shell Rust shard 3/4 (push) Successful in 7m26s
Project CI / AI game creator shell Rust shard 4/4 (push) Successful in 7m37s
Project CI / AI game creator shell Rust smoke (push) Successful in 1m57s
Project CI / AI game creator shell Rust crates (push) Successful in 2m10s
Project CI / Frontend tests (push) Successful in 6m4s
Project CI / Native shell tests (push) Successful in 8m58s
Project CI / Backend tests (push) Has been cancelled
Project CI / AI game creator shell web tests (push) Has been cancelled
Project CI / Repository checks (push) Has been cancelled
- 新增设置「工作区」分类,承载「项目创建目录」的选择与恢复默认入口 - 自动建项命令新增可选 projectsRoot,未指定时沿用 AppData projects 默认目录 - 新增项目创建目录校验与解析:只接受目录选择器返回且通过私有路径门禁的既有目录 - 目录选择器新增可选 title 参数,用于「选择项目创建目录」文案 - 首页自动建项与模板建项在建项时携带用户选定的项目创建目录 - 客户端本地保存项目创建目录偏好,建项时重新校验,不作为授权凭据 - 补充设置页工作区、首页建项与目录偏好单测 - 修复项目开发视图缺失的 Shapes 图标导入,恢复 agc:typecheck 通过 - 同步实施计划、决策日志与排障记忆文档
382 lines
12 KiB
TypeScript
382 lines
12 KiB
TypeScript
import type { ComponentType } from 'react';
|
|
|
|
import type { AuthUser } from '../../../../../packages/shared/src/contracts/auth';
|
|
import type {
|
|
GameCreationAppManifest,
|
|
GameCreationAppPreviewState,
|
|
} from '../../../../../packages/shared/src/contracts/gameCreationApp';
|
|
import type {
|
|
ChatMessage,
|
|
LauncherImportedAttachment,
|
|
LocalProjectDirectoryStatus,
|
|
} from '../../app/types';
|
|
import type { HomeCreationType } from '../../view/home';
|
|
import type { LauncherView } from '../../view/layout';
|
|
import type {
|
|
ProjectAgentResultSummary,
|
|
ProjectAgentRuntimeSummary,
|
|
} from '../../view/project-development';
|
|
import type { ProjectManifestSnapshotMetadata } from '../../view/project-development/projectResourceLiveUpdateModel';
|
|
import {
|
|
isAbsoluteProjectPath,
|
|
projectPathHasControlCharacter,
|
|
} from '../project-summary/projectSummary';
|
|
|
|
const RECENT_WORKSPACES_STORAGE_KEY =
|
|
'genarrative-ai-game-creator.recent-workspaces.v1';
|
|
const PROJECT_CREATION_DIRECTORY_STORAGE_KEY =
|
|
'genarrative-ai-game-creator.project-creation-directory.v1';
|
|
const SUPERVISOR_CHAT_DRAFT_STORAGE_PREFIX =
|
|
'genarrative.supervisor-chat.draft';
|
|
|
|
export type WorkspaceLauncherProps = {
|
|
currentUser: AuthUser;
|
|
onLogout: () => void;
|
|
initialView?: LauncherView;
|
|
};
|
|
|
|
export type ProjectSupervisorComponentProps = {
|
|
initialProjectPath?: string;
|
|
initialProjectManifest?: GameCreationAppManifest;
|
|
initialProjectKind?: 'web' | 'godot' | 'cocos';
|
|
initialSupervisorMessage?: string;
|
|
initialSupervisorMessageClaimScope?: string;
|
|
initialCreationType?: HomeCreationType | null;
|
|
initialAttachments?: LauncherImportedAttachment[];
|
|
orchestrationMode?: 'single-supervisor' | 'professional-dag';
|
|
projectSupervisorOnly?: boolean;
|
|
planningStartMode?: boolean;
|
|
/**
|
|
* C7 当前游戏版本:由工作台壳持有,supervisor 里的 `@` 面板按它切「当前版本素材」。
|
|
* `null` 表示回退到 manifest 中最新的版本。
|
|
*/
|
|
activeVersionId?: string | null;
|
|
playRequest?: {
|
|
projectPath: string;
|
|
requestId: number;
|
|
} | null;
|
|
onPlayRequestHandled?: (requestId: number) => void;
|
|
onManifestChange?: (
|
|
projectPath: string,
|
|
manifest: GameCreationAppManifest,
|
|
metadata?: ProjectManifestSnapshotMetadata,
|
|
) => void;
|
|
onPreviewChange?: (preview: GameCreationAppPreviewState | null) => void;
|
|
onAgentRuntimeSummariesChange?: (
|
|
summaries: ProjectAgentRuntimeSummary[],
|
|
) => void;
|
|
onAgentResultsChange?: (results: ProjectAgentResultSummary[]) => void;
|
|
onMakeGameFromApprovedGdd?: (projectPath: string) => Promise<void>;
|
|
onSwitchToGameRuntime?: (projectPath: string) => Promise<void>;
|
|
};
|
|
|
|
export type WorkspaceLauncherShellProps = WorkspaceLauncherProps & {
|
|
ProjectSupervisor: ComponentType<ProjectSupervisorComponentProps>;
|
|
};
|
|
|
|
export type RecentProjectRow = {
|
|
path: string;
|
|
name: string;
|
|
status: string;
|
|
projectKind: 'web' | 'cocos' | 'unity' | 'ue' | 'godot' | 'unknown';
|
|
cocosProjectRoot: string | null;
|
|
godotProjectRoot: string | null;
|
|
modifiedAt: number | null;
|
|
recentRunStatus: string | null;
|
|
recentRunStopReason: string | null;
|
|
canReveal: boolean;
|
|
canOpen: boolean;
|
|
};
|
|
|
|
export type LauncherNotice = {
|
|
title: string;
|
|
message: string;
|
|
};
|
|
|
|
function normalizeRecentWorkspaceList(values: unknown[]) {
|
|
const recent: string[] = [];
|
|
for (const value of values) {
|
|
if (typeof value !== 'string') {
|
|
continue;
|
|
}
|
|
const workspace = value.trim();
|
|
if (
|
|
!workspace ||
|
|
!isAbsoluteProjectPath(workspace) ||
|
|
recent.includes(workspace)
|
|
) {
|
|
continue;
|
|
}
|
|
recent.push(workspace);
|
|
if (recent.length >= 8) {
|
|
break;
|
|
}
|
|
}
|
|
return recent;
|
|
}
|
|
|
|
export function readRecentWorkspaces() {
|
|
try {
|
|
const raw = window.localStorage.getItem(RECENT_WORKSPACES_STORAGE_KEY);
|
|
const parsed: unknown = raw ? JSON.parse(raw) : [];
|
|
return Array.isArray(parsed) ? normalizeRecentWorkspaceList(parsed) : [];
|
|
} catch {
|
|
return [];
|
|
}
|
|
}
|
|
|
|
export function writeRecentWorkspace(path: string) {
|
|
const recent = normalizeRecentWorkspaceList([
|
|
path,
|
|
...readRecentWorkspaces(),
|
|
]);
|
|
try {
|
|
window.localStorage.setItem(
|
|
RECENT_WORKSPACES_STORAGE_KEY,
|
|
JSON.stringify(recent),
|
|
);
|
|
} catch {
|
|
// WebView storage can be unavailable in restricted test shells.
|
|
}
|
|
return recent;
|
|
}
|
|
|
|
export function removeRecentWorkspace(path: string) {
|
|
const trimmedPath = path.trim();
|
|
const recent = readRecentWorkspaces().filter(
|
|
(workspace) => workspace !== trimmedPath,
|
|
);
|
|
try {
|
|
if (recent.length > 0) {
|
|
window.localStorage.setItem(
|
|
RECENT_WORKSPACES_STORAGE_KEY,
|
|
JSON.stringify(recent),
|
|
);
|
|
} else {
|
|
window.localStorage.removeItem(RECENT_WORKSPACES_STORAGE_KEY);
|
|
}
|
|
} catch {
|
|
// WebView storage can be unavailable in restricted test shells.
|
|
}
|
|
return recent;
|
|
}
|
|
|
|
/**
|
|
* 「项目创建目录」偏好:空串表示沿用 AGC 管理的默认位置(应用数据目录下的 projects)。
|
|
*
|
|
* 这里只保存用户意图,不是授权凭据:目录授权来自原生目录选择器,并由 Rust 侧私有路径门禁
|
|
* 在每次建项时重新复核,所以存储被改坏最坏只是退回默认位置或拿到一次可见的建项失败。
|
|
*/
|
|
export function normalizeProjectCreationDirectory(value: string) {
|
|
const trimmed = value.trim();
|
|
if (!trimmed || projectPathHasControlCharacter(trimmed)) {
|
|
return '';
|
|
}
|
|
const withoutTrailingSeparator = trimmed.replace(/[\\/]+$/, '');
|
|
// `C:\` 这类盘根只去掉分隔符会变成相对路径 `C:`,必须补回来。
|
|
return /^[a-zA-Z]:$/.test(withoutTrailingSeparator)
|
|
? `${withoutTrailingSeparator}\\`
|
|
: withoutTrailingSeparator;
|
|
}
|
|
|
|
export function readProjectCreationDirectory() {
|
|
try {
|
|
const raw = window.localStorage.getItem(
|
|
PROJECT_CREATION_DIRECTORY_STORAGE_KEY,
|
|
);
|
|
const parsed: unknown = raw ? JSON.parse(raw) : '';
|
|
if (typeof parsed !== 'string') {
|
|
return '';
|
|
}
|
|
const directory = normalizeProjectCreationDirectory(parsed);
|
|
return isAbsoluteProjectPath(directory) ? directory : '';
|
|
} catch {
|
|
return '';
|
|
}
|
|
}
|
|
|
|
export function writeProjectCreationDirectory(path: string) {
|
|
const directory = normalizeProjectCreationDirectory(path);
|
|
try {
|
|
if (directory) {
|
|
window.localStorage.setItem(
|
|
PROJECT_CREATION_DIRECTORY_STORAGE_KEY,
|
|
JSON.stringify(directory),
|
|
);
|
|
} else {
|
|
window.localStorage.removeItem(PROJECT_CREATION_DIRECTORY_STORAGE_KEY);
|
|
}
|
|
} catch {
|
|
// WebView storage can be unavailable in restricted test shells.
|
|
}
|
|
return directory;
|
|
}
|
|
|
|
export function isTransientProjectOpenMessage(
|
|
message: ChatMessage,
|
|
projectPath: string,
|
|
) {
|
|
return (
|
|
message.role === 'assistant' &&
|
|
message.text === `已设置本地项目:${projectPath}`
|
|
);
|
|
}
|
|
|
|
export function latestVisibleItems<T>(items: T[], visibleCount: number) {
|
|
if (items.length <= visibleCount) {
|
|
return items;
|
|
}
|
|
return items.slice(items.length - visibleCount);
|
|
}
|
|
|
|
export function isDeveloperMode() {
|
|
if (!import.meta.env.DEV) {
|
|
return false;
|
|
}
|
|
const params = new URLSearchParams(window.location.search);
|
|
return params.has('dev') || window.location.hash === '#dev';
|
|
}
|
|
|
|
export function readInitialProjectPath() {
|
|
const params = new URLSearchParams(window.location.search);
|
|
return params.get('projectPath') ?? '';
|
|
}
|
|
|
|
function supervisorChatDraftStorageKey(projectPath: string) {
|
|
return `${SUPERVISOR_CHAT_DRAFT_STORAGE_PREFIX}:${projectPath}`;
|
|
}
|
|
|
|
export function readSupervisorChatDraft(projectPath: string) {
|
|
const normalizedProjectPath = projectPath.trim();
|
|
if (!normalizedProjectPath) {
|
|
return '';
|
|
}
|
|
try {
|
|
return (
|
|
window.sessionStorage.getItem(
|
|
supervisorChatDraftStorageKey(normalizedProjectPath),
|
|
) ?? ''
|
|
);
|
|
} catch {
|
|
return '';
|
|
}
|
|
}
|
|
|
|
export function persistSupervisorChatDraft(projectPath: string, draft: string) {
|
|
const normalizedProjectPath = projectPath.trim();
|
|
if (!normalizedProjectPath) {
|
|
return;
|
|
}
|
|
try {
|
|
const storageKey = supervisorChatDraftStorageKey(normalizedProjectPath);
|
|
if (draft) {
|
|
window.sessionStorage.setItem(storageKey, draft);
|
|
} else {
|
|
window.sessionStorage.removeItem(storageKey);
|
|
}
|
|
} catch {
|
|
// A disabled session store must not block the development chat surface.
|
|
}
|
|
}
|
|
|
|
export function buildRecentProjectRows(
|
|
recentWorkspaces: string[],
|
|
recentWorkspaceStatuses: Record<
|
|
string,
|
|
LocalProjectDirectoryStatus | null | undefined
|
|
>,
|
|
recentWorkspaceRefreshing: boolean,
|
|
): RecentProjectRow[] {
|
|
// The refresh flag is kept for the page-level indicator. Each row owns its
|
|
// pending state so a slow directory cannot disable already inspected rows.
|
|
void recentWorkspaceRefreshing;
|
|
return recentWorkspaces.map((workspace) => {
|
|
const directoryStatus = recentWorkspaceStatuses[workspace];
|
|
const isPendingStatus = directoryStatus === undefined;
|
|
const projectName =
|
|
directoryStatus?.projectName ||
|
|
workspace.split(/[\\/]/).filter(Boolean).pop() ||
|
|
workspace;
|
|
const status = isPendingStatus
|
|
? '检查中'
|
|
: directoryStatus === null
|
|
? '检查失败'
|
|
: directoryStatus?.exists === false
|
|
? '未找到'
|
|
: directoryStatus?.isDirectory === false
|
|
? '不是文件夹'
|
|
: directoryStatus?.manifestError
|
|
? '无法读取'
|
|
: (directoryStatus?.isGodotProject === true ||
|
|
directoryStatus?.isCocosProject === true) &&
|
|
directoryStatus?.isGameCreatorProject === false
|
|
? '可导入'
|
|
: directoryStatus?.isGameCreatorProject === false
|
|
? '未初始化'
|
|
: directoryStatus?.recentRunStatus
|
|
? formatRecentProjectRunStatus(
|
|
directoryStatus.recentRunStatus,
|
|
directoryStatus.recentRunStopReason,
|
|
)
|
|
: directoryStatus?.isGodotProject
|
|
? '可打开'
|
|
: '本地项目';
|
|
const canReveal =
|
|
Boolean(directoryStatus) &&
|
|
directoryStatus?.exists !== false &&
|
|
directoryStatus?.isDirectory !== false;
|
|
return {
|
|
path: workspace,
|
|
name: projectName,
|
|
status,
|
|
projectKind: directoryStatus?.isCocosProject
|
|
? 'cocos'
|
|
: directoryStatus?.isGodotProject
|
|
? 'godot'
|
|
: directoryStatus?.isGameCreatorProject
|
|
? 'web'
|
|
: 'unknown',
|
|
godotProjectRoot: directoryStatus?.godotProjectRoot ?? null,
|
|
cocosProjectRoot: directoryStatus?.cocosProjectRoot ?? null,
|
|
modifiedAt: directoryStatus?.modifiedAt ?? null,
|
|
recentRunStatus: directoryStatus?.recentRunStatus ?? null,
|
|
recentRunStopReason: directoryStatus?.recentRunStopReason ?? null,
|
|
canReveal,
|
|
canOpen:
|
|
Boolean(directoryStatus) &&
|
|
directoryStatus?.exists !== false &&
|
|
directoryStatus?.isDirectory !== false &&
|
|
!directoryStatus?.manifestError &&
|
|
(directoryStatus?.isGameCreatorProject !== false ||
|
|
directoryStatus?.isGodotProject === true ||
|
|
directoryStatus?.isCocosProject === true),
|
|
};
|
|
});
|
|
}
|
|
|
|
function formatRecentProjectRunStatus(
|
|
status: string,
|
|
stopReason: string | null,
|
|
) {
|
|
const statusLabel =
|
|
{
|
|
completed: '已完成',
|
|
done: '已完成',
|
|
failed: '运行失败',
|
|
running: '运行中',
|
|
pending: '等待运行',
|
|
cancelled: '已取消',
|
|
}[status] ?? status;
|
|
const stopReasonLabel = stopReason
|
|
? ({
|
|
'preview-running': '预览运行中',
|
|
completed: '已完成',
|
|
failed: '运行失败',
|
|
cancelled: '已取消',
|
|
}[stopReason] ?? stopReason)
|
|
: null;
|
|
return stopReasonLabel && stopReasonLabel !== statusLabel
|
|
? `${statusLabel} · ${stopReasonLabel}`
|
|
: statusLabel;
|
|
}
|