Phase 3: Plugin-UI-System (WordPress-Style)
Backend: - PluginManifest um 5 neue UI-Felder erweitert: menu_items, page_routes, detail_tabs, settings_pages, dashboard_widgets (FrontendMenuItem, FrontendPageRoute, FrontendDetailTab, FrontendSettingsPage, FrontendDashboardWidget) - GET /api/v1/plugins/active-manifests Endpoint liefert UI-Manifeste aller aktiven Plugins - Registry.get_active_manifests() + PluginService.get_active_manifests() - 12 Built-in Plugins mit UI-Manifest-Daten gefuellt (menu_items, page_routes, detail_tabs, settings_pages) - Plugin-Install-System: POST /upload (ZIP), POST /install-url (URL) mit Validierung (Manifest, dangerous imports, SQL migrations) Frontend: - pluginStore.ts (Zustand) mit PluginUiManifest Typen + Selektoren - useActivePluginManifests() React Query Hook - PluginRegistry.tsx — fetcht Manifeste beim App-Start - PluginLoader.tsx — dynamisches React.lazy() mit ErrorBoundary - PluginRouteRenderer.tsx — Catch-all fuer Plugin-Routes - routes/index.tsx — Catch-all Routes fuer Plugin-Pages + Settings - Sidebar.tsx — dynamische Plugin Menu-Items mit Grouping + Icons - Settings.tsx — dynamische Plugin Settings-Pages - ContactDetail.tsx — dynamische Plugin Detail-Tabs mit Permissions - AppShell.tsx — PluginRegistry Provider eingebunden - SettingsPlugins.tsx — Install-UI (ZIP Upload + URL Install) - plugins.ts — useUploadPlugin() + useInstallPluginFromUrl() Hooks Docs & Templates: - docs/plugin-development-guide.md — komplette Entwickler-Doku - templates/plugin-template/ — Boilerplate mit allen Manifest-Feldern Tests: - 34 Vitest-Tests (PluginRegistry, PluginLoader, PluginRouteRenderer, pluginStore) — alle bestanden - TSC: keine neuen Errors (nur pre-existing Dms.tsx)
This commit is contained in:
+687
-182
@@ -1,43 +1,296 @@
|
||||
# LeoCRM Plugin Development Guide
|
||||
|
||||
> **Version:** 1.0
|
||||
> **Datum:** 2026-07-23
|
||||
> **Gültig für:** Alle Plugin-Entwickler
|
||||
> **Version:** 2.0
|
||||
> **Date:** 2026-07-23
|
||||
> **Applies to:** All plugin developers
|
||||
|
||||
---
|
||||
|
||||
## 1. Plugin-Struktur
|
||||
## 1. Overview
|
||||
|
||||
Jedes Plugin liegt unter `app/plugins/builtins/<plugin_name>/`:
|
||||
Plugins are self-contained modules that extend LeoCRM's functionality. Each plugin lives in its own directory under `app/plugins/builtins/` and declares its capabilities via a `PluginManifest`. The plugin system supports:
|
||||
|
||||
- **API Routes** — Register FastAPI route handlers
|
||||
- **Event Subscriptions** — React to domain events (contact.created, etc.)
|
||||
- **Database Migrations** — Versioned SQL migrations run on install
|
||||
- **RBAC Permissions** — Declare and enforce permissions
|
||||
- **Frontend UI Contributions** — Menu items, page routes, detail tabs, settings pages, dashboard widgets
|
||||
- **AI Agent Capabilities** — Register tools for the AI assistant
|
||||
- **Lifecycle Hooks** — on_install, on_activate, on_deactivate, on_uninstall
|
||||
|
||||
---
|
||||
|
||||
## 2. Plugin Structure
|
||||
|
||||
Every plugin lives under `app/plugins/builtins/<plugin_name>/`:
|
||||
|
||||
```
|
||||
app/plugins/builtins/my_plugin/
|
||||
├── __init__.py
|
||||
├── plugin.py # Plugin-Klasse mit Manifest
|
||||
├── routes.py # API-Routes
|
||||
├── models.py # SQLAlchemy-Modelle (optional)
|
||||
├── schemas.py # Pydantic-Schemas (optional)
|
||||
├── services.py # Business-Logik (optional)
|
||||
├── migrations/ # SQL-Migrationen
|
||||
├── __init__.py # Package init (can be empty)
|
||||
├── plugin.py # Plugin class with manifest (required)
|
||||
├── routes.py # FastAPI route definitions
|
||||
├── models.py # SQLAlchemy models (optional)
|
||||
├── schemas.py # Pydantic schemas (optional)
|
||||
├── services.py # Business logic (optional)
|
||||
├── migrations/ # SQL migration files
|
||||
│ └── 0001_initial.sql
|
||||
└── tests/ # Plugin-Tests (optional)
|
||||
└── tests/ # Plugin tests (optional)
|
||||
├── __init__.py
|
||||
└── test_plugin.py
|
||||
```
|
||||
|
||||
## 2. Plugin-Manifest
|
||||
---
|
||||
|
||||
Das Manifest definiert Metadaten, Abhängigkeiten, Routes, Events und Permissions:
|
||||
## 3. Manifest Format
|
||||
|
||||
The `PluginManifest` is a Pydantic v2 model that declares all metadata and capabilities of a plugin. It is defined in `app/plugins/manifest.py`.
|
||||
|
||||
### 3.1 Core Fields
|
||||
|
||||
| Field | Type | Required | Description |
|
||||
|---|---|---|---|
|
||||
| `name` | `str` | Yes | Unique plugin identifier (snake_case, max 80 chars, alphanumeric with underscores only) |
|
||||
| `version` | `str` | Yes | Semantic version string (e.g. "1.0.0") |
|
||||
| `display_name` | `str` | Yes | Human-readable plugin name (max 120 chars) |
|
||||
| `description` | `str` | No | Plugin description (max 500 chars) |
|
||||
| `dependencies` | `list[str]` | No | Other plugin names this plugin depends on |
|
||||
| `is_core` | `bool` | No | Whether this is a core plugin that cannot be deactivated (default: `false`) |
|
||||
|
||||
### 3.2 Route Definitions
|
||||
|
||||
```python
|
||||
from app.plugins.manifest import PluginRouteDef
|
||||
|
||||
routes=[
|
||||
PluginRouteDef(
|
||||
path="/api/v1/my-plugin", # URL path prefix
|
||||
module="app.plugins.builtins.my_plugin.routes", # Dotted module path
|
||||
router_attr="router", # Attribute name of the APIRouter in the module
|
||||
),
|
||||
]
|
||||
```
|
||||
|
||||
### 3.3 Event Subscriptions
|
||||
|
||||
```python
|
||||
events=[
|
||||
"contact.created",
|
||||
"contact.updated",
|
||||
"contact.deleted",
|
||||
]
|
||||
```
|
||||
|
||||
Event naming convention: `<entity>.<action>` (e.g. `company.created`, `task.assigned`).
|
||||
|
||||
### 3.4 Migrations
|
||||
|
||||
```python
|
||||
migrations=[
|
||||
"0001_initial.sql",
|
||||
"0002_add_indexes.sql",
|
||||
]
|
||||
```
|
||||
|
||||
Migration files are stored in the plugin's `migrations/` directory and run in order on install.
|
||||
|
||||
### 3.5 Permissions
|
||||
|
||||
```python
|
||||
permissions=[
|
||||
"my_plugin:read",
|
||||
"my_plugin:write",
|
||||
"my_plugin:delete",
|
||||
"my_plugin:admin",
|
||||
]
|
||||
```
|
||||
|
||||
Permission naming convention: `<plugin_name>:<action>`.
|
||||
|
||||
### 3.6 Field Definitions (Field-Level Permissions)
|
||||
|
||||
```python
|
||||
from app.plugins.manifest import FieldDefinition
|
||||
|
||||
field_definitions=[
|
||||
FieldDefinition(
|
||||
module="companies", # Module name (e.g. 'companies', 'contacts')
|
||||
field="annual_revenue", # Field name
|
||||
label="Annual Revenue", # Human-readable label
|
||||
sensitivity="sensitive", # normal|sensitive|critical
|
||||
),
|
||||
]
|
||||
```
|
||||
|
||||
### 3.7 Agent Capabilities
|
||||
|
||||
```python
|
||||
agent_capabilities=[
|
||||
"contact_search", # Contact search capability
|
||||
"email_draft", # Email draft generation
|
||||
"calendar_scheduling", # Calendar scheduling
|
||||
]
|
||||
```
|
||||
|
||||
### 3.8 Frontend UI Fields (Phase 3)
|
||||
|
||||
#### FrontendMenuItem — Sidebar Navigation
|
||||
|
||||
```python
|
||||
from app.plugins.manifest import FrontendMenuItem
|
||||
|
||||
menu_items=[
|
||||
FrontendMenuItem(
|
||||
label_key="nav.myPlugin", # i18n key for the menu label
|
||||
label="My Plugin", # Fallback label if i18n key is missing
|
||||
path="/my-plugin", # Frontend route path
|
||||
icon="Sparkles", # lucide-react icon name
|
||||
group="", # Optional group label_key for tree-style nesting
|
||||
order=100, # Sort order within the sidebar
|
||||
badge_key="", # Optional store key for badge count
|
||||
),
|
||||
]
|
||||
```
|
||||
|
||||
| Field | Type | Required | Default | Description |
|
||||
|---|---|---|---|---|
|
||||
| `label_key` | `str` | Yes | — | i18n key for the menu label |
|
||||
| `label` | `str` | No | `""` | Fallback label if i18n key is missing |
|
||||
| `path` | `str` | Yes | — | Frontend route path, e.g. `/mail` |
|
||||
| `icon` | `str` | No | `"FileText"` | lucide-react icon name |
|
||||
| `group` | `str` | No | `""` | Optional group label_key for tree-style nesting |
|
||||
| `order` | `int` | No | `100` | Sort order within the sidebar |
|
||||
| `badge_key` | `str` | No | `""` | Optional store key for badge count |
|
||||
|
||||
#### FrontendPageRoute — Page Routes
|
||||
|
||||
```python
|
||||
from app.plugins.manifest import FrontendPageRoute
|
||||
|
||||
page_routes=[
|
||||
FrontendPageRoute(
|
||||
path="/my-plugin", # Frontend route path
|
||||
component="@/pages/MyPlugin", # Dotted path to the React component
|
||||
parent="", # Parent route path for nested routes
|
||||
protected=True, # Whether the route requires authentication
|
||||
order=100, # Sort order
|
||||
),
|
||||
]
|
||||
```
|
||||
|
||||
| Field | Type | Required | Default | Description |
|
||||
|---|---|---|---|---|
|
||||
| `path` | `str` | Yes | — | Frontend route path, e.g. `/mail` or `/mail/settings` |
|
||||
| `component` | `str` | Yes | — | Dotted path to the React component, e.g. `@/pages/Mail` |
|
||||
| `parent` | `str` | No | `""` | Parent route path for nested routes (e.g. `/settings` for a settings sub-page) |
|
||||
| `protected` | `bool` | No | `True` | Whether the route requires authentication |
|
||||
| `order` | `int` | No | `100` | Sort order |
|
||||
|
||||
#### FrontendDetailTab — Entity Detail Tabs
|
||||
|
||||
```python
|
||||
from app.plugins.manifest import FrontendDetailTab
|
||||
|
||||
detail_tabs=[
|
||||
FrontendDetailTab(
|
||||
entity_type="contact", # Entity type this tab applies to
|
||||
label_key="tabs.myPlugin", # i18n key for the tab label
|
||||
label="My Tab", # Fallback label
|
||||
component="@/components/MyTab", # Dotted path to the React component
|
||||
icon="FileText", # lucide-react icon name
|
||||
order=50, # Sort order within the detail view
|
||||
permission="my_plugin:read", # Optional permission required to see this tab
|
||||
),
|
||||
]
|
||||
```
|
||||
|
||||
| Field | Type | Required | Default | Description |
|
||||
|---|---|---|---|---|
|
||||
| `entity_type` | `str` | Yes | — | Entity type this tab applies to, e.g. `'contact'` |
|
||||
| `label_key` | `str` | Yes | — | i18n key for the tab label |
|
||||
| `label` | `str` | No | `""` | Fallback label |
|
||||
| `component` | `str` | Yes | — | Dotted path to the React component |
|
||||
| `icon` | `str` | No | `"FileText"` | lucide-react icon name |
|
||||
| `order` | `int` | No | `100` | Sort order within the detail view |
|
||||
| `permission` | `str` | No | `""` | Optional permission required to see this tab |
|
||||
|
||||
#### FrontendSettingsPage — Settings Sub-Pages
|
||||
|
||||
```python
|
||||
from app.plugins.manifest import FrontendSettingsPage
|
||||
|
||||
settings_pages=[
|
||||
FrontendSettingsPage(
|
||||
path="my-plugin", # Settings sub-route path
|
||||
label_key="settings.myPlugin", # i18n key for the settings nav label
|
||||
label="My Plugin", # Fallback label
|
||||
component="@/pages/MyPluginSettings", # Dotted path to the React component
|
||||
icon="Sparkles", # lucide-react icon name
|
||||
order=100, # Sort order within settings nav
|
||||
permission="my_plugin:admin", # Optional permission required
|
||||
),
|
||||
]
|
||||
```
|
||||
|
||||
| Field | Type | Required | Default | Description |
|
||||
|---|---|---|---|---|
|
||||
| `path` | `str` | Yes | — | Settings sub-route path, e.g. `mail` or `notifications` |
|
||||
| `label_key` | `str` | Yes | — | i18n key for the settings nav label |
|
||||
| `label` | `str` | No | `""` | Fallback label |
|
||||
| `component` | `str` | Yes | — | Dotted path to the React component |
|
||||
| `icon` | `str` | No | `"Settings"` | lucide-react icon name |
|
||||
| `order` | `int` | No | `100` | Sort order within settings nav |
|
||||
| `permission` | `str` | No | `""` | Optional permission required |
|
||||
|
||||
#### FrontendDashboardWidget — Dashboard Widgets
|
||||
|
||||
```python
|
||||
from app.plugins.manifest import FrontendDashboardWidget
|
||||
|
||||
dashboard_widgets=[
|
||||
FrontendDashboardWidget(
|
||||
id="my_plugin_stats", # Unique widget identifier
|
||||
label_key="widgets.myPlugin", # i18n key for the widget title
|
||||
label="My Plugin Stats", # Fallback label
|
||||
component="@/components/MyWidget", # Dotted path to the React component
|
||||
icon="LayoutDashboard", # lucide-react icon name
|
||||
order=100, # Sort order on the dashboard
|
||||
col_span=1, # Grid column span (1-4)
|
||||
row_span=1, # Grid row span
|
||||
permission="my_plugin:read", # Optional permission required
|
||||
),
|
||||
]
|
||||
```
|
||||
|
||||
| Field | Type | Required | Default | Description |
|
||||
|---|---|---|---|---|
|
||||
| `id` | `str` | Yes | — | Unique widget identifier |
|
||||
| `label_key` | `str` | Yes | — | i18n key for the widget title |
|
||||
| `label` | `str` | No | `""` | Fallback label |
|
||||
| `component` | `str` | Yes | — | Dotted path to the React component |
|
||||
| `icon` | `str` | No | `"LayoutDashboard"` | lucide-react icon name |
|
||||
| `order` | `int` | No | `100` | Sort order on the dashboard |
|
||||
| `col_span` | `int` | No | `1` | Grid column span (1-4) |
|
||||
| `row_span` | `int` | No | `1` | Grid row span |
|
||||
| `permission` | `str` | No | `""` | Optional permission required |
|
||||
|
||||
### 3.9 Complete Manifest Example
|
||||
|
||||
```python
|
||||
from app.plugins.base import BasePlugin
|
||||
from app.plugins.manifest import PluginManifest, PluginRouteDef
|
||||
from app.plugins.manifest import (
|
||||
PluginManifest, PluginRouteDef, FieldDefinition,
|
||||
FrontendMenuItem, FrontendPageRoute, FrontendDetailTab,
|
||||
FrontendSettingsPage, FrontendDashboardWidget,
|
||||
)
|
||||
|
||||
class MyPlugin(BasePlugin):
|
||||
manifest = PluginManifest(
|
||||
name="my_plugin",
|
||||
version="1.0.0",
|
||||
display_name="My Plugin",
|
||||
description="Description of what the plugin does.",
|
||||
dependencies=["permissions"], # Other plugins this depends on
|
||||
description="A comprehensive example plugin.",
|
||||
dependencies=["permissions"],
|
||||
is_core=False,
|
||||
routes=[
|
||||
PluginRouteDef(
|
||||
path="/api/v1/my-plugin",
|
||||
@@ -47,40 +300,280 @@ class MyPlugin(BasePlugin):
|
||||
],
|
||||
events=["contact.created", "contact.updated"],
|
||||
migrations=["0001_initial.sql"],
|
||||
permissions=[
|
||||
"my_plugin:read",
|
||||
"my_plugin:write",
|
||||
"my_plugin:delete",
|
||||
permissions=["my_plugin:read", "my_plugin:write", "my_plugin:admin"],
|
||||
field_definitions=[
|
||||
FieldDefinition(
|
||||
module="contacts",
|
||||
field="custom_field",
|
||||
label="Custom Field",
|
||||
sensitivity="normal",
|
||||
),
|
||||
],
|
||||
agent_capabilities=[
|
||||
"my_plugin:search",
|
||||
"my_plugin:analyze",
|
||||
agent_capabilities=["my_plugin:search"],
|
||||
menu_items=[
|
||||
FrontendMenuItem(
|
||||
label_key="nav.myPlugin",
|
||||
label="My Plugin",
|
||||
path="/my-plugin",
|
||||
icon="Sparkles",
|
||||
order=100,
|
||||
),
|
||||
],
|
||||
page_routes=[
|
||||
FrontendPageRoute(
|
||||
path="/my-plugin",
|
||||
component="@/pages/MyPlugin",
|
||||
protected=True,
|
||||
),
|
||||
],
|
||||
detail_tabs=[
|
||||
FrontendDetailTab(
|
||||
entity_type="contact",
|
||||
label_key="tabs.myPlugin",
|
||||
label="My Tab",
|
||||
component="@/components/MyTab",
|
||||
icon="FileText",
|
||||
order=50,
|
||||
permission="my_plugin:read",
|
||||
),
|
||||
],
|
||||
settings_pages=[
|
||||
FrontendSettingsPage(
|
||||
path="my-plugin",
|
||||
label_key="settings.myPlugin",
|
||||
label="My Plugin",
|
||||
component="@/pages/MyPluginSettings",
|
||||
icon="Sparkles",
|
||||
order=100,
|
||||
permission="my_plugin:admin",
|
||||
),
|
||||
],
|
||||
dashboard_widgets=[
|
||||
FrontendDashboardWidget(
|
||||
id="my_plugin_stats",
|
||||
label_key="widgets.myPlugin",
|
||||
label="My Plugin Stats",
|
||||
component="@/components/MyWidget",
|
||||
icon="LayoutDashboard",
|
||||
order=100,
|
||||
col_span=2,
|
||||
row_span=1,
|
||||
permission="my_plugin:read",
|
||||
),
|
||||
],
|
||||
)
|
||||
```
|
||||
|
||||
### Manifest-Felder
|
||||
---
|
||||
|
||||
| Feld | Typ | Pflicht | Beschreibung |
|
||||
|---|---|---|---|
|
||||
| `name` | `str` | Ja | Eindeutiger Plugin-Name (snake_case) |
|
||||
| `version` | `str` | Ja | Semantic Version |
|
||||
| `display_name` | `str` | Ja | Anzeigename |
|
||||
| `description` | `str` | Nein | Kurzbeschreibung |
|
||||
| `dependencies` | `list[str]` | Nein | Andere Plugins, die geladen sein müssen |
|
||||
| `routes` | `list[PluginRouteDef]` | Nein | API-Routen-Definitionen |
|
||||
| `events` | `list[str]` | Nein | Events, die das Plugin abonniert |
|
||||
| `migrations` | `list[str]` | Nein | SQL-Migrationsdateien |
|
||||
| `permissions` | `list[str]` | Nein | RBAC-Permissions, die das Plugin definiert |
|
||||
| `field_definitions` | `list[FieldDefinition]` | Nein | Feld-Level-Permissions |
|
||||
| `agent_capabilities` | `list[str]` | Nein | KI-Agent-Fähigkeiten, die das Plugin bietet |
|
||||
| `is_core` | `bool` | Nein | Core-Plugin (kann nicht deaktiviert werden) |
|
||||
## 4. Plugin Lifecycle
|
||||
|
||||
## 3. RBAC-Permissions
|
||||
### 4.1 Installation (`on_install`)
|
||||
|
||||
### Permissions definieren
|
||||
Called when the plugin is installed, after migrations are run. Override to perform seed data or initial setup.
|
||||
|
||||
Im Manifest werden alle Permissions des Plugins aufgelistet:
|
||||
```python
|
||||
async def on_install(self, db: AsyncSession, service_container: ServiceContainer) -> None:
|
||||
"""Perform initial setup after migrations."""
|
||||
# Create default settings
|
||||
settings_service = service_container.settings
|
||||
await settings_service.create_defaults(db, plugin_name=self.name)
|
||||
```
|
||||
|
||||
### 4.2 Activation (`on_activate`)
|
||||
|
||||
Called when the plugin is activated. Override to register event listeners and prepare runtime state. The default implementation subscribes to events listed in the manifest.
|
||||
|
||||
```python
|
||||
async def on_activate(
|
||||
self, db: AsyncSession, service_container: ServiceContainer, event_bus: EventBus
|
||||
) -> None:
|
||||
"""Register event listeners and prepare runtime state."""
|
||||
# Default: subscribes to manifest events
|
||||
for event_name in self.manifest.events:
|
||||
handler = self._make_event_handler(event_name)
|
||||
self._event_handlers[event_name] = handler
|
||||
event_bus.subscribe(event_name, handler)
|
||||
self._container = service_container
|
||||
```
|
||||
|
||||
### 4.3 Deactivation (`on_deactivate`)
|
||||
|
||||
Called when the plugin is deactivated. Override to clean up runtime state. The default implementation unsubscribes all event listeners.
|
||||
|
||||
```python
|
||||
async def on_deactivate(
|
||||
self, db: AsyncSession, service_container: ServiceContainer, event_bus: EventBus
|
||||
) -> None:
|
||||
"""Clean up runtime state."""
|
||||
for event_name, handler in self._event_handlers.items():
|
||||
event_bus.unsubscribe(event_name, handler)
|
||||
self._event_handlers.clear()
|
||||
```
|
||||
|
||||
### 4.4 Uninstallation (`on_uninstall`)
|
||||
|
||||
Called when the plugin is uninstalled (before data tables are dropped). Override to clean up external resources.
|
||||
|
||||
```python
|
||||
async def on_uninstall(self, db: AsyncSession, service_container: ServiceContainer) -> None:
|
||||
"""Clean up external resources before tables are dropped."""
|
||||
# Remove external API webhooks, etc.
|
||||
pass
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 5. UI Registration
|
||||
|
||||
Plugins contribute frontend UI elements through their manifest. The frontend `PluginRegistry` component fetches active manifests and populates the `pluginStore` (Zustand), which is then consumed by:
|
||||
|
||||
### 5.1 Sidebar Menu Items
|
||||
|
||||
Menu items from all active plugins are merged and sorted by `order` via `getAllMenuItems()`. The sidebar renders them alongside built-in navigation items.
|
||||
|
||||
### 5.2 Page Routes
|
||||
|
||||
Page routes are registered via `PluginRouteRenderer`, a catch-all route handler that checks the current URL against all plugin `page_routes`. When a match is found, it renders the plugin's page component using `PluginPage` (React.lazy + Suspense + ErrorBoundary).
|
||||
|
||||
### 5.3 Detail Tabs
|
||||
|
||||
Detail tabs are filtered by `entity_type` via `getDetailTabsForEntity(entityType)`. Entity detail views (contacts, companies, etc.) render these tabs alongside built-in tabs.
|
||||
|
||||
### 5.4 Settings Pages
|
||||
|
||||
Settings pages are merged and sorted via `getAllSettingsPages()`. The settings navigation renders them alongside built-in settings pages.
|
||||
|
||||
### 5.5 Dashboard Widgets
|
||||
|
||||
Dashboard widgets are merged and sorted via `getAllDashboardWidgets()`. The dashboard grid renders them with their specified `col_span` and `row_span`.
|
||||
|
||||
### 5.6 Frontend Component Resolution
|
||||
|
||||
Component paths use the `@/` alias (resolved to `src/` by Vite). The `PluginPage` component converts `@/pages/MyPlugin` to `../pages/MyPlugin` for dynamic import:
|
||||
|
||||
```typescript
|
||||
// PluginLoader.tsx
|
||||
const importPath = componentPath.replace(/^@\//, '../');
|
||||
const LazyComp = lazy(() =>
|
||||
import(/* @vite-ignore */ importPath).then((m) => ({
|
||||
default: m.default || m[Object.keys(m)[0]],
|
||||
}))
|
||||
);
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 6. Event Bus
|
||||
|
||||
### 6.1 Subscribing to Events
|
||||
|
||||
Events are declared in the manifest and handled by methods named `on_<event_name>` with dots replaced by underscores:
|
||||
|
||||
```python
|
||||
# Manifest
|
||||
events=["contact.created", "contact.updated"]
|
||||
|
||||
# Handler methods
|
||||
async def on_contact_created(self, event_data: dict):
|
||||
contact_id = event_data.get("contact_id")
|
||||
# React to new contact
|
||||
|
||||
async def on_contact_updated(self, event_data: dict):
|
||||
contact_id = event_data.get("contact_id")
|
||||
# React to contact update
|
||||
```
|
||||
|
||||
### 6.2 Publishing Events
|
||||
|
||||
Events are published via the EventBus service:
|
||||
|
||||
```python
|
||||
from app.core.event_bus import get_event_bus
|
||||
|
||||
event_bus = get_event_bus()
|
||||
await event_bus.publish("contact.created", {"contact_id": str(contact.id)})
|
||||
```
|
||||
|
||||
### 6.3 Event Naming Conventions
|
||||
|
||||
- Format: `<entity>.<action>`
|
||||
- Standard actions: `created`, `updated`, `deleted`, `assigned`, `completed`
|
||||
- Examples: `company.created`, `task.assigned`, `email.sent`
|
||||
- Use past tense for actions
|
||||
|
||||
---
|
||||
|
||||
## 7. Migration Runner
|
||||
|
||||
### 7.1 Writing Migrations
|
||||
|
||||
SQL migration files are stored in the plugin's `migrations/` directory and referenced in the manifest:
|
||||
|
||||
```sql
|
||||
-- migrations/0001_initial.sql
|
||||
CREATE TABLE my_plugin_items (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
tenant_id UUID NOT NULL REFERENCES tenants(id),
|
||||
name VARCHAR(200) NOT NULL,
|
||||
config JSONB DEFAULT '{}',
|
||||
is_active BOOLEAN DEFAULT true,
|
||||
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
|
||||
updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW()
|
||||
);
|
||||
|
||||
CREATE INDEX idx_my_plugin_items_tenant ON my_plugin_items(tenant_id);
|
||||
```
|
||||
|
||||
### 7.2 Migration Naming
|
||||
|
||||
- Files are named with a zero-padded sequence number and a descriptive slug: `0001_initial.sql`, `0002_add_indexes.sql`
|
||||
- Migrations run in alphanumeric order
|
||||
- Each migration runs exactly once per plugin installation
|
||||
|
||||
### 7.3 Migration Guidelines
|
||||
|
||||
- Always include `tenant_id` for multi-tenant tables
|
||||
- Use `UUID` primary keys with `gen_random_uuid()`
|
||||
- Include `created_at` and `updated_at` timestamps
|
||||
- Add appropriate indexes for foreign keys and frequently queried columns
|
||||
- Use `IF NOT EXISTS` / `IF EXISTS` for idempotent operations
|
||||
|
||||
---
|
||||
|
||||
## 8. Service Container
|
||||
|
||||
After activation, plugins can access shared services via `self.services`:
|
||||
|
||||
```python
|
||||
class MyPlugin(BasePlugin):
|
||||
async def on_activate(self, db, service_container, event_bus):
|
||||
self._container = service_container
|
||||
|
||||
async def do_something(self):
|
||||
# Access services after activation
|
||||
settings = self.services.settings
|
||||
audit = self.services.audit
|
||||
cache = self.services.cache
|
||||
```
|
||||
|
||||
Available services (defined in `app/core/service_container.py`):
|
||||
|
||||
| Service | Accessor | Description |
|
||||
|---|---|---|
|
||||
| Settings | `self.services.settings` | Global and per-tenant settings |
|
||||
| Audit | `self.services.audit` | Audit logging |
|
||||
| Cache | `self.services.cache` | Redis/In-memory cache |
|
||||
| EventBus | `self.services.event_bus` | Event publishing/subscribing |
|
||||
| Notifications | `self.services.notifications` | User notifications |
|
||||
|
||||
---
|
||||
|
||||
## 9. RBAC / Permissions
|
||||
|
||||
### 9.1 Declaring Permissions
|
||||
|
||||
Permissions are declared in the manifest:
|
||||
|
||||
```python
|
||||
permissions=[
|
||||
@@ -88,12 +581,12 @@ permissions=[
|
||||
"my_plugin:write",
|
||||
"my_plugin:delete",
|
||||
"my_plugin:admin",
|
||||
],
|
||||
]
|
||||
```
|
||||
|
||||
### Routes absichern
|
||||
### 9.2 Securing Routes
|
||||
|
||||
Jede Route muss mit `require_permission` abgesichert werden:
|
||||
Use the `require_permission` dependency:
|
||||
|
||||
```python
|
||||
from app.deps import get_current_user, require_permission
|
||||
@@ -106,72 +599,48 @@ async def list_items(current_user: dict = Depends(get_current_user)):
|
||||
@router.post("", status_code=201, dependencies=[Depends(require_permission("my_plugin:write"))])
|
||||
async def create_item(data: ItemCreate, current_user: dict = Depends(get_current_user)):
|
||||
...
|
||||
|
||||
@router.delete("/{item_id}", dependencies=[Depends(require_permission("my_plugin:delete"))])
|
||||
async def delete_item(item_id: str, current_user: dict = Depends(get_current_user)):
|
||||
...
|
||||
```
|
||||
|
||||
### Permission-Namenskonvention
|
||||
### 9.3 Permission Naming Convention
|
||||
|
||||
- Format: `<plugin_name>:<action>`
|
||||
- Standard-Actions: `read`, `write`, `delete`, `share`, `admin`
|
||||
- Beispiele: `calendar:read`, `dms:write`, `tags:delete`
|
||||
- Standard actions: `read`, `write`, `delete`, `share`, `admin`
|
||||
- Examples: `calendar:read`, `dms:write`, `tags:delete`
|
||||
|
||||
## 4. KI-Agent-Framework
|
||||
### 9.4 Field-Level Permissions
|
||||
|
||||
LeoCRM bietet ein integriertes KI-Agent-Framework basierend auf **LiteLLM** und **PydanticAI**.
|
||||
|
||||
### Architektur
|
||||
|
||||
```
|
||||
Plugin (ai_assistant, ai_proactive, zukünftige)
|
||||
↓
|
||||
LiteLLM (unified LLM interface — 100+ Provider)
|
||||
↓
|
||||
Provider (OpenAI, Anthropic, Google, Ollama, ...)
|
||||
↑
|
||||
Tool Registry (Plugin-Tools für KI-Agenten)
|
||||
```
|
||||
|
||||
### LiteLLM — Unified LLM Interface
|
||||
|
||||
LiteLLM bietet eine einheitliche API für über 100 LLM-Provider. Alle KI-Funktionen
|
||||
in LeoCRM nutzen `litellm.acompletion()`:
|
||||
Field definitions in the manifest enable field-level access control:
|
||||
|
||||
```python
|
||||
import litellm
|
||||
|
||||
response = await litellm.acompletion(
|
||||
model="openai/gpt-4o", # oder anthropic/claude-3-sonnet, ollama/llama3
|
||||
messages=[
|
||||
{"role": "system", "content": system_prompt},
|
||||
{"role": "user", "content": user_query},
|
||||
],
|
||||
temperature=0.3,
|
||||
max_tokens=1000,
|
||||
api_key=os.environ.get("AI_API_KEY"),
|
||||
api_base=os.environ.get("AI_API_BASE"), # optional für self-hosted
|
||||
)
|
||||
content = response.choices[0].message.content
|
||||
field_definitions=[
|
||||
FieldDefinition(
|
||||
module="companies",
|
||||
field="annual_revenue",
|
||||
label="Annual Revenue",
|
||||
sensitivity="sensitive", # normal|sensitive|critical
|
||||
),
|
||||
]
|
||||
```
|
||||
|
||||
### Konfiguration
|
||||
---
|
||||
|
||||
| Env-Var | Beschreibung | Standard |
|
||||
|---|---|---|
|
||||
| `AI_MODEL` | Modell-Name (z.B. `gpt-4o`, `claude-3-sonnet`, `llama3`) | — |
|
||||
| `AI_API_KEY` | API-Key für den Provider | — |
|
||||
| `AI_API_BASE` | Custom API-Base-URL (optional) | Provider-Standard |
|
||||
| `AI_PROVIDER` | Provider-Präfix (`openai`, `anthropic`, `google`, `ollama`) | `openai` |
|
||||
## 10. AI Agent Integration
|
||||
|
||||
Wenn `AI_MODEL` und `AI_API_KEY` nicht gesetzt sind, läuft der LLM-Client im Mock-Modus
|
||||
(keyword-basierte Action-Mapping für Tests).
|
||||
### 10.1 Agent Capabilities
|
||||
|
||||
### Tool Registry — KI-Tools registrieren
|
||||
Declare AI capabilities in the manifest:
|
||||
|
||||
Plugins können Tools registrieren, die KI-Agenten während Chat-Sessions aufrufen können.
|
||||
Jedes Tool deklariert Name, Beschreibung, JSON-Schema für Parameter und einen async Handler.
|
||||
```python
|
||||
agent_capabilities=[
|
||||
"contact_search",
|
||||
"email_draft",
|
||||
"calendar_scheduling",
|
||||
]
|
||||
```
|
||||
|
||||
### 10.2 Tool Registry
|
||||
|
||||
Plugins can register tools for the AI assistant:
|
||||
|
||||
```python
|
||||
from app.plugins.builtins.ai_assistant.tool_registry import get_tool_registry
|
||||
@@ -196,94 +665,31 @@ registry.register(
|
||||
)
|
||||
```
|
||||
|
||||
### Tool Handler
|
||||
|
||||
Der Handler ist eine async Funktion, die Argumente und Kontext empfängt:
|
||||
### 10.3 Tool Handler
|
||||
|
||||
```python
|
||||
async def my_search_handler(arguments: dict, context: dict) -> str:
|
||||
query = arguments.get("query", "")
|
||||
limit = arguments.get("limit", 10)
|
||||
# ... perform search ...
|
||||
# Perform search...
|
||||
return json.dumps({"results": results})
|
||||
```
|
||||
|
||||
### Tools bei Plugin-Deaktivierung abmelden
|
||||
### 10.4 Cleanup on Deactivation
|
||||
|
||||
```python
|
||||
def on_deactivate(self):
|
||||
async def on_deactivate(self, db, service_container, event_bus):
|
||||
registry = get_tool_registry()
|
||||
registry.unregister_plugin("my_plugin")
|
||||
```
|
||||
|
||||
### Agent Capabilities im Manifest
|
||||
---
|
||||
|
||||
Das `agent_capabilities` Feld im Manifest deklariert, welche KI-Fähigkeiten ein Plugin bietet:
|
||||
## 11. Testing Guide
|
||||
|
||||
```python
|
||||
agent_capabilities=[
|
||||
"contact_search", # Kontakt-Suche
|
||||
"email_draft", # E-Mail-Entwürfe generieren
|
||||
"calendar_scheduling", # Terminvorschläge
|
||||
],
|
||||
```
|
||||
### 11.1 Backend Tests
|
||||
|
||||
Diese Informationen werden vom AI Assistant verwendet, um Nutzern zu zeigen,
|
||||
welche KI-Funktionen verfügbar sind.
|
||||
|
||||
## 5. Events
|
||||
|
||||
Plugins können Events abonnieren und auslösen:
|
||||
|
||||
```python
|
||||
# Im Manifest:
|
||||
events=["contact.created", "contact.updated", "contact.deleted"]
|
||||
|
||||
# Event-Handler im Plugin:
|
||||
async def on_contact_created(self, event_data: dict):
|
||||
# Reagiere auf neues Kontakt-Event
|
||||
pass
|
||||
```
|
||||
|
||||
Events werden vom Event-Publisher im Contact-Service ausgelöst:
|
||||
|
||||
```python
|
||||
from app.core.events import publish_event
|
||||
await publish_event(db, "contact.created", {"contact_id": str(contact.id)})
|
||||
```
|
||||
|
||||
## 6. Datenbank-Migrationen
|
||||
|
||||
SQL-Migrationen liegen unter `migrations/` im Plugin-Verzeichnis:
|
||||
|
||||
```sql
|
||||
-- migrations/0001_initial.sql
|
||||
CREATE TABLE my_plugin_items (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
tenant_id UUID NOT NULL REFERENCES tenants(id),
|
||||
name VARCHAR(200) NOT NULL,
|
||||
created_at TIMESTAMP DEFAULT NOW(),
|
||||
updated_at TIMESTAMP DEFAULT NOW()
|
||||
);
|
||||
```
|
||||
|
||||
Im Manifest referenzieren:
|
||||
```python
|
||||
migrations=["0001_initial.sql"]
|
||||
```
|
||||
|
||||
## 7. UI-Integration
|
||||
|
||||
Siehe `docs/ui-design-guidelines.md` für Frontend-Konventionen.
|
||||
|
||||
- Plugin-Seiten verwenden das 3-Spalten-Explorer-Layout
|
||||
- PluginToolbar für Aktionen
|
||||
- Plugin-Settings als eigene Settings-Sub-Seite
|
||||
- i18n-Keys mit Plugin-Präfix
|
||||
|
||||
## 8. Testing
|
||||
|
||||
Tests liegen unter `tests/` im Plugin-Verzeichnis oder im zentralen `tests/` Ordner:
|
||||
Tests live in the plugin's `tests/` directory or in the central `tests/` folder:
|
||||
|
||||
```python
|
||||
# tests/test_my_plugin.py
|
||||
@@ -301,48 +707,147 @@ async def test_list_items_without_permission_returns_403(client: AsyncClient, no
|
||||
assert response.status_code == 403
|
||||
```
|
||||
|
||||
## 9. Plugin-Beispiel
|
||||
### 11.2 Frontend Tests
|
||||
|
||||
Minimal-Beispiel für ein neues Plugin:
|
||||
Frontend tests use vitest, @testing-library/react, and jsdom:
|
||||
|
||||
```typescript
|
||||
// frontend/src/components/plugins/__tests__/PluginRegistry.test.tsx
|
||||
import React from 'react';
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import { render } from '@testing-library/react';
|
||||
import { PluginRegistry } from '../PluginRegistry';
|
||||
import { usePluginStore } from '@/store/pluginStore';
|
||||
|
||||
// Mock the API hook
|
||||
vi.mock('@/api/pluginManifests', () => ({
|
||||
useActivePluginManifests: () => ({
|
||||
data: { plugins: [/* ... */], total: 1 },
|
||||
isLoading: false,
|
||||
error: null,
|
||||
}),
|
||||
}));
|
||||
|
||||
describe('PluginRegistry', () => {
|
||||
beforeEach(() => {
|
||||
usePluginStore.getState().reset();
|
||||
});
|
||||
|
||||
it('populates store with manifests on mount', () => {
|
||||
render(<PluginRegistry />);
|
||||
const manifests = usePluginStore.getState().manifests;
|
||||
expect(manifests).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 12. Do's and Don'ts
|
||||
|
||||
### Do's
|
||||
|
||||
- **Do** use snake_case for plugin names
|
||||
- **Do** declare all permissions in the manifest
|
||||
- **Do** secure every API route with `require_permission`
|
||||
- **Do** include `tenant_id` in all multi-tenant tables
|
||||
- **Do** use UUID primary keys
|
||||
- **Do** prefix i18n keys with the plugin name
|
||||
- **Do** clean up resources in `on_deactivate` and `on_uninstall`
|
||||
- **Do** write tests for both backend and frontend
|
||||
- **Do** follow the event naming convention `<entity>.<action>`
|
||||
- **Do** use semantic versioning for plugin versions
|
||||
|
||||
### Don'ts
|
||||
|
||||
- **Don't** hardcode tenant IDs or user IDs
|
||||
- **Don't** use synchronous database operations
|
||||
- **Don't** store secrets in the database without encryption
|
||||
- **Don't** modify other plugins' data directly (use events instead)
|
||||
- **Don't** create circular dependencies between plugins
|
||||
- **Don't** use `is_core=True` unless the plugin is essential for system operation
|
||||
- **Don't** skip error handling in event handlers (they run asynchronously)
|
||||
- **Don't** register the same route path in multiple plugins
|
||||
|
||||
---
|
||||
|
||||
## 13. Examples
|
||||
|
||||
### 13.1 Minimal Plugin
|
||||
|
||||
```python
|
||||
# app/plugins/builtins/my_plugin/plugin.py
|
||||
# app/plugins/builtins/minimal_example/plugin.py
|
||||
from app.plugins.base import BasePlugin
|
||||
from app.plugins.manifest import PluginManifest, PluginRouteDef
|
||||
|
||||
class MyPlugin(BasePlugin):
|
||||
class MinimalExamplePlugin(BasePlugin):
|
||||
manifest = PluginManifest(
|
||||
name="my_plugin",
|
||||
name="minimal_example",
|
||||
version="1.0.0",
|
||||
display_name="My Plugin",
|
||||
display_name="Minimal Example",
|
||||
description="A minimal example plugin.",
|
||||
dependencies=[],
|
||||
routes=[
|
||||
PluginRouteDef(
|
||||
path="/api/v1/my-plugin",
|
||||
module="app.plugins.builtins.my_plugin.routes",
|
||||
path="/api/v1/minimal-example",
|
||||
module="app.plugins.builtins.minimal_example.routes",
|
||||
router_attr="router",
|
||||
),
|
||||
],
|
||||
events=[],
|
||||
migrations=[],
|
||||
permissions=["my_plugin:read", "my_plugin:write"],
|
||||
agent_capabilities=[],
|
||||
permissions=["minimal_example:read"],
|
||||
)
|
||||
```
|
||||
|
||||
```python
|
||||
# app/plugins/builtins/my_plugin/routes.py
|
||||
# app/plugins/builtins/minimal_example/routes.py
|
||||
from fastapi import APIRouter, Depends
|
||||
from app.deps import get_current_user, require_permission
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
@router.get("", dependencies=[Depends(require_permission("my_plugin:read"))])
|
||||
@router.get("", dependencies=[Depends(require_permission("minimal_example:read"))])
|
||||
async def list_items(current_user: dict = Depends(get_current_user)):
|
||||
return {"items": []}
|
||||
```
|
||||
|
||||
### 13.2 Plugin with UI
|
||||
|
||||
See the complete manifest example in Section 3.9 for a plugin with full UI contributions (menu items, page routes, detail tabs, settings pages, dashboard widgets).
|
||||
|
||||
### 13.3 Plugin with Events
|
||||
|
||||
```python
|
||||
# app/plugins/builtins/event_example/plugin.py
|
||||
from app.plugins.base import BasePlugin
|
||||
from app.plugins.manifest import PluginManifest
|
||||
|
||||
class EventExamplePlugin(BasePlugin):
|
||||
manifest = PluginManifest(
|
||||
name="event_example",
|
||||
version="1.0.0",
|
||||
display_name="Event Example",
|
||||
description="Demonstrates event handling.",
|
||||
dependencies=[],
|
||||
events=["contact.created", "contact.updated", "contact.deleted"],
|
||||
migrations=[],
|
||||
permissions=[],
|
||||
)
|
||||
|
||||
async def on_contact_created(self, event_data: dict):
|
||||
contact_id = event_data.get("contact_id")
|
||||
print(f"Contact created: {contact_id}")
|
||||
|
||||
async def on_contact_updated(self, event_data: dict):
|
||||
contact_id = event_data.get("contact_id")
|
||||
print(f"Contact updated: {contact_id}")
|
||||
|
||||
async def on_contact_deleted(self, event_data: dict):
|
||||
contact_id = event_data.get("contact_id")
|
||||
print(f"Contact deleted: {contact_id}")
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
*Dieses Dokument ist verbindlich für alle Plugin-Entwicklung an LeoCRM.*
|
||||
*This document is authoritative for all plugin development at LeoCRM.*
|
||||
|
||||
Reference in New Issue
Block a user