Compare commits

...
Author SHA1 Message Date
MervinPraisonandClaude Opus 5.5 ef5ed0ae62 fix: guard session rail against non-array session payloads
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-04 12:20:19 +00:00
MervinPraisonandClaude Opus 5.5 42c0a48640 fix: add in-rail session search to chat sidebar (fixes #324)
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-04 11:45:03 +00:00
Mervin PraisonandClaude Opus 5.5 7002e956fb ci: bump github-automation-template to v1.1.20 (#320)
Co-authored-by: Claude Opus 5.5 (1M context) <[email protected]>
2026-10-29 02:38:22 +01:00
MervinPraison ebbb5f863e Release v0.3.149 2026-10-27 10:17:57 +00:00
Mervin Praison 34a01c1738 Merge pull request #318 from MervinPraison/fix/replay-unnamed-agents
fix(replay): map and narrate unnamed agents (Agent(instructions=...))
2026-10-27 11:14:51 +01:00
Mervin Praison 0d2b7b1d09 Merge branch 'main' into fix/replay-unnamed-agents 2026-10-27 11:13:04 +01:00
Mervin Praison 4ab1eee24f Merge pull request #319 from MervinPraison/fix/frontend-typecheck
fix(frontend): make the dashboard build pass again (26 type errors since April)
2026-10-27 11:12:54 +01:00
MervinPraisonandClaude Opus 5.5 1dad99b2c8 fix(frontend): make the dashboard build pass again (26 type errors since April)
The frontend build gate added on 2026-09-23 (166091b) has failed on every
main run since: five April PRs left code that never compiled — imports of
shadcn components that were never added (ui/alert, ui/textarea), seven
example widgets that never existed (CustomElementHost), react-router-dom
(not a dependency), a useSSE call written for a different hook, a wrong
ChatMessages prop set, and unused locals under noUnusedLocals.

- add the standard shadcn alert and textarea components
- CustomElementHost: empty registry instead of imports of missing widgets
- useWindowMessage: small local EventSource hook for /sse/window-message
- SharedThread: read the token from /shared/{token}; pass the props
  ChatMessages takes; type messages as ChatMessage[]
- drop unused imports/locals

None of these files are imported by the app today, so nothing users see
changes; the gate now builds and catches real breakage again.

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-10-27 10:08:44 +00:00
MervinPraisonandClaude Opus 5.5 a89b00da5b fix(replay): map and narrate unnamed agents (Agent(instructions=...))
An agent created without name= records agent_name null. The Replay map
skipped every such event, so watch(Agent(instructions=...)) showed
"Nothing to map yet" and its steps were narrated as "The run".

- agentOf(ev): agent_name, else the package's id (agent:Agent from the
  companion core fix), else tool:None/<tool> or nothing in 4.7.19 traces,
  falling back to "Agent" for an agent's own run events.
- deriveGraph / nodeIdsForEvent / narrate use it, so the agent and its tools
  are drawn and lit, and steps read "Agent started.".
- Only when no graph was recorded: a team graph leaves unnamed agents out on
  purpose, and they must not reappear as "Outside the team".

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-10-27 09:46:43 +00:00
MervinPraison 706c934629 Release v0.3.148 2026-10-26 19:36:13 +00:00
Mervin Praison 7a22011892 Merge pull request #317 from MervinPraison/feat/replay-embed
feat(replay): embeddable Replay app, Run box, Play, and a map that follows the cursor
2026-10-26 17:35:07 +01:00
MervinPraisonandClaude Opus 5.5 7037cf7bd3 feat(replay): embeddable Replay app, Run box, Play, and a map that follows the cursor
- create_replay_app(run=None): the Replay view as a small ASGI app (no dashboard)
  to mount in any FastAPI/Starlette app; /?run=<id> 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) <[email protected]>
2026-10-26 15:46:54 +00:00
Mervin Praison bf98d29a38 Merge pull request #316 from MervinPraison/feat/replay-view
feat(replay): PraisonAI Replay page (timeline, map, live, why, approvals) + trace-reader and UI-approval fixes
2026-10-26 14:44:22 +01:00
30 changed files with 1088 additions and 176 deletions

No files matched your search

+2 -2
View File
@@ -27,7 +27,7 @@ jobs:
permissions:
issues: write
pull-requests: write
uses: MervinPraison/github-automation-template/.github/workflows/[email protected].19
uses: MervinPraison/github-automation-template/.github/workflows/[email protected].20
secrets: inherit
with:
hub_ref: v1.1.19
hub_ref: v1.1.20
+2 -2
View File
@@ -16,8 +16,8 @@ permissions:
jobs:
recover:
uses: MervinPraison/github-automation-template/.github/workflows/[email protected].19
uses: MervinPraison/github-automation-template/.github/workflows/[email protected].20
secrets: inherit
with:
hub_ref: v1.1.19
hub_ref: v1.1.20
pr_number: ${{ github.event.inputs.pr_number || '' }}
+2 -2
View File
@@ -25,8 +25,8 @@ permissions:
jobs:
detect-and-trigger:
uses: MervinPraison/github-automation-template/.github/workflows/[email protected].19
uses: MervinPraison/github-automation-template/.github/workflows/[email protected].20
secrets: inherit
with:
hub_ref: v1.1.19
hub_ref: v1.1.20
pr_number: ${{ github.event.inputs.pr_number || '' }}
+4 -4
View File
@@ -43,9 +43,9 @@ jobs:
id-token: write
steps:
- uses: actions/checkout@v4
- uses: MervinPraison/github-automation-template/.github/actions/[email protected].19
- uses: MervinPraison/github-automation-template/.github/actions/[email protected].20
with:
hub_ref: v1.1.19
hub_ref: v1.1.20
- name: Generate GitHub App token
id: app-token
uses: actions/create-github-app-token@v1
@@ -88,9 +88,9 @@ jobs:
actions: read
id-token: write
statuses: write # the assess job records security/outsider-review
uses: MervinPraison/github-automation-template/.github/workflows/[email protected].19
uses: MervinPraison/github-automation-template/.github/workflows/[email protected].20
secrets: inherit
with:
hub_ref: v1.1.19
hub_ref: v1.1.20
pr_number: ${{ github.event.inputs.pr_number || github.event.client_payload.pr_number || '' }}
merge_method: ${{ github.event.inputs.merge_method || 'merge' }}
+2 -2
View File
@@ -18,7 +18,7 @@ jobs:
issues: write
actions: read
id-token: write
uses: MervinPraison/github-automation-template/.github/workflows/[email protected].19
uses: MervinPraison/github-automation-template/.github/workflows/[email protected].20
secrets: inherit
with:
hub_ref: v1.1.19
hub_ref: v1.1.20
+2 -2
View File
@@ -25,9 +25,9 @@ jobs:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: MervinPraison/github-automation-template/.github/actions/[email protected].19
- uses: MervinPraison/github-automation-template/.github/actions/[email protected].20
with:
hub_ref: v1.1.19
hub_ref: v1.1.20
- name: Detect conflicting PRs and trigger Claude
uses: actions/github-script@v7
with:
+2 -2
View File
@@ -13,7 +13,7 @@ permissions:
jobs:
sync:
uses: MervinPraison/github-automation-template/.github/workflows/[email protected].19
uses: MervinPraison/github-automation-template/.github/workflows/[email protected].20
secrets: inherit
with:
hub_ref: v1.1.19
hub_ref: v1.1.20
+78 -5
View File
@@ -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
View File
@@ -1,6 +1,6 @@
[project]
name = "aiui"
version = "0.3.147"
version = "0.3.149"
description = "YAML-driven website generator - CLI and compiler"
readme = "README.md"
license = {text = "MIT"}
+1 -1
View File
@@ -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)) {
+1 -1
View File
@@ -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"
+2 -2
View File
@@ -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}
/>
+7 -34
View File
@@ -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
-1
View File
@@ -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'
+44
View File
@@ -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 }
+1 -1
View File
@@ -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);
+28 -3
View File
@@ -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 {
+9 -11
View File
@@ -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 -1
View File
@@ -1,3 +1,3 @@
"""Version information."""
__version__ = "0.3.147"
__version__ = "0.3.149"
+145 -3
View File
@@ -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)
@@ -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;
@@ -230,6 +231,8 @@ 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); }
@@ -812,6 +815,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 +898,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 +1047,61 @@ 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));
list.appendChild(el);
});
}
async function loadSession(sessionId) {
currentSessionId = sessionId;
refreshStreamingUI();
@@ -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…`;
+56
View File
@@ -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)
+110
View File
@@ -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,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
+166
View File
@@ -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)
Generated
+2 -2
View File
@@ -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.149"
source = { editable = "." }
dependencies = [
{ name = "pydantic" },