Compare commits

..
37 Commits
Author SHA1 Message Date
MervinPraison 7727aae293 Release v0.3.154 2026-11-13 11:37:08 +00:00
MervinPraisonandClaude Opus 5.5 b25c142f34 Merge wp/WP0a into feat/ui-reset
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-12 21:35:25 +00:00
MervinPraisonandClaude Opus 5.5 a698248b7a WP0a: Contracts & build
- Pinned frontend deps for the UI reset (assistant-ui, streamdown, shiki,
  katex, radix-ui, sonner, next-themes; playwright, size-limit, esbuild).
- vite.config: hashed chunks, fixed assets/index.js + assets/index.css,
  vendor-react manualChunk, preload-helper chunk, aiui-manifest.json,
  dev proxy (incl. /api/chat/ws), build guard that no chunk imports the
  entry (it is loaded with ?v=, so it would run twice).
- main.tsx boot split (shell/mount vs legacy-app-entry) with a one-shot
  per-build vite:preloadError reload guard.
- sdk/manifest.ts + sdk/registry.ts contracts (SDK_EXPORTS, SHELL_EXTERNALS),
  contract stubs for shell, legacy, markdown, chat-engine, sdk/install, blocks.
- scripts: run-tests (esbuild-bundles every *.test.ts(x)), sync-templates
  (--dest), gen-externals (dist/aiui-externals.json, postbuild).
- 'app' style in types.ts; themes.get_seed_theme_css stub;
  AIUI_TEMPLATES_DIR override for the served frontend build.
- Golden _build_html fixture captured before any edit.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-12 21:02:57 +00:00
MervinPraison 6bdf6f081b Release v0.3.153 2026-11-12 05:28:36 +00:00
praisonai-triage-agent[bot] a1723b5432 Merge pull request #353 from MervinPraison/claude/issue-341-20261111-footer
fix: dashboard footer connection indicator uses liveness instead of deep health
2026-11-11 19:35:43 +00:00
praisonai-triage-agent[bot] a8ae997fb0 Merge pull request #352 from MervinPraison/claude/issue-339-20261111
fix: shell header branding as a content-sized row, not a flex column
2026-11-11 19:35:41 +00:00
praisonai-triage-agent[bot] f1ace7a3ae Merge pull request #350 from MervinPraison/claude/issue-338-20261111
fix: set_custom_js registrations before dashboard is ready (fixes #338)
2026-11-11 19:35:27 +00:00
praisonai-triage-agent[bot] 3d20f78529 Merge pull request #351 from MervinPraison/claude/issue-341-20261111
fix: bound provider health check in /health/ready and cold /api/health
2026-11-11 19:35:26 +00:00
praisonai-triage-agent[bot] f34aac552a Merge pull request #349 from MervinPraison/claude/issue-334-20261111
fix: align extensibility example docs with set_custom_js
2026-11-11 17:39:00 +00:00
MervinPraison afe1f026fe Release v0.3.152 2026-11-11 12:14:53 +00:00
praisonai-triage-agent[bot] 374c8a4eb7 Merge pull request #343 from MervinPraison/claude/issue-335-20261111
fix: make feature-module doc example instantiable
2026-11-11 12:12:22 +00:00
praisonai-triage-agent[bot] 98c89f9a87 Merge pull request #346 from MervinPraison/claude/issue-340-20261111
fix: explicit dashboard plugin scope for pages and script discovery
2026-11-11 12:12:08 +00:00
praisonai-triage-agent[bot] 3cb68b661e Merge pull request #345 from MervinPraison/claude/issue-337-20261111
fix: pagination exposes a page-change event to the host
2026-11-11 12:12:04 +00:00
praisonai-triage-agent[bot] 772dacc8f7 Merge pull request #347 from MervinPraison/claude/issue-336-20261111
feat: opt-in stable row IDs, selection and row actions for table()
2026-11-11 12:11:51 +00:00
praisonai-triage-agent[bot] b8f26f5f53 Merge pull request #348 from MervinPraison/claude/issue-342-20261111
fix: keyboard-accessible dashboard sidebar navigation
2026-11-11 12:11:48 +00:00
MervinPraisonandClaude Opus 5.5 88683e7263 fix: ignore non-dict provider health in readiness to avoid 500
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-11 09:51:52 +00:00
MervinPraisonandClaude Opus 5.5 05ea62bf1c fix: run cleanup of superseded view renders in selectPage (#338)
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-11 09:48:02 +00:00
MervinPraisonandClaude Opus 5.5 d3a7116225 fix: drop DevTools-paste instruction from example 29 fallback page (fixes #334)
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-11 09:27:12 +00:00
MervinPraisonandClaude Opus 5.5 341aed5ab8 fix: dashboard footer connection indicator uses /api/health/live (fixes #341)
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-11 09:21:41 +00:00
MervinPraisonandClaude Opus 5.5 1be805fef6 fix: lay out shell header as a content-sized row above the page (fixes #339)
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-11 09:16:36 +00:00
MervinPraisonandClaude Opus 5.5 a62b38ed2f fix: bound provider health in readiness / cold /api/health (fixes #341)
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-11 09:16:25 +00:00
MervinPraisonandClaude Opus 5.5 0939339b11 fix: queue early set_custom_js registrations and re-render active page on late registerView (fixes #338)
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-11 09:15:18 +00:00
MervinPraisonandClaude Opus 5.5 2add8a7687 fix: align extensibility example loading docs with set_custom_js (fixes #334)
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-11 09:14:58 +00:00
MervinPraisonandClaude Opus 5.5 487efb8a34 fix: keyboard-accessible dashboard sidebar navigation (fixes #342)
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-11 09:14:14 +00:00
MervinPraisonandClaude Opus 5.5 81342a98df feat: opt-in stable row IDs, selection and row actions for table() (fixes #336)
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-11 09:14:05 +00:00
MervinPraisonandClaude Opus 5.5 0073018a7a fix: explicit dashboard plugin allowlist for pages and manifests (fixes #340)
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-11 09:13:22 +00:00
MervinPraisonandClaude Opus 5.5 0825601c66 fix: pagination dispatches aiui:pagination page-change event (fixes #337)
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-11 09:12:49 +00:00
MervinPraisonandClaude Opus 5.5 29ffce9405 fix: make feature-module doc example instantiable (fixes #335)
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-11 09:11:15 +00:00
MervinPraison 78493e0e17 Release v0.3.151 2026-11-05 16:51:31 +00:00
praisonai-triage-agent[bot] bf5415555b Merge pull request #332 from MervinPraison/claude/issue-331-20261105
fix: rename sessions from the chat rail
2026-11-05 16:48:12 +00:00
MervinPraisonandClaude Opus 5.5 e69a06c2d5 fix: rename sessions from the chat rail (fixes #331)
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-05 10:41:19 +00:00
MervinPraison 085bdd3793 Release v0.3.150 2026-11-04 12:47:04 +00:00
Superbot Helper 93abaac212 Bump @a2ui/web_core from 0.10.0 to 0.10.2 in the frontend [SECURITY] (#321)
GHSA-72qq-p3r5-f7wq: the basic catalog openUrl action accepted any URL
scheme and passed it to window.open, so an agent-supplied A2UI Button
carrying a javascript: URI would execute script in the app origin on click.
0.10.2 resolves the URL and refuses anything that is not http or https.
2026-11-04 12:45:00 +00:00
f2ecc8e39e fix: link job rows to their chat session (fixes #323) (#325)
Co-authored-by: MervinPraison <[email protected]>
Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-11-04 12:44:48 +00:00
e86b187246 fix: add in-rail session search to chat sidebar (#327)
* fix: add in-rail session search to chat sidebar (fixes #324)

Co-Authored-By: Claude Opus 5.5 <[email protected]>

* fix: guard session rail against non-array session payloads

Co-Authored-By: Claude Opus 5.5 <[email protected]>

---------

Co-authored-by: MervinPraison <[email protected]>
Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-11-04 12:44:26 +00:00
1255f44105 fix: make dashboard chat fill the main column (#326)
* fix: make dashboard chat fill the main column (fixes #322)

Chat and Chat+Canvas built-in pages declare layout: full-bleed. selectPage()
toggles a db-fill class on #db-main-content and skips the duplicate page
header, so .chat-root sizes from flex allocation instead of
calc(100vh - 140px). Session rail stacks above the thread below 900px.

Co-Authored-By: Claude Opus 5.5 <[email protected]>

* fix: make full-bleed page metadata test non-vacuous

Co-Authored-By: Claude Opus 5.5 <[email protected]>

---------

Co-authored-by: MervinPraison <[email protected]>
Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-11-04 12:44:10 +00:00
Mervin PraisonandClaude Opus 5.5 7002e956fb ci: bump github-automation-template to v1.1.20 (#320)
Co-authored-by: Claude Opus 5.5 (1M context) <[email protected]>
2026-10-29 02:38:22 +01:00
58 changed files with 4212 additions and 825 deletions

No files matched your search

+2 -2
View File
@@ -27,7 +27,7 @@ jobs:
permissions:
issues: write
pull-requests: write
uses: MervinPraison/github-automation-template/.github/workflows/[email protected].19
uses: MervinPraison/github-automation-template/.github/workflows/[email protected].20
secrets: inherit
with:
hub_ref: v1.1.19
hub_ref: v1.1.20
+2 -2
View File
@@ -16,8 +16,8 @@ permissions:
jobs:
recover:
uses: MervinPraison/github-automation-template/.github/workflows/[email protected].19
uses: MervinPraison/github-automation-template/.github/workflows/[email protected].20
secrets: inherit
with:
hub_ref: v1.1.19
hub_ref: v1.1.20
pr_number: ${{ github.event.inputs.pr_number || '' }}
+2 -2
View File
@@ -25,8 +25,8 @@ permissions:
jobs:
detect-and-trigger:
uses: MervinPraison/github-automation-template/.github/workflows/[email protected].19
uses: MervinPraison/github-automation-template/.github/workflows/[email protected].20
secrets: inherit
with:
hub_ref: v1.1.19
hub_ref: v1.1.20
pr_number: ${{ github.event.inputs.pr_number || '' }}
+4 -4
View File
@@ -43,9 +43,9 @@ jobs:
id-token: write
steps:
- uses: actions/checkout@v4
- uses: MervinPraison/github-automation-template/.github/actions/[email protected].19
- uses: MervinPraison/github-automation-template/.github/actions/[email protected].20
with:
hub_ref: v1.1.19
hub_ref: v1.1.20
- name: Generate GitHub App token
id: app-token
uses: actions/create-github-app-token@v1
@@ -88,9 +88,9 @@ jobs:
actions: read
id-token: write
statuses: write # the assess job records security/outsider-review
uses: MervinPraison/github-automation-template/.github/workflows/[email protected].19
uses: MervinPraison/github-automation-template/.github/workflows/[email protected].20
secrets: inherit
with:
hub_ref: v1.1.19
hub_ref: v1.1.20
pr_number: ${{ github.event.inputs.pr_number || github.event.client_payload.pr_number || '' }}
merge_method: ${{ github.event.inputs.merge_method || 'merge' }}
+2 -2
View File
@@ -18,7 +18,7 @@ jobs:
issues: write
actions: read
id-token: write
uses: MervinPraison/github-automation-template/.github/workflows/[email protected].19
uses: MervinPraison/github-automation-template/.github/workflows/[email protected].20
secrets: inherit
with:
hub_ref: v1.1.19
hub_ref: v1.1.20
+2 -2
View File
@@ -25,9 +25,9 @@ jobs:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: MervinPraison/github-automation-template/.github/actions/[email protected].19
- uses: MervinPraison/github-automation-template/.github/actions/[email protected].20
with:
hub_ref: v1.1.19
hub_ref: v1.1.20
- name: Detect conflicting PRs and trigger Claude
uses: actions/github-script@v7
with:
+2 -2
View File
@@ -13,7 +13,7 @@ permissions:
jobs:
sync:
uses: MervinPraison/github-automation-template/.github/workflows/[email protected].19
uses: MervinPraison/github-automation-template/.github/workflows/[email protected].20
secrets: inherit
with:
hub_ref: v1.1.19
hub_ref: v1.1.20
+54 -1
View File
@@ -73,6 +73,47 @@ aiui.table(
|-----------|------|----------|-------------|
| `headers` | `Sequence[str]` | ✅ | Column header labels |
| `rows` | `Sequence[Sequence[Any]]` | ✅ | Row data arrays |
| `row_ids` | `Sequence[str]` | — | Stable unique ID per row. Makes rows selectable by click, Enter or Space |
| `actions` | `Sequence[str]` | — | Row action button labels (requires `row_ids`) |
With `row_ids`, the dashboard renderer emits bubbling DOM events from the table, so
a custom view can own query state (search, filters, page, sort) and react to rows:
| Event | `detail` |
|-------|----------|
| `aiui:table-select` | `{rowId, index}` |
| `aiui:table-action` | `{action, rowId}` |
Because events carry the stable `rowId`, selection stays correct after the server
re-sorts or filters rows. Loading, empty and error states compose from existing
components (`aiui.skeleton()`, `aiui.empty()`, `aiui.alert()`):
```js
// Custom view: server-paged, searchable table using only documented APIs.
window.aiui.registerView('companies', (container) => {
const state = { q: '', page: 1 };
const show = (comps) => { container.replaceChildren(...comps.map(window.aiui.renderComponent)); };
async function load() {
show([{ type: 'skeleton', variant: 'card' }]);
try {
const res = await fetch(`/api/companies?q=${encodeURIComponent(state.q)}&page=${state.page}`);
if (!res.ok) throw new Error(`HTTP ${res.status}`);
const { rows, total } = await res.json(); // [{id, name, status}], count
if (!rows.length) return show([{ type: 'empty', text: 'No results' }]);
show([{ type: 'table', headers: ['Name', 'Status'],
rows: rows.map(r => [r.name, r.status]),
row_ids: rows.map(r => r.id), actions: ['Edit'] },
{ type: 'caption', text: `${total} companies` }]);
} catch (e) {
show([{ type: 'alert', variant: 'error', message: 'Could not load companies. Try again.' }]);
}
}
container.addEventListener('aiui:table-select', (e) => openDetail(e.detail.rowId));
container.addEventListener('aiui:table-action', (e) => editRow(e.detail.rowId));
// On search/filter change: state.q = value; state.page = 1; load();
load();
});
```
### `aiui.text(content)`
@@ -705,7 +746,7 @@ aiui.breadcrumb([
|-----------|------|----------|-------------|
| `items` | `Sequence[dict]` | ✅ | List of `{"label": str, "href": str\|None}` |
### `aiui.pagination(*, total, page, per_page)`
### `aiui.pagination(*, total, page, per_page, id)`
Pagination controls.
@@ -718,6 +759,18 @@ aiui.pagination(total=250, page=3, per_page=25)
| `total` | `int` | ✅ | — | Total items |
| `page` | `int` | ❌ | `1` | Current page (1-based) |
| `per_page` | `int` | ❌ | `10` | Items per page |
| `id` | `str` | ❌ | `None` | Identifier echoed in the page-change event |
The control is optimistic: clicking ←, →, or a page number updates the active
page and dispatches a bubbling `aiui:pagination` DOM event. Buttons that would
go out of bounds are disabled and emit nothing. `page` is clamped to the valid range.
```javascript
container.addEventListener('aiui:pagination', (e) => {
const { id, page, per_page, total } = e.detail;
renderRows(rows.slice((page - 1) * per_page, page * per_page)); // or fetch()
});
```
### `aiui.key_value_list(items, *, title)`
+9 -1
View File
@@ -208,7 +208,7 @@ aiui.set_chat_features(
---
### `aiui.set_dashboard(*, sidebar, page_header)`
### `aiui.set_dashboard(*, sidebar, page_header, modules, plugin_dirs, plugins)`
Configure dashboard layout options.
@@ -216,6 +216,9 @@ Configure dashboard layout options.
|-----------|------|---------|-------------|
| `sidebar` | `bool` | `True` | Show the left sidebar navigation |
| `page_header` | `bool` | `True` | Show the page title/description header |
| `modules` | `list[str] \| None` | `None` | Optional dashboard JS modules (`jobs`, `auth`, `api`) |
| `plugin_dirs` | `list[str] \| None` | `None` | Extra directories to scan for dashboard plugins |
| `plugins` | `list[str] \| None` | `None` | Allowlist of dashboard plugin names. `None` = all discovered (bundled, `~/.praisonai/dashboard-plugins`, `plugin_dirs`); `[]` = none |
```python
import praisonaiui as aiui
@@ -223,8 +226,13 @@ 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.set_dashboard(plugins=[]) # No auto-discovered dashboard plugins
```
`plugins` filters both the sidebar pages (`/api/pages`) and the browser plugin
manifest (`/api/dashboard/plugins`), so disabled plugin scripts are never loaded.
It scopes navigation only — it is **not** an authorization boundary for API routes.
---
### `aiui.set_chat_preview(enabled, *, surface_id, width)`
+16 -4
View File
@@ -259,19 +259,31 @@ from starlette.responses import JSONResponse
class MyFeature(BaseFeatureProtocol):
name = "my-feature"
feature_name = "my-feature"
description = "Example feature with GET and PATCH routes"
def routes(self):
return [
Route("/api/my-feature", self._handler, methods=["GET"]),
Route("/api/my-feature", self._get, methods=["GET"]),
Route("/api/my-feature/{item_id}", self._patch, methods=["PATCH"]),
]
async def _handler(self, request):
return JSONResponse({"data": "hello"})
async def _get(self, request):
# Query parameters: GET /api/my-feature?q=hello
return JSONResponse({"data": request.query_params.get("q", "hello")})
async def _patch(self, request):
# Path parameter + JSON body: PATCH /api/my-feature/42
body = await request.json()
return JSONResponse({"ok": True, "id": request.path_params["item_id"], **body})
# Register before the app is created — create_app() mounts feature
# routes ahead of the SPA fallback, so no route reordering is needed.
register_feature(MyFeature())
```
`name` and `description` are abstract on `BaseFeatureProtocol`; omitting
either raises `TypeError: Can't instantiate abstract class`.
See [Protocol Architecture](protocol-architecture.md).
---
+1 -1
View File
@@ -36,7 +36,7 @@ curl http://localhost:8082/health/live
### Readiness Check: `/health/ready`
Verifies the server is ready to handle requests. Performs parallel health checks on all features with timeout protection.
Verifies the server is ready to handle requests. Performs parallel health checks on all features with timeout protection. The provider check runs concurrently and is bounded by the same 0.5s timeout, so a slow provider cannot hang the response (or a cold `/api/health`).
**Use for:**
- Kubernetes readiness probes
@@ -1,4 +1,4 @@
# Example 28 — Full Extensibility Showcase
# Example 29 — Full Extensibility Showcase
Demonstrates **every** extension point in PraisonAIUI.
@@ -45,6 +45,9 @@ The `timeline` component and the `custom-view` page are client-side extensions.
> **Note:** `aiui.set_custom_js(path)` injects client-side JS from a file path at page load.
> See `docs/features/how-to-add-a-feature.md` for the extension API contract.
> **Tip:** if a custom view renders its own heading, call
> `aiui.set_dashboard(page_header=False)` to hide the default page title/description header.
## Testing the form action
1. Navigate to **Contact Form**
+4 -5
View File
@@ -1,4 +1,4 @@
"""Example 28 — Full Extensibility Showcase.
"""Example 29 — Full Extensibility Showcase.
Demonstrates EVERY extension point in PraisonAIUI:
@@ -203,10 +203,9 @@ async def custom_view_fallback():
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."
"This page is meant to be rendered client-side by "
"plugin.js, which app.py loads via aiui.set_custom_js(). "
"If you see this, check that /custom.js loaded."
),
variant="warning",
),
@@ -5,9 +5,11 @@
* 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.)
* Loaded automatically by app.py, which serves it at /custom.js:
*
* aiui.set_custom_js(Path(__file__).with_name("plugin.js"))
*
* See 29-full-extensibility/README.md.
*/
(function () {
+1 -1
View File
@@ -1,6 +1,6 @@
[project]
name = "aiui"
version = "0.3.149"
version = "0.3.154"
description = "YAML-driven website generator - CLI and compiler"
readme = "README.md"
license = {text = "MIT"}
+1 -1
View File
@@ -6,7 +6,7 @@ import tseslint from 'typescript-eslint'
import { defineConfig, globalIgnores } from 'eslint/config'
export default defineConfig([
globalIgnores(['dist']),
globalIgnores(['dist', 'src/components/ui', 'src/spike', 'dev']),
{
files: ['**/*.{ts,tsx}'],
extends: [
+1892 -637
View File
File diff suppressed because it is too large. Load diff
+19 -1
View File
@@ -27,10 +27,16 @@
"scripts": {
"dev": "vite",
"build": "tsc -b && vite build",
"postbuild": "node scripts/gen-externals.mjs",
"build:lib": "tsc -b && vite build --mode lib",
"lint": "eslint .",
"preview": "vite preview",
"test": "node --experimental-strip-types --test \"src/agent-ui/**/*.test.ts\""
"test": "node scripts/run-tests.mjs",
"test:e2e": "playwright test -c e2e/playwright.config.ts",
"size": "size-limit",
"sync": "node scripts/sync-templates.mjs",
"check:bundle": "node scripts/check-bundle-sync.mjs",
"check:closure": "node scripts/check-entry-closure.mjs"
},
"keywords": [
"react",
@@ -63,6 +69,7 @@
}
},
"dependencies": {
"@assistant-ui/react": "0.15.36",
"@radix-ui/react-accordion": "^1.2.12",
"@radix-ui/react-avatar": "^1.1.11",
"@radix-ui/react-checkbox": "^1.3.3",
@@ -82,6 +89,7 @@
"@radix-ui/react-switch": "^1.2.6",
"@radix-ui/react-tabs": "^1.1.13",
"@radix-ui/react-tooltip": "^1.2.8",
"@streamdown/math": "1.0.3",
"@tailwindcss/typography": "^0.5.19",
"@types/react-syntax-highlighter": "^15.5.13",
"class-variance-authority": "^0.7.1",
@@ -89,31 +97,41 @@
"cmdk": "^1.1.1",
"date-fns": "^4.1.0",
"embla-carousel-react": "^8.6.0",
"katex": "0.16.47",
"lucide-react": "^0.563.0",
"mermaid": "^11.17.2",
"next-themes": "0.4.6",
"radix-ui": "1.8.1",
"react-day-picker": "^9.13.0",
"react-markdown": "^10.1.0",
"react-resizable-panels": "^4.5.4",
"react-syntax-highlighter": "^16.1.1",
"recharts": "2.15.4",
"remark-gfm": "^4.0.1",
"shiki": "4.6.0",
"sonner": "2.0.8",
"streamdown": "2.7.1",
"tailwind-merge": "^3.4.0"
},
"devDependencies": {
"@a2ui/react": "^0.10.0",
"@a2ui/web_core": "^0.10.0",
"@eslint/js": "^9.39.1",
"@playwright/test": "1.65.0",
"@size-limit/file": "14.3.0",
"@tailwindcss/vite": "^4.1.18",
"@types/node": "^24.10.1",
"@types/react": "^19.2.5",
"@types/react-dom": "^19.2.3",
"@vitejs/plugin-react": "^5.1.1",
"esbuild": "0.27.3",
"eslint": "^9.39.1",
"eslint-plugin-react-hooks": "^7.0.1",
"eslint-plugin-react-refresh": "^0.4.24",
"globals": "^16.5.0",
"react": "^19.2.0",
"react-dom": "^19.2.0",
"size-limit": "14.3.0",
"tailwindcss": "^4.1.18",
"tw-animate-css": "^1.4.0",
"typescript": "~5.9.3",
+120
View File
@@ -0,0 +1,120 @@
#!/usr/bin/env node
/**
* Post-build step (`npm run build` runs it as `postbuild`).
*
* Writes dist/aiui-externals.json: the named exports of every bare specifier a
* plugin may import (ADR-0001 §8). The server builds its
* /_aiui/externals/*.js ESM shims from this file, so the shims always match
* the React version actually bundled instead of a hand-written list.
*
* {
* "schema": 1,
* "sdkVersion": 1,
* "react": "19.2.4",
* "modules": {
* "react": ["Children", ...],
* "react/jsx-runtime": [...],
* "react-dom": [...],
* "react-dom/client": [...],
* "@aiui/sdk": [...] // SDK_EXPORTS from src/sdk/manifest.ts
* }
* }
*
* Export lists are the union of React's production and development builds,
* restricted to valid JS identifiers, sorted, without `default`.
*
* Usage: node scripts/gen-externals.mjs [--out <file>]
*/
import { mkdirSync, readFileSync, writeFileSync } from 'node:fs'
import { createRequire } from 'node:module'
import path from 'node:path'
import { fileURLToPath } from 'node:url'
import * as esbuild from 'esbuild'
const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..')
const require = createRequire(path.join(ROOT, 'package.json'))
const IDENT = /^[A-Za-z_$][\w$]*$/
function argValue(flag) {
const i = process.argv.indexOf(flag)
return i >= 0 ? process.argv[i + 1] : undefined
}
const outFile = path.resolve(ROOT, argValue('--out') ?? 'dist/aiui-externals.json')
/** Load src/sdk/manifest.ts on its own (it has no runtime imports). */
async function loadManifestModule() {
const file = path.join(ROOT, 'src', 'sdk', 'manifest.ts')
const { code } = await esbuild.transform(readFileSync(file, 'utf8'), {
loader: 'ts',
format: 'esm',
target: 'node22',
sourcefile: file,
})
if (/^\s*import\s/m.test(code)) {
throw new Error('src/sdk/manifest.ts must not have runtime imports (gen-externals loads it standalone)')
}
return import(`data:text/javascript;base64,${Buffer.from(code).toString('base64')}`)
}
/** Object.keys of a CommonJS package entry, loaded under a given NODE_ENV. */
function exportsUnder(specifier, nodeEnv) {
const prev = process.env.NODE_ENV
process.env.NODE_ENV = nodeEnv
try {
const resolved = require.resolve(specifier)
// The package entry picks ./cjs/*.{production,development}.js at require
// time; drop it from the cache so the other build is loaded next time.
delete require.cache[resolved]
return Object.keys(require(specifier))
} finally {
if (prev === undefined) delete process.env.NODE_ENV
else process.env.NODE_ENV = prev
}
}
function exportNames(specifier) {
const names = new Set([...exportsUnder(specifier, 'production'), ...exportsUnder(specifier, 'development')])
names.delete('default')
names.delete('__esModule')
return [...names].filter((n) => IDENT.test(n)).sort()
}
const manifest = await loadManifestModule()
const { SHELL_EXTERNALS, SDK_EXPORTS, SDK_VERSION } = manifest
if (!Array.isArray(SHELL_EXTERNALS) || !Array.isArray(SDK_EXPORTS)) {
throw new Error('src/sdk/manifest.ts must export SHELL_EXTERNALS and SDK_EXPORTS arrays')
}
const badSdk = SDK_EXPORTS.filter((n) => typeof n !== 'string' || !IDENT.test(n))
if (badSdk.length) throw new Error(`SDK_EXPORTS has invalid identifiers: ${badSdk.join(', ')}`)
if (new Set(SDK_EXPORTS).size !== SDK_EXPORTS.length) throw new Error('SDK_EXPORTS has duplicates')
const modules = {}
for (const spec of SHELL_EXTERNALS) {
if (spec === '@aiui/sdk') {
modules[spec] = [...SDK_EXPORTS]
continue
}
const names = exportNames(spec)
if (names.length === 0) throw new Error(`no exports found for ${spec}`)
modules[spec] = names
}
for (const required of ['react', 'react/jsx-runtime', 'react-dom', 'react-dom/client', '@aiui/sdk']) {
if (!modules[required]) throw new Error(`SHELL_EXTERNALS is missing ${required}`)
}
const out = {
schema: 1,
sdkVersion: SDK_VERSION,
react: require('react/package.json').version,
modules,
}
mkdirSync(path.dirname(outFile), { recursive: true })
writeFileSync(outFile, `${JSON.stringify(out, null, 2)}\n`)
const counts = Object.entries(modules)
.map(([k, v]) => `${k}=${v.length}`)
.join(' ')
console.log(`gen-externals: wrote ${path.relative(ROOT, outFile)} (React ${out.react}; ${counts})`)
+117
View File
@@ -0,0 +1,117 @@
#!/usr/bin/env node
/**
* Frontend unit test runner: `npm test [-- <path-substring> ...]`.
*
* Finds every src/**\/*.test.{ts,tsx}, bundles each one with esbuild into
* node_modules/.cache/aiui-tests/ and runs the bundles with `node --test`.
* Bundling is required: Node's type stripping cannot resolve extensionless
* imports (`./protocol`), the `@/` alias, TSX or CSS imports.
*
* - npm packages stay external (Node loads them from node_modules, so React
* has one copy and react-dom/server works).
* - CSS imports (relative or from packages) become empty modules.
* - `import.meta.env` is defined the way Vite would in a test build.
* - Positional arguments filter test files by path substring.
*
* Exits 0 when no test file matches.
*/
import { spawnSync } from 'node:child_process'
import { existsSync, readdirSync, rmSync, statSync } from 'node:fs'
import path from 'node:path'
import { fileURLToPath } from 'node:url'
import * as esbuild from 'esbuild'
const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..')
const SRC = path.join(ROOT, 'src')
const OUT = path.join(ROOT, 'node_modules', '.cache', 'aiui-tests')
const TEST_FILE = /\.test\.tsx?$/
const SKIP_DIRS = new Set(['node_modules', 'dist'])
function findTests(dir) {
const found = []
for (const name of readdirSync(dir)) {
if (SKIP_DIRS.has(name)) continue
const full = path.join(dir, name)
const st = statSync(full)
if (st.isDirectory()) found.push(...findTests(full))
else if (TEST_FILE.test(name)) found.push(full)
}
return found
}
/** Any `*.css` import (including bare package paths) becomes an empty module. */
const ignoreCss = {
name: 'aiui-ignore-css',
setup(build) {
build.onResolve({ filter: /\.css(\?.*)?$/ }, (args) => ({ path: args.path, namespace: 'aiui-empty-css' }))
build.onLoad({ filter: /.*/, namespace: 'aiui-empty-css' }, () => ({ contents: '', loader: 'js' }))
},
}
const filters = process.argv.slice(2).filter((a) => !a.startsWith('-'))
const tests = findTests(SRC)
.filter((f) => filters.length === 0 || filters.some((s) => path.relative(ROOT, f).includes(s)))
.sort()
if (tests.length === 0) {
console.log(filters.length ? `No test files match: ${filters.join(', ')}` : 'No test files found under src/.')
process.exit(0)
}
rmSync(OUT, { recursive: true, force: true })
try {
await esbuild.build({
entryPoints: tests,
outdir: OUT,
outbase: SRC,
outExtension: { '.js': '.mjs' },
bundle: true,
splitting: false,
platform: 'node',
format: 'esm',
target: 'node22',
packages: 'external',
tsconfig: path.join(ROOT, 'tsconfig.app.json'),
alias: { '@': SRC },
loader: {
'.css': 'empty',
'.svg': 'dataurl',
'.png': 'dataurl',
'.jpg': 'dataurl',
'.gif': 'dataurl',
'.webp': 'dataurl',
},
plugins: [ignoreCss],
define: {
'import.meta.env': JSON.stringify({ MODE: 'test', DEV: false, PROD: true, SSR: true, BASE_URL: '/' }),
},
jsx: 'automatic',
sourcemap: 'inline',
logLevel: 'warning',
})
} catch {
// esbuild already printed the diagnostics.
process.exit(1)
}
const bundles = tests.map((t) => {
const rel = path.relative(SRC, t).replace(/\.tsx?$/, '.mjs')
const out = path.join(OUT, rel)
if (!existsSync(out)) {
console.error(`run-tests: esbuild did not emit ${out}`)
process.exit(1)
}
return out
})
console.log(`run-tests: ${tests.length} test file(s): ${tests.map((t) => path.relative(ROOT, t)).join(', ')}`)
const res = spawnSync(process.execPath, ['--enable-source-maps', '--test', ...bundles], {
cwd: ROOT,
stdio: 'inherit',
})
if (res.error) {
console.error(res.error)
process.exit(1)
}
process.exit(res.status ?? 1)
+80
View File
@@ -0,0 +1,80 @@
#!/usr/bin/env node
/**
* Copy the Vite build (dist/) into the Python package's frontend templates.
*
* node scripts/sync-templates.mjs # -> src/praisonaiui/templates/frontend (tracked tree)
* node scripts/sync-templates.mjs --dest <dir> # -> any directory, e.g. a temp dir
*
* Serve a --dest copy without touching the tracked tree:
* AIUI_TEMPLATES_DIR=<dir> aiui run ...
*
* Steps:
* 1. delete <dest>/assets (removes stale hashed chunks from earlier builds)
* 2. copy dist/assets/**, dist/index.html, dist/icon.svg,
* dist/aiui-manifest.json and dist/aiui-externals.json into <dest>
* Anything else in <dest> (plugins/, dashboard-plugins/, chat.css, ...) is
* left untouched.
*/
import { copyFileSync, cpSync, existsSync, mkdirSync, rmSync, statSync } from 'node:fs'
import os from 'node:os'
import path from 'node:path'
import { fileURLToPath } from 'node:url'
const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..')
const DIST = path.join(ROOT, 'dist')
const DEFAULT_DEST = path.resolve(ROOT, '..', 'praisonaiui', 'templates', 'frontend')
const ROOT_FILES = ['index.html', 'icon.svg', 'aiui-manifest.json', 'aiui-externals.json']
function fail(msg) {
console.error(`sync-templates: ${msg}`)
process.exit(1)
}
function parseDest(argv) {
for (let i = 0; i < argv.length; i++) {
const a = argv[i]
if (a === '--dest') {
if (!argv[i + 1] || argv[i + 1].startsWith('--')) fail('--dest needs a directory')
return path.resolve(argv[i + 1])
}
if (a.startsWith('--dest=')) return path.resolve(a.slice('--dest='.length))
if (a === '-h' || a === '--help') {
console.log('usage: node scripts/sync-templates.mjs [--dest <dir>]')
process.exit(0)
}
fail(`unknown argument: ${a}`)
}
return DEFAULT_DEST
}
const dest = parseDest(process.argv.slice(2))
// Guard against wiping something important with a typo'd --dest.
const forbidden = new Set([path.parse(dest).root, os.homedir(), ROOT, path.resolve(ROOT, '..'), DIST])
if (forbidden.has(dest)) fail(`refusing to sync into ${dest}`)
for (const required of ['index.html', 'assets/index.js', 'aiui-manifest.json']) {
if (!existsSync(path.join(DIST, required))) fail(`dist/${required} is missing; run \`npm run build\` first`)
}
if (!existsSync(path.join(DIST, 'aiui-externals.json'))) {
fail('dist/aiui-externals.json is missing; run `npm run build` (postbuild runs scripts/gen-externals.mjs)')
}
if (existsSync(dest) && !statSync(dest).isDirectory()) fail(`${dest} is not a directory`)
mkdirSync(dest, { recursive: true })
const destAssets = path.join(dest, 'assets')
rmSync(destAssets, { recursive: true, force: true })
cpSync(path.join(DIST, 'assets'), destAssets, { recursive: true })
const copied = []
for (const f of ROOT_FILES) {
const src = path.join(DIST, f)
if (!existsSync(src)) {
console.warn(`sync-templates: dist/${f} not found, skipped`)
continue
}
copyFileSync(src, path.join(dest, f))
copied.push(f)
}
console.log(`sync-templates: dist/assets + ${copied.join(', ')} -> ${dest}`)
+15
View File
@@ -0,0 +1,15 @@
/**
* CONTRACT STUB (WP0a). WP7 replaces this file with the `kind=blocks` page
* renderer. Contract (ADR-0001 §5):
* export default function BlockPage(p: { page: ShellPage }): JSX.Element
*/
import type { JSX } from 'react'
import type { ShellPage } from '../sdk/manifest'
export default function BlockPage({ page }: { page: ShellPage }): JSX.Element {
return (
<div data-aiui-stub="block-page" data-page-id={page.id}>
{page.title}
</div>
)
}
+15
View File
@@ -0,0 +1,15 @@
/**
* CONTRACT STUB (WP0a). WP2 replaces this file with the assistant-ui chat page.
* Contract (ADR-0001 §5):
* export default function ChatPage(p: { page: ShellPage }): JSX.Element
*/
import type { JSX } from 'react'
import type { ShellPage } from '../sdk/manifest'
export default function ChatPage({ page }: { page: ShellPage }): JSX.Element {
return (
<div data-aiui-stub="chat-page" data-page-id={page.id}>
{page.title}
</div>
)
}
+16
View File
@@ -0,0 +1,16 @@
import { StrictMode } from 'react'
import { createRoot } from 'react-dom/client'
import App from './App.tsx'
/**
* The pre-shell React UI (styles chat, docs, agents, playground, custom).
* main.tsx lazy-loads this chunk unless <html data-aiui-shell="react"> is set.
* Rendering is exactly what main.tsx did before the boot split.
*/
export function mountApp(el: HTMLElement) {
createRoot(el).render(
<StrictMode>
<App />
</StrictMode>,
)
}
@@ -0,0 +1,21 @@
/**
* CONTRACT STUB (WP0a). WP4 replaces this file with the host that mounts a
* legacy dashboard view through legacy/runtime.ts mountView().
* Contract (ADR-0001 §5):
* export function LegacyViewHost(p: { pageId: string; page?: ShellPage }): JSX.Element
*/
import type { JSX } from 'react'
import type { ShellPage } from '../sdk/manifest'
export interface LegacyViewHostProps {
pageId: string
page?: ShellPage
}
export function LegacyViewHost({ pageId, page }: LegacyViewHostProps): JSX.Element {
return (
<div className="aiui-legacy-host" data-legacy-page={pageId} data-status="stub" data-aiui-stub="legacy-view-host">
{page?.title ?? pageId}
</div>
)
}
+31
View File
@@ -0,0 +1,31 @@
/**
* CONTRACT STUB (WP0a). WP4 replaces this file with the loader that imports
* /plugins/dashboard.js once and calls its initHostRuntime().
* Contract (ADR-0001 §5):
* ensureLegacyRuntime(): Promise<{ pages: unknown[]; mountView(id, el, {page, signal}): Promise<() => void> }>
* preloadLegacyRuntime(): void
* setLegacyNavigator(fn: (id: string) => void): void
*/
import type { ShellPage } from '../sdk/manifest'
export interface LegacyMountOptions {
page?: ShellPage
signal?: AbortSignal
}
export interface LegacyRuntime {
pages: unknown[]
mountView(pageId: string, el: HTMLElement, opts: LegacyMountOptions): Promise<() => void>
}
export function ensureLegacyRuntime(): Promise<LegacyRuntime> {
return Promise.reject(new Error('legacy runtime is not available in this build (WP4 stub)'))
}
/** Warm the runtime import ahead of navigation. No-op in the stub. */
export function preloadLegacyRuntime(): void {}
/** The shell installs its router here; legacy selectPage() calls go through it. */
export function setLegacyNavigator(fn: (pageId: string) => void): void {
void fn
}
+49 -8
View File
@@ -1,10 +1,51 @@
import { StrictMode } from 'react'
import { createRoot } from 'react-dom/client'
import './index.css'
import App from './App.tsx'
createRoot(document.getElementById('root')!).render(
<StrictMode>
<App />
</StrictMode>,
)
// Boot entry (assets/index.js). It stays tiny: it only decides which UI to
// load, and both choices are lazy chunks.
// <html data-aiui-shell="react"> (style: app) -> shell/mount
// anything else (chat, docs, agents, playground, custom) -> legacy-app-entry
// The vanilla `dashboard` style never loads this script at all.
// A deploy replaces the hashed chunks. A tab still running the previous
// index.js then fails to fetch a lazy chunk; reload once to pick up the new
// build. The guard is keyed by this entry's URL (it carries ?v=<build>), so a
// chunk that is missing in the *current* build cannot cause a reload loop.
const RELOAD_KEY = 'aiuiReloadedFor'
window.addEventListener('vite:preloadError', (event) => {
const build = import.meta.url
try {
if (sessionStorage.getItem(RELOAD_KEY) === build) return
sessionStorage.setItem(RELOAD_KEY, build)
} catch {
return // no sessionStorage: never risk an unguarded reload loop
}
event.preventDefault()
window.location.reload()
})
const rootEl = document.getElementById('root')
function bootFailed(err: unknown) {
console.error('[aiui] failed to load the UI bundle', err)
if (rootEl && !rootEl.hasChildNodes()) {
const msg = document.createElement('div')
msg.setAttribute('role', 'alert')
msg.style.cssText = 'padding:24px;font:14px system-ui,sans-serif'
msg.textContent = 'The interface failed to load. Reload the page to try again.'
rootEl.appendChild(msg)
}
}
// When the preloadError handler above has scheduled a reload, Vite resolves
// the import with `undefined` instead of rejecting, hence the `m &&` guards.
if (rootEl) {
if (document.documentElement.dataset.aiuiShell === 'react') {
import('./shell/mount')
.then((m) => m && m.mountShell(rootEl))
.catch(bootFailed)
} else {
import('./legacy-app-entry')
.then((m) => m && m.mountApp(rootEl))
.catch(bootFailed)
}
}
@@ -0,0 +1,31 @@
/**
* CONTRACT STUB (WP0a). WP1 replaces this file with the lazy Streamdown
* renderer. Contract (ADR-0001 §5):
* export function Markdown(p: { children: string; variant?: 'chat'|'docs'; streaming?: boolean;
* className?: string; components?: Record<string, ComponentType<any>> })
* export const preloadMarkdown
*/
import type { ComponentType } from 'react'
import { cn } from '@/lib/utils'
export interface MarkdownProps {
children: string
variant?: 'chat' | 'docs'
streaming?: boolean
className?: string
// eslint-disable-next-line @typescript-eslint/no-explicit-any
components?: Record<string, ComponentType<any>>
}
/** Plain-text rendering until WP1 lands. */
export function Markdown({ children, variant = 'chat', className }: MarkdownProps) {
return (
<div className={cn('aiui-markdown whitespace-pre-wrap', className)} data-variant={variant}>
{children}
</div>
)
}
/** Start fetching the renderer chunk before it is needed. */
// eslint-disable-next-line react-refresh/only-export-components -- contract export (ADR §5)
export const preloadMarkdown = (): Promise<void> => Promise.resolve()
+14
View File
@@ -0,0 +1,14 @@
/**
* CONTRACT STUB (WP0a). WP7 replaces this file: installSdk() sets
* window.__AIUI_SDK__, loadManifestPlugins() imports plugin modules.
* Contract (ADR-0001 §5):
* export function installSdk(): void
* export function loadManifestPlugins(m: ShellManifest): Promise<void>
*/
import type { ShellManifest } from './manifest'
export function installSdk(): void {}
export async function loadManifestPlugins(manifest: ShellManifest): Promise<void> {
void manifest
}
+160
View File
@@ -0,0 +1,160 @@
/**
* Shell manifest contract (ADR-0001 §5). Mirrors the server's
* `_build_shell_manifest()` served inline as `<script id="aiui-boot">` and at
* `GET /api/shell/manifest`.
*
* FROZEN CONTRACT: change only additively, and keep the server in sync.
*
* This module must stay free of runtime imports: scripts/gen-externals.mjs
* transpiles it on its own to read SDK_EXPORTS at build time.
*/
/** Major version of the browser plugin SDK (`window.__AIUI_SDK__.version`). */
export const SDK_VERSION = 1
/**
* Bare specifiers a plugin ESM bundle may import. The server's import map
* points each one at an `/_aiui/externals/*.js` shim that re-exports the
* shell's own instance from `window.__AIUI_SDK__.modules[specifier]`.
*/
export const SHELL_EXTERNALS = [
'react',
'react/jsx-runtime',
'react-dom',
'react-dom/client',
'@aiui/sdk',
] as const
export type ShellExternal = (typeof SHELL_EXTERNALS)[number]
/**
* The frozen named-export list of the `@aiui/sdk` module. The object installed
* at `window.__AIUI_SDK__.modules['@aiui/sdk']` (WP7, sdk/install.ts) must have
* exactly these keys, and the server's ESM shim re-exports exactly these names
* (read from dist/aiui-externals.json, generated from this list).
*/
export const SDK_EXPORTS = [
'SDK_VERSION',
'registerPage',
'registerBlock',
'registerSlot',
'registerToolRenderer',
'registerView',
'getPage',
'getBlock',
'getSlot',
'getToolRenderers',
'subscribe',
'useRegistryVersion',
'Markdown',
] as const
export type SdkExportName = (typeof SDK_EXPORTS)[number]
export type ShellPageKind = 'native' | 'react' | 'blocks' | 'legacy' | 'iframe'
export interface ShellPage {
id: string
title: string
kind: ShellPageKind
icon?: string
group?: string
order?: number
description?: string
apiEndpoint?: string | null
actionEndpoint?: string
module?: string
css?: string
integrity?: string
layout?: string
source?: string
}
export type ShellPluginStatus = 'ok' | 'error' | 'disabled' | 'incompatible' | 'disabled-by-default'
export interface ShellManifest {
sdk: number
version: string
style: string
site: Record<string, unknown>
layout: { sidebar?: unknown; pageHeader?: unknown }
pages: ShellPage[]
blocks: { type: string; module: string; integrity?: string; source: string }[]
toolRenderers: { toolName: string; module: string; integrity?: string; source: string }[]
plugins: { name: string; version?: string; status: ShellPluginStatus; error?: string }[]
legacy: { customJs: string | null; viewsBase: string }
}
export const BOOT_ELEMENT_ID = 'aiui-boot'
export const MANIFEST_URL = '/api/shell/manifest'
function isRecord(v: unknown): v is Record<string, unknown> {
return typeof v === 'object' && v !== null && !Array.isArray(v)
}
function arrayOf<T>(v: unknown): T[] {
return Array.isArray(v) ? (v as T[]) : []
}
/**
* Fill every optional collection with a safe default so consumers can iterate
* without guards. Returns null when `raw` is not a full manifest (no `pages`
* array), e.g. the minimal `{sdk, version, style}` copy the server inlines
* when auth is enforced.
*/
export function normalizeManifest(raw: unknown): ShellManifest | null {
if (!isRecord(raw) || !Array.isArray(raw.pages)) return null
const legacy = isRecord(raw.legacy) ? raw.legacy : {}
return {
sdk: typeof raw.sdk === 'number' ? raw.sdk : SDK_VERSION,
version: typeof raw.version === 'string' ? raw.version : '',
style: typeof raw.style === 'string' ? raw.style : 'app',
site: isRecord(raw.site) ? raw.site : {},
layout: isRecord(raw.layout) ? raw.layout : {},
pages: arrayOf<ShellPage>(raw.pages).filter((p) => isRecord(p) && typeof p.id === 'string'),
blocks: arrayOf(raw.blocks),
toolRenderers: arrayOf(raw.toolRenderers),
plugins: arrayOf(raw.plugins),
legacy: {
customJs: typeof legacy.customJs === 'string' ? legacy.customJs : null,
viewsBase: typeof legacy.viewsBase === 'string' ? legacy.viewsBase : '/plugins/views/',
},
}
}
/**
* Read the manifest the server inlined as `<script id="aiui-boot"
* type="application/json">`. Returns null when it is missing, unparseable or
* partial (auth-enforced servers inline only `{sdk, version, style}`); the
* shell then calls fetchManifest().
*/
export function readBootManifest(): ShellManifest | null {
if (typeof document === 'undefined') return null
const el = document.getElementById(BOOT_ELEMENT_ID)
if (!el?.textContent) return null
try {
return normalizeManifest(JSON.parse(el.textContent))
} catch {
return null
}
}
/**
* GET /api/shell/manifest. `fetchImpl` lets the shell pass its auth-aware
* fetch. Rejects on a non-2xx response or a body that is not a manifest.
*/
export async function fetchManifest(fetchImpl: typeof fetch = fetch): Promise<ShellManifest> {
const res = await fetchImpl(MANIFEST_URL, {
credentials: 'same-origin',
headers: { Accept: 'application/json' },
})
if (!res.ok) throw new Error(`GET ${MANIFEST_URL} failed: ${res.status}`)
const m = normalizeManifest(await res.json())
if (!m) throw new Error(`GET ${MANIFEST_URL} returned an invalid manifest`)
return m
}
declare global {
interface Window {
/** Cache-bust string of the running build (style `app`; set inline by the server). */
__AIUI_BUILD__?: string
}
}
+100
View File
@@ -0,0 +1,100 @@
// Contract tests for sdk/registry.ts and sdk/manifest.ts (WP0a).
// Uses the `@/` alias and extensionless imports on purpose: scripts/run-tests.mjs
// bundles every test with esbuild, which Node's type stripping alone cannot do.
import { test } from 'node:test'
import assert from 'node:assert/strict'
import { createElement, Suspense } from 'react'
import { renderToStaticMarkup } from 'react-dom/server'
import {
getBlock,
getPage,
getSlot,
getToolRenderers,
registerBlock,
registerPage,
registerSlot,
registerToolRenderer,
subscribe,
type BlockComponent,
type PageComponent,
} from '@/sdk/registry'
import { normalizeManifest, SDK_EXPORTS, SHELL_EXTERNALS, type ShellPage } from '@/sdk/manifest'
const page: ShellPage = { id: 'p', title: 'P', kind: 'react' }
test('registerPage stores a function component as-is and notifies subscribers', () => {
let calls = 0
const off = subscribe(() => {
calls += 1
})
function Hello({ page }: { page: ShellPage }) {
return createElement('span', null, `hello ${page.title}`)
}
registerPage('hello', Hello)
assert.equal(getPage('hello'), Hello)
assert.equal(calls, 1)
registerPage('hello', Hello) // same component: no change, no notification
assert.equal(calls, 1)
off()
registerPage('hello2', Hello)
assert.equal(calls, 1, 'unsubscribed listener must not fire')
assert.equal(renderToStaticMarkup(createElement(Hello, { page })), '<span>hello P</span>')
})
test('registerPage treats a zero-arg arrow returning import() as a lazy loader', async () => {
function Lazy({ page }: { page: ShellPage }) {
return createElement('b', null, page.id)
}
registerPage('lazy', () => Promise.resolve({ default: Lazy as PageComponent }))
const C = getPage('lazy')
assert.ok(C)
assert.notEqual(C, Lazy)
// React.lazy object; resolve it the way React would.
const payload = (C as unknown as { _payload: unknown; _init: (p: unknown) => unknown })
try {
payload._init(payload._payload)
} catch (thenable) {
await thenable
}
const resolved = payload._init(payload._payload)
assert.equal(resolved, Lazy)
const html = renderToStaticMarkup(createElement(Suspense, { fallback: 'x' }, createElement(C!, { page })))
assert.equal(html, '<b>p</b>')
})
test('blocks, tool renderers and slots round-trip; tool renderer snapshot is stable', () => {
const Block = () => null
registerBlock('metric', Block)
assert.equal(getBlock('metric'), Block)
assert.equal(getBlock('missing'), undefined)
const Weather = () => null
registerToolRenderer('get_weather', Weather)
const a = getToolRenderers()
assert.equal(a.get_weather, Weather)
assert.equal(getToolRenderers(), a, 'snapshot identity is stable without changes')
registerSlot('header.right', Weather)
assert.notEqual(getToolRenderers(), a, 'snapshot is rebuilt after any registry change')
assert.equal(getSlot('header.right'), Weather)
})
test('registration rejects bad ids and non-components', () => {
assert.throws(() => registerPage('', (() => null) as unknown as PageComponent), TypeError)
assert.throws(() => registerBlock('x', 42 as unknown as BlockComponent), TypeError)
})
test('normalizeManifest fills defaults and rejects partial boot copies', () => {
assert.equal(normalizeManifest({ sdk: 1, version: '1', style: 'app' }), null)
assert.equal(normalizeManifest(null), null)
const m = normalizeManifest({ pages: [{ id: 'chat', title: 'Chat', kind: 'react' }, { nope: 1 }] })
assert.ok(m)
assert.deepEqual(m.pages.map((p) => p.id), ['chat'])
assert.deepEqual(m.blocks, [])
assert.deepEqual(m.legacy, { customJs: null, viewsBase: '/plugins/views/' })
})
test('SDK_EXPORTS and SHELL_EXTERNALS are frozen-shape lists', () => {
assert.equal(new Set(SDK_EXPORTS).size, SDK_EXPORTS.length)
for (const name of SDK_EXPORTS) assert.match(name, /^[A-Za-z_$][\w$]*$/)
assert.deepEqual([...SHELL_EXTERNALS].sort(), ['@aiui/sdk', 'react', 'react-dom', 'react-dom/client', 'react/jsx-runtime'])
})
+171
View File
@@ -0,0 +1,171 @@
/**
* Browser-side registry for pages, blocks, tool renderers and slots
* (ADR-0001 §5). The shell resolves a page id here first, then `kind=blocks`
* (BlockPage), then the legacy host. Plugins reach these functions through
* `@aiui/sdk` (window.__AIUI_SDK__).
*
* FROZEN CONTRACT: change only additively.
*/
import { lazy, useSyncExternalStore, type ComponentType } from 'react'
import type { ShellPage } from './manifest'
export interface BlockNode {
type: string
props?: Record<string, unknown>
children?: BlockNode[] | string
}
export type PageComponent = ComponentType<{ page: ShellPage }>
export type PageLoader = () => Promise<{ default: PageComponent }>
export type BlockComponent = ComponentType<{ node: BlockNode; page?: ShellPage }>
// eslint-disable-next-line @typescript-eslint/no-explicit-any
export type AnyComponent = ComponentType<any>
const pages = new Map<string, PageComponent>()
const blocks = new Map<string, BlockComponent>()
const toolRenderers = new Map<string, AnyComponent>()
const slots = new Map<string, AnyComponent>()
const listeners = new Set<() => void>()
let version = 0
let toolRenderersSnapshot: Record<string, AnyComponent> | null = null
function emit() {
version += 1
toolRenderersSnapshot = null
for (const fn of [...listeners]) {
try {
fn()
} catch (err) {
console.error('[aiui sdk] registry listener failed', err)
}
}
}
function requireName(kind: string, name: unknown): string {
if (typeof name !== 'string' || !name) {
throw new TypeError(`[aiui sdk] ${kind}: expected a non-empty string id, got ${String(name)}`)
}
return name
}
function requireComponent(kind: string, name: string, c: unknown) {
const ok =
typeof c === 'function' ||
// memo / forwardRef / lazy components are objects with $$typeof
(typeof c === 'object' && c !== null && '$$typeof' in c)
if (!ok) throw new TypeError(`[aiui sdk] ${kind}('${name}'): expected a React component`)
}
function setEntry<T>(map: Map<string, T>, kind: string, name: string, value: T) {
const prev = map.get(name)
if (prev === value) return
if (prev !== undefined) console.warn(`[aiui sdk] ${kind}('${name}') replaces an earlier registration`)
map.set(name, value)
emit()
}
function isThenable(v: unknown): v is PromiseLike<unknown> {
return (
(typeof v === 'object' || typeof v === 'function') &&
v !== null &&
typeof (v as { then?: unknown }).then === 'function'
)
}
/**
* A zero-argument arrow function (no `prototype`) is treated as a lazy loader,
* e.g. `registerPage('chat', () => import('./ChatPage'))`. Components declared
* with `function`, classes, and memo/forwardRef/lazy objects are components.
*/
function looksLikeLoader(c: unknown): boolean {
return typeof c === 'function' && c.length === 0 && !('prototype' in c && c.prototype)
}
function lazyPage(load: () => unknown, fallbackComponent: PageComponent): PageComponent {
return lazy(async () => {
let result: unknown
try {
result = load()
} catch {
// Calling a zero-arg arrow *component* outside render can throw
// (hooks). It was a component after all.
return { default: fallbackComponent }
}
if (!isThenable(result)) return { default: fallbackComponent }
const mod = await result
const c =
typeof mod === 'object' && mod !== null && 'default' in mod
? (mod as { default: unknown }).default
: mod
requireComponent('registerPage', '<loader result>', c)
return { default: c as PageComponent }
}) as unknown as PageComponent
}
/**
* Register a React page for a shell page id. `c` is a component, or a loader
* returning `import()` of a module whose default export is the component.
* Render the result of getPage() inside <Suspense>: a loader becomes a
* React.lazy component.
*/
export function registerPage(id: string, c: PageComponent | PageLoader): void {
const name = requireName('registerPage', id)
requireComponent('registerPage', name, c)
const component = looksLikeLoader(c) ? lazyPage(c as () => unknown, c as PageComponent) : (c as PageComponent)
setEntry(pages, 'registerPage', name, component)
}
export function registerBlock(type: string, c: BlockComponent): void {
const name = requireName('registerBlock', type)
requireComponent('registerBlock', name, c)
setEntry(blocks, 'registerBlock', name, c)
}
/** Feeds the chat Thread's `tools.by_name` renderers. */
export function registerToolRenderer(toolName: string, c: AnyComponent): void {
const name = requireName('registerToolRenderer', toolName)
requireComponent('registerToolRenderer', name, c)
setEntry(toolRenderers, 'registerToolRenderer', name, c)
}
export function registerSlot(slot: string, c: AnyComponent): void {
const name = requireName('registerSlot', slot)
requireComponent('registerSlot', name, c)
setEntry(slots, 'registerSlot', name, c)
}
export function getPage(id: string): PageComponent | undefined {
return pages.get(id)
}
export function getBlock(type: string): BlockComponent | undefined {
return blocks.get(type)
}
/** Snapshot object; its identity changes only when the registry changes. */
export function getToolRenderers(): Record<string, AnyComponent> {
if (!toolRenderersSnapshot) toolRenderersSnapshot = Object.fromEntries(toolRenderers)
return toolRenderersSnapshot
}
export function getSlot(name: string): AnyComponent | undefined {
return slots.get(name)
}
/** Called after every registration change. Returns an unsubscribe function. */
export function subscribe(fn: () => void): () => void {
listeners.add(fn)
return () => {
listeners.delete(fn)
}
}
function getVersion() {
return version
}
/** Re-renders the caller whenever anything is (re-)registered. */
export function useRegistryVersion(): number {
return useSyncExternalStore(subscribe, getVersion, getVersion)
}
+23
View File
@@ -0,0 +1,23 @@
/**
* CONTRACT STUB (WP0a). WP6 replaces this file with the React shell.
* Contract (ADR-0001 §5): export function mountShell(el: HTMLElement): void
*/
import { StrictMode } from 'react'
import { createRoot } from 'react-dom/client'
// eslint-disable-next-line react-refresh/only-export-components -- mount entry, not an HMR boundary
function ShellPlaceholder() {
return (
<div className="aiui-app" data-aiui-stub="shell" role="status">
Shell loading
</div>
)
}
export function mountShell(el: HTMLElement): void {
createRoot(el).render(
<StrictMode>
<ShellPlaceholder />
</StrictMode>,
)
}
+1 -1
View File
@@ -120,7 +120,7 @@ export interface TemplateConfig {
export interface UIConfig {
debug?: boolean
site?: SiteConfig
style?: 'docs' | 'chat' | 'agents' | 'playground' | 'custom' | 'dashboard'
style?: 'docs' | 'chat' | 'agents' | 'playground' | 'custom' | 'dashboard' | 'app'
layout?: LayoutConfig
chat?: ChatConfig
auth?: AuthConfig
+113 -6
View File
@@ -1,24 +1,131 @@
import { defineConfig } from 'vite'
import { defineConfig, type Plugin } from 'vite'
import react from '@vitejs/plugin-react'
import tailwindcss from '@tailwindcss/vite'
import path from 'path'
// Dev server: proxy every server-owned path to a running `aiui` backend so
// `npm run dev` (and the dev/*.html harness pages) can talk to real APIs.
const DEV_BACKEND = process.env.AIUI_DEV_BACKEND || 'http://127.0.0.1:8000'
const PROXIED_PATHS = [
'/api',
'/run',
'/cancel',
'/starters',
'/sessions',
'/plugins',
'/dashboard-plugins',
'/_aiui',
'/_plugins',
'/custom.js',
]
// React, ReactDOM and the scheduler get one stable chunk shared by the shell,
// the legacy App path and every lazy route (size budgets glob for it).
const VENDOR_REACT = /[\\/]node_modules[\\/](react|react-dom|scheduler)[\\/]/
// No chunk may import the entry. The server loads it as
// /assets/index.js?v=<build>, and a chunk's `import "./index.js"` is a
// different module URL, so the entry (and main.tsx's mount) would run twice.
// Two things would otherwise create such an import:
// - Vite's dynamic-import preload helper, emitted inside the entry by default
// (moved to its own chunk below);
// - a second import of the global stylesheet src/index.css (App.tsx has one),
// which makes the importing chunk depend on the entry that owns the CSS.
const ENTRY_FILE = 'assets/index.js'
const PRELOAD_HELPER = 'vite/preload-helper'
const MAIN_TSX = path.resolve(__dirname, 'src/main.tsx')
const GLOBAL_CSS = path.resolve(__dirname, 'src/index.css')
const NOOP_CSS_ID = '\0aiui-global-css-already-loaded'
/**
* src/index.css is the entry's stylesheet (main.tsx imports it, the server
* links /assets/index.css). In a build, any other module's import of it is
* redundant and is resolved to an empty module.
*/
function globalCssOnlyFromEntry(): Plugin {
return {
name: 'aiui-global-css-only-from-entry',
apply: 'build',
enforce: 'pre',
async resolveId(source, importer, options) {
if (!importer || !/index\.css(\?.*)?$/.test(source)) return null
if (importer.split('?')[0] === MAIN_TSX) return null
const resolved = await this.resolve(source, importer, { ...options, skipSelf: true })
if (resolved && resolved.id.split('?')[0] === GLOBAL_CSS) return NOOP_CSS_ID
return null
},
load(id) {
return id === NOOP_CSS_ID ? '' : null
},
}
}
/** Fail the build if any chunk imports the entry (see above). */
function noEntryImports(): Plugin {
return {
name: 'aiui-no-entry-imports',
apply: 'build',
generateBundle(_options, bundle) {
for (const chunk of Object.values(bundle)) {
if (chunk.type !== 'chunk' || chunk.fileName === ENTRY_FILE) continue
if (chunk.imports.includes(ENTRY_FILE) || chunk.dynamicImports.includes(ENTRY_FILE)) {
this.error(
`${chunk.fileName} imports ${ENTRY_FILE}; the entry is loaded with ?v= so it would be evaluated twice. ` +
'Do not export from or import main.tsx, and import src/index.css only from main.tsx.',
)
}
}
},
}
}
export default defineConfig({
plugins: [react(), tailwindcss()],
plugins: [globalCssOnlyFromEntry(), react(), tailwindcss(), noEntryImports()],
resolve: {
alias: {
"@": path.resolve(__dirname, "./src"),
'@': path.resolve(__dirname, './src'),
},
},
server: {
proxy: {
// The chat WebSocket must be listed before the /api prefix rule.
'/api/chat/ws': { target: DEV_BACKEND, ws: true, changeOrigin: true },
...Object.fromEntries(PROXIED_PATHS.map((p) => [p, { target: DEV_BACKEND, changeOrigin: true }])),
},
},
build: {
outDir: 'dist',
assetsDir: 'assets',
// Vite manifest (not the PWA kind): maps source modules to emitted chunks.
// Gates (closure check, size budgets) and the server resolve chunks with it.
manifest: 'aiui-manifest.json',
rollupOptions: {
// Only the real app entry. dev/*.html harness pages are served by the dev
// server but never built (and spike.html is a dev-only page too).
input: path.resolve(__dirname, 'index.html'),
output: {
// Static filenames instead of hashed (cleaner for git)
// The entry keeps a fixed name: the Python server references
// /assets/index.js?v=<cache_bust> directly. Everything it loads lazily
// is content-hashed (immutable caching, and no case-insensitive name
// collisions such as shiki's markdown.js vs our Markdown.js on macOS).
entryFileNames: 'assets/index.js',
chunkFileNames: 'assets/[name].js',
assetFileNames: 'assets/[name].[ext]',
chunkFileNames: 'assets/[name]-[hash].js',
assetFileNames: (assetInfo) => {
const names = assetInfo.names ?? []
const origins = assetInfo.originalFileNames ?? []
// The entry's CSS (index.html -> main.tsx -> index.css) is linked by
// the server as /assets/index.css, so it keeps its fixed name. Any
// other asset (lazy-chunk CSS, fonts, images) is hashed.
if (names.includes('index.css') && origins.includes('index.html')) {
return 'assets/index.css'
}
return 'assets/[name]-[hash][extname]'
},
manualChunks: (id) => {
if (VENDOR_REACT.test(id)) return 'vendor-react'
if (id.includes(PRELOAD_HELPER)) return 'preload-helper'
return undefined
},
},
},
},
+1 -1
View File
@@ -1,3 +1,3 @@
"""Version information."""
__version__ = "0.3.149"
__version__ = "0.3.154"
+31 -7
View File
@@ -32,8 +32,18 @@ def _plugin_search_dirs(extra_dirs: list[str] | None = None) -> list[Path]:
return dirs
def discover_dashboard_plugins(extra_dirs: list[str] | None = None) -> list[dict[str, Any]]:
"""Scan dashboard-plugins folders for manifest.json files."""
def discover_dashboard_plugins(
extra_dirs: list[str] | None = None,
allowed: list[str] | None = None,
) -> list[dict[str, Any]]:
"""Scan dashboard-plugins folders for manifest.json files.
``allowed`` restricts results to the given plugin names; ``None`` keeps
every discovered plugin and ``[]`` disables all of them.
"""
if allowed is not None and not allowed:
return []
allow = set(allowed) if allowed is not None else None
manifests: list[dict[str, Any]] = []
seen: set[str] = set()
@@ -53,6 +63,8 @@ def discover_dashboard_plugins(extra_dirs: list[str] | None = None) -> list[dict
if name in seen:
continue
seen.add(name)
if allow is not None and name not in allow:
continue
tab = data.get("tab") or {}
path = tab.get("path") or f"/{name}"
page_id = path.strip("/").split("/")[0] or name
@@ -80,8 +92,13 @@ def discover_dashboard_plugins(extra_dirs: list[str] | None = None) -> list[dict
return manifests
def _resolve_plugin_file(name: str, subpath: str, extra_dirs: list[str] | None) -> Path | None:
for manifest in discover_dashboard_plugins(extra_dirs):
def _resolve_plugin_file(
name: str,
subpath: str,
extra_dirs: list[str] | None,
allowed: list[str] | None = None,
) -> Path | None:
for manifest in discover_dashboard_plugins(extra_dirs, allowed):
if manifest["name"] != name:
continue
root = Path(manifest["root"])
@@ -95,18 +112,25 @@ def _resolve_plugin_file(name: str, subpath: str, extra_dirs: list[str] | None)
def make_dashboard_plugin_routes(
get_extra_dirs,
get_allowed=None,
) -> list[Route]:
"""Factory: ``get_extra_dirs`` returns plugin dir list from server config."""
"""Factory: ``get_extra_dirs`` returns plugin dir list from server config.
``get_allowed`` optionally returns the plugin-name allowlist (``None`` = all).
"""
def _allowed() -> list[str] | None:
return get_allowed() if get_allowed else None
async def list_plugins(request: Request) -> JSONResponse:
extra = get_extra_dirs()
return JSONResponse({"plugins": discover_dashboard_plugins(extra)})
return JSONResponse({"plugins": discover_dashboard_plugins(extra, _allowed())})
async def serve_plugin_asset(request: Request) -> Response:
name = request.path_params["name"]
subpath = request.path_params["path"]
extra = get_extra_dirs()
file_path = _resolve_plugin_file(name, subpath, extra)
file_path = _resolve_plugin_file(name, subpath, extra, _allowed())
if not file_path:
return Response(status_code=404)
media = "text/javascript"
+79 -14
View File
@@ -276,9 +276,11 @@ def set_custom_js(path: str | Path) -> None:
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.
*after* the plugin loader. ``window.aiui`` registry APIs
(``registerView``, ``registerComponent``, ``registerSlot``,
``registerSurfaceRenderer``) can be called directly at load time:
calls made before the dashboard module is ready are queued and
replayed, and a view registered for the active page re-renders it.
Use this to ship client-side extensions from your Python app without
modifying the package bundle.
@@ -373,6 +375,7 @@ def set_dashboard(
page_header: bool = True,
modules: list[str] | None = None,
plugin_dirs: list[str] | None = None,
plugins: list[str] | None = None,
) -> None:
"""Configure dashboard layout options.
@@ -384,6 +387,11 @@ def set_dashboard(
page_header: Show the page title/description header (default True).
modules: Optional JS modules to load (``jobs``, ``auth``, ``api``).
plugin_dirs: Extra paths to scan for ``dashboard-plugins`` manifests.
plugins: Allowlist of dashboard plugin names to enable. ``None``
(default) enables every discovered plugin (bundled, home and
``plugin_dirs``); ``[]`` disables all. Applies to both the sidebar
pages and the browser plugin manifest. This scopes navigation only;
it is not an authorization boundary for API routes.
Example::
@@ -399,6 +407,14 @@ def set_dashboard(
"modules": list(modules) if modules else [],
"pluginDirs": list(plugin_dirs) if plugin_dirs else [],
}
if plugins is not None:
_dashboard_config["plugins"] = list(plugins)
def _dashboard_plugin_allowlist() -> list[str] | None:
"""Plugin-name allowlist from ``set_dashboard(plugins=...)`` (``None`` = all)."""
allowed = (_dashboard_config or {}).get("plugins")
return list(allowed) if allowed is not None else None
_jobs_api_config: dict[str, str] = {"apiBase": "/api/jobs", "backend": "aiui"}
@@ -788,11 +804,37 @@ def _build_html(style: str) -> str:
)
def _frontend_templates_dir() -> Path:
"""Directory holding the built React frontend (``assets/``, ``aiui-manifest.json``,
``aiui-externals.json``).
Defaults to the packaged ``templates/frontend``. ``AIUI_TEMPLATES_DIR`` points
it at another build, e.g. one copied with
``node scripts/sync-templates.mjs --dest <dir>``, without touching the
packaged (tracked) files.
"""
override = os.environ.get("AIUI_TEMPLATES_DIR", "").strip()
if override:
return Path(override).expanduser().resolve()
return Path(__file__).parent / "templates" / "frontend"
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>'
return _CUSTOM_JS_QUEUE + f'<script src="/custom.js?v={cache_bust}"></script>'
# Registration stubs for /custom.js: the dashboard module loads
# asynchronously, so until it defines the real registry APIs these stubs
# queue calls on ``window.aiui.__queue``; dashboard.js replays them.
_CUSTOM_JS_QUEUE = (
"<script>(function(a){a.__queue=a.__queue||[];"
"['registerView','registerComponent','registerSlot','registerSurfaceRenderer']"
".forEach(function(n){if(!a[n])a[n]=function(){a.__queue.push([n,arguments]);};});"
"})(window.aiui=window.aiui||{});</script>"
)
async def _serve_custom_js(request: Request) -> Response:
@@ -1081,11 +1123,18 @@ async def health_ready(request: Request) -> JSONResponse:
provider = get_provider()
provider_info = {"name": type(provider).__name__}
try:
provider_health = await provider.health()
provider_info.update(provider_health)
except Exception:
pass
async def _safe_provider() -> Dict[str, Any]:
# Bounded like feature checks so a slow provider cannot hang readiness
# (or the cold /api/health dashboard request that delegates here).
try:
result = await asyncio.wait_for(provider.health(), timeout=0.5)
except Exception:
return {}
return result if isinstance(result, dict) else {}
# Start the provider check now so it runs concurrently with feature checks.
provider_task = asyncio.ensure_future(_safe_provider())
features_health = {}
sdk_gaps: list = []
@@ -1124,6 +1173,8 @@ async def health_ready(request: Request) -> JSONResponse:
except Exception:
pass
provider_info.update(await provider_task)
payload: Dict[str, Any] = {
"status": "ok",
"timestamp": datetime.utcnow().isoformat(),
@@ -1303,9 +1354,11 @@ async def patch_session(request: Request) -> JSONResponse:
body = await request.json()
except Exception:
return JSONResponse({"error": "Invalid JSON"}, status_code=400)
title = body.get("title")
title = body.get("title") if isinstance(body, dict) else None
if title is not None:
await _datastore.update_session(session_id, title=title)
if not isinstance(title, str) or not title.strip():
return JSONResponse({"error": "title must be a non-empty string"}, status_code=400)
await _datastore.update_session(session_id, title=title.strip()[:200])
return JSONResponse({"status": "updated", "session_id": session_id})
@@ -1723,7 +1776,7 @@ async def api_pages(request: Request) -> JSONResponse:
pages = list(_pages.values())
extra_dirs = (_dashboard_config or {}).get("pluginDirs") or []
known_ids = {p["id"] for p in pages}
for manifest in discover_dashboard_plugins(extra_dirs):
for manifest in discover_dashboard_plugins(extra_dirs, _dashboard_plugin_allowlist()):
page = manifest.get("page")
if page and page["id"] not in known_ids:
pages.append(page)
@@ -3191,7 +3244,7 @@ def create_app(
cfg = _dashboard_config or {}
return list(cfg.get("pluginDirs") or cfg.get("plugin_dirs") or [])
routes.extend(make_dashboard_plugin_routes(_dashboard_plugin_dirs))
routes.extend(make_dashboard_plugin_routes(_dashboard_plugin_dirs, _dashboard_plugin_allowlist))
# ── Gap S1: Auto-initialize gateway in standalone mode ──────────
# When running standalone (not via AIUIGateway.start()), we need to
@@ -3298,6 +3351,7 @@ def create_app(
"group": "Agent",
"description": "AI agent chat",
"order": 1,
"layout": "full-bleed",
},
{
"id": "chat-canvas",
@@ -3306,6 +3360,7 @@ def create_app(
"group": "Agent",
"description": "Chat with live A2UI preview panel",
"order": 2,
"layout": "full-bleed",
},
{
"id": "canvas",
@@ -3631,11 +3686,21 @@ def create_app(
}
# Always mount built-in frontend plugins and assets BEFORE catch-all /
_frontend_dir = Path(__file__).parent / "templates" / "frontend"
_package_frontend_dir = Path(__file__).parent / "templates" / "frontend"
_frontend_dir = _frontend_templates_dir()
# Hand-written plugins/ ship with the package; an AIUI_TEMPLATES_DIR build
# copy only replaces them if it carries its own plugins/ directory.
_plugins_dir = _frontend_dir / "plugins"
if not _plugins_dir.is_dir():
_plugins_dir = _package_frontend_dir / "plugins"
if static_dir and (Path(static_dir) / "plugins").is_dir():
_plugins_dir = Path(static_dir) / "plugins"
_assets_dir = _frontend_dir / "assets"
if not _assets_dir.is_dir() and _frontend_dir != _package_frontend_dir:
logging.getLogger(__name__).warning(
"AIUI_TEMPLATES_DIR=%s has no assets/ directory; /assets will not be served",
_frontend_dir,
)
# Dynamic plugins.json route — MUST come before static /plugins mount
# so the dynamic endpoint takes priority over the static file.
@@ -241,6 +241,10 @@ const aiuiSdk = {
window.aiui = window.aiui || {};
window.aiui.registerView = function(pageId, renderFn, cleanupFn) {
VIEW_REGISTRY[pageId] = { render: renderFn, cleanup: cleanupFn || null };
// Late registration for the page already on screen: re-render it.
if (pageId === activePageId && document.getElementById('db-main-content')) {
selectPage(pageId);
}
};
window.aiui.views = VIEW_REGISTRY;
window.aiui.registerComponent = function(type, renderFn) {
@@ -291,6 +295,14 @@ const DASHBOARD_STYLE = `
/* Root becomes the flex container */
#root { display: flex; min-height: 100vh; }
/* With a shell:header slot: sidebar | (header row sized to content, page panel below). */
#root.db-active:has(> #db-shell-header-slot) {
display: grid; grid-template-columns: auto minmax(0, 1fr); grid-template-rows: auto minmax(0, 1fr);
height: 100vh; min-height: 0;
}
#root.db-active:has(> #db-shell-header-slot) > .db-sidebar { grid-column: 1; grid-row: 1 / -1; }
#db-shell-header-slot { grid-column: 2; grid-row: 1; min-width: 0; padding: 8px 40px 0; }
#root.db-active:has(> #db-shell-header-slot) > .db-main { grid-column: 2; grid-row: 2; height: auto; min-height: 0; }
/* Sidebar */
.db-sidebar {
@@ -367,7 +379,9 @@ const DASHBOARD_STYLE = `
border-left: 3px solid transparent;
text-decoration: none;
}
button.db-nav-item { width: 100%; background: none; border-top: 0; border-right: 0; border-bottom: 0; font: inherit; text-align: left; }
.db-nav-item:hover { background: var(--db-hover); color: var(--db-text); }
.db-nav-item:focus-visible { outline: 2px solid var(--db-accent); outline-offset: -2px; }
.db-nav-item.active {
color: var(--db-accent); background: var(--db-accent-glow);
border-left-color: var(--db-accent); font-weight: 500;
@@ -378,6 +392,9 @@ const DASHBOARD_STYLE = `
.db-main { flex: 1; padding: 32px 40px; overflow-y: auto; min-width: 0; }
.db-main.db-clean { padding: 0; display: flex; flex-direction: column; overflow: hidden; height: 100vh; }
.db-main.db-clean > #db-page-content { flex: 1; display: flex; flex-direction: column; min-height: 0; }
/* Per-page full bleed (page.layout === 'full-bleed'): view fills the main column. */
.db-main.db-fill { display: flex; flex-direction: column; overflow: hidden; height: 100vh; box-sizing: border-box; }
.db-main.db-fill > #db-page-content { flex: 1; display: flex; flex-direction: column; min-height: 0; }
.db-page-header { margin-bottom: 28px; }
.db-page-title { font-size: 26px; font-weight: 700; letter-spacing: -0.02em; margin: 0 0 4px; }
.db-page-icon svg, .db-nav-icon svg { display: inline-block; vertical-align: -0.125em; }
@@ -461,13 +478,13 @@ const DASHBOARD_STYLE = `
.db-mobile-header { display: flex; }
/* !important: buildSidebar() sets an inline position:relative for its toggle,
which otherwise keeps the hidden sidebar's 260px in the flex row. */
.db-sidebar { position: fixed !important; top: 0; left: 0; z-index: 45; transform: translateX(-100%); transition: transform 0.2s ease; }
.db-sidebar.db-sidebar-open { transform: translateX(0); }
/* visibility keeps links in the closed drawer out of the tab order. */
.db-sidebar { position: fixed !important; top: 0; left: 0; z-index: 45; transform: translateX(-100%); visibility: hidden; transition: transform 0.2s ease, visibility 0s linear 0.2s; }
.db-sidebar.db-sidebar-open { transform: translateX(0); visibility: visible; transition: transform 0.2s ease; }
.db-mobile-overlay.db-visible { display: block; }
.db-main { padding-top: 48px; }
/* The shell header slot sits in the same flex row; give it its own line. */
#root.db-active { flex-wrap: wrap; }
#db-shell-header-slot { flex: 0 0 100%; margin-top: 48px; }
/* Sidebar is fixed here; the header row sits below the fixed mobile bar. */
#db-shell-header-slot { margin-top: 48px; padding: 8px 16px 0; }
#db-shell-header-slot + .db-main { padding-top: 16px; }
}
@media (max-width: 600px) {
@@ -1810,6 +1827,32 @@ function setPageIcon(span, page, fallback) {
if (svg) { span.textContent = ''; span.appendChild(svg); } else span.textContent = page.icon || fallback;
}
// Sidebar entry: a real link for page routes (keyboard reachable, open-in-new-tab
// works) or a button for actions. Plain left clicks stay in the SPA.
function navEntry(page, fallbackIcon, onAction) {
const el = document.createElement(onAction ? 'button' : 'a');
el.className = 'db-nav-item';
if (onAction) {
el.type = 'button';
el.addEventListener('click', onAction);
} else {
el.dataset.navId = page.id;
el.href = '/' + encodeURIComponent(page.id);
el.addEventListener('click', (e) => {
if (e.button !== 0 || e.metaKey || e.ctrlKey || e.shiftKey || e.altKey) return;
e.preventDefault();
selectPage(page.id);
});
}
const iconSpan = document.createElement('span');
iconSpan.className = 'db-nav-icon';
iconSpan.setAttribute('aria-hidden', 'true');
setPageIcon(iconSpan, page, fallbackIcon);
el.appendChild(iconSpan);
el.appendChild(document.createTextNode(' ' + page.title));
return el;
}
function buildSidebar(pages) {
const sidebar = document.createElement('nav');
sidebar.className = 'db-sidebar';
@@ -1902,18 +1945,7 @@ function buildSidebar(pages) {
label.textContent = groupName;
sidebar.appendChild(label);
groupPages.forEach(page => {
const item = document.createElement('div');
item.className = 'db-nav-item';
item.dataset.navId = page.id;
const iconSpan = document.createElement('span');
iconSpan.className = 'db-nav-icon';
setPageIcon(iconSpan, page, '📄');
item.appendChild(iconSpan);
item.appendChild(document.createTextNode(' ' + page.title));
item.addEventListener('click', () => selectPage(page.id));
sidebar.appendChild(item);
});
groupPages.forEach(page => sidebar.appendChild(navEntry(page, '📄')));
}
// Footer pages — render any page with position='footer' at the sidebar bottom
@@ -1926,21 +1958,8 @@ function buildSidebar(pages) {
sidebar.appendChild(spacer);
for (const page of footerPages) {
const btn = document.createElement('div');
btn.className = 'db-nav-item';
const iconSpan = document.createElement('span');
iconSpan.className = 'db-nav-icon';
setPageIcon(iconSpan, page, '⚙');
btn.appendChild(iconSpan);
btn.appendChild(document.createTextNode(' ' + page.title));
btn.addEventListener('click', () => {
// Use built-in handler if available, otherwise navigate to page
if (page.id === 'inspector') {
showTestPanel();
} else {
selectPage(page.id);
}
});
// Inspector opens a built-in panel (an action); others are page links
const btn = navEntry(page, '⚙', page.id === 'inspector' ? showTestPanel : null);
btn.style.borderTop = '1px solid var(--db-border)';
btn.style.marginTop = '8px';
sidebar.appendChild(btn);
@@ -1964,6 +1983,12 @@ function showDashboardInitError(message) {
banner.textContent = message;
}
// A render superseded by a newer selectPage never became active, so run its
// own cleanup to release intervals/listeners it set up.
function _supersededCleanup(fn) {
if (fn) { try { fn(); } catch (e) {} }
}
async function selectPage(pageId) {
// Cleanup previous view if it has a cleanup function
if (_activeCleanup) { try { _activeCleanup(); } catch(e) {} _activeCleanup = null; }
@@ -1976,9 +2001,11 @@ async function selectPage(pageId) {
}
// Update nav active state
document.querySelectorAll('.db-nav-item').forEach(el => el.classList.remove('active'));
const active = document.querySelector(`[data-nav-id="${pageId}"]`);
if (active) active.classList.add('active');
document.querySelectorAll('.db-nav-item').forEach(el => {
const on = el.dataset.navId === pageId;
el.classList.toggle('active', on);
if (on) el.setAttribute('aria-current', 'page'); else el.removeAttribute('aria-current');
});
const page = pagesData.find(p => p.id === pageId);
const main = document.getElementById('db-main-content');
@@ -1992,8 +2019,12 @@ async function selectPage(pageId) {
return;
}
// Full-bleed pages (e.g. chat) fill the main column and carry their own chrome
const fullBleed = page.layout === 'full-bleed';
main.classList.toggle('db-fill', fullBleed);
// Set page header (controlled by dashboard config protocol)
const showPageHeader = main.dataset.showPageHeader !== 'false';
const showPageHeader = main.dataset.showPageHeader !== 'false' && !fullBleed;
const toolbarSlotId = `page:${pageId}:toolbar`;
const headerHtml = showPageHeader ? `
<div class="db-page-header">
@@ -2028,9 +2059,11 @@ async function selectPage(pageId) {
// Priority 1: Custom registered view
if (VIEW_REGISTRY[pageId]) {
const view = VIEW_REGISTRY[pageId];
try {
await VIEW_REGISTRY[pageId].render(container);
_activeCleanup = VIEW_REGISTRY[pageId].cleanup || null;
await view.render(container);
if (!container.isConnected) { _supersededCleanup(view.cleanup); return; } // superseded by a newer selectPage
_activeCleanup = view.cleanup || null;
rendered = true;
} catch (e) { console.warn(`View '${pageId}' render error:`, e); }
}
@@ -2041,17 +2074,21 @@ async function selectPage(pageId) {
const viewUrl = BUILTIN_VIEWS[pageId] + '?v=' + Date.now();
const mod = await import(viewUrl);
if (mod.render) {
if (!container.isConnected) return; // superseded by a newer selectPage
await mod.render(container);
if (!container.isConnected) { _supersededCleanup(mod.cleanup); return; }
_activeCleanup = mod.cleanup || null;
rendered = true;
// Cache for future use
VIEW_REGISTRY[pageId] = { render: mod.render, cleanup: mod.cleanup || null };
// Cache for future use (never overwrite a view registered meanwhile)
if (!VIEW_REGISTRY[pageId]) {
VIEW_REGISTRY[pageId] = { render: mod.render, cleanup: mod.cleanup || null };
}
}
} catch (e) { console.warn(`Built-in view '${pageId}' import error:`, e); }
}
// Priority 3: Generic JSON viewer fallback
if (!rendered) {
if (!rendered && container.isConnected) {
loadGenericViewer(page, container);
}
@@ -2310,7 +2347,17 @@ function renderTable(comp) {
table.appendChild(thead);
}
rows.forEach(row => {
// Opt-in row selection/actions (#336): only when stable row_ids are given.
const rowIds = Array.isArray(comp.row_ids) && comp.row_ids.length === rows.length ? comp.row_ids : null;
const actions = rowIds && Array.isArray(comp.actions) ? comp.actions : [];
const emit = (name, detail) => wrapper.dispatchEvent(new CustomEvent(name, { detail, bubbles: true }));
if (actions.length && headers.length) {
const th = document.createElement('th');
th.style.cssText = 'padding:8px 12px;border-bottom:1px solid var(--db-border);';
table.firstChild.appendChild(th);
}
rows.forEach((row, index) => {
const tr = document.createElement('tr');
row.forEach(cell => {
const td = document.createElement('td');
@@ -2318,6 +2365,43 @@ function renderTable(comp) {
td.textContent = cell !== null && cell !== undefined ? cell : '';
tr.appendChild(td);
});
if (rowIds) {
const rowId = String(rowIds[index]);
tr.dataset.rowId = rowId;
tr.tabIndex = 0;
tr.style.cursor = 'pointer';
tr.setAttribute('aria-selected', 'false');
const select = () => {
table.querySelectorAll('tr[aria-selected="true"]').forEach(r => {
r.setAttribute('aria-selected', 'false');
r.style.background = '';
});
tr.setAttribute('aria-selected', 'true');
tr.style.background = 'var(--db-hover)';
emit('aiui:table-select', { rowId, index });
};
tr.addEventListener('click', select);
tr.addEventListener('keydown', (e) => {
if (e.target === tr && (e.key === 'Enter' || e.key === ' ')) { e.preventDefault(); select(); }
});
if (actions.length) {
const td = document.createElement('td');
td.style.cssText = 'padding:4px 12px;border-bottom:1px solid var(--db-border);text-align:right;white-space:nowrap;';
actions.forEach(action => {
const btn = document.createElement('button');
btn.type = 'button';
btn.className = 'db-btn';
btn.textContent = action;
btn.setAttribute('aria-label', `${action} ${row[0] ?? rowId}`);
btn.addEventListener('click', (e) => {
e.stopPropagation();
emit('aiui:table-action', { action, rowId });
});
td.appendChild(btn);
});
tr.appendChild(td);
}
}
table.appendChild(tr);
});
@@ -2983,24 +3067,44 @@ function renderBreadcrumb(comp) {
}
function renderPagination(comp) {
const el = document.createElement('div');
// Optimistic control: the active page updates locally, then a bubbling
// `aiui:pagination` CustomEvent ({id, page, per_page, total}) notifies the
// host so it can re-slice local rows or fetch the requested page.
const el = document.createElement('nav');
el.className = 'db-pagination';
const total = comp.total || 0;
const perPage = comp.per_page || 10;
const totalPages = Math.ceil(total / perPage);
let currentPage = comp.page || 1;
el.setAttribute('aria-label', 'Pagination');
if (comp.id) el.dataset.id = comp.id;
const total = Math.max(0, comp.total || 0);
const perPage = Math.max(1, comp.per_page || 10);
const totalPages = Math.max(1, Math.ceil(total / perPage));
let currentPage = Math.min(Math.max(1, comp.page || 1), totalPages);
function go(page) {
if (page < 1 || page > totalPages || page === currentPage) return;
currentPage = page;
render();
el.dispatchEvent(new CustomEvent('aiui:pagination', {
bubbles: true,
detail: { id: comp.id || null, page, per_page: perPage, total },
}));
}
function button(label, ariaLabel, page, disabled) {
const btn = document.createElement('button');
btn.type = 'button';
btn.textContent = label;
btn.setAttribute('aria-label', ariaLabel);
btn.disabled = disabled;
btn.addEventListener('click', () => go(page));
return btn;
}
function render() {
el.innerHTML = '';
const prev = document.createElement('button');
prev.textContent = '←';
prev.disabled = currentPage <= 1;
prev.addEventListener('click', () => { if (currentPage > 1) { currentPage--; render(); } });
el.appendChild(prev);
el.appendChild(button('←', 'Previous page', currentPage - 1, currentPage <= 1));
for (let i = 1; i <= Math.min(totalPages, 7); i++) {
const btn = document.createElement('button');
btn.textContent = i;
if (i === currentPage) btn.classList.add('active');
btn.addEventListener('click', () => { currentPage = i; render(); });
const btn = button(String(i), `Page ${i}`, i, false);
if (i === currentPage) {
btn.classList.add('active');
btn.setAttribute('aria-current', 'page');
}
el.appendChild(btn);
}
if (totalPages > 7) {
@@ -3009,11 +3113,7 @@ function renderPagination(comp) {
dots.style.cssText = 'padding:0 4px;color:var(--db-text-dim)';
el.appendChild(dots);
}
const next = document.createElement('button');
next.textContent = '→';
next.disabled = currentPage >= totalPages;
next.addEventListener('click', () => { if (currentPage < totalPages) { currentPage++; render(); } });
el.appendChild(next);
el.appendChild(button('→', 'Next page', currentPage + 1, currentPage >= totalPages));
}
render();
return el;
@@ -3116,7 +3216,10 @@ async function showTestPanel() {
if (location.pathname !== '/inspector') {
history.pushState({ pageId: '__test__' }, '', '/inspector');
}
document.querySelectorAll('.db-nav-item').forEach(el => el.classList.remove('active'));
document.querySelectorAll('.db-nav-item').forEach(el => {
el.classList.remove('active');
el.removeAttribute('aria-current');
});
const main = document.getElementById('db-main-content');
if (!main) return;
@@ -3437,23 +3540,21 @@ window.aiui.registerSlot('shell:header', () => {
window.aiui.registerSlot('shell:sidebar:footer', () => {
const el = document.createElement('div');
el.innerHTML = '<span class="db-health-dot" style="color:var(--db-text-dim)">●</span> <span class="db-health-label">Checking…</span>';
fetch('/api/health').then((r) => r.json()).then((d) => {
// Connection indicator uses side-effect-free liveness (#341). Deep
// feature/SDK-gap diagnostics remain at /api/health (Debug view).
fetch('/api/health/live').then((r) => r.json()).then((d) => {
const ok = d && (d.status === 'ok' || d.status === 'healthy');
const dot = el.querySelector('.db-health-dot');
const label = el.querySelector('.db-health-label');
const gaps = (d && d.sdk_gaps) || [];
if (dot) dot.style.color = ok && !gaps.length ? '#22c55e' : (gaps.length ? '#f59e0b' : '#ef4444');
if (label) {
if (gaps.length) {
label.textContent = `SDK gaps (${gaps.length})`;
label.title = gaps.map((g) => `${g.feature}: ${g.message}`).join('\n');
} else {
label.textContent = ok ? (d.integrated ? 'Integrated' : 'Connected') : 'Degraded';
label.title = '';
}
}
if (dot) dot.style.color = ok ? '#22c55e' : '#ef4444';
if (label) label.textContent = ok ? 'Connected' : 'Degraded';
}).catch(() => { const label = el.querySelector('.db-health-label'); if (label) label.textContent = 'Offline'; });
return el;
});
// Replay registrations queued by the /custom.js stubs (see server.py).
(window.aiui.__queue || []).splice(0).forEach(([name, args]) => {
try { window.aiui[name](...args); } catch (e) { console.warn(`[AIUI] queued ${name} failed:`, e); }
});
export { init, onContentChange };
@@ -164,6 +164,20 @@ function renderProgressBar(percentage) {
`;
}
function escapeAttr(value) {
return String(value).replace(/&/g, '&amp;').replace(/"/g, '&quot;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
}
// Navigate to Chat and load the job's linked session (same event the
// session-search palette and run recorder use).
function openJobSession(sessionId) {
if (!sessionId) return;
const nav = (typeof window.aiui?.selectPage === 'function') ? window.aiui.selectPage('chat') : null;
Promise.resolve(nav).finally(() => {
window.dispatchEvent(new CustomEvent('aiui:session-select', { detail: { sessionId } }));
});
}
function renderJobRow(job) {
const status = job.status || 'queued';
const isTerminal = ['succeeded', 'failed', 'cancelled'].includes(status);
@@ -199,6 +213,7 @@ function renderJobRow(job) {
` : `
${_jobsCfg.backend !== 'praisonai' ? `<button class="aiui-btn aiui-btn-sm aiui-btn-delete" onclick="window.aiuiDeleteJob('${job.id}')" title="Delete">🗑️</button>` : ''}
`}
${job.session_id ? `<button class="aiui-btn aiui-btn-sm aiui-btn-open-session" data-session-id="${escapeAttr(job.session_id)}" onclick="window.aiuiOpenJobSession(this.dataset.sessionId)" title="Open session in Chat">💬</button>` : ''}
<button class="aiui-btn aiui-btn-sm aiui-btn-view" onclick="window.aiuiViewJob('${job.id}')" title="View Details">
👁️
</button>
@@ -599,6 +614,7 @@ function checkForJobsPage(root) {
window.aiuiCancelJob = cancelJob;
window.aiuiDeleteJob = deleteJob;
window.aiuiViewJob = viewJob;
window.aiuiOpenJobSession = openJobSession;
export default {
name: 'jobs',
@@ -21,6 +21,7 @@ function injectStyles() {
overflow: hidden;
}
.db-clean .chat-canvas-root { height: 100vh; }
.db-fill .chat-canvas-root { flex: 1; min-height: 0; height: auto; }
.chat-canvas-chat {
flex: 1 1 60%;
min-width: 0;
@@ -19,6 +19,7 @@ let isStreaming = false;
let streamingSessionId = null; // tracks which session is actively streaming
let currentRunId = null;
let agents = [];
let allSessions = []; // last fetched session list, filtered client-side by the rail search
let messageQueue = [];
let focusMode = false;
let sessionSelectHandler = null;
@@ -221,6 +222,11 @@ function injectStyles() {
style.textContent = `
.chat-root { display:flex; height:calc(100vh - 140px); gap:0; border-radius:12px; overflow:hidden; border:1px solid var(--db-border); background:var(--db-card-bg); }
.db-clean .chat-root { height:100%; border-radius:0; border:none; }
.db-fill .chat-root { flex:1; min-height:0; height:auto; }
@media (max-width:900px) {
.db-fill .chat-root { flex-direction:column; }
.db-fill .chat-sidebar-panel { width:100%; min-width:0; max-height:40vh; border-right:none; border-bottom:1px solid var(--db-border); }
}
.chat-root.focus-mode .chat-sidebar-panel { display:none; }
.chat-root.focus-mode .chat-main-panel { border-left:none; }
@@ -230,12 +236,17 @@ function injectStyles() {
.chat-sidebar-agent { padding:10px 16px; border-bottom:1px solid var(--db-border); }
.chat-sidebar-agent label { font-size:11px; color:var(--db-text-dim); display:block; margin-bottom:4px; text-transform:uppercase; font-weight:600; letter-spacing:.5px; }
.chat-sidebar-agent select { width:100%; padding:6px 10px; background:var(--db-card-bg); border:1px solid var(--db-border); border-radius:6px; color:var(--db-text); font-size:13px; }
.chat-sess-search { padding:8px 16px 0; }
.chat-sess-search input { width:100%; box-sizing:border-box; padding:6px 10px; background:var(--db-card-bg); border:1px solid var(--db-border); border-radius:6px; color:var(--db-text); font-size:13px; }
.chat-sess-list { flex:1; overflow-y:auto; padding:8px; }
.chat-sess-item { padding:10px 12px; border-radius:8px; cursor:pointer; margin-bottom:4px; font-size:13px; transition:background .15s; display:flex; flex-direction:column; gap:2px; }
.chat-sess-item:hover { background:rgba(var(--db-accent-rgb,100,100,255),.08); }
.chat-sess-item.active { background:rgba(var(--db-accent-rgb,100,100,255),.15); }
.chat-sess-item .sess-title { font-weight:500; white-space:nowrap; overflow:hidden; text-overflow:ellipsis; }
.chat-sess-item .sess-meta { font-size:11px; color:var(--db-text-dim); }
.chat-sess-item { position:relative; }
.chat-sess-item .sess-rename { position:absolute; top:6px; right:6px; display:none; background:none; border:none; color:var(--db-text-dim); cursor:pointer; font-size:12px; padding:2px 4px; }
.chat-sess-item:hover .sess-rename, .chat-sess-item .sess-rename:focus { display:block; }
.chat-main-panel { flex:1; display:flex; flex-direction:column; min-width:0; }
.chat-top-bar { padding:10px 16px; border-bottom:1px solid var(--db-border); display:flex; align-items:center; justify-content:space-between; gap:10px; }
@@ -812,6 +823,9 @@ export async function render(container) {
<label>Agent</label>
<select id="chat-agent-selector"><option value="">Auto (default)</option></select>
</div>
<div class="chat-sess-search">
<input type="search" id="chat-session-search" placeholder="Search sessions…" aria-label="Search sessions" autocomplete="off">
</div>
<div class="chat-sess-list" id="chat-sessions-list"></div>
</div>
<div class="chat-main-panel">
@@ -892,6 +906,8 @@ export async function render(container) {
const agentSelector = document.getElementById('chat-agent-selector');
const attachBtn = document.getElementById('chat-attach-btn');
const fileInput = document.getElementById('chat-file-input');
const sessionSearch = document.getElementById('chat-session-search');
if (sessionSearch) sessionSearch.addEventListener('input', renderSessionList);
// Guard: if DOM elements aren't present yet (race condition), bail out
if (!input || !sendBtn) {
console.warn('[chat] DOM elements not ready — skipping event binding');
@@ -1039,47 +1055,92 @@ async function loadSessions() {
resp = await fetch('/sessions');
}
const data = await resp.json();
const sessions = data.sessions || data || [];
const list = document.getElementById('chat-sessions-list');
if (!list) return;
list.innerHTML = '';
if (sessions.length === 0) {
list.innerHTML = '<div style="padding:16px;text-align:center;color:var(--db-text-dim);font-size:12px">No sessions yet</div>';
return;
}
sessions.slice(0, 30).forEach((s) => {
const el = document.createElement('div');
const id = s.id || s.session_id || '';
el.className = 'chat-sess-item' + (id === currentSessionId ? ' active' : '');
// Show platform icon for channel sessions (e.g. Slack with SVG)
const platformName = s.platform || '';
const platSvg = _getPlatformSVG(platformName);
let title;
let titlePrefix = '';
if (platformName) {
const displayName = platformName.charAt(0).toUpperCase() + platformName.slice(1);
if (platSvg) {
titlePrefix = '<span class="sess-platform-icon ' + _getPlatformClass(platformName) + '">' + platSvg + '</span>';
}
title = displayName;
} else {
title = s.name || s.title || s.label || id.substring(0, 12) + '…';
}
const agent = s.agent_id || s.agent || '';
const time = timeAgo(s.created_at || s.updated_at);
el.innerHTML = `
<div class="sess-title">${titlePrefix}${escapeHtml(title)}</div>
<div class="sess-meta">${agent ? '🤖 ' + escapeHtml(agent) + ' · ' : ''}${time}</div>
`;
el.title = id;
el.addEventListener('click', () => loadSession(id));
list.appendChild(el);
});
const fetched = data.sessions || data || [];
allSessions = Array.isArray(fetched) ? fetched : [];
renderSessionList();
} catch (e) {
console.warn('[Chat] Failed to load sessions:', e);
}
}
function _sessionMatches(s, q) {
const fields = [s.id, s.session_id, s.name, s.title, s.label, s.agent_id, s.agent, s.platform];
return fields.some((v) => typeof v === 'string' && v.toLowerCase().includes(q));
}
function renderSessionList() {
const list = document.getElementById('chat-sessions-list');
if (!list) return;
const input = document.getElementById('chat-session-search');
const q = (input && input.value || '').trim().toLowerCase();
const sessions = q ? allSessions.filter((s) => _sessionMatches(s, q)) : allSessions;
list.innerHTML = '';
if (sessions.length === 0) {
const msg = q ? 'No matching sessions' : 'No sessions yet';
list.innerHTML = '<div style="padding:16px;text-align:center;color:var(--db-text-dim);font-size:12px">' + msg + '</div>';
return;
}
sessions.slice(0, 30).forEach((s) => {
const el = document.createElement('div');
const id = s.id || s.session_id || '';
el.className = 'chat-sess-item' + (id === currentSessionId ? ' active' : '');
// Show platform icon for channel sessions (e.g. Slack with SVG)
const platformName = s.platform || '';
const platSvg = _getPlatformSVG(platformName);
let title;
let titlePrefix = '';
if (platformName) {
const displayName = platformName.charAt(0).toUpperCase() + platformName.slice(1);
if (platSvg) {
titlePrefix = '<span class="sess-platform-icon ' + _getPlatformClass(platformName) + '">' + platSvg + '</span>';
}
title = displayName;
} else {
title = s.name || s.title || s.label || id.substring(0, 12) + '…';
}
const agent = s.agent_id || s.agent || '';
const time = timeAgo(s.created_at || s.updated_at);
el.innerHTML = `
<div class="sess-title">${titlePrefix}${escapeHtml(title)}</div>
<div class="sess-meta">${agent ? '🤖 ' + escapeHtml(agent) + ' · ' : ''}${time}</div>
`;
el.title = id;
el.addEventListener('click', () => loadSession(id));
if (!platformName && id) {
const btn = document.createElement('button');
btn.className = 'sess-rename';
btn.title = 'Rename session';
btn.setAttribute('aria-label', 'Rename session');
btn.textContent = '✎';
btn.addEventListener('click', (e) => { e.stopPropagation(); renameSession(id, title); });
el.appendChild(btn);
}
list.appendChild(el);
});
}
async function renameSession(sessionId, currentTitle) {
const next = (window.prompt('Rename session', currentTitle || '') || '').trim();
if (!next || next === currentTitle) return;
try {
const resp = await fetch('/sessions/' + encodeURIComponent(sessionId), {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ title: next }),
});
if (!resp.ok) throw new Error('HTTP ' + resp.status);
const s = allSessions.find((x) => (x.id || x.session_id) === sessionId);
if (s) { s.title = next; delete s.name; }
renderSessionList();
if (sessionId === currentSessionId) {
const header = document.getElementById('chat-header-title');
if (header) header.textContent = next;
}
} catch (e) {
console.warn('[Chat] Failed to rename session:', e);
}
}
async function loadSession(sessionId) {
currentSessionId = sessionId;
refreshStreamingUI();
@@ -1131,7 +1192,8 @@ async function loadSession(sessionId) {
title.textContent = (sessionIcon || '📨') + ' ' + displayName;
}
} else if (title) {
title.textContent = 'Session ' + sessionId.substring(0, 8);
const cached = allSessions.find((x) => (x.id || x.session_id) === sessionId);
title.textContent = (cached && (cached.name || cached.title)) || ('Session ' + sessionId.substring(0, 8));
}
} catch (e) {
console.warn('[Chat] Failed to load session:', e);
+14
View File
@@ -379,3 +379,17 @@ RADIUS_PRESETS = {
def get_radius_value(preset: str = "md") -> str:
"""Get radius CSS value from preset name."""
return RADIUS_PRESETS.get(preset, RADIUS_PRESETS["md"])
def get_seed_theme_css(
preset: Optional[str] = None,
radius: Optional[str] = None,
accent: Optional[str] = None,
density: Optional[str] = None,
) -> str:
"""Return the L0 seed CSS (``--seed-*``, ``--radius``, ``--density``) for a theme.
Contract stub (WP0a). WP0b replaces it with the real seed emitter
(ADR-0001 §7); until then it emits nothing so callers can wire it in.
"""
return ""
+33 -3
View File
@@ -110,18 +110,38 @@ def table(
*,
headers: Sequence[str],
rows: Sequence[Sequence[Any]],
row_ids: Sequence[str] | None = None,
actions: Sequence[str] | None = None,
) -> dict:
"""A data table.
Args:
headers: Column header labels
rows: List of row arrays
row_ids: Optional stable ID per row. When given, rows are selectable
(click, Enter or Space) and emit a bubbling ``aiui:table-select``
DOM event with ``detail = {rowId, index}``.
actions: Optional row action labels (requires ``row_ids``). Each row
gets one button per action, emitting a bubbling
``aiui:table-action`` DOM event with ``detail = {action, rowId}``.
"""
return {
comp: dict[str, Any] = {
"type": "table",
"headers": list(headers),
"rows": [list(r) for r in rows],
}
if row_ids is not None:
ids = [str(i) for i in row_ids]
if len(ids) != len(comp["rows"]):
raise ValueError("row_ids must have one entry per row")
if len(set(ids)) != len(ids):
raise ValueError("row_ids must be unique")
comp["row_ids"] = ids
if actions:
if row_ids is None:
raise ValueError("actions require row_ids")
comp["actions"] = [str(a) for a in actions]
return comp
def text(content: str) -> dict:
@@ -829,15 +849,25 @@ def breadcrumb(items: Sequence[dict]) -> dict:
return {"type": "breadcrumb", "items": list(items)}
def pagination(*, total: int, page: int = 1, per_page: int = 10) -> dict:
def pagination(
*, total: int, page: int = 1, per_page: int = 10, id: str | None = None
) -> dict:
"""Pagination controls.
Clicking a page updates the control and dispatches a bubbling
``aiui:pagination`` DOM event with ``{id, page, per_page, total}`` so
the host can re-slice or fetch rows.
Args:
total: Total number of items
page: Current page number (1-based)
per_page: Items per page
id: Optional identifier echoed in the event to tell controls apart
"""
return {"type": "pagination", "total": total, "page": page, "per_page": per_page}
comp: dict[str, Any] = {"type": "pagination", "total": total, "page": page, "per_page": per_page}
if id is not None:
comp["id"] = id
return comp
def definition_list(items: Sequence[dict], *, title: str | None = None) -> dict:
@@ -0,0 +1,6 @@
===== style=dashboard =====
<!doctype html><html lang="en"><head><meta charset="UTF-8"><meta name="viewport" content="width=device-width,initial-scale=1.0"><script id="aiui-canonical-url">(function(){var p=location.pathname.replace(/\/+$/, '')||'/';if(p!==location.pathname){history.replaceState(null,'',p+location.search+location.hash);}})();</script><script id="aiui-theme-bootstrap">(function(){try{var t=localStorage.getItem('aiui-theme-preference');if(t==='dark'||(t==='system'&&matchMedia('(prefers-color-scheme: dark)').matches))document.documentElement.classList.add('dark');else if(t==='light')document.documentElement.classList.remove('dark');}catch(e){}})();</script><link rel="stylesheet" href="/assets/index.css?v=0"><style id="aiui-server-theme">:root{--db-accent:#71717a!important;--db-accent-glow:rgba(113,113,122,0.15)!important;--db-accent-rgb:113,113,122!important;--db-radius:0.5rem!important}</style><title>PraisonAI Dashboard</title></head><body style="margin:0"><div id="root"></div><script src="/plugins/plugin-loader.js?v=0"></script></body></html>
===== style=chat =====
<!doctype html><html lang="en"><head><meta charset="UTF-8"><meta name="viewport" content="width=device-width,initial-scale=1.0"><script id="aiui-canonical-url">(function(){var p=location.pathname.replace(/\/+$/, '')||'/';if(p!==location.pathname){history.replaceState(null,'',p+location.search+location.hash);}})();</script><script id="aiui-theme-bootstrap">(function(){try{var t=localStorage.getItem('aiui-theme-preference');if(t==='dark'||(t==='system'&&matchMedia('(prefers-color-scheme: dark)').matches))document.documentElement.classList.add('dark');else if(t==='light')document.documentElement.classList.remove('dark');}catch(e){}})();</script><link rel="stylesheet" href="/assets/index.css?v=0"><title>PraisonAI</title></head><body style="margin:0"><div id="root"></div><script type="module" crossorigin src="/assets/index.js?v=0"></script><script src="/plugins/plugin-loader.js?v=0"></script></body></html>
===== style=docs =====
<!doctype html><html lang="en"><head><meta charset="UTF-8"><meta name="viewport" content="width=device-width,initial-scale=1.0"><script id="aiui-canonical-url">(function(){var p=location.pathname.replace(/\/+$/, '')||'/';if(p!==location.pathname){history.replaceState(null,'',p+location.search+location.hash);}})();</script><script id="aiui-theme-bootstrap">(function(){try{var t=localStorage.getItem('aiui-theme-preference');if(t==='dark'||(t==='system'&&matchMedia('(prefers-color-scheme: dark)').matches))document.documentElement.classList.add('dark');else if(t==='light')document.documentElement.classList.remove('dark');}catch(e){}})();</script><link rel="stylesheet" href="/assets/index.css?v=0"><style id="aiui-anti-flicker">html,body{background:hsl(var(--background))!important;color:hsl(var(--foreground))}#root>*{opacity:0;transition:opacity .15s ease}</style><title>PraisonAI</title></head><body style="margin:0"><div id="root"></div><script type="module" crossorigin src="/assets/index.js?v=0"></script><script src="/plugins/plugin-loader.js?v=0"></script></body></html>
+54
View File
@@ -0,0 +1,54 @@
"""Regression tests for #322 — Chat must fill the dashboard main column.
Chat pages declare ``layout: "full-bleed"``; ``selectPage()`` then adds
``db-fill`` to ``#db-main-content`` and skips the duplicate page header, and
``.chat-root`` sizes from flex allocation instead of ``calc(100vh - 140px)``.
"""
from __future__ import annotations
import re
from pathlib import Path
PLUGINS = (
Path(__file__).resolve().parents[2]
/ "src" / "praisonaiui" / "templates" / "frontend" / "plugins"
)
def _select_page_body() -> str:
src = (PLUGINS / "dashboard.js").read_text(encoding="utf-8")
m = re.search(r"async function selectPage\(pageId\)\s*\{(.*?)^}", src, re.DOTALL | re.MULTILINE)
assert m
return m.group(1)
def test_chat_pages_declare_full_bleed():
from starlette.testclient import TestClient
from praisonaiui.server import create_app, reset_state
reset_state() # isolate from page whitelists set by other tests
pages = {p["id"]: p for p in TestClient(create_app()).get("/api/pages").json()["pages"]}
for pid in ("chat", "chat-canvas"):
assert pages[pid].get("layout") == "full-bleed"
assert pages["overview"].get("layout") is None
def test_select_page_toggles_fill_and_skips_header():
body = _select_page_body()
assert "page.layout === 'full-bleed'" in body
assert "classList.toggle('db-fill'" in body
assert re.search(r"showPageHeader = .*&& !fullBleed", body)
def test_fill_css_defined():
src = (PLUGINS / "dashboard.js").read_text(encoding="utf-8")
assert ".db-main.db-fill > #db-page-content" in src
def test_chat_root_fills_column_in_fill_mode():
chat = (PLUGINS / "views" / "chat.js").read_text(encoding="utf-8")
assert re.search(r"\.db-fill \.chat-root \{[^}]*flex:1;[^}]*min-height:0", chat)
canvas = (PLUGINS / "views" / "chat-canvas.js").read_text(encoding="utf-8")
assert ".db-fill .chat-canvas-root" in canvas
@@ -0,0 +1,31 @@
"""Tests for the in-rail session search in the chat view (issue #324).
The chat sidebar must expose a search input that filters the already
fetched session list client-side, without refetching from the API.
"""
from pathlib import Path
CHAT = Path("src/praisonaiui/templates/frontend/plugins/views/chat.js")
class TestChatSessionRailSearch:
def test_search_input_rendered_in_rail(self):
src = CHAT.read_text()
assert 'id="chat-session-search"' in src
assert src.index('id="chat-session-search"') < src.index('id="chat-sessions-list"')
def test_input_wired_to_client_side_render(self):
src = CHAT.read_text()
assert "sessionSearch.addEventListener('input', renderSessionList)" in src
def test_load_sessions_caches_and_delegates_render(self):
src = CHAT.read_text()
assert "allSessions = Array.isArray(fetched) ? fetched : [];" in src
body = src.split("async function loadSessions()", 1)[1].split("\n}\n", 1)[0]
assert "renderSessionList();" in body
def test_filter_matches_title_and_id_fields(self):
src = CHAT.read_text()
assert "function _sessionMatches(s, q)" in src
assert "No matching sessions" in src
+49
View File
@@ -0,0 +1,49 @@
"""Issue #331 — rename a session from the chat rail via the existing PATCH route."""
from __future__ import annotations
import asyncio
from pathlib import Path
from starlette.applications import Starlette
from starlette.routing import Route
from starlette.testclient import TestClient
import praisonaiui.server as server
from praisonaiui.datastore import MemoryDataStore
CHAT = Path("src/praisonaiui/templates/frontend/plugins/views/chat.js")
def _client(monkeypatch):
ds = MemoryDataStore()
monkeypatch.setattr(server, "_datastore", ds)
app = Starlette(routes=[Route("/sessions/{session_id}", server.patch_session, methods=["PATCH"])])
sid = asyncio.run(ds.create_session())["id"]
return TestClient(app), ds, sid
def test_patch_renames_and_trims(monkeypatch):
client, ds, sid = _client(monkeypatch)
r = client.patch(f"/sessions/{sid}", json={"title": " My chat "})
assert r.status_code == 200
assert asyncio.run(ds.get_session(sid))["title"] == "My chat"
def test_patch_rejects_blank_or_non_string_title(monkeypatch):
client, _, sid = _client(monkeypatch)
assert client.patch(f"/sessions/{sid}", json={"title": " "}).status_code == 400
assert client.patch(f"/sessions/{sid}", json={"title": 123}).status_code == 400
def test_patch_unknown_session_404(monkeypatch):
client, _, _ = _client(monkeypatch)
assert client.patch("/sessions/nope", json={"title": "x"}).status_code == 404
def test_rail_has_rename_button_wired_to_patch():
src = CHAT.read_text()
assert "async function renameSession(sessionId, currentTitle)" in src
assert "method: 'PATCH'" in src
assert "renameSession(id, title)" in src
assert "e.stopPropagation()" in src
+43
View File
@@ -122,3 +122,46 @@ class TestCustomJsServing:
"custom.js must be injected AFTER plugin-loader.js so window.aiui "
"APIs are available when custom.js runs"
)
# ── Registration lifecycle (issue #338) ───────────────────────────
_DASHBOARD_JS = (
Path(srv.__file__).parent / "templates" / "frontend" / "plugins" / "dashboard.js"
)
class TestCustomJsRegistrationLifecycle:
def test_queue_stub_injected_before_custom_js(self, tmp_js):
"""Registry stubs must exist before /custom.js runs (dashboard loads async)."""
srv.set_custom_js(tmp_js)
srv.set_style("dashboard")
html = TestClient(srv.create_app()).get("/").text
stub_idx = html.find("__queue")
assert stub_idx != -1, "registration queue stub must be injected"
assert stub_idx < html.find("/custom.js")
for name in ("registerView", "registerComponent", "registerSlot",
"registerSurfaceRenderer"):
assert name in html[stub_idx:html.find("/custom.js")]
def test_no_queue_stub_when_unset(self, client):
assert "__queue" not in client.get("/").text
def test_dashboard_replays_queued_registrations(self):
src = _DASHBOARD_JS.read_text(encoding="utf-8")
replay_idx = src.find("window.aiui.__queue")
assert replay_idx != -1, "dashboard.js must replay queued registrations"
# Replay must run after `let activePageId` is initialised (no TDZ error).
assert replay_idx > src.find("let activePageId")
def test_late_register_view_rerenders_active_page(self):
src = _DASHBOARD_JS.read_text(encoding="utf-8")
start = src.find("window.aiui.registerView = function")
body = src[start:src.find("\n};", start)]
assert "activePageId" in body and "selectPage(pageId)" in body
def test_superseded_render_runs_its_cleanup(self):
"""A render replaced by a newer selectPage must still be cleaned up."""
src = _DASHBOARD_JS.read_text(encoding="utf-8")
assert "_supersededCleanup(view.cleanup)" in src
assert "_supersededCleanup(mod.cleanup)" in src
+68
View File
@@ -0,0 +1,68 @@
"""Regression tests for #342 — dashboard sidebar navigation keyboard semantics.
Sidebar entries were clickable ``div``s: not focusable, no role, and the
current page was only a CSS class. They are now native links (pages) or
buttons (actions such as the Inspector) with ``aria-current`` kept in sync.
"""
from __future__ import annotations
import re
from pathlib import Path
DASHBOARD_JS = (
Path(__file__).resolve().parents[2]
/ "src" / "praisonaiui" / "templates" / "frontend" / "plugins" / "dashboard.js"
)
def _src() -> str:
return DASHBOARD_JS.read_text(encoding="utf-8")
def _fn(name: str) -> str:
m = re.search(rf"function {name}\([^)]*\)\s*\{{(.*?)^}}", _src(), re.DOTALL | re.MULTILINE)
assert m, f"{name} not found"
return m.group(1)
def test_nav_items_are_not_divs():
src = _src()
assert not re.search(
r"createElement\('div'\);\s*\n\s*\w+\.className = 'db-nav-item'", src
), "db-nav-item must not be a clickable div"
def test_nav_entry_uses_link_or_button():
body = _fn("navEntry")
assert "onAction ? 'button' : 'a'" in body
assert "el.href = '/' + encodeURIComponent(page.id)" in body
assert "el.type = 'button'" in body
def test_nav_link_keeps_modified_clicks_native():
body = _fn("navEntry")
assert "e.preventDefault()" in body
for mod in ("metaKey", "ctrlKey", "shiftKey", "altKey", "e.button !== 0"):
assert mod in body
def test_sidebar_and_footer_use_nav_entry():
body = _fn("buildSidebar")
assert "navEntry(page, '📄')" in body
assert "navEntry(page, '⚙', page.id === 'inspector' ? showTestPanel : null)" in body
def test_select_page_syncs_aria_current():
m = re.search(r"async function selectPage\(pageId\)\s*\{(.*?)^}", _src(), re.DOTALL | re.MULTILINE)
assert m
body = m.group(1)
assert "setAttribute('aria-current', 'page')" in body
assert "removeAttribute('aria-current')" in body
def test_focus_visible_and_closed_drawer_hidden():
src = _src()
assert ".db-nav-item:focus-visible" in src
assert re.search(r"\.db-sidebar \{ position: fixed !important;[^}]*visibility: hidden", src)
assert re.search(r"\.db-sidebar\.db-sidebar-open \{[^}]*visibility: visible", src)
+71
View File
@@ -0,0 +1,71 @@
"""Dashboard plugin scope: ``set_dashboard(plugins=...)`` applies to pages and manifests."""
import json
import pytest
from starlette.testclient import TestClient
import praisonaiui.dashboard_plugins as dp
import praisonaiui.server as srv
def _make_plugin(root, name, label=None):
d = root / name
d.mkdir(parents=True)
(d / "manifest.json").write_text(json.dumps({"name": name, "label": label or name}))
(d / "index.js").write_text("// plugin")
@pytest.fixture
def env(tmp_path, monkeypatch):
home = tmp_path / "home"
bundled = tmp_path / "bundled"
extra = tmp_path / "extra"
_make_plugin(home / ".praisonai" / "dashboard-plugins", "home-plug")
_make_plugin(home / ".praisonai" / "dashboard-plugins", "dup", label="from-home")
_make_plugin(bundled, "bundled-plug")
_make_plugin(bundled, "dup", label="from-bundled")
bad = bundled / "broken"
bad.mkdir()
(bad / "manifest.json").write_text("{not json")
_make_plugin(extra, "extra-plug")
monkeypatch.setattr(dp.Path, "home", classmethod(lambda cls: home))
monkeypatch.setattr(dp, "_BUILTIN_PLUGIN_ROOT", bundled)
srv.reset_state()
srv.set_style("dashboard")
srv.set_pages([])
srv.register_page("synthetic", title="Synthetic", handler=lambda: {"_components": []})
yield str(extra)
srv.reset_state()
def _state():
client = TestClient(srv.create_app())
pages = [p["id"] for p in client.get("/api/pages").json()["pages"]]
plugins = {p["name"]: p for p in client.get("/api/dashboard/plugins").json()["plugins"]}
return client, pages, plugins
def test_default_keeps_all_discovered(env):
srv.set_dashboard(plugin_dirs=[env])
_, pages, plugins = _state()
assert set(plugins) == {"home-plug", "dup", "bundled-plug", "extra-plug"}
assert plugins["dup"]["label"] == "from-home" # first root wins on duplicates
assert {"synthetic", "home-plug", "bundled-plug", "extra-plug", "dup"} <= set(pages)
def test_empty_disables_all_plugins(env):
srv.set_dashboard(plugin_dirs=[env], plugins=[])
client, pages, plugins = _state()
assert plugins == {}
assert pages == ["synthetic"]
assert client.get("/dashboard-plugins/bundled-plug/index.js").status_code == 404
def test_allowlist_filters_pages_and_manifest(env):
srv.set_dashboard(plugin_dirs=[env], plugins=["extra-plug", "missing"])
client, pages, plugins = _state()
assert set(plugins) == {"extra-plug"}
assert sorted(pages) == ["extra-plug", "synthetic"]
assert client.get("/dashboard-plugins/extra-plug/index.js").status_code == 200
assert client.get("/dashboard-plugins/home-plug/index.js").status_code == 404
+45
View File
@@ -0,0 +1,45 @@
"""Smoke test for the feature-module example in docs/features/how-to-add-a-feature.md (#335)."""
from starlette.responses import JSONResponse
from starlette.routing import Route
from starlette.testclient import TestClient
from praisonaiui.features import BaseFeatureProtocol, _features, register_feature
from praisonaiui.server import create_app
# Mirrors section 7 of docs/features/how-to-add-a-feature.md — keep in sync.
class MyFeature(BaseFeatureProtocol):
name = "my-feature"
description = "Example feature with GET and PATCH routes"
def routes(self):
return [
Route("/api/my-feature", self._get, methods=["GET"]),
Route("/api/my-feature/{item_id}", self._patch, methods=["PATCH"]),
]
async def _get(self, request):
return JSONResponse({"data": request.query_params.get("q", "hello")})
async def _patch(self, request):
body = await request.json()
return JSONResponse({"ok": True, "id": request.path_params["item_id"], **body})
def test_doc_feature_example_routes():
saved = dict(_features)
try:
register_feature(MyFeature())
client = TestClient(create_app())
r = client.get("/api/my-feature", params={"q": "hi"})
assert r.status_code == 200
assert r.json() == {"data": "hi"}
r = client.patch("/api/my-feature/42", json={"title": "x"})
assert r.status_code == 200
assert r.json() == {"ok": True, "id": "42", "title": "x"}
finally:
_features.clear()
_features.update(saved)
+31
View File
@@ -0,0 +1,31 @@
"""Issue #323 — Jobs rows link to their chat session when ``session_id`` is set.
Source-parsing test, mirroring ``test_session_search_dashboard.py``.
"""
from __future__ import annotations
from pathlib import Path
JOBS_JS = (
Path(__file__).resolve().parents[2]
/ "src" / "praisonaiui" / "templates" / "frontend" / "plugins" / "jobs.js"
)
def _src() -> str:
return JOBS_JS.read_text(encoding="utf-8")
def test_open_session_button_only_when_session_id():
src = _src()
assert "job.session_id ? `<button" in src
assert 'data-session-id="${escapeAttr(job.session_id)}"' in src
def test_open_session_reuses_session_select_event():
src = _src()
assert "function openJobSession(sessionId)" in src
assert "selectPage('chat')" in src
assert "'aiui:session-select'" in src
assert "window.aiuiOpenJobSession = openJobSession;" in src
+91
View File
@@ -0,0 +1,91 @@
"""Pagination exposes a page-change event to the host (issue #337)."""
from __future__ import annotations
import json
import re
import shutil
import subprocess
from pathlib import Path
import pytest
from praisonaiui.ui import pagination
DASHBOARD_JS = (
Path(__file__).resolve().parents[2]
/ "src/praisonaiui/templates/frontend/plugins/dashboard.js"
)
def test_helper_id_optional():
assert "id" not in pagination(total=10)
assert pagination(total=10, id="users")["id"] == "users"
def _render_pagination_src() -> str:
src = DASHBOARD_JS.read_text()
m = re.search(r"function renderPagination\(comp\) \{.*?\n\}\n", src, re.S)
assert m
return m.group(0)
# Minimal DOM shim so the renderer runs under plain Node.
_HARNESS = r"""
class CustomEvent { constructor(t, o) { this.type = t; this.detail = o.detail; this.bubbles = o.bubbles; } }
class El {
constructor(tag) { this.tag = tag; this.children = []; this.attrs = {}; this.dataset = {};
this.listeners = {}; this.disabled = false; this.textContent = '';
const cls = new Set(); this.classList = { add: c => cls.add(c), has: c => cls.has(c) }; this.style = {}; }
set innerHTML(v) { this.children = []; }
appendChild(c) { this.children.push(c); return c; }
setAttribute(k, v) { this.attrs[k] = v; }
addEventListener(t, f) { (this.listeners[t] ||= []).push(f); }
dispatchEvent(e) { (this.listeners[e.type] || []).forEach(f => f(e)); }
click() { if (!this.disabled) (this.listeners.click || []).forEach(f => f()); }
}
const document = { createElement: t => new El(t) };
__SRC__
const out = {};
function mk(comp) {
const el = renderPagination(comp); const events = [];
el.addEventListener('aiui:pagination', e => events.push(e.detail));
const btn = l => el.children.find(c => c.attrs && c.attrs['aria-label'] === l);
const active = () => el.children.find(c => c.attrs && c.attrs['aria-current'] === 'page').textContent;
return { el, events, btn, active };
}
// next / prev / numbered, exactly once each
let a = mk({ total: 30, page: 1, per_page: 10, id: 'a' });
let b = mk({ total: 30, page: 1, per_page: 10, id: 'b' });
a.btn('Previous page').click(); // disabled at bound
a.btn('Next page').click();
a.btn('Page 3').click();
a.btn('Next page').click(); // disabled at bound
a.btn('Page 3').click(); // same page: no event
a.btn('Previous page').click();
out.a = a.events; out.aActive = a.active(); out.b = b.events; out.bActive = b.active();
out.tag = a.el.tag; out.label = a.el.attrs['aria-label'];
// clamping
out.clampHigh = mk({ total: 25, page: 99, per_page: 10 }).active();
out.clampLow = mk({ total: 0, page: 0, per_page: 0 }).active();
console.log(JSON.stringify(out));
"""
@pytest.mark.skipif(shutil.which("node") is None, reason="node not installed")
def test_pagination_dispatches_page_change(tmp_path):
script = tmp_path / "h.js"
script.write_text(_HARNESS.replace("__SRC__", _render_pagination_src()))
res = subprocess.run(["node", str(script)], capture_output=True, text=True, check=True)
out = json.loads(res.stdout)
d = {"per_page": 10, "total": 30}
assert out["a"] == [
{"id": "a", "page": 2, **d},
{"id": "a", "page": 3, **d},
{"id": "a", "page": 2, **d},
]
assert out["aActive"] == "2"
assert out["b"] == [] and out["bActive"] == "1"
assert out["tag"] == "nav" and out["label"] == "Pagination"
assert out["clampHigh"] == "3"
assert out["clampLow"] == "1"
+10 -1
View File
@@ -139,10 +139,19 @@ def test_replay_page_uses_own_svg_mark_not_emoji_heading():
def test_dashboard_mobile_shell_gives_content_the_width():
dash = (FRONTEND / "dashboard.js").read_text(encoding="utf-8")
assert ".db-sidebar { position: fixed !important;" in dash
assert "#db-shell-header-slot { flex: 0 0 100%;" in dash
assert "#db-shell-header-slot { margin-top: 48px;" in dash
assert ".db-main { padding: 64px 16px 16px; }" in dash
def test_shell_header_is_a_content_sized_row_not_a_column():
"""#339: the header slot must not take a flex column or stretch on mobile."""
dash = (FRONTEND / "dashboard.js").read_text(encoding="utf-8")
assert "#root.db-active:has(> #db-shell-header-slot) {" in dash
assert "grid-template-rows: auto minmax(0, 1fr);" in dash
assert "#db-shell-header-slot { grid-column: 2; grid-row: 1;" in dash
assert "#root.db-active { flex-wrap: wrap; }" not in dash
def test_theme_defaults_to_system():
view = VIEW.read_text(encoding="utf-8")
assert "@media (prefers-color-scheme:dark){.rp-root[data-theme=\"system\"]" in view
+93
View File
@@ -25,6 +25,8 @@ def client():
_srv._DEEP_HEALTH_CACHE = {}
_srv._DEEP_HEALTH_CACHE_TIME = 0.0
_srv._health_cache = {}
_srv._health_cache_time = 0
# Snapshot and clear the global feature registry so a feature leaked by an
# earlier test (e.g. one that crashed before its cleanup) cannot slow down
# /health and flake the deep-health timing/cache assertions under the full
@@ -203,6 +205,97 @@ class TestHealthEndpoint:
assert second.json().get("cached") is True
assert duration < 0.1
def test_slow_provider_does_not_block_readiness(self, client):
"""Cold /api/health and /health/ready bound a slow provider (#341)."""
import asyncio
import time
import praisonaiui.server as _srv
class _SlowProvider:
async def health(self):
await asyncio.sleep(5)
return {"status": "ok"}
prev_provider = _srv._provider
_srv._provider = _SlowProvider()
_srv._health_cache = {}
_srv._health_cache_time = 0
try:
for path in ("/api/health", "/health/ready"):
start = time.perf_counter()
response = client.get(path)
duration = time.perf_counter() - start
assert response.status_code == 200
data = response.json()
assert data["status"] == "ok"
assert data["provider"]["name"] == "_SlowProvider"
assert duration < 2.0
finally:
_srv._provider = prev_provider
_srv._health_cache = {}
_srv._health_cache_time = 0
def test_non_dict_provider_health_does_not_500_readiness(self, client):
"""A provider returning a non-dict from health() must not break /health/ready."""
import praisonaiui.server as _srv
class _NoneProvider:
async def health(self):
return None
prev_provider = _srv._provider
_srv._provider = _NoneProvider()
try:
response = client.get("/health/ready")
assert response.status_code == 200
assert response.json()["provider"] == {"name": "_NoneProvider"}
finally:
_srv._provider = prev_provider
class TestFooterLiveness:
"""Dashboard footer uses side-effect-free liveness (#341)."""
def test_live_makes_no_provider_or_feature_calls(self, client):
import praisonaiui.server as _srv
from praisonaiui.features import _features
calls = {"provider": 0, "feature": 0}
class _Provider:
async def health(self):
calls["provider"] += 1
return {}
class _Feature:
async def health(self):
calls["feature"] += 1
return {"healthy": True}
original = _srv._provider
_srv._provider = _Provider()
_features["__count__"] = _Feature()
try:
assert client.get("/api/health/live").status_code == 200
assert calls == {"provider": 0, "feature": 0}
finally:
_srv._provider = original
_features.pop("__count__", None)
def test_sidebar_footer_uses_liveness_endpoint(self):
from pathlib import Path
import praisonaiui
js = (
Path(praisonaiui.__file__).parent
/ "templates/frontend/plugins/dashboard.js"
).read_text(encoding="utf-8")
footer = js[js.index("shell:sidebar:footer"):]
assert "fetch('/api/health/live')" in footer
assert "fetch('/api/health')" not in footer
class TestProviderHealthEndpoint:
"""Tests for the provider health JSON route (#151)."""
+71
View File
@@ -0,0 +1,71 @@
"""Tests for #336 — opt-in stable row IDs, selection and row actions on table()."""
from __future__ import annotations
import re
from pathlib import Path
import pytest
from praisonaiui import table
DASHBOARD_JS = (
Path(__file__).resolve().parents[2]
/ "src" / "praisonaiui" / "templates" / "frontend" / "plugins" / "dashboard.js"
)
class TestTableHelper:
def test_static_payload_unchanged(self):
assert table(headers=["A"], rows=[["x"]]) == {
"type": "table", "headers": ["A"], "rows": [["x"]],
}
def test_row_ids_and_actions(self):
t = table(headers=["Name"], rows=[["Acme"], ["Beta"]],
row_ids=["c1", "c2"], actions=["Edit"])
assert t["row_ids"] == ["c1", "c2"]
assert t["actions"] == ["Edit"]
def test_row_ids_length_mismatch(self):
with pytest.raises(ValueError):
table(headers=["A"], rows=[["x"], ["y"]], row_ids=["1"])
def test_row_ids_unique(self):
with pytest.raises(ValueError):
table(headers=["A"], rows=[["x"], ["y"]], row_ids=["1", "1"])
def test_actions_require_row_ids(self):
with pytest.raises(ValueError):
table(headers=["A"], rows=[["x"]], actions=["Edit"])
class TestRenderTable:
def _body(self) -> str:
src = DASHBOARD_JS.read_text(encoding="utf-8")
m = re.search(r"function renderTable\(comp\)\s*\{(.*?)^}", src,
re.DOTALL | re.MULTILINE)
assert m, "renderTable not found"
return m.group(1)
def test_selection_is_opt_in_and_uses_row_id(self):
body = self._body()
assert "comp.row_ids" in body
assert "aiui:table-select" in body
assert "rowId" in body
def test_keyboard_and_pointer_selection(self):
body = self._body()
assert "tabIndex = 0" in body
assert "'click', select" in body
assert "'Enter'" in body and "' '" in body
assert "aria-selected" in body
def test_row_actions_are_buttons(self):
body = self._body()
assert "aiui:table-action" in body
assert "btn.type = 'button'" in body
assert "stopPropagation" in body
def test_events_bubble(self):
assert "bubbles: true" in self._body()
Generated
+1 -1
View File
@@ -207,7 +207,7 @@ wheels = [
[[package]]
name = "aiui"
version = "0.3.149"
version = "0.3.154"
source = { editable = "." }
dependencies = [
{ name = "pydantic" },