Files
Genarrative/apps/ai-game-creator-shell/src/features/app-shell/WorkspaceLauncher.tsx
T
suzmii d5e05d79fe
Project CI / Repository checks (push) Successful in 2m49s
Project CI / Frontend tests (push) Successful in 2m45s
Project CI / Backend tests (push) Successful in 7m4s
Project CI / Native shell tests (push) Failing after 15m31s
优化项目名称显示与命名链路 (#248)
## 优化项目名称显示与命名链路

关联:#245

按 Issue 中的需求文档实现:

- 新增项目名称修改入口,保存到本地项目 \.agent/manifest.json\
- 项目页、当前项目上下文、窗口标题在重命名后同步刷新
- 首页自动创建工作区时,根据用户输入用 LLM 提炼项目名
- LLM 命名失败或返回非法内容时,回退现有默认名,不阻断创作流程

---------

Co-authored-by: 段舒康 <kdletters@qq.com>
Reviewed-on: http://192.168.35.82/git/GenarrativeAI/Genarrative/pulls/248
Co-authored-by: suzmii <suzmii@qq.com>
Co-committed-by: suzmii <suzmii@qq.com>
2026-09-03 15:50:55 +08:00

433 lines
15 KiB
TypeScript

import { Fragment, useCallback, useEffect, useRef, useState } from 'react';
import { createPortal } from 'react-dom';
import { launcherNotifications } from '../../app/constants';
import { closeDialogOnEscape } from '../../app/dialogs';
import { resolveTauriInvoke } from '../../app/tauri';
import type { LocalGameProjectRevisionStatus } from '../../app/types';
import {
useWindowChrome,
WINDOW_CHROME_DEFAULT_TITLE,
} from '../../components/windowChromeContext';
import HomeView from '../../view/home';
import { type LauncherView, Sidebar } from '../../view/layout';
import ProjectDevelopmentView from '../../view/project-development';
import {
createProjectManifestMergeState,
mergeProjectManifestSnapshot,
type ProjectManifestMergeState,
type ProjectManifestSnapshot,
type ProjectManifestSnapshotMetadata,
} from '../../view/project-development/projectResourceLiveUpdateModel';
import { RuntimeConfigDialog } from '../runtime-config/RuntimeConfigDialog';
import { AccountWalletBar, AccountWalletDialogs } from './AccountWallet';
import {
DeveloperAgentDialogs,
DeveloperAgentPanel,
} from './DeveloperAgentPanel';
import type { WorkspaceLauncherShellProps } from './model';
import { NonEmptyProjectDialog, ProjectsPage } from './ProjectCreation';
import { useAccountWallet } from './useAccountWallet';
import { useDeveloperAgentPanel } from './useDeveloperAgentPanel';
import { useHomeProjectCreation } from './useHomeProjectCreation';
import { useRecentProjects } from './useRecentProjects';
export function WorkspaceLauncherShell({
currentUser,
onLogout,
initialView = 'home',
ProjectSupervisor,
}: WorkspaceLauncherShellProps) {
const {
isWindowChrome,
setTitle: setWindowTitle,
walletSlot,
} = useWindowChrome();
const accountWallet = useAccountWallet(currentUser.id);
const [status, setStatus] = useState('');
const recentProjects = useRecentProjects(setStatus);
const { recentProjectRows, rememberRecentWorkspace } = recentProjects;
const [launcherView, setLauncherView] = useState<LauncherView>(initialView);
const [runtimeConfigOpen, setRuntimeConfigOpen] = useState(false);
const [playRequest, setPlayRequest] = useState<{
projectPath: string;
requestId: number;
} | null>(null);
const playRequestIdRef = useRef(0);
const [launcherNotice, setLauncherNotice] = useState<{
title: string;
message: string;
} | null>(null);
const developerAgent = useDeveloperAgentPanel(launcherView);
const homeProject = useHomeProjectCreation({
setStatus,
setLauncherView,
setAgentChatProjectPath: developerAgent.setAgentChatProjectPath,
rememberRecentWorkspace,
});
const {
projectPath,
setProjectPath,
currentProjectContext,
setCurrentProjectContext,
activeProjectPreview,
setActiveProjectPreview,
activeProjectAgentRuntimeSummaries,
setAgentRuntimeSummaries: setActiveProjectAgentRuntimeSummaries,
activeProjectAgentResults,
setAgentResults: setActiveProjectAgentResults,
resetLauncherHomeDraft,
startGameFromApprovedGdd,
createHomeDraftAutomatically,
openProject,
} = homeProject;
const activeProjectContextRef = useRef(currentProjectContext);
const manifestMergeRef = useRef<ProjectManifestMergeState | null>(null);
activeProjectContextRef.current = currentProjectContext;
useEffect(() => {
const nextTitle =
launcherView === 'project-development'
? currentProjectContext?.projectName.trim()
: '';
setWindowTitle(nextTitle || WINDOW_CHROME_DEFAULT_TITLE);
return () => setWindowTitle(WINDOW_CHROME_DEFAULT_TITLE);
}, [currentProjectContext?.projectName, launcherView, setWindowTitle]);
useEffect(() => {
const current = activeProjectContextRef.current;
manifestMergeRef.current =
current?.projectRevision === null || !current
? null
: createProjectManifestMergeState({
projectPath: current.projectPath,
projectId: current.manifest.projectId,
revision: current.projectRevision,
manifest: current.manifest,
source: 'initial',
});
}, [
currentProjectContext?.createdAt,
currentProjectContext?.projectName,
currentProjectContext?.projectPath,
]);
const applyManifestSnapshot = useCallback(
(snapshot: ProjectManifestSnapshot) => {
const current = activeProjectContextRef.current;
if (
!current ||
current.projectPath !== snapshot.projectPath ||
current.manifest.projectId !== snapshot.projectId
) {
return;
}
let previous = manifestMergeRef.current;
if (
!previous ||
previous.projectPath !== current.projectPath ||
previous.projectId !== current.manifest.projectId
) {
previous =
current.projectRevision === null
? null
: createProjectManifestMergeState({
projectPath: current.projectPath,
projectId: current.manifest.projectId,
revision: current.projectRevision,
manifest: current.manifest,
source: 'initial',
});
}
if (!previous) {
manifestMergeRef.current = createProjectManifestMergeState(snapshot);
} else {
const merged = mergeProjectManifestSnapshot(previous, snapshot);
manifestMergeRef.current = merged.state;
if (merged.decision !== 'accepted') {
return;
}
}
setCurrentProjectContext((active) =>
active &&
active.projectPath === snapshot.projectPath &&
active.manifest.projectId === snapshot.projectId
? {
...active,
manifest: snapshot.manifest,
projectRevision: snapshot.revision,
}
: active,
);
},
[setCurrentProjectContext],
);
const syncActiveProjectManifest = useCallback(
(
sourceProjectPath: string,
manifest: NonNullable<typeof currentProjectContext>['manifest'],
metadata?: ProjectManifestSnapshotMetadata,
) => {
const current = activeProjectContextRef.current;
if (!current || current.projectPath !== sourceProjectPath) {
return;
}
if (metadata) {
applyManifestSnapshot({
projectPath: sourceProjectPath,
manifest,
...metadata,
});
return;
}
const invoke = resolveTauriInvoke();
if (!invoke) {
if (current.projectRevision === null) {
setCurrentProjectContext((active) =>
active?.projectPath === sourceProjectPath
? { ...active, manifest }
: active,
);
}
return;
}
void invoke<LocalGameProjectRevisionStatus>(
'get_local_game_project_revision',
{ projectPath: sourceProjectPath },
)
.then((status) => {
applyManifestSnapshot({
projectPath: sourceProjectPath,
projectId: manifest.projectId,
revision: status.revision,
manifest,
source: 'supervisor',
});
})
.catch(() => {
if (activeProjectContextRef.current?.projectRevision === null) {
setCurrentProjectContext((active) =>
active?.projectPath === sourceProjectPath
? { ...active, manifest }
: active,
);
}
});
},
[applyManifestSnapshot, setCurrentProjectContext],
);
const requestCurrentProjectPlay = useCallback((nextProjectPath: string) => {
playRequestIdRef.current += 1;
setPlayRequest({
projectPath: nextProjectPath,
requestId: playRequestIdRef.current,
});
}, []);
const handlePlayRequestHandled = useCallback((requestId: number) => {
setPlayRequest((current) =>
current?.requestId === requestId ? null : current,
);
}, []);
function showLauncherNotice(title: string) {
setLauncherNotice({
title,
message: `${title}正在接入中,当前版本会先保留入口。`,
});
}
const currentHelpTitle =
launcherView === 'guide'
? '使用指南'
: launcherView === 'contact'
? '联系我们'
: '最新动态';
const currentHelpItems =
launcherView === 'guide'
? ['创建游戏项目', '上传参考素材', '选择审批档位', '生成试玩原型']
: launcherView === 'contact'
? ['产品反馈', '商务合作', '账号与充值支持']
: [
'GameAgent V1.0 首页改版',
'项目工作区和 Agent 会话建设中',
'美术生成将接入平台 API',
];
return (
<main
className="launcher-shell platform-theme platform-theme--light"
aria-label="GameAgent 客户端"
>
<Sidebar
activeView={launcherView}
currentUser={currentUser}
onLogout={() => {
resetLauncherHomeDraft();
accountWallet.resetWalletBalance();
onLogout();
}}
onNoticeRequest={showLauncherNotice}
onRechargeRequest={accountWallet.openRecharge}
onRuntimeConfigOpen={() => setRuntimeConfigOpen(true)}
onViewChange={setLauncherView}
/>
<section
className={
launcherNotifications.length > 0
? 'launcher-main launcher-main-with-promo'
: 'launcher-main'
}
>
{launcherNotifications.length > 0 ? (
<header className="launcher-promo" aria-label="通知">
{launcherNotifications.map((notification) => (
<Fragment key={`${notification.label}:${notification.detail}`}>
<strong>{notification.label}</strong>
<span>{notification.detail}</span>
<button type="button">{notification.actionLabel}</button>
</Fragment>
))}
</header>
) : null}
{launcherView === 'home' ? (
<HomeView
hasPromo={launcherNotifications.length > 0}
onStatusChange={setStatus}
recentProjectRows={recentProjectRows}
onCreateDraftAutomatically={createHomeDraftAutomatically}
onProjectsOpen={() => setLauncherView('projects')}
onProjectOpen={(path) => {
setProjectPath(path);
void openProject(path, 'open');
}}
onProjectPick={() => void homeProject.pickAndOpenProject()}
/>
) : launcherView === 'projects' ? (
<ProjectsPage
status={status}
homeProject={homeProject}
recentProjects={recentProjects}
/>
) : launcherView === 'agent-chat' ? (
<DeveloperAgentPanel
controller={developerAgent}
onRuntimeConfigOpen={() => setRuntimeConfigOpen(true)}
/>
) : launcherView === 'project-development' && currentProjectContext ? (
<ProjectDevelopmentView
orchestrationMode="single-supervisor"
projectName={currentProjectContext.projectName}
projectPath={currentProjectContext.projectPath}
manifest={currentProjectContext.manifest}
attachments={currentProjectContext.attachments}
recentRunStatus={currentProjectContext.recentRunStatus}
recentRunStopReason={currentProjectContext.recentRunStopReason}
preview={activeProjectPreview}
agentRuntimeSummaries={activeProjectAgentRuntimeSummaries}
agentResults={activeProjectAgentResults}
planningStartMode={currentProjectContext.startMode === 'planning'}
onPlay={() =>
requestCurrentProjectPlay(currentProjectContext.projectPath)
}
onManifestChange={syncActiveProjectManifest}
onHomeOpen={() => setLauncherView('home')}
onProjectsOpen={() => setLauncherView('projects')}
supervisor={
<ProjectSupervisor
key={currentProjectContext.projectPath}
initialProjectPath={currentProjectContext.projectPath}
initialProjectManifest={currentProjectContext.manifest}
initialProjectKind={currentProjectContext.projectKind}
initialSupervisorMessage={currentProjectContext.initialPrompt}
initialCreationType={currentProjectContext.creationType}
initialAttachments={currentProjectContext.attachments}
orchestrationMode="single-supervisor"
projectSupervisorOnly
planningStartMode={
currentProjectContext.startMode === 'planning'
}
playRequest={playRequest}
onPlayRequestHandled={handlePlayRequestHandled}
onManifestChange={syncActiveProjectManifest}
onPreviewChange={setActiveProjectPreview}
onAgentRuntimeSummariesChange={
setActiveProjectAgentRuntimeSummaries
}
onAgentResultsChange={setActiveProjectAgentResults}
onMakeGameFromApprovedGdd={startGameFromApprovedGdd}
/>
}
/>
) : (
<section className="launcher-page launcher-help-page">
<header>
<div>
<h1>{currentHelpTitle}</h1>
<p>陶泥儿 GameAgent</p>
</div>
</header>
<div className="launcher-help-cards">
{currentHelpItems.map((item) => (
<article key={item}>{item}</article>
))}
</div>
</section>
)}
</section>
{runtimeConfigOpen ? (
<RuntimeConfigDialog
projectPath={
currentProjectContext?.projectPath ||
developerAgent.agentChatProjectPath ||
projectPath
}
onClose={() => setRuntimeConfigOpen(false)}
/>
) : null}
<AccountWalletDialogs controller={accountWallet} />
{walletSlot ? (
createPortal(
<AccountWalletBar controller={accountWallet} />,
walletSlot,
)
) : isWindowChrome ? null : (
<AccountWalletBar controller={accountWallet} />
)}
<DeveloperAgentDialogs controller={developerAgent} />
{launcherNotice ? (
<div
className="launcher-dialog-backdrop"
role="presentation"
onMouseDown={(event) => {
if (event.target === event.currentTarget) {
setLauncherNotice(null);
}
}}
>
<section
aria-labelledby="launcher-notice-title"
aria-modal="true"
className="launcher-dialog"
role="dialog"
onKeyDown={(event) =>
closeDialogOnEscape(event, () => setLauncherNotice(null))
}
>
<h2 id="launcher-notice-title">{launcherNotice.title}</h2>
<p>{launcherNotice.message}</p>
<div className="launcher-dialog-actions">
<button type="button" onClick={() => setLauncherNotice(null)}>
知道了
</button>
</div>
</section>
</div>
) : null}
<NonEmptyProjectDialog controller={homeProject} />
</main>
);
}