mirror of
https://github.com/MervinPraison/PraisonAIUI.git
synced 2026-11-12 18:47:48 -10:00
Compare commits
44
Commits
No files matched your search
@@ -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
|
||||
@@ -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 || '' }}
|
||||
@@ -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 || '' }}
|
||||
@@ -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' }}
|
||||
@@ -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
|
||||
@@ -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:
|
||||
|
||||
@@ -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
@@ -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
@@ -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)`
|
||||
|
||||
@@ -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).
|
||||
|
||||
---
|
||||
|
||||
+78
-5
@@ -32,13 +32,83 @@ aiui run "$(python -c 'import praisonaiui.replay_app as m; print(m.__file__)')"
|
||||
`/#/replay?run=<id>` works too. In a full dashboard, Replay is in the sidebar under
|
||||
**Control**.
|
||||
|
||||
## Watch a run live: the Run button
|
||||
|
||||
For learning, demos and debugging, open a page with a **Run** button. Each click records
|
||||
a new run, and the page draws it as it happens:
|
||||
|
||||
```python
|
||||
from praisonai.replay import watch
|
||||
|
||||
watch(flow, input="solar sails") # an Agent, AgentTeam, AgentFlow or agents.yaml
|
||||
```
|
||||
|
||||
```bash
|
||||
praisonai replay watch agents.yaml
|
||||
```
|
||||
|
||||
On the **Map**, each step and agent shows where the run is: dashed means not reached,
|
||||
blue (pulsing) means running now, green means done and red means failed. After a run,
|
||||
**▶ Play** moves through it again one step at a time, and the map rewinds with it. One
|
||||
run goes at a time. Each run of a team starts its tasks afresh; an Agent keeps its chat
|
||||
history between runs.
|
||||
|
||||
## Show progress inside your own app
|
||||
|
||||
`create_replay_app()` is the same view as a small ASGI app, without the dashboard. Mount
|
||||
it in any FastAPI or Starlette app:
|
||||
|
||||
```python
|
||||
from praisonai.replay import record, replay_app
|
||||
|
||||
app.mount("/replay", replay_app()) # read-only: shows recorded runs
|
||||
|
||||
# wherever your app starts work:
|
||||
with record(run_id, live=True):
|
||||
team.start()
|
||||
```
|
||||
|
||||
Then show one run, live, in your page, for example in an `<iframe>`:
|
||||
|
||||
```html
|
||||
<iframe src="/replay/?run=RUN_ID" style="width:100%;height:640px"></iframe>
|
||||
```
|
||||
|
||||
| URL | Shows |
|
||||
|---|---|
|
||||
| `/replay/` | The run list and the selected run |
|
||||
| `/replay/?run=<id>` | Only that run, no list. A run that has not started yet is waited for. |
|
||||
|
||||
`replay_app(team)` adds the Run box to the mounted page. Without the PraisonAI wrapper,
|
||||
pass any `run(input, run_id)` callable to `praisonaiui.features.replay.create_replay_app`;
|
||||
it must record the run under `run_id`. For a page you build yourself, the view is one
|
||||
module:
|
||||
|
||||
```html
|
||||
<div id="progress"></div>
|
||||
<script type="module">
|
||||
import { render } from "/replay/replay.js";
|
||||
render(document.getElementById("progress"), { base: "/replay", run: "RUN_ID", approve: false });
|
||||
</script>
|
||||
```
|
||||
|
||||
`render()` options: `base` (where the API is mounted), `run` (show only this run),
|
||||
`runner` (`{input, placeholder, ask}` to show the Run box) and `approve` (`false` hides
|
||||
the approve and deny buttons; the embedded app has no approvals API, so answer approvals
|
||||
where your app asks for them).
|
||||
|
||||
Anyone who can reach the mounted app can read the runs, and with a runner can start one.
|
||||
Keep it on localhost or behind your app's login. `POST /api/replay/run` only accepts
|
||||
`Content-Type: application/json`, so another website cannot start a run from a visitor's
|
||||
browser.
|
||||
|
||||
## What you see
|
||||
|
||||
| Part | What it shows |
|
||||
|---|---|
|
||||
| **Runs** | Recorded runs, newest first. A run that is still being written has a **Live** badge. |
|
||||
| **Timeline** (default) | One lane per agent, plus **Manager** and **You**. Time runs left to right. LLM calls and tool calls are blocks in their agent's lane. Arrows show hand-offs, delegation, routing and approval requests. Parallel branches get their own sub-lanes. A call or step repeated back to back is one block marked ×N. Tasks and steps run across the top. |
|
||||
| **Map** | The team or flow as a graph, drawn from the graph recorded at the start of the run and fitted to the page (−, Fit and + zoom; drag to pan). Agents that took part but are not in the graph, for example after a hand-off to an agent outside the team, appear in an **Outside the team** group with their hand-offs. Older recordings without a graph get a "who talked to whom" map built from the events. Nodes you have not reached yet have a dashed outline; the node the cursor is on has a bold one. |
|
||||
| **Map** | The team or flow as a graph, drawn from the graph recorded at the start of the run and fitted to the page (−, Fit and + zoom; drag to pan). Agents that took part but are not in the graph, for example after a hand-off to an agent outside the team, appear in an **Outside the team** group with their hand-offs. Older recordings without a graph get a "who talked to whom" map built from the events. Each node shows its state at the cursor: dashed means not reached, blue means running, green means done (×N when it ran more than once) and red means failed; the node the cursor is on has a bold outline. |
|
||||
| **What happened** | One plain sentence per step, for example *"Researcher asked web_search for 'solar sail facts' — 1.2 s, $0.002."* Hovering or focusing a sentence highlights its block, and the other way round. |
|
||||
| **Details** | Three tabs for the selected step. **What happened**: when, who, how long, cost, tokens, output. **Why**: only what the recording holds, such as the input given to a tool, the context passed in a hand-off and its reason, the manager's task snapshot, the route a step chose, and an approval's decision and where an automatic approval came from. **Raw JSON**: the events themselves. |
|
||||
| **Cost & latency** | Adds a bar under each block (taller and darker means more cost, or more time) and per-agent totals in the lane labels. |
|
||||
@@ -65,7 +135,8 @@ anything after the cursor is drawn as an outline.
|
||||
| Enter or Space | Open the focused block or node |
|
||||
| ← → in the tabs | Switch between What happened, Why and Raw JSON |
|
||||
|
||||
The scrubber under the timeline does the same with a mouse or touch. On a live run the
|
||||
The scrubber under the timeline does the same with a mouse or touch, and **▶ Play** steps
|
||||
through on its own. On a live run the
|
||||
cursor follows new events; if you step back, **Jump to live** takes you to the latest.
|
||||
|
||||
## Approving from Replay
|
||||
@@ -92,11 +163,13 @@ an exported file.
|
||||
| Endpoint | Returns |
|
||||
|---|---|
|
||||
| `GET /api/replay/runs` | `[{run_id, session_id, path, started_at, ended_at?, status, kind, name, agents, events, cost_usd, live}]`, newest first |
|
||||
| `GET /api/replay/runs/{id}/events?after_seq=N` | `{events, graph, run}`; `after_seq` keeps only events with a larger `sequence_num` |
|
||||
| `GET /api/replay/runs/{id}/events?after_seq=N` | `{events, graph, run}`; `after_seq` keeps only events with a larger `sequence_num`; `wait=1` answers `{events: [], graph: null, run: null}` instead of 404 for a run that has not started yet |
|
||||
| `GET /api/replay/runs/{id}/stream` | Server-sent events: `event: ctx` per trace event (with `id:` = `sequence_num`), `ping` every 15 s, `end` once the run has finished and gone quiet for 2 s. Honours `after_seq` and `Last-Event-ID`. |
|
||||
|
||||
Run ids must match `^[A-Za-z0-9._-]+$`. Replay is read-only: it never writes trace
|
||||
files. Half-written last lines, unknown fields and unknown event types are skipped
|
||||
With a runner (`create_replay_app(run=...)`), `POST /api/replay/run` with
|
||||
`{"input": "..."}` starts a run and returns `{run_id}` (409 while one is going).
|
||||
|
||||
Run ids must match `^[A-Za-z0-9._-]+$`. Replay never writes trace files itself. Half-written last lines, unknown fields and unknown event types are skipped
|
||||
safely, and schema 1.0 recordings still open.
|
||||
|
||||
The **Traces** page reads the same files: each run is a trace, and its spans come from
|
||||
|
||||
@@ -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**
|
||||
|
||||
@@ -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
@@ -1,6 +1,6 @@
|
||||
[project]
|
||||
name = "aiui"
|
||||
version = "0.3.147"
|
||||
version = "0.3.153"
|
||||
description = "YAML-driven website generator - CLI and compiler"
|
||||
readme = "README.md"
|
||||
license = {text = "MIT"}
|
||||
|
||||
Generated
+87
-23
@@ -82,20 +82,6 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@a2ui/markdown-it": {
|
||||
"version": "0.0.4",
|
||||
"resolved": "https://registry.npmjs.org/@a2ui/markdown-it/-/markdown-it-0.0.4.tgz",
|
||||
"integrity": "sha512-uaYNywdiHDH4i//pHpyckFnApvOshiUNT2Xl8awceNjL1XAX/gqtki9GRUJQ26RE7id2+aPNgOy1W6mXNBxriQ==",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"dompurify": "^3.3.1",
|
||||
"markdown-it": "^14.1.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@a2ui/web_core": "^0.9.2"
|
||||
}
|
||||
},
|
||||
"node_modules/@a2ui/react": {
|
||||
"version": "0.10.0",
|
||||
"resolved": "https://registry.npmjs.org/@a2ui/react/-/react-0.10.0.tgz",
|
||||
@@ -129,16 +115,16 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@a2ui/web_core": {
|
||||
"version": "0.10.0",
|
||||
"resolved": "https://registry.npmjs.org/@a2ui/web_core/-/web_core-0.10.0.tgz",
|
||||
"integrity": "sha512-pSieKxaB6aZWBL2LjZfKo4/1v4xMWylczpzte1ehuGH0kfNGf/iTd73PKsIKYx3b0FNWTbD8gAloOM4JCVqUig==",
|
||||
"version": "0.10.2",
|
||||
"resolved": "https://registry.npmjs.org/@a2ui/web_core/-/web_core-0.10.2.tgz",
|
||||
"integrity": "sha512-IfS4K7baTL9P9AzUOzKRWzL9A9/+Z7TCgMY2GPwsZ0H7LgY/9/uJnlbReNhpHsAfN12KZtW9iERWkw5HOf71GA==",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"@preact/signals-core": "^1.13.0",
|
||||
"date-fns": "^4.1.0",
|
||||
"@preact/signals-core": "^1.14.2",
|
||||
"date-fns": "^4.4.0",
|
||||
"zod": "^3.25.76",
|
||||
"zod-to-json-schema": "^3.25.1"
|
||||
"zod-to-json-schema": "^3.25.2"
|
||||
}
|
||||
},
|
||||
"node_modules/@a2ui/web_core/node_modules/zod": {
|
||||
@@ -5502,9 +5488,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/date-fns": {
|
||||
"version": "4.1.0",
|
||||
"resolved": "https://registry.npmjs.org/date-fns/-/date-fns-4.1.0.tgz",
|
||||
"integrity": "sha512-Ukq0owbQXxa/U3EGtsdVBkR1w7KOQ5gIBqdH2hkvknzZPYvBxb/aa6E8L7tmjFtkwZBu3UXBbjIgPo/Ez4xaNg==",
|
||||
"version": "4.4.0",
|
||||
"resolved": "https://registry.npmjs.org/date-fns/-/date-fns-4.4.0.tgz",
|
||||
"integrity": "sha512-+1UMbeh68lH1SegH83CGWwpb6OHHbpSgr3+s5Eww5M4CAgswBpoWS0AjTOfEJ33HiYKz1hdj/KTFprzXHmq/6w==",
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"type": "github",
|
||||
@@ -9747,6 +9733,84 @@
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/wooorm"
|
||||
}
|
||||
},
|
||||
"node_modules/@a2ui/react/node_modules/@a2ui/markdown-it": {
|
||||
"version": "0.0.4",
|
||||
"resolved": "https://registry.npmjs.org/@a2ui/markdown-it/-/markdown-it-0.0.4.tgz",
|
||||
"integrity": "sha512-uaYNywdiHDH4i//pHpyckFnApvOshiUNT2Xl8awceNjL1XAX/gqtki9GRUJQ26RE7id2+aPNgOy1W6mXNBxriQ==",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"dompurify": "^3.3.1",
|
||||
"markdown-it": "^14.1.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@a2ui/web_core": "^0.9.2"
|
||||
}
|
||||
},
|
||||
"node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@emnapi/core": {
|
||||
"version": "1.8.1",
|
||||
"dev": true,
|
||||
"inBundle": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"dependencies": {
|
||||
"@emnapi/wasi-threads": "1.1.0",
|
||||
"tslib": "^2.4.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@emnapi/runtime": {
|
||||
"version": "1.8.1",
|
||||
"dev": true,
|
||||
"inBundle": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"dependencies": {
|
||||
"tslib": "^2.4.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@emnapi/wasi-threads": {
|
||||
"version": "1.1.0",
|
||||
"dev": true,
|
||||
"inBundle": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"dependencies": {
|
||||
"tslib": "^2.4.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@napi-rs/wasm-runtime": {
|
||||
"version": "1.1.1",
|
||||
"dev": true,
|
||||
"inBundle": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"dependencies": {
|
||||
"@emnapi/core": "^1.7.1",
|
||||
"@emnapi/runtime": "^1.7.1",
|
||||
"@tybys/wasm-util": "^0.10.1"
|
||||
},
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/Brooooooklyn"
|
||||
}
|
||||
},
|
||||
"node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@tybys/wasm-util": {
|
||||
"version": "0.10.1",
|
||||
"dev": true,
|
||||
"inBundle": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"dependencies": {
|
||||
"tslib": "^2.4.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/tslib": {
|
||||
"version": "2.8.1",
|
||||
"dev": true,
|
||||
"inBundle": true,
|
||||
"license": "0BSD",
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -38,7 +38,7 @@ interface ActionButtonsProps {
|
||||
*/
|
||||
export function ActionButtons({ actions, messageId, sessionId }: ActionButtonsProps) {
|
||||
const [pendingActions, setPendingActions] = useState<Set<string>>(new Set())
|
||||
const [hiddenActions, setHiddenActions] = useState<Set<string>>(new Set())
|
||||
const [hiddenActions] = useState<Set<string>>(new Set())
|
||||
|
||||
const handleActionClick = useCallback(async (action: ActionButton) => {
|
||||
if (pendingActions.has(action.id) || hiddenActions.has(action.id)) {
|
||||
|
||||
@@ -73,7 +73,7 @@ export function AskActionPrompt({
|
||||
|
||||
{/* Action buttons */}
|
||||
<div className="space-y-3">
|
||||
{actions.map((action, index) => (
|
||||
{actions.map((action) => (
|
||||
<button
|
||||
key={action.name}
|
||||
type="button"
|
||||
|
||||
@@ -15,7 +15,7 @@ import { Textarea } from '../components/ui/textarea';
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '../components/ui/select';
|
||||
import { Slider } from '../components/ui/slider';
|
||||
import { Switch } from '../components/ui/switch';
|
||||
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from '../components/ui/card';
|
||||
import { Card, CardContent } from '../components/ui/card';
|
||||
import { Sheet, SheetContent, SheetHeader, SheetTitle, SheetDescription } from '../components/ui/sheet';
|
||||
|
||||
interface SettingsWidget {
|
||||
@@ -74,7 +74,7 @@ const WidgetRenderer: React.FC<{
|
||||
id={name}
|
||||
placeholder={placeholder}
|
||||
value={value || ''}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
onChange={(e: React.ChangeEvent<HTMLTextAreaElement>) => onChange(e.target.value)}
|
||||
maxLength={widget.maxLength}
|
||||
rows={4}
|
||||
/>
|
||||
|
||||
@@ -20,43 +20,16 @@ interface CustomElementProps {
|
||||
onError?: (error: Error) => void;
|
||||
}
|
||||
|
||||
interface CustomComponentModule {
|
||||
default: React.ComponentType<any>;
|
||||
}
|
||||
|
||||
// Registry of loaded components (cache)
|
||||
const componentCache = new Map<string, Promise<React.ComponentType<any>>>();
|
||||
|
||||
// List of available custom components (in practice, this would be populated from backend)
|
||||
const availableComponents = new Set([
|
||||
'ExampleWidget',
|
||||
'UserCard',
|
||||
'DataChart',
|
||||
'FormBuilder',
|
||||
'CodeEditor',
|
||||
'ImageGallery',
|
||||
'ChatEmbed'
|
||||
]);
|
||||
|
||||
// Static imports for build-tool compatibility
|
||||
const ExampleWidget = React.lazy(() => import('../components/custom/ExampleWidget'));
|
||||
const UserCard = React.lazy(() => import('../components/custom/UserCard'));
|
||||
const DataChart = React.lazy(() => import('../components/custom/DataChart'));
|
||||
const FormBuilder = React.lazy(() => import('../components/custom/FormBuilder'));
|
||||
const CodeEditor = React.lazy(() => import('../components/custom/CodeEditor'));
|
||||
const ImageGallery = React.lazy(() => import('../components/custom/ImageGallery'));
|
||||
const ChatEmbed = React.lazy(() => import('../components/custom/ChatEmbed'));
|
||||
|
||||
// Static mapping of component names to imports
|
||||
const componentMap: Record<string, React.ComponentType<any>> = {
|
||||
ExampleWidget,
|
||||
UserCard,
|
||||
DataChart,
|
||||
FormBuilder,
|
||||
CodeEditor,
|
||||
ImageGallery,
|
||||
ChatEmbed,
|
||||
};
|
||||
// Custom components available to <CustomElementHost name="..." />. The example
|
||||
// widgets this once listed (ExampleWidget, UserCard, ...) were never added, and
|
||||
// importing them broke the build. Register real components here as they land:
|
||||
// const MyWidget = React.lazy(() => import('../components/custom/MyWidget'));
|
||||
// componentMap.MyWidget = MyWidget;
|
||||
const componentMap: Record<string, React.ComponentType<any>> = {};
|
||||
const availableComponents = new Set(Object.keys(componentMap));
|
||||
|
||||
/**
|
||||
* Load a custom component with static mapping for build-tool compatibility
|
||||
|
||||
@@ -4,7 +4,6 @@
|
||||
* Shows live-updating task lists with states, progress bars, and click-to-jump functionality.
|
||||
*/
|
||||
|
||||
import React from 'react'
|
||||
import { ChevronRight, ChevronDown, CheckCircle, Clock, XCircle, Circle, ExternalLink } from 'lucide-react'
|
||||
import { useTaskState, setTasksCollapsed, type Task, type TaskList, type TaskStatus } from '../state/tasks'
|
||||
import { cn } from '../lib/utils'
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
import * as React from "react"
|
||||
import { cva, type VariantProps } from "class-variance-authority"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const alertVariants = cva(
|
||||
"relative w-full rounded-lg border px-4 py-3 text-sm [&>svg+div]:translate-y-[-3px] [&>svg]:absolute [&>svg]:left-4 [&>svg]:top-4 [&>svg]:text-foreground [&>svg~*]:pl-7",
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default: "bg-background text-foreground",
|
||||
destructive:
|
||||
"border-destructive/50 text-destructive dark:border-destructive [&>svg]:text-destructive",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: "default",
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
const Alert = React.forwardRef<
|
||||
HTMLDivElement,
|
||||
React.HTMLAttributes<HTMLDivElement> & VariantProps<typeof alertVariants>
|
||||
>(({ className, variant, ...props }, ref) => (
|
||||
<div ref={ref} role="alert" className={cn(alertVariants({ variant }), className)} {...props} />
|
||||
))
|
||||
Alert.displayName = "Alert"
|
||||
|
||||
const AlertTitle = React.forwardRef<HTMLParagraphElement, React.HTMLAttributes<HTMLHeadingElement>>(
|
||||
({ className, ...props }, ref) => (
|
||||
<h5 ref={ref} className={cn("mb-1 font-medium leading-none tracking-tight", className)} {...props} />
|
||||
)
|
||||
)
|
||||
AlertTitle.displayName = "AlertTitle"
|
||||
|
||||
const AlertDescription = React.forwardRef<HTMLParagraphElement, React.HTMLAttributes<HTMLParagraphElement>>(
|
||||
({ className, ...props }, ref) => (
|
||||
<div ref={ref} className={cn("text-sm [&_p]:leading-relaxed", className)} {...props} />
|
||||
)
|
||||
)
|
||||
AlertDescription.displayName = "AlertDescription"
|
||||
|
||||
export { Alert, AlertTitle, AlertDescription }
|
||||
@@ -0,0 +1,19 @@
|
||||
import * as React from "react"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const Textarea = React.forwardRef<HTMLTextAreaElement, React.ComponentProps<"textarea">>(
|
||||
({ className, ...props }, ref) => (
|
||||
<textarea
|
||||
className={cn(
|
||||
"flex min-h-[60px] w-full rounded-md border border-input bg-transparent px-3 py-2 text-base shadow-sm placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50 md:text-sm",
|
||||
className
|
||||
)}
|
||||
ref={ref}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
)
|
||||
Textarea.displayName = "Textarea"
|
||||
|
||||
export { Textarea }
|
||||
@@ -183,7 +183,7 @@ export function useMicStream(options: UseMicStreamOptions = {}): UseMicStreamRet
|
||||
onConnectionChangeRef.current?.(false);
|
||||
};
|
||||
|
||||
ws.onerror = (error) => {
|
||||
ws.onerror = () => {
|
||||
onErrorRef.current?.(new Error('WebSocket error'));
|
||||
setConnected(false);
|
||||
onConnectionChangeRef.current?.(false);
|
||||
|
||||
@@ -5,8 +5,33 @@
|
||||
* incoming messages from parent.
|
||||
*/
|
||||
|
||||
import { useEffect, useCallback, useRef } from 'react';
|
||||
import { useSSE } from './useSSE';
|
||||
import { useEffect, useCallback, useRef, useState } from 'react';
|
||||
|
||||
interface EventSourceOptions {
|
||||
enabled?: boolean;
|
||||
onMessage?: (event: MessageEvent) => void;
|
||||
onOpen?: () => void;
|
||||
onError?: () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Minimal EventSource subscription. (useSSE in this package is the chat
|
||||
* streaming store, not a generic SSE hook, so it cannot open this stream.)
|
||||
*/
|
||||
function useEventSource(url: string, { enabled = true, onMessage, onOpen, onError }: EventSourceOptions) {
|
||||
const [connected, setConnected] = useState(false);
|
||||
const handlers = useRef({ onMessage, onOpen, onError });
|
||||
handlers.current = { onMessage, onOpen, onError };
|
||||
useEffect(() => {
|
||||
if (!enabled || typeof EventSource === 'undefined') return;
|
||||
const source = new EventSource(url);
|
||||
source.onopen = () => { setConnected(true); handlers.current.onOpen?.(); };
|
||||
source.onmessage = (event) => handlers.current.onMessage?.(event);
|
||||
source.onerror = () => { setConnected(false); handlers.current.onError?.(); };
|
||||
return () => { source.close(); setConnected(false); };
|
||||
}, [url, enabled]);
|
||||
return { connected };
|
||||
}
|
||||
|
||||
export interface WindowMessageData {
|
||||
type: string;
|
||||
@@ -85,7 +110,7 @@ export function useWindowMessage(options: UseWindowMessageOptions = {}): UseWind
|
||||
const isIframe = window !== window.parent;
|
||||
|
||||
// SSE connection for outbound messages
|
||||
const { connected } = useSSE('/sse/window-message', {
|
||||
const { connected } = useEventSource('/sse/window-message', {
|
||||
enabled: enabled && isIframe,
|
||||
onMessage: (event) => {
|
||||
try {
|
||||
|
||||
@@ -7,9 +7,7 @@ import React, { useState } from 'react';
|
||||
import { Button } from '../components/ui/button';
|
||||
import { Input } from '../components/ui/input';
|
||||
import { Label } from '../components/ui/label';
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '../components/ui/select';
|
||||
import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle, DialogFooter } from '../components/ui/dialog';
|
||||
import { Separator } from '../components/ui/separator';
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from '../components/ui/tabs';
|
||||
|
||||
interface AddMCPServerModalProps {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useParams } from 'react-router-dom';
|
||||
import { Eye, AlertCircle } from 'lucide-react';
|
||||
import { ChatMessages } from '../chat/ChatMessages';
|
||||
import type { ChatMessage } from '../types';
|
||||
import { Button } from '../components/ui/button';
|
||||
|
||||
interface SharedThreadData {
|
||||
@@ -11,18 +11,13 @@ interface SharedThreadData {
|
||||
title: string;
|
||||
created_at: string;
|
||||
};
|
||||
messages: Array<{
|
||||
id: string;
|
||||
role: string;
|
||||
content: string;
|
||||
timestamp: string;
|
||||
toolCalls?: any[];
|
||||
}>;
|
||||
messages: ChatMessage[];
|
||||
read_only: boolean;
|
||||
}
|
||||
|
||||
export function SharedThread() {
|
||||
const { token } = useParams<{ token: string }>();
|
||||
// Served at /shared/{token} (server.py); this app has no client-side router.
|
||||
const token = decodeURIComponent(window.location.pathname.split('/shared/')[1]?.split('/')[0] ?? '');
|
||||
const [threadData, setThreadData] = useState<SharedThreadData | null>(null);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [error, setError] = useState<string>('');
|
||||
@@ -128,10 +123,13 @@ export function SharedThread() {
|
||||
<div className="bg-white rounded-lg shadow-sm border border-gray-200 overflow-hidden">
|
||||
{threadData.messages.length > 0 ? (
|
||||
<div className="divide-y divide-gray-100">
|
||||
<ChatMessages
|
||||
<ChatMessages
|
||||
messages={threadData.messages}
|
||||
sessionId={threadData.thread_id}
|
||||
isSharedView={true}
|
||||
currentResponse=""
|
||||
thinkingSteps={[]}
|
||||
toolCalls={[]}
|
||||
isStreaming={false}
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
|
||||
@@ -1,3 +1,3 @@
|
||||
"""Version information."""
|
||||
|
||||
__version__ = "0.3.147"
|
||||
__version__ = "0.3.153"
|
||||
@@ -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"
|
||||
|
||||
@@ -3,7 +3,9 @@
|
||||
Routes (contract v1, section 5):
|
||||
|
||||
* ``GET /api/replay/runs`` run picker rows, newest first
|
||||
* ``GET /api/replay/runs/{run_id}/events`` ``{events, graph, run}``; ``?after_seq=N``
|
||||
* ``GET /api/replay/runs/{run_id}/events`` ``{events, graph, run}``; ``?after_seq=N``;
|
||||
``?wait=1`` answers ``{events: [], graph: null, run: null}`` (not 404) for a run
|
||||
that has not written its first event yet
|
||||
* ``GET /api/replay/runs/{run_id}/stream`` SSE tail of the JSONL file
|
||||
|
||||
The trace directory is ``$PRAISONAI_TRACES_DIR``, else ``$PRAISON_HOME/traces``,
|
||||
@@ -12,20 +14,30 @@ else ``~/.praison/traces``. Run ids must match ``^[A-Za-z0-9._-]+$``.
|
||||
The browser side lives in ``templates/frontend/plugins/views/replay-core.js``
|
||||
(pure functions) and ``replay.js`` (DOM/SVG). :func:`standalone_html` inlines
|
||||
both into one self-contained page for ``praisonai replay export --format html``.
|
||||
|
||||
:func:`create_replay_app` is the same view as a small ASGI app (no dashboard) to
|
||||
mount inside your own app, optionally with a Run box that starts runs::
|
||||
|
||||
app.mount("/replay", create_replay_app(run=my_runner))
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import html
|
||||
import inspect
|
||||
import json
|
||||
import logging
|
||||
import threading
|
||||
import time
|
||||
import uuid
|
||||
from pathlib import Path
|
||||
from typing import Any, Dict, List, Optional
|
||||
from typing import Any, Callable, Dict, List, Optional
|
||||
from urllib.parse import quote
|
||||
|
||||
from starlette.concurrency import run_in_threadpool
|
||||
from starlette.requests import Request
|
||||
from starlette.responses import JSONResponse, StreamingResponse
|
||||
from starlette.responses import FileResponse, HTMLResponse, JSONResponse, StreamingResponse
|
||||
from starlette.routing import Route
|
||||
|
||||
from . import _trace_jsonl as tj
|
||||
@@ -233,6 +245,8 @@ class ReplayFeature(BaseFeatureProtocol):
|
||||
async def _events(self, request: Request) -> JSONResponse:
|
||||
path, err = _resolve(request)
|
||||
if err is not None:
|
||||
if err.status_code == 404 and request.query_params.get("wait") == "1":
|
||||
return JSONResponse({"events": [], "graph": None, "run": None})
|
||||
return err
|
||||
after_seq, err = _parse_after_seq(request)
|
||||
if err is not None:
|
||||
@@ -297,3 +311,131 @@ class ReplayFeature(BaseFeatureProtocol):
|
||||
media_type="text/event-stream",
|
||||
headers={"Cache-Control": "no-cache", "Connection": "keep-alive", "X-Accel-Buffering": "no"},
|
||||
)
|
||||
|
||||
|
||||
# ── embeddable app ─────────────────────────────────────────────────────
|
||||
|
||||
#: Longest input the Run box accepts (characters).
|
||||
MAX_RUN_INPUT = 20_000
|
||||
|
||||
|
||||
def _embed_page(base: str, title: str, cfg: Dict[str, Any]) -> str:
|
||||
"""The page at the app's root: the view, mounted with *cfg*, and nothing else."""
|
||||
return (
|
||||
"<!doctype html>\n<html lang=\"en\">\n<head>\n<meta charset=\"utf-8\">\n"
|
||||
"<meta name=\"viewport\" content=\"width=device-width, initial-scale=1\">\n"
|
||||
f"<title>{html.escape(title)}</title>\n"
|
||||
f"<link rel=\"icon\" href=\"data:image/svg+xml,{quote(REPLAY_MARK_SVG.replace('currentColor', '#1A56DB'))}\">\n"
|
||||
"<style>body{margin:0;font-family:system-ui,-apple-system,'Segoe UI',sans-serif}"
|
||||
"#replay-root{padding:12px;box-sizing:border-box;max-width:100%}</style>\n"
|
||||
"</head>\n<body>\n<main id=\"replay-root\"></main>\n"
|
||||
"<script type=\"module\">\n"
|
||||
f"import {{ render }} from {_json_for_script(base + '/replay.js')};\n"
|
||||
f"render(document.getElementById('replay-root'), {_json_for_script(cfg)});\n"
|
||||
"</script>\n</body>\n</html>\n"
|
||||
)
|
||||
|
||||
|
||||
def create_replay_app(
|
||||
run: Optional[Callable[[str, str], Any]] = None,
|
||||
*,
|
||||
title: str = "PraisonAI Replay",
|
||||
input: Optional[str] = "",
|
||||
placeholder: str = "",
|
||||
):
|
||||
"""PraisonAI Replay as a small ASGI app to mount in your own app.
|
||||
|
||||
Serves the view at ``/`` (no dashboard around it), its two scripts, and the
|
||||
``/api/replay/...`` routes, all relative to where it is mounted::
|
||||
|
||||
from fastapi import FastAPI
|
||||
from praisonaiui.features.replay import create_replay_app
|
||||
|
||||
app = FastAPI()
|
||||
app.mount("/replay", create_replay_app())
|
||||
# <iframe src="/replay/?run=RUN_ID"> shows one run live; /replay/ lists runs
|
||||
|
||||
Args:
|
||||
run: Optional ``run(input, run_id)``. When given, the page shows an
|
||||
input box and a **Run** button; each click calls it in a worker
|
||||
thread with a new ``run_id``, and the page follows that run live.
|
||||
It must record the run under ``run_id`` (``praisonai.replay.app()``
|
||||
does this for an agent, team or flow). One run at a time.
|
||||
title: Page title.
|
||||
input: Text the input box starts with; ``None`` for no input box
|
||||
(just a Run button, e.g. for a YAML file that has its own topic).
|
||||
placeholder: Placeholder text for the input box.
|
||||
|
||||
Traces are read from the same folder as the rest of Replay
|
||||
(``$PRAISONAI_TRACES_DIR`` > ``$PRAISON_HOME/traces`` > ``~/.praison/traces``).
|
||||
Anyone who can reach the app can read the runs (and, with ``run``, start
|
||||
them): keep it on localhost or behind your app's auth.
|
||||
"""
|
||||
from starlette.applications import Starlette
|
||||
|
||||
feature = ReplayFeature()
|
||||
busy = threading.Lock()
|
||||
|
||||
async def page(request: Request) -> HTMLResponse:
|
||||
base = str(request.scope.get("root_path", "")).rstrip("/")
|
||||
cfg: Dict[str, Any] = {"base": base, "approve": False}
|
||||
wanted = request.query_params.get("run")
|
||||
if wanted and tj.is_valid_run_id(wanted):
|
||||
cfg["run"] = wanted
|
||||
if run is not None:
|
||||
cfg["runner"] = {"input": input or "", "placeholder": placeholder, "ask": input is not None}
|
||||
return HTMLResponse(_embed_page(base, title, cfg), headers={"Cache-Control": "no-store"})
|
||||
|
||||
def script(path: Path):
|
||||
async def serve(request: Request) -> FileResponse:
|
||||
return FileResponse(path, media_type="text/javascript", headers={"Cache-Control": "no-cache"})
|
||||
return serve
|
||||
|
||||
def _call(text: str, run_id: str) -> None:
|
||||
try:
|
||||
result = run(text, run_id) # type: ignore[misc]
|
||||
if inspect.isawaitable(result):
|
||||
asyncio.run(result)
|
||||
except Exception:
|
||||
logger.exception("replay run %s failed", run_id)
|
||||
finally:
|
||||
busy.release()
|
||||
|
||||
async def start(request: Request) -> JSONResponse:
|
||||
# JSON only: a cross-site form post cannot set this content type without
|
||||
# a CORS preflight, which this app never answers.
|
||||
if not request.headers.get("content-type", "").lower().startswith("application/json"):
|
||||
return JSONResponse({"error": "send JSON: {\"input\": \"...\"}"}, status_code=415)
|
||||
try:
|
||||
body = await request.json()
|
||||
except ValueError:
|
||||
return _bad_request("body must be JSON")
|
||||
text = body.get("input", "") if isinstance(body, dict) else None
|
||||
if not isinstance(text, str) or len(text) > MAX_RUN_INPUT:
|
||||
return _bad_request(f"input must be a string of at most {MAX_RUN_INPUT} characters")
|
||||
if not busy.acquire(blocking=False):
|
||||
return JSONResponse({"error": "a run is already going; wait for it to finish"}, status_code=409)
|
||||
run_id = f"run-{uuid.uuid4().hex[:12]}"
|
||||
try:
|
||||
threading.Thread(target=_call, args=(text, run_id), name=f"replay-{run_id}", daemon=True).start()
|
||||
except Exception:
|
||||
busy.release()
|
||||
raise
|
||||
# Answer once the trace file exists (usually milliseconds), so the page
|
||||
# can open the run straight away; it waits on its own if this times out.
|
||||
path = tj.run_path(run_id)
|
||||
for _ in range(40):
|
||||
if path is not None and path.is_file():
|
||||
break
|
||||
await asyncio.sleep(0.05)
|
||||
return JSONResponse({"run_id": run_id})
|
||||
|
||||
routes = [
|
||||
Route("/", page, methods=["GET"]),
|
||||
Route("/replay.js", script(REPLAY_VIEW_JS), methods=["GET"]),
|
||||
Route("/replay-core.js", script(REPLAY_CORE_JS), methods=["GET"]),
|
||||
*feature.routes(),
|
||||
]
|
||||
if run is not None:
|
||||
routes.append(Route("/api/replay/run", start, methods=["POST"]))
|
||||
return Starlette(routes=routes)
|
||||
+53
-13
@@ -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"}
|
||||
@@ -792,7 +808,18 @@ 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 +1108,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 +1158,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 +1339,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 +1761,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 +3229,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 +3336,7 @@ def create_app(
|
||||
"group": "Agent",
|
||||
"description": "AI agent chat",
|
||||
"order": 1,
|
||||
"layout": "full-bleed",
|
||||
},
|
||||
{
|
||||
"id": "chat-canvas",
|
||||
@@ -3306,6 +3345,7 @@ def create_app(
|
||||
"group": "Agent",
|
||||
"description": "Chat with live A2UI preview panel",
|
||||
"order": 2,
|
||||
"layout": "full-bleed",
|
||||
},
|
||||
{
|
||||
"id": "canvas",
|
||||
|
||||
@@ -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, '&').replace(/"/g, '"').replace(/</g, '<').replace(/>/g, '>');
|
||||
}
|
||||
|
||||
// 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);
|
||||
|
||||
@@ -51,6 +51,9 @@ const PAIRS = {
|
||||
};
|
||||
const END_OF = Object.fromEntries(Object.entries(PAIRS).map(([s, e]) => [e, s]));
|
||||
|
||||
/** Events an agent run emits for itself (lifecycle, model and tool calls). */
|
||||
const AGENT_RUN_EVENTS = new Set(['agent_start', 'agent_end', 'llm_request', 'llm_response', 'tool_call_start', 'tool_call_end']);
|
||||
|
||||
/** Approvers that mean "a person decided", anything else is an automatic source. */
|
||||
const HUMAN_APPROVERS = new Set(['user', 'human', 'you', 'ui', 'replay', 'console', 'web', 'dashboard']);
|
||||
|
||||
@@ -172,7 +175,7 @@ export function narrate(ev, related = null) {
|
||||
if (!ev || typeof ev !== 'object') return '';
|
||||
const d = dataOf(ev);
|
||||
const rd = dataOf(related);
|
||||
const who = str(ev.agent_name) || 'The run';
|
||||
const who = str(ev.agent_name) || (AGENT_RUN_EVENTS.has(ev.event_type) ? agentOf(ev) : '') || 'The run';
|
||||
switch (ev.event_type) {
|
||||
case 'session_start':
|
||||
return 'Recording started.';
|
||||
@@ -699,14 +702,32 @@ export function buildModel(events, graph = null) {
|
||||
// ── graph helpers ─────────────────────────────────────────────────────
|
||||
|
||||
/** Graph node ids an event touches (node_id, else derived from agent/tool names). */
|
||||
/**
|
||||
* The agent an event belongs to. An unnamed agent (``Agent(instructions=...)``)
|
||||
* has no agent_name: newer traces give it the id ``agent:Agent``, older ones
|
||||
* ``tool:None/<tool>`` or nothing, so its own run events fall back to "Agent".
|
||||
*/
|
||||
export function agentOf(ev) {
|
||||
if (!ev) return null;
|
||||
if (ev.agent_name) return ev.agent_name;
|
||||
const id = str(ev.node_id);
|
||||
if (id.startsWith('agent:')) return id.slice(6) || 'Agent';
|
||||
if (id.startsWith('tool:') && AGENT_RUN_EVENTS.has(ev.event_type)) {
|
||||
const who = id.slice(5).split('/')[0];
|
||||
return who && who !== 'None' ? who : 'Agent';
|
||||
}
|
||||
return AGENT_RUN_EVENTS.has(ev.event_type) ? 'Agent' : null;
|
||||
}
|
||||
|
||||
export function nodeIdsForEvent(ev) {
|
||||
const out = [];
|
||||
if (!ev) return out;
|
||||
if (ev.node_id) out.push(ev.node_id);
|
||||
if (ev.node_id && !str(ev.node_id).startsWith('tool:None/')) out.push(ev.node_id);
|
||||
const d = dataOf(ev);
|
||||
if (ev.agent_name) {
|
||||
out.push(ev.agent_name === 'Manager' ? MANAGER_LANE : `agent:${ev.agent_name}`);
|
||||
if (d.tool_name) out.push(`tool:${ev.agent_name}/${d.tool_name}`);
|
||||
const who = agentOf(ev);
|
||||
if (who) {
|
||||
out.push(who === 'Manager' ? MANAGER_LANE : `agent:${who}`);
|
||||
if (d.tool_name) out.push(`tool:${who}/${d.tool_name}`);
|
||||
}
|
||||
if (String(ev.event_type).startsWith('approval_')) out.push(HUMAN_LANE);
|
||||
return [...new Set(out)];
|
||||
@@ -717,8 +738,11 @@ export function nodeIdsForEvent(ev) {
|
||||
* sessions): agents, their tools, the manager and you, with hand-off/uses/reviews
|
||||
* edges labelled with how often they happened.
|
||||
*/
|
||||
export function deriveGraph(events) {
|
||||
export function deriveGraph(events, opts = {}) {
|
||||
const evs = sortEvents(events);
|
||||
// Unnamed agents count only when there is no recorded graph: a team graph
|
||||
// leaves them out on purpose, and they must not reappear as "outside".
|
||||
const nameOf = opts.unnamed === false ? (ev) => ev.agent_name || null : agentOf;
|
||||
const nodes = new Map();
|
||||
const edges = new Map();
|
||||
const node = (id, kind, label) => { if (!nodes.has(id)) nodes.set(id, { id, kind, label, parent: null, meta: {} }); return id; };
|
||||
@@ -732,16 +756,17 @@ export function deriveGraph(events) {
|
||||
};
|
||||
evs.forEach((ev) => {
|
||||
const d = dataOf(ev);
|
||||
if (ev.agent_name) agentNode(ev.agent_name);
|
||||
if (ev.event_type === 'agent_handoff' && (d.from_agent || ev.agent_name) && d.to_agent) {
|
||||
const who = nameOf(ev);
|
||||
if (who) agentNode(who);
|
||||
if (ev.event_type === 'agent_handoff' && (d.from_agent || who) && d.to_agent) {
|
||||
const kind = { delegate: 'delegates', route: 'routes', spawn: 'handoff' }[d.kind] || 'handoff';
|
||||
edge(agentNode(d.from_agent || ev.agent_name), agentNode(d.to_agent), kind);
|
||||
} else if (ev.event_type === 'tool_call_start' && ev.agent_name && d.tool_name) {
|
||||
const tool = node(`tool:${ev.agent_name}/${d.tool_name}`, 'tool', d.tool_name);
|
||||
edge(agentNode(ev.agent_name), tool, 'uses');
|
||||
edge(agentNode(d.from_agent || who), agentNode(d.to_agent), kind);
|
||||
} else if (ev.event_type === 'tool_call_start' && who && d.tool_name) {
|
||||
const tool = node(`tool:${who}/${d.tool_name}`, 'tool', d.tool_name);
|
||||
edge(agentNode(who), tool, 'uses');
|
||||
} else if (ev.event_type === 'approval_requested') {
|
||||
const you = node(HUMAN_LANE, 'human', 'You');
|
||||
const target = d.tool && ev.agent_name ? node(`tool:${ev.agent_name}/${d.tool}`, 'tool', d.tool) : (ev.agent_name ? agentNode(ev.agent_name) : null);
|
||||
const target = d.tool && who ? node(`tool:${who}/${d.tool}`, 'tool', d.tool) : (who ? agentNode(who) : null);
|
||||
if (target) edge(you, target, 'reviews');
|
||||
}
|
||||
});
|
||||
@@ -929,7 +954,8 @@ export function layoutMap(graph, opts = {}) {
|
||||
* them as "Outside the team". Without a recorded graph this is deriveGraph().
|
||||
*/
|
||||
export function observedGraph(graph, events) {
|
||||
const derived = deriveGraph(events);
|
||||
const hasGraph = Boolean(graph && Array.isArray(graph.nodes) && graph.nodes.length);
|
||||
const derived = deriveGraph(events, { unnamed: !hasGraph });
|
||||
if (!graph || !Array.isArray(graph.nodes) || !graph.nodes.length) return derived;
|
||||
const nodes = graph.nodes.filter((n) => n && n.id).map((n) => ({ ...n, meta: { ...(n.meta || {}) } }));
|
||||
const edges = (Array.isArray(graph.edges) ? graph.edges : []).filter((e) => e && e.source && e.target).map((e) => ({ ...e }));
|
||||
@@ -1024,6 +1050,56 @@ export function layoutRunMap(graph, events, opts = {}) {
|
||||
};
|
||||
}
|
||||
|
||||
/** End events that mean "this node finished one run", and which node that is. */
|
||||
const RUN_ENDS = {
|
||||
step_end: (nid, ev) => nid === ev.node_id,
|
||||
task_end: (nid, ev) => nid === ev.node_id,
|
||||
agent_end: (nid) => nid.startsWith('agent:') || nid === MANAGER_LANE,
|
||||
tool_call_end: (nid) => nid.startsWith('tool:'),
|
||||
};
|
||||
|
||||
/**
|
||||
* Each map node's state after the first ``upTo + 1`` events (sorted, as in
|
||||
* buildModel().events): ``{events, runs, state}`` where state is "running"
|
||||
* (started, not ended yet), "done", "failed" or "skipped" (from the last end's
|
||||
* ``data.status``). Nodes without an entry were not reached by then. The flow's
|
||||
* start/end nodes count as reached once the run started/ended.
|
||||
*/
|
||||
export function nodeStates(events, upTo = Infinity, nodes = []) {
|
||||
const out = new Map();
|
||||
const last = Math.min(events.length - 1, upTo);
|
||||
let ended = false;
|
||||
for (let i = 0; i <= last; i++) {
|
||||
const ev = events[i];
|
||||
const type = ev && ev.event_type;
|
||||
if (type === 'workflow_end' || type === 'session_end') ended = true;
|
||||
for (const nid of nodeIdsForEvent(ev)) {
|
||||
const st = out.get(nid) || { events: 0, runs: 0, open: 0, last: null };
|
||||
st.events += 1;
|
||||
if (PAIRS[type]) st.open += 1;
|
||||
else if (END_OF[type]) {
|
||||
st.open = Math.max(0, st.open - 1);
|
||||
if (RUN_ENDS[type] && RUN_ENDS[type](nid, ev)) {
|
||||
st.runs += 1;
|
||||
const status = str(dataOf(ev).status);
|
||||
st.last = status === 'failed' || dataOf(ev).error ? 'failed' : (status === 'skipped' ? 'skipped' : 'done');
|
||||
}
|
||||
}
|
||||
out.set(nid, st);
|
||||
}
|
||||
}
|
||||
for (const n of nodes || []) {
|
||||
if (out.has(n.id)) continue;
|
||||
if ((n.kind === 'start' && last >= 0) || (n.kind === 'end' && ended)) out.set(n.id, { events: 1, runs: 1, open: 0, last: 'done' });
|
||||
}
|
||||
for (const st of out.values()) {
|
||||
st.state = st.open > 0 ? 'running' : (st.last || 'done');
|
||||
delete st.open;
|
||||
delete st.last;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/** Words for edge kinds on the map (labels never rely on colour). */
|
||||
export const EDGE_WORDS = Object.freeze({
|
||||
handoff: 'hands off',
|
||||
@@ -1045,7 +1121,7 @@ export function edgeText(edge) {
|
||||
|
||||
const api = {
|
||||
PALETTE, SHAPES, HUMAN_LANE, MANAGER_LANE, EDGE_WORDS,
|
||||
buildModel, narrate, layoutMap, layoutRunMap, observedGraph, deriveGraph, nodeIdsForEvent, pairEvents, sortEvents,
|
||||
buildModel, narrate, layoutMap, layoutRunMap, observedGraph, deriveGraph, nodeIdsForEvent, agentOf, nodeStates, pairEvents, sortEvents,
|
||||
eventId, formatDuration, formatCost, argPreview, truncate, stripPrefix, edgeText,
|
||||
};
|
||||
if (typeof globalThis !== 'undefined') globalThis.PraisonReplayCore = api;
|
||||
|
||||
@@ -15,6 +15,9 @@
|
||||
*
|
||||
* Standalone: with window.__REPLAY_DATA__ = {events, graph} set and no aiui
|
||||
* dashboard present, the module renders itself into #replay-root (or <body>).
|
||||
*
|
||||
* Embedded (any page): render(el, {base, run, runner}) — see render() below and
|
||||
* praisonaiui.features.replay.create_replay_app() for the server side.
|
||||
* Pure logic lives in replay-core.js.
|
||||
*/
|
||||
|
||||
@@ -131,6 +134,12 @@ const STYLE = `
|
||||
.rp-btn[disabled]{opacity:.55;cursor:not-allowed}
|
||||
.rp-btn-primary{background:var(--rp-human);border-color:var(--rp-human);color:var(--rp-on-fill)}
|
||||
.rp-seg{display:inline-flex;gap:0}.rp-seg .rp-btn{border-radius:0}.rp-seg .rp-btn:first-child{border-radius:8px 0 0 8px}.rp-seg .rp-btn:last-child{border-radius:0 8px 8px 0;margin-left:-1px}
|
||||
.rp-runbox{display:flex;gap:8px;align-items:center;flex-wrap:wrap;padding:10px 14px;border-bottom:1px solid var(--rp-border)}
|
||||
.rp-runbox[hidden]{display:none}
|
||||
.rp-input{flex:1;min-width:180px;font:inherit;font-size:14px;color:var(--rp-text);background:var(--rp-bg);border:1px solid var(--rp-control);border-radius:8px;padding:6px 10px;min-height:34px}
|
||||
.rp-primary{background:var(--rp-focus);border-color:var(--rp-focus);color:var(--rp-bg);font-weight:600;min-height:34px;padding:4px 16px}
|
||||
.rp-btn:disabled{opacity:.6;cursor:default}
|
||||
.rp-primary:disabled{opacity:1;background:var(--rp-lane);border-color:var(--rp-control);color:var(--rp-text)}
|
||||
.rp-select{font:inherit;font-size:13px;color:var(--rp-text);background:var(--rp-bg);border:1px solid var(--rp-control);border-radius:8px;padding:3px 6px;min-height:30px}
|
||||
.rp-pending{display:flex;flex-direction:column;gap:8px;padding:10px 14px;border-bottom:1px solid var(--rp-border);background:var(--rp-surface)}
|
||||
.rp-pending[hidden]{display:none}
|
||||
@@ -224,6 +233,10 @@ const STYLE = `
|
||||
.rp-svg .rp-node .rp-card{fill:var(--rp-bg);stroke:var(--rp-control);stroke-width:1.5}
|
||||
.rp-svg .rp-node.rp-unvisited .rp-card{stroke-dasharray:4 3}
|
||||
.rp-svg .rp-node.rp-now .rp-card{stroke:var(--rp-text);stroke-width:3}
|
||||
.rp-svg .rp-node.rp-st-running .rp-card{stroke:var(--rp-focus);stroke-width:3}
|
||||
.rp-svg .rp-node.rp-st-done .rp-card{stroke:var(--rp-ok)}
|
||||
.rp-svg .rp-node.rp-st-failed .rp-card{stroke:var(--rp-err);stroke-width:2.5}
|
||||
.rp-svg .rp-node.rp-st-skipped .rp-card{opacity:.6}
|
||||
.rp-svg .rp-node{cursor:pointer}
|
||||
.rp-svg .rp-node .rp-kindword{fill:var(--rp-dim);font-size:11px}
|
||||
.rp-svg .rp-edge path{fill:none;stroke:var(--rp-control);stroke-width:1.6}
|
||||
@@ -237,7 +250,8 @@ const STYLE = `
|
||||
.rp-svg .rp-group rect{fill:none;stroke:var(--rp-control);stroke-width:1.5;stroke-dasharray:6 4}
|
||||
.rp-map{cursor:grab}.rp-map.rp-panning{cursor:grabbing}
|
||||
@media (prefers-reduced-motion:no-preference){
|
||||
.rp-b-live .rp-dot{animation:rp-pulse 1.6s ease-in-out infinite}
|
||||
.rp-b-live .rp-dot,.rp-svg .rp-node.rp-st-running .rp-ring{animation:rp-pulse 1.6s ease-in-out infinite}
|
||||
.rp-svg .rp-node.rp-st-running .rp-ring{stroke:var(--rp-focus);stroke-width:2}
|
||||
.rp-step,.rp-svg .rp-block,.rp-svg .rp-arrow{transition:opacity .15s ease}
|
||||
}
|
||||
@keyframes rp-pulse{0%,100%{opacity:1}50%{opacity:.35}}
|
||||
@@ -288,7 +302,7 @@ function runFromUrl() {
|
||||
}
|
||||
|
||||
function writeRunToUrl(id) {
|
||||
if (S.solo) return;
|
||||
if (S.solo || S.pinned) return;
|
||||
try {
|
||||
const url = new URL(window.location.href);
|
||||
url.searchParams.set('run', id);
|
||||
@@ -326,6 +340,14 @@ function statusBadge(row) {
|
||||
/**
|
||||
* Mount the view. Dashboard: render(container). Standalone:
|
||||
* render(container, {standalone: true, events, graph, title}).
|
||||
*
|
||||
* Embedded in another page (options are all optional):
|
||||
* base — URL prefix the replay API is mounted under, e.g. "/replay"
|
||||
* run — show only this run id (no run list); waits for it to start
|
||||
* runner — {input, placeholder, ask}: show a Run box that POSTs {input} to
|
||||
* <base>/api/replay/run and follows the run it returns
|
||||
* (ask: false shows just the Run button)
|
||||
* approve — false to hide the approve/deny buttons (no approvals API here)
|
||||
*/
|
||||
export async function render(container, opts = {}) {
|
||||
cleanup();
|
||||
@@ -335,6 +357,11 @@ export async function render(container, opts = {}) {
|
||||
S = {
|
||||
core,
|
||||
solo,
|
||||
base: String(opts.base || '').replace(/\/+$/, ''),
|
||||
pinned: opts.run ? String(opts.run) : null,
|
||||
runner: opts.runner && typeof opts.runner === 'object' ? { input: '', ...opts.runner } : null,
|
||||
canApprove: !solo && opts.approve !== false,
|
||||
play: null,
|
||||
container,
|
||||
abort: new AbortController(),
|
||||
timers: [],
|
||||
@@ -369,6 +396,8 @@ export async function render(container, opts = {}) {
|
||||
setEvents(opts.events || [], opts.graph || null);
|
||||
S.cursor = S.model.narration.length - 1;
|
||||
renderAll();
|
||||
} else if (S.pinned) {
|
||||
await loadRun(S.pinned);
|
||||
} else {
|
||||
await refreshRuns();
|
||||
const wanted = runFromUrl();
|
||||
@@ -400,6 +429,7 @@ export function cleanup() {
|
||||
try { S.abort.abort(); } catch { /* ignore */ }
|
||||
if (S.es) { try { S.es.close(); } catch { /* ignore */ } }
|
||||
for (const t of S.timers) clearInterval(t);
|
||||
if (S.play) clearInterval(S.play);
|
||||
if (S.ro) { try { S.ro.disconnect(); } catch { /* ignore */ } }
|
||||
if (S.unlisten) { try { S.unlisten(); } catch { /* ignore */ } }
|
||||
S = null;
|
||||
@@ -409,14 +439,20 @@ export function cleanup() {
|
||||
|
||||
async function fetchJson(url, init = {}) {
|
||||
const res = await fetch(url, { signal: S.abort.signal, ...init });
|
||||
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
||||
if (!res.ok) {
|
||||
let msg = `HTTP ${res.status}`;
|
||||
try { const body = await res.json(); if (body && body.error) msg = body.error; } catch { /* not JSON */ }
|
||||
const err = new Error(msg);
|
||||
err.status = res.status;
|
||||
throw err;
|
||||
}
|
||||
return res.json();
|
||||
}
|
||||
|
||||
async function refreshRuns(autoload = false) {
|
||||
if (!S || S.solo) return;
|
||||
if (!S || S.solo || S.pinned) return;
|
||||
try {
|
||||
const rows = await fetchJson('/api/replay/runs');
|
||||
const rows = await fetchJson(`${S.base}/api/replay/runs`);
|
||||
S.runs = Array.isArray(rows) ? rows : [];
|
||||
} catch (e) {
|
||||
if (e.name === 'AbortError') return;
|
||||
@@ -482,15 +518,35 @@ async function loadRun(id) {
|
||||
renderRunList();
|
||||
let data;
|
||||
try {
|
||||
data = await fetchJson(`/api/replay/runs/${encodeURIComponent(id)}/events`);
|
||||
const waiting = S.pinned === id || S.awaiting === id;
|
||||
data = await fetchJson(`${S.base}/api/replay/runs/${encodeURIComponent(id)}/events${waiting ? '?wait=1' : ''}`);
|
||||
if (waiting && !data.run) {
|
||||
const err = new Error('run not found');
|
||||
err.status = 404;
|
||||
throw err;
|
||||
}
|
||||
} catch (e) {
|
||||
if (e.name === 'AbortError') return;
|
||||
if (e.status === 404 && (S.pinned === id || S.awaiting === id) && (S.waitTries = (S.waitTries || 0) + 1) <= 120) {
|
||||
// the run was asked for but its first event is not written yet (wait up to 60 s)
|
||||
S.run = { run_id: id, name: 'Starting…', status: 'running', live: true };
|
||||
setEvents([], null);
|
||||
renderAll();
|
||||
setTimeout(() => { if (S && S.runId === id) loadRun(id); }, 500);
|
||||
return;
|
||||
}
|
||||
S.waitTries = 0;
|
||||
S.run = { run_id: id, name: id, status: 'incomplete', live: false };
|
||||
setEvents([], null);
|
||||
renderAll(`Could not load run ${id} (${e.message}).`);
|
||||
return;
|
||||
}
|
||||
if (!S || S.runId !== id) return;
|
||||
S.waitTries = 0;
|
||||
if (S.awaiting === id) {
|
||||
S.awaiting = null;
|
||||
refreshRuns(); // replace the "Starting…" row with the real one
|
||||
}
|
||||
S.run = data.run || S.runs.find((r) => r.run_id === id) || { run_id: id, name: id };
|
||||
S.follow = true;
|
||||
setEvents(data.events || [], data.graph || null);
|
||||
@@ -508,7 +564,7 @@ function maxSeq() {
|
||||
function startStream(id) {
|
||||
if (typeof EventSource === 'undefined') return;
|
||||
const after = maxSeq();
|
||||
const url = `/api/replay/runs/${encodeURIComponent(id)}/stream${after != null ? `?after_seq=${after}` : ''}`;
|
||||
const url = `${S.base}/api/replay/runs/${encodeURIComponent(id)}/stream${after != null ? `?after_seq=${after}` : ''}`;
|
||||
const es = new EventSource(url);
|
||||
S.es = es;
|
||||
es.addEventListener('ctx', (e) => {
|
||||
@@ -533,8 +589,8 @@ function buildShell() {
|
||||
clear(c);
|
||||
const els = {};
|
||||
els.root = h('section', { class: 'rp-root', 'data-theme': S.theme, 'aria-label': 'PraisonAI Replay' });
|
||||
els.shell = h('div', { class: `rp-shell${S.solo ? ' rp-solo' : ''}` });
|
||||
if (!S.solo) {
|
||||
els.shell = h('div', { class: `rp-shell${S.solo || S.pinned ? ' rp-solo' : ''}` });
|
||||
if (!S.solo && !S.pinned) {
|
||||
els.runs = h('nav', { class: 'rp-runs', 'aria-label': 'Recorded runs' });
|
||||
els.shell.appendChild(els.runs);
|
||||
}
|
||||
@@ -549,7 +605,8 @@ function buildShell() {
|
||||
els.scrub = h('div', { class: 'rp-scrub' });
|
||||
els.drawer = h('div', { class: 'rp-drawer', role: 'region', 'aria-label': 'Details' });
|
||||
els.live = h('div', { class: 'rp-sr', 'aria-live': 'polite', role: 'status' });
|
||||
els.main.append(els.bar, els.pending, els.stage, els.legend, els.scrub, els.drawer);
|
||||
els.runbox = h('form', { class: 'rp-runbox', hidden: true, onsubmit: (e) => { e.preventDefault(); startRun(); } });
|
||||
els.main.append(els.runbox, els.bar, els.pending, els.stage, els.legend, els.scrub, els.drawer);
|
||||
els.shell.appendChild(els.main);
|
||||
els.root.append(els.shell, els.live);
|
||||
els.root.addEventListener('keydown', onKeyDown);
|
||||
@@ -600,6 +657,7 @@ function renderAll(errorText) {
|
||||
S.els.root.setAttribute('data-theme', S.theme);
|
||||
renderRunList();
|
||||
renderBar();
|
||||
renderRunBox();
|
||||
if (!S.solo && !S.runs.length && !S.runId) {
|
||||
renderEmpty();
|
||||
return;
|
||||
@@ -619,6 +677,12 @@ function renderEmpty() {
|
||||
for (const k of ['pending', 'narr', 'scrub', 'drawer', 'legend']) clear(S.els[k]);
|
||||
S.els.pending.hidden = true;
|
||||
clear(S.els.canvas);
|
||||
if (S.runner) {
|
||||
S.els.canvas.appendChild(h('div', { class: 'rp-empty' },
|
||||
h('h2', { text: 'Nothing has run yet' }),
|
||||
h('p', { text: 'Type an input above and press Run. Each step lights up on the map as it happens.' })));
|
||||
return;
|
||||
}
|
||||
S.els.canvas.appendChild(h('div', { class: 'rp-empty' },
|
||||
h('h2', { text: 'No recorded runs yet' }),
|
||||
h('p', { text: 'Replay shows who did what, when, why and at what cost, once a run has been recorded. Record one and it appears here (live runs update as they go):' }),
|
||||
@@ -629,6 +693,56 @@ function renderEmpty() {
|
||||
));
|
||||
}
|
||||
|
||||
// ── run box (embedded with a runner) ──────────────────────────────────
|
||||
|
||||
/** Input + Run button; built once so typing survives live re-renders. */
|
||||
function renderRunBox() {
|
||||
const box = S.els.runbox;
|
||||
if (!S.runner || !box) return;
|
||||
box.hidden = false;
|
||||
if (!box.firstChild) {
|
||||
append(box, [
|
||||
S.runner.ask === false ? null : h('label', { for: 'rp-run-input', class: 'rp-sr', text: 'Input for the run' }),
|
||||
h('input', { id: 'rp-run-input', class: 'rp-input', type: S.runner.ask === false ? 'hidden' : 'text', value: S.runner.input || '', placeholder: S.runner.placeholder || 'What should the agents work on?', 'data-key': 'run-input' }),
|
||||
h('button', { type: 'submit', class: 'rp-btn rp-primary', 'data-key': 'run' }),
|
||||
h('span', { class: 'rp-note rp-runbox-msg', role: 'status' })]);
|
||||
}
|
||||
const busy = Boolean(S.awaiting || S.starting || (S.run && S.run.live));
|
||||
const btn = box.querySelector('button');
|
||||
btn.disabled = busy;
|
||||
btn.textContent = busy ? 'Running…' : '▶ Run';
|
||||
}
|
||||
|
||||
async function startRun() {
|
||||
if (!S || !S.runner || S.starting) return;
|
||||
const box = S.els.runbox;
|
||||
const msg = box.querySelector('.rp-runbox-msg');
|
||||
msg.textContent = '';
|
||||
S.starting = true;
|
||||
renderRunBox();
|
||||
let res;
|
||||
try {
|
||||
res = await fetchJson(`${S.base}/api/replay/run`, {
|
||||
method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ input: box.querySelector('input').value }),
|
||||
});
|
||||
} catch (e) {
|
||||
if (e.name === 'AbortError' || !S) return;
|
||||
S.starting = false;
|
||||
msg.textContent = `Could not start the run: ${e.message}`;
|
||||
renderRunBox();
|
||||
return;
|
||||
}
|
||||
if (!S) return;
|
||||
S.starting = false;
|
||||
S.awaiting = res.run_id;
|
||||
S.waitTries = 0;
|
||||
stopPlay();
|
||||
if (S.pinned) S.pinned = res.run_id;
|
||||
else S.runs = [{ run_id: res.run_id, name: 'Starting…', status: 'running', live: true, events: 0, agents: [] }, ...S.runs.filter((r) => r.run_id !== res.run_id)];
|
||||
announce('Run started.');
|
||||
await loadRun(res.run_id);
|
||||
}
|
||||
|
||||
// ── run picker ────────────────────────────────────────────────────────
|
||||
|
||||
function renderRunList() {
|
||||
@@ -1224,19 +1338,30 @@ function mapGraph() {
|
||||
return mapLayout().graph;
|
||||
}
|
||||
|
||||
function nodeVisits() {
|
||||
const counts = new Map();
|
||||
/** Event index the cursor is on (-1 before the first step). */
|
||||
function cursorEventIndex() {
|
||||
const m = S.model;
|
||||
for (const ev of m.events) {
|
||||
for (const nid of C().nodeIdsForEvent(ev)) counts.set(nid, (counts.get(nid) || 0) + 1);
|
||||
}
|
||||
// Brief/Delivered are reached when the run starts/ends, though no event names them.
|
||||
const ended = m.events.some((e) => e.event_type === 'workflow_end' || e.event_type === 'session_end');
|
||||
for (const n of mapGraph().nodes || []) {
|
||||
if (n.kind === 'start' && m.events.length && !counts.has(n.id)) counts.set(n.id, 1);
|
||||
if (n.kind === 'end' && ended && !counts.has(n.id)) counts.set(n.id, 1);
|
||||
}
|
||||
return { counts };
|
||||
const item = m.narration[S.cursor];
|
||||
if (!item) return -1;
|
||||
const i = m.eventIndex.get(item.id);
|
||||
return typeof i === 'number' ? i : m.events.length - 1;
|
||||
}
|
||||
|
||||
/** Each node's state at the cursor (see replay-core nodeStates). */
|
||||
function nodeStatesAtCursor() {
|
||||
return C().nodeStates(S.model.events, cursorEventIndex(), mapGraph().nodes || []);
|
||||
}
|
||||
|
||||
const STATE_WORD = { running: 'running now', done: 'done', failed: 'failed', skipped: 'skipped' };
|
||||
|
||||
/** What a node did up to the cursor: "done ×2" on the card (its shape shows the kind), full words for screen readers. */
|
||||
function nodeStateText(n, st) {
|
||||
const word = KIND_WORD[n.kind] || n.kind;
|
||||
if (!st) return { line: word, label: `${n.label} ${word}, not reached yet` };
|
||||
const times = st.runs > 1 ? ` ×${st.runs}` : '';
|
||||
const state = STATE_WORD[st.state] || st.state;
|
||||
// the accessible name starts with the visible text (WCAG 2.5.3 label in name)
|
||||
return { line: `${state}${times}`, label: `${n.label} ${state}${times} (${word}${st.runs > 1 ? `, ran ${st.runs} times` : ''})` };
|
||||
}
|
||||
|
||||
function shapePath(shape, x, y, w, h) {
|
||||
@@ -1263,6 +1388,26 @@ function nodeGlyph(node, gx, gy) {
|
||||
|
||||
const MAP_MIN_SCALE = 0.55; // below this, labels stop being readable: scroll instead
|
||||
|
||||
/**
|
||||
* A node's name, whole (so its accessible name contains the visible text,
|
||||
* WCAG 2.5.3): squeezed a little when slightly too long, clipped at the card's
|
||||
* edge when much longer (the <title> and the drawer show it in full).
|
||||
*/
|
||||
function nodeLabel(n, i, svg) {
|
||||
const room = n.w - 52;
|
||||
const need = String(n.label).length * 7.2;
|
||||
const attrs = { x: n.x + 44, y: n.y + n.h / 2 - 2, 'font-weight': '600', 'aria-hidden': 'true', text: n.label };
|
||||
if (need > room && need <= room * 1.3) {
|
||||
attrs.textLength = room;
|
||||
attrs.lengthAdjust = 'spacingAndGlyphs';
|
||||
} else if (need > room) {
|
||||
const id = `rp-clip-${i}`;
|
||||
svg.querySelector('defs').appendChild(s('clipPath', { id }, s('rect', { x: n.x + 40, y: n.y, width: room + 4, height: n.h })));
|
||||
attrs['clip-path'] = `url(#${id})`;
|
||||
}
|
||||
return s('text', attrs);
|
||||
}
|
||||
|
||||
function renderMap() {
|
||||
const canvas = S.els.canvas;
|
||||
const prev = { left: canvas.scrollLeft, top: canvas.scrollTop };
|
||||
@@ -1274,7 +1419,6 @@ function renderMap() {
|
||||
canvas.appendChild(h('div', { class: 'rp-empty' }, h('h2', { text: 'Nothing to map yet' }), h('p', { text: 'The map appears once agents start talking.' })));
|
||||
return;
|
||||
}
|
||||
const visits = nodeVisits();
|
||||
const W = lay.width + 20;
|
||||
const H = lay.height + 20;
|
||||
const cw = Math.max(200, (canvas.clientWidth || 900) - 8);
|
||||
@@ -1318,14 +1462,9 @@ function renderMap() {
|
||||
const groups = { main: s('g', { role: 'group', 'aria-label': 'Nodes' }), outside: null };
|
||||
if ((lay.groups || []).length) groups.outside = s('g', { role: 'group', 'aria-label': lay.groups[0].label });
|
||||
ordered.forEach((n, i) => {
|
||||
const count = visits.counts.get(n.id) || 0;
|
||||
const word = KIND_WORD[n.kind] || n.kind;
|
||||
const kindLine = count ? `${word} · ×${count}` : word;
|
||||
const name = `${n.label} ${kindLine}${count ? `, ${count} events here` : ', not reached yet'}`;
|
||||
const g = s('g', {
|
||||
class: `rp-node${count ? '' : ' rp-unvisited'}${S.nodeSel === n.id ? ' rp-sel' : ''}`,
|
||||
class: `rp-node${S.nodeSel === n.id ? ' rp-sel' : ''}`,
|
||||
'data-node': n.id, 'data-key': `node:${n.id}`, role: 'button', tabindex: i === 0 ? '0' : '-1',
|
||||
'aria-label': name,
|
||||
onclick: () => selectNode(n.id),
|
||||
onfocus: () => highlight(null),
|
||||
});
|
||||
@@ -1334,9 +1473,9 @@ function renderMap() {
|
||||
g.appendChild(path ? s('path', { class: 'rp-card', d: path }) : s('rect', { class: 'rp-card', x: n.x, y: n.y, width: n.w, height: n.h, rx: n.kind === 'tool' ? 14 : (n.kind === 'end' ? 2 : 6) }));
|
||||
if (n.kind === 'end') g.appendChild(s('rect', { class: 'rp-card', x: n.x + 3, y: n.y + 3, width: n.w - 6, height: n.h - 6, rx: 2, fill: 'none' }));
|
||||
g.appendChild(nodeGlyph(n, n.x + 12, n.y + n.h / 2 - 11));
|
||||
g.appendChild(s('text', { x: n.x + 44, y: n.y + n.h / 2 - 2, 'font-weight': '600', 'aria-hidden': 'true', text: fitLabel(n.label, n.w - 52, 7.2) }));
|
||||
g.appendChild(s('text', { class: 'rp-kindword', x: n.x + 44, y: n.y + n.h / 2 + 13, 'aria-hidden': 'true', text: ` ${kindLine}` }));
|
||||
g.appendChild(s('title', { text: name }));
|
||||
g.appendChild(nodeLabel(n, i, svg));
|
||||
g.appendChild(s('text', { class: 'rp-kindword', x: n.x + 44, y: n.y + n.h / 2 + 13, 'aria-hidden': 'true' }));
|
||||
g.appendChild(s('title'));
|
||||
(n.outside && groups.outside ? groups.outside : groups.main).appendChild(g);
|
||||
});
|
||||
svg.appendChild(groups.main);
|
||||
@@ -1367,12 +1506,25 @@ function enablePan(svg, box) {
|
||||
svg.addEventListener('pointercancel', end);
|
||||
}
|
||||
|
||||
/** Node states follow the cursor: not reached (dashed), running, done, failed. */
|
||||
function applyMapCursor() {
|
||||
const item = S.model.narration[S.cursor];
|
||||
const ev = item ? S.model.events[S.model.eventIndex.get(item.id)] : null;
|
||||
const ev = S.model.events[cursorEventIndex()];
|
||||
const now = new Set(ev ? C().nodeIdsForEvent(ev) : []);
|
||||
const states = nodeStatesAtCursor();
|
||||
const byId = new Map((mapLayout().nodes || []).map((n) => [n.id, n]));
|
||||
for (const g of S.els.canvas.querySelectorAll('.rp-node')) {
|
||||
g.classList.toggle('rp-now', now.has(g.getAttribute('data-node')));
|
||||
const id = g.getAttribute('data-node');
|
||||
const st = states.get(id);
|
||||
const n = byId.get(id) || { id, label: id, kind: '' };
|
||||
const { line, label } = nodeStateText(n, st);
|
||||
g.classList.toggle('rp-now', now.has(id));
|
||||
g.classList.toggle('rp-unvisited', !st);
|
||||
for (const k of ['running', 'done', 'failed', 'skipped']) g.classList.toggle(`rp-st-${k}`, Boolean(st && st.state === k));
|
||||
g.setAttribute('aria-label', label);
|
||||
const kw = g.querySelector('.rp-kindword');
|
||||
if (kw) kw.textContent = ` ${fitLabel(line, (n.w || 136) - 52, 6.2)}`;
|
||||
const tip = g.querySelector('title');
|
||||
if (tip) tip.textContent = label;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1403,7 +1555,7 @@ function renderLegend() {
|
||||
lg.append(h('span', { text: 'solid arrow: hand-off · dashed: delegate · dotted: route' }), h('span', { text: 'outlined block: not reached yet' }));
|
||||
if (S.overlay) lg.append(h('span', { text: `bar under a block: ${S.heat === 'cost' ? 'cost' : 'latency'} (taller and darker = more)` }));
|
||||
} else {
|
||||
lg.append(h('span', { text: 'dashed outline: not reached · bold outline: now' }));
|
||||
lg.append(h('span', { text: 'dashed: not reached · blue: running · green: done · red: failed · bold: the step you are on' }));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1447,6 +1599,11 @@ function renderScrubber() {
|
||||
const item = S.model.narration[S.cursor];
|
||||
const existing = box.querySelector('input[type=range]');
|
||||
const valueText = item ? `Step ${S.cursor + 1} of ${n}: ${item.text}` : 'No steps';
|
||||
const playBtn = box.querySelector('[data-key="play"]');
|
||||
if (playBtn) {
|
||||
playBtn.textContent = S.play ? '❚❚ Pause' : '▶ Play';
|
||||
playBtn.setAttribute('aria-pressed', S.play ? 'true' : 'false');
|
||||
}
|
||||
if (existing && Number(existing.max) === Math.max(0, n - 1)) {
|
||||
existing.value = String(Math.max(0, S.cursor));
|
||||
existing.setAttribute('aria-valuetext', valueText);
|
||||
@@ -1456,6 +1613,7 @@ function renderScrubber() {
|
||||
clear(box);
|
||||
if (!n) return;
|
||||
box.append(
|
||||
h('button', { type: 'button', class: 'rp-btn', 'data-key': 'play', 'aria-pressed': S.play ? 'true' : 'false', title: 'Play the run step by step', onclick: togglePlay }, S.play ? '❚❚ Pause' : '▶ Play'),
|
||||
h('button', { type: 'button', class: 'rp-btn', 'data-key': 'prev', onclick: () => setCursor(S.cursor - 1) }, h('span', { 'aria-hidden': 'true' }, '← '), 'Previous'),
|
||||
h('label', { for: 'rp-scrub-range', class: 'rp-sr', text: 'Step' }),
|
||||
h('input', {
|
||||
@@ -1469,6 +1627,28 @@ function renderScrubber() {
|
||||
);
|
||||
}
|
||||
|
||||
/** Play: move the cursor one step every PLAY_MS, from the start if at the end. */
|
||||
const PLAY_MS = 900;
|
||||
|
||||
function togglePlay() {
|
||||
if (!S) return;
|
||||
if (S.play) { stopPlay(); return; }
|
||||
if (S.cursor >= S.model.narration.length - 1) setCursor(0);
|
||||
S.play = setInterval(() => {
|
||||
if (!S || !S.model) return;
|
||||
if (S.cursor >= S.model.narration.length - 1) { stopPlay(); return; }
|
||||
setCursor(S.cursor + 1);
|
||||
}, PLAY_MS);
|
||||
renderScrubber();
|
||||
}
|
||||
|
||||
function stopPlay() {
|
||||
if (!S || !S.play) return;
|
||||
clearInterval(S.play);
|
||||
S.play = null;
|
||||
renderScrubber();
|
||||
}
|
||||
|
||||
// ── drawer ────────────────────────────────────────────────────────────
|
||||
|
||||
const TABS = [['what', 'What happened'], ['why', 'Why'], ['raw', 'Raw JSON']];
|
||||
@@ -1702,7 +1882,7 @@ function renderPending() {
|
||||
const pending = S.model.pending;
|
||||
// Rebuild only when something visible changed, so a live update never
|
||||
// resets the caret of someone typing arguments or a reason.
|
||||
const sig = `${S.solo}|${Boolean(S.run?.live)}|${pending.map((b) => {
|
||||
const sig = `${S.canApprove}|${Boolean(S.run?.live)}|${pending.map((b) => {
|
||||
const st = S.asks.get(b.approvalId) || {};
|
||||
return `${b.approvalId}:${Boolean(st.editing)}:${st.sent || ''}:${Boolean(st.loaded)}`;
|
||||
}).join(',')}`;
|
||||
@@ -1724,8 +1904,8 @@ function renderPending() {
|
||||
`${req ? req.agent_name || 'An agent' : 'An agent'} asks to ${d.kind === 'task_output_review' ? 'deliver the output of' : 'run'} ${b.label}`,
|
||||
d.risk ? h('span', { class: 'rp-badge', text: `${d.risk} risk` }) : null));
|
||||
card.appendChild(h('pre', { tabindex: '0', 'aria-label': 'Recorded arguments (redacted)', text: prettyJson(d.args || {}) }));
|
||||
if (S.solo) {
|
||||
card.appendChild(h('p', { class: 'rp-note', text: 'This export cannot answer. Open the run with praisonai replay view to approve or deny.' }));
|
||||
if (!S.canApprove) {
|
||||
card.appendChild(h('p', { class: 'rp-note', text: S.solo ? 'This export cannot answer. Open the run with praisonai replay view to approve or deny.' : 'Answer this request where your app asks for approval.' }));
|
||||
box.appendChild(card);
|
||||
continue;
|
||||
}
|
||||
@@ -1792,7 +1972,7 @@ async function startEdit(b, recorded) {
|
||||
st.text = prettyJson(recorded.args || {});
|
||||
st.loaded = false;
|
||||
try {
|
||||
const res = await fetch(`/api/approvals/${encodeURIComponent(b.approvalId)}`, { signal: S.abort.signal });
|
||||
const res = await fetch(`${S.base}/api/approvals/${encodeURIComponent(b.approvalId)}`, { signal: S.abort.signal });
|
||||
if (res.ok) {
|
||||
const entry = await res.json();
|
||||
if (entry && entry.arguments && typeof entry.arguments === 'object') {
|
||||
@@ -1825,7 +2005,7 @@ async function decide(b, action, withArgs) {
|
||||
}
|
||||
let msg;
|
||||
try {
|
||||
const res = await fetch(`/api/approvals/${encodeURIComponent(b.approvalId)}/${action}`, {
|
||||
const res = await fetch(`${S.base}/api/approvals/${encodeURIComponent(b.approvalId)}/${action}`, {
|
||||
method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body), signal: S.abort.signal,
|
||||
});
|
||||
if (res.ok) msg = `${action === 'approve' ? 'Approved' : 'Denied'}. Waiting for the run to continue…`;
|
||||
|
||||
+33
-3
@@ -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:
|
||||
|
||||
@@ -466,3 +466,59 @@ def test_empty_state_explains_how_to_record(browser, tmp_path, monkeypatch):
|
||||
srv.should_exit = True
|
||||
th.join(timeout=5)
|
||||
server.reset_state()
|
||||
|
||||
|
||||
def test_embedded_run_box_draws_each_step_live_then_plays_back(browser, tmp_path, monkeypatch):
|
||||
"""create_replay_app(run=...) mounted in a host app: Run, watch the map, then Play."""
|
||||
import uvicorn
|
||||
from starlette.applications import Starlette
|
||||
from starlette.routing import Mount
|
||||
|
||||
from praisonaiui.features.replay import create_replay_app
|
||||
|
||||
traces = tmp_path / "traces"
|
||||
traces.mkdir()
|
||||
monkeypatch.setenv("PRAISONAI_TRACES_DIR", str(traces))
|
||||
lines = (FIXTURES / "flow_parallel_loop.jsonl").read_text(encoding="utf-8").splitlines(keepends=True)
|
||||
seen = []
|
||||
|
||||
def runner(text, run_id): # writes a recorded flow slowly, like a live run
|
||||
seen.append(text)
|
||||
with open(traces / f"{run_id}.jsonl", "w", encoding="utf-8") as fh:
|
||||
for line in lines:
|
||||
fh.write(line)
|
||||
fh.flush()
|
||||
time.sleep(0.08)
|
||||
|
||||
host = Starlette(routes=[Mount("/replay", app=create_replay_app(runner, input="weekly digest"))])
|
||||
port = _free_port()
|
||||
srv = uvicorn.Server(uvicorn.Config(host, host="127.0.0.1", port=port, log_level="warning"))
|
||||
t = threading.Thread(target=srv.run, daemon=True)
|
||||
t.start()
|
||||
for _ in range(100):
|
||||
if srv.started:
|
||||
break
|
||||
time.sleep(0.05)
|
||||
try:
|
||||
page, errors = _page(browser)
|
||||
page.add_init_script("try{localStorage.setItem('rp-view','map')}catch(e){}")
|
||||
page.goto(f"http://127.0.0.1:{port}/replay/")
|
||||
page.wait_for_selector(".rp-empty h2:has-text('Nothing has run yet')")
|
||||
page.get_by_role("button", name="▶ Run").click()
|
||||
page.wait_for_selector(".rp-node.rp-st-running", timeout=10000) # a step is drawn as running
|
||||
assert page.locator(".rp-node.rp-unvisited").count() > 0 # later steps not reached yet
|
||||
page.wait_for_selector(".rp-bar .rp-b-completed", timeout=20000)
|
||||
assert seen == ["weekly digest"]
|
||||
assert page.locator(".rp-node.rp-st-running").count() == 0
|
||||
assert page.get_by_role("button", name="▶ Run").is_enabled()
|
||||
page.get_by_role("button", name="▶ Play").click() # from the end: restarts at step 1
|
||||
page.wait_for_timeout(1300)
|
||||
assert page.locator("[data-key=play]").inner_text() == "❚❚ Pause"
|
||||
assert page.locator(".rp-node.rp-unvisited").count() > 0 # the map rewound with the cursor
|
||||
page.locator("[data-key=play]").click()
|
||||
violations = _axe(page)
|
||||
assert violations == [], json.dumps(violations, indent=2)
|
||||
assert not errors, errors
|
||||
finally:
|
||||
srv.should_exit = True
|
||||
t.join(timeout=5)
|
||||
@@ -397,3 +397,113 @@ test('real-run shape: LLM span encloses the tool call in the same lane', () => {
|
||||
assert.ok(m.lanes.some((l) => l.id === 'agent:Triage') && m.lanes.some((l) => l.id === 'agent:Billing'));
|
||||
assert.deepEqual(m.arrows.map((a) => `${a.kind}:${a.from}->${a.to}`), ['handoff:agent:Triage->agent:Billing']);
|
||||
});
|
||||
|
||||
// ── nodeStates: the map at the cursor ─────────────────────────────────
|
||||
|
||||
const sev = (seq, event_type, extra = {}) => ({ sequence_num: seq, timestamp: 100 + seq, event_type, ...extra });
|
||||
|
||||
test('nodeStates: a step is running between its start and end, then done', () => {
|
||||
const events = [
|
||||
sev(0, 'workflow_start'),
|
||||
sev(1, 'step_start', { node_id: 'step:research' }),
|
||||
sev(2, 'step_end', { node_id: 'step:research', data: { status: 'completed' } }),
|
||||
sev(3, 'step_start', { node_id: 'step:draft' }),
|
||||
];
|
||||
const nodes = [{ id: 'start', kind: 'start' }, { id: 'step:research' }, { id: 'step:draft' }, { id: 'end', kind: 'end' }];
|
||||
const at = (i) => Object.fromEntries([...core.nodeStates(events, i, nodes)].map(([k, v]) => [k, v.state]));
|
||||
assert.deepEqual(at(1), { 'step:research': 'running', start: 'done' });
|
||||
assert.deepEqual(at(2), { 'step:research': 'done', start: 'done' });
|
||||
assert.deepEqual(at(3), { 'step:research': 'done', 'step:draft': 'running', start: 'done' });
|
||||
assert.equal(core.nodeStates(events, -1, nodes).size, 0, 'nothing is reached before the first step');
|
||||
});
|
||||
|
||||
test('nodeStates: end node is reached when the run ends; failed and skipped come from status', () => {
|
||||
const events = [
|
||||
sev(0, 'step_start', { node_id: 'step:a' }),
|
||||
sev(1, 'step_end', { node_id: 'step:a', data: { status: 'failed' } }),
|
||||
sev(2, 'step_start', { node_id: 'step:b' }),
|
||||
sev(3, 'step_end', { node_id: 'step:b', data: { status: 'skipped' } }),
|
||||
sev(4, 'workflow_end'),
|
||||
];
|
||||
const st = core.nodeStates(events, Infinity, [{ id: 'end', kind: 'end' }]);
|
||||
assert.equal(st.get('step:a').state, 'failed');
|
||||
assert.equal(st.get('step:b').state, 'skipped');
|
||||
assert.equal(st.get('end').state, 'done');
|
||||
});
|
||||
|
||||
test('nodeStates: runs count only on the node an end event belongs to', () => {
|
||||
const events = [
|
||||
sev(0, 'task_start', { node_id: 'task:research', agent_name: 'Researcher' }),
|
||||
sev(1, 'agent_start', { agent_name: 'Researcher' }),
|
||||
sev(2, 'agent_end', { agent_name: 'Researcher' }),
|
||||
sev(3, 'task_end', { node_id: 'task:research', agent_name: 'Researcher', data: { status: 'completed' } }),
|
||||
sev(4, 'step_start', { node_id: 'step:loop' }),
|
||||
sev(5, 'step_end', { node_id: 'step:loop' }),
|
||||
sev(6, 'step_start', { node_id: 'step:loop' }),
|
||||
sev(7, 'step_end', { node_id: 'step:loop' }),
|
||||
];
|
||||
const st = core.nodeStates(events);
|
||||
assert.equal(st.get('agent:Researcher').runs, 1, 'task_end must not count as another agent run');
|
||||
assert.equal(st.get('task:research').runs, 1);
|
||||
assert.equal(st.get('step:loop').runs, 2);
|
||||
assert.equal(st.get('agent:Researcher').state, 'done');
|
||||
});
|
||||
|
||||
test('nodeStates: a recorded flow fixture ends with every visited node done or failed', () => {
|
||||
const { events, graph } = load('flow_parallel_loop');
|
||||
const m = buildModel(events, graph);
|
||||
const st = core.nodeStates(m.events, Infinity, graph.nodes);
|
||||
assert.ok(st.size > 0);
|
||||
for (const [id, v] of st) assert.notEqual(v.state, 'running', `${id} still running after the run ended`);
|
||||
});
|
||||
|
||||
// ── unnamed agents (Agent(instructions=...), agent_name null) ────────────
|
||||
|
||||
const unnamedRun = (newIds) => [
|
||||
{ sequence_num: 0, timestamp: 1, event_type: 'session_start', agent_name: null },
|
||||
{ sequence_num: 1, timestamp: 2, event_type: 'agent_start', agent_name: null, node_id: newIds ? 'agent:Agent' : null },
|
||||
{ sequence_num: 2, timestamp: 3, event_type: 'llm_request', agent_name: null, node_id: newIds ? 'agent:Agent' : null },
|
||||
{ sequence_num: 3, timestamp: 4, event_type: 'llm_response', agent_name: null, node_id: newIds ? 'agent:Agent' : null },
|
||||
{ sequence_num: 4, timestamp: 5, event_type: 'tool_call_start', agent_name: null, node_id: newIds ? 'tool:Agent/search' : 'tool:None/search', data: { tool_name: 'search' } },
|
||||
{ sequence_num: 5, timestamp: 6, event_type: 'tool_call_end', agent_name: null, node_id: newIds ? 'tool:Agent/search' : 'tool:None/search', data: { tool_name: 'search' } },
|
||||
{ sequence_num: 6, timestamp: 7, event_type: 'agent_end', agent_name: null, node_id: newIds ? 'agent:Agent' : null, data: newIds ? { status: 'completed' } : {} },
|
||||
{ sequence_num: 7, timestamp: 8, event_type: 'session_end', agent_name: null },
|
||||
];
|
||||
|
||||
for (const newIds of [true, false]) {
|
||||
test(`unnamed agent is mapped as "Agent" (${newIds ? 'new' : '4.7.19'} trace ids)`, () => {
|
||||
const g = deriveGraph(unnamedRun(newIds));
|
||||
assert.deepEqual(g.nodes.map((n) => [n.id, n.label]), [['agent:Agent', 'Agent'], ['tool:Agent/search', 'search']]);
|
||||
assert.deepEqual(g.edges.map((e) => `${e.source}->${e.target}:${e.kind}`), ['agent:Agent->tool:Agent/search:uses']);
|
||||
const st = core.nodeStates(unnamedRun(newIds), Infinity, g.nodes);
|
||||
assert.equal(st.get('agent:Agent').state, 'done');
|
||||
assert.equal(st.get('tool:Agent/search').state, 'done');
|
||||
assert.ok(![...st.keys()].some((k) => k.includes('None')), 'no tool:None ids leak into the map');
|
||||
});
|
||||
}
|
||||
|
||||
test('a failed unnamed agent run is red on the map', () => {
|
||||
const evs = unnamedRun(true);
|
||||
evs[6] = { ...evs[6], data: { status: 'failed' } };
|
||||
assert.equal(core.nodeStates(evs, Infinity, deriveGraph(evs).nodes).get('agent:Agent').state, 'failed');
|
||||
});
|
||||
|
||||
test('unnamed agents do not reappear "outside" a recorded team graph', () => {
|
||||
const graph = { schema: 'praisonai.graph/1', kind: 'team', nodes: [
|
||||
{ id: 'start:begin', kind: 'start', label: 'Brief' }, { id: 'task:t', kind: 'task', label: 't' }, { id: 'end:done', kind: 'end', label: 'Delivered' }],
|
||||
edges: [{ source: 'start:begin', target: 'task:t', kind: 'next' }, { source: 'task:t', target: 'end:done', kind: 'next' }] };
|
||||
const og = core.observedGraph(graph, unnamedRun(true));
|
||||
assert.deepEqual(og.nodes.map((n) => n.id), ['start:begin', 'task:t', 'end:done']);
|
||||
assert.deepEqual(og.outside, []);
|
||||
});
|
||||
|
||||
test('named agents are unchanged', () => {
|
||||
const evs = unnamedRun(true).map((e) => ({ ...e, agent_name: e.agent_name === null && e.event_type !== 'session_start' && e.event_type !== 'session_end' ? 'Writer' : e.agent_name, node_id: e.node_id && e.node_id.replace('Agent', 'Writer') }));
|
||||
assert.deepEqual(deriveGraph(evs).nodes.map((n) => n.id), ['agent:Writer', 'tool:Writer/search']);
|
||||
});
|
||||
|
||||
test('an unnamed agent is narrated as "Agent", not "The run"', () => {
|
||||
const evs = unnamedRun(true);
|
||||
assert.equal(narrate(evs[1]), 'Agent started.');
|
||||
assert.equal(narrate(evs[7]).startsWith('Recording'), true);
|
||||
});
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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)
|
||||
@@ -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
|
||||
@@ -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)
|
||||
@@ -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
|
||||
@@ -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"
|
||||
@@ -0,0 +1,166 @@
|
||||
"""create_replay_app(): the Replay view mounted inside another app, with an optional Run box."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import shutil
|
||||
import threading
|
||||
import time
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
from starlette.applications import Starlette
|
||||
from starlette.routing import Mount
|
||||
from starlette.testclient import TestClient
|
||||
|
||||
FIXTURES = Path(__file__).resolve().parents[1] / "fixtures" / "replay"
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def traces(tmp_path, monkeypatch):
|
||||
d = tmp_path / "traces"
|
||||
d.mkdir()
|
||||
monkeypatch.setenv("PRAISONAI_TRACES_DIR", str(d))
|
||||
monkeypatch.delenv("PRAISON_HOME", raising=False)
|
||||
return d
|
||||
|
||||
|
||||
def _host(sub) -> TestClient:
|
||||
"""A host app with the replay app mounted at /replay, like app.mount() in FastAPI."""
|
||||
return TestClient(Starlette(routes=[Mount("/replay", app=sub)]))
|
||||
|
||||
|
||||
def _cfg(page: str) -> dict:
|
||||
line = next(l for l in page.splitlines() if l.startswith("render("))
|
||||
return json.loads(line[line.index(",") + 1 : line.rindex(")")].strip())
|
||||
|
||||
|
||||
def test_page_mounts_the_view_relative_to_the_mount(traces):
|
||||
from praisonaiui.features.replay import create_replay_app
|
||||
|
||||
c = _host(create_replay_app())
|
||||
r = c.get("/replay/")
|
||||
assert r.status_code == 200
|
||||
assert 'from "/replay/replay.js"' in r.text
|
||||
cfg = _cfg(r.text)
|
||||
assert cfg == {"base": "/replay", "approve": False} # no run list pin, no Run box
|
||||
assert "aiui" not in r.text.lower() # no dashboard around it
|
||||
for name in ("replay.js", "replay-core.js"):
|
||||
js = c.get(f"/replay/{name}")
|
||||
assert js.status_code == 200 and js.headers["content-type"].startswith("text/javascript")
|
||||
|
||||
|
||||
def test_page_pins_a_valid_run_and_ignores_a_bad_one(traces):
|
||||
from praisonaiui.features.replay import create_replay_app
|
||||
|
||||
c = _host(create_replay_app())
|
||||
assert _cfg(c.get("/replay/?run=run-abc").text)["run"] == "run-abc"
|
||||
page = c.get("/replay/?run=../../etc/passwd").text
|
||||
assert "run" not in _cfg(page) and "passwd" not in page
|
||||
|
||||
|
||||
def test_api_routes_answer_under_the_mount(traces):
|
||||
from praisonaiui.features.replay import create_replay_app
|
||||
|
||||
shutil.copy(FIXTURES / "team_handoff.jsonl", traces / "team_handoff.jsonl")
|
||||
c = _host(create_replay_app())
|
||||
rows = c.get("/replay/api/replay/runs").json()
|
||||
assert [r["run_id"] for r in rows] == ["team_handoff"]
|
||||
assert c.get("/replay/api/replay/runs/team_handoff/events").json()["events"]
|
||||
|
||||
|
||||
def test_events_wait_param_answers_a_missing_run_without_404(traces):
|
||||
from praisonaiui.features.replay import create_replay_app
|
||||
|
||||
c = _host(create_replay_app())
|
||||
assert c.get("/replay/api/replay/runs/run-later/events").status_code == 404
|
||||
r = c.get("/replay/api/replay/runs/run-later/events?wait=1")
|
||||
assert r.status_code == 200 and r.json() == {"events": [], "graph": None, "run": None}
|
||||
|
||||
|
||||
def test_no_run_route_without_a_runner(traces):
|
||||
from praisonaiui.features.replay import create_replay_app
|
||||
|
||||
c = _host(create_replay_app())
|
||||
assert c.post("/replay/api/replay/run", json={"input": "x"}).status_code in (404, 405)
|
||||
|
||||
|
||||
def test_run_box_calls_the_runner_with_a_new_run_id(traces):
|
||||
from praisonaiui.features.replay import create_replay_app
|
||||
|
||||
calls = []
|
||||
|
||||
def runner(text, run_id):
|
||||
(traces / f"{run_id}.jsonl").write_text(json.dumps({"event_type": "session_start", "sequence_num": 0, "timestamp": time.time()}) + "\n")
|
||||
calls.append((text, run_id))
|
||||
|
||||
c = _host(create_replay_app(runner, input="solar sails", placeholder="Topic"))
|
||||
cfg = _cfg(c.get("/replay/").text)
|
||||
assert cfg["runner"] == {"input": "solar sails", "placeholder": "Topic", "ask": True}
|
||||
r = c.post("/replay/api/replay/run", json={"input": "comets"})
|
||||
assert r.status_code == 200
|
||||
run_id = r.json()["run_id"]
|
||||
assert (traces / f"{run_id}.jsonl").is_file(), "answers once the trace exists"
|
||||
for _ in range(100):
|
||||
if calls:
|
||||
break
|
||||
time.sleep(0.01)
|
||||
assert calls == [("comets", run_id)]
|
||||
assert run_id.startswith("run-") and len(run_id) == 16
|
||||
|
||||
|
||||
def test_run_box_without_input_box(traces):
|
||||
from praisonaiui.features.replay import create_replay_app
|
||||
|
||||
cfg = _cfg(_host(create_replay_app(lambda t, r: None, input=None)).get("/replay/").text)
|
||||
assert cfg["runner"]["ask"] is False
|
||||
|
||||
|
||||
def test_run_rejects_non_json_bad_input_and_a_second_run(traces):
|
||||
from praisonaiui.features.replay import MAX_RUN_INPUT, create_replay_app
|
||||
|
||||
gate = threading.Event()
|
||||
c = _host(create_replay_app(lambda text, run_id: gate.wait(5)))
|
||||
assert c.post("/replay/api/replay/run", data="input=x", headers={"content-type": "application/x-www-form-urlencoded"}).status_code == 415
|
||||
assert c.post("/replay/api/replay/run", content="input=x", headers={"content-type": "text/plain"}).status_code == 415
|
||||
assert c.post("/replay/api/replay/run", json={"input": 3}).status_code == 400
|
||||
assert c.post("/replay/api/replay/run", json={"input": "x" * (MAX_RUN_INPUT + 1)}).status_code == 400
|
||||
assert c.post("/replay/api/replay/run", json={"input": "a"}).status_code == 200
|
||||
busy = c.post("/replay/api/replay/run", json={"input": "b"})
|
||||
assert busy.status_code == 409 and "already" in busy.json()["error"]
|
||||
gate.set()
|
||||
for _ in range(100): # the lock is released when the runner returns
|
||||
r = c.post("/replay/api/replay/run", json={"input": "c"})
|
||||
if r.status_code == 200:
|
||||
break
|
||||
time.sleep(0.02)
|
||||
assert r.status_code == 200
|
||||
|
||||
|
||||
def test_a_failing_runner_frees_the_run_box(traces, caplog):
|
||||
from praisonaiui.features.replay import create_replay_app
|
||||
|
||||
def boom(text, run_id):
|
||||
raise RuntimeError("no key")
|
||||
|
||||
c = _host(create_replay_app(boom))
|
||||
assert c.post("/replay/api/replay/run", json={"input": ""}).status_code == 200
|
||||
for _ in range(100):
|
||||
r = c.post("/replay/api/replay/run", json={"input": ""})
|
||||
if r.status_code == 200:
|
||||
break
|
||||
time.sleep(0.02)
|
||||
assert r.status_code == 200
|
||||
|
||||
|
||||
def test_async_runner_is_awaited(traces):
|
||||
from praisonaiui.features.replay import create_replay_app
|
||||
|
||||
done = threading.Event()
|
||||
|
||||
async def runner(text, run_id):
|
||||
done.set()
|
||||
|
||||
c = _host(create_replay_app(runner))
|
||||
assert c.post("/replay/api/replay/run", json={"input": ""}).status_code == 200
|
||||
assert done.wait(3)
|
||||
@@ -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
|
||||
|
||||
@@ -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)."""
|
||||
|
||||
@@ -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()
|
||||
@@ -1,5 +1,5 @@
|
||||
version = 1
|
||||
revision = 3
|
||||
revision = 5
|
||||
requires-python = ">=3.10"
|
||||
resolution-markers = [
|
||||
"python_full_version >= '3.14' and sys_platform == 'win32'",
|
||||
@@ -207,7 +207,7 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "aiui"
|
||||
version = "0.3.147"
|
||||
version = "0.3.153"
|
||||
source = { editable = "." }
|
||||
dependencies = [
|
||||
{ name = "pydantic" },
|
||||
|
||||
Reference in new issue
Block a user