Files
Genarrative/apps/ai-game-creator-shell/src/features/app-shell/model.ts
T
suzmii e6083050a8 运行/预览提示退出对话区,改 toast 与运行区域小字
- 删除播放、/preview、/open-preview 与生成后自动启动预览写进对话区的成功提示,对话区只保留对话内容
- 新增 onRunNotice 通道,工作台壳用 RunNoticeToast 弹 2.6 秒浮层,同一句连续触发会重新计时
- 运行表现层在预览框上方常驻小字「已载入客户端运行视图:URL」,没有活预览时不占位
- 更新 appSurface 四处用例,新增 onRunNotice、小字与 RunNoticeToast 用例
- 同步 check-config 与 check-native-shells 的字符串判据,并记录决策到 shared-memory
2026-09-22 10:00:38 +08:00

393 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,
LocalProjectKind,
} 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?: LocalProjectKind;
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;
/**
* 运行 / 预览类动作的一次性浮层提示(成功态)。
*
* 「跑起来了」「已切到运行视图」属于过程反馈,不进对话区——对话区只保留对话内容。
* 工作台壳收到后弹 toast;预览地址由运行区域上方的小字常驻,不再占对话位置。
*/
onRunNotice?: (message: string) => void;
onAgentRuntimeSummariesChange?: (
summaries: ProjectAgentRuntimeSummary[],
) => void;
onAgentResultsChange?: (results: ProjectAgentResultSummary[]) => 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?.isUnityProject === 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?.isUnityProject
? 'unity'
: 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 ||
directoryStatus?.isUnityProject === 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;
}