Spaces:
Build error
Build error
| # Backend Plugin System Architecture | |
| Technical architecture documentation for **backend plugins** in the PDF-TEI Editor. | |
| **Note**: This guide covers **backend plugins** (Python code running on the server). For **frontend plugins** (JavaScript code running in the browser), see [plugin-system-frontend.md](plugin-system-frontend.md). For practical backend plugin development, see [Backend Plugin Development Guide](../code-assistant/backend-plugins.md). | |
| **Key Differences**: | |
| - **Backend plugins**: Python modules in `fastapi_app/plugins/` that provide server-side functionality and API endpoints | |
| - **Frontend plugins**: JavaScript classes in `app/src/plugins/` that extend the UI and handle client-side logic | |
| ## Architecture | |
| Backend plugins are Python modules discovered at runtime from: | |
| - `fastapi_app/plugins/<plugin_id>/` | |
| - Paths in `FASTAPI_PLUGIN_PATHS` environment variable (colon-separated) | |
| Each plugin: | |
| - Inherits from `fastapi_app.lib.plugin_base.Plugin` | |
| - Defines metadata (id, name, description, category, version, required_roles) | |
| - Implements endpoints as async methods | |
| - Has access to `PluginContext` for app state and user info | |
| ## Creating a Plugin | |
| ### Directory Structure | |
| **Use underscores in directory names** (not hyphens) to avoid Python import issues: | |
| ```text | |
| fastapi_app/plugins/my_plugin/ | |
| ├── __init__.py | |
| ├── plugin.py # Main plugin class | |
| ├── routes.py # Optional custom routes | |
| ├── my-script.js # Optional: frontend JavaScript | |
| ├── html/ # Optional: static files (auto-mounted) | |
| │ ├── styles.css | |
| │ └── template.xslt | |
| └── tests/ # Plugin tests | |
| ├── test_plugin.py # Python unit tests | |
| └── script.test.js # JavaScript unit tests (if applicable) | |
| ``` | |
| ### Static File Serving | |
| Plugins can serve static files (CSS, JS, XSLT, images) by placing them in an `html/` subdirectory. The `PluginManager` automatically mounts this directory during plugin discovery. | |
| **Pattern:** | |
| - Put files in: `fastapi_app/plugins/{plugin_id}/html/` | |
| - Files are served at: `/api/plugins/{plugin_id}/static/` | |
| **Example:** | |
| ```text | |
| fastapi_app/plugins/grobid/html/bibl-struct.xslt | |
| → Served at: /api/plugins/grobid/static/bibl-struct.xslt | |
| ``` | |
| **Use cases:** | |
| - XSLT stylesheets for XML transformations | |
| - CSS/JS assets for plugin HTML pages | |
| - Static templates or data files | |
| **Frontend access:** | |
| ```javascript | |
| // In frontend extension | |
| const xsltString = await sandbox.fetchText('/api/plugins/grobid/static/bibl-struct.xslt'); | |
| ``` | |
| **Implementation:** See `PluginManager._try_mount_plugin_static_files()` in [plugin_manager.py](../../fastapi_app/lib/plugins/plugin_manager.py). | |
| **Test Discovery**: The smart test runner automatically discovers tests in plugin `tests/` directories. Use `@testCovers` annotations to link tests to plugin files for dependency-based test execution. | |
| **Example Test Annotation**: | |
| ```python | |
| """ | |
| Unit tests for My Plugin. | |
| @testCovers fastapi_app/plugins/my_plugin/plugin.py | |
| """ | |
| ``` | |
| ### Minimal Plugin | |
| ```python | |
| # plugin.py | |
| from fastapi_app.lib.plugins.plugin_base import Plugin | |
| from typing import Any | |
| class MyPlugin(Plugin): | |
| @property | |
| def metadata(self) -> dict[str, Any]: | |
| return { | |
| "id": "my-plugin", | |
| "name": "My Plugin", | |
| "description": "What it does", | |
| "category": "analyzer", # Used for UI grouping | |
| "version": "1.0.0", | |
| "required_roles": ["user"] # or ["*"] for all | |
| } | |
| def get_endpoints(self) -> dict[str, callable]: | |
| return { | |
| "execute": self.execute, | |
| "info": self.get_info | |
| } | |
| async def execute(self, context, params: dict) -> dict: | |
| """Main execution endpoint""" | |
| # Access user: context.user | |
| # Access app: context.app | |
| return {"result": "data"} | |
| async def get_info(self, context, params: dict) -> dict: | |
| """Info endpoint""" | |
| return {"plugin": self.metadata["id"]} | |
| ``` | |
| ### Plugin Registration | |
| In `__init__.py`: | |
| ```python | |
| from .plugin import MyPlugin | |
| plugin = MyPlugin() | |
| ``` | |
| ## Conditional Availability | |
| Plugins can define runtime availability conditions using the `is_available()` class method. This allows plugins to be conditionally loaded based on: | |
| - Environment variables (e.g., application mode) | |
| - External dependencies | |
| - Configuration settings | |
| - System capabilities | |
| ```python | |
| import os | |
| from fastapi_app.lib.plugins.plugin_base import Plugin | |
| class MyPlugin(Plugin): | |
| # ... metadata and endpoints ... | |
| @classmethod | |
| def is_available(cls) -> bool: | |
| """Only available in development and testing modes.""" | |
| app_mode = os.environ.get("FASTAPI_APPLICATION_MODE", "development") | |
| return app_mode in ("development", "testing") | |
| ``` | |
| **When to Use:** | |
| - Development/testing-only plugins (like sample_analyzer) | |
| - Plugins requiring optional external services | |
| - Feature-flagged functionality | |
| - Environment-specific tools | |
| **Behavior:** | |
| - Unavailable plugins are skipped during discovery (not registered) | |
| - Default implementation returns `True` (always available) | |
| - Checked once at startup during plugin discovery | |
| **Example - Mock Extractor Pattern:** | |
| Similar to [mock_extractor.py](../../fastapi_app/extractors/mock_extractor.py): | |
| ```python | |
| @classmethod | |
| def is_available(cls) -> bool: | |
| """Available only in testing mode.""" | |
| app_mode = os.environ.get("FASTAPI_APPLICATION_MODE", "development") | |
| return app_mode == "testing" | |
| ``` | |
| ## Plugin Configuration with Environment Variables | |
| Plugins often need configuration that can be set via environment variables or config keys. | |
| ### Initialization Pattern | |
| **Initialize configuration values at plugin registration time** in `__init__.py`. This ensures config keys are created from environment variables when the plugin is loaded: | |
| ```python | |
| # __init__.py | |
| from fastapi_app.lib.plugins.plugin_tools import get_plugin_config | |
| # Initialize config values from environment variables | |
| get_plugin_config("plugin.my-plugin.enabled", "MY_PLUGIN_ENABLED", default=False, value_type="boolean") | |
| get_plugin_config("plugin.my-plugin.api-key", "MY_PLUGIN_API_KEY", default=None) | |
| get_plugin_config("plugin.my-plugin.timeout", "MY_PLUGIN_TIMEOUT", default=30, value_type="number") | |
| from .plugin import MyPlugin | |
| plugin = MyPlugin() | |
| ``` | |
| **Access configuration in plugin methods** using `get_config()`: | |
| ```python | |
| # plugin.py | |
| from fastapi_app.lib.utils.config_utils import get_config | |
| class MyPlugin(Plugin): | |
| async def execute(self, context, params: dict) -> dict: | |
| config = get_config() | |
| api_key = config.get("plugin.my-plugin.api-key") | |
| timeout = config.get("plugin.my-plugin.timeout", default=30) | |
| # Use config values... | |
| ``` | |
| **Access configuration in custom routes** using `get_config()`: | |
| ```python | |
| # routes.py | |
| from fastapi_app.lib.utils.config_utils import get_config | |
| @router.get("/action") | |
| async def custom_action(): | |
| config = get_config() | |
| api_key = config.get("plugin.my-plugin.api-key") | |
| # Use config values... | |
| ``` | |
| **Priority**: Config file (`data/db/config.json`) > Environment variable > Default value | |
| **Key points**: | |
| - Initialize config in `__init__.py` using `get_plugin_config()` (creates keys from env vars) | |
| - Access config everywhere else using `get_config()` (retrieves existing keys) | |
| - Config values are automatically created from environment variables on first initialization | |
| - Routes and plugin methods use the same `get_config()` pattern | |
| **Example - Plugin availability based on config**: | |
| ```python | |
| @classmethod | |
| def is_available(cls) -> bool: | |
| """Only available if enabled in config.""" | |
| from fastapi_app.lib.plugins.plugin_tools import get_plugin_config | |
| enabled = get_plugin_config( | |
| "plugin.my-plugin.enabled", | |
| "MY_PLUGIN_ENABLED", | |
| default=False, | |
| value_type="boolean" | |
| ) | |
| if not enabled: | |
| return False | |
| # Check if required configuration is present | |
| api_key = get_plugin_config( | |
| "plugin.my-plugin.api-key", | |
| "MY_PLUGIN_API_KEY", | |
| default=None | |
| ) | |
| if not api_key: | |
| return False | |
| return True | |
| ``` | |
| **Reference Implementation**: See [local_sync plugin](../../fastapi_app/plugins/local_sync) for complete example. | |
| ## Service Registry | |
| Plugins can register and consume services by capability name, enabling loose coupling between plugins. Instead of depending on a specific plugin, consumers request services by what they do (e.g., `"structured-data-extraction"`). | |
| ```python | |
| # Register a service during initialization | |
| from fastapi_app.lib.services.service_registry import get_service_registry | |
| service_registry = get_service_registry() | |
| service_registry.register_service(MyExtractionService()) | |
| # Consume a service by capability | |
| service = context.get_service("structured-data-extraction", ExtractionService) | |
| if service: | |
| result = await service.extract(model="model-id", prompt="Extract data", ...) | |
| ``` | |
| See [Service Registry](service-registry.md) for complete documentation. | |
| ## Role-Based Access | |
| - `required_roles: ["admin"]` - Only admin users | |
| - `required_roles: ["user"]` - Any authenticated user | |
| - `required_roles: ["*"]` - Everyone (including anonymous) | |
| - `required_roles: []` - Everyone (including anonymous) | |
| Wildcard `*` in user roles grants access to all plugins. | |
| ## Frontend Integration | |
| Plugins appear in toolbar dropdown, organized by category. Frontend calls: | |
| ```javascript | |
| // List plugins (role-filtered) | |
| const plugins = await api.getBackendPlugins(); | |
| // Execute plugin | |
| const result = await api.executeBackendPlugin( | |
| 'my-plugin', | |
| 'execute', | |
| { param: 'value' } | |
| ); | |
| ``` | |
| ### Multi-Endpoint Menu Support | |
| Plugins can define multiple menu entries, each calling a different endpoint with different parameters from the application state: | |
| ```python | |
| @property | |
| def metadata(self) -> dict[str, Any]: | |
| return { | |
| "id": "my-analyzer", | |
| "name": "Document Analyzer", | |
| "description": "Analyzes documents", | |
| "version": "1.0.0", | |
| "category": "analyzer", | |
| "required_roles": ["user"], | |
| "endpoints": [ | |
| { | |
| "name": "analyze", | |
| "label": "Analyze Current XML", | |
| "description": "Analyze currently open XML document", | |
| "state_params": ["xml", "variant"] | |
| }, | |
| { | |
| "name": "analyze_all", | |
| "label": "Analyze All Documents", | |
| "description": "Run analysis on all documents", | |
| "state_params": [] | |
| }, | |
| { | |
| "name": "info", | |
| "label": "Plugin Info", | |
| "description": "Get plugin information", | |
| "state_params": [] | |
| } | |
| ] | |
| } | |
| ``` | |
| **Endpoint Definition Fields:** | |
| - `name` (required): Endpoint method name (must match key in `get_endpoints()`) | |
| - `label` (required): Display label for menu item | |
| - `description` (optional): Tooltip text | |
| - `state_params` (required): List of state fields to pass as parameters (see [app/src/state.js](../../app/src/state.js)) | |
| **Available State Parameters:** | |
| - `pdf` - PDF document ID | |
| - `xml` - XML document ID | |
| - `diff` - Diff XML document ID | |
| - `xpath` - Current XPath selection | |
| - `variant` - Variant filter | |
| - `collection` - Current collection ID | |
| - Other fields from `ApplicationState` typedef | |
| **Backward Compatibility:** | |
| - If `endpoints` not defined: Single menu item calls `execute` endpoint | |
| - If `endpoints` is empty array: Plugin appears in list but adds no menu items | |
| **Example with State Parameters:** | |
| ```python | |
| async def analyze(self, context, params: dict) -> dict: | |
| """Analyze XML document from state parameters""" | |
| xml_id = params.get("xml") # Passed from frontend state | |
| variant = params.get("variant") # Passed from frontend state | |
| if xml_id: | |
| # Load and analyze the XML file | |
| from fastapi_app.lib.core.dependencies import get_db, get_file_storage | |
| from fastapi_app.lib.repository.file_repository import FileRepository | |
| db = get_db() | |
| file_repo = FileRepository(db) | |
| file_storage = get_file_storage() | |
| file_metadata = file_repo.get_file_by_id_or_stable_id(xml_id) | |
| if file_metadata and file_metadata.file_type == "tei": | |
| content_bytes = file_storage.read_file(file_metadata.id, "tei") | |
| text = content_bytes.decode("utf-8") | |
| # Perform analysis on text | |
| return {"analysis": "result"} | |
| return {"error": "No XML document open"} | |
| ``` | |
| ## API Endpoints | |
| - `GET /api/v1/plugins` - List plugins (filtered by user roles) | |
| - `POST /api/v1/plugins/{plugin_id}/execute` - Execute endpoint | |
| Request body: | |
| ```json | |
| { | |
| "endpoint": "execute", | |
| "params": {"key": "value"} | |
| } | |
| ``` | |
| ## Custom Routes (Optional) | |
| Add `routes.py` for custom FastAPI routes. Plugin routes use the unversioned `/api/plugins` prefix: | |
| ```python | |
| from fastapi import APIRouter | |
| # Router prefix uses unversioned /api/plugins | |
| router = APIRouter(prefix="/api/plugins/my-plugin", tags=["my-plugin"]) | |
| @router.get("/custom") | |
| async def custom_route(): | |
| return {"custom": "data"} | |
| ``` | |
| This creates the endpoint at `/api/plugins/my-plugin/custom`. | |
| **Path Requirements:** | |
| - Router prefix should be `/api/plugins/{plugin-id}` (unversioned) | |
| - The router is registered directly on the app (not under the versioned api_v1 router) | |
| - Plugin routes are unversioned and independent of the main API versioning | |
| - Plugin routes are excluded from the generated API client (`api-client-v1.js`) | |
| **Frontend Access:** | |
| Plugin routes should be called using the `callPluginApi` method from `BackendPluginsPlugin`, not the main `callApi` function: | |
| ```javascript | |
| // In a plugin or component that has access to BackendPluginsPlugin | |
| const backendPluginsPlugin = /* get BackendPluginsPlugin instance */; | |
| // GET request with query params | |
| const response = await backendPluginsPlugin.callPluginApi( | |
| '/api/plugins/my-plugin/custom', | |
| 'GET', | |
| { param1: 'value1', param2: 'value2' } | |
| ); | |
| // POST request with JSON body | |
| const response = await backendPluginsPlugin.callPluginApi( | |
| '/api/plugins/my-plugin/action', | |
| 'POST', | |
| { data: 'value' } | |
| ); | |
| // Handle different response types | |
| const jsonData = await response.json(); // For JSON responses | |
| const blob = await response.blob(); // For file downloads | |
| const text = await response.text(); // For text responses | |
| ``` | |
| The `callPluginApi` method: | |
| - Automatically adds authentication headers (`X-Session-ID`) | |
| - Handles query parameters for GET requests | |
| - Handles JSON body for POST/PUT/etc requests | |
| - Returns the raw `Response` object for flexible response handling | |
| - Throws errors for non-OK responses | |
| ## Key Files | |
| - [fastapi_app/lib/plugins/plugin_base.py](../../fastapi_app/lib/plugins/plugin_base.py) - Base classes | |
| - [fastapi_app/lib/plugins/plugin_registry.py](../../fastapi_app/lib/plugins/plugin_registry.py) - Discovery | |
| - [fastapi_app/lib/plugins/plugin_manager.py](../../fastapi_app/lib/plugins/plugin_manager.py) - Lifecycle | |
| - [fastapi_app/routes/plugins.py](../../fastapi_app/routes/plugins.py) - API routes | |
| - [app/src/plugins/backend-plugins.js](../../app/src/plugins/backend-plugins.js) - Frontend integration | |
| ## Plugin Response Formats | |
| Backend plugins can return results in three formats depending on the complexity and interaction requirements: | |
| ### 1. Inline HTML (for simple content) | |
| Use the `html` field for short, simple text results that fit comfortably in the dialog: | |
| ```python | |
| async def execute(self, context, params: dict) -> dict: | |
| """Return simple HTML content.""" | |
| return { | |
| "html": "<p>Analysis complete. Found 42 matches.</p>" | |
| } | |
| ``` | |
| **When to use:** | |
| - Short text results (a few paragraphs) | |
| - Simple lists or small tables | |
| - Quick status messages or summaries | |
| - Content that doesn't need extensive formatting | |
| ### 2. Standalone Pages (for complex content) | |
| Use the `outputUrl` field for complex, tabular data, or content requiring JavaScript libraries: | |
| ```python | |
| async def execute(self, context, params: dict) -> dict: | |
| """Return URL to standalone page.""" | |
| collection_id = params.get("collection") | |
| variant = params.get("variant") | |
| # Build URL to custom route that returns complete HTML page | |
| view_url = f"/api/plugins/my-plugin/view?collection={collection_id}&variant={variant}" | |
| export_url = f"/api/plugins/my-plugin/export?collection={collection_id}&variant={variant}" | |
| return { | |
| "outputUrl": view_url, # Displayed in iframe | |
| "exportUrl": export_url, # Optional: enable export button | |
| "collection": collection_id # Optional: pass data to frontend | |
| } | |
| ``` | |
| **When to use:** | |
| - Large tables with sorting/filtering (e.g., DataTables) | |
| - Content requiring external JavaScript libraries | |
| - Complex visualizations or charts | |
| - Multi-section reports | |
| - Any content needing custom CSS or extensive styling | |
| **Benefits:** | |
| - Proper script execution (iframe loads scripts naturally) | |
| - Better performance (libraries load once) | |
| - "Open in new window" button for full-screen viewing | |
| - Cleaner separation (route generates HTML, plugin coordinates) | |
| **Implementation pattern:** | |
| 1. **Create a custom route** in `routes.py` that generates the full HTML page: | |
| ```python | |
| from fastapi import APIRouter | |
| from fastapi.responses import HTMLResponse | |
| from fastapi_app.lib.plugins.plugin_tools import generate_datatable_page, escape_html | |
| router = APIRouter(prefix="/api/plugins/my-plugin", tags=["my-plugin"]) | |
| @router.get("/view", response_class=HTMLResponse) | |
| async def view_results( | |
| collection: str = Query(...), | |
| variant: str | None = Query(None), | |
| session_id: str | None = Query(None), | |
| x_session_id: str | None = Header(None, alias="X-Session-ID"), | |
| session_manager=Depends(get_session_manager), | |
| auth_manager=Depends(get_auth_manager), | |
| ): | |
| """Generate standalone HTML page with results.""" | |
| # Authenticate user (see User Authentication section) | |
| # ... | |
| # Prepare table data | |
| headers = ["Column 1", "Column 2", "Column 3"] | |
| rows = [ | |
| [escape_html("Data 1"), escape_html("Data 2"), "Data 3"], | |
| # ... more rows | |
| ] | |
| # Generate HTML page with DataTables | |
| html = generate_datatable_page( | |
| title="My Plugin Results", | |
| headers=headers, | |
| rows=rows, | |
| table_id="resultsTable", | |
| page_length=25, | |
| default_sort_col=0, | |
| default_sort_dir="desc", | |
| enable_sandbox_client=True # For inter-window communication | |
| ) | |
| return HTMLResponse(content=html) | |
| ``` | |
| 1. **Return the URL** from your plugin endpoint: | |
| ```python | |
| async def execute(self, context, params: dict) -> dict: | |
| collection_id = params.get("collection") | |
| view_url = f"/api/plugins/my-plugin/view?collection={collection_id}" | |
| return { | |
| "outputUrl": view_url, | |
| "collection": collection_id | |
| } | |
| ``` | |
| **See also:** [edit_history plugin](../../fastapi_app/plugins/edit_history) for complete example. | |
| ### 3. Preview-then-Execute Pattern (for operations requiring confirmation) | |
| Use the `outputUrl` and `executeUrl` fields together for operations that should show a preview before execution: | |
| ```python | |
| async def execute(self, context, params: dict) -> dict: | |
| """Return URLs for preview and execute endpoints.""" | |
| collection_id = params.get("collection") | |
| variant = params.get("variant", "all") | |
| # Build URLs for preview and execute | |
| variant_param = f"&variant={variant}" if variant != "all" else "" | |
| preview_url = f"/api/plugins/my-plugin/preview?collection={collection_id}{variant_param}" | |
| execute_url = f"/api/plugins/my-plugin/execute?collection={collection_id}{variant_param}" | |
| return { | |
| "outputUrl": preview_url, # Shows preview in iframe | |
| "executeUrl": execute_url, # Execute button calls this URL | |
| "collection": collection_id, | |
| "variant": variant | |
| } | |
| ``` | |
| **When to use:** | |
| - Operations that modify data (syncing, batch updates, deletions) | |
| - Operations where users need to review changes before applying | |
| - Operations that can be expensive and should be confirmed | |
| **User flow:** | |
| 1. Plugin returns `outputUrl` and `executeUrl` | |
| 2. Frontend displays preview in iframe (from `outputUrl`) | |
| 3. Execute button appears in dialog footer | |
| 4. User reviews changes and clicks Execute | |
| 5. Execute button loads `executeUrl` in the same iframe | |
| 6. Execute button is hidden after clicking | |
| **Implementation pattern:** | |
| Create two routes in `routes.py`: | |
| ```python | |
| @router.get("/preview", response_class=HTMLResponse) | |
| async def preview_operation( | |
| collection: str = Query(...), | |
| session_id: str | None = Query(None), | |
| x_session_id: str | None = Header(None, alias="X-Session-ID"), | |
| session_manager=Depends(get_session_manager), | |
| auth_manager=Depends(get_auth_manager), | |
| ): | |
| """Generate preview HTML (dry-run mode).""" | |
| # Authenticate user | |
| # Perform dry-run operation | |
| # Generate detailed HTML with preview notice | |
| # Return HTML with message: "Click Execute to apply these changes" | |
| return HTMLResponse(content=preview_html) | |
| @router.get("/execute", response_class=HTMLResponse) | |
| async def execute_operation( | |
| collection: str = Query(...), | |
| session_id: str | None = Query(None), | |
| x_session_id: str | None = Header(None, alias="X-Session-ID"), | |
| session_manager=Depends(get_session_manager), | |
| auth_manager=Depends(get_auth_manager), | |
| ): | |
| """Execute the operation and return summary HTML.""" | |
| # Authenticate user | |
| # Perform actual operation | |
| # Generate summary HTML (statistics only) | |
| return HTMLResponse(content=summary_html) | |
| ``` | |
| **Preview HTML structure:** | |
| - Include prominent notice: "Preview Mode - Click Execute to apply changes" | |
| - Show detailed list of changes that will be made | |
| - Use complete HTML document with styles | |
| - Keep layout clean and readable | |
| **Execute HTML structure:** | |
| - Show success message | |
| - Display summary statistics only (no details) | |
| - Optionally show errors if any occurred | |
| - Use complete HTML document with styles | |
| **See also:** [local_sync plugin](../../fastapi_app/plugins/local_sync) for complete example. | |
| ### 4. Options-then-Execute Pattern (for user-configured operations) | |
| Use `outputUrl` with `sandbox.navigateIframe()` when the user must configure options before execution. Unlike the Preview-then-Execute pattern (which uses a fixed `executeUrl`), this pattern lets the iframe itself build the next URL dynamically based on form input. | |
| **When to use:** | |
| - Operations with user-configurable parameters (mode selection, checkboxes, etc.) | |
| - Multi-step workflows where the next URL depends on user choices | |
| - Any case where the execute URL cannot be determined at plugin endpoint time | |
| **User flow:** | |
| 1. Plugin endpoint returns `outputUrl` pointing to an options form route | |
| 2. Frontend loads options form in iframe | |
| 3. User configures options and clicks "Start" | |
| 4. Form JavaScript calls `sandbox.navigateIframe(url)` with the built URL | |
| 5. Sandbox injects `session_id` and navigates the iframe to the execution route | |
| 6. Execution route runs and displays results in the same iframe | |
| **How `navigateIframe` works:** | |
| The `navigateIframe(url)` method on the `PluginSandbox` class ([backend-plugin-sandbox.js](../../app/src/modules/backend-plugin-sandbox.js)): | |
| 1. Finds the iframe in the plugin result dialog | |
| 2. Constructs a full URL from the relative path | |
| 3. Injects the current `session_id` from application state (matching the pattern used by `displayResultInIframe` and `configureExecuteButton`) | |
| 4. Sets `iframe.src` to navigate | |
| Since `PluginSandbox` methods are auto-discovered by `_extract_sandbox_methods()` in [plugin_tools.py](../../fastapi_app/lib/plugins/plugin_tools.py), the method is automatically available as `sandbox.navigateIframe()` in any iframe that includes the sandbox client script. | |
| **Implementation pattern:** | |
| **Step 1 — Plugin endpoint** returns the options form URL: | |
| ```python | |
| async def update(self, context, params: dict) -> dict: | |
| return {"outputUrl": "/api/plugins/my-plugin/options"} | |
| ``` | |
| **Step 2 — Options route** serves an HTML form with the sandbox client: | |
| ```python | |
| from fastapi_app.lib.plugins.plugin_tools import generate_sandbox_client_script | |
| @router.get("/options", response_class=HTMLResponse) | |
| async def options_form( | |
| session_id: str | None = Query(None), | |
| x_session_id: str | None = Header(None, alias="X-Session-ID"), | |
| session_manager=Depends(get_session_manager), | |
| auth_manager=Depends(get_auth_manager), | |
| ): | |
| # Authenticate user... | |
| sandbox_script = generate_sandbox_client_script() | |
| html = f"""<!DOCTYPE html> | |
| <html> | |
| <head> | |
| <script>{sandbox_script}</script> | |
| </head> | |
| <body> | |
| <label><input type="radio" name="mode" value="a" checked> Mode A</label> | |
| <label><input type="radio" name="mode" value="b"> Mode B</label> | |
| <label><input type="checkbox" id="force" checked> Force overwrite</label> | |
| <button onclick="startExecution()">Start</button> | |
| <button onclick="sandbox.closeDialog()">Cancel</button> | |
| <script> | |
| function startExecution() {{ | |
| const mode = document.querySelector('input[name="mode"]:checked').value; | |
| const force = document.getElementById('force').checked; | |
| const params = new URLSearchParams({{ mode, force }}); | |
| sandbox.navigateIframe('/api/plugins/my-plugin/execute?' + params); | |
| }} | |
| </script> | |
| </body> | |
| </html>""" | |
| return HTMLResponse(content=html) | |
| ``` | |
| **Step 3 — Execute route** receives the user-chosen parameters and runs the operation: | |
| ```python | |
| @router.get("/execute", response_class=HTMLResponse) | |
| async def execute_operation( | |
| mode: str = Query("a"), | |
| force: bool = Query(False), | |
| # ... auth params | |
| ): | |
| # Run operation with user-selected options | |
| # Return HTML results page | |
| return HTMLResponse(content=results_html) | |
| ``` | |
| **Key points:** | |
| - The options form must include the sandbox client script (`generate_sandbox_client_script()`) so `sandbox.navigateIframe()` is available | |
| - `session_id` is automatically injected by `navigateIframe` — the form JavaScript only needs to build the path and query parameters | |
| - The "Cancel" button can call `sandbox.closeDialog()` to dismiss the dialog | |
| - Each page in the chain is a full page load in the iframe, so the sandbox client reconnects on each navigation | |
| **See also:** [update_metadata plugin](../../fastapi_app/plugins/update_metadata) for complete example. | |
| ## Interactive HTML Content | |
| Both response formats support interactive elements through the **Plugin Sandbox** interface. | |
| ### Plugin Sandbox API | |
| When plugin content is displayed (either via `html` or `outputUrl`), a `window.pluginSandbox` object (or `window.sandbox` in standalone pages) exposes methods to interact with the application: | |
| ```javascript | |
| // Available methods: | |
| // - In inline HTML: window.pluginSandbox | |
| // - In standalone pages (outputUrl): window.sandbox | |
| // Update application state (any fields from ApplicationState) - async | |
| await pluginSandbox.updateState({ xml: 'doc-id', variant: 'model-x' }); | |
| // Close the result dialog | |
| pluginSandbox.closeDialog(); | |
| // Navigate the iframe to a new URL (session_id injected automatically) | |
| sandbox.navigateIframe('/api/plugins/my-plugin/execute?param=value'); | |
| // Convenience: Open a document (updates xml state, clears diff, closes dialog) - async | |
| await pluginSandbox.openDocument('stable-id'); | |
| // Convenience: Open diff view (updates xml and diff states, closes dialog) - async | |
| await pluginSandbox.openDiff('stable-id-1', 'stable-id-2'); | |
| ``` | |
| ### Example: Clickable Links | |
| **In inline HTML:** | |
| ```python | |
| async def execute(self, context, params: dict) -> dict: | |
| """Generate interactive HTML with clickable links.""" | |
| doc_id = "abc123" | |
| # Create clickable link that opens document | |
| html = f''' | |
| <p>View document: | |
| <a href="#" | |
| onclick="window.pluginSandbox.openDocument('{doc_id}'); return false;" | |
| style="color: #0066cc; text-decoration: underline;"> | |
| {doc_id} | |
| </a> | |
| </p> | |
| ''' | |
| return {"html": html} | |
| ``` | |
| **In standalone pages (outputUrl):** | |
| When using `generate_datatable_page()` with `enable_sandbox_client=True`, use `sandbox` (not `pluginSandbox`): | |
| ```python | |
| # In your custom route | |
| from fastapi_app.lib.plugins.plugin_tools import escape_html | |
| doc_link = f'''<a href="#" | |
| onclick="sandbox.openDocument('{entry["stable_id"]}'); return false;" | |
| style="color: #0066cc; text-decoration: underline;"> | |
| {escape_html(entry["doc_label"])} | |
| </a>''' | |
| rows.append([ | |
| escape_html(entry["date"]), | |
| doc_link, # Clickable link | |
| escape_html(entry["description"]) | |
| ]) | |
| ``` | |
| ### Available State Fields | |
| The sandbox can update any field from `ApplicationState` (see [app/src/state.js](../../app/src/state.js)): | |
| - `xml` - Open XML document ID | |
| - `diff` - Diff XML document ID (triggers diff view when set with `xml`) | |
| - `pdf` - Open PDF document ID | |
| - `xpath` - Current XPath selection | |
| - `variant` - Variant filter | |
| - `collection` - Current collection ID | |
| - Other fields as needed | |
| ### Implementation Examples | |
| - **Inline HTML**: The [iaa_analyzer plugin](../../fastapi_app/plugins/iaa_analyzer/plugin.py) demonstrates inline HTML with clickable stable IDs and match counts that open documents or diff views | |
| - **Standalone pages**: The [edit_history plugin](../../fastapi_app/plugins/edit_history) demonstrates the `outputUrl` pattern with a complete DataTables implementation | |
| ### Utility Functions | |
| The `fastapi_app.lib.plugin_tools` module provides utilities for generating plugin content: | |
| **`generate_datatable_page()`** - Generate complete HTML page with sortable DataTables table: | |
| ```python | |
| from fastapi_app.lib.plugins.plugin_tools import generate_datatable_page, escape_html | |
| html = generate_datatable_page( | |
| title="Results", # Page title | |
| headers=["Col1", "Col2"], # Column headers | |
| rows=[ # Table rows (can contain HTML) | |
| [escape_html("A"), "B"], | |
| [escape_html("C"), "D"] | |
| ], | |
| table_id="myTable", # HTML table ID | |
| page_length=25, # Rows per page | |
| default_sort_col=0, # Sort column index | |
| default_sort_dir="desc", # "asc" or "desc" | |
| enable_sandbox_client=True, # Include sandbox for links | |
| custom_css="", # Additional CSS | |
| custom_js="" # Additional JavaScript | |
| ) | |
| ``` | |
| **`escape_html()`** - Escape HTML to prevent XSS: | |
| ```python | |
| from fastapi_app.lib.plugins.plugin_tools import escape_html | |
| safe_text = escape_html(user_input) # Escapes <, >, &, ", ' | |
| ``` | |
| **`generate_sandbox_client_script()`** - Generate sandbox client for custom HTML pages (advanced use). | |
| ## Notes | |
| - **Directory naming**: Use underscores (e.g., `my_plugin`) not hyphens (e.g., `my-plugin`) in directory names to avoid Python import issues | |
| - Shadow DOM: Frontend uses `querySelector` to access Shoelace menu elements | |
| - Plugin discovery happens at startup | |
| - Plugins can be reloaded without restart in dev mode | |
| - Use `PluginManager.get_instance()` to access plugin manager | |
| - **Plugin Sandbox**: Always available as `window.pluginSandbox` when plugin HTML is displayed | |
| - **HTML escaping**: Always escape user-provided content in HTML to prevent XSS attacks | |
| ## Related Documentation | |
| - [Plugin System Overview](plugin-system.md) - Overview of frontend and backend plugin systems | |
| - [Frontend Plugin System](plugin-system-frontend.md) - Frontend plugin architecture | |
| - [Backend Plugin Development Guide](../code-assistant/backend-plugins.md) - Practical guide for creating backend plugins | |
| - [Service Registry](service-registry.md) - Plugin-to-plugin communication via capability-based services | |
| - [Architecture Overview](architecture.md) - Complete system architecture | |
| - [Access Control](access-control.md) - RBAC implementation | |
| - [API Reference](api-reference.md) - FastAPI endpoint documentation | |