需求:将待审 Release 扫码验收与项目级真机入口并入登录、发布集成候选。 实现:合并 Main-owned 精确预览与提交映射能力,保留现有登录和一键发布边界。
417 lines
14 KiB
TypeScript
417 lines
14 KiB
TypeScript
/**
|
|
* Root Application Component
|
|
* Handles routing and global providers
|
|
*/
|
|
import { Navigate, Routes, Route, useNavigate, useLocation } from 'react-router-dom';
|
|
import { Component, useEffect, useState } from 'react';
|
|
import type { ErrorInfo, ReactNode } from 'react';
|
|
import { Toaster } from 'sonner';
|
|
import i18n from './i18n';
|
|
import { MainLayout } from './components/layout/MainLayout';
|
|
import { TooltipProvider } from '@/components/ui/tooltip';
|
|
import { Models } from './pages/Models';
|
|
import { Projects } from './pages/Projects';
|
|
import { Sessions } from './pages/Sessions';
|
|
import { Chat } from './pages/Chat';
|
|
import { Home } from './pages/Home';
|
|
import { PreviewScene } from './pages/Makelore';
|
|
import { ProjectConfiguration } from './pages/ProjectConfiguration';
|
|
import { Workbench } from './pages/Workbench';
|
|
import { ImageCanvas } from './pages/ImageCanvas';
|
|
import { DevicePreview } from './pages/DevicePreview';
|
|
import { Settings } from './pages/Settings';
|
|
import { Setup } from './pages/Setup';
|
|
import { Login } from './pages/Login';
|
|
import { ModuleSelection } from './pages/ModuleSelection';
|
|
import { useSettingsStore } from './stores/settings';
|
|
import { useProviderStore } from './stores/providers';
|
|
import { useAuthStore } from './stores/auth';
|
|
import { useOpencodeStore } from './stores/opencode';
|
|
import { useProjectConfigStore } from './stores/project-config';
|
|
import { AI_MODULE_SELECTION_PATH } from './lib/ai-modules';
|
|
import { useUserSyncStore } from './stores/user-sync';
|
|
import { subscribeHostEvent } from '@/lib/host-events';
|
|
|
|
|
|
/**
|
|
* Error Boundary to catch and display React rendering errors
|
|
*/
|
|
class ErrorBoundary extends Component<
|
|
{ children: ReactNode },
|
|
{ hasError: boolean; error: Error | null }
|
|
> {
|
|
constructor(props: { children: ReactNode }) {
|
|
super(props);
|
|
this.state = { hasError: false, error: null };
|
|
}
|
|
|
|
static getDerivedStateFromError(error: Error) {
|
|
return { hasError: true, error };
|
|
}
|
|
|
|
componentDidCatch(error: Error, info: ErrorInfo) {
|
|
console.error('React Error Boundary caught error:', error, info);
|
|
}
|
|
|
|
render() {
|
|
if (this.state.hasError) {
|
|
return (
|
|
<div style={{
|
|
padding: '40px',
|
|
color: '#7f1d1d',
|
|
background: '#FFFFFF',
|
|
minHeight: '100vh',
|
|
fontFamily: 'system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif'
|
|
}}>
|
|
<h1 style={{ fontSize: '24px', fontWeight: 600, marginBottom: '16px', letterSpacing: '-0.02em' }}>Something went wrong</h1>
|
|
<pre style={{
|
|
whiteSpace: 'pre-wrap',
|
|
wordBreak: 'break-all',
|
|
background: '#FFFFFF',
|
|
padding: '16px',
|
|
borderRadius: '16px',
|
|
fontSize: '14px',
|
|
color: '#27313f',
|
|
border: '1px solid #dfe3e8',
|
|
boxShadow: '0 12px 32px rgba(35, 43, 56, 0.08)'
|
|
}}>
|
|
{this.state.error?.message}
|
|
{'\n\n'}
|
|
{this.state.error?.stack}
|
|
</pre>
|
|
<button
|
|
onClick={() => { this.setState({ hasError: false, error: null }); window.location.reload(); }}
|
|
style={{
|
|
marginTop: '16px',
|
|
padding: '10px 16px',
|
|
background: 'hsl(var(--brand))',
|
|
color: 'white',
|
|
border: 'none',
|
|
borderRadius: '12px',
|
|
fontWeight: 600,
|
|
cursor: 'pointer'
|
|
}}
|
|
>
|
|
Reload
|
|
</button>
|
|
</div>
|
|
);
|
|
}
|
|
return this.props.children;
|
|
}
|
|
}
|
|
|
|
function StartupScreen() {
|
|
return (
|
|
<div className="flex min-h-screen items-center justify-center bg-background text-sm text-muted-foreground">
|
|
Makelore
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function getReturnPath(location: ReturnType<typeof useLocation>): string {
|
|
return `${location.pathname}${location.search}`;
|
|
}
|
|
|
|
function ProtectedLayout({
|
|
authReady,
|
|
authRequired,
|
|
imageWorkspaceLocalDevelopment,
|
|
setupReady,
|
|
}: {
|
|
authReady: boolean;
|
|
authRequired: boolean;
|
|
imageWorkspaceLocalDevelopment: boolean;
|
|
setupReady: boolean;
|
|
}) {
|
|
const location = useLocation();
|
|
const authenticated = useAuthStore((state) => state.isAuthenticated());
|
|
const allowsAnonymousImageWorkspace = imageWorkspaceLocalDevelopment
|
|
&& (location.pathname === '/'
|
|
|| location.pathname === '/image-canvas'
|
|
|| location.pathname.startsWith('/image-canvas/'));
|
|
|
|
if (!setupReady) {
|
|
return <StartupScreen />;
|
|
}
|
|
|
|
if (authRequired && !authReady && !allowsAnonymousImageWorkspace) {
|
|
return <StartupScreen />;
|
|
}
|
|
|
|
if (authRequired && !allowsAnonymousImageWorkspace && !authenticated) {
|
|
return (
|
|
<Navigate
|
|
to="/login"
|
|
replace
|
|
state={{ from: getReturnPath(location) }}
|
|
/>
|
|
);
|
|
}
|
|
|
|
return <MainLayout />;
|
|
}
|
|
|
|
function ProtectedModuleSelection({
|
|
authReady,
|
|
authRequired,
|
|
imageWorkspaceLocalDevelopment,
|
|
setupReady,
|
|
}: {
|
|
authReady: boolean;
|
|
authRequired: boolean;
|
|
imageWorkspaceLocalDevelopment: boolean;
|
|
setupReady: boolean;
|
|
}) {
|
|
const authenticated = useAuthStore((state) => state.isAuthenticated());
|
|
|
|
if (!setupReady) {
|
|
return <StartupScreen />;
|
|
}
|
|
|
|
if (authRequired && !authReady && !imageWorkspaceLocalDevelopment) {
|
|
return <StartupScreen />;
|
|
}
|
|
|
|
if (authRequired && !imageWorkspaceLocalDevelopment && !authenticated) {
|
|
return <Navigate to="/login" replace state={{ from: AI_MODULE_SELECTION_PATH }} />;
|
|
}
|
|
|
|
return <ModuleSelection />;
|
|
}
|
|
|
|
function ProjectChatRoute() {
|
|
const activeProject = useOpencodeStore((state) => state.activeProject);
|
|
const loadProjectList = useOpencodeStore((state) => state.loadProjectList);
|
|
const loadProjectConfig = useProjectConfigStore((state) => state.load);
|
|
const [resolvedProjectKey, setResolvedProjectKey] = useState<string | null | undefined>(undefined);
|
|
const [resolvedRoute, setResolvedRoute] = useState<'chat' | 'config'>('config');
|
|
const projectKey = activeProject?.id ?? null;
|
|
|
|
useEffect(() => {
|
|
let cancelled = false;
|
|
|
|
void (async () => {
|
|
try {
|
|
await loadProjectList();
|
|
|
|
const project = useOpencodeStore.getState().activeProject;
|
|
if (!project) {
|
|
if (!cancelled) {
|
|
setResolvedProjectKey(null);
|
|
setResolvedRoute('config');
|
|
}
|
|
return;
|
|
}
|
|
|
|
const configResult = await loadProjectConfig(project.id);
|
|
if (!cancelled) {
|
|
setResolvedProjectKey(project.id);
|
|
setResolvedRoute(configResult.status === 'valid' && Boolean(configResult.config?.initialized) ? 'chat' : 'config');
|
|
}
|
|
} catch {
|
|
if (!cancelled) {
|
|
setResolvedProjectKey(projectKey);
|
|
setResolvedRoute('config');
|
|
}
|
|
}
|
|
})();
|
|
|
|
return () => {
|
|
cancelled = true;
|
|
};
|
|
}, [loadProjectConfig, loadProjectList, projectKey]);
|
|
|
|
if (resolvedProjectKey === undefined || resolvedProjectKey !== projectKey) {
|
|
return <StartupScreen />;
|
|
}
|
|
if (resolvedRoute === 'config') {
|
|
return <Navigate to="/project-config" replace />;
|
|
}
|
|
return <Chat />;
|
|
}
|
|
|
|
function App() {
|
|
const navigate = useNavigate();
|
|
const location = useLocation();
|
|
const rendererOnlyPreview = typeof __NIANCODE_RENDERER_ONLY__ !== 'undefined'
|
|
&& __NIANCODE_RENDERER_ONLY__;
|
|
const skipSetupForE2E = typeof window !== 'undefined'
|
|
&& new URLSearchParams(window.location.search).get('e2eSkipSetup') === '1';
|
|
const initSettings = useSettingsStore((state) => state.init);
|
|
const language = useSettingsStore((state) => state.language);
|
|
const setupComplete = useSettingsStore((state) => state.setupComplete);
|
|
const initProviders = useProviderStore((state) => state.init);
|
|
const initAuth = useAuthStore((state) => state.init);
|
|
const authInitialized = useAuthStore((state) => state.initialized);
|
|
const authenticated = useAuthStore((state) => state.isAuthenticated());
|
|
const authAccessToken = useAuthStore((state) => state.accessToken);
|
|
const bootstrapUserSync = useUserSyncStore((state) => state.bootstrap);
|
|
const setupReady = setupComplete || skipSetupForE2E || rendererOnlyPreview;
|
|
const authRequired = !skipSetupForE2E && !rendererOnlyPreview;
|
|
const imageWorkspaceLocalDevelopment = window.electron?.imageWorkspaceLocalDevelopment === true;
|
|
|
|
useEffect(() => {
|
|
void initAuth();
|
|
}, [initAuth]);
|
|
|
|
useEffect(() => {
|
|
const unsubscribe = subscribeHostEvent('auth:session-changed', (session) => {
|
|
useAuthStore.getState().applyMainSession(session);
|
|
});
|
|
return unsubscribe;
|
|
}, []);
|
|
|
|
useEffect(() => {
|
|
if (rendererOnlyPreview || !authInitialized || !authAccessToken) return;
|
|
|
|
const recordActivity = (event: Event) => {
|
|
if (!event.isTrusted) return;
|
|
void useAuthStore.getState().markActivity();
|
|
};
|
|
window.addEventListener('pointerdown', recordActivity, { passive: true });
|
|
window.addEventListener('touchstart', recordActivity, { passive: true });
|
|
window.addEventListener('wheel', recordActivity, { passive: true });
|
|
window.addEventListener('keydown', recordActivity);
|
|
window.addEventListener('focus', recordActivity);
|
|
|
|
return () => {
|
|
window.removeEventListener('pointerdown', recordActivity);
|
|
window.removeEventListener('touchstart', recordActivity);
|
|
window.removeEventListener('wheel', recordActivity);
|
|
window.removeEventListener('keydown', recordActivity);
|
|
window.removeEventListener('focus', recordActivity);
|
|
};
|
|
}, [authAccessToken, authInitialized, rendererOnlyPreview]);
|
|
|
|
useEffect(() => {
|
|
initSettings();
|
|
}, [initSettings]);
|
|
|
|
// Sync i18n language with persisted settings on mount
|
|
useEffect(() => {
|
|
if (language && language !== i18n.language) {
|
|
i18n.changeLanguage(language);
|
|
}
|
|
}, [language]);
|
|
|
|
// Initialize provider snapshot only after the user can enter the app.
|
|
useEffect(() => {
|
|
if (rendererOnlyPreview) return;
|
|
if (!setupReady) return;
|
|
initProviders();
|
|
}, [initProviders, rendererOnlyPreview, setupReady]);
|
|
|
|
useEffect(() => {
|
|
if (rendererOnlyPreview) return;
|
|
if (!setupReady) return;
|
|
if (!authInitialized) return;
|
|
if (!authenticated) return;
|
|
void bootstrapUserSync();
|
|
}, [authenticated, authInitialized, bootstrapUserSync, rendererOnlyPreview, setupReady]);
|
|
|
|
// Redirect to setup wizard if not complete
|
|
useEffect(() => {
|
|
if (rendererOnlyPreview && location.pathname.startsWith('/setup')) {
|
|
navigate('/');
|
|
return;
|
|
}
|
|
|
|
if (!setupComplete && !skipSetupForE2E && !rendererOnlyPreview && !location.pathname.startsWith('/setup')) {
|
|
navigate('/setup');
|
|
}
|
|
}, [setupComplete, skipSetupForE2E, rendererOnlyPreview, location.pathname, navigate]);
|
|
|
|
// Listen for navigation events from main process
|
|
useEffect(() => {
|
|
if (!window.electron?.ipcRenderer?.on) return;
|
|
|
|
const handleNavigate = (...args: unknown[]) => {
|
|
const path = args[0];
|
|
if (typeof path === 'string') {
|
|
navigate(path);
|
|
}
|
|
};
|
|
|
|
const unsubscribe = window.electron.ipcRenderer.on('navigate', handleNavigate);
|
|
|
|
return () => {
|
|
if (typeof unsubscribe === 'function') {
|
|
unsubscribe();
|
|
}
|
|
};
|
|
}, [navigate]);
|
|
|
|
// Apply the product palette. Makelore now uses a single light interface.
|
|
useEffect(() => {
|
|
const root = window.document.documentElement;
|
|
root.classList.remove('light', 'dark');
|
|
root.classList.add('light');
|
|
}, []);
|
|
|
|
return (
|
|
<ErrorBoundary>
|
|
<TooltipProvider delayDuration={300}>
|
|
<Routes>
|
|
{/* Setup wizard (shown on first launch) */}
|
|
<Route path="/setup/*" element={<Setup />} />
|
|
<Route path="/login" element={<Login />} />
|
|
<Route
|
|
path={AI_MODULE_SELECTION_PATH}
|
|
element={(
|
|
<ProtectedModuleSelection
|
|
authReady={authInitialized}
|
|
authRequired={authRequired}
|
|
imageWorkspaceLocalDevelopment={imageWorkspaceLocalDevelopment}
|
|
setupReady={setupReady}
|
|
/>
|
|
)}
|
|
/>
|
|
|
|
{/* Main application routes */}
|
|
<Route
|
|
element={(
|
|
<ProtectedLayout
|
|
authReady={authInitialized}
|
|
authRequired={authRequired}
|
|
imageWorkspaceLocalDevelopment={imageWorkspaceLocalDevelopment}
|
|
setupReady={setupReady}
|
|
/>
|
|
)}
|
|
>
|
|
<Route
|
|
path="/"
|
|
element={<Navigate to={imageWorkspaceLocalDevelopment ? '/image-canvas' : '/opencode-chat'} replace />}
|
|
/>
|
|
<Route path="/project-config" element={<ProjectConfiguration />} />
|
|
<Route path="/makelore-home" element={<Home />} />
|
|
<Route path="/kangaroo" element={<Navigate to="/project-config" replace />} />
|
|
<Route path="/subagents" element={<Navigate to="/project-config" replace />} />
|
|
<Route path="/chat" element={<Navigate to="/opencode-chat" replace />} />
|
|
<Route path="/deliverables" element={<PreviewScene />} />
|
|
<Route path="/image-canvas" element={<ImageCanvas />} />
|
|
<Route path="/device-preview" element={<DevicePreview />} />
|
|
<Route path="/workbench/:projectId" element={<Workbench />} />
|
|
<Route path="/opencode-chat" element={<ProjectChatRoute />} />
|
|
<Route path="/projects" element={<Projects />} />
|
|
<Route path="/sessions" element={<Sessions />} />
|
|
<Route path="/models" element={<Models />} />
|
|
<Route path="/settings/*" element={<Settings />} />
|
|
<Route path="*" element={<Navigate to="/" replace />} />
|
|
</Route>
|
|
</Routes>
|
|
|
|
{/* Global toast notifications */}
|
|
<Toaster
|
|
position="bottom-right"
|
|
richColors
|
|
closeButton
|
|
style={{ zIndex: 99999 }}
|
|
/>
|
|
</TooltipProvider>
|
|
</ErrorBoundary>
|
|
);
|
|
}
|
|
|
|
export default App;
|