mirror of
https://github.com/MervinPraison/PraisonAIUI.git
synced 2026-11-11 11:41:30 -10:00
Compare commits
30
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
afe1f026fe | ||
|
|
374c8a4eb7 | ||
|
|
98c89f9a87 | ||
|
|
3cb68b661e | ||
|
|
772dacc8f7 | ||
|
|
b8f26f5f53 | ||
|
|
487efb8a34 | ||
|
|
81342a98df | ||
|
|
0073018a7a | ||
|
|
0825601c66 | ||
|
|
29ffce9405 | ||
|
|
78493e0e17 | ||
|
|
bf5415555b | ||
|
|
e69a06c2d5 | ||
|
|
085bdd3793 | ||
|
|
93abaac212 | ||
|
|
f2ecc8e39e | ||
|
|
e86b187246 | ||
|
|
1255f44105 | ||
|
|
7002e956fb | ||
|
|
ebbb5f863e | ||
|
|
34a01c1738 | ||
|
|
0d2b7b1d09 | ||
|
|
4ab1eee24f | ||
|
|
1dad99b2c8 | ||
|
|
a89b00da5b | ||
|
|
706c934629 | ||
|
|
7a22011892 | ||
|
|
7037cf7bd3 | ||
|
|
bf98d29a38 |
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
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
[project]
|
||||
name = "aiui"
|
||||
version = "0.3.147"
|
||||
version = "0.3.152"
|
||||
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.152"
|
||||
@@ -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)
|
||||
@@ -373,6 +373,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 +385,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 +405,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"}
|
||||
@@ -1303,9 +1317,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 +1739,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 +3207,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 +3314,7 @@ def create_app(
|
||||
"group": "Agent",
|
||||
"description": "AI agent chat",
|
||||
"order": 1,
|
||||
"layout": "full-bleed",
|
||||
},
|
||||
{
|
||||
"id": "chat-canvas",
|
||||
@@ -3306,6 +3323,7 @@ def create_app(
|
||||
"group": "Agent",
|
||||
"description": "Chat with live A2UI preview panel",
|
||||
"order": 2,
|
||||
"layout": "full-bleed",
|
||||
},
|
||||
{
|
||||
"id": "canvas",
|
||||
|
||||
@@ -367,7 +367,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 +380,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,8 +466,9 @@ 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. */
|
||||
@@ -1810,6 +1816,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 +1934,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 +1947,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);
|
||||
@@ -1976,9 +1984,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 +2002,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">
|
||||
@@ -2310,7 +2324,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 +2342,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 +3044,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 +3090,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 +3193,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;
|
||||
|
||||
|
||||
@@ -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
|
||||
@@ -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)
|
||||
@@ -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.152"
|
||||
source = { editable = "." }
|
||||
dependencies = [
|
||||
{ name = "pydantic" },
|
||||
|
||||
Reference in new issue
Block a user