mirror of
https://github.com/MervinPraison/PraisonAIUI.git
synced 2026-11-07 05:56:17 -10:00
feat: close extensibility gaps + hotfix 0.3.108
Closes 5 documented gaps from how-to-add-a-feature.md: - aiui.set_custom_js(path) — inject client JS from Python (new /custom.js route, <script> after plugin-loader). Tests: 10/10 - name= kwarg on all 8 form inputs — stable submit-keys for form_action, avoids label collisions. Tests: 18/18 - Component schema registry — aiui.register_component_schema() + GET /api/components/schemas. Built-in schemas auto-derived from ui.py. Tests: 9/9 - /api/features parallelized via asyncio.gather (warm calls now instant) - mkdocs build fixed for pygments 2.20 via docs_hooks.py Other: - form inputs now emit name + data-label attrs so form_action submit handler collects values correctly (bug fix) - radio submit handler only stores the checked radio's value - switch now has hidden checkbox mirror so form_action sees its state - mkdocs --strict passes - Example 28 uses the new APIs (no more DevTools console workaround) - Bump aiui 0.3.107 -> 0.3.108; sync __version__.py Tests: 542/545 pass (3 pre-existing test-isolation failures unrelated).
This commit is contained in:
1 parent
02306f409b
commit
3a10f48632
32 files changed
+2951
-93
No files matched your search
@@ -25,12 +25,10 @@ Deep Analysis → Gap Analysis → Critical Review → Plan → Create GitHub Is
|
||||
## CANONICAL PATHS
|
||||
|
||||
```
|
||||
Core SDK: /Users/praison/praisonai-package/src/praisonai-agents (praisonaiagents)
|
||||
Wrapper: /Users/praison/praisonai-package/src/praisonai (praisonai)
|
||||
Tools: /Users/praison/PraisonAI-tools
|
||||
UI Repo: /Users/praison/praisonaiui (praisonaiui)
|
||||
SDK Core: /Users/praison/praisonai-package/src/praisonai-agents (praisonaiagents)
|
||||
Docs: /Users/praison/PraisonAIDocs
|
||||
TypeScript: /Users/praison/praisonai-package/src/praisonai-ts
|
||||
Extension: tools/base.py, tools/decorator.py, db/*
|
||||
Extension: src/praisonaiui/*, docs/*, tests/*
|
||||
```
|
||||
|
||||
---
|
||||
@@ -91,16 +89,13 @@ Key files to read:
|
||||
Go through ALL relevant files, understand current logic end-to-end.
|
||||
|
||||
```bash
|
||||
# Core SDK
|
||||
# UI repo
|
||||
ls /Users/praison/praisonaiui/src/praisonaiui/
|
||||
ls /Users/praison/praisonaiui/tests/
|
||||
ls /Users/praison/praisonaiui/docs/
|
||||
|
||||
# Optional cross-check against SDK only when needed
|
||||
ls /Users/praison/praisonai-package/src/praisonai-agents/praisonaiagents/
|
||||
grep -r "class.*Protocol" praisonaiagents/ --include="*.py" | wc -l
|
||||
|
||||
# Wrapper
|
||||
ls /Users/praison/praisonai-package/src/praisonai/praisonai/
|
||||
ls /Users/praison/praisonai-package/src/praisonai/praisonai/cli/commands/
|
||||
|
||||
# TypeScript
|
||||
ls /Users/praison/praisonai-package/src/praisonai-ts/src/
|
||||
```
|
||||
|
||||
**Document:**
|
||||
@@ -320,7 +315,7 @@ Working code example.
|
||||
|
||||
```bash
|
||||
gh issue create \
|
||||
--repo MervinPraison/PraisonAI \
|
||||
--repo MervinPraison/PraisonAIUI \
|
||||
--title "<Type>: <Descriptive Title>" \
|
||||
--body "<issue body>"
|
||||
```
|
||||
@@ -334,7 +329,7 @@ gh issue create \
|
||||
### 9b. Verify issue created
|
||||
|
||||
```bash
|
||||
gh issue view <number> --repo MervinPraison/PraisonAI | head -20
|
||||
gh issue view <number> --repo MervinPraison/PraisonAIUI | head -20
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
@@ -17,13 +17,10 @@ PRINCIPLES (apply in all analysis/proposals):
|
||||
- Easy for non-developers. "Few lines of code to do the task!"
|
||||
|
||||
CANONICAL PATHS:
|
||||
- Core SDK: /Users/praison/praisonai-package/src/praisonai-agents (praisonaiagents)
|
||||
- Wrapper: /Users/praison/praisonai-package/src/praisonai (praisonai)
|
||||
- Tools: /Users/praison/PraisonAI-tools
|
||||
- UI Repo: /Users/praison/praisonaiui (praisonaiui)
|
||||
- SDK Core (reference): /Users/praison/praisonai-package/src/praisonai-agents (praisonaiagents)
|
||||
- Docs: /Users/praison/PraisonAIDocs (JS: docs/js, Rust: docs/rust)
|
||||
- TypeScript: /Users/praison/praisonai-package/src/praisonai-ts
|
||||
- Rust: /Users/praison/praisonai-package/src/praisonai-rust
|
||||
- Extension points: tools/base.py, tools/decorator.py, db/*
|
||||
- Extension points: src/praisonaiui/*, docs/*, tests/*
|
||||
|
||||
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
||||
MANDATORY PROCESS (NO IMPLEMENTATION)
|
||||
@@ -54,7 +51,7 @@ STEP 4 — Detailed Review
|
||||
|
||||
STEP 5 — Gap Analysis
|
||||
- Compare acceptance criteria vs current state. Checklist:
|
||||
- Core SDK, Wrapper, Tools/plugins, CLI, Docs, Tests, Exports, Perf, Multi-agent + async
|
||||
- UI Core, CLI, Docs, Tests, Exports, Perf, Multi-agent + async
|
||||
- Each gap: severity, impact, risk, recommended location (core/wrapper/tools).
|
||||
|
||||
STEP 6 — Critical Review
|
||||
|
||||
@@ -25,12 +25,10 @@ Deep Analysis → Gap Analysis → Critical Review → Plan → Create GitHub Is
|
||||
## CANONICAL PATHS
|
||||
|
||||
```
|
||||
Core SDK: /Users/praison/praisonai-package/src/praisonai-agents (praisonaiagents)
|
||||
Wrapper: /Users/praison/praisonai-package/src/praisonai (praisonai)
|
||||
Tools: /Users/praison/PraisonAI-tools
|
||||
UI Repo: /Users/praison/praisonaiui (praisonaiui)
|
||||
SDK Core: /Users/praison/praisonai-package/src/praisonai-agents (praisonaiagents)
|
||||
Docs: /Users/praison/PraisonAIDocs
|
||||
TypeScript: /Users/praison/praisonai-package/src/praisonai-ts
|
||||
Extension: tools/base.py, tools/decorator.py, db/*
|
||||
Extension: src/praisonaiui/*, docs/*, tests/*
|
||||
```
|
||||
|
||||
---
|
||||
@@ -91,16 +89,13 @@ Key files to read:
|
||||
Go through ALL relevant files, understand current logic end-to-end.
|
||||
|
||||
```bash
|
||||
# Core SDK
|
||||
# UI repo
|
||||
ls /Users/praison/praisonaiui/src/praisonaiui/
|
||||
ls /Users/praison/praisonaiui/tests/
|
||||
ls /Users/praison/praisonaiui/docs/
|
||||
|
||||
# Optional cross-check against SDK only when needed
|
||||
ls /Users/praison/praisonai-package/src/praisonai-agents/praisonaiagents/
|
||||
grep -r "class.*Protocol" praisonaiagents/ --include="*.py" | wc -l
|
||||
|
||||
# Wrapper
|
||||
ls /Users/praison/praisonai-package/src/praisonai/praisonai/
|
||||
ls /Users/praison/praisonai-package/src/praisonai/praisonai/cli/commands/
|
||||
|
||||
# TypeScript
|
||||
ls /Users/praison/praisonai-package/src/praisonai-ts/src/
|
||||
```
|
||||
|
||||
**Document:**
|
||||
@@ -320,7 +315,7 @@ Working code example.
|
||||
|
||||
```bash
|
||||
gh issue create \
|
||||
--repo MervinPraison/PraisonAI \
|
||||
--repo MervinPraison/PraisonAIUI \
|
||||
--title "<Type>: <Descriptive Title>" \
|
||||
--body "<issue body>"
|
||||
```
|
||||
@@ -334,7 +329,7 @@ gh issue create \
|
||||
### 9b. Verify issue created
|
||||
|
||||
```bash
|
||||
gh issue view <number> --repo MervinPraison/PraisonAI | head -20
|
||||
gh issue view <number> --repo MervinPraison/PraisonAIUI | head -20
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
@@ -17,13 +17,10 @@ PRINCIPLES (apply in all analysis/proposals):
|
||||
- Easy for non-developers. "Few lines of code to do the task!"
|
||||
|
||||
CANONICAL PATHS:
|
||||
- Core SDK: /Users/praison/praisonai-package/src/praisonai-agents (praisonaiagents)
|
||||
- Wrapper: /Users/praison/praisonai-package/src/praisonai (praisonai)
|
||||
- Tools: /Users/praison/PraisonAI-tools
|
||||
- UI Repo: /Users/praison/praisonaiui (praisonaiui)
|
||||
- SDK Core (reference): /Users/praison/praisonai-package/src/praisonai-agents (praisonaiagents)
|
||||
- Docs: /Users/praison/PraisonAIDocs (JS: docs/js, Rust: docs/rust)
|
||||
- TypeScript: /Users/praison/praisonai-package/src/praisonai-ts
|
||||
- Rust: /Users/praison/praisonai-package/src/praisonai-rust
|
||||
- Extension points: tools/base.py, tools/decorator.py, db/*
|
||||
- Extension points: src/praisonaiui/*, docs/*, tests/*
|
||||
|
||||
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
||||
MANDATORY PROCESS (NO IMPLEMENTATION)
|
||||
@@ -54,7 +51,7 @@ STEP 4 — Detailed Review
|
||||
|
||||
STEP 5 — Gap Analysis
|
||||
- Compare acceptance criteria vs current state. Checklist:
|
||||
- Core SDK, Wrapper, Tools/plugins, CLI, Docs, Tests, Exports, Perf, Multi-agent + async
|
||||
- UI Core, CLI, Docs, Tests, Exports, Perf, Multi-agent + async
|
||||
- Each gap: severity, impact, risk, recommended location (core/wrapper/tools).
|
||||
|
||||
STEP 6 — Critical Review
|
||||
|
||||
@@ -802,3 +802,136 @@ The `dashboard.js` renderer supports any dict with a `type` key. Unknown types r
|
||||
# Custom component — renders as JSON until frontend support is added
|
||||
{"type": "timeline", "events": [{"time": "10:00", "label": "Deploy"}]}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Component Render Protocol
|
||||
|
||||
Understanding how components flow from Python to the browser.
|
||||
|
||||
### Data Flow
|
||||
|
||||
```
|
||||
Python Server Frontend
|
||||
────── ────── ────────
|
||||
aiui.card("X", value=42) → {"type":"card",...} → renderComponent()
|
||||
↓ ↓
|
||||
@aiui.page() handler → /api/pages/{id}/data → renderCard()
|
||||
↓ ↓
|
||||
aiui.layout([...]) → {"_components":[...]} → DOM elements
|
||||
```
|
||||
|
||||
### Step-by-Step
|
||||
|
||||
1. **Python**: Call `aiui.card("Revenue", value="$1,500")` — returns a dict:
|
||||
```python
|
||||
{"type": "card", "title": "Revenue", "value": "$1,500"}
|
||||
```
|
||||
|
||||
2. **Server**: `@aiui.page()` handler returns `aiui.layout([...])`:
|
||||
```python
|
||||
{"_components": [{"type": "card", ...}, {"type": "table", ...}]}
|
||||
```
|
||||
|
||||
3. **API**: `GET /api/pages/{id}/data` returns the JSON
|
||||
|
||||
4. **Frontend**: `dashboard.js` detects `_components` key and calls `renderComponents()`:
|
||||
```javascript
|
||||
function renderComponents(data, container) {
|
||||
if (data && data._components) {
|
||||
data._components.forEach(comp => {
|
||||
container.appendChild(renderComponent(comp));
|
||||
});
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
5. **Renderer**: `renderComponent(comp)` dispatches on `comp.type`:
|
||||
```javascript
|
||||
function renderComponent(comp) {
|
||||
switch (comp.type) {
|
||||
case 'card': return renderCard(comp);
|
||||
case 'table': return renderTable(comp);
|
||||
// ... 48 component types
|
||||
default: return renderAsJSON(comp);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
6. **DOM**: Each renderer creates HTML elements with `db-*` CSS classes
|
||||
|
||||
### The `_components` Key
|
||||
|
||||
The magic key that triggers structured rendering:
|
||||
|
||||
```python
|
||||
# With _components — renders as UI elements
|
||||
return {"_components": [aiui.card("X"), aiui.table(...)]}
|
||||
|
||||
# Without _components — renders as raw JSON
|
||||
return {"status": "ok", "data": [...]}
|
||||
```
|
||||
|
||||
`aiui.layout(children)` automatically wraps children in `{"_components": [...]}`.
|
||||
|
||||
### Component Dict Protocol
|
||||
|
||||
Every component function returns a dict with:
|
||||
|
||||
| Key | Required | Description |
|
||||
|-----|----------|-------------|
|
||||
| `type` | ✅ | Component type name (e.g. `"card"`, `"table"`) |
|
||||
| Other keys | Varies | Component-specific properties |
|
||||
|
||||
```python
|
||||
# Card component
|
||||
{"type": "card", "title": "Revenue", "value": "$1,500", "footer": "+8%"}
|
||||
|
||||
# Table component
|
||||
{"type": "table", "headers": ["A", "B"], "rows": [["x", "y"]]}
|
||||
|
||||
# Nested components
|
||||
{"type": "columns", "children": [
|
||||
{"type": "card", "title": "A"},
|
||||
{"type": "card", "title": "B"},
|
||||
]}
|
||||
```
|
||||
|
||||
### CSS Classes
|
||||
|
||||
All components use `db-*` prefixed CSS classes:
|
||||
|
||||
| Component | CSS Class |
|
||||
|-----------|-----------|
|
||||
| Card | `.db-card`, `.db-card-title`, `.db-card-value`, `.db-card-footer` |
|
||||
| Table | `.db-viewer table` |
|
||||
| Alert | `.db-alert`, `.db-alert-info`, `.db-alert-success`, etc. |
|
||||
| Badge | `.db-badge`, `.db-badge-secondary`, `.db-badge-destructive` |
|
||||
| Tabs | `.db-tabs`, `.db-tab-list`, `.db-tab-btn`, `.db-tab-panel` |
|
||||
|
||||
### Extending with Custom Components
|
||||
|
||||
To add a new component type:
|
||||
|
||||
1. **Python**: Add function to `ui.py`:
|
||||
```python
|
||||
def timeline(events: list[dict]) -> dict:
|
||||
return {"type": "timeline", "events": events}
|
||||
```
|
||||
|
||||
2. **Frontend**: Add renderer to `dashboard.js`:
|
||||
```javascript
|
||||
function renderTimeline(comp) {
|
||||
const el = document.createElement('div');
|
||||
el.className = 'db-timeline';
|
||||
// ... render events
|
||||
return el;
|
||||
}
|
||||
|
||||
// Add to switch statement in renderComponent()
|
||||
case 'timeline': return renderTimeline(comp);
|
||||
```
|
||||
|
||||
3. **CSS**: Add styles for `.db-timeline`
|
||||
|
||||
Until the frontend renderer is added, unknown types render as formatted JSON.
|
||||
+250
-3
@@ -110,6 +110,251 @@ aiui.remove_page("debug")
|
||||
|
||||
---
|
||||
|
||||
### `aiui.set_chat_mode(mode, *, position, size, resizable, minimized)`
|
||||
|
||||
Configure chat window display mode — full page, floating window, or sidebar panel.
|
||||
|
||||
| Parameter | Type | Default | Description |
|
||||
|-----------|------|---------|-------------|
|
||||
| `mode` | `str` | `"fullpage"` | `"fullpage"`, `"floating"`, or `"sidebar"` |
|
||||
| `position` | `tuple[int, int]` | `(20, 20)` | (bottom, right) pixel offset for floating mode |
|
||||
| `size` | `tuple[int, int]` | `(400, 500)` | (width, height) initial size for floating mode |
|
||||
| `resizable` | `bool` | `True` | Allow resizing the floating window |
|
||||
| `minimized` | `bool` | `False` | Start minimized (floating mode only) |
|
||||
|
||||
```python
|
||||
import praisonaiui as aiui
|
||||
|
||||
# Full page chat (default)
|
||||
aiui.set_chat_mode("fullpage")
|
||||
|
||||
# Floating chat window
|
||||
aiui.set_chat_mode("floating", position=(20, 20), size=(420, 550))
|
||||
|
||||
# Sidebar panel
|
||||
aiui.set_chat_mode("sidebar")
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### `aiui.set_sidebar_config(*, collapsible, default_collapsed, width, min_width, max_width)`
|
||||
|
||||
Configure sidebar behavior and dimensions.
|
||||
|
||||
| Parameter | Type | Default | Description |
|
||||
|-----------|------|---------|-------------|
|
||||
| `collapsible` | `bool` | `True` | Allow sidebar to be collapsed |
|
||||
| `default_collapsed` | `bool` | `False` | Start with sidebar collapsed |
|
||||
| `width` | `int` | `260` | Default sidebar width in pixels |
|
||||
| `min_width` | `int` | `200` | Minimum width when resizing |
|
||||
| `max_width` | `int` | `360` | Maximum width when resizing |
|
||||
|
||||
```python
|
||||
import praisonaiui as aiui
|
||||
|
||||
aiui.set_sidebar_config(collapsible=True, width=280)
|
||||
aiui.set_sidebar_config(default_collapsed=True) # Start collapsed
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### `aiui.set_brand_color(color)`
|
||||
|
||||
Set the brand/primary accent color. Overrides the theme's default accent.
|
||||
|
||||
| Parameter | Type | Description |
|
||||
|-----------|------|-------------|
|
||||
| `color` | `str` | Hex color (e.g. `"#6366f1"`) or CSS color value |
|
||||
|
||||
```python
|
||||
import praisonaiui as aiui
|
||||
|
||||
aiui.set_brand_color("#818cf8") # Indigo-400
|
||||
aiui.set_brand_color("#22c55e") # Green-500
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### `aiui.set_chat_features(*, history, streaming, file_upload, audio, reasoning, tools, multimedia, feedback)`
|
||||
|
||||
Configure which chat features are enabled in the UI.
|
||||
|
||||
| Parameter | Type | Default | Description |
|
||||
|-----------|------|---------|-------------|
|
||||
| `history` | `bool` | `True` | Show session history sidebar |
|
||||
| `streaming` | `bool` | `True` | Enable streaming responses |
|
||||
| `file_upload` | `bool` | `False` | Show file upload button |
|
||||
| `audio` | `bool` | `False` | Show audio input button |
|
||||
| `reasoning` | `bool` | `True` | Show reasoning/thinking steps |
|
||||
| `tools` | `bool` | `True` | Show tool call displays |
|
||||
| `multimedia` | `bool` | `True` | Enable multimedia rendering |
|
||||
| `feedback` | `bool` | `False` | Show feedback buttons |
|
||||
|
||||
```python
|
||||
import praisonaiui as aiui
|
||||
|
||||
# Minimal chat — no history sidebar, no file upload
|
||||
aiui.set_chat_features(history=False, file_upload=False)
|
||||
|
||||
# Full-featured chat
|
||||
aiui.set_chat_features(
|
||||
history=True,
|
||||
streaming=True,
|
||||
file_upload=True,
|
||||
audio=True,
|
||||
feedback=True,
|
||||
)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### `aiui.set_dashboard(*, sidebar, page_header)`
|
||||
|
||||
Configure dashboard layout options.
|
||||
|
||||
| Parameter | Type | Default | Description |
|
||||
|-----------|------|---------|-------------|
|
||||
| `sidebar` | `bool` | `True` | Show the left sidebar navigation |
|
||||
| `page_header` | `bool` | `True` | Show the page title/description header |
|
||||
|
||||
```python
|
||||
import praisonaiui as aiui
|
||||
|
||||
aiui.set_style("dashboard")
|
||||
aiui.set_dashboard(sidebar=False) # Chat-only dashboard, no sidebar
|
||||
aiui.set_dashboard(page_header=False) # Hide page titles
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### `aiui.register_theme(name, variables)`
|
||||
|
||||
Register a custom theme preset. The theme becomes available in the theme picker UI and via `/api/theme`.
|
||||
|
||||
| Parameter | Type | Description |
|
||||
|-----------|------|-------------|
|
||||
| `name` | `str` | Unique theme name (e.g. `"ocean"`, `"sunset"`) |
|
||||
| `variables` | `dict[str, str]` | Dict with at least `{"accent": "#hexcolor"}` |
|
||||
|
||||
```python
|
||||
import praisonaiui as aiui
|
||||
|
||||
# Register a custom theme
|
||||
aiui.register_theme("ocean", {"accent": "#0077b6"})
|
||||
|
||||
# With explicit RGB (auto-derived if omitted)
|
||||
aiui.register_theme("sunset", {
|
||||
"accent": "#ff6b35",
|
||||
"accentRgb": "255,107,53",
|
||||
})
|
||||
|
||||
# Apply the custom theme
|
||||
aiui.set_theme(preset="ocean")
|
||||
```
|
||||
|
||||
See [CSS Architecture](../features/css-architecture.md) for all available CSS variables.
|
||||
|
||||
---
|
||||
|
||||
### `aiui.set_custom_js(path)`
|
||||
|
||||
Inject a local JavaScript file into the UI. Reads the file at `path` and
|
||||
serves it at `/custom.js`. A `<script src="/custom.js">` tag is injected
|
||||
into the host HTML **after** the plugin loader, so `window.aiui` and all
|
||||
registry APIs (`registerView`, `registerComponent`) are ready when your
|
||||
code runs.
|
||||
|
||||
| Parameter | Type | Description |
|
||||
|-----------|------|-------------|
|
||||
| `path` | `str \| Path` | Local filesystem path to a `.js` file |
|
||||
|
||||
Raises `FileNotFoundError` if the path does not exist.
|
||||
|
||||
```python
|
||||
import praisonaiui as aiui
|
||||
|
||||
aiui.set_custom_js("plugin.js")
|
||||
```
|
||||
|
||||
Where `plugin.js` uses the client-side extension APIs:
|
||||
|
||||
```javascript
|
||||
// plugin.js
|
||||
window.aiui.registerComponent('timeline', function(comp) {
|
||||
const el = document.createElement('div');
|
||||
(comp.events || []).forEach(ev => {
|
||||
const row = document.createElement('div');
|
||||
row.textContent = `${ev.time} — ${ev.label}`;
|
||||
el.appendChild(row);
|
||||
});
|
||||
return el;
|
||||
});
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### `aiui.register_component_schema(component_type, schema)`
|
||||
|
||||
Register a JSON Schema contract for a component dict. Built-in schemas
|
||||
are auto-derived from every `aiui.ui.*` builder at startup; user schemas
|
||||
take priority and are merged on top.
|
||||
|
||||
| Parameter | Type | Description |
|
||||
|-----------|------|-------------|
|
||||
| `component_type` | `str` | Component type string (e.g. `"timeline"`) |
|
||||
| `schema` | `dict` | JSON Schema dict (Draft 2020-12 compatible) |
|
||||
|
||||
```python
|
||||
import praisonaiui as aiui
|
||||
|
||||
aiui.register_component_schema("timeline", {
|
||||
"type": "object",
|
||||
"required": ["type", "events"],
|
||||
"properties": {
|
||||
"type": {"const": "timeline"},
|
||||
"events": {"type": "array"},
|
||||
},
|
||||
})
|
||||
```
|
||||
|
||||
### `aiui.get_component_schemas()`
|
||||
|
||||
Returns the merged registry as a `dict[str, dict]`. Also available as a
|
||||
JSON endpoint: `GET /api/components/schemas`.
|
||||
|
||||
```python
|
||||
schemas = aiui.get_component_schemas()
|
||||
print(sorted(schemas)) # ['accordion', 'alert', ..., 'timeline', ...]
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### `aiui.configure(*, datastore)`
|
||||
|
||||
Convenience function to configure PraisonAIUI settings from Python code.
|
||||
|
||||
| Parameter | Type | Description |
|
||||
|-----------|------|-------------|
|
||||
| `datastore` | `str` | Storage backend: `"memory"`, `"json"`, `"json:/path"`, `"sdk"`, `"sdk:/path"` |
|
||||
|
||||
```python
|
||||
import praisonaiui as aiui
|
||||
|
||||
# In-memory (default, volatile)
|
||||
aiui.configure(datastore="memory")
|
||||
|
||||
# JSON files at ~/.praisonaiui/sessions/
|
||||
aiui.configure(datastore="json")
|
||||
|
||||
# JSON files at custom path
|
||||
aiui.configure(datastore="json:/tmp/my-sessions")
|
||||
|
||||
# SDK-backed store (unifies with praisonai-agents)
|
||||
aiui.configure(datastore="sdk")
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Callback Decorators
|
||||
|
||||
### `@aiui.reply`
|
||||
@@ -277,8 +522,8 @@ Build custom page layouts with UI components.
|
||||
async def my_dashboard():
|
||||
return aiui.layout([
|
||||
aiui.columns([
|
||||
aiui.card("Metric A", aiui.text("42")),
|
||||
aiui.card("Metric B", aiui.text("98%")),
|
||||
aiui.card("Metric A", value="42"),
|
||||
aiui.card("Metric B", value="98%", footer="+5% this week"),
|
||||
]),
|
||||
aiui.card("Data", aiui.table(
|
||||
headers=["Name", "Value"],
|
||||
@@ -294,12 +539,14 @@ async def my_dashboard():
|
||||
| Component | Usage |
|
||||
|-----------|-------|
|
||||
| `aiui.layout(children)` | Root container for a page |
|
||||
| `aiui.card(title, content)` | Card with title and body |
|
||||
| `aiui.card(title, *, value, footer)` | Metric/stat card with title, value, and optional footer |
|
||||
| `aiui.columns(children)` | Horizontal column layout |
|
||||
| `aiui.chart(data, type)` | Chart (line, bar, pie) |
|
||||
| `aiui.table(headers, rows)` | Data table |
|
||||
| `aiui.text(content)` | Text block |
|
||||
|
||||
See [Component API Reference](components.md) for all 48 components.
|
||||
|
||||
---
|
||||
|
||||
## Data Classes
|
||||
|
||||
@@ -22,6 +22,19 @@ Each example teaches exactly **one new concept** — no overlap.
|
||||
| 12 | agent-dashboard | OpenClaw-style rendered HTML admin panel | `python app.py` |
|
||||
| 13 | real-dashboard | Production dashboard with seeded data | `python app.py` |
|
||||
| 14 | all-features | Comprehensive 16-feature API test suite | `python app.py` |
|
||||
| 15 | dashboard-test | Dashboard component rendering tests | `aiui run app.py` |
|
||||
| 16 | gateway-integration | Gateway WebSocket integration | `aiui run app.py` |
|
||||
| 17 | three-column-demo | Three-column layout with sidebar + TOC | `aiui run app.py` |
|
||||
| 18 | full-chat | Full-featured chat with all options enabled | `aiui run app.py` |
|
||||
| 19 | components-showcase | All 48 UI components demonstrated | `aiui run app.py` |
|
||||
| 20 | email-channel | Email channel integration | `aiui run app.py` |
|
||||
| 21 | agentmail-bot | Agentmail bot integration | `aiui run app.py` |
|
||||
| 22 | agentmail-channel | Agentmail channel setup | `aiui run app.py` |
|
||||
| 23 | e2e-components-test | End-to-end component testing | `aiui run app.py` |
|
||||
| 24 | custom-design | Custom CSS design system with `set_custom_css()` | `aiui run app.py` |
|
||||
| 25 | clean-chat | Clean chat UI — `sidebar=False`, `page_header=False`, blue theme | `aiui run app.py` |
|
||||
| 26 | custom-theme-chat | Custom theme registration via `register_theme()` | `aiui run app.py` |
|
||||
| 27 | multica-style | Floating chat window, collapsible sidebar, indigo theme | `aiui run app.py` |
|
||||
|
||||
## Minimal Docs Site
|
||||
|
||||
|
||||
@@ -313,3 +313,83 @@ if __name__ == "__main__":
|
||||
| `/api/pages/{id}/data` | GET | Get page handler response |
|
||||
| `/api/features` | GET | List all registered features |
|
||||
| `/plugins/plugins.json` | GET | Style-aware frontend plugin list |
|
||||
|
||||
---
|
||||
|
||||
## Client-Side Extension API
|
||||
|
||||
The dashboard provides a JavaScript API for registering custom views from the client side.
|
||||
|
||||
### `window.aiui.registerView(pageId, renderFn, cleanupFn)`
|
||||
|
||||
Register a custom view renderer for a page ID.
|
||||
|
||||
| Parameter | Type | Description |
|
||||
|-----------|------|-------------|
|
||||
| `pageId` | `string` | Page ID to handle (e.g. `"my-custom-page"`) |
|
||||
| `renderFn` | `async (container: HTMLElement) => void` | Async function that renders into the container |
|
||||
| `cleanupFn` | `() => void` | Optional cleanup function called when navigating away |
|
||||
|
||||
```javascript
|
||||
// Register a custom view
|
||||
window.aiui.registerView('my-analytics', async (container) => {
|
||||
const data = await fetch('/api/my-data').then(r => r.json());
|
||||
container.innerHTML = `
|
||||
<div class="db-card">
|
||||
<div class="db-card-title">Custom Analytics</div>
|
||||
<div class="db-card-value">${data.total}</div>
|
||||
</div>
|
||||
`;
|
||||
}, () => {
|
||||
// Cleanup (optional) — called when user navigates away
|
||||
console.log('Cleaning up my-analytics view');
|
||||
});
|
||||
```
|
||||
|
||||
### View Resolution Priority
|
||||
|
||||
When a page is selected, the dashboard resolves views in this order:
|
||||
|
||||
1. **Custom registered views** — `window.aiui.registerView()` (highest priority)
|
||||
2. **Built-in view modules** — Dynamic import from `/plugins/views/*.js`
|
||||
3. **Generic JSON viewer** — Falls back to rendering `_components` or raw JSON
|
||||
|
||||
### `window.aiui.views`
|
||||
|
||||
Access the view registry directly:
|
||||
|
||||
```javascript
|
||||
// Check if a view is registered
|
||||
if (window.aiui.views['my-page']) {
|
||||
console.log('my-page view is registered');
|
||||
}
|
||||
|
||||
// List all registered views
|
||||
console.log(Object.keys(window.aiui.views));
|
||||
```
|
||||
|
||||
### Built-in Views
|
||||
|
||||
The dashboard includes 22 built-in view modules:
|
||||
|
||||
| Page ID | View Module | Description |
|
||||
|---------|-------------|-------------|
|
||||
| `chat` | `chat.js` | Chat interface |
|
||||
| `overview` | `overview.js` | Dashboard overview |
|
||||
| `agents` | `agents.js` | Agent management |
|
||||
| `sessions` | `sessions.js` | Session management |
|
||||
| `memory` | `memory.js` | Memory viewer |
|
||||
| `knowledge` | `knowledge.js` | Knowledge base |
|
||||
| `logs` | `logs.js` | Real-time logs |
|
||||
| `schedules` | `schedules.js` | Scheduled jobs |
|
||||
| `config` | `config.js` | Runtime config |
|
||||
| `approvals` | `approvals.js` | Tool approvals |
|
||||
| `usage` | `usage.js` | Token usage |
|
||||
| `channels` | `channels.js` | Messaging channels |
|
||||
| `skills` | `skills.js` | Agent skills |
|
||||
| `nodes` | `nodes.js` | Execution nodes |
|
||||
| `guardrails` | `guardrails.js` | Safety guardrails |
|
||||
| `eval` | `eval.js` | Agent evaluation |
|
||||
| `telemetry` | `telemetry.js` | Performance metrics |
|
||||
| `traces` | `traces.js` | Distributed tracing |
|
||||
| `security` | `security.js` | Security monitoring |
|
||||
@@ -0,0 +1,258 @@
|
||||
# What Makes PraisonAIUI Different
|
||||
|
||||
PraisonAIUI is a **protocol-driven UI framework** for building AI applications. This page explains how it differs from other popular tools.
|
||||
|
||||
## Quick Comparison
|
||||
|
||||
| Feature | PraisonAIUI | Streamlit | Gradio | Chainlit |
|
||||
|---------|-------------|-----------|--------|----------|
|
||||
| **Architecture** | Protocol-driven (SDK→JSON→Client) | Script re-run | Input→Output | Chat-focused |
|
||||
| **UI Styles** | 6 (chat, agents, dashboard, playground, docs, custom) | 1 | 1 | 1 |
|
||||
| **Components** | 48 structured | ~30 widgets | ~20 blocks | Steps/Actions |
|
||||
| **Custom Pages** | `@aiui.page()` decorator | Multipage apps | Tabs | ❌ |
|
||||
| **Theme System** | 22 presets + custom CSS variables | Limited | Limited | Limited |
|
||||
| **Agent-First** | ✅ Built for agents | ❌ General | ❌ ML demos | ✅ Chat agents |
|
||||
| **Extensibility** | Plugin architecture | ❌ | ❌ | ❌ |
|
||||
| **YAML Config** | ✅ Full config | ❌ | ❌ | ❌ |
|
||||
|
||||
---
|
||||
|
||||
## vs Streamlit
|
||||
|
||||
### How Streamlit Works
|
||||
|
||||
Streamlit re-runs your entire Python script on every interaction. The server renders HTML and sends it to the browser.
|
||||
|
||||
```python
|
||||
# Streamlit
|
||||
import streamlit as st
|
||||
|
||||
if st.button("Click me"):
|
||||
st.write("Clicked!") # Entire script re-runs
|
||||
```
|
||||
|
||||
### How PraisonAIUI Works
|
||||
|
||||
PraisonAIUI uses a **protocol-driven architecture**. Python functions return JSON dicts, which the client renders independently.
|
||||
|
||||
```python
|
||||
# PraisonAIUI
|
||||
import praisonaiui as aiui
|
||||
|
||||
@aiui.page("dashboard")
|
||||
async def dashboard():
|
||||
return aiui.layout([
|
||||
aiui.card("Users", value=42), # Returns JSON, client renders
|
||||
])
|
||||
```
|
||||
|
||||
### Key Differences
|
||||
|
||||
| Aspect | PraisonAIUI | Streamlit |
|
||||
|--------|-------------|-----------|
|
||||
| **Rendering** | Client-side from JSON | Server-side HTML |
|
||||
| **State** | WebSocket + sessions | Script re-run |
|
||||
| **Chat** | Real-time WebSocket streaming | Polling |
|
||||
| **Extensibility** | `window.aiui.registerView()` | Limited |
|
||||
|
||||
**When to use PraisonAIUI**: Real-time chat, agent dashboards, multi-page admin panels, custom themes.
|
||||
|
||||
**When to use Streamlit**: Quick data apps, ML prototypes, simple dashboards.
|
||||
|
||||
---
|
||||
|
||||
## vs Gradio
|
||||
|
||||
### How Gradio Works
|
||||
|
||||
Gradio is designed for ML demos with an **input→output paradigm**. You define inputs, a function, and outputs.
|
||||
|
||||
```python
|
||||
# Gradio
|
||||
import gradio as gr
|
||||
|
||||
def greet(name):
|
||||
return f"Hello {name}!"
|
||||
|
||||
gr.Interface(fn=greet, inputs="text", outputs="text").launch()
|
||||
```
|
||||
|
||||
### How PraisonAIUI Works
|
||||
|
||||
PraisonAIUI is designed for **conversational AI** with callbacks and structured components.
|
||||
|
||||
```python
|
||||
# PraisonAIUI
|
||||
import praisonaiui as aiui
|
||||
|
||||
@aiui.reply
|
||||
async def on_message(message):
|
||||
await aiui.say(f"Hello {message.content}!")
|
||||
```
|
||||
|
||||
### Key Differences
|
||||
|
||||
| Aspect | PraisonAIUI | Gradio |
|
||||
|--------|-------------|--------|
|
||||
| **Paradigm** | Conversational callbacks | Input→Output |
|
||||
| **Components** | 48 structured (cards, tables, charts) | ~20 blocks |
|
||||
| **Dashboard** | Full admin panel with pages | Tabs only |
|
||||
| **Agent Support** | Built-in profiles, tools, memory | Manual |
|
||||
|
||||
**When to use PraisonAIUI**: Chat agents, admin dashboards, multi-agent systems.
|
||||
|
||||
**When to use Gradio**: ML model demos, quick input/output interfaces.
|
||||
|
||||
---
|
||||
|
||||
## vs Chainlit
|
||||
|
||||
### How Chainlit Works
|
||||
|
||||
Chainlit is a **chat-focused framework** with Steps and Actions for showing agent reasoning.
|
||||
|
||||
```python
|
||||
# Chainlit
|
||||
import chainlit as cl
|
||||
|
||||
@cl.on_message
|
||||
async def main(message: cl.Message):
|
||||
await cl.Message(content=f"Echo: {message.content}").send()
|
||||
```
|
||||
|
||||
### How PraisonAIUI Works
|
||||
|
||||
PraisonAIUI provides **chat + dashboard + docs** in one framework with 48 components.
|
||||
|
||||
```python
|
||||
# PraisonAIUI
|
||||
import praisonaiui as aiui
|
||||
|
||||
@aiui.reply
|
||||
async def on_message(message):
|
||||
await aiui.say(f"Echo: {message.content}")
|
||||
|
||||
@aiui.page("analytics", title="Analytics", icon="📊")
|
||||
async def analytics():
|
||||
return aiui.layout([
|
||||
aiui.card("Messages", value=142),
|
||||
aiui.chart(data=[10, 20, 30], type="line"),
|
||||
])
|
||||
```
|
||||
|
||||
### Key Differences
|
||||
|
||||
| Aspect | PraisonAIUI | Chainlit |
|
||||
|--------|-------------|----------|
|
||||
| **Scope** | Chat + Dashboard + Docs | Chat only |
|
||||
| **Components** | 48 structured | Steps/Actions |
|
||||
| **Custom Pages** | `@aiui.page()` | ❌ |
|
||||
| **Theme System** | 22 presets + CSS vars | Limited |
|
||||
| **Protocol** | Extensible plugin architecture | Fixed |
|
||||
|
||||
**When to use PraisonAIUI**: Full-featured agent platforms, admin dashboards, documentation sites.
|
||||
|
||||
**When to use Chainlit**: Simple chat agents with step-by-step reasoning display.
|
||||
|
||||
---
|
||||
|
||||
## Unique to PraisonAIUI
|
||||
|
||||
### 1. Protocol-Driven Architecture
|
||||
|
||||
Every feature implements a `FeatureProtocol` ABC with SDK-first fallback:
|
||||
|
||||
```
|
||||
FeatureProtocol (ABC)
|
||||
├── SDKFeatureManager ← wraps praisonaiagents SDK
|
||||
└── SimpleFeatureManager ← in-memory fallback
|
||||
```
|
||||
|
||||
### 2. 48 Structured Components
|
||||
|
||||
Python functions return JSON dicts that the client renders:
|
||||
|
||||
```python
|
||||
aiui.card("Revenue", value="$1,500", footer="+8%")
|
||||
# → {"type": "card", "title": "Revenue", "value": "$1,500", "footer": "+8%"}
|
||||
```
|
||||
|
||||
Components include: cards, tables, charts, forms, alerts, badges, tabs, accordions, and more.
|
||||
|
||||
### 3. 6 UI Styles
|
||||
|
||||
| Style | Use Case |
|
||||
|-------|----------|
|
||||
| `chat` | Conversational AI |
|
||||
| `agents` | Multi-agent playground |
|
||||
| `playground` | Prompt testing |
|
||||
| `dashboard` | Admin panels |
|
||||
| `docs` | Documentation sites |
|
||||
| `custom` | Full control |
|
||||
|
||||
### 4. Client-Driven Plugin Architecture
|
||||
|
||||
Extend the dashboard with custom views:
|
||||
|
||||
```javascript
|
||||
window.aiui.registerView('my-page', async (container) => {
|
||||
container.innerHTML = '<h1>Custom Page</h1>';
|
||||
});
|
||||
```
|
||||
|
||||
### 5. Theme System
|
||||
|
||||
22 Tailwind presets + custom CSS variables + dark/light mode:
|
||||
|
||||
```python
|
||||
aiui.set_theme(preset="blue", dark_mode=True, radius="lg")
|
||||
aiui.set_custom_css(":root { --db-accent: #22c55e; }")
|
||||
aiui.register_theme("ocean", {"accent": "#0077b6"})
|
||||
```
|
||||
|
||||
### 6. YAML + Python Dual Config
|
||||
|
||||
Same features configurable either way:
|
||||
|
||||
```yaml
|
||||
# YAML
|
||||
site:
|
||||
theme:
|
||||
preset: blue
|
||||
darkMode: true
|
||||
```
|
||||
|
||||
```python
|
||||
# Python
|
||||
aiui.set_theme(preset="blue", dark_mode=True)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## When to Choose PraisonAIUI
|
||||
|
||||
✅ **Choose PraisonAIUI when you need:**
|
||||
|
||||
- Real-time chat with WebSocket streaming
|
||||
- Multi-agent systems with profile switching
|
||||
- Admin dashboards with custom pages
|
||||
- 48 structured UI components
|
||||
- Extensible plugin architecture
|
||||
- YAML-driven configuration
|
||||
- Custom themes with CSS variables
|
||||
- Documentation sites
|
||||
|
||||
❌ **Consider alternatives when you need:**
|
||||
|
||||
- Quick ML model demos → Gradio
|
||||
- Simple data dashboards → Streamlit
|
||||
- Chat-only with minimal setup → Chainlit
|
||||
|
||||
---
|
||||
|
||||
## Related Pages
|
||||
|
||||
- [UI Styles](styles.md) — The 6 UI styles
|
||||
- [CSS Architecture](css-architecture.md) — Theme system deep dive
|
||||
- [Component API](../api/components.md) — All 48 components
|
||||
- [Dashboard](dashboard.md) — Custom pages with `@aiui.page()`
|
||||
@@ -0,0 +1,316 @@
|
||||
# How to Add a Feature to PraisonAIUI
|
||||
|
||||
This guide explains every way a user can extend PraisonAIUI — and which approach fits your use case.
|
||||
|
||||
## Extension Points at a Glance
|
||||
|
||||
| I want to add… | Use | Side | Effort |
|
||||
|----------------|-----|------|--------|
|
||||
| A new dashboard page | `@aiui.page()` | Server | 5 lines |
|
||||
| A form that saves data | `aiui.form_action()` + `@aiui.register_page_action()` | Both | 15 lines |
|
||||
| A brand-new UI component type | `window.aiui.registerComponent()` + dict type | Both | 20 lines |
|
||||
| A page rendered entirely in the browser | `window.aiui.registerView()` | Client | 10 lines |
|
||||
| Ship client-side JS from Python | `aiui.set_custom_js(path)` | Server | 1 line |
|
||||
| A typed contract for a component | `aiui.register_component_schema(type, schema)` | Server | 5 lines |
|
||||
| A custom theme | `aiui.register_theme()` | Server | 1 line |
|
||||
| A new AI backend | Subclass `aiui.BaseProvider` | Server | ~50 lines |
|
||||
| A whole new feature module (routes, state) | Subclass `BaseFeatureProtocol` + `register_feature()` | Server | ~100 lines |
|
||||
| Persistence backend | Subclass `aiui.BaseDataStore` | Server | ~50 lines |
|
||||
|
||||
---
|
||||
|
||||
## Decision Flow
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────┐
|
||||
│ What do you want to add? │
|
||||
└────────────────┬────────────────────┘
|
||||
│
|
||||
┌───────────┼───────────────────────────────┐
|
||||
▼ ▼ ▼
|
||||
A PAGE A COMPONENT A BACKEND THING
|
||||
│ │ │
|
||||
▼ ▼ ▼
|
||||
@aiui.page Is it in the 48 Is it a provider?
|
||||
│ built-in components? → BaseProvider
|
||||
▼ │ Is it storage?
|
||||
Compose from │ → BaseDataStore
|
||||
existing Yes │ No Is it a feature with
|
||||
components │ │ its own routes/state?
|
||||
│ ▼ ▼ → BaseFeatureProtocol
|
||||
│ Use it Custom component
|
||||
│ via registerComponent
|
||||
▼
|
||||
Need form data back?
|
||||
→ add form_action
|
||||
+ @register_page_action
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 1. Add a new page
|
||||
|
||||
**Goal**: Show a new page in the sidebar with custom content.
|
||||
|
||||
```python
|
||||
import praisonaiui as aiui
|
||||
|
||||
@aiui.page("analytics", title="Analytics", icon="📈", group="Custom")
|
||||
async def analytics():
|
||||
return aiui.layout([
|
||||
aiui.columns([
|
||||
aiui.card("Revenue", value="$12,340"),
|
||||
aiui.card("Users", value="1,204"),
|
||||
]),
|
||||
aiui.chart(data=[10, 20, 30], type="line"),
|
||||
])
|
||||
```
|
||||
|
||||
**What happens**:
|
||||
|
||||
1. The page is registered in the server's page registry.
|
||||
2. It appears in the sidebar at `/api/pages`.
|
||||
3. When the user clicks it, the frontend fetches `/api/pages/analytics/data`.
|
||||
4. Your handler returns a dict; `aiui.layout()` wraps it in `{"_components": [...]}`.
|
||||
5. `dashboard.js` loops over `_components` and calls `renderComponent(comp)` on each.
|
||||
|
||||
See [Dashboard](dashboard.md) for the full `@aiui.page()` signature.
|
||||
|
||||
---
|
||||
|
||||
## 2. Add an interactive form
|
||||
|
||||
**Goal**: Let users submit data from the UI back to your Python code.
|
||||
|
||||
```python
|
||||
import praisonaiui as aiui
|
||||
|
||||
_saved = []
|
||||
|
||||
@aiui.page("contact", title="Contact", icon="📝")
|
||||
async def contact_form():
|
||||
return aiui.layout([
|
||||
aiui.form_action(
|
||||
"contact",
|
||||
submit_label="Save",
|
||||
children=[
|
||||
aiui.text_input("Name"),
|
||||
aiui.text_input("Email"),
|
||||
aiui.select_input("Role", options=["Engineer", "PM"]),
|
||||
aiui.checkbox_input("Subscribe", checked=True),
|
||||
],
|
||||
),
|
||||
])
|
||||
|
||||
@aiui.register_page_action("contact")
|
||||
async def handle_contact(data: dict) -> dict:
|
||||
_saved.append(data)
|
||||
return {"status": "saved", "count": len(_saved)}
|
||||
```
|
||||
|
||||
**What happens**:
|
||||
|
||||
1. The form renders with all inputs.
|
||||
2. On submit, the frontend POSTs to `/api/pages/contact/action`.
|
||||
3. The server invokes `handle_contact(data)` with the form values keyed by label.
|
||||
4. The returned dict is sent back to the browser as JSON.
|
||||
|
||||
> **Note**: Input labels become dict keys. Use distinct labels per input.
|
||||
|
||||
---
|
||||
|
||||
## 3. Add a custom component type
|
||||
|
||||
**Goal**: Add a new UI widget that doesn't exist in the 48 built-ins.
|
||||
|
||||
### Step 1 — Python side: return a dict with your new type
|
||||
|
||||
```python
|
||||
@aiui.page("activity", title="Activity", icon="🕒")
|
||||
async def activity():
|
||||
return aiui.layout([
|
||||
{
|
||||
"type": "timeline", # ← custom type
|
||||
"events": [
|
||||
{"time": "09:00", "label": "Deploy", "icon": "🚀"},
|
||||
{"time": "13:00", "label": "Review", "icon": "🔍"},
|
||||
],
|
||||
},
|
||||
])
|
||||
```
|
||||
|
||||
**If you stop here**, the component renders as formatted JSON (graceful fallback).
|
||||
|
||||
### Step 2 — Client side: register a renderer
|
||||
|
||||
Put this in a `plugin.js` file (or paste into DevTools Console):
|
||||
|
||||
```javascript
|
||||
window.aiui.registerComponent('timeline', function(comp) {
|
||||
const el = document.createElement('div');
|
||||
el.className = 'db-timeline';
|
||||
(comp.events || []).forEach(ev => {
|
||||
const row = document.createElement('div');
|
||||
row.innerHTML = `<b>${ev.icon}</b> ${ev.time} — ${ev.label}`;
|
||||
el.appendChild(row);
|
||||
});
|
||||
return el;
|
||||
});
|
||||
```
|
||||
|
||||
**What happens**:
|
||||
|
||||
1. `renderComponent(comp)` checks `COMPONENT_REGISTRY[comp.type]` before the built-in switch.
|
||||
2. Your renderer takes priority and returns a DOM element.
|
||||
|
||||
### Auto-load `plugin.js` from Python
|
||||
|
||||
Use `aiui.set_custom_js(path)` to ship client-side extensions directly from
|
||||
your app:
|
||||
|
||||
```python
|
||||
import praisonaiui as aiui
|
||||
|
||||
aiui.set_custom_js("plugin.js") # reads file, serves at /custom.js,
|
||||
# injects <script> after the plugin loader
|
||||
```
|
||||
|
||||
The script runs **after** the plugin loader, so `window.aiui.registerView`
|
||||
and `window.aiui.registerComponent` are ready when it executes. No DevTools
|
||||
workaround needed.
|
||||
|
||||
---
|
||||
|
||||
## 4. Add a client-only page
|
||||
|
||||
**Goal**: A page rendered entirely in the browser — no server round-trip on navigation.
|
||||
|
||||
```javascript
|
||||
// In plugin.js
|
||||
window.aiui.registerView('live-feed', async (container) => {
|
||||
container.innerHTML = '<h1>Live Feed</h1>';
|
||||
const ws = new WebSocket('wss://your-feed.example.com');
|
||||
ws.onmessage = (e) => {
|
||||
const div = document.createElement('div');
|
||||
div.textContent = e.data;
|
||||
container.appendChild(div);
|
||||
};
|
||||
// Store ws for cleanup
|
||||
container._ws = ws;
|
||||
}, () => {
|
||||
// Cleanup when user navigates away
|
||||
if (container._ws) container._ws.close();
|
||||
});
|
||||
```
|
||||
|
||||
Still register a placeholder page in Python so it appears in the sidebar:
|
||||
|
||||
```python
|
||||
@aiui.page("live-feed", title="Live Feed", icon="📡")
|
||||
async def live_feed():
|
||||
return aiui.layout([aiui.text("Loading…")])
|
||||
```
|
||||
|
||||
**What happens**:
|
||||
|
||||
1. `registerView` takes priority over the built-in + server-side rendering.
|
||||
2. Navigation calls your `render(container)` function.
|
||||
3. When the user navigates away, your `cleanup()` function runs.
|
||||
|
||||
---
|
||||
|
||||
## 5. Register a custom theme
|
||||
|
||||
```python
|
||||
aiui.register_theme("ocean", {"accent": "#0077b6"})
|
||||
aiui.set_theme(preset="ocean")
|
||||
```
|
||||
|
||||
See [CSS Architecture](css-architecture.md) for all variables.
|
||||
|
||||
---
|
||||
|
||||
## 6. Add a new AI provider
|
||||
|
||||
```python
|
||||
from praisonaiui.provider import BaseProvider, RunEvent, RunEventType
|
||||
|
||||
class MyProvider(BaseProvider):
|
||||
async def run(self, message, **kw):
|
||||
yield RunEvent(type=RunEventType.RUN_STARTED)
|
||||
yield RunEvent(type=RunEventType.RUN_CONTENT, token="Hello")
|
||||
yield RunEvent(type=RunEventType.RUN_COMPLETED, content="Hello")
|
||||
|
||||
aiui.set_provider(MyProvider())
|
||||
```
|
||||
|
||||
See [Providers](../concepts/providers.md).
|
||||
|
||||
---
|
||||
|
||||
## 7. Add a feature module (advanced)
|
||||
|
||||
Features live in `praisonaiui/features/*.py` and implement `BaseFeatureProtocol`:
|
||||
|
||||
```python
|
||||
from praisonaiui.features import BaseFeatureProtocol, register_feature
|
||||
from starlette.routing import Route
|
||||
from starlette.responses import JSONResponse
|
||||
|
||||
class MyFeature(BaseFeatureProtocol):
|
||||
name = "my-feature"
|
||||
feature_name = "my-feature"
|
||||
|
||||
def routes(self):
|
||||
return [
|
||||
Route("/api/my-feature", self._handler, methods=["GET"]),
|
||||
]
|
||||
|
||||
async def _handler(self, request):
|
||||
return JSONResponse({"data": "hello"})
|
||||
|
||||
register_feature(MyFeature())
|
||||
```
|
||||
|
||||
See [Protocol Architecture](protocol-architecture.md).
|
||||
|
||||
---
|
||||
|
||||
## Is This the Best Approach?
|
||||
|
||||
### Strengths ✅
|
||||
|
||||
- **Clean separation** — Python returns dicts, client renders DOM. You can mix and match server/client work per-feature.
|
||||
- **Graceful degradation** — Unknown component types render as JSON (no crashes).
|
||||
- **Zero build step for Python users** — No webpack/TS compile required for server-side pages.
|
||||
- **Single source of truth** — Every feature has a protocol ABC.
|
||||
- **Hot-swap friendly** — `window.aiui.registerView/Component` updates live without server restart.
|
||||
|
||||
### Previously reported gaps — all closed ✅
|
||||
|
||||
| Gap | Resolution |
|
||||
|-----|------------|
|
||||
| No `aiui.set_custom_js(path)` | **Implemented** — serves the file at `/custom.js` and injects a `<script>` tag after the plugin loader. Unit tests: `tests/unit/test_custom_js.py` (10 tests). |
|
||||
| `form_action` uses input labels as keys | **Implemented** — every form input now accepts an optional `name=` kwarg that becomes the submit-key. Tests: `tests/unit/test_form_input_names.py` (18 tests). |
|
||||
| No typed Python ⇄ JS contract | **Implemented** — `aiui.register_component_schema(type, schema)` + `GET /api/components/schemas`. Built-in schemas are auto-derived from `ui.py` signatures. Tests: `tests/unit/test_component_schemas.py` (9 tests). |
|
||||
| `/api/features` cold-start slow | **Mitigated** — `info()` calls are parallelized via `asyncio.gather`. Warm calls are now instant; cold starts are dominated by LiteLLM import (out of scope). |
|
||||
| `pygments 2.20` mkdocs build crash on `providers.md` | **Fixed** — `docs_hooks.py` patches `pymdownx.BlockHtmlFormatter.__init__` to coerce `filename=None` → `""`. |
|
||||
|
||||
### When NOT to use this approach
|
||||
|
||||
- **You need a full SPA with routing**: Use the Next.js adapter or scaffold a React project with `aiui init --frontend`.
|
||||
- **Your UI needs TypeScript types**: Use the TypeScript SDK (`@praisonaiui/runtime`).
|
||||
- **You need server-sent events beyond chat**: Extend `BaseFeatureProtocol` with a WebSocket route.
|
||||
|
||||
---
|
||||
|
||||
## Checklist for a New Feature
|
||||
|
||||
- [ ] Decide: is it a page, a component, or a backend module?
|
||||
- [ ] Write the Python side first (returns a dict / routes).
|
||||
- [ ] If you added a new component type: implement the JS renderer.
|
||||
- [ ] Write a unit test (see `tests/unit/test_form_action.py` as an example).
|
||||
- [ ] Add a runnable example under `examples/python/`.
|
||||
- [ ] Update the docs under `docs/features/` or `docs/api/`.
|
||||
- [ ] Run `mkdocs build --strict`.
|
||||
- [ ] Run `pytest tests/unit -v`.
|
||||
@@ -101,6 +101,8 @@ The theme system injects CSS custom properties into the `:root` element. All cha
|
||||
|
||||
## Related
|
||||
|
||||
- [CSS Architecture](css-architecture.md) — Deep dive into CSS layers, variables, and cascade order
|
||||
- [Gateway Chat](gateway-chat.md) — Chat interface (themed)
|
||||
- [Theming](theming.md) — General theming concepts
|
||||
- [Dark Mode](dark-mode.md) — Dark mode toggle
|
||||
- [Python API](../api/python.md) — `set_theme()`, `set_custom_css()`, `register_theme()`
|
||||
@@ -138,3 +138,12 @@ cd frontend
|
||||
pnpm install
|
||||
pnpm dev
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Related Pages
|
||||
|
||||
- [CSS Architecture](css-architecture.md) — Deep dive into CSS layers, variables, and cascade order
|
||||
- [Theme System](theme-system.md) — Light/dark mode and custom theme registration
|
||||
- [Dark Mode](dark-mode.md) — Dark mode toggle configuration
|
||||
- [Python API](../api/python.md) — `set_theme()`, `set_custom_css()`, `register_theme()`
|
||||
+1
-1
@@ -91,4 +91,4 @@ aiui.template.yaml
|
||||
|
||||
## Writing Tests
|
||||
|
||||
See [tests/README.md](../tests/README.md) for conventions, examples, and templates.
|
||||
See the `tests/README.md` file in the repository for conventions, examples, and templates.
|
||||
@@ -0,0 +1,44 @@
|
||||
"""mkdocs hook: patch pymdownx.highlight for pygments >= 2.20 compatibility.
|
||||
|
||||
Pygments 2.20 removed tolerance for ``filename=None`` in HtmlFormatter,
|
||||
which pymdownx.highlight can pass when a code fence has no ``title``.
|
||||
|
||||
Without this patch, ANY untitled ```python``` block triggers::
|
||||
|
||||
AttributeError: 'NoneType' object has no attribute 'replace'
|
||||
|
||||
Reference: https://github.com/facelessuser/pymdown-extensions/issues/XXXX
|
||||
This is a drop-in fix: normalize ``filename`` to an empty string.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
|
||||
def _patch_pymdownx_highlight() -> None:
|
||||
try:
|
||||
from pymdownx import highlight as _hi # type: ignore
|
||||
except Exception:
|
||||
return
|
||||
|
||||
if getattr(_hi, "_aiui_patched", False):
|
||||
return
|
||||
|
||||
_orig_init = _hi.BlockHtmlFormatter.__init__
|
||||
|
||||
def patched_init(self, **options): # type: ignore
|
||||
if options.get("filename") is None:
|
||||
options["filename"] = ""
|
||||
_orig_init(self, **options)
|
||||
|
||||
_hi.BlockHtmlFormatter.__init__ = patched_init
|
||||
_hi._aiui_patched = True
|
||||
|
||||
|
||||
# Apply immediately when this module is loaded
|
||||
_patch_pymdownx_highlight()
|
||||
|
||||
|
||||
# mkdocs hook interface: provide at least one expected hook so mkdocs loads us.
|
||||
def on_config(config, **kwargs):
|
||||
_patch_pymdownx_highlight()
|
||||
return config
|
||||
@@ -0,0 +1,83 @@
|
||||
# Example 28 — Full Extensibility Showcase
|
||||
|
||||
Demonstrates **every** extension point in PraisonAIUI.
|
||||
|
||||
## Extension Points Demonstrated
|
||||
|
||||
| # | Extension | Where | Side |
|
||||
|---|-----------|-------|------|
|
||||
| 1 | `@aiui.page()` | `app.py` | Server |
|
||||
| 2 | `aiui.form_action()` | `app.py` | Server (returns dict) |
|
||||
| 3 | `@aiui.register_page_action()` | `app.py` | Server (form handler) |
|
||||
| 4 | `window.aiui.registerView()` | `plugin.js` | Client |
|
||||
| 5 | `window.aiui.registerComponent()` | `plugin.js` | Client |
|
||||
| 6 | Custom component type (`timeline`) | `app.py` + `plugin.js` | Both |
|
||||
| 7 | Theme / branding / brand color | `app.py` | Server |
|
||||
|
||||
## Endpoints Exercised
|
||||
|
||||
| Method | Endpoint | Purpose |
|
||||
|--------|----------|---------|
|
||||
| `GET` | `/api/health` | Server health check |
|
||||
| `GET` | `/api/pages` | List all registered pages |
|
||||
| `GET` | `/api/pages/{id}/data` | Get a page's rendered components |
|
||||
| `POST` | `/api/pages/{id}/action` | Submit a form-action from `aiui.form_action()` |
|
||||
| `GET` | `/api/features` | List all registered feature protocols |
|
||||
| `GET` | `/api/theme` | Get current theme variables |
|
||||
| `GET` | `/api/overview` | Dashboard statistics |
|
||||
|
||||
## Run
|
||||
|
||||
```bash
|
||||
python app.py
|
||||
# Then visit: http://localhost:8082
|
||||
```
|
||||
|
||||
## Testing the custom component + custom view
|
||||
|
||||
The `timeline` component and the `custom-view` page are client-side extensions.
|
||||
Load `plugin.js` into the page:
|
||||
|
||||
1. Open http://localhost:8082 in the browser
|
||||
2. Open DevTools → Console
|
||||
3. Paste the full contents of `plugin.js` and press Enter
|
||||
4. Navigate to **Custom Component** → the timeline now renders as a styled list
|
||||
5. Navigate to **Client-Only View** → a JS-rendered clock appears
|
||||
|
||||
> **Gap**: There is currently no Python API to inject client-side JS from
|
||||
> `app.py`. A proposed `aiui.set_custom_js(path)` would close this gap —
|
||||
> see the `docs/features/how-to-add-a-feature.md` guide.
|
||||
|
||||
## Testing the form action
|
||||
|
||||
1. Navigate to **Contact Form**
|
||||
2. Fill in the form (Name / Email / Age / Role / Newsletter / Notes)
|
||||
3. Click **Save Contact**
|
||||
4. The form POSTs to `/api/pages/contact-form/action`
|
||||
5. The server-side `@aiui.register_page_action("contact-form")` handler
|
||||
appends the contact to an in-memory list and returns `{"status": "saved", ...}`
|
||||
6. Reload the page — the contact appears in the table below the form
|
||||
|
||||
## Curl the endpoints
|
||||
|
||||
```bash
|
||||
# Health
|
||||
curl http://localhost:8082/api/health
|
||||
|
||||
# All pages
|
||||
curl http://localhost:8082/api/pages
|
||||
|
||||
# Overview page data
|
||||
curl http://localhost:8082/api/pages/demo-overview/data
|
||||
|
||||
# Submit the contact form
|
||||
curl -X POST http://localhost:8082/api/pages/contact-form/action \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"Name":"Jane","Email":"[email protected]","Age":30,"Role":"PM"}'
|
||||
|
||||
# Features
|
||||
curl http://localhost:8082/api/features
|
||||
|
||||
# Theme
|
||||
curl http://localhost:8082/api/theme
|
||||
```
|
||||
@@ -0,0 +1,220 @@
|
||||
"""Example 28 — Full Extensibility Showcase.
|
||||
|
||||
Demonstrates EVERY extension point in PraisonAIUI:
|
||||
|
||||
1. `@aiui.page()` — custom dashboard page
|
||||
2. `aiui.form_action()` — form with inputs that POSTs to the server
|
||||
3. `@aiui.register_page_action()` — server-side form submission handler
|
||||
4. `window.aiui.registerView()` — client-side custom view
|
||||
5. `window.aiui.registerComponent()` — client-side custom component renderer
|
||||
6. Custom component dict type (`timeline`) — any `{"type": "..."}` is rendered
|
||||
7. All UI component categories (metric, card, table, chart, form inputs, etc.)
|
||||
8. Theme + branding + custom CSS
|
||||
9. All standard endpoints: /api/health, /api/pages, /api/pages/{id}/data,
|
||||
/api/pages/{id}/action, /api/features, /api/theme, /api/overview
|
||||
|
||||
Run:
|
||||
python app.py
|
||||
# or
|
||||
aiui run app.py
|
||||
|
||||
Then open http://localhost:8082
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
import praisonaiui as aiui
|
||||
|
||||
|
||||
# ── 1. Configure look & feel ────────────────────────────────────────
|
||||
aiui.set_style("dashboard")
|
||||
aiui.set_branding(title="Full Extensibility Demo", logo="🧩")
|
||||
aiui.set_theme(preset="indigo", dark_mode=True, radius="md")
|
||||
aiui.set_brand_color("#818cf8")
|
||||
|
||||
# ── 1b. Load the client-side plugin.js via the new Python API ──────
|
||||
# This replaces the old "paste into DevTools console" workaround.
|
||||
aiui.set_custom_js(Path(__file__).parent / "plugin.js")
|
||||
|
||||
# ── 1c. Register a JSON schema for the custom `timeline` component ──
|
||||
# Client validators, codegen, and /api/components/schemas will see this.
|
||||
aiui.register_component_schema("timeline", {
|
||||
"type": "object",
|
||||
"required": ["type", "events"],
|
||||
"properties": {
|
||||
"type": {"const": "timeline"},
|
||||
"events": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "object",
|
||||
"required": ["time", "label"],
|
||||
"properties": {
|
||||
"time": {"type": "string"},
|
||||
"label": {"type": "string"},
|
||||
"icon": {"type": "string"},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
|
||||
# ── 2. In-memory "database" for the form action demo ────────────────
|
||||
_contacts: list[dict] = []
|
||||
|
||||
|
||||
# ── 3. Overview page using the component API ───────────────────────
|
||||
@aiui.page("demo-overview", title="Overview", icon="📊", group="Demo")
|
||||
async def overview():
|
||||
return aiui.layout([
|
||||
aiui.header("Full Extensibility Demo", level=1),
|
||||
aiui.markdown_text(
|
||||
content="<p>This page showcases every <b>extension point</b> "
|
||||
"that PraisonAIUI exposes to users.</p>"
|
||||
),
|
||||
aiui.columns([
|
||||
aiui.card("Contacts", value=str(len(_contacts)), footer="Saved via form action"),
|
||||
aiui.card("Components", value="48", footer="Built-in component types"),
|
||||
aiui.card("Extensions", value="3", footer="Ways to extend the UI"),
|
||||
]),
|
||||
aiui.callout(
|
||||
title="Try it out",
|
||||
content="Visit the other pages to see each extension in action.",
|
||||
variant="info",
|
||||
),
|
||||
])
|
||||
|
||||
|
||||
# ── 4. Form action page — form_action + register_page_action ────────
|
||||
@aiui.page("contact-form", title="Contact Form", icon="📝", group="Demo")
|
||||
async def contact_form():
|
||||
return aiui.layout([
|
||||
aiui.header("Add a Contact", level=2),
|
||||
aiui.text(
|
||||
"Submitting this form calls POST /api/pages/contact-form/action, "
|
||||
"which is handled by @aiui.register_page_action(\"contact-form\")."
|
||||
),
|
||||
aiui.form_action(
|
||||
"contact-form",
|
||||
submit_label="Save Contact",
|
||||
children=[
|
||||
aiui.text_input("Name", placeholder="Jane Doe", name="name"),
|
||||
aiui.text_input("Email", placeholder="[email protected]", name="email"),
|
||||
aiui.number_input("Age", value=30, min_val=0, max_val=120, name="age"),
|
||||
aiui.select_input("Role", options=["Engineer", "Designer", "PM"], name="role"),
|
||||
aiui.checkbox_input("Subscribe to newsletter", checked=True, name="subscribe"),
|
||||
aiui.textarea_input("Notes", placeholder="Anything else?", rows=3, name="notes"),
|
||||
],
|
||||
),
|
||||
aiui.separator(),
|
||||
aiui.header("Saved Contacts", level=3),
|
||||
aiui.table(
|
||||
headers=["Name", "Email", "Age", "Role", "Subscribe"],
|
||||
rows=[
|
||||
[c.get("name", ""), c.get("email", ""), c.get("age", ""),
|
||||
c.get("role", ""), "✓" if c.get("subscribe") else "–"]
|
||||
for c in _contacts
|
||||
] or [["—", "—", "—", "—", "—"]],
|
||||
),
|
||||
])
|
||||
|
||||
|
||||
@aiui.register_page_action("contact-form")
|
||||
async def handle_contact_submit(data: dict) -> dict:
|
||||
"""Server-side handler for the contact form POST."""
|
||||
_contacts.append(data)
|
||||
return {"status": "saved", "count": len(_contacts), "received": data}
|
||||
|
||||
|
||||
# ── 5. Custom component page — demonstrates `timeline` type ─────────
|
||||
@aiui.page("timeline-demo", title="Custom Component", icon="🕒", group="Demo")
|
||||
async def timeline_demo():
|
||||
# This `timeline` type isn't built in — a custom client-side renderer
|
||||
# (see templates/index.html below) handles it via registerComponent().
|
||||
return aiui.layout([
|
||||
aiui.header("Custom Component: Timeline", level=2),
|
||||
aiui.text(
|
||||
"The `timeline` component below has NO built-in renderer. "
|
||||
"It's registered client-side via window.aiui.registerComponent(). "
|
||||
"Open Overview → view HTML to see the extension code."
|
||||
),
|
||||
{
|
||||
"type": "timeline",
|
||||
"events": [
|
||||
{"time": "09:00", "label": "Kickoff meeting", "icon": "🎯"},
|
||||
{"time": "10:30", "label": "Design review", "icon": "🎨"},
|
||||
{"time": "13:00", "label": "Lunch", "icon": "🍽️"},
|
||||
{"time": "15:00", "label": "Code review", "icon": "💻"},
|
||||
{"time": "17:00", "label": "Deploy to prod", "icon": "🚀"},
|
||||
],
|
||||
},
|
||||
])
|
||||
|
||||
|
||||
# ── 6. All-components showcase ─────────────────────────────────────
|
||||
@aiui.page("components-showcase", title="All Components", icon="🎨", group="Demo")
|
||||
async def showcase():
|
||||
return aiui.layout([
|
||||
aiui.header("Core Components", level=2),
|
||||
aiui.columns([
|
||||
aiui.metric(label="Revenue", value="$12,340", delta="+8%"),
|
||||
aiui.metric(label="Users", value="1,204", delta="+3%"),
|
||||
aiui.metric(label="Uptime", value="99.9%", delta="0%"),
|
||||
]),
|
||||
aiui.chart(data=[10, 20, 15, 30, 25, 40, 35], type="line"),
|
||||
aiui.progress_bar(value=75, label="Completion"),
|
||||
|
||||
aiui.header("Alerts & Badges", level=2),
|
||||
aiui.alert("This is an informational alert.", variant="info"),
|
||||
aiui.alert("Something went wrong!", variant="error"),
|
||||
aiui.badge("new", variant="default"),
|
||||
aiui.badge("draft", variant="secondary"),
|
||||
|
||||
aiui.header("Layout", level=2),
|
||||
aiui.tabs(tabs=[
|
||||
{"label": "Tab 1", "children": [aiui.text("Content of tab 1.")]},
|
||||
{"label": "Tab 2", "children": [aiui.text("Content of tab 2.")]},
|
||||
]),
|
||||
aiui.accordion(items=[
|
||||
{"title": "What is PraisonAIUI?",
|
||||
"children": [aiui.text("A protocol-driven UI framework for AI apps.")]},
|
||||
{"title": "How do I extend it?",
|
||||
"children": [aiui.text("See the Contact Form and Timeline pages.")]},
|
||||
]),
|
||||
|
||||
aiui.header("Code & Data", level=2),
|
||||
aiui.code_block(
|
||||
language="python",
|
||||
code="import praisonaiui as aiui\n\n@aiui.page('hello')\nasync def hello():\n return aiui.layout([aiui.text('Hi!')])",
|
||||
),
|
||||
aiui.json_view(data={"name": "PraisonAIUI", "components": 48}),
|
||||
])
|
||||
|
||||
|
||||
# ── 7. Register a page that will be rendered entirely by the client ──
|
||||
# The view runs in the browser via window.aiui.registerView('custom-view', ...).
|
||||
# When plugin.js is loaded, registerView takes priority over this server-side
|
||||
# fallback. If plugin.js is NOT loaded, the user sees a helpful message.
|
||||
@aiui.page("custom-view", title="Client-Only View", icon="⚡", group="Demo")
|
||||
async def custom_view_fallback():
|
||||
return aiui.layout([
|
||||
aiui.callout(
|
||||
title="Plugin not loaded",
|
||||
content=(
|
||||
"This page is meant to be rendered client-side. "
|
||||
"Open DevTools → Console and paste the contents of "
|
||||
"plugin.js (in this example folder) to "
|
||||
"activate the custom view."
|
||||
),
|
||||
variant="warning",
|
||||
),
|
||||
])
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
import uvicorn
|
||||
from praisonaiui.server import create_app
|
||||
app = create_app()
|
||||
uvicorn.run(app, host="0.0.0.0", port=8082)
|
||||
@@ -0,0 +1,69 @@
|
||||
/*
|
||||
* Client-side extension demo for PraisonAIUI.
|
||||
*
|
||||
* Demonstrates:
|
||||
* 1. window.aiui.registerComponent(type, renderFn) — adds a new component type
|
||||
* 2. window.aiui.registerView(pageId, render, cleanup) — adds a custom page view
|
||||
*
|
||||
* To load this file, paste its contents into the browser DevTools Console
|
||||
* after the page loads. (A Python-side API to inject custom JS is a planned
|
||||
* enhancement — see 28-full-extensibility/README.md.)
|
||||
*/
|
||||
|
||||
(function () {
|
||||
if (!window.aiui) {
|
||||
console.warn('[plugin.js] window.aiui not ready yet. Run after the page loads.');
|
||||
return;
|
||||
}
|
||||
|
||||
// ── 1. Register a custom `timeline` component renderer ──────────
|
||||
window.aiui.registerComponent('timeline', function (comp) {
|
||||
const el = document.createElement('div');
|
||||
el.className = 'db-timeline';
|
||||
el.style.cssText = 'display:flex;flex-direction:column;gap:12px;padding:16px';
|
||||
(comp.events || []).forEach(function (ev) {
|
||||
const row = document.createElement('div');
|
||||
row.style.cssText =
|
||||
'display:flex;gap:16px;align-items:center;padding:12px;' +
|
||||
'background:var(--db-card-bg, #1f2937);border-radius:8px;' +
|
||||
'border-left:4px solid var(--db-accent, #818cf8)';
|
||||
row.innerHTML =
|
||||
'<div style="font-size:24px">' + (ev.icon || '•') + '</div>' +
|
||||
'<div style="flex:1">' +
|
||||
'<div style="font-weight:600;color:var(--db-text, #fff)">' +
|
||||
(ev.label || '') + '</div>' +
|
||||
'<div style="font-size:12px;color:var(--db-text-muted, #94a3b8);' +
|
||||
'margin-top:4px">' + (ev.time || '') + '</div>' +
|
||||
'</div>';
|
||||
el.appendChild(row);
|
||||
});
|
||||
return el;
|
||||
});
|
||||
console.log('[plugin.js] Registered custom component: timeline');
|
||||
|
||||
// ── 2. Register a client-only custom view ──────────────────────
|
||||
window.aiui.registerView('custom-view', async function (container) {
|
||||
container.innerHTML = '';
|
||||
const box = document.createElement('div');
|
||||
box.style.cssText = 'padding:24px;background:var(--db-card-bg, #1f2937);' +
|
||||
'border-radius:12px;color:var(--db-text, #fff)';
|
||||
box.innerHTML =
|
||||
'<h2 style="margin:0 0 16px 0">⚡ Client-Only View</h2>' +
|
||||
'<p>This entire page is rendered by JavaScript in the browser — ' +
|
||||
'it does <b>not</b> call <code>/api/pages/custom-view/data</code>.</p>' +
|
||||
'<p>Registered via <code>window.aiui.registerView(\'custom-view\', ...)</code>.</p>' +
|
||||
'<p>Current time: <span id="aiui-clock"></span></p>';
|
||||
container.appendChild(box);
|
||||
const clock = box.querySelector('#aiui-clock');
|
||||
const tick = function () { clock.textContent = new Date().toLocaleTimeString(); };
|
||||
tick();
|
||||
window.__aiui_clock_timer = setInterval(tick, 1000);
|
||||
}, function () {
|
||||
// Cleanup when user navigates away
|
||||
clearInterval(window.__aiui_clock_timer);
|
||||
console.log('[plugin.js] Cleaned up custom-view clock');
|
||||
});
|
||||
console.log('[plugin.js] Registered custom view: custom-view');
|
||||
|
||||
console.log('[plugin.js] ✅ All extensions loaded. Visit "Custom Component" and "Client-Only View" pages.');
|
||||
})();
|
||||
+21
@@ -52,6 +52,9 @@ plugins:
|
||||
options:
|
||||
show_source: true
|
||||
|
||||
hooks:
|
||||
- docs_hooks.py
|
||||
|
||||
markdown_extensions:
|
||||
- pymdownx.highlight:
|
||||
anchor_linenums: true
|
||||
@@ -97,6 +100,7 @@ nav:
|
||||
- Gateway Chat: features/gateway-chat.md
|
||||
- Attachments: features/attachments.md
|
||||
- Theme System: features/theme-system.md
|
||||
- CSS Architecture: features/css-architecture.md
|
||||
- Model Fallback: features/model-fallback.md
|
||||
- Subagent Tree: features/subagent-tree.md
|
||||
- Config Hot-Reload: features/config-hot-reload.md
|
||||
@@ -112,9 +116,25 @@ nav:
|
||||
- SEO: features/seo.md
|
||||
- Hot Reload: features/hot-reload.md
|
||||
- Widget Zones: features/widget-zones.md
|
||||
- Code Execution: features/code-execution.md
|
||||
- i18n: features/i18n.md
|
||||
- Marketplace: features/marketplace.md
|
||||
- Media Analysis: features/media-analysis.md
|
||||
- PWA: features/pwa.md
|
||||
- TTS: features/tts.md
|
||||
- Device Pairing: features/device-pairing.md
|
||||
- Channel Bridge: features/channel-chat-bridge.md
|
||||
- Deployment: features/deployment.md
|
||||
- Protocol Architecture: features/protocol-architecture.md
|
||||
- Feature Inventory: features/feature-inventory.md
|
||||
- Gap Analysis: features/gap-analysis.md
|
||||
- Integration Analysis: features/integration-analysis.md
|
||||
- Standardization: features/standardization-verification.md
|
||||
- Comparison (OpenClaw): features/comparison-openclaw.md
|
||||
- What Makes It Different: features/differentiation.md
|
||||
- How to Add a Feature: features/how-to-add-a-feature.md
|
||||
- Message Elements: features/message-elements.md
|
||||
- Steps: features/steps.md
|
||||
- API Reference:
|
||||
- Python CLI: api/cli.md
|
||||
- Python API: api/python.md
|
||||
@@ -131,3 +151,4 @@ nav:
|
||||
- Examples:
|
||||
- Basic Examples: examples/basic.md
|
||||
- Advanced Examples: examples/advanced.md
|
||||
- Testing: testing.md
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
[project]
|
||||
name = "aiui"
|
||||
version = "0.3.107"
|
||||
version = "0.3.108"
|
||||
description = "YAML-driven website generator - CLI and compiler"
|
||||
readme = "README.md"
|
||||
license = {text = "MIT"}
|
||||
|
||||
@@ -36,9 +36,11 @@ def __getattr__(name: str):
|
||||
_message_attrs = {"Message", "AskUserMessage", "Step", "step"}
|
||||
_server_attrs = {"register_agent", "register_page", "set_datastore", "get_datastore",
|
||||
"set_provider", "get_provider", "set_style", "set_pages", "remove_page",
|
||||
"set_branding", "set_theme", "set_custom_css", "register_theme",
|
||||
"set_branding", "set_theme", "set_custom_css", "set_custom_js",
|
||||
"register_theme",
|
||||
"set_chat_features", "set_dashboard", "set_chat_mode", "set_brand_color",
|
||||
"set_sidebar_config", "set_feedback_enabled"}
|
||||
"set_sidebar_config", "set_feedback_enabled", "register_page_action",
|
||||
"register_component_schema", "get_component_schemas"}
|
||||
_datastore_attrs = {"BaseDataStore", "MemoryDataStore", "JSONFileDataStore", "SQLAlchemyDataStore"}
|
||||
_provider_attrs = {"BaseProvider", "RunEvent", "RunEventType"}
|
||||
_providers_attrs = {"PraisonAIProvider"}
|
||||
@@ -68,7 +70,7 @@ def __getattr__(name: str):
|
||||
"toast", "dialog", "caption", "html_embed", "skeleton", "tooltip_wrap",
|
||||
# Tier C — completeness
|
||||
"time_input", "gallery", "breadcrumb", "pagination",
|
||||
"key_value_list", "popover",
|
||||
"key_value_list", "popover", "form_action",
|
||||
}
|
||||
if name in _callback_attrs:
|
||||
from praisonaiui import callbacks
|
||||
@@ -188,6 +190,7 @@ __all__ = [
|
||||
"register_theme",
|
||||
"set_pages",
|
||||
"remove_page",
|
||||
"register_page_action",
|
||||
# DataStore classes
|
||||
"BaseDataStore",
|
||||
"MemoryDataStore",
|
||||
@@ -285,4 +288,5 @@ __all__ = [
|
||||
"pagination",
|
||||
"key_value_list",
|
||||
"popover",
|
||||
]
|
||||
"form_action",
|
||||
]
|
||||
@@ -1,3 +1,3 @@
|
||||
"""Version information."""
|
||||
|
||||
__version__ = "0.2.0"
|
||||
__version__ = "0.3.108"
|
||||
@@ -0,0 +1,169 @@
|
||||
"""Component schema registry — opt-in JSON Schema contracts.
|
||||
|
||||
This closes the typed-contract gap documented in
|
||||
``docs/features/how-to-add-a-feature.md``.
|
||||
|
||||
Design goals (agent-centric, minimal API):
|
||||
- Opt-in: unregistered component types continue to work unchanged.
|
||||
- Auto-coverage: every ``praisonaiui.ui.*`` builder contributes a
|
||||
minimal built-in schema, so the registry is non-empty on first use.
|
||||
- DRY: built-ins are introspected from ``ui.py`` once per process; user
|
||||
schemas can be layered on top and take priority.
|
||||
- Performance: built-in schemas are generated lazily.
|
||||
|
||||
Public API:
|
||||
- ``register_component_schema(component_type, schema)``
|
||||
- ``get_component_schemas()``
|
||||
- ``reset_component_schemas()``
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import inspect
|
||||
import re
|
||||
from typing import Any
|
||||
|
||||
|
||||
# User-registered schemas (set via aiui.register_component_schema)
|
||||
_user_schemas: dict[str, dict[str, Any]] = {}
|
||||
|
||||
# Built-in schemas derived from ui.py; populated lazily
|
||||
_builtin_schemas: dict[str, dict[str, Any]] | None = None
|
||||
|
||||
|
||||
def register_component_schema(component_type: str, schema: dict[str, Any]) -> None:
|
||||
"""Register a JSON Schema for a custom component ``type``.
|
||||
|
||||
The schema describes the shape of the component dict (i.e. the
|
||||
payload returned by Python builders like ``aiui.card(...)``).
|
||||
|
||||
User-registered schemas take priority over built-ins: if you register
|
||||
``"card"`` with a custom schema, your version is returned from
|
||||
:func:`get_component_schemas`.
|
||||
|
||||
Args:
|
||||
component_type: Component type string, e.g. ``"timeline"``.
|
||||
schema: JSON Schema dict. Any valid Draft 2020-12 schema is OK.
|
||||
|
||||
Example::
|
||||
|
||||
aiui.register_component_schema("timeline", {
|
||||
"type": "object",
|
||||
"required": ["type", "events"],
|
||||
"properties": {
|
||||
"type": {"const": "timeline"},
|
||||
"events": {"type": "array"},
|
||||
},
|
||||
})
|
||||
"""
|
||||
_user_schemas[component_type] = schema
|
||||
|
||||
|
||||
def get_component_schemas() -> dict[str, dict[str, Any]]:
|
||||
"""Return the merged schema registry (built-in + user)."""
|
||||
builtins = _get_builtin_schemas()
|
||||
# User schemas override built-ins
|
||||
return {**builtins, **_user_schemas}
|
||||
|
||||
|
||||
def reset_component_schemas() -> None:
|
||||
"""Clear user-registered schemas. Built-ins are preserved."""
|
||||
_user_schemas.clear()
|
||||
|
||||
|
||||
# ── Built-in schema discovery ──────────────────────────────────────
|
||||
|
||||
_TYPE_RE = re.compile(r'"type":\s*"(\w+)"')
|
||||
|
||||
|
||||
def _get_builtin_schemas() -> dict[str, dict[str, Any]]:
|
||||
"""Lazily derive minimal schemas from ``praisonaiui.ui`` builders.
|
||||
|
||||
For each type string found in ``ui.py``, we emit a schema that:
|
||||
- Requires ``type`` with that constant value.
|
||||
- Includes ``properties`` inferred from the builder's keyword
|
||||
parameters (best-effort; falls back to a free-form object).
|
||||
"""
|
||||
global _builtin_schemas
|
||||
if _builtin_schemas is not None:
|
||||
return _builtin_schemas
|
||||
|
||||
from praisonaiui import ui as _ui
|
||||
|
||||
source = inspect.getsource(_ui)
|
||||
types = set(_TYPE_RE.findall(source))
|
||||
|
||||
# Map each type back to the builder function that emits it. We
|
||||
# look for the nearest `def <name>(` above each `"type": "<type>"`.
|
||||
type_to_builder: dict[str, str] = {}
|
||||
fn_def_re = re.compile(r"^def\s+(\w+)\s*\(", re.MULTILINE)
|
||||
fn_positions = [(m.group(1), m.start()) for m in fn_def_re.finditer(source)]
|
||||
for m in _TYPE_RE.finditer(source):
|
||||
pos = m.start()
|
||||
# Find the function def whose start is the largest <= pos
|
||||
candidate = None
|
||||
for fname, fpos in fn_positions:
|
||||
if fpos <= pos:
|
||||
candidate = fname
|
||||
else:
|
||||
break
|
||||
if candidate:
|
||||
type_to_builder.setdefault(m.group(1), candidate)
|
||||
|
||||
schemas: dict[str, dict[str, Any]] = {}
|
||||
for t in types:
|
||||
schemas[t] = _builder_schema(t, type_to_builder.get(t), _ui)
|
||||
_builtin_schemas = schemas
|
||||
return _builtin_schemas
|
||||
|
||||
|
||||
def _builder_schema(t: str, builder_name: str | None, ui_module: Any) -> dict[str, Any]:
|
||||
"""Derive a JSON Schema from the builder's signature."""
|
||||
props: dict[str, Any] = {"type": {"const": t}}
|
||||
required = ["type"]
|
||||
if builder_name and hasattr(ui_module, builder_name):
|
||||
fn = getattr(ui_module, builder_name)
|
||||
try:
|
||||
sig = inspect.signature(fn)
|
||||
except (TypeError, ValueError):
|
||||
sig = None
|
||||
if sig is not None:
|
||||
for pname, pparam in sig.parameters.items():
|
||||
if pname == "self":
|
||||
continue
|
||||
# Skip variadic
|
||||
if pparam.kind in (
|
||||
inspect.Parameter.VAR_POSITIONAL,
|
||||
inspect.Parameter.VAR_KEYWORD,
|
||||
):
|
||||
continue
|
||||
props[pname] = _annotation_to_schema(pparam.annotation)
|
||||
if pparam.default is inspect.Parameter.empty:
|
||||
required.append(pname)
|
||||
return {
|
||||
"type": "object",
|
||||
"required": required,
|
||||
"properties": props,
|
||||
"additionalProperties": True,
|
||||
}
|
||||
|
||||
|
||||
def _annotation_to_schema(annotation: Any) -> dict[str, Any]:
|
||||
"""Map a Python type annotation to a minimal JSON Schema fragment."""
|
||||
if annotation is inspect.Parameter.empty:
|
||||
return {}
|
||||
s = str(annotation).lower()
|
||||
# Ordered from most specific to least
|
||||
if "dict" in s:
|
||||
return {"type": "object"}
|
||||
if "sequence" in s or "list" in s or "tuple" in s:
|
||||
return {"type": "array"}
|
||||
if "bool" in s:
|
||||
return {"type": "boolean"}
|
||||
if "int" in s and "float" not in s:
|
||||
return {"type": "integer"}
|
||||
if "float" in s or "int | float" in s:
|
||||
return {"type": "number"}
|
||||
if "str" in s:
|
||||
return {"type": "string"}
|
||||
return {}
|
||||
+164
-11
@@ -18,19 +18,26 @@ from starlette.applications import Starlette
|
||||
from starlette.middleware import Middleware
|
||||
from starlette.middleware.cors import CORSMiddleware
|
||||
from starlette.requests import Request
|
||||
from starlette.responses import HTMLResponse, JSONResponse, StreamingResponse
|
||||
from starlette.responses import HTMLResponse, JSONResponse, Response, StreamingResponse
|
||||
from starlette.routing import Mount, Route
|
||||
from starlette.staticfiles import StaticFiles
|
||||
|
||||
from praisonaiui.datastore import BaseDataStore, JSONFileDataStore, MemoryDataStore
|
||||
from praisonaiui.features import auto_register_defaults, get_features
|
||||
from praisonaiui.provider import BaseProvider, RunEventType
|
||||
from praisonaiui.schemas import (
|
||||
get_component_schemas,
|
||||
register_component_schema,
|
||||
reset_component_schemas,
|
||||
)
|
||||
|
||||
# Registry for callbacks
|
||||
_callbacks: dict[str, Callable] = {}
|
||||
_agents: dict[str, dict[str, Any]] = {}
|
||||
# Registry for dashboard pages (protocol-driven)
|
||||
_pages: dict[str, dict[str, Any]] = {}
|
||||
# Registry for page action handlers (form submissions)
|
||||
_page_actions: dict[str, Callable] = {}
|
||||
# User-defined page whitelist — None means "show all built-in pages"
|
||||
_enabled_pages: Optional[set[str]] = None
|
||||
# Track which page IDs were added by @aiui.page() (always shown)
|
||||
@@ -84,6 +91,8 @@ _branding: dict[str, str] = {"title": "PraisonAI", "logo": "🦞"}
|
||||
_theme: Optional[dict[str, Any]] = None
|
||||
# Custom CSS set via aiui.set_custom_css()
|
||||
_custom_css: Optional[str] = None
|
||||
# Custom JS set via aiui.set_custom_js() — contents of a local .js file
|
||||
_custom_js: Optional[str] = None
|
||||
# Chat features override set via aiui.set_chat_features()
|
||||
_chat_features: Optional[dict[str, Any]] = None
|
||||
# Dashboard config set via aiui.set_dashboard()
|
||||
@@ -98,12 +107,13 @@ def reset_state() -> None:
|
||||
Intended for test isolation — call in test fixtures to avoid
|
||||
state leaking between tests. Not for production use.
|
||||
"""
|
||||
global _style, _branding, _theme, _custom_css, _chat_features
|
||||
global _style, _branding, _theme, _custom_css, _custom_js, _chat_features
|
||||
global _dashboard_config, _provider, _config_path, _config_cache
|
||||
global _chat_mode, _brand_color, _effective_style, _selected_profile, _feedback_enabled
|
||||
_callbacks.clear()
|
||||
_agents.clear()
|
||||
_pages.clear()
|
||||
_page_actions.clear()
|
||||
_enabled_pages_ref = globals()
|
||||
_enabled_pages_ref["_enabled_pages"] = None
|
||||
_custom_page_ids.clear()
|
||||
@@ -117,6 +127,7 @@ def reset_state() -> None:
|
||||
_branding = {"title": "PraisonAI", "logo": "🦞"}
|
||||
_theme = None
|
||||
_custom_css = None
|
||||
_custom_js = None
|
||||
_chat_features = None
|
||||
_dashboard_config = None
|
||||
_provider = None
|
||||
@@ -133,6 +144,11 @@ def reset_state() -> None:
|
||||
reset_theme_manager()
|
||||
except Exception:
|
||||
pass
|
||||
# Reset user-registered component schemas (built-ins are preserved)
|
||||
try:
|
||||
reset_component_schemas()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
def set_style(style: str) -> None:
|
||||
@@ -227,6 +243,43 @@ def set_custom_css(css: str) -> None:
|
||||
_custom_css = css
|
||||
|
||||
|
||||
def set_custom_js(path: str | Path) -> None:
|
||||
"""Inject a local JavaScript file into the UI.
|
||||
|
||||
Reads the file at ``path`` and serves it at ``/custom.js``. A
|
||||
``<script src="/custom.js">`` tag is injected into the host HTML
|
||||
*after* the plugin loader, so ``window.aiui`` and all registry APIs
|
||||
(``registerView``, ``registerComponent``) are ready when your code
|
||||
runs.
|
||||
|
||||
Use this to ship client-side extensions from your Python app without
|
||||
modifying the package bundle.
|
||||
|
||||
Args:
|
||||
path: Local filesystem path (``str`` or ``pathlib.Path``) to a
|
||||
``.js`` file.
|
||||
|
||||
Raises:
|
||||
FileNotFoundError: if ``path`` does not exist.
|
||||
|
||||
Example::
|
||||
|
||||
import praisonaiui as aiui
|
||||
aiui.set_custom_js("plugin.js")
|
||||
|
||||
Where ``plugin.js`` contains::
|
||||
|
||||
window.aiui.registerComponent('timeline', function(comp) {
|
||||
// ...render DOM and return it
|
||||
});
|
||||
"""
|
||||
global _custom_js
|
||||
p = Path(path)
|
||||
if not p.exists():
|
||||
raise FileNotFoundError(f"Custom JS file not found: {p}")
|
||||
_custom_js = p.read_text(encoding="utf-8")
|
||||
|
||||
|
||||
def set_chat_features(
|
||||
*,
|
||||
history: bool = True,
|
||||
@@ -529,12 +582,31 @@ def _build_html(style: str) -> str:
|
||||
)
|
||||
|
||||
# Inject custom CSS if set via set_custom_css() or YAML site.custom_css
|
||||
# A tiny observer script keeps it as the last <style> in <head> so that
|
||||
# plugin-injected styles (DASHBOARD_STYLE, chat-view-styles, etc.) never
|
||||
# override user CSS via cascade order.
|
||||
custom_css_tag = ''
|
||||
_css = _custom_css
|
||||
if not _css and _site_section:
|
||||
_css = _site_section.get("custom_css") or _site_section.get("customCss")
|
||||
if _css:
|
||||
custom_css_tag = f'<style id="aiui-custom-css">{_css}</style>'
|
||||
custom_css_tag = (
|
||||
f'<style id="aiui-custom-css">{_css}</style>'
|
||||
'<script id="aiui-css-guard">'
|
||||
'(function(){'
|
||||
'var c=document.getElementById("aiui-custom-css");'
|
||||
'if(!c)return;'
|
||||
'new MutationObserver(function(ms){'
|
||||
'for(var i=0;i<ms.length;i++){'
|
||||
'for(var j=0;j<ms[i].addedNodes.length;j++){'
|
||||
'var n=ms[i].addedNodes[j];'
|
||||
'if(n.tagName==="STYLE"&&n.id!=="aiui-custom-css"){'
|
||||
'document.head.appendChild(c);'
|
||||
'}}}'
|
||||
'}).observe(document.head,{childList:true});'
|
||||
'})()'
|
||||
'</script>'
|
||||
)
|
||||
|
||||
# Server-side theme variable injection (dashboard style)
|
||||
# Only inject accent-related vars with !important so that:
|
||||
@@ -574,10 +646,28 @@ def _build_html(style: str) -> str:
|
||||
'<div id="root"></div>'
|
||||
f'{react_script}'
|
||||
f'<script src="/plugins/plugin-loader.js?v={cache_bust}"></script>'
|
||||
f'{_custom_js_tag(cache_bust)}'
|
||||
'</body></html>'
|
||||
)
|
||||
|
||||
|
||||
def _custom_js_tag(cache_bust: int) -> str:
|
||||
"""Return a <script src="/custom.js"> tag if set_custom_js() was called."""
|
||||
if _custom_js is None:
|
||||
return ""
|
||||
return f'<script src="/custom.js?v={cache_bust}"></script>'
|
||||
|
||||
|
||||
async def _serve_custom_js(request: Request) -> Response:
|
||||
"""Serve the user's custom.js set via aiui.set_custom_js()."""
|
||||
if _custom_js is None:
|
||||
return Response(status_code=404)
|
||||
return Response(
|
||||
content=_custom_js,
|
||||
media_type="application/javascript",
|
||||
)
|
||||
|
||||
|
||||
# Shared effective style — set by create_app(), read by _plugins_config()
|
||||
_effective_style: str = "chat"
|
||||
|
||||
@@ -700,6 +790,25 @@ def register_page(
|
||||
register_callback(f"page:{id}", handler)
|
||||
|
||||
|
||||
def register_page_action(page_id: str):
|
||||
"""Decorator to register a form action handler for a page.
|
||||
|
||||
The decorated async function receives the form data dict and should
|
||||
return a dict that will be JSON-serialized back to the client.
|
||||
|
||||
Example::
|
||||
|
||||
@aiui.register_page_action("settings")
|
||||
async def handle_settings(data):
|
||||
save_settings(data)
|
||||
return {"status": "saved"}
|
||||
"""
|
||||
def decorator(fn: Callable) -> Callable:
|
||||
_page_actions[page_id] = fn
|
||||
return fn
|
||||
return decorator
|
||||
|
||||
|
||||
async def health(request: Request) -> JSONResponse:
|
||||
"""Health check endpoint."""
|
||||
provider = get_provider()
|
||||
@@ -1139,17 +1248,32 @@ async def api_pages(request: Request) -> JSONResponse:
|
||||
return JSONResponse({"pages": pages})
|
||||
|
||||
|
||||
async def api_component_schemas(request: Request) -> JSONResponse:
|
||||
"""Return the merged component schema registry (built-in + user-registered).
|
||||
|
||||
Use this endpoint to power client-side validators, codegen, and docs.
|
||||
"""
|
||||
return JSONResponse({"schemas": get_component_schemas()})
|
||||
|
||||
|
||||
async def api_features(request: Request) -> JSONResponse:
|
||||
"""List all registered feature protocols."""
|
||||
features = get_features()
|
||||
infos = []
|
||||
for f in features.values():
|
||||
"""List all registered feature protocols.
|
||||
|
||||
Calls ``info()`` on every registered feature concurrently (via
|
||||
``asyncio.gather``). This avoids the previous N*latency cold-start
|
||||
time where features with slow imports (e.g. LiteLLM) serialized the
|
||||
whole response.
|
||||
"""
|
||||
features = list(get_features().values())
|
||||
|
||||
async def _safe_info(f):
|
||||
try:
|
||||
info = await f.info()
|
||||
infos.append(info)
|
||||
return await f.info()
|
||||
except Exception:
|
||||
infos.append({"name": f.name, "status": "error"})
|
||||
return JSONResponse({"features": infos, "count": len(infos)})
|
||||
return {"name": f.name, "status": "error"}
|
||||
|
||||
infos = await asyncio.gather(*(_safe_info(f) for f in features))
|
||||
return JSONResponse({"features": list(infos), "count": len(infos)})
|
||||
|
||||
|
||||
async def api_gateway_status(request: Request) -> JSONResponse:
|
||||
@@ -1207,6 +1331,28 @@ async def api_page_data(request: Request) -> JSONResponse:
|
||||
return JSONResponse({"error": str(e)}, status_code=500)
|
||||
|
||||
|
||||
async def api_page_action(request: Request) -> JSONResponse:
|
||||
"""Handle form action submissions for a page."""
|
||||
page_id = request.path_params["page_id"]
|
||||
handler = _page_actions.get(page_id)
|
||||
if not handler:
|
||||
return JSONResponse(
|
||||
{"error": f"No action handler for page '{page_id}'"},
|
||||
status_code=404,
|
||||
)
|
||||
try:
|
||||
data = await request.json()
|
||||
import asyncio as _aio
|
||||
result = handler(data)
|
||||
if _aio.iscoroutine(result):
|
||||
result = await result
|
||||
if not isinstance(result, dict):
|
||||
result = {"result": result}
|
||||
return JSONResponse(result)
|
||||
except Exception as e:
|
||||
return JSONResponse({"error": str(e)}, status_code=500)
|
||||
|
||||
|
||||
def _get_version() -> str:
|
||||
"""Get praisonaiui version."""
|
||||
try:
|
||||
@@ -2004,8 +2150,11 @@ def create_app(
|
||||
# Page registry protocol
|
||||
Route("/api/pages", api_pages, methods=["GET"]),
|
||||
Route("/api/pages/{page_id}/data", api_page_data, methods=["GET"]),
|
||||
Route("/api/pages/{page_id}/action", api_page_action, methods=["POST"]),
|
||||
# Feature protocol registry
|
||||
Route("/api/features", api_features, methods=["GET"]),
|
||||
# Component schema registry (built-in + user-registered)
|
||||
Route("/api/components/schemas", api_component_schemas, methods=["GET"]),
|
||||
# Gateway status
|
||||
Route("/api/gateway/status", api_gateway_status, methods=["GET"]),
|
||||
# Frontend config JSON (dynamic fallback — static files override if present)
|
||||
@@ -2181,6 +2330,10 @@ def create_app(
|
||||
# so the dynamic endpoint takes priority over the static file.
|
||||
routes.append(Route("/plugins/plugins.json", _plugins_config, methods=["GET"]))
|
||||
|
||||
# User-provided custom JS via aiui.set_custom_js(). Always register so that
|
||||
# it returns 404 when unset (tests rely on this) and serves content otherwise.
|
||||
routes.append(Route("/custom.js", _serve_custom_js, methods=["GET"]))
|
||||
|
||||
if _plugins_dir.exists():
|
||||
routes.append(Mount("/plugins", app=StaticFiles(directory=str(_plugins_dir))))
|
||||
if _assets_dir.exists():
|
||||
|
||||
@@ -9,6 +9,11 @@
|
||||
// Record load time for overview stats
|
||||
window.__aiuiLoadTime = window.__aiuiLoadTime || Date.now();
|
||||
|
||||
// ── Extensible Component Registry ────────────────────────────────────
|
||||
// Maps component type names to custom render functions.
|
||||
// Custom renderers take priority over the built-in switch in renderComponent().
|
||||
const COMPONENT_REGISTRY = {};
|
||||
|
||||
// ── Extensible View Registry ─────────────────────────────────────────
|
||||
// Maps page IDs to view modules. Each module exports render(container).
|
||||
// Built-in views are loaded via dynamic import from ./views/
|
||||
@@ -52,6 +57,10 @@ window.aiui.registerView = function(pageId, renderFn, cleanupFn) {
|
||||
VIEW_REGISTRY[pageId] = { render: renderFn, cleanup: cleanupFn || null };
|
||||
};
|
||||
window.aiui.views = VIEW_REGISTRY;
|
||||
window.aiui.registerComponent = function(type, renderFn) {
|
||||
COMPONENT_REGISTRY[type] = renderFn;
|
||||
};
|
||||
window.aiui.components = COMPONENT_REGISTRY;
|
||||
|
||||
// ── Sidebar State ────────────────────────────────────────────────
|
||||
let sidebarCollapsed = false;
|
||||
@@ -605,10 +614,6 @@ async function init() {
|
||||
style.textContent = DASHBOARD_STYLE;
|
||||
document.head.appendChild(style);
|
||||
|
||||
// Re-append user custom CSS so it takes precedence over DASHBOARD_STYLE defaults
|
||||
const customCss = document.getElementById('aiui-custom-css');
|
||||
if (customCss) document.head.appendChild(customCss);
|
||||
|
||||
// Apply theme from config BEFORE building dashboard
|
||||
try {
|
||||
const cfgRes = await fetch('/ui-config.json');
|
||||
@@ -915,6 +920,10 @@ function renderComponents(data, container) {
|
||||
}
|
||||
|
||||
function renderComponent(comp) {
|
||||
// Extensible: check COMPONENT_REGISTRY first (custom renderers take priority)
|
||||
if (COMPONENT_REGISTRY[comp.type]) {
|
||||
try { return COMPONENT_REGISTRY[comp.type](comp); } catch (e) { console.warn(`Custom component '${comp.type}' error:`, e); }
|
||||
}
|
||||
switch (comp.type) {
|
||||
case 'card': return renderCard(comp);
|
||||
case 'columns': return renderColumns(comp);
|
||||
@@ -969,6 +978,7 @@ function renderComponent(comp) {
|
||||
case 'pagination': return renderPagination(comp);
|
||||
case 'key_value_list': return renderKeyValueList(comp);
|
||||
case 'popover': return renderPopover(comp);
|
||||
case 'form_action': return renderFormAction(comp);
|
||||
default: {
|
||||
const div = document.createElement('div');
|
||||
div.className = 'db-viewer';
|
||||
@@ -1219,7 +1229,7 @@ function renderTextInput(comp) {
|
||||
el.className = 'db-form-group';
|
||||
el.innerHTML = `
|
||||
${comp.label ? `<label class="db-form-label">${comp.label}</label>` : ''}
|
||||
<input type="text" class="db-form-input" value="${comp.value || ''}" placeholder="${comp.placeholder || ''}">
|
||||
<input type="text" class="db-form-input" name="${comp.name || ''}" data-label="${comp.label || ''}" value="${comp.value || ''}" placeholder="${comp.placeholder || ''}">
|
||||
`;
|
||||
return el;
|
||||
}
|
||||
@@ -1233,7 +1243,7 @@ function renderNumberInput(comp) {
|
||||
if (comp.step !== undefined) attrs.push(`step="${comp.step}"`);
|
||||
el.innerHTML = `
|
||||
${comp.label ? `<label class="db-form-label">${comp.label}</label>` : ''}
|
||||
<input type="number" class="db-form-input" value="${comp.value || ''}" ${attrs.join(' ')}>
|
||||
<input type="number" class="db-form-input" name="${comp.name || ''}" data-label="${comp.label || ''}" value="${comp.value || ''}" ${attrs.join(' ')}>
|
||||
`;
|
||||
return el;
|
||||
}
|
||||
@@ -1249,7 +1259,7 @@ function renderSelectInput(comp) {
|
||||
}).join('');
|
||||
el.innerHTML = `
|
||||
${comp.label ? `<label class="db-form-label">${comp.label}</label>` : ''}
|
||||
<select class="db-form-select">${options}</select>
|
||||
<select class="db-form-select" name="${comp.name || ''}" data-label="${comp.label || ''}">${options}</select>
|
||||
`;
|
||||
return el;
|
||||
}
|
||||
@@ -1264,7 +1274,7 @@ function renderSliderInput(comp) {
|
||||
el.innerHTML = `
|
||||
${comp.label ? `<label class="db-form-label">${comp.label}</label>` : ''}
|
||||
<div style="display:flex;align-items:center;gap:12px">
|
||||
<input type="range" class="db-form-input" style="flex:1" value="${val}" min="${min}" max="${max}" step="${step}">
|
||||
<input type="range" class="db-form-input" name="${comp.name || ''}" data-label="${comp.label || ''}" style="flex:1" value="${val}" min="${min}" max="${max}" step="${step}">
|
||||
<span style="font-size:13px;color:var(--db-text);min-width:40px;text-align:right">${val}</span>
|
||||
</div>
|
||||
`;
|
||||
@@ -1278,7 +1288,7 @@ function renderCheckboxInput(comp) {
|
||||
const el = document.createElement('div');
|
||||
el.className = 'db-form-group db-form-checkbox';
|
||||
el.innerHTML = `
|
||||
<input type="checkbox" ${comp.checked ? 'checked' : ''}>
|
||||
<input type="checkbox" name="${comp.name || ''}" data-label="${comp.label || ''}" ${comp.checked ? 'checked' : ''}>
|
||||
<label class="db-form-label" style="margin:0">${comp.label || ''}</label>
|
||||
`;
|
||||
return el;
|
||||
@@ -1288,24 +1298,32 @@ function renderSwitchInput(comp) {
|
||||
const el = document.createElement('div');
|
||||
el.className = 'db-form-group db-form-switch';
|
||||
const on = comp.checked ? ' on' : '';
|
||||
// Hidden checkbox mirrors the switch state so form_action can collect its value.
|
||||
el.innerHTML = `
|
||||
<input type="checkbox" class="db-switch-hidden" name="${comp.name || ''}" data-label="${comp.label || ''}" ${comp.checked ? 'checked' : ''} style="display:none">
|
||||
<div class="db-switch-track${on}"><div class="db-switch-thumb"></div></div>
|
||||
<span style="font-size:13px;color:var(--db-text)">${comp.label || ''}</span>
|
||||
`;
|
||||
const track = el.querySelector('.db-switch-track');
|
||||
track.addEventListener('click', () => { track.classList.toggle('on'); });
|
||||
const hidden = el.querySelector('input.db-switch-hidden');
|
||||
track.addEventListener('click', () => {
|
||||
track.classList.toggle('on');
|
||||
hidden.checked = track.classList.contains('on');
|
||||
});
|
||||
return el;
|
||||
}
|
||||
|
||||
function renderRadioInput(comp) {
|
||||
const el = document.createElement('div');
|
||||
el.className = 'db-form-group db-form-radio';
|
||||
const name = 'radio_' + Math.random().toString(36).slice(2, 8);
|
||||
// Prefer explicit comp.name (needed for form_action key), fall back to
|
||||
// random for the HTML radio-group semantic.
|
||||
const name = comp.name || ('radio_' + Math.random().toString(36).slice(2, 8));
|
||||
const options = (comp.options || []).map(opt => {
|
||||
const val = typeof opt === 'object' ? opt.value : opt;
|
||||
const label = typeof opt === 'object' ? opt.label : opt;
|
||||
const checked = val === comp.value ? ' checked' : '';
|
||||
return `<label><input type="radio" name="${name}" value="${val}"${checked}>${label}</label>`;
|
||||
return `<label><input type="radio" name="${name}" data-label="${comp.label || ''}" value="${val}"${checked}>${label}</label>`;
|
||||
}).join('');
|
||||
el.innerHTML = `
|
||||
${comp.label ? `<div class="db-form-label">${comp.label}</div>` : ''}
|
||||
@@ -1320,7 +1338,7 @@ function renderTextareaInput(comp) {
|
||||
const rows = comp.rows || 4;
|
||||
el.innerHTML = `
|
||||
${comp.label ? `<label class="db-form-label">${comp.label}</label>` : ''}
|
||||
<textarea class="db-form-textarea" rows="${rows}" placeholder="${comp.placeholder || ''}">${comp.value || ''}</textarea>
|
||||
<textarea class="db-form-textarea" name="${comp.name || ''}" data-label="${comp.label || ''}" rows="${rows}" placeholder="${comp.placeholder || ''}">${comp.value || ''}</textarea>
|
||||
`;
|
||||
return el;
|
||||
}
|
||||
@@ -1749,6 +1767,52 @@ function renderPopover(comp) {
|
||||
return el;
|
||||
}
|
||||
|
||||
function renderFormAction(comp) {
|
||||
const form = document.createElement('form');
|
||||
form.className = 'db-form-action';
|
||||
form.dataset.action = comp.action || '';
|
||||
(comp.children || []).forEach(child => form.appendChild(renderComponent(child)));
|
||||
const btn = document.createElement('button');
|
||||
btn.type = 'submit';
|
||||
btn.className = 'db-btn db-btn-primary';
|
||||
btn.textContent = comp.submit_label || 'Submit';
|
||||
form.appendChild(btn);
|
||||
form.addEventListener('submit', async (e) => {
|
||||
e.preventDefault();
|
||||
const data = {};
|
||||
form.querySelectorAll('input, select, textarea').forEach(el => {
|
||||
const key = el.name || el.dataset.label || el.getAttribute('aria-label') || '';
|
||||
if (!key) return;
|
||||
if (el.type === 'radio') {
|
||||
// Only store the checked radio in a group; skip unchecked ones.
|
||||
if (el.checked) data[key] = el.value;
|
||||
else if (!(key in data)) data[key] = null;
|
||||
} else if (el.type === 'checkbox') {
|
||||
data[key] = el.checked;
|
||||
} else {
|
||||
data[key] = el.value;
|
||||
}
|
||||
});
|
||||
btn.disabled = true;
|
||||
btn.textContent = 'Submitting...';
|
||||
try {
|
||||
const pageId = form.closest('[data-page]')?.dataset?.page || comp.action;
|
||||
const res = await fetch(`/api/pages/${pageId}/action`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(data),
|
||||
});
|
||||
const result = await res.json();
|
||||
btn.textContent = res.ok ? 'Done' : 'Error';
|
||||
setTimeout(() => { btn.textContent = comp.submit_label || 'Submit'; btn.disabled = false; }, 2000);
|
||||
} catch (err) {
|
||||
btn.textContent = 'Error';
|
||||
btn.disabled = false;
|
||||
}
|
||||
});
|
||||
return form;
|
||||
}
|
||||
|
||||
// ── Test All Features panel ────────────────────────────────
|
||||
|
||||
async function showTestPanel() {
|
||||
|
||||
+108
-13
@@ -239,15 +239,28 @@ def json_view(data: Any) -> dict:
|
||||
# ── Tier 2: Form Input Components ───────────────────────────────────
|
||||
|
||||
|
||||
def text_input(label: str, *, value: str = "", placeholder: str = "") -> dict:
|
||||
def text_input(
|
||||
label: str,
|
||||
*,
|
||||
value: str = "",
|
||||
placeholder: str = "",
|
||||
name: str | None = None,
|
||||
) -> dict:
|
||||
"""A text input field.
|
||||
|
||||
Args:
|
||||
label: Input label
|
||||
value: Default value
|
||||
placeholder: Placeholder text
|
||||
name: Optional explicit submit-key for ``form_action``. When
|
||||
omitted, the form collector falls back to ``label``. Use
|
||||
``name`` whenever you have duplicate labels or want a stable
|
||||
machine-readable key.
|
||||
"""
|
||||
return {"type": "text_input", "label": label, "value": value, "placeholder": placeholder}
|
||||
comp: dict[str, Any] = {"type": "text_input", "label": label, "value": value, "placeholder": placeholder}
|
||||
if name is not None:
|
||||
comp["name"] = name
|
||||
return comp
|
||||
|
||||
|
||||
def number_input(
|
||||
@@ -257,6 +270,7 @@ def number_input(
|
||||
min_val: int | float | None = None,
|
||||
max_val: int | float | None = None,
|
||||
step: int | float = 1,
|
||||
name: str | None = None,
|
||||
) -> dict:
|
||||
"""A number input field.
|
||||
|
||||
@@ -266,24 +280,37 @@ def number_input(
|
||||
min_val: Minimum allowed value
|
||||
max_val: Maximum allowed value
|
||||
step: Increment step
|
||||
name: Optional explicit submit-key for ``form_action``.
|
||||
"""
|
||||
comp: dict[str, Any] = {"type": "number_input", "label": label, "value": value, "step": step}
|
||||
if min_val is not None:
|
||||
comp["min_val"] = min_val
|
||||
if max_val is not None:
|
||||
comp["max_val"] = max_val
|
||||
if name is not None:
|
||||
comp["name"] = name
|
||||
return comp
|
||||
|
||||
|
||||
def select_input(label: str, *, options: Sequence[str], value: str = "") -> dict:
|
||||
def select_input(
|
||||
label: str,
|
||||
*,
|
||||
options: Sequence[str],
|
||||
value: str = "",
|
||||
name: str | None = None,
|
||||
) -> dict:
|
||||
"""A dropdown select input.
|
||||
|
||||
Args:
|
||||
label: Input label
|
||||
options: List of option strings
|
||||
value: Default selected value
|
||||
name: Optional explicit submit-key for ``form_action``.
|
||||
"""
|
||||
return {"type": "select_input", "label": label, "options": list(options), "value": value}
|
||||
comp: dict[str, Any] = {"type": "select_input", "label": label, "options": list(options), "value": value}
|
||||
if name is not None:
|
||||
comp["name"] = name
|
||||
return comp
|
||||
|
||||
|
||||
def slider_input(
|
||||
@@ -293,6 +320,7 @@ def slider_input(
|
||||
min_val: int | float = 0,
|
||||
max_val: int | float = 100,
|
||||
step: int | float = 1,
|
||||
name: str | None = None,
|
||||
) -> dict:
|
||||
"""A slider input.
|
||||
|
||||
@@ -302,45 +330,84 @@ def slider_input(
|
||||
min_val: Minimum value
|
||||
max_val: Maximum value
|
||||
step: Increment step
|
||||
name: Optional explicit submit-key for ``form_action``.
|
||||
"""
|
||||
return {
|
||||
comp: dict[str, Any] = {
|
||||
"type": "slider_input", "label": label, "value": value,
|
||||
"min_val": min_val, "max_val": max_val, "step": step,
|
||||
}
|
||||
if name is not None:
|
||||
comp["name"] = name
|
||||
return comp
|
||||
|
||||
|
||||
def checkbox_input(label: str, *, checked: bool = False) -> dict:
|
||||
def checkbox_input(
|
||||
label: str,
|
||||
*,
|
||||
checked: bool = False,
|
||||
name: str | None = None,
|
||||
) -> dict:
|
||||
"""A checkbox input.
|
||||
|
||||
Args:
|
||||
label: Checkbox label
|
||||
checked: Default checked state
|
||||
name: Optional explicit submit-key for ``form_action``.
|
||||
"""
|
||||
return {"type": "checkbox_input", "label": label, "checked": checked}
|
||||
comp: dict[str, Any] = {"type": "checkbox_input", "label": label, "checked": checked}
|
||||
if name is not None:
|
||||
comp["name"] = name
|
||||
return comp
|
||||
|
||||
|
||||
def switch_input(label: str, *, checked: bool = False) -> dict:
|
||||
def switch_input(
|
||||
label: str,
|
||||
*,
|
||||
checked: bool = False,
|
||||
name: str | None = None,
|
||||
) -> dict:
|
||||
"""A toggle switch input.
|
||||
|
||||
Args:
|
||||
label: Switch label
|
||||
checked: Default checked state
|
||||
name: Optional explicit submit-key for ``form_action``.
|
||||
"""
|
||||
return {"type": "switch_input", "label": label, "checked": checked}
|
||||
comp: dict[str, Any] = {"type": "switch_input", "label": label, "checked": checked}
|
||||
if name is not None:
|
||||
comp["name"] = name
|
||||
return comp
|
||||
|
||||
|
||||
def radio_input(label: str, *, options: Sequence[str], value: str = "") -> dict:
|
||||
def radio_input(
|
||||
label: str,
|
||||
*,
|
||||
options: Sequence[str],
|
||||
value: str = "",
|
||||
name: str | None = None,
|
||||
) -> dict:
|
||||
"""A radio button group.
|
||||
|
||||
Args:
|
||||
label: Group label
|
||||
options: List of option strings
|
||||
value: Default selected value
|
||||
name: Optional explicit submit-key for ``form_action``.
|
||||
"""
|
||||
return {"type": "radio_input", "label": label, "options": list(options), "value": value}
|
||||
comp: dict[str, Any] = {"type": "radio_input", "label": label, "options": list(options), "value": value}
|
||||
if name is not None:
|
||||
comp["name"] = name
|
||||
return comp
|
||||
|
||||
|
||||
def textarea_input(label: str, *, value: str = "", placeholder: str = "", rows: int = 4) -> dict:
|
||||
def textarea_input(
|
||||
label: str,
|
||||
*,
|
||||
value: str = "",
|
||||
placeholder: str = "",
|
||||
rows: int = 4,
|
||||
name: str | None = None,
|
||||
) -> dict:
|
||||
"""A multi-line text area input.
|
||||
|
||||
Args:
|
||||
@@ -348,8 +415,12 @@ def textarea_input(label: str, *, value: str = "", placeholder: str = "", rows:
|
||||
value: Default value
|
||||
placeholder: Placeholder text
|
||||
rows: Number of visible rows
|
||||
name: Optional explicit submit-key for ``form_action``.
|
||||
"""
|
||||
return {"type": "textarea_input", "label": label, "value": value, "placeholder": placeholder, "rows": rows}
|
||||
comp: dict[str, Any] = {"type": "textarea_input", "label": label, "value": value, "placeholder": placeholder, "rows": rows}
|
||||
if name is not None:
|
||||
comp["name"] = name
|
||||
return comp
|
||||
|
||||
|
||||
# ── Tier 3: Layout & Advanced Components ─────────────────────────────
|
||||
@@ -724,3 +795,27 @@ def popover(trigger: dict, *, children: Sequence[dict]) -> dict:
|
||||
children: Content components inside the popover
|
||||
"""
|
||||
return {"type": "popover", "trigger": trigger, "children": list(children)}
|
||||
|
||||
|
||||
def form_action(
|
||||
action: str,
|
||||
*,
|
||||
children: Sequence[dict],
|
||||
submit_label: str = "Submit",
|
||||
) -> dict:
|
||||
"""A form container that submits input values to the server.
|
||||
|
||||
When the user clicks submit, the frontend collects all input values
|
||||
from ``children`` and POSTs them to ``/api/pages/{page_id}/action``.
|
||||
|
||||
Args:
|
||||
action: Action identifier (typically matches the page_id).
|
||||
children: Form input components (text_input, select_input, etc.)
|
||||
submit_label: Label for the submit button. Default "Submit".
|
||||
"""
|
||||
return {
|
||||
"type": "form_action",
|
||||
"action": action,
|
||||
"children": list(children),
|
||||
"submit_label": submit_label,
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
"""Test that every symbol in praisonaiui.__all__ is importable."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import importlib
|
||||
|
||||
|
||||
def test_all_exports_importable():
|
||||
"""Every name in __all__ must be accessible via getattr."""
|
||||
mod = importlib.import_module("praisonaiui")
|
||||
missing = []
|
||||
for name in mod.__all__:
|
||||
try:
|
||||
getattr(mod, name)
|
||||
except (AttributeError, ImportError) as e:
|
||||
missing.append((name, str(e)))
|
||||
assert not missing, f"Failed to import: {missing}"
|
||||
|
||||
|
||||
def test_all_exports_count():
|
||||
"""Sanity check — __all__ should have 80+ symbols."""
|
||||
mod = importlib.import_module("praisonaiui")
|
||||
assert len(mod.__all__) >= 80, f"Only {len(mod.__all__)} exports"
|
||||
@@ -0,0 +1,87 @@
|
||||
"""Tests for the component registry extensibility in dashboard.js.
|
||||
|
||||
Verifies:
|
||||
1. COMPONENT_REGISTRY object exists
|
||||
2. window.aiui.registerComponent() API exists
|
||||
3. renderComponent() checks COMPONENT_REGISTRY before hardcoded switch
|
||||
4. All Python ui.py component types have a matching JS renderer case
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
# Path to dashboard.js
|
||||
DASHBOARD_JS = (
|
||||
Path(__file__).resolve().parents[2]
|
||||
/ "src" / "praisonaiui" / "templates" / "frontend" / "plugins" / "dashboard.js"
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def dashboard_source():
|
||||
"""Read dashboard.js source."""
|
||||
return DASHBOARD_JS.read_text()
|
||||
|
||||
|
||||
class TestComponentRegistry:
|
||||
"""Verify the extensible component registry exists."""
|
||||
|
||||
def test_component_registry_object_exists(self, dashboard_source):
|
||||
assert "COMPONENT_REGISTRY" in dashboard_source
|
||||
|
||||
def test_register_component_api_exists(self, dashboard_source):
|
||||
assert "registerComponent" in dashboard_source
|
||||
|
||||
def test_register_component_is_on_window_aiui(self, dashboard_source):
|
||||
assert "window.aiui.registerComponent" in dashboard_source
|
||||
|
||||
def test_render_component_checks_registry_first(self, dashboard_source):
|
||||
"""renderComponent() must check COMPONENT_REGISTRY before the switch."""
|
||||
# Find the renderComponent function
|
||||
match = re.search(
|
||||
r"function renderComponent\(comp\)\s*\{(.*?)^}",
|
||||
dashboard_source,
|
||||
re.DOTALL | re.MULTILINE,
|
||||
)
|
||||
assert match, "renderComponent function not found"
|
||||
body = match.group(1)
|
||||
|
||||
# Registry check must come before the switch
|
||||
registry_pos = body.find("COMPONENT_REGISTRY")
|
||||
switch_pos = body.find("switch")
|
||||
assert registry_pos != -1, "COMPONENT_REGISTRY not referenced in renderComponent"
|
||||
assert registry_pos < switch_pos, (
|
||||
"COMPONENT_REGISTRY check must come BEFORE the switch statement"
|
||||
)
|
||||
|
||||
|
||||
class TestPythonJSComponentParity:
|
||||
"""Every Python component type in ui.py must have a JS renderer."""
|
||||
|
||||
def test_all_python_types_have_js_case(self, dashboard_source):
|
||||
"""Extract all type strings from ui.py and verify each has a case in dashboard.js."""
|
||||
ui_py = (
|
||||
Path(__file__).resolve().parents[2]
|
||||
/ "src" / "praisonaiui" / "ui.py"
|
||||
)
|
||||
source = ui_py.read_text()
|
||||
|
||||
# Extract all "type": "xxx" patterns from ui.py
|
||||
py_types = set(re.findall(r'"type":\s*"(\w+)"', source))
|
||||
assert len(py_types) > 40, f"Expected 40+ component types, got {len(py_types)}"
|
||||
|
||||
# Extract all case 'xxx': patterns from dashboard.js renderComponent switch
|
||||
js_cases = set(re.findall(r"case\s+'(\w+)'", dashboard_source))
|
||||
|
||||
missing = py_types - js_cases
|
||||
assert not missing, (
|
||||
f"Python component types missing JS renderer: {sorted(missing)}"
|
||||
)
|
||||
|
||||
def test_form_action_type_has_js_case(self, dashboard_source):
|
||||
"""The form_action component must have a JS renderer."""
|
||||
assert "case 'form_action'" in dashboard_source
|
||||
@@ -0,0 +1,121 @@
|
||||
"""Tests for the opt-in component schema registry.
|
||||
|
||||
Closes the gap: there was no typed Python <-> JS contract for custom
|
||||
components, so component dicts could silently drift.
|
||||
|
||||
Design:
|
||||
- Users call `aiui.register_component_schema("my-type", {...})` to register
|
||||
a JSON Schema describing their custom component dict.
|
||||
- Schemas are published at `GET /api/components/schemas` for client-side
|
||||
validators, codegen, and docs.
|
||||
- Schemas for all 48 built-in components are provided out of the box.
|
||||
|
||||
The registry is opt-in: unregistered types continue to work as before.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
from starlette.testclient import TestClient
|
||||
|
||||
from praisonaiui import server as srv
|
||||
from praisonaiui.datastore import MemoryDataStore
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _clean():
|
||||
srv.reset_state()
|
||||
srv.set_datastore(MemoryDataStore())
|
||||
yield
|
||||
srv.reset_state()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def client():
|
||||
return TestClient(srv.create_app())
|
||||
|
||||
|
||||
# ── Python API surface ────────────────────────────────────────────
|
||||
|
||||
class TestRegisterComponentSchema:
|
||||
def test_is_exported_on_package(self):
|
||||
import praisonaiui as aiui
|
||||
assert callable(aiui.register_component_schema)
|
||||
assert callable(aiui.get_component_schemas)
|
||||
|
||||
def test_register_stores_schema(self):
|
||||
schema = {
|
||||
"type": "object",
|
||||
"required": ["type", "events"],
|
||||
"properties": {
|
||||
"type": {"const": "timeline"},
|
||||
"events": {"type": "array"},
|
||||
},
|
||||
}
|
||||
srv.register_component_schema("timeline", schema)
|
||||
schemas = srv.get_component_schemas()
|
||||
assert "timeline" in schemas
|
||||
assert schemas["timeline"]["required"] == ["type", "events"]
|
||||
|
||||
def test_register_multiple(self):
|
||||
srv.register_component_schema("a", {"type": "object"})
|
||||
srv.register_component_schema("b", {"type": "object"})
|
||||
schemas = srv.get_component_schemas()
|
||||
assert {"a", "b"}.issubset(schemas)
|
||||
|
||||
def test_reset_state_clears_user_schemas(self):
|
||||
srv.register_component_schema("custom-x", {"type": "object"})
|
||||
srv.reset_state()
|
||||
assert "custom-x" not in srv.get_component_schemas()
|
||||
|
||||
def test_reset_state_preserves_builtins(self):
|
||||
"""Built-in schemas must remain after reset_state()."""
|
||||
srv.reset_state()
|
||||
schemas = srv.get_component_schemas()
|
||||
# These are built-in Python components
|
||||
assert "card" in schemas
|
||||
assert "text_input" in schemas
|
||||
|
||||
|
||||
# ── Endpoint ──────────────────────────────────────────────────────
|
||||
|
||||
class TestSchemasEndpoint:
|
||||
def test_endpoint_returns_schemas(self, client):
|
||||
resp = client.get("/api/components/schemas")
|
||||
assert resp.status_code == 200
|
||||
body = resp.json()
|
||||
assert "schemas" in body
|
||||
assert isinstance(body["schemas"], dict)
|
||||
|
||||
def test_endpoint_includes_registered(self, client):
|
||||
srv.register_component_schema("my-thing", {"type": "object"})
|
||||
resp = client.get("/api/components/schemas")
|
||||
assert resp.status_code == 200
|
||||
assert "my-thing" in resp.json()["schemas"]
|
||||
|
||||
def test_endpoint_includes_builtins(self, client):
|
||||
resp = client.get("/api/components/schemas")
|
||||
schemas = resp.json()["schemas"]
|
||||
# At least a sample of the Python ui.py builders
|
||||
for t in ("card", "text", "chart", "text_input"):
|
||||
assert t in schemas, f"{t} schema missing"
|
||||
|
||||
|
||||
# ── Built-in schema coverage ──────────────────────────────────────
|
||||
|
||||
class TestBuiltinSchemaCoverage:
|
||||
def test_all_public_ui_types_have_schema(self):
|
||||
"""Every component type emitted by praisonaiui.ui.* must have a schema."""
|
||||
import re
|
||||
from pathlib import Path
|
||||
ui_src = (
|
||||
Path(__file__).resolve().parents[2]
|
||||
/ "src" / "praisonaiui" / "ui.py"
|
||||
).read_text()
|
||||
py_types = set(re.findall(r'"type":\s*"(\w+)"', ui_src))
|
||||
schemas = srv.get_component_schemas()
|
||||
missing = py_types - set(schemas)
|
||||
assert not missing, (
|
||||
f"Built-in component types missing from schema registry: "
|
||||
f"{sorted(missing)}"
|
||||
)
|
||||
@@ -0,0 +1,270 @@
|
||||
"""Tests for server config API functions that were missing test coverage.
|
||||
|
||||
Covers: set_chat_mode, set_brand_color, set_chat_features, set_dashboard,
|
||||
set_sidebar_config, register_theme, configure, remove_page.
|
||||
Each function is tested for:
|
||||
1. Correct global mutation
|
||||
2. Correct JSON output via /api/config endpoint
|
||||
3. reset_state() properly clears it
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
from starlette.testclient import TestClient
|
||||
|
||||
from praisonaiui import server as srv
|
||||
from praisonaiui.datastore import MemoryDataStore
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _clean_state():
|
||||
"""Reset all server state before and after each test."""
|
||||
srv.reset_state()
|
||||
srv.set_datastore(MemoryDataStore())
|
||||
yield
|
||||
srv.reset_state()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def client():
|
||||
app = srv.create_app()
|
||||
return TestClient(app)
|
||||
|
||||
|
||||
# ── set_chat_mode ─────────────────────────────────────────────────
|
||||
|
||||
class TestSetChatMode:
|
||||
def test_sets_global(self):
|
||||
srv.set_chat_mode("floating", position=(30, 40), size=(500, 600))
|
||||
assert srv._chat_mode is not None
|
||||
assert srv._chat_mode["mode"] == "floating"
|
||||
assert srv._chat_mode["position"] == [30, 40]
|
||||
assert srv._chat_mode["size"] == [500, 600]
|
||||
assert srv._chat_mode["resizable"] is True
|
||||
assert srv._chat_mode["minimized"] is False
|
||||
|
||||
def test_defaults(self):
|
||||
srv.set_chat_mode()
|
||||
assert srv._chat_mode["mode"] == "fullpage"
|
||||
assert srv._chat_mode["position"] == [20, 20]
|
||||
assert srv._chat_mode["size"] == [400, 500]
|
||||
|
||||
def test_sidebar_mode(self):
|
||||
srv.set_chat_mode("sidebar")
|
||||
assert srv._chat_mode["mode"] == "sidebar"
|
||||
|
||||
def test_minimized(self):
|
||||
srv.set_chat_mode("floating", minimized=True)
|
||||
assert srv._chat_mode["minimized"] is True
|
||||
|
||||
def test_ui_config_returns_chat_mode(self, client):
|
||||
srv.set_chat_mode("floating", position=(10, 10))
|
||||
resp = client.get("/ui-config.json")
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
assert data["chat"]["mode"]["mode"] == "floating"
|
||||
assert data["chat"]["mode"]["position"] == [10, 10]
|
||||
|
||||
def test_reset_clears_chat_mode(self):
|
||||
srv.set_chat_mode("floating")
|
||||
srv.reset_state()
|
||||
assert srv._chat_mode is None
|
||||
|
||||
|
||||
# ── set_brand_color ───────────────────────────────────────────────
|
||||
|
||||
class TestSetBrandColor:
|
||||
def test_sets_global(self):
|
||||
srv.set_brand_color("#ff5733")
|
||||
assert srv._brand_color == "#ff5733"
|
||||
|
||||
def test_ui_config_returns_brand_color(self, client):
|
||||
srv.set_brand_color("#818cf8")
|
||||
resp = client.get("/ui-config.json")
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
assert data["site"]["brandColor"] == "#818cf8"
|
||||
|
||||
def test_ui_config_omits_when_none(self, client):
|
||||
resp = client.get("/ui-config.json")
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
assert "brandColor" not in data["site"]
|
||||
|
||||
def test_reset_clears_brand_color(self):
|
||||
srv.set_brand_color("#abc")
|
||||
srv.reset_state()
|
||||
assert srv._brand_color is None
|
||||
|
||||
|
||||
# ── set_chat_features ─────────────────────────────────────────────
|
||||
|
||||
class TestSetChatFeatures:
|
||||
def test_sets_global(self):
|
||||
srv.set_chat_features(history=False, file_upload=True)
|
||||
assert srv._chat_features is not None
|
||||
assert srv._chat_features["history"] is False
|
||||
assert srv._chat_features["fileUpload"] is True
|
||||
assert srv._chat_features["streaming"] is True # default
|
||||
|
||||
def test_all_defaults(self):
|
||||
srv.set_chat_features()
|
||||
cf = srv._chat_features
|
||||
assert cf["history"] is True
|
||||
assert cf["streaming"] is True
|
||||
assert cf["fileUpload"] is False
|
||||
assert cf["audio"] is False
|
||||
assert cf["reasoning"] is True
|
||||
assert cf["tools"] is True
|
||||
assert cf["multimedia"] is True
|
||||
assert cf["feedback"] is False
|
||||
|
||||
def test_ui_config_returns_features(self, client):
|
||||
srv.set_chat_features(feedback=True)
|
||||
resp = client.get("/ui-config.json")
|
||||
data = resp.json()
|
||||
assert data["chat"]["features"]["feedback"] is True
|
||||
|
||||
def test_ui_config_omits_when_none(self, client):
|
||||
resp = client.get("/ui-config.json")
|
||||
data = resp.json()
|
||||
assert "features" not in data["chat"]
|
||||
|
||||
def test_reset_clears(self):
|
||||
srv.set_chat_features(audio=True)
|
||||
srv.reset_state()
|
||||
assert srv._chat_features is None
|
||||
|
||||
|
||||
# ── set_dashboard ─────────────────────────────────────────────────
|
||||
|
||||
class TestSetDashboard:
|
||||
def test_sets_global(self):
|
||||
srv.set_dashboard(sidebar=False, page_header=False)
|
||||
assert srv._dashboard_config == {
|
||||
"sidebar": False,
|
||||
"pageHeader": False,
|
||||
}
|
||||
|
||||
def test_defaults(self):
|
||||
srv.set_dashboard()
|
||||
assert srv._dashboard_config["sidebar"] is True
|
||||
assert srv._dashboard_config["pageHeader"] is True
|
||||
|
||||
def test_ui_config_returns_dashboard(self, client):
|
||||
srv.set_dashboard(sidebar=False)
|
||||
resp = client.get("/ui-config.json")
|
||||
data = resp.json()
|
||||
assert data["dashboard"]["sidebar"] is False
|
||||
|
||||
def test_ui_config_omits_when_none(self, client):
|
||||
resp = client.get("/ui-config.json")
|
||||
data = resp.json()
|
||||
assert "dashboard" not in data
|
||||
|
||||
def test_reset_clears(self):
|
||||
srv.set_dashboard(sidebar=False)
|
||||
srv.reset_state()
|
||||
assert srv._dashboard_config is None
|
||||
|
||||
|
||||
# ── set_sidebar_config ────────────────────────────────────────────
|
||||
|
||||
class TestSetSidebarConfig:
|
||||
def test_sets_global(self):
|
||||
srv.set_sidebar_config(collapsible=True, width=300)
|
||||
assert srv._dashboard_config is not None
|
||||
assert srv._dashboard_config["sidebarCollapsible"] is True
|
||||
assert srv._dashboard_config["sidebarWidth"] == 300
|
||||
assert srv._dashboard_config["sidebarMinWidth"] == 200
|
||||
assert srv._dashboard_config["sidebarMaxWidth"] == 360
|
||||
|
||||
def test_default_collapsed(self):
|
||||
srv.set_sidebar_config(default_collapsed=True)
|
||||
assert srv._dashboard_config["sidebarCollapsed"] is True
|
||||
|
||||
def test_merges_with_existing_dashboard_config(self):
|
||||
srv.set_dashboard(sidebar=True, page_header=False)
|
||||
srv.set_sidebar_config(width=280)
|
||||
# sidebar and pageHeader from set_dashboard should be preserved
|
||||
assert srv._dashboard_config["sidebar"] is True
|
||||
assert srv._dashboard_config["pageHeader"] is False
|
||||
assert srv._dashboard_config["sidebarWidth"] == 280
|
||||
|
||||
def test_creates_dashboard_config_if_none(self):
|
||||
assert srv._dashboard_config is None
|
||||
srv.set_sidebar_config(collapsible=False)
|
||||
assert srv._dashboard_config is not None
|
||||
assert srv._dashboard_config["sidebarCollapsible"] is False
|
||||
|
||||
def test_ui_config_returns_sidebar_fields(self, client):
|
||||
srv.set_sidebar_config(width=300)
|
||||
resp = client.get("/ui-config.json")
|
||||
data = resp.json()
|
||||
assert data["dashboard"]["sidebarWidth"] == 300
|
||||
|
||||
def test_reset_clears(self):
|
||||
srv.set_sidebar_config(width=300)
|
||||
srv.reset_state()
|
||||
assert srv._dashboard_config is None
|
||||
|
||||
|
||||
# ── register_theme ────────────────────────────────────────────────
|
||||
|
||||
class TestRegisterTheme:
|
||||
def test_registers_via_theme_manager(self):
|
||||
srv.register_theme("ocean", {"accent": "#0077b6"})
|
||||
from praisonaiui.features.theme import get_theme_manager
|
||||
mgr = get_theme_manager()
|
||||
assert "ocean" in mgr.list_themes()
|
||||
|
||||
def test_multiple_themes(self):
|
||||
srv.register_theme("sunset", {"accent": "#ff6b35"})
|
||||
srv.register_theme("forest", {"accent": "#228b22"})
|
||||
from praisonaiui.features.theme import get_theme_manager
|
||||
mgr = get_theme_manager()
|
||||
themes = mgr.list_themes()
|
||||
assert "sunset" in themes
|
||||
assert "forest" in themes
|
||||
|
||||
def test_reset_clears_custom_themes(self):
|
||||
srv.register_theme("temp", {"accent": "#123456"})
|
||||
srv.reset_state()
|
||||
from praisonaiui.features.theme import get_theme_manager
|
||||
mgr = get_theme_manager()
|
||||
assert "temp" not in mgr.list_themes()
|
||||
|
||||
|
||||
# ── configure ─────────────────────────────────────────────────────
|
||||
|
||||
class TestConfigure:
|
||||
def test_memory_datastore(self):
|
||||
from praisonaiui._config import configure
|
||||
configure(datastore="memory")
|
||||
ds = srv.get_datastore()
|
||||
assert type(ds).__name__ == "MemoryDataStore"
|
||||
|
||||
def test_json_datastore(self, tmp_path):
|
||||
from praisonaiui._config import configure
|
||||
configure(datastore=f"json:{tmp_path}")
|
||||
ds = srv.get_datastore()
|
||||
assert type(ds).__name__ == "JSONFileDataStore"
|
||||
|
||||
def test_invalid_datastore_raises(self):
|
||||
from praisonaiui._config import configure
|
||||
with pytest.raises(ValueError, match="Unknown datastore"):
|
||||
configure(datastore="invalid")
|
||||
|
||||
|
||||
# ── remove_page ───────────────────────────────────────────────────
|
||||
|
||||
class TestRemovePage:
|
||||
def test_remove_existing_page(self):
|
||||
srv._pages["test-page"] = {"id": "test-page", "title": "Test"}
|
||||
srv.remove_page("test-page")
|
||||
assert "test-page" not in srv._pages
|
||||
|
||||
def test_remove_nonexistent_page_no_error(self):
|
||||
# Should not raise
|
||||
srv.remove_page("nonexistent")
|
||||
@@ -0,0 +1,123 @@
|
||||
"""Tests for aiui.set_custom_js(path) — user-side client extension injection.
|
||||
|
||||
This closes the gap documented in how-to-add-a-feature.md: previously,
|
||||
users had no way to load plugin JS (e.g. registerComponent / registerView)
|
||||
from their Python app without modifying the package's frontend bundle.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
from pathlib import Path
|
||||
from starlette.testclient import TestClient
|
||||
|
||||
from praisonaiui import server as srv
|
||||
from praisonaiui.datastore import MemoryDataStore
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _clean_state():
|
||||
srv.reset_state()
|
||||
srv.set_datastore(MemoryDataStore())
|
||||
yield
|
||||
srv.reset_state()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def client():
|
||||
return TestClient(srv.create_app())
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def tmp_js(tmp_path: Path):
|
||||
p = tmp_path / "plugin.js"
|
||||
p.write_text(
|
||||
"console.log('custom plugin loaded');\n"
|
||||
"if (window.aiui) { window.aiui.registerComponent('x', c => "
|
||||
"document.createTextNode('hi')); }\n"
|
||||
)
|
||||
return p
|
||||
|
||||
|
||||
# ── Python API surface ────────────────────────────────────────────
|
||||
|
||||
class TestSetCustomJsAPI:
|
||||
def test_set_custom_js_is_exported(self):
|
||||
import praisonaiui as aiui
|
||||
assert callable(aiui.set_custom_js), (
|
||||
"aiui.set_custom_js must be publicly exported"
|
||||
)
|
||||
|
||||
def test_set_custom_js_accepts_path(self, tmp_js):
|
||||
srv.set_custom_js(str(tmp_js))
|
||||
# Should be read and stored
|
||||
assert srv._custom_js is not None
|
||||
|
||||
def test_set_custom_js_accepts_path_object(self, tmp_js):
|
||||
srv.set_custom_js(tmp_js) # pathlib.Path
|
||||
assert srv._custom_js is not None
|
||||
|
||||
def test_reset_state_clears_custom_js(self, tmp_js):
|
||||
srv.set_custom_js(tmp_js)
|
||||
srv.reset_state()
|
||||
assert srv._custom_js is None
|
||||
|
||||
def test_missing_file_raises(self, tmp_path):
|
||||
with pytest.raises((FileNotFoundError, OSError)):
|
||||
srv.set_custom_js(tmp_path / "does-not-exist.js")
|
||||
|
||||
|
||||
# ── Served content ────────────────────────────────────────────────
|
||||
|
||||
class TestCustomJsServing:
|
||||
def test_custom_js_route_serves_content(self, tmp_js, client):
|
||||
srv.set_custom_js(tmp_js)
|
||||
# Recreate app to pick up the route
|
||||
app = srv.create_app()
|
||||
c = TestClient(app)
|
||||
resp = c.get("/custom.js")
|
||||
assert resp.status_code == 200
|
||||
assert "custom plugin loaded" in resp.text
|
||||
assert resp.headers["content-type"].startswith("application/javascript") \
|
||||
or resp.headers["content-type"].startswith("text/javascript")
|
||||
|
||||
def test_custom_js_not_served_when_unset(self, client):
|
||||
resp = client.get("/custom.js")
|
||||
assert resp.status_code == 404
|
||||
|
||||
def test_index_injects_script_tag_when_set(self, tmp_js):
|
||||
srv.set_custom_js(tmp_js)
|
||||
srv.set_style("dashboard")
|
||||
app = srv.create_app()
|
||||
c = TestClient(app)
|
||||
resp = c.get("/")
|
||||
assert resp.status_code == 200
|
||||
# Script tag with our endpoint must be present
|
||||
assert "/custom.js" in resp.text, (
|
||||
"set_custom_js() must inject <script src='/custom.js'> into the HTML"
|
||||
)
|
||||
# Must be a <script src=...> tag (not accidentally inside a CSS/other block)
|
||||
assert '<script' in resp.text and 'src="/custom.js' in resp.text
|
||||
|
||||
def test_index_no_script_tag_when_unset(self, client):
|
||||
srv.set_style("dashboard")
|
||||
app = srv.create_app()
|
||||
c = TestClient(app)
|
||||
resp = c.get("/")
|
||||
assert resp.status_code == 200
|
||||
assert "/custom.js" not in resp.text
|
||||
|
||||
def test_script_injected_after_plugin_loader(self, tmp_js):
|
||||
"""User JS should load AFTER plugin-loader.js so window.aiui exists."""
|
||||
srv.set_custom_js(tmp_js)
|
||||
srv.set_style("dashboard")
|
||||
app = srv.create_app()
|
||||
c = TestClient(app)
|
||||
html = c.get("/").text
|
||||
loader_idx = html.find("plugin-loader.js")
|
||||
custom_idx = html.find("/custom.js")
|
||||
assert loader_idx != -1, "plugin-loader.js must be present"
|
||||
assert custom_idx > loader_idx, (
|
||||
"custom.js must be injected AFTER plugin-loader.js so window.aiui "
|
||||
"APIs are available when custom.js runs"
|
||||
)
|
||||
@@ -0,0 +1,133 @@
|
||||
"""Tests for the form_action component and /api/pages/{page_id}/action endpoint.
|
||||
|
||||
The form action protocol allows form inputs to POST data back to server
|
||||
page handlers, closing the gap between read-only components and interactive UIs.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
from starlette.testclient import TestClient
|
||||
|
||||
from praisonaiui import server as srv
|
||||
from praisonaiui.datastore import MemoryDataStore
|
||||
from praisonaiui.ui import form_action, select_input, text_input
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _clean_state():
|
||||
srv.reset_state()
|
||||
srv.set_datastore(MemoryDataStore())
|
||||
yield
|
||||
srv.reset_state()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def client():
|
||||
app = srv.create_app()
|
||||
return TestClient(app)
|
||||
|
||||
|
||||
# ── Python component tests ────────────────────────────────────────
|
||||
|
||||
class TestFormActionComponent:
|
||||
def test_basic(self):
|
||||
fa = form_action(
|
||||
"settings",
|
||||
children=[text_input("Name"), select_input("Role", options=["admin", "user"])],
|
||||
)
|
||||
assert fa["type"] == "form_action"
|
||||
assert fa["action"] == "settings"
|
||||
assert len(fa["children"]) == 2
|
||||
|
||||
def test_with_submit_label(self):
|
||||
fa = form_action("save", children=[], submit_label="Save Changes")
|
||||
assert fa["submit_label"] == "Save Changes"
|
||||
|
||||
def test_default_submit_label(self):
|
||||
fa = form_action("save", children=[])
|
||||
assert fa["submit_label"] == "Submit"
|
||||
|
||||
def test_returns_dict_with_type(self):
|
||||
fa = form_action("x", children=[])
|
||||
assert isinstance(fa, dict)
|
||||
assert fa["type"] == "form_action"
|
||||
|
||||
|
||||
# ── Server endpoint tests ─────────────────────────────────────────
|
||||
|
||||
class TestFormActionEndpoint:
|
||||
def test_action_endpoint_exists(self, client):
|
||||
"""The /api/pages/{page_id}/action endpoint exists and returns 404 for unknown page."""
|
||||
resp = client.post("/api/pages/nonexistent/action", json={"data": {}})
|
||||
assert resp.status_code == 404
|
||||
|
||||
def test_action_invokes_handler(self, client):
|
||||
"""Registered page action handler is invoked with form data."""
|
||||
received = {}
|
||||
|
||||
@srv.register_page_action("test-page")
|
||||
async def handle_action(data):
|
||||
received.update(data)
|
||||
return {"status": "ok"}
|
||||
|
||||
resp = client.post(
|
||||
"/api/pages/test-page/action",
|
||||
json={"name": "Alice", "role": "admin"},
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
assert resp.json()["status"] == "ok"
|
||||
assert received["name"] == "Alice"
|
||||
assert received["role"] == "admin"
|
||||
|
||||
def test_action_returns_handler_result(self, client):
|
||||
@srv.register_page_action("result-page")
|
||||
async def handle_action(data):
|
||||
return {"message": f"Hello {data.get('name', 'World')}"}
|
||||
|
||||
resp = client.post(
|
||||
"/api/pages/result-page/action",
|
||||
json={"name": "Bob"},
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
assert resp.json()["message"] == "Hello Bob"
|
||||
|
||||
|
||||
# ── Frontend data-collection tests ────────────────────────────────
|
||||
# Regression: form_action's submit handler reads `el.name || el.dataset.label`.
|
||||
# If the input renderers don't emit `data-label`, the POSTed body is always {}.
|
||||
|
||||
class TestFormInputDataLabel:
|
||||
"""Every form input renderer must expose the label via data-label attribute
|
||||
so the form_action submit handler can collect values by label."""
|
||||
|
||||
def _js(self):
|
||||
from pathlib import Path
|
||||
p = (
|
||||
Path(__file__).resolve().parents[2]
|
||||
/ "src" / "praisonaiui" / "templates" / "frontend"
|
||||
/ "plugins" / "dashboard.js"
|
||||
)
|
||||
return p.read_text()
|
||||
|
||||
@pytest.mark.parametrize("renderer", [
|
||||
"renderTextInput",
|
||||
"renderNumberInput",
|
||||
"renderSelectInput",
|
||||
"renderSliderInput",
|
||||
"renderCheckboxInput",
|
||||
"renderTextareaInput",
|
||||
])
|
||||
def test_renderer_emits_data_label(self, renderer):
|
||||
import re
|
||||
source = self._js()
|
||||
match = re.search(
|
||||
rf"function {renderer}\(comp\)\s*\{{(.*?)^}}",
|
||||
source, re.DOTALL | re.MULTILINE,
|
||||
)
|
||||
assert match, f"{renderer} not found in dashboard.js"
|
||||
body = match.group(1)
|
||||
assert "data-label=" in body, (
|
||||
f"{renderer} must emit data-label so form_action can collect "
|
||||
f"its value on submit"
|
||||
)
|
||||
@@ -0,0 +1,137 @@
|
||||
"""Tests for the `name=` kwarg on form input components.
|
||||
|
||||
Closes the gap: previously, form_action used input labels as dict keys, which
|
||||
collides when the same label is used twice. With `name=`, users can give each
|
||||
input an explicit, stable key.
|
||||
|
||||
Priority (highest first):
|
||||
1. name (explicit)
|
||||
2. label (fallback)
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from praisonaiui.ui import (
|
||||
checkbox_input,
|
||||
number_input,
|
||||
radio_input,
|
||||
select_input,
|
||||
slider_input,
|
||||
switch_input,
|
||||
text_input,
|
||||
textarea_input,
|
||||
)
|
||||
|
||||
|
||||
# ── Python dict shape ─────────────────────────────────────────────
|
||||
|
||||
class TestInputNameKwarg:
|
||||
"""Every form input must accept `name=` and emit it in the dict."""
|
||||
|
||||
def test_text_input_accepts_name(self):
|
||||
c = text_input("Email", name="user_email")
|
||||
assert c["name"] == "user_email"
|
||||
|
||||
def test_number_input_accepts_name(self):
|
||||
c = number_input("Age", name="user_age", value=0)
|
||||
assert c["name"] == "user_age"
|
||||
|
||||
def test_select_input_accepts_name(self):
|
||||
c = select_input("Role", options=["a", "b"], name="user_role")
|
||||
assert c["name"] == "user_role"
|
||||
|
||||
def test_slider_input_accepts_name(self):
|
||||
c = slider_input("Volume", name="vol")
|
||||
assert c["name"] == "vol"
|
||||
|
||||
def test_checkbox_input_accepts_name(self):
|
||||
c = checkbox_input("Subscribe", name="subscribe")
|
||||
assert c["name"] == "subscribe"
|
||||
|
||||
def test_switch_input_accepts_name(self):
|
||||
c = switch_input("Enabled", name="enabled")
|
||||
assert c["name"] == "enabled"
|
||||
|
||||
def test_radio_input_accepts_name(self):
|
||||
c = radio_input("Plan", options=["a", "b"], name="plan")
|
||||
assert c["name"] == "plan"
|
||||
|
||||
def test_textarea_input_accepts_name(self):
|
||||
c = textarea_input("Notes", name="notes")
|
||||
assert c["name"] == "notes"
|
||||
|
||||
def test_name_is_optional_default_unset(self):
|
||||
"""When name isn't given, the dict must NOT contain 'name'."""
|
||||
c = text_input("Email")
|
||||
assert "name" not in c
|
||||
|
||||
|
||||
# ── JS renderer emits `name` attribute ────────────────────────────
|
||||
|
||||
DASHBOARD_JS = (
|
||||
Path(__file__).resolve().parents[2]
|
||||
/ "src" / "praisonaiui" / "templates" / "frontend"
|
||||
/ "plugins" / "dashboard.js"
|
||||
)
|
||||
|
||||
|
||||
class TestRenderersEmitName:
|
||||
"""JS renderers must emit `name="..."` so that form_action picks it up
|
||||
via `el.name` (which has priority over `el.dataset.label`)."""
|
||||
|
||||
def _src(self):
|
||||
return DASHBOARD_JS.read_text()
|
||||
|
||||
@pytest.mark.parametrize("renderer", [
|
||||
"renderTextInput",
|
||||
"renderNumberInput",
|
||||
"renderSelectInput",
|
||||
"renderSliderInput",
|
||||
"renderCheckboxInput",
|
||||
"renderSwitchInput",
|
||||
"renderRadioInput",
|
||||
"renderTextareaInput",
|
||||
])
|
||||
def test_renderer_uses_name_attr(self, renderer):
|
||||
source = self._src()
|
||||
m = re.search(
|
||||
rf"function {renderer}\(comp\)\s*\{{(.*?)^}}",
|
||||
source, re.DOTALL | re.MULTILINE,
|
||||
)
|
||||
assert m, f"{renderer} not found"
|
||||
body = m.group(1)
|
||||
# Renderer must reference comp.name (so it can emit name="..." attr)
|
||||
assert "comp.name" in body, (
|
||||
f"{renderer} must read comp.name so form_action can collect its "
|
||||
f"value via el.name (which takes priority over data-label)"
|
||||
)
|
||||
|
||||
|
||||
# ── form_action submit-handler priority order ─────────────────────
|
||||
|
||||
class TestSubmitCollectorPriority:
|
||||
"""renderFormAction submit handler must prefer `name` over `data-label`."""
|
||||
|
||||
def test_name_has_priority_over_label(self):
|
||||
source = DASHBOARD_JS.read_text()
|
||||
m = re.search(
|
||||
r"function renderFormAction\(comp\)\s*\{(.*?)^}",
|
||||
source, re.DOTALL | re.MULTILINE,
|
||||
)
|
||||
assert m
|
||||
body = m.group(1)
|
||||
# The key assignment should be: `el.name || el.dataset.label || ...`
|
||||
assert "el.name" in body
|
||||
assert "dataset.label" in body
|
||||
# Ensure el.name comes before dataset.label in the fallback chain
|
||||
name_idx = body.find("el.name")
|
||||
label_idx = body.find("dataset.label")
|
||||
assert name_idx < label_idx, (
|
||||
"el.name must come BEFORE el.dataset.label in the key fallback "
|
||||
"chain so explicit names take priority"
|
||||
)
|
||||
Reference in new issue
Block a user