From 7037cf7bd34d1246895b5a0aaf78e212ad81c58d Mon Sep 17 00:00:00 2001 From: MervinPraison <454862+MervinPraison@users.noreply.github.com> Date: Mon, 26 Oct 2026 15:46:54 +0000 Subject: [PATCH] feat(replay): embeddable Replay app, Run box, Play, and a map that follows the cursor MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - create_replay_app(run=None): the Replay view as a small ASGI app (no dashboard) to mount in any FastAPI/Starlette app; /?run= pins one run (iframe-friendly). With run(input, run_id) it shows a Run box: POST /api/replay/run (JSON only, one run at a time) starts a run and the page follows it live. - render(el, {base, run, runner, approve}) so the view works under a mount prefix. - Map nodes show their state at the cursor: not reached / running / done / failed / skipped, and "×N" counts real runs instead of events (a step that ran once no longer reads "×2"). Scrubbing or Play rewinds the map with the cursor. - ▶ Play steps through a run on its own. - events?wait=1 answers a not-yet-started run without a 404. - Node names stay whole (squeezed or clipped) so accessible names contain the visible text (WCAG 2.5.3; axe flagged truncated tool names). Co-Authored-By: Claude Opus 5.5 (1M context) --- docs/features/replay.md | 83 +++++- src/praisonaiui/features/replay.py | 148 +++++++++- .../frontend/plugins/views/replay-core.js | 52 +++- .../frontend/plugins/views/replay.js | 262 +++++++++++++++--- tests/browser/test_replay_browser.py | 56 ++++ tests/js/replay-core.test.mjs | 59 ++++ tests/unit/test_replay_embed.py | 166 +++++++++++ 7 files changed, 776 insertions(+), 50 deletions(-) create mode 100644 tests/unit/test_replay_embed.py diff --git a/docs/features/replay.md b/docs/features/replay.md index 01fad86..69a878b 100644 --- a/docs/features/replay.md +++ b/docs/features/replay.md @@ -32,13 +32,83 @@ aiui run "$(python -c 'import praisonaiui.replay_app as m; print(m.__file__)')" `/#/replay?run=` 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 ` +``` + +| URL | Shows | +|---|---| +| `/replay/` | The run list and the selected run | +| `/replay/?run=` | 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 +
+ +``` + +`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 diff --git a/src/praisonaiui/features/replay.py b/src/praisonaiui/features/replay.py index 6e2a0a3..67ea5ce 100644 --- a/src/praisonaiui/features/replay.py +++ b/src/praisonaiui/features/replay.py @@ -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 @@ 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 @@ async def _runs(self, request: Request) -> JSONResponse: 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 @@ async def gen(): 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 ( + "\n\n\n\n" + "\n" + f"{html.escape(title)}\n" + f"\n" + "\n" + "\n\n
\n" + "\n\n\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()) + #