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:
MervinPraison committed 2026-04-19 02:36:38 +01:00
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
```
---
+4 -7
View File
@@ -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
```
---
+4 -7
View File
@@ -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
+133
View File
@@ -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
View File
@@ -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
+13
View File
@@ -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
+80
View File
@@ -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 |
+258
View File
@@ -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()`
+316
View File
@@ -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`.
+2
View File
@@ -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()`
+9
View File
@@ -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
View File
@@ -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.
+44
View File
@@ -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
View File
@@ -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
View File
@@ -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"}
+8 -4
View File
@@ -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 -1
View File
@@ -1,3 +1,3 @@
"""Version information."""
__version__ = "0.2.0"
__version__ = "0.3.108"
+169
View File
@@ -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
View File
@@ -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
View File
@@ -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,
}
+23
View File
@@ -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"
+87
View File
@@ -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
+121
View File
@@ -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)}"
)
+270
View File
@@ -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")
+123
View File
@@ -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"
)
+133
View File
@@ -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"
)
+137
View File
@@ -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"
)