Files
makelore/src/App.tsx
brother7 5a275b93a7 feat: remove legacy OpenCode runtime
Cut product flows over to Coding/Pi and retain only the migration-owned v1 boundary. Promote supported native optional packages because electron-builder omitted pnpm transitive optional closure from the packaged ASAR.
2026-08-24 12:17:43 +08:00

487 lines
17 KiB
TypeScript

/**
* Root Application Component
* Handles routing and global providers
*/
import { Navigate, Routes, Route, useNavigate, useLocation } from 'react-router-dom';
import { Component, lazy, Suspense, 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 { useSettingsStore } from './stores/settings';
import { useProviderStore } from './stores/providers';
import { useAuthStore } from './stores/auth';
import { codingWorkspaceStore, useCodingWorkspaceStore } from './stores/coding-workspace';
import { useProjectConfigStore } from './stores/project-config';
import {
AI_MODULE_SELECTION_PATH,
getGuardedAiModuleForPath,
isAiModuleAllowed,
isProgrammingProviderRoute,
} from './lib/ai-modules';
import { useUserSyncStore } from './stores/user-sync';
import { flushPendingAgentSessionSync } from '@/lib/agent-session-sync';
import { subscribeHostEvent } from '@/lib/host-events';
import { reportDesktopActivity, type DesktopActivityModule } from '@/lib/host-api';
import { installRendererPerformanceDiagnostics } from '@/lib/performance-diagnostics';
import { resolveSupportedLanguage } from '../shared/language';
const Models = lazy(() => import('./pages/Models').then(({ Models: component }) => ({ default: component })));
const Chat = lazy(() => import('./pages/Chat').then(({ Chat: component }) => ({ default: component })));
const Home = lazy(() => import('./pages/Home').then(({ Home: component }) => ({ default: component })));
const PreviewScene = lazy(() => import('./pages/Makelore').then(({ PreviewScene: component }) => ({ default: component })));
const ProjectConfiguration = lazy(() => import('./pages/ProjectConfiguration').then(({ ProjectConfiguration: component }) => ({ default: component })));
const Workbench = lazy(() => import('./pages/Workbench').then(({ Workbench: component }) => ({ default: component })));
const ImageCanvas = lazy(() => import('./pages/ImageCanvas').then(({ ImageCanvas: component }) => ({ default: component })));
const ImagePromptMuseum = lazy(() => import('./pages/ImagePromptMuseum').then(({ ImagePromptMuseum: component }) => ({ default: component })));
const AiHardware = lazy(() => import('./pages/AiHardware').then(({ AiHardware: component }) => ({ default: component })));
const Settings = lazy(() => import('./pages/Settings').then(({ Settings: component }) => ({ default: component })));
const Setup = lazy(() => import('./pages/Setup').then(({ Setup: component }) => ({ default: component })));
const Login = lazy(() => import('./pages/Login').then(({ Login: component }) => ({ default: component })));
const ModuleSelection = lazy(() => import('./pages/ModuleSelection').then(({ ModuleSelection: component }) => ({ default: component })));
const Learning = lazy(() => import('./pages/Learning').then(({ Learning: component }) => ({ default: component })));
const LearningProjectDetail = lazy(() => import('./pages/Learning/ProjectDetail').then(({ LearningProjectDetail: component }) => ({ default: component })));
/**
* 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: 'var(--font-sans)'
}}>
<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 moduleForPath(pathname: string): DesktopActivityModule | null {
const module = getGuardedAiModuleForPath(pathname);
if (module === 'programming') return 'programming';
if (module === 'painting') return 'painting';
if (module === 'learning') return 'learning';
if (module === 'robot') return 'robot';
return null;
}
function publishDesktopActivity(input: {
visible: boolean;
module: DesktopActivityModule | null;
}): void {
try {
void Promise.resolve(reportDesktopActivity(input)).catch(() => undefined);
} catch {
// Renderer-only hosts may not expose lifecycle IPC; do not block routing.
}
}
function ProtectedLayout({
authReady,
authRequired,
imageWorkspaceLocalDevelopment,
setupReady,
}: {
authReady: boolean;
authRequired: boolean;
imageWorkspaceLocalDevelopment: boolean;
setupReady: boolean;
}) {
const location = useLocation();
const authenticated = useAuthStore((state) => state.isAuthenticated());
const moduleAccess = useAuthStore((state) => state.moduleAccess);
const requestedModule = getGuardedAiModuleForPath(location.pathname);
const allowsAnonymousImageWorkspace = imageWorkspaceLocalDevelopment
&& (location.pathname === '/image-canvas'
|| location.pathname.startsWith('/image-canvas/')
|| location.pathname === '/image-prompts'
|| location.pathname.startsWith('/image-prompts/'));
if (!setupReady) {
return <StartupScreen />;
}
if (authRequired && !authReady && !allowsAnonymousImageWorkspace) {
return <StartupScreen />;
}
if (authRequired && !allowsAnonymousImageWorkspace && !authenticated) {
return (
<Navigate
to="/login"
replace
state={{ from: getReturnPath(location) }}
/>
);
}
if (authenticated && requestedModule && !isAiModuleAllowed(requestedModule, moduleAccess)) {
return <Navigate to={AI_MODULE_SELECTION_PATH} replace />;
}
return <MainLayout />;
}
function ModuleSelectionRoute({
authReady,
authRequired,
setupReady,
}: {
authReady: boolean;
authRequired: boolean;
setupReady: boolean;
}) {
if (!setupReady) {
return <StartupScreen />;
}
if (authRequired && !authReady) {
return <StartupScreen />;
}
return <ModuleSelection authRequired={authRequired} />;
}
function ProjectChatRoute() {
const activeProject = useCodingWorkspaceStore((state) => state.activeProject);
const loadProjectList = useCodingWorkspaceStore((state) => state.load);
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 = codingWorkspaceStore.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 programmingModuleAllowed = useAuthStore(
(state) => state.moduleAccess.programming,
);
const bootstrapUserSync = useUserSyncStore((state) => state.bootstrap);
const setupReady = setupComplete || skipSetupForE2E || rendererOnlyPreview;
const authRequired = !skipSetupForE2E && !rendererOnlyPreview;
const imageWorkspaceLocalDevelopment = window.electron?.imageWorkspaceLocalDevelopment === true;
useEffect(() => installRendererPerformanceDiagnostics(), []);
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]);
// Keep the document language in sync with the persisted setting.
useEffect(() => {
const resolvedLanguage = resolveSupportedLanguage(language || i18n.language);
const root = window.document.documentElement;
root.lang = resolvedLanguage === 'zh' ? 'zh-CN' : resolvedLanguage;
root.dataset.uiLanguage = resolvedLanguage;
}, [language]);
// Initialize provider snapshot only after the user can enter the app.
useEffect(() => {
if (rendererOnlyPreview) return;
if (!setupReady) return;
if (authRequired && !authInitialized) return;
if (authRequired && !authenticated) return;
if (!isProgrammingProviderRoute(location.pathname)) return;
if (
getGuardedAiModuleForPath(location.pathname) === 'programming'
&& !programmingModuleAllowed
) return;
initProviders();
}, [
authInitialized,
authRequired,
authenticated,
initProviders,
location.pathname,
programmingModuleAllowed,
rendererOnlyPreview,
setupReady,
]);
useEffect(() => {
if (rendererOnlyPreview) return;
if (!setupReady) return;
if (!authInitialized) return;
if (!authenticated) return;
void bootstrapUserSync().finally(() => {
void flushPendingAgentSessionSync();
});
}, [authenticated, authInitialized, bootstrapUserSync, rendererOnlyPreview, setupReady]);
useEffect(() => {
if (rendererOnlyPreview) return undefined;
const publish = () => {
publishDesktopActivity({
visible: document.visibilityState === 'visible',
module: moduleForPath(location.pathname),
});
};
publish();
document.addEventListener('visibilitychange', publish, { passive: true });
return () => document.removeEventListener('visibilitychange', publish);
}, [location.pathname, rendererOnlyPreview]);
// 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}>
<Suspense fallback={<StartupScreen />}>
<Routes>
{/* Setup wizard (shown on first launch) */}
<Route path="/setup/*" element={<Setup />} />
<Route path="/login" element={<Login />} />
<Route
path={AI_MODULE_SELECTION_PATH}
element={(
<ModuleSelectionRoute
authReady={authInitialized}
authRequired={authRequired}
setupReady={setupReady}
/>
)}
/>
<Route path="/" element={<Navigate to={AI_MODULE_SELECTION_PATH} replace />} />
{/* Main application routes */}
<Route
element={(
<ProtectedLayout
authReady={authInitialized}
authRequired={authRequired}
imageWorkspaceLocalDevelopment={imageWorkspaceLocalDevelopment}
setupReady={setupReady}
/>
)}
>
<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={<ProjectChatRoute />} />
<Route path="/deliverables" element={<PreviewScene />} />
<Route path="/image-canvas" element={<ImageCanvas />} />
<Route path="/image-prompts" element={<ImagePromptMuseum />} />
<Route path="/ai-hardware" element={<AiHardware />} />
<Route path="/learning" element={<Learning />} />
<Route path="/learning/project/:projectId" element={<LearningProjectDetail />} />
<Route path="/workbench/:projectId" element={<Workbench />} />
<Route path="/models" element={<Models />} />
<Route path="/settings/*" element={<Settings />} />
<Route path="*" element={<Navigate to="/" replace />} />
</Route>
</Routes>
</Suspense>
{/* Global toast notifications */}
<Toaster
position="bottom-right"
richColors
closeButton
style={{ zIndex: 99999 }}
/>
</TooltipProvider>
</ErrorBoundary>
);
}
export default App;