feat(frontend): Q3+Q4 — Komponenten-Chunk-Map wird aus Plugin-Manifesten GENERIERT
Check Cross-Plugin Imports / check (push) Has been cancelled

scripts/generate_component_map.py scannt alle builtin-Manifeste + system_miniapps.py
und erzeugt frontend/src/generated/pluginComponents.generated.ts (37 Komponenten).
PluginLoader (STATIC_COMPONENT_MAP) und MiniAppHost (widgetRegistry) nutzen die
generierte Map — ein Plugin meldet seine Komponenten nur noch im Manifest,
keine zentrale Frontend-Datei muss angefasst werden.

Garantien: Generator failt hart bei Ghost-Komponenten (bewiesen: exit 1),
erkennt default- vs. named-exports, deterministische Ausgabe, --check-Modus
fuer CI. Kontakts DedupMergePage-Pfad-Alias auf echte Datei korrigiert.

Verifikation: tsc exit 0; production build exit 0; Ghost-Fail-Hard exit 1;
Dashboard+MiniAppWindow 17/17; pluginStore 18/18; keine Restreferenzen auf
STATIC_COMPONENT_MAP/widgetRegistry.
This commit is contained in:
Agent Zero
2026-09-13 08:40:39 +02:00
parent dbe9ded4f1
commit 895f85dde0
5 changed files with 275 additions and 95 deletions
@@ -12,46 +12,13 @@ import { useTranslation } from 'react-i18next';
import { AppWindow } from 'lucide-react';
import { Skeleton } from '@/components/ui/Skeleton';
import { useMiniapps, type MiniAppDef } from '@/api/miniapps';
import { PLUGIN_COMPONENT_MAP } from '@/generated/pluginComponents.generated';
export interface WidgetComponentProps {
settings?: Record<string, unknown>;
}
const widgetRegistry: Record<string, React.LazyExoticComponent<React.ComponentType<WidgetComponentProps>>> = {
'@/components/dashboard/RecentContactsWidget': lazy(() =>
import('@/components/dashboard/RecentContactsWidget').then((m) => ({ default: m.RecentContactsWidget }))
),
'@/components/dashboard/TasksSummaryWidget': lazy(() =>
import('@/components/dashboard/TasksSummaryWidget').then((m) => ({ default: m.TasksSummaryWidget }))
),
'@/components/dashboard/CalendarUpcomingWidget': lazy(() =>
import('@/components/dashboard/CalendarUpcomingWidget').then((m) => ({ default: m.CalendarUpcomingWidget }))
),
'@/components/dashboard/ContactsStatsWidget': lazy(() =>
import('@/components/dashboard/ContactsStatsWidget').then((m) => ({ default: m.ContactsStatsWidget }))
),
'@/components/dashboard/AuditActivityWidget': lazy(() =>
import('@/components/dashboard/AuditActivityWidget').then((m) => ({ default: m.AuditActivityWidget }))
),
'@/components/dashboard/SystemMetricsWidget': lazy(() =>
import('@/components/dashboard/SystemMetricsWidget').then((m) => ({ default: m.SystemMetricsWidget }))
),
'@/components/dashboard/DmsFoldersWidget': lazy(() =>
import('@/components/dashboard/DmsFoldersWidget').then((m) => ({ default: m.DmsFoldersWidget }))
),
'@/components/dashboard/MailUnreadWidget': lazy(() =>
import('@/components/dashboard/MailUnreadWidget').then((m) => ({ default: m.MailUnreadWidget }))
),
'@/components/dashboard/WikiRecentWidget': lazy(() =>
import('@/components/dashboard/WikiRecentWidget').then((m) => ({ default: m.WikiRecentWidget }))
),
'@/components/dashboard/GraphOverviewWidget': lazy(() =>
import('@/components/dashboard/GraphOverviewWidget').then((m) => ({ default: m.GraphOverviewWidget }))
),
'@/components/dashboard/AutomationStatusWidget': lazy(() =>
import('@/components/dashboard/AutomationStatusWidget').then((m) => ({ default: m.AutomationStatusWidget }))
),
};
interface MiniAppHostProps {
appId: string;
@@ -86,7 +53,10 @@ export function MiniAppHost({ appId, settings, def }: MiniAppHostProps) {
const resolved = def ?? data?.items.find((a) => a.app_id === appId);
if (resolved && resolved.component) {
const WidgetComponent = widgetRegistry[resolved.component];
// Phase Q3: widgets resolve via the GENERATED component map — a plugin
// contributes dashboard widgets through its manifest, no central registry.
const widgetFactory = PLUGIN_COMPONENT_MAP[resolved.component];
const WidgetComponent = widgetFactory ? lazy(widgetFactory) : undefined;
if (WidgetComponent) {
return (
<div data-testid={`miniapp-${appId}`}>
@@ -1,6 +1,7 @@
import React, { Suspense, lazy, Component, ReactNode } from 'react';
import { Loader2 } from 'lucide-react';
import { logError } from '@/utils/errorLogger';
import { PLUGIN_COMPONENT_MAP } from '@/generated/pluginComponents.generated';
// ── Error Boundary ──────────────────────────────────────────────────────
@@ -91,66 +92,18 @@ class PluginErrorBoundary extends Component<PluginErrorBoundaryProps, PluginErro
}
}
// ── Static Chunk Map (ARCH-019) ─────────────────────────────────────────
// ── Plugin Component Map (Phase Q3, ARCH-019 successor) ──────────────────
//
// Vite cannot statically analyze dynamic import paths built at runtime
// (`import(/* @vite-ignore */ path)`), so production builds would fail to
// load plugin pages. All plugin-contributed component paths are known at
// build time — they are declared in the backend manifests so we register
// them here as explicit lazy imports. Vite chunks each one correctly.
//
// Unknown paths (e.g. third-party plugins added later) fall back to the
// runtime dynamic import, which works in dev mode.
// The chunk map is GENERATED from the plugin manifests by
// scripts/generate_component_map.py (frontend/src/generated/
// pluginComponents.generated.ts). A plugin contributes its frontend
// components via its manifest (page_routes / settings_pages / miniapps /
// dashboard_widgets) — no central frontend file needs editing anymore.
// Unknown paths (truly external plugins) still fall back to the runtime
// dynamic import, which works in dev mode.
type ComponentModule = Record<string, unknown> & { default?: React.ComponentType<any> };
type LazyComponentFactory = () => Promise<{ default: React.ComponentType<any> }>;
/**
* Normalize a module namespace to the { default } shape React.lazy expects:
* uses the default export when present, otherwise the first named export
* (same fallback as the runtime dynamic-import path below).
*/
function normalizeModule(m: ComponentModule): { default: React.ComponentType<any> } {
const Comp = (m.default ?? m[Object.keys(m)[0]]) as React.ComponentType<any>;
return { default: Comp };
}
const STATIC_COMPONENT_MAP: Record<string, LazyComponentFactory> = {
// Pages
'@/pages/AgentDashboard': () => import('@/pages/AgentDashboard').then(normalizeModule),
// BUG (ghost page) fixed in Block I-D: page now exists.
'@/pages/AIAssistant': () => import('@/pages/AIAssistant').then(normalizeModule),
'@/pages/AISettings': () => import('@/pages/AISettings').then(normalizeModule),
'@/pages/AutomationDashboard': () => import('@/pages/AutomationDashboard').then(normalizeModule),
'@/pages/AutomationSettings': () => import('@/pages/AutomationSettings').then(normalizeModule),
'@/pages/Calendar': () => import('@/pages/Calendar').then(normalizeModule),
'@/pages/Communication': () => import('@/pages/Communication').then(normalizeModule),
'@/pages/ContactDetailPage': () => import('@/pages/ContactDetailPage').then((m) => ({ default: m.ContactDetailPage })),
'@/pages/ContactsList': () => import('@/pages/ContactsList').then((m) => ({ default: m.ContactsListPage })),
'@/pages/DedupMergePage': () => import('@/pages/DedupMerge').then((m) => ({ default: m.DedupMergePage })),
'@/pages/DocumentSettings': () => import('@/pages/DocumentSettings').then(normalizeModule),
'@/pages/Dms': () => import('@/pages/Dms').then(normalizeModule),
'@/pages/DmsTrash': () => import('@/pages/DmsTrash').then(normalizeModule),
'@/pages/GlobalSearchResults': () => import('@/pages/GlobalSearchResults').then(normalizeModule),
'@/pages/ImportExport': () => import('@/pages/ImportExport').then(normalizeModule),
'@/pages/Mail': () => import('@/pages/Mail').then(normalizeModule),
'@/pages/MailSettings': () => import('@/pages/MailSettings').then(normalizeModule),
'@/pages/ProactiveAISettings': () => import('@/pages/ProactiveAISettings').then(normalizeModule),
'@/pages/Reports': () => import('@/pages/Reports').then(normalizeModule),
'@/pages/SettingsGroups': () => import('@/pages/SettingsGroups').then(normalizeModule),
'@/pages/SettingsNotifications': () => import('@/pages/SettingsNotifications').then(normalizeModule),
'@/pages/SettingsRoles': () => import('@/pages/SettingsRoles').then(normalizeModule),
'@/pages/SettingsUsers': () => import('@/pages/SettingsUsers').then(normalizeModule),
'@/pages/Tasks': () => import('@/pages/Tasks').then(normalizeModule),
'@/pages/Wiki': () => import('@/pages/Wiki').then(normalizeModule),
'@/pages/Workflows': () => import('@/pages/Workflows').then(normalizeModule),
// NOTE: The backend manifests also declare contact detail tabs
// (@/components/contact/Contact*Tab) whose components do not exist in the
// frontend yet — they are intentionally NOT registered here; they fall
// through to the runtime fallback and surface via the error boundary.
// See PROGRESS.md "Ghost components" finding.
};
// ── Lazy Component Cache ────────────────────────────────────────────────
const componentCache = new Map<string, React.LazyExoticComponent<React.ComponentType<any>>>();
@@ -160,8 +113,9 @@ function getLazyComponent(componentPath: string): React.LazyExoticComponent<Reac
return componentCache.get(componentPath)!;
}
// Known plugin components: statically chunked at build time (ARCH-019)
const factory = STATIC_COMPONENT_MAP[componentPath];
// Known plugin components: statically chunked at build time via the
// GENERATED map (Phase Q3 — manifests are the single source of truth).
const factory = (PLUGIN_COMPONENT_MAP as Record<string, LazyComponentFactory>)[componentPath];
if (factory) {
const LazyComp = lazy(factory);
componentCache.set(componentPath, LazyComp);
@@ -0,0 +1,58 @@
// AUTO-GENERATED by scripts/generate_component_map.py — DO NOT EDIT.
// Regenerate with: python3 scripts/generate_component_map.py
// Sources: app/plugins/builtins/*/plugin.py + app/core/system_miniapps.py
// Failure mode: the generator refuses ghost components (manifest path without
// a matching frontend file), so this map is always loadable.
/* eslint-disable */
// NOTE: ComponentType<any> — components take their own specific props and
// consumers (PluginLoader, MiniAppHost) pass arbitrary props; `unknown`
// would forbid all JSX attributes (Phase Q3).
import type { ComponentType } from 'react';
type ComponentModule = Record<string, unknown> & { default?: ComponentType<any> };
export type LazyComponentFactory = () => Promise<{ default: ComponentType<any> }>;
function normalizeModule(m: ComponentModule): { default: ComponentType<any> } {
const Comp = (m.default ?? m[Object.keys(m)[0]]) as ComponentType<any>;
return { default: Comp };
}
export const PLUGIN_COMPONENT_MAP: Record<string, LazyComponentFactory> = {
'@/components/dashboard/AuditActivityWidget': () => import('@/components/dashboard/AuditActivityWidget').then((m) => ({ default: m.AuditActivityWidget })),
'@/components/dashboard/AutomationStatusWidget': () => import('@/components/dashboard/AutomationStatusWidget').then((m) => ({ default: m.AutomationStatusWidget })),
'@/components/dashboard/CalendarUpcomingWidget': () => import('@/components/dashboard/CalendarUpcomingWidget').then((m) => ({ default: m.CalendarUpcomingWidget })),
'@/components/dashboard/ContactsStatsWidget': () => import('@/components/dashboard/ContactsStatsWidget').then((m) => ({ default: m.ContactsStatsWidget })),
'@/components/dashboard/DmsFoldersWidget': () => import('@/components/dashboard/DmsFoldersWidget').then((m) => ({ default: m.DmsFoldersWidget })),
'@/components/dashboard/GraphOverviewWidget': () => import('@/components/dashboard/GraphOverviewWidget').then((m) => ({ default: m.GraphOverviewWidget })),
'@/components/dashboard/MailUnreadWidget': () => import('@/components/dashboard/MailUnreadWidget').then((m) => ({ default: m.MailUnreadWidget })),
'@/components/dashboard/RecentContactsWidget': () => import('@/components/dashboard/RecentContactsWidget').then((m) => ({ default: m.RecentContactsWidget })),
'@/components/dashboard/SystemMetricsWidget': () => import('@/components/dashboard/SystemMetricsWidget').then((m) => ({ default: m.SystemMetricsWidget })),
'@/components/dashboard/TasksSummaryWidget': () => import('@/components/dashboard/TasksSummaryWidget').then((m) => ({ default: m.TasksSummaryWidget })),
'@/components/dashboard/WikiRecentWidget': () => import('@/components/dashboard/WikiRecentWidget').then((m) => ({ default: m.WikiRecentWidget })),
'@/pages/AIAssistant': () => import('@/pages/AIAssistant').then(normalizeModule),
'@/pages/AISettings': () => import('@/pages/AISettings').then((m) => ({ default: m.AISettingsPage })),
'@/pages/AgentDashboard': () => import('@/pages/AgentDashboard').then((m) => ({ default: m.AgentDashboardPage })),
'@/pages/AutomationDashboard': () => import('@/pages/AutomationDashboard').then((m) => ({ default: m.AutomationDashboardPage })),
'@/pages/AutomationSettings': () => import('@/pages/AutomationSettings').then((m) => ({ default: m.AutomationSettingsPage })),
'@/pages/Calendar': () => import('@/pages/Calendar').then(normalizeModule),
'@/pages/Communication': () => import('@/pages/Communication').then(normalizeModule),
'@/pages/ContactDetailPage': () => import('@/pages/ContactDetailPage').then((m) => ({ default: m.ContactDetailPage })),
'@/pages/ContactsList': () => import('@/pages/ContactsList').then((m) => ({ default: m.ContactsListPage })),
'@/pages/DedupMerge': () => import('@/pages/DedupMerge').then((m) => ({ default: m.DedupMergePage })),
'@/pages/Dms': () => import('@/pages/Dms').then((m) => ({ default: m.DmsPage })),
'@/pages/DmsTrash': () => import('@/pages/DmsTrash').then((m) => ({ default: m.DmsTrashPage })),
'@/pages/DocumentSettings': () => import('@/pages/DocumentSettings').then((m) => ({ default: m.DocumentSettingsPage })),
'@/pages/GlobalSearchResults': () => import('@/pages/GlobalSearchResults').then((m) => ({ default: m.GlobalSearchResultsPage })),
'@/pages/ImportExport': () => import('@/pages/ImportExport').then((m) => ({ default: m.ImportExportPage })),
'@/pages/Mail': () => import('@/pages/Mail').then((m) => ({ default: m.MailPage })),
'@/pages/MailSettings': () => import('@/pages/MailSettings').then((m) => ({ default: m.MailSettingsPage })),
'@/pages/ProactiveAISettings': () => import('@/pages/ProactiveAISettings').then((m) => ({ default: m.ProactiveAISettings })),
'@/pages/Reports': () => import('@/pages/Reports').then((m) => ({ default: m.ReportsPage })),
'@/pages/SettingsGroups': () => import('@/pages/SettingsGroups').then((m) => ({ default: m.SettingsGroupsPage })),
'@/pages/SettingsNotifications': () => import('@/pages/SettingsNotifications').then((m) => ({ default: m.SettingsNotificationsPage })),
'@/pages/SettingsRoles': () => import('@/pages/SettingsRoles').then((m) => ({ default: m.SettingsRolesPage })),
'@/pages/SettingsUsers': () => import('@/pages/SettingsUsers').then((m) => ({ default: m.SettingsUsersPage })),
'@/pages/Tasks': () => import('@/pages/Tasks').then((m) => ({ default: m.TasksPage })),
'@/pages/Wiki': () => import('@/pages/Wiki').then((m) => ({ default: m.WikiPage })),
'@/pages/Workflows': () => import('@/pages/Workflows').then((m) => ({ default: m.WorkflowsPage })),
};