mirror of
https://github.com/MervinPraison/PraisonAIUI.git
synced 2026-11-07 05:56:17 -10:00
Compare commits
61
Commits
No files matched your search
@@ -3,7 +3,7 @@
|
||||
"stack": "claude",
|
||||
"profile": "sdk",
|
||||
"mode": "hub",
|
||||
"hubRef": "v1.1.8",
|
||||
"hubRef": "v1.1.11",
|
||||
"templateVersion": "1.1.7",
|
||||
"installedAt": "2026-09-07T10:02:11Z"
|
||||
}
|
||||
@@ -10,7 +10,7 @@ on:
|
||||
pull_request_target:
|
||||
types: [opened, synchronize]
|
||||
schedule:
|
||||
- cron: '30 3,9,15,21 * * *'
|
||||
- cron: '30 * * * *'
|
||||
workflow_dispatch:
|
||||
|
||||
env:
|
||||
@@ -27,7 +27,7 @@ jobs:
|
||||
permissions:
|
||||
issues: write
|
||||
pull-requests: write
|
||||
uses: MervinPraison/github-automation-template/.github/workflows/[email protected].8
|
||||
uses: MervinPraison/github-automation-template/.github/workflows/[email protected].20
|
||||
secrets: inherit
|
||||
with:
|
||||
hub_ref: v1.1.8
|
||||
hub_ref: v1.1.20
|
||||
@@ -16,8 +16,8 @@ permissions:
|
||||
|
||||
jobs:
|
||||
recover:
|
||||
uses: MervinPraison/github-automation-template/.github/workflows/[email protected].8
|
||||
uses: MervinPraison/github-automation-template/.github/workflows/[email protected].20
|
||||
secrets: inherit
|
||||
with:
|
||||
hub_ref: v1.1.8
|
||||
hub_ref: v1.1.20
|
||||
pr_number: ${{ github.event.inputs.pr_number || '' }}
|
||||
@@ -25,8 +25,8 @@ permissions:
|
||||
|
||||
jobs:
|
||||
detect-and-trigger:
|
||||
uses: MervinPraison/github-automation-template/.github/workflows/[email protected].8
|
||||
uses: MervinPraison/github-automation-template/.github/workflows/[email protected].20
|
||||
secrets: inherit
|
||||
with:
|
||||
hub_ref: v1.1.8
|
||||
hub_ref: v1.1.20
|
||||
pr_number: ${{ github.event.inputs.pr_number || '' }}
|
||||
@@ -36,6 +36,30 @@ jobs:
|
||||
- name: Integration smoke tests
|
||||
run: pytest tests/integration/test_agentic_roundtrip.py tests/test_feature_sdk_backends.py -q --no-cov --timeout=120
|
||||
|
||||
frontend:
|
||||
name: frontend
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Set up Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: "22"
|
||||
cache: npm
|
||||
cache-dependency-path: src/frontend/package-lock.json
|
||||
|
||||
- name: Install dependencies
|
||||
run: npm ci
|
||||
working-directory: src/frontend
|
||||
|
||||
# `npm run build` is the dashboard ship path: dist/ is synced to
|
||||
# src/praisonaiui/templates/frontend/ (see src/frontend/README.md).
|
||||
- name: Build frontend
|
||||
run: npm run build
|
||||
working-directory: src/frontend
|
||||
|
||||
docs:
|
||||
runs-on: ubuntu-latest
|
||||
if: github.ref == 'refs/heads/main'
|
||||
|
||||
@@ -43,9 +43,9 @@ jobs:
|
||||
id-token: write
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: MervinPraison/github-automation-template/.github/actions/[email protected].8
|
||||
- uses: MervinPraison/github-automation-template/.github/actions/[email protected].20
|
||||
with:
|
||||
hub_ref: v1.1.8
|
||||
hub_ref: v1.1.20
|
||||
- name: Generate GitHub App token
|
||||
id: app-token
|
||||
uses: actions/create-github-app-token@v1
|
||||
@@ -87,9 +87,10 @@ jobs:
|
||||
issues: write
|
||||
actions: read
|
||||
id-token: write
|
||||
uses: MervinPraison/github-automation-template/.github/workflows/[email protected]
|
||||
statuses: write # the assess job records security/outsider-review
|
||||
uses: MervinPraison/github-automation-template/.github/workflows/[email protected]
|
||||
secrets: inherit
|
||||
with:
|
||||
hub_ref: v1.1.8
|
||||
hub_ref: v1.1.20
|
||||
pr_number: ${{ github.event.inputs.pr_number || github.event.client_payload.pr_number || '' }}
|
||||
merge_method: ${{ github.event.inputs.merge_method || 'merge' }}
|
||||
@@ -18,7 +18,7 @@ jobs:
|
||||
issues: write
|
||||
actions: read
|
||||
id-token: write
|
||||
uses: MervinPraison/github-automation-template/.github/workflows/[email protected].8
|
||||
uses: MervinPraison/github-automation-template/.github/workflows/[email protected].20
|
||||
secrets: inherit
|
||||
with:
|
||||
hub_ref: v1.1.8
|
||||
hub_ref: v1.1.20
|
||||
@@ -25,9 +25,9 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: MervinPraison/github-automation-template/.github/actions/[email protected].8
|
||||
- uses: MervinPraison/github-automation-template/.github/actions/[email protected].20
|
||||
with:
|
||||
hub_ref: v1.1.8
|
||||
hub_ref: v1.1.20
|
||||
- name: Detect conflicting PRs and trigger Claude
|
||||
uses: actions/github-script@v7
|
||||
with:
|
||||
|
||||
@@ -13,7 +13,7 @@ permissions:
|
||||
|
||||
jobs:
|
||||
sync:
|
||||
uses: MervinPraison/github-automation-template/.github/workflows/[email protected].8
|
||||
uses: MervinPraison/github-automation-template/.github/workflows/[email protected].20
|
||||
secrets: inherit
|
||||
with:
|
||||
hub_ref: v1.1.8
|
||||
hub_ref: v1.1.20
|
||||
@@ -366,3 +366,7 @@ pytest tests -v
|
||||
## License
|
||||
|
||||
MIT © [Praison Limited](https://praison.ai)
|
||||
|
||||
Third-party vendored source is documented in [THIRD_PARTY_NOTICES.md](THIRD_PARTY_NOTICES.md).
|
||||
See the [BeautifulUI upgrade playbook](docs/frontend/beautifului.md) for how BeautifulUI
|
||||
primitives are vendored and pinned.
|
||||
+37
@@ -0,0 +1,37 @@
|
||||
# Security Policy
|
||||
|
||||
We take the security of PraisonAIUI seriously and appreciate responsible
|
||||
disclosure of vulnerabilities.
|
||||
|
||||
## Supported Versions
|
||||
|
||||
Security fixes are applied to the latest released version of PraisonAIUI.
|
||||
Please make sure you are on the most recent release before reporting.
|
||||
|
||||
## Reporting a Vulnerability
|
||||
|
||||
**Please do not open a public issue for security vulnerabilities.**
|
||||
|
||||
Report privately through GitHub's **Private Vulnerability Reporting**:
|
||||
|
||||
1. Go to the [Security tab](https://github.com/MervinPraison/PraisonAIUI/security)
|
||||
of this repository.
|
||||
2. Click **"Report a vulnerability"** to open a private advisory.
|
||||
3. Include as much detail as you can:
|
||||
- Affected component(s) and version.
|
||||
- Steps to reproduce.
|
||||
- Impact assessment.
|
||||
- Any suggested fix or mitigation.
|
||||
|
||||
If you are unable to use private reporting, you may open a public issue that
|
||||
contains **no technical specifics** and requests a private channel, and a
|
||||
maintainer will follow up.
|
||||
|
||||
## What to Expect
|
||||
|
||||
- We aim to acknowledge new reports within a few business days.
|
||||
- We will keep you informed as we investigate and work on a fix.
|
||||
- Once a fix is released, we are happy to credit you in the advisory (unless
|
||||
you prefer to remain anonymous).
|
||||
|
||||
Thank you for helping keep PraisonAIUI and its users safe.
|
||||
@@ -0,0 +1,43 @@
|
||||
# Third-Party Notices
|
||||
|
||||
PraisonAIUI incorporates or vendors source from the third-party projects listed below.
|
||||
Their licences and copyright notices are retained here as required.
|
||||
|
||||
---
|
||||
|
||||
## BeautifulUI
|
||||
|
||||
- Project: BeautifulUI — <https://www.beautifului.dev/>
|
||||
- Source: <https://github.com/slev12397/beautiful-ui>
|
||||
- Licence: MIT
|
||||
|
||||
BeautifulUI is distributed as a shadcn registry / copy-paste library (no npm package).
|
||||
When its React primitives are vendored into `src/frontend`, the following MIT licence
|
||||
applies to that copied source.
|
||||
|
||||
```
|
||||
MIT License
|
||||
|
||||
Copyright (c) Shane Levine
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
```
|
||||
|
||||
See the upgrade playbook at [`docs/frontend/beautifului.md`](docs/frontend/beautifului.md)
|
||||
for how BeautifulUI is vendored and pinned.
|
||||
@@ -59,6 +59,11 @@ navigation:
|
||||
- typescript/runtime
|
||||
- typescript/nextjs
|
||||
- typescript/components
|
||||
- group: "Frontend"
|
||||
icon: "layout"
|
||||
prefix: "frontend"
|
||||
pages:
|
||||
- frontend/beautifului
|
||||
- group: "Examples"
|
||||
icon: "folder-open"
|
||||
prefix: "examples"
|
||||
|
||||
@@ -0,0 +1,94 @@
|
||||
# ADR 0001: PraisonAI Replay view, a clean-room design
|
||||
|
||||
- **Status:** accepted
|
||||
- **Date:** 2026-10-26
|
||||
- **Scope:** `src/praisonaiui/features/replay.py`, `features/_trace_jsonl.py`,
|
||||
`templates/frontend/plugins/views/replay-core.js`, `templates/frontend/plugins/views/replay.js`
|
||||
|
||||
## Context
|
||||
|
||||
PraisonAI records agent runs as context-trace JSONL (one `ContextEvent.to_dict()` per
|
||||
line). People need to see what a team or flow did: who did what, when, why, and at what
|
||||
cost, both after the fact and live. Other tools have shown that a graph plus a trace is a
|
||||
useful way to look at agent runs. We want that value without copying anyone's product,
|
||||
code or look, and without adding dependencies.
|
||||
|
||||
## Decision
|
||||
|
||||
Build our own view, in plain DOM and SVG, with our own layout code, palette and words.
|
||||
|
||||
### Ideas we took (ideas only)
|
||||
|
||||
| Idea | Where it came from | What we did with it |
|
||||
|---|---|---|
|
||||
| Show the graph of a multi-agent program next to the trace of a run | The general concept behind LangGraph Studio's graph-plus-trace view, known from public talks and articles | A **Map** view drawn by our own layered layout (`layoutMap`); the default view is a **Timeline**, which Studio does not have |
|
||||
| Parallel voices on shared time, read left to right | Musical scores and swimlane diagrams | One lane per agent plus Manager and You; parallel branches as sub-lanes; tasks and steps as "chapters" across the top |
|
||||
| Vocabulary for LLM and tool spans, tokens and cost | OpenTelemetry GenAI semantic conventions (public specification) | Block kinds (LLM, tool), token and cost totals; the wrapper's OTLP export uses the conventions, the UI only borrows the concepts |
|
||||
|
||||
### What we did not use
|
||||
|
||||
- **No third-party UI code, CSS, icons, layouts or screenshots** were used to build this
|
||||
view. Shapes, palette, spacing, wording ("Brief", "Delivered", "Waiting for you") and
|
||||
the layout algorithm are our own.
|
||||
- **LangGraph's server (Elastic License 2.0) and the proprietary LangGraph Studio were
|
||||
not consulted for code**, and none of their assets, markup or styles were copied. We
|
||||
do not use the name "Studio", any "Lang…" naming, or `__start__`/`__end__` pills.
|
||||
- No Mermaid, D3, React Flow, dagre, ELK or other graph libraries. The dashboard's
|
||||
existing Mermaid CDN plugin is not used by Replay.
|
||||
|
||||
### How it is built
|
||||
|
||||
- `replay-core.js`: pure functions (`buildModel`, `narrate`, `layoutMap`,
|
||||
`deriveGraph`). No DOM, no network. Tested with `node --test`.
|
||||
- `replay.js`: DOM/SVG rendering, keyboard handling, SSE follow and approvals. It also
|
||||
boots by itself when `window.__REPLAY_DATA__ = {events, graph}` is set, which is how
|
||||
the wrapper's single-file HTML export works (`standalone_html()` inlines both files).
|
||||
- `layoutMap` is a textbook layered layout written from scratch: depth-first search to
|
||||
find back edges (loops), longest-path layering, one virtual waypoint per crossed layer
|
||||
for long edges, and a few barycentre sweeps to order each layer. It is deterministic.
|
||||
- Narration is template text, chosen per event type. No LLM, no randomness.
|
||||
|
||||
## Palette and accessibility rationale
|
||||
|
||||
| Role | Fill | Shape | Why |
|
||||
|---|---|---|---|
|
||||
| Agent | `#8B0000` | circle | PraisonAI's brand red; white text on it is 10.0:1 |
|
||||
| Tool | `#0F7287` | rounded rectangle | Teal kept from the brand but darkened from `#189AB4` (white text on `#189AB4` fails AA); white on `#0F7287` is 5.6:1 |
|
||||
| You (human) | `#8A5A00` | diamond | Amber distinct from red and teal for common colour-vision deficiencies; white on it is 5.9:1 |
|
||||
| Manager | `#3C4A5C` | square | Neutral slate, so the manager reads as coordination, not work; white on it is 9.0:1 |
|
||||
|
||||
- **Never colour alone.** Every lane, block, node and step has a shape and a text label;
|
||||
arrows differ by dash pattern and carry words ("hands off", "delegates", "routes").
|
||||
Heat in the cost/latency overlay is a bar whose height and shade both grow, plus the
|
||||
value in text.
|
||||
- **Theme.** Follows the system setting (`prefers-color-scheme`) by default, with a
|
||||
Light/Dark override; the light theme is the design baseline.
|
||||
- **Light theme first.** Text tokens are at least 6.9:1 on their backgrounds in light mode
|
||||
and 7.7:1 in dark mode. In dark mode the palette fills keep white text at AA, and get a
|
||||
light 2 px outline so each shape's boundary stays above 3:1 against the dark page.
|
||||
- **Keyboard throughout.** ← → (and ↑ ↓ in the step list) move the replay cursor, Home
|
||||
and End jump to the ends, tabs follow the ARIA tabs pattern, map nodes use a roving
|
||||
tab stop, and every action has a real button.
|
||||
- **Screen readers.** The narration column is a real ordered list and is the main way
|
||||
through a run. Timeline blocks and map nodes are named buttons whose names start with
|
||||
their visible text (WCAG 2.5.3). Live updates are announced through a polite live
|
||||
region, throttled.
|
||||
- **Motion.** The only animation (the live dot) runs only under
|
||||
`prefers-reduced-motion: no-preference`; scrolling is instant when motion is reduced.
|
||||
- Checked with axe-core against WCAG 2.0, 2.1 and 2.2 A/AA in both themes (dev-only test
|
||||
in `tests/browser/`).
|
||||
|
||||
## Dependency licences
|
||||
|
||||
No new runtime or build dependencies. The Python side uses only the standard library and
|
||||
Starlette, which aiui already depends on. The browser side is plain ES modules with no
|
||||
imports beyond our own file. Dev-only test tools that are not shipped: Playwright
|
||||
(Apache-2.0, already used in this environment) and axe-core (MPL-2.0, loaded from a path
|
||||
given by `AXE_CORE_JS`; not vendored).
|
||||
|
||||
## Consequences
|
||||
|
||||
- We own the look and the layout code, and can change them without licence questions.
|
||||
- The layout is simpler than a dedicated graph engine. It is fine for the team and flow
|
||||
sizes we see (tens of nodes). Very large graphs may need a better crossing-reduction
|
||||
step later.
|
||||
@@ -0,0 +1,56 @@
|
||||
# BeautifulUI Integration Strategy
|
||||
|
||||
> **Epic:** [#292](https://github.com/MervinPraison/PraisonAIUI/issues/292) — BeautifulUI integration strategy for AI-native dashboard UX.
|
||||
> This is the durable, agreed strategy that the linked child issues execute against.
|
||||
|
||||
[BeautifulUI](https://www.beautifului.dev/) (MIT, Shane Levine / Turbo) is a **copy-paste / shadcn-registry** library of React primitives for AI-native interfaces: thinking traces, streaming answers with inline sources, HITL approval cards, tool chips, task rows, context chunks, diff/records tables, prompt bars, and a reference chat harness.
|
||||
|
||||
There is **no published npm package** named `beautiful-ui`. It ships only as source consumed via the shadcn registry or manual copy.
|
||||
|
||||
## 1. Strategic decision
|
||||
|
||||
BeautifulUI is adopted as **vendored source**, not a runtime dependency, and is exposed to dashboard plugins **only through the existing `@praisonaiui/react` package** — never by importing upstream file paths directly. Rollout is **phased behind a feature flag**, keeping the current vanilla dashboard as the fallback until parity is proven.
|
||||
|
||||
Upstream stack (Next.js 15 · React 19 · Tailwind v4 · TypeScript) is closely aligned with PraisonAIUI's `src/frontend` (Vite · React 19 · Tailwind v4 · shadcn/Radix), so primitives port with adapter work rather than a runtime bridge.
|
||||
|
||||
## 2. Pillars
|
||||
|
||||
| # | Pillar | Decision |
|
||||
|---|--------|----------|
|
||||
| 1 | **Vendoring model** | Add primitives into `src/frontend` via `npx shadcn add https://www.beautifului.dev/r/<component>.json`, or manual copy from [github.com/slev12397/beautiful-ui](https://github.com/slev12397/beautiful-ui). Pin via lockfile plus a recorded registry URL / git SHA. No `beautiful-ui` npm dependency in production builds. |
|
||||
| 2 | **Adapter layer** | Map PraisonAIUI chat / approval / trace **protocol events** to stable internal props. Primitives sit behind `@praisonaiui/react` exports; dashboard plugins consume those exports, not upstream paths. See [#294](https://github.com/MervinPraison/PraisonAIUI/issues/294). |
|
||||
| 3 | **Hybrid rollout** | Mount React **islands** (or expand `@praisonaiui/react`) for high-value surfaces first — chat streaming/thinking/tools, then approvals. Vanilla chat (`templates/frontend/plugins/views/chat.js`) stays as the flag-gated fallback until parity is proven. |
|
||||
| 4 | **Token strategy** | Merge BeautifulUI foundation CSS **selectively** into the existing shadcn theme generation (`praisonaiui/themes.py`). One design system per page — avoid two competing token sets. |
|
||||
| 5 | **Icon licence** | Upstream `SidebarNav` uses the paid `@central-icons-react`. Port nav patterns with **lucide-react** (already in PraisonAIUI) instead. |
|
||||
|
||||
## 3. Phasing
|
||||
|
||||
1. **Spike** — evaluate primitives in the dev environment, confirm React 19 / Tailwind v4 alignment. [#293](https://github.com/MervinPraison/PraisonAIUI/issues/293)
|
||||
2. **Architecture** — adapter layer + upstream pin policy. [#294](https://github.com/MervinPraison/PraisonAIUI/issues/294)
|
||||
3. **CI gate** — frontend build + BeautifulUI compatibility check (no frontend build gate exists today). [#295](https://github.com/MervinPraison/PraisonAIUI/issues/295)
|
||||
4. **Migrations** — chat streaming / thinking / tool chips ([#296](https://github.com/MervinPraison/PraisonAIUI/issues/296)), then the approval card ([#297](https://github.com/MervinPraison/PraisonAIUI/issues/297)).
|
||||
5. **Docs** — upgrade playbook + MIT attribution. [#298](https://github.com/MervinPraison/PraisonAIUI/issues/298)
|
||||
|
||||
## 4. Risks
|
||||
|
||||
| Risk | Mitigation |
|
||||
|------|------------|
|
||||
| No semver npm package | Vendor the source; pin to a recorded registry URL / git SHA in the lockfile. |
|
||||
| Upstream breaking registry JSON | Vendoring decouples builds from live registry; re-sync is a deliberate, reviewed step. |
|
||||
| Icon licence (`@central-icons-react` is paid) | Use lucide-react for ported nav patterns; never vendor the paid package. |
|
||||
| Two competing design systems on one page | Selective CSS token merge into `themes.py`; adapter-only prop surface. |
|
||||
| React / Tailwind drift | Upstream (React 19 / Tailwind v4) already matches `src/frontend`; the CI gate catches drift. |
|
||||
|
||||
## 5. Out of scope
|
||||
|
||||
- Replacing the entire dashboard shell in one PR.
|
||||
- PyPI packaging of BeautifulUI.
|
||||
- iframe embedding of beautifului.dev.
|
||||
- Depending on any unpublished npm package for production builds.
|
||||
|
||||
## 6. Acceptance criteria status
|
||||
|
||||
- [x] Written integration strategy agreed (vendoring + adapter + phased rollout) — this document.
|
||||
- [x] Risks documented — section 4.
|
||||
- [x] Child issues filed and prioritised — [#293](https://github.com/MervinPraison/PraisonAIUI/issues/293)–[#298](https://github.com/MervinPraison/PraisonAIUI/issues/298).
|
||||
- [x] No requirement to depend on unpublished npm packages for production builds — pillar 1, out of scope.
|
||||
@@ -0,0 +1,176 @@
|
||||
# Replay
|
||||
|
||||
**PraisonAI Replay** shows a recorded agent run as a story: who did what, when, why, and
|
||||
at what cost. It works for finished runs and for runs that are still going.
|
||||
|
||||
## Record a run, then open it
|
||||
|
||||
```bash
|
||||
praisonai replay view agents.yaml # records the run and opens Replay on it
|
||||
```
|
||||
|
||||
or from Python:
|
||||
|
||||
```python
|
||||
from praisonai.replay import record
|
||||
|
||||
with record(live=True):
|
||||
team.start()
|
||||
```
|
||||
|
||||
Every recorded run is a file `<id>.jsonl` in the traces folder. Replay reads it from
|
||||
`$PRAISONAI_TRACES_DIR`, else `$PRAISON_HOME/traces`, else `~/.praison/traces`.
|
||||
|
||||
To open aiui on Replay yourself:
|
||||
|
||||
```bash
|
||||
aiui run "$(python -c 'import praisonaiui.replay_app as m; print(m.__file__)')" \
|
||||
--style dashboard --host 127.0.0.1 --port 8090
|
||||
# then open http://127.0.0.1:8090/replay?run=<id>
|
||||
```
|
||||
|
||||
`/#/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. 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. |
|
||||
|
||||
Shapes always carry meaning as well as colour: agents are circles, tools are rounded
|
||||
rectangles, you are a diamond and the manager is a square. In each lane, LLM calls sit on
|
||||
the top row and that agent's tool calls on a thinner row under them, so a long LLM call
|
||||
never hides its tools. Labels that do not fit end in "…"; hover or focus a block to see the
|
||||
whole label.
|
||||
|
||||
The **Theme** menu follows your system's light or dark setting by default; choose Light or
|
||||
Dark to override it. On a phone the run list, timeline and steps stack in one column.
|
||||
|
||||
## Moving through a run
|
||||
|
||||
Replay opens at the end of the run, so you see everything. Move the cursor to replay it:
|
||||
anything after the cursor is drawn as an outline.
|
||||
|
||||
| Key | Action |
|
||||
|---|---|
|
||||
| ← → | Previous / next step |
|
||||
| ↑ ↓ | Previous / next step in the step list; previous / next node in the map |
|
||||
| Home / End | First / last step |
|
||||
| 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, 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
|
||||
|
||||
When an agent asks for approval and the run uses aiui's approval store, a **Waiting for
|
||||
you** card appears above the timeline:
|
||||
|
||||
- **Approve** or **Deny**, with an optional reason.
|
||||
- **Edit arguments…** opens the arguments as JSON (taken from the server, not the redacted
|
||||
recording when the server has them). The JSON must be an object; mistakes are shown
|
||||
as you type. **Approve with these arguments** sends them as `modified_args`.
|
||||
|
||||
These buttons call `POST /api/approvals/{id}/approve` and `/deny`. The agent waits until
|
||||
you answer, and is denied if nobody answers before the approval timeout.
|
||||
|
||||
## Sharing a run as one file
|
||||
|
||||
`praisonai replay export RUN --format html` writes a single HTML file with the run and
|
||||
this viewer inside. It needs no server and no network. Approvals cannot be answered from
|
||||
an exported file.
|
||||
|
||||
## HTTP API
|
||||
|
||||
| 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`; `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`. |
|
||||
|
||||
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
|
||||
matching start and end events.
|
||||
@@ -0,0 +1,35 @@
|
||||
# BeautifulUI upstream pin record
|
||||
|
||||
BeautifulUI is vendored (shadcn-style registry copy), **not** an npm dependency.
|
||||
This file is the single source of truth for what upstream revision is vendored.
|
||||
Update it in the **same PR** that adds or bumps any vendored component, and make
|
||||
sure the frontend CI gate (issue #295) passes.
|
||||
|
||||
See [beautifului-adapter-architecture.md](./beautifului-adapter-architecture.md)
|
||||
for the adapter boundary and folder layout.
|
||||
|
||||
## Pin
|
||||
|
||||
| Field | Value |
|
||||
|-------|-------|
|
||||
| Upstream repo | _TBD — record on first vendor_ |
|
||||
| Git SHA | _TBD_ |
|
||||
| Vendored on (date) | _TBD_ |
|
||||
| Registry base URL | _TBD_ |
|
||||
| License | MIT (kept in-tree) |
|
||||
|
||||
## Vendored components
|
||||
|
||||
Record one row per component the first time it is copied in.
|
||||
|
||||
| Component | Registry URL | SHA | Vendored path | Date |
|
||||
|-----------|--------------|-----|---------------|------|
|
||||
| _(none yet)_ | | | | |
|
||||
|
||||
## Upgrade checklist
|
||||
|
||||
1. Update the SHA / date / registry rows above.
|
||||
2. Re-copy changed components into `src/frontend/src/agent-ui/vendors/beautifului/`.
|
||||
3. Keep all upstream imports inside `src/agent-ui/` (adapter boundary rule).
|
||||
4. Run the frontend build + adapter unit tests (frontend CI gate, #295).
|
||||
5. Do **not** add a `beautiful-ui` entry to `package.json` dependencies.
|
||||
@@ -0,0 +1,102 @@
|
||||
# ADR: BeautifulUI adapter layer & upstream pin policy
|
||||
|
||||
**Status:** Accepted · **Issue:** [#294](https://github.com/MervinPraison/PraisonAIUI/issues/294)
|
||||
|
||||
## Context
|
||||
|
||||
BeautifulUI is **not** a semver npm library — it is distributed as shadcn-style
|
||||
registry components that are copied into the consuming repo. PraisonAIUI must
|
||||
integrate it without leaking upstream types or import paths across the codebase,
|
||||
and without breaking when either side updates.
|
||||
|
||||
## Decision
|
||||
|
||||
Introduce a single **adapter boundary** between transport payloads and the UI,
|
||||
and vendor BeautifulUI components behind that boundary.
|
||||
|
||||
```
|
||||
Agent APIs (Python) ──► event bus / WS messages
|
||||
│
|
||||
▼
|
||||
Agent-UI adapter (src/agent-ui/adapter.ts)
|
||||
(stable PraisonAIUI contracts)
|
||||
│
|
||||
▼
|
||||
@praisonaiui/react re-exports
|
||||
(BeautifulUI vendored components)
|
||||
│
|
||||
▼
|
||||
Dashboard / chat mount points (React islands, then full shell)
|
||||
```
|
||||
|
||||
### 1. Folder layout
|
||||
|
||||
```
|
||||
src/frontend/src/agent-ui/
|
||||
├── contracts.ts # Stable PraisonAIUI view models (the ONLY types consumers import)
|
||||
├── adapter.ts # Pure mappers: wire payload -> contracts
|
||||
├── adapter.test.ts # Unit tests (mock events -> props), node:test
|
||||
├── index.ts # Barrel: contracts + adapters + (later) vendored re-exports
|
||||
└── vendors/
|
||||
└── beautifului/ # Vendored BeautifulUI components (added during migration)
|
||||
```
|
||||
|
||||
**Rule:** BeautifulUI / upstream prop types must **never** be imported outside
|
||||
`src/agent-ui/`. Consumers depend on `contracts.ts` only, so the vendored
|
||||
implementation can be swapped later without touching call sites.
|
||||
|
||||
### 2. Adapter contracts
|
||||
|
||||
Internal, transport-agnostic types live in `contracts.ts`:
|
||||
|
||||
- `AgentStreamEvent` — normalized form of the WebSocket frames handled in
|
||||
`chat.js` (`run_started`, `run_content`, `tool_call_started`, …). Unknown
|
||||
frame `type`s degrade to `kind: 'unknown'` with `rawType` preserved instead
|
||||
of throwing, so new upstream events never break the UI.
|
||||
- `ToolCallChipModel` — tool-call chip view model.
|
||||
- `ApprovalPromptModel` — approval card view model, mapped from the
|
||||
`/api/approvals/pending` REST payload.
|
||||
|
||||
Mappers (`adapter.ts`): `mapChatEventToUi(frame)` and
|
||||
`mapApprovalToCardProps(item)`. Both are **pure** and unit tested.
|
||||
|
||||
### 3. Vendoring & pin policy
|
||||
|
||||
| Mechanism | Purpose |
|
||||
|-----------|---------|
|
||||
| [`BEAUTIFULUI_UPSTREAM.md`](./BEAUTIFULUI_UPSTREAM.md) | Record git SHA, date, registry base URL for every vendored component |
|
||||
| shadcn registry URLs | Per-component install, pinned in the upstream doc |
|
||||
| **No** `dependencies.beautiful-ui` in `package.json` | Avoid a phantom npm package |
|
||||
|
||||
**Upgrade rule:** bump upstream **only** via a PR that updates
|
||||
`BEAUTIFULUI_UPSTREAM.md` and passes the frontend CI gate (issue #295). Icons
|
||||
stay on `lucide-react`; do not vendor paid Central icons.
|
||||
|
||||
### 4. Feature flag (default **off**)
|
||||
|
||||
Integration is gated so the existing vanilla-JS chat/approvals plugins remain
|
||||
the fallback until migration milestones (#296, #297) complete.
|
||||
|
||||
- **Flag:** `AIUI_BEAUTIFULUI` (build-time env / dashboard config JSON).
|
||||
- **Default:** off — vanilla JS renders; the adapter is imported but not mounted.
|
||||
- **On:** React islands mount using `src/agent-ui` contracts.
|
||||
- **Fallback:** if a vendored component fails to load, the flag is treated as
|
||||
off and the vanilla path renders. No user-facing regression when off.
|
||||
|
||||
### 5. Design tokens
|
||||
|
||||
Scope agent-UI primitives under an `.aiui-agent-ui { … }` CSS layer (or extend
|
||||
the existing shadcn presets in `themes.py`) so vendored component tokens cannot
|
||||
collide with the global dashboard theme.
|
||||
|
||||
### 6. Deprecation / upstream abandonment
|
||||
|
||||
Vendored code stays MIT-licensed in-tree. Because consumers depend only on the
|
||||
`contracts.ts` view models, a future first-party implementation can satisfy the
|
||||
same contracts and replace the vendored components with no call-site churn.
|
||||
|
||||
## Consequences
|
||||
|
||||
- One place (`src/agent-ui/`) owns the upstream coupling.
|
||||
- Adapters are testable without a browser or the transport layer.
|
||||
- The default-off flag keeps the migration reversible at every step.
|
||||
@@ -0,0 +1,107 @@
|
||||
# BeautifulUI Upgrade Playbook
|
||||
|
||||
How PraisonAIUI vendors [BeautifulUI](https://www.beautifului.dev/) React primitives so
|
||||
upgrades are **repeatable** and licence obligations are **explicit**.
|
||||
|
||||
!!! info "Related planning"
|
||||
Epic [#292](https://github.com/MervinPraison/PraisonAIUI/issues/292) (integration
|
||||
strategy) and the adapter/pin policy in
|
||||
[#294](https://github.com/MervinPraison/PraisonAIUI/issues/294).
|
||||
|
||||
## 1. What BeautifulUI is
|
||||
|
||||
BeautifulUI (MIT, © Shane Levine / Turbo) is a library of React primitives aimed at
|
||||
**AI-native interfaces** — thinking traces, streaming answers with inline sources,
|
||||
human-in-the-loop approval cards, tool chips, task rows, context chunks, and a reference
|
||||
chat harness.
|
||||
|
||||
**It is a shadcn registry + copy-paste library — not an npm package.** There is no
|
||||
published `beautiful-ui` package. You install components by resolving a registry URL, and
|
||||
the source is copied into your tree (like any other shadcn/ui primitive).
|
||||
|
||||
- Website & registry: <https://www.beautifului.dev/>
|
||||
- Upstream source: <https://github.com/slev12397/beautiful-ui>
|
||||
|
||||
Upstream stack (Next.js 15 · React 19 · Tailwind CSS v4 · TypeScript) is closely aligned
|
||||
with PraisonAIUI's `src/frontend` (Vite · React 19 · Tailwind v4 · shadcn/Radix), which is
|
||||
why copy-paste vendoring works without a framework shim.
|
||||
|
||||
## 2. One-time foundation setup
|
||||
|
||||
PraisonAIUI already carries the shadcn/ui foundation that BeautifulUI primitives depend on:
|
||||
|
||||
- **shadcn config** — `src/frontend/components.json` (`style: new-york`,
|
||||
`baseColor: neutral`, `iconLibrary: lucide`, `cssVariables: true`). Its `registries`
|
||||
map is where pinned BeautifulUI registry URLs are added (see §3).
|
||||
- **Theme tokens** — `src/praisonaiui/themes.py` generates the shadcn CSS variables
|
||||
(`--background`, `--foreground`, `--primary`, `--radius`, …) at build time for all 22
|
||||
official presets, offline. BeautifulUI's foundation CSS (upstream `app/globals.css`)
|
||||
must only contribute **new** tokens/utilities; it must **not** redefine variables that
|
||||
`themes.py` already owns, or theme switching and dark mode break.
|
||||
|
||||
**Rule:** when merging upstream `app/globals.css`, diff it against the variables emitted by
|
||||
`themes.py` and keep only the additive slices (new keyframes, component-scoped utilities).
|
||||
Record what was merged in the pin doc (§3).
|
||||
|
||||
## 3. Adding or updating a primitive
|
||||
|
||||
1. **Install with a pinned registry URL** from `src/frontend/`:
|
||||
```bash
|
||||
npx shadcn add https://www.beautifului.dev/r/<component>.json
|
||||
```
|
||||
The registry resolves dependencies and CSS slices. Commit the resulting
|
||||
`package-lock.json` change in the same PR.
|
||||
2. **Re-export through the adapter** — add the component to
|
||||
`src/frontend/src/components/index.ts` (`@praisonaiui/react`). Consumers never import
|
||||
the raw vendored path (see §4).
|
||||
3. **Update the pin record** — create/update `docs/frontend/BEAUTIFULUI_UPSTREAM.md` with:
|
||||
the upstream **git SHA**, the **date**, the **registry base URL**, and the **component
|
||||
list** vendored so far. This file is the single source of truth for "what version are
|
||||
we on". (It is created the first time a real primitive is vendored — do not add it
|
||||
empty.)
|
||||
4. **Rebuild & sync the bundle** — the committed frontend bundle must be regenerated:
|
||||
```bash
|
||||
cd src/frontend && npm run build
|
||||
# then sync dist/{index.html,assets/,icon.svg} → src/praisonaiui/templates/frontend/
|
||||
```
|
||||
(See `AGENTS.md` → "Frontend Build".)
|
||||
5. **Run frontend CI** — `.github/workflows/ci.yml` (ruff + pytest) must pass, plus the
|
||||
frontend build step.
|
||||
|
||||
## 4. Adapter boundary
|
||||
|
||||
Consumers import from **`@praisonaiui/react` only** — never from raw vendored paths such as
|
||||
`@/components/ui/...` or any upstream module.
|
||||
|
||||
```tsx
|
||||
// ✅ stable PraisonAIUI contract
|
||||
import { Button, ThemeToggle } from '@praisonaiui/react'
|
||||
|
||||
// ❌ leaks upstream/shadcn paths across the codebase
|
||||
import { Button } from '@/components/ui/button'
|
||||
```
|
||||
|
||||
This keeps the upstream surface behind one file (`components/index.ts`), so a BeautifulUI
|
||||
bump changes vendored source but not consumer imports.
|
||||
|
||||
## 5. Rollback
|
||||
|
||||
- BeautifulUI-backed views mount behind a **feature flag**; the **vanilla dashboard views**
|
||||
(`src/praisonaiui/templates/frontend/plugins/*`) remain the fallback and are kept working.
|
||||
- To roll back a bad bump: revert the vendoring PR (source + `BEAUTIFULUI_UPSTREAM.md` +
|
||||
rebuilt bundle move together in one commit), and disable the flag. No consumer import
|
||||
changes are needed because everything routes through the adapter (§4).
|
||||
|
||||
## 6. Licence
|
||||
|
||||
BeautifulUI is **MIT, © Shane Levine**. The full licence text is retained in
|
||||
[`THIRD_PARTY_NOTICES.md`](https://github.com/MervinPraison/PraisonAIUI/blob/main/THIRD_PARTY_NOTICES.md)
|
||||
at the repository root. Any PR that vendors or bumps BeautifulUI source must keep that
|
||||
notice present and current.
|
||||
|
||||
## 7. Commercial icons — do not vendor
|
||||
|
||||
Do **not** use `@central-icons-react` without a licence — it is commercial. PraisonAIUI
|
||||
standardises on **`lucide-react`** (`iconLibrary: lucide` in `components.json`). If an
|
||||
upstream BeautifulUI primitive imports commercial icons, swap them for lucide equivalents
|
||||
during vendoring.
|
||||
@@ -95,8 +95,12 @@ nav:
|
||||
- Templates & Layouts: concepts/templates.md
|
||||
- Slots & Components: concepts/slots.md
|
||||
- Providers: concepts/providers.md
|
||||
- Frontend:
|
||||
- BeautifulUI Adapter: frontend/beautifului-adapter-architecture.md
|
||||
- BeautifulUI Upstream Pin: frontend/BEAUTIFULUI_UPSTREAM.md
|
||||
- Features:
|
||||
- Dashboard: features/dashboard.md
|
||||
- Replay: features/replay.md
|
||||
- Gateway Chat: features/gateway-chat.md
|
||||
- Attachments: features/attachments.md
|
||||
- Theme System: features/theme-system.md
|
||||
@@ -129,12 +133,15 @@ nav:
|
||||
- Feature Inventory: features/feature-inventory.md
|
||||
- Gap Analysis: features/gap-analysis.md
|
||||
- Integration Analysis: features/integration-analysis.md
|
||||
- BeautifulUI Integration: features/beautifului-integration.md
|
||||
- Standardization: features/standardization-verification.md
|
||||
- Comparison (OpenClaw): features/comparison-openclaw.md
|
||||
- What Makes It Different: features/differentiation.md
|
||||
- How to Add a Feature: features/how-to-add-a-feature.md
|
||||
- Message Elements: features/message-elements.md
|
||||
- Steps: features/steps.md
|
||||
- Frontend:
|
||||
- BeautifulUI Playbook: frontend/beautifului.md
|
||||
- API Reference:
|
||||
- Python CLI: api/cli.md
|
||||
- Python API: api/python.md
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
[project]
|
||||
name = "aiui"
|
||||
version = "0.3.144"
|
||||
version = "0.3.151"
|
||||
description = "YAML-driven website generator - CLI and compiler"
|
||||
readme = "README.md"
|
||||
license = {text = "MIT"}
|
||||
|
||||
+23
-2
@@ -1,6 +1,27 @@
|
||||
# React + TypeScript + Vite
|
||||
# PraisonAIUI Frontend (React + TypeScript + Vite)
|
||||
|
||||
This template provides a minimal setup to get React working in Vite with HMR and some ESLint rules.
|
||||
The BeautifulUI dashboard source. Built with React 19 + Vite 7 + Tailwind v4.
|
||||
|
||||
## Build & CI
|
||||
|
||||
- **Node.js:** 22 (matches the `frontend` job in `.github/workflows/ci.yml`)
|
||||
- **Lockfile:** `src/frontend/package-lock.json` — install with `npm ci`
|
||||
|
||||
```bash
|
||||
cd src/frontend
|
||||
npm ci
|
||||
npm run build # SPA build → dist/ (dashboard ship path)
|
||||
npm run build:lib # library build for the @praisonaiui/react package
|
||||
npm run lint # eslint
|
||||
```
|
||||
|
||||
`npm run build` output in `dist/` is synced to `src/praisonaiui/templates/frontend/`
|
||||
(the committed bundle served by `aiui build`). The CI `frontend` job runs
|
||||
`npm ci && npm run build` on PRs to `main` and fails on TypeScript/Vite errors.
|
||||
|
||||
## Vite template notes
|
||||
|
||||
This project uses a minimal setup to get React working in Vite with HMR and some ESLint rules.
|
||||
|
||||
Currently, two official plugins are available:
|
||||
|
||||
|
||||
Generated
+87
-23
@@ -82,20 +82,6 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@a2ui/markdown-it": {
|
||||
"version": "0.0.4",
|
||||
"resolved": "https://registry.npmjs.org/@a2ui/markdown-it/-/markdown-it-0.0.4.tgz",
|
||||
"integrity": "sha512-uaYNywdiHDH4i//pHpyckFnApvOshiUNT2Xl8awceNjL1XAX/gqtki9GRUJQ26RE7id2+aPNgOy1W6mXNBxriQ==",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"dompurify": "^3.3.1",
|
||||
"markdown-it": "^14.1.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@a2ui/web_core": "^0.9.2"
|
||||
}
|
||||
},
|
||||
"node_modules/@a2ui/react": {
|
||||
"version": "0.10.0",
|
||||
"resolved": "https://registry.npmjs.org/@a2ui/react/-/react-0.10.0.tgz",
|
||||
@@ -129,16 +115,16 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@a2ui/web_core": {
|
||||
"version": "0.10.0",
|
||||
"resolved": "https://registry.npmjs.org/@a2ui/web_core/-/web_core-0.10.0.tgz",
|
||||
"integrity": "sha512-pSieKxaB6aZWBL2LjZfKo4/1v4xMWylczpzte1ehuGH0kfNGf/iTd73PKsIKYx3b0FNWTbD8gAloOM4JCVqUig==",
|
||||
"version": "0.10.2",
|
||||
"resolved": "https://registry.npmjs.org/@a2ui/web_core/-/web_core-0.10.2.tgz",
|
||||
"integrity": "sha512-IfS4K7baTL9P9AzUOzKRWzL9A9/+Z7TCgMY2GPwsZ0H7LgY/9/uJnlbReNhpHsAfN12KZtW9iERWkw5HOf71GA==",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"@preact/signals-core": "^1.13.0",
|
||||
"date-fns": "^4.1.0",
|
||||
"@preact/signals-core": "^1.14.2",
|
||||
"date-fns": "^4.4.0",
|
||||
"zod": "^3.25.76",
|
||||
"zod-to-json-schema": "^3.25.1"
|
||||
"zod-to-json-schema": "^3.25.2"
|
||||
}
|
||||
},
|
||||
"node_modules/@a2ui/web_core/node_modules/zod": {
|
||||
@@ -5502,9 +5488,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/date-fns": {
|
||||
"version": "4.1.0",
|
||||
"resolved": "https://registry.npmjs.org/date-fns/-/date-fns-4.1.0.tgz",
|
||||
"integrity": "sha512-Ukq0owbQXxa/U3EGtsdVBkR1w7KOQ5gIBqdH2hkvknzZPYvBxb/aa6E8L7tmjFtkwZBu3UXBbjIgPo/Ez4xaNg==",
|
||||
"version": "4.4.0",
|
||||
"resolved": "https://registry.npmjs.org/date-fns/-/date-fns-4.4.0.tgz",
|
||||
"integrity": "sha512-+1UMbeh68lH1SegH83CGWwpb6OHHbpSgr3+s5Eww5M4CAgswBpoWS0AjTOfEJ33HiYKz1hdj/KTFprzXHmq/6w==",
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"type": "github",
|
||||
@@ -9747,6 +9733,84 @@
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/wooorm"
|
||||
}
|
||||
},
|
||||
"node_modules/@a2ui/react/node_modules/@a2ui/markdown-it": {
|
||||
"version": "0.0.4",
|
||||
"resolved": "https://registry.npmjs.org/@a2ui/markdown-it/-/markdown-it-0.0.4.tgz",
|
||||
"integrity": "sha512-uaYNywdiHDH4i//pHpyckFnApvOshiUNT2Xl8awceNjL1XAX/gqtki9GRUJQ26RE7id2+aPNgOy1W6mXNBxriQ==",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"dompurify": "^3.3.1",
|
||||
"markdown-it": "^14.1.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@a2ui/web_core": "^0.9.2"
|
||||
}
|
||||
},
|
||||
"node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@emnapi/core": {
|
||||
"version": "1.8.1",
|
||||
"dev": true,
|
||||
"inBundle": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"dependencies": {
|
||||
"@emnapi/wasi-threads": "1.1.0",
|
||||
"tslib": "^2.4.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@emnapi/runtime": {
|
||||
"version": "1.8.1",
|
||||
"dev": true,
|
||||
"inBundle": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"dependencies": {
|
||||
"tslib": "^2.4.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@emnapi/wasi-threads": {
|
||||
"version": "1.1.0",
|
||||
"dev": true,
|
||||
"inBundle": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"dependencies": {
|
||||
"tslib": "^2.4.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@napi-rs/wasm-runtime": {
|
||||
"version": "1.1.1",
|
||||
"dev": true,
|
||||
"inBundle": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"dependencies": {
|
||||
"@emnapi/core": "^1.7.1",
|
||||
"@emnapi/runtime": "^1.7.1",
|
||||
"@tybys/wasm-util": "^0.10.1"
|
||||
},
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/Brooooooklyn"
|
||||
}
|
||||
},
|
||||
"node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@tybys/wasm-util": {
|
||||
"version": "0.10.1",
|
||||
"dev": true,
|
||||
"inBundle": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"dependencies": {
|
||||
"tslib": "^2.4.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/tslib": {
|
||||
"version": "2.8.1",
|
||||
"dev": true,
|
||||
"inBundle": true,
|
||||
"license": "0BSD",
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -29,7 +29,8 @@
|
||||
"build": "tsc -b && vite build",
|
||||
"build:lib": "tsc -b && vite build --mode lib",
|
||||
"lint": "eslint .",
|
||||
"preview": "vite preview"
|
||||
"preview": "vite preview",
|
||||
"test": "node --experimental-strip-types --test \"src/agent-ui/**/*.test.ts\""
|
||||
},
|
||||
"keywords": [
|
||||
"react",
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
<!doctype html>
|
||||
<!-- Spike #293 dev harness. Open with `npm run dev` → http://localhost:5173/spike.html -->
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>BeautifulUI spike · PraisonAIUI #293</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/spike/main.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,104 @@
|
||||
/**
|
||||
* Unit tests for the Agent-UI adapter (mock events -> stable contracts).
|
||||
*
|
||||
* Dependency-free: uses the Node built-in test runner so it needs no extra
|
||||
* toolchain. Run with `npm test` (from src/frontend), which invokes the Node
|
||||
* test runner with `--experimental-strip-types` so the TypeScript types are
|
||||
* stripped on Node 22+. The frontend CI gate (see issue #295) wires this in.
|
||||
*/
|
||||
|
||||
import { test } from 'node:test'
|
||||
import assert from 'node:assert/strict'
|
||||
|
||||
import { mapApprovalToCardProps, mapChatEventToUi } from './adapter.ts'
|
||||
|
||||
test('mapChatEventToUi normalizes a streaming token frame', () => {
|
||||
const event = mapChatEventToUi({
|
||||
type: 'run_content',
|
||||
token: 'Hello',
|
||||
agent_name: 'Researcher',
|
||||
session_id: 's1',
|
||||
run_id: 'r1',
|
||||
})
|
||||
assert.equal(event.kind, 'run-token')
|
||||
assert.equal(event.text, 'Hello')
|
||||
assert.equal(event.agentName, 'Researcher')
|
||||
assert.equal(event.sessionId, 's1')
|
||||
assert.equal(event.runId, 'r1')
|
||||
})
|
||||
|
||||
test('mapChatEventToUi maps a started tool call to a running chip', () => {
|
||||
const event = mapChatEventToUi({
|
||||
type: 'tool_call_started',
|
||||
name: 'search',
|
||||
description: '🔎 Searching the web',
|
||||
icon: '🔎',
|
||||
step_number: 2,
|
||||
tool_call_id: 'tc-1',
|
||||
})
|
||||
assert.equal(event.kind, 'tool-call')
|
||||
assert.deepEqual(event.toolCall, {
|
||||
id: 'tc-1',
|
||||
label: '🔎 Searching the web',
|
||||
icon: '🔎',
|
||||
stepNumber: 2,
|
||||
status: 'running',
|
||||
error: undefined,
|
||||
})
|
||||
})
|
||||
|
||||
test('mapChatEventToUi marks completed tool calls as done', () => {
|
||||
const event = mapChatEventToUi({ type: 'tool_call_completed', name: 'search' })
|
||||
assert.equal(event.toolCall?.status, 'done')
|
||||
// Falls back to the tool name for id and label when unspecified.
|
||||
assert.equal(event.toolCall?.id, 'search')
|
||||
assert.equal(event.toolCall?.label, 'search')
|
||||
})
|
||||
|
||||
test('mapChatEventToUi treats team_* frames the same as their solo variant', () => {
|
||||
assert.equal(mapChatEventToUi({ type: 'team_run_content', token: 'x' }).kind, 'run-token')
|
||||
assert.equal(mapChatEventToUi({ type: 'team_run_error', error: 'boom' }).kind, 'run-error')
|
||||
})
|
||||
|
||||
test('mapChatEventToUi preserves unknown frame types without throwing', () => {
|
||||
const event = mapChatEventToUi({ type: 'future_event', foo: 'bar' })
|
||||
assert.equal(event.kind, 'unknown')
|
||||
assert.equal(event.rawType, 'future_event')
|
||||
})
|
||||
|
||||
test('mapChatEventToUi defaults run errors to a readable message', () => {
|
||||
const event = mapChatEventToUi({ type: 'run_error' })
|
||||
assert.equal(event.kind, 'run-error')
|
||||
assert.equal(event.error, 'Unknown error')
|
||||
})
|
||||
|
||||
test('mapApprovalToCardProps maps a pending approval item', () => {
|
||||
const card = mapApprovalToCardProps({
|
||||
id: 'a-1',
|
||||
tool_name: 'delete_file',
|
||||
agent_name: 'Ops',
|
||||
risk_level: 'high',
|
||||
risk_icon: '🔥',
|
||||
description: 'Deletes a file',
|
||||
arguments: { path: '/tmp/x' },
|
||||
// Approvals API emits epoch seconds (time.time()), not an ISO string.
|
||||
created_at: 1758585600,
|
||||
})
|
||||
assert.equal(card.id, 'a-1')
|
||||
assert.equal(card.toolName, 'delete_file')
|
||||
assert.equal(card.agentName, 'Ops')
|
||||
assert.equal(card.riskLevel, 'high')
|
||||
assert.equal(card.riskIcon, '🔥')
|
||||
assert.equal(card.argumentsJson, '{\n "path": "/tmp/x"\n}')
|
||||
// Epoch seconds are normalized to an ISO-8601 string for display.
|
||||
assert.equal(card.createdAt, '2025-09-23T00:00:00.000Z')
|
||||
})
|
||||
|
||||
test('mapApprovalToCardProps falls back for missing/invalid fields', () => {
|
||||
const card = mapApprovalToCardProps({ id: 'a-2', risk_level: 'nonsense' })
|
||||
assert.equal(card.toolName, 'unknown')
|
||||
assert.equal(card.agentName, 'Unknown')
|
||||
assert.equal(card.riskLevel, 'medium')
|
||||
assert.equal(card.riskIcon, '⚠️')
|
||||
assert.equal(card.argumentsJson, '{}')
|
||||
})
|
||||
@@ -0,0 +1,177 @@
|
||||
/**
|
||||
* PraisonAIUI Agent-UI adapter
|
||||
*
|
||||
* Pure mapping functions from wire payloads to stable PraisonAIUI contracts.
|
||||
* This is the single boundary between transport shapes (WebSocket frames from
|
||||
* chat.js, REST payloads from /api/approvals/*) and the UI layer. Vendored
|
||||
* BeautifulUI components are rendered from these contracts elsewhere in
|
||||
* `src/agent-ui/`; consumers never import upstream prop types directly.
|
||||
*
|
||||
* Functions are intentionally side-effect free so they can be unit tested
|
||||
* with mock events (see adapter.test.ts).
|
||||
*/
|
||||
|
||||
import type {
|
||||
AgentStreamEvent,
|
||||
ApprovalPromptModel,
|
||||
ApprovalRiskLevel,
|
||||
ToolCallChipModel,
|
||||
} from './contracts'
|
||||
|
||||
/** Raw WebSocket frame as parsed in chat.js (`JSON.parse(event.data)`). */
|
||||
export type RawChatFrame = Record<string, unknown>
|
||||
|
||||
/** Raw approval item as returned by `/api/approvals/pending`. */
|
||||
export type RawApprovalItem = Record<string, unknown>
|
||||
|
||||
const VALID_RISK_LEVELS: readonly ApprovalRiskLevel[] = [
|
||||
'low',
|
||||
'medium',
|
||||
'high',
|
||||
'critical',
|
||||
]
|
||||
|
||||
function asString(value: unknown): string | undefined {
|
||||
return typeof value === 'string' ? value : undefined
|
||||
}
|
||||
|
||||
function asNumber(value: unknown): number | undefined {
|
||||
return typeof value === 'number' ? value : undefined
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalize a `created_at` value to an ISO-8601 string. The approvals API
|
||||
* emits epoch seconds (`time.time()`), while some payloads already carry an
|
||||
* ISO string — accept both, drop anything else.
|
||||
*/
|
||||
function asTimestamp(value: unknown): string | undefined {
|
||||
if (typeof value === 'string') return value
|
||||
if (typeof value === 'number' && Number.isFinite(value)) {
|
||||
return new Date(value * 1000).toISOString()
|
||||
}
|
||||
return undefined
|
||||
}
|
||||
|
||||
/** Map a raw tool-call frame to a stable chip view model. */
|
||||
function mapToolCall(
|
||||
frame: RawChatFrame,
|
||||
status: ToolCallChipModel['status'],
|
||||
): ToolCallChipModel {
|
||||
const name = asString(frame.name) ?? 'tool'
|
||||
return {
|
||||
id: asString(frame.tool_call_id) ?? name,
|
||||
label: asString(frame.description) ?? name,
|
||||
icon: asString(frame.icon),
|
||||
stepNumber: asNumber(frame.step_number),
|
||||
status,
|
||||
error: asString(frame.error),
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Map a raw WebSocket chat frame to a normalized {@link AgentStreamEvent}.
|
||||
*
|
||||
* Unknown frame types are preserved as `kind: 'unknown'` with `rawType` set,
|
||||
* so new upstream event types degrade gracefully instead of throwing.
|
||||
*/
|
||||
export function mapChatEventToUi(frame: RawChatFrame): AgentStreamEvent {
|
||||
const rawType = asString(frame.type)
|
||||
const sessionId = asString(frame.session_id)
|
||||
const runId = asString(frame.run_id)
|
||||
const agentName = asString(frame.agent_name)
|
||||
|
||||
switch (rawType) {
|
||||
case 'run_started':
|
||||
return { kind: 'run-started', sessionId, runId, agentName }
|
||||
case 'run_content':
|
||||
case 'team_run_content':
|
||||
return {
|
||||
kind: 'run-token',
|
||||
sessionId,
|
||||
runId,
|
||||
agentName,
|
||||
text: asString(frame.token),
|
||||
}
|
||||
case 'run_completed':
|
||||
case 'team_run_completed':
|
||||
return {
|
||||
kind: 'run-completed',
|
||||
sessionId,
|
||||
runId,
|
||||
agentName,
|
||||
text: asString(frame.content),
|
||||
}
|
||||
case 'run_error':
|
||||
case 'team_run_error':
|
||||
return {
|
||||
kind: 'run-error',
|
||||
sessionId,
|
||||
runId,
|
||||
agentName,
|
||||
error: asString(frame.error) ?? 'Unknown error',
|
||||
}
|
||||
case 'tool_call_started':
|
||||
case 'team_tool_call_started':
|
||||
return {
|
||||
kind: 'tool-call',
|
||||
sessionId,
|
||||
runId,
|
||||
agentName,
|
||||
toolCall: mapToolCall(frame, 'running'),
|
||||
}
|
||||
case 'tool_call_completed':
|
||||
case 'team_tool_call_completed':
|
||||
return {
|
||||
kind: 'tool-call',
|
||||
sessionId,
|
||||
runId,
|
||||
agentName,
|
||||
toolCall: mapToolCall(frame, 'done'),
|
||||
}
|
||||
case 'reasoning_step':
|
||||
case 'team_reasoning_step':
|
||||
return {
|
||||
kind: 'reasoning-step',
|
||||
sessionId,
|
||||
runId,
|
||||
agentName,
|
||||
text: asString(frame.step),
|
||||
}
|
||||
default:
|
||||
return { kind: 'unknown', sessionId, runId, agentName, rawType }
|
||||
}
|
||||
}
|
||||
|
||||
function normalizeRisk(value: unknown): ApprovalRiskLevel {
|
||||
return VALID_RISK_LEVELS.includes(value as ApprovalRiskLevel)
|
||||
? (value as ApprovalRiskLevel)
|
||||
: 'medium'
|
||||
}
|
||||
|
||||
/**
|
||||
* Map a raw approval item from `/api/approvals/pending` to a stable
|
||||
* {@link ApprovalPromptModel}. Arguments are pretty-printed for display and
|
||||
* an unknown/missing risk level falls back to `medium`.
|
||||
*/
|
||||
export function mapApprovalToCardProps(
|
||||
item: RawApprovalItem,
|
||||
): ApprovalPromptModel {
|
||||
let argumentsJson = '{}'
|
||||
try {
|
||||
argumentsJson = JSON.stringify(item.arguments ?? {}, null, 2)
|
||||
} catch {
|
||||
// Circular or non-serializable args — degrade to empty object.
|
||||
argumentsJson = '{}'
|
||||
}
|
||||
|
||||
return {
|
||||
id: asString(item.id) ?? '',
|
||||
toolName: asString(item.tool_name) ?? 'unknown',
|
||||
agentName: asString(item.agent_name) ?? 'Unknown',
|
||||
riskLevel: normalizeRisk(item.risk_level),
|
||||
riskIcon: asString(item.risk_icon) ?? '⚠️',
|
||||
description: asString(item.description),
|
||||
argumentsJson,
|
||||
createdAt: asTimestamp(item.created_at),
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
/**
|
||||
* PraisonAIUI Agent-UI contracts
|
||||
*
|
||||
* Stable, framework-agnostic view models owned by PraisonAIUI. These are the
|
||||
* ONLY types that non-adapter code (chat views, dashboards, tests) should
|
||||
* depend on. BeautifulUI / vendored component prop types must never leak
|
||||
* outside `src/agent-ui/` — the adapter maps to these contracts instead so
|
||||
* the upstream implementation can be swapped without touching consumers.
|
||||
*
|
||||
* See docs/frontend/beautifului-adapter-architecture.md.
|
||||
*/
|
||||
|
||||
/** Discriminant for a normalized agent stream event. */
|
||||
export type AgentStreamEventKind =
|
||||
| 'run-started'
|
||||
| 'run-token'
|
||||
| 'run-completed'
|
||||
| 'run-error'
|
||||
| 'tool-call'
|
||||
| 'reasoning-step'
|
||||
| 'unknown'
|
||||
|
||||
/**
|
||||
* Normalized agent stream event — the stable shape UI code consumes,
|
||||
* decoupled from the raw WebSocket frame `type` strings handled in chat.js.
|
||||
*/
|
||||
export interface AgentStreamEvent {
|
||||
kind: AgentStreamEventKind
|
||||
/** Owning session, when the frame carried one. */
|
||||
sessionId?: string
|
||||
/** Run identifier, when the frame carried one. */
|
||||
runId?: string
|
||||
/** Human-facing agent name, when present. */
|
||||
agentName?: string
|
||||
/** Streaming token or final content, depending on `kind`. */
|
||||
text?: string
|
||||
/** Populated for `kind === 'tool-call'`. */
|
||||
toolCall?: ToolCallChipModel
|
||||
/** Populated for `kind === 'run-error'`. */
|
||||
error?: string
|
||||
/** Raw frame `type` for events we do not yet normalize (`kind === 'unknown'`). */
|
||||
rawType?: string
|
||||
}
|
||||
|
||||
/** View model for a tool-call chip, independent of any component library. */
|
||||
export interface ToolCallChipModel {
|
||||
id: string
|
||||
label: string
|
||||
icon?: string
|
||||
stepNumber?: number
|
||||
status: 'running' | 'done' | 'error'
|
||||
error?: string
|
||||
}
|
||||
|
||||
/** Risk levels surfaced by the approvals REST API (`/api/approvals/*`). */
|
||||
export type ApprovalRiskLevel = 'low' | 'medium' | 'high' | 'critical'
|
||||
|
||||
/** View model for an approval prompt/card, independent of any component library. */
|
||||
export interface ApprovalPromptModel {
|
||||
id: string
|
||||
toolName: string
|
||||
agentName: string
|
||||
riskLevel: ApprovalRiskLevel
|
||||
riskIcon: string
|
||||
description?: string
|
||||
/** Pretty-printed tool arguments, ready for display. */
|
||||
argumentsJson: string
|
||||
createdAt?: string
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
/**
|
||||
* Agent-UI boundary barrel.
|
||||
*
|
||||
* Consumers import PraisonAIUI's stable agent-UI contracts and adapters from
|
||||
* here. Vendored BeautifulUI components live under `src/agent-ui/vendors/` and
|
||||
* are re-exported from this folder only — no upstream prop types should be
|
||||
* imported outside `src/agent-ui/`.
|
||||
*/
|
||||
|
||||
export type {
|
||||
AgentStreamEvent,
|
||||
AgentStreamEventKind,
|
||||
ApprovalPromptModel,
|
||||
ApprovalRiskLevel,
|
||||
ToolCallChipModel,
|
||||
} from './contracts'
|
||||
|
||||
export type { RawApprovalItem, RawChatFrame } from './adapter'
|
||||
export { mapApprovalToCardProps, mapChatEventToUi } from './adapter'
|
||||
@@ -38,7 +38,7 @@ interface ActionButtonsProps {
|
||||
*/
|
||||
export function ActionButtons({ actions, messageId, sessionId }: ActionButtonsProps) {
|
||||
const [pendingActions, setPendingActions] = useState<Set<string>>(new Set())
|
||||
const [hiddenActions, setHiddenActions] = useState<Set<string>>(new Set())
|
||||
const [hiddenActions] = useState<Set<string>>(new Set())
|
||||
|
||||
const handleActionClick = useCallback(async (action: ActionButton) => {
|
||||
if (pendingActions.has(action.id) || hiddenActions.has(action.id)) {
|
||||
|
||||
@@ -73,7 +73,7 @@ export function AskActionPrompt({
|
||||
|
||||
{/* Action buttons */}
|
||||
<div className="space-y-3">
|
||||
{actions.map((action, index) => (
|
||||
{actions.map((action) => (
|
||||
<button
|
||||
key={action.name}
|
||||
type="button"
|
||||
|
||||
@@ -15,7 +15,7 @@ import { Textarea } from '../components/ui/textarea';
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '../components/ui/select';
|
||||
import { Slider } from '../components/ui/slider';
|
||||
import { Switch } from '../components/ui/switch';
|
||||
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from '../components/ui/card';
|
||||
import { Card, CardContent } from '../components/ui/card';
|
||||
import { Sheet, SheetContent, SheetHeader, SheetTitle, SheetDescription } from '../components/ui/sheet';
|
||||
|
||||
interface SettingsWidget {
|
||||
@@ -74,7 +74,7 @@ const WidgetRenderer: React.FC<{
|
||||
id={name}
|
||||
placeholder={placeholder}
|
||||
value={value || ''}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
onChange={(e: React.ChangeEvent<HTMLTextAreaElement>) => onChange(e.target.value)}
|
||||
maxLength={widget.maxLength}
|
||||
rows={4}
|
||||
/>
|
||||
|
||||
@@ -20,43 +20,16 @@ interface CustomElementProps {
|
||||
onError?: (error: Error) => void;
|
||||
}
|
||||
|
||||
interface CustomComponentModule {
|
||||
default: React.ComponentType<any>;
|
||||
}
|
||||
|
||||
// Registry of loaded components (cache)
|
||||
const componentCache = new Map<string, Promise<React.ComponentType<any>>>();
|
||||
|
||||
// List of available custom components (in practice, this would be populated from backend)
|
||||
const availableComponents = new Set([
|
||||
'ExampleWidget',
|
||||
'UserCard',
|
||||
'DataChart',
|
||||
'FormBuilder',
|
||||
'CodeEditor',
|
||||
'ImageGallery',
|
||||
'ChatEmbed'
|
||||
]);
|
||||
|
||||
// Static imports for build-tool compatibility
|
||||
const ExampleWidget = React.lazy(() => import('../components/custom/ExampleWidget'));
|
||||
const UserCard = React.lazy(() => import('../components/custom/UserCard'));
|
||||
const DataChart = React.lazy(() => import('../components/custom/DataChart'));
|
||||
const FormBuilder = React.lazy(() => import('../components/custom/FormBuilder'));
|
||||
const CodeEditor = React.lazy(() => import('../components/custom/CodeEditor'));
|
||||
const ImageGallery = React.lazy(() => import('../components/custom/ImageGallery'));
|
||||
const ChatEmbed = React.lazy(() => import('../components/custom/ChatEmbed'));
|
||||
|
||||
// Static mapping of component names to imports
|
||||
const componentMap: Record<string, React.ComponentType<any>> = {
|
||||
ExampleWidget,
|
||||
UserCard,
|
||||
DataChart,
|
||||
FormBuilder,
|
||||
CodeEditor,
|
||||
ImageGallery,
|
||||
ChatEmbed,
|
||||
};
|
||||
// Custom components available to <CustomElementHost name="..." />. The example
|
||||
// widgets this once listed (ExampleWidget, UserCard, ...) were never added, and
|
||||
// importing them broke the build. Register real components here as they land:
|
||||
// const MyWidget = React.lazy(() => import('../components/custom/MyWidget'));
|
||||
// componentMap.MyWidget = MyWidget;
|
||||
const componentMap: Record<string, React.ComponentType<any>> = {};
|
||||
const availableComponents = new Set(Object.keys(componentMap));
|
||||
|
||||
/**
|
||||
* Load a custom component with static mapping for build-tool compatibility
|
||||
|
||||
@@ -4,7 +4,6 @@
|
||||
* Shows live-updating task lists with states, progress bars, and click-to-jump functionality.
|
||||
*/
|
||||
|
||||
import React from 'react'
|
||||
import { ChevronRight, ChevronDown, CheckCircle, Clock, XCircle, Circle, ExternalLink } from 'lucide-react'
|
||||
import { useTaskState, setTasksCollapsed, type Task, type TaskList, type TaskStatus } from '../state/tasks'
|
||||
import { cn } from '../lib/utils'
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
import * as React from "react"
|
||||
import { cva, type VariantProps } from "class-variance-authority"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const alertVariants = cva(
|
||||
"relative w-full rounded-lg border px-4 py-3 text-sm [&>svg+div]:translate-y-[-3px] [&>svg]:absolute [&>svg]:left-4 [&>svg]:top-4 [&>svg]:text-foreground [&>svg~*]:pl-7",
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default: "bg-background text-foreground",
|
||||
destructive:
|
||||
"border-destructive/50 text-destructive dark:border-destructive [&>svg]:text-destructive",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: "default",
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
const Alert = React.forwardRef<
|
||||
HTMLDivElement,
|
||||
React.HTMLAttributes<HTMLDivElement> & VariantProps<typeof alertVariants>
|
||||
>(({ className, variant, ...props }, ref) => (
|
||||
<div ref={ref} role="alert" className={cn(alertVariants({ variant }), className)} {...props} />
|
||||
))
|
||||
Alert.displayName = "Alert"
|
||||
|
||||
const AlertTitle = React.forwardRef<HTMLParagraphElement, React.HTMLAttributes<HTMLHeadingElement>>(
|
||||
({ className, ...props }, ref) => (
|
||||
<h5 ref={ref} className={cn("mb-1 font-medium leading-none tracking-tight", className)} {...props} />
|
||||
)
|
||||
)
|
||||
AlertTitle.displayName = "AlertTitle"
|
||||
|
||||
const AlertDescription = React.forwardRef<HTMLParagraphElement, React.HTMLAttributes<HTMLParagraphElement>>(
|
||||
({ className, ...props }, ref) => (
|
||||
<div ref={ref} className={cn("text-sm [&_p]:leading-relaxed", className)} {...props} />
|
||||
)
|
||||
)
|
||||
AlertDescription.displayName = "AlertDescription"
|
||||
|
||||
export { Alert, AlertTitle, AlertDescription }
|
||||
@@ -0,0 +1,19 @@
|
||||
import * as React from "react"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const Textarea = React.forwardRef<HTMLTextAreaElement, React.ComponentProps<"textarea">>(
|
||||
({ className, ...props }, ref) => (
|
||||
<textarea
|
||||
className={cn(
|
||||
"flex min-h-[60px] w-full rounded-md border border-input bg-transparent px-3 py-2 text-base shadow-sm placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50 md:text-sm",
|
||||
className
|
||||
)}
|
||||
ref={ref}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
)
|
||||
Textarea.displayName = "Textarea"
|
||||
|
||||
export { Textarea }
|
||||
@@ -183,7 +183,7 @@ export function useMicStream(options: UseMicStreamOptions = {}): UseMicStreamRet
|
||||
onConnectionChangeRef.current?.(false);
|
||||
};
|
||||
|
||||
ws.onerror = (error) => {
|
||||
ws.onerror = () => {
|
||||
onErrorRef.current?.(new Error('WebSocket error'));
|
||||
setConnected(false);
|
||||
onConnectionChangeRef.current?.(false);
|
||||
|
||||
@@ -5,8 +5,33 @@
|
||||
* incoming messages from parent.
|
||||
*/
|
||||
|
||||
import { useEffect, useCallback, useRef } from 'react';
|
||||
import { useSSE } from './useSSE';
|
||||
import { useEffect, useCallback, useRef, useState } from 'react';
|
||||
|
||||
interface EventSourceOptions {
|
||||
enabled?: boolean;
|
||||
onMessage?: (event: MessageEvent) => void;
|
||||
onOpen?: () => void;
|
||||
onError?: () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Minimal EventSource subscription. (useSSE in this package is the chat
|
||||
* streaming store, not a generic SSE hook, so it cannot open this stream.)
|
||||
*/
|
||||
function useEventSource(url: string, { enabled = true, onMessage, onOpen, onError }: EventSourceOptions) {
|
||||
const [connected, setConnected] = useState(false);
|
||||
const handlers = useRef({ onMessage, onOpen, onError });
|
||||
handlers.current = { onMessage, onOpen, onError };
|
||||
useEffect(() => {
|
||||
if (!enabled || typeof EventSource === 'undefined') return;
|
||||
const source = new EventSource(url);
|
||||
source.onopen = () => { setConnected(true); handlers.current.onOpen?.(); };
|
||||
source.onmessage = (event) => handlers.current.onMessage?.(event);
|
||||
source.onerror = () => { setConnected(false); handlers.current.onError?.(); };
|
||||
return () => { source.close(); setConnected(false); };
|
||||
}, [url, enabled]);
|
||||
return { connected };
|
||||
}
|
||||
|
||||
export interface WindowMessageData {
|
||||
type: string;
|
||||
@@ -85,7 +110,7 @@ export function useWindowMessage(options: UseWindowMessageOptions = {}): UseWind
|
||||
const isIframe = window !== window.parent;
|
||||
|
||||
// SSE connection for outbound messages
|
||||
const { connected } = useSSE('/sse/window-message', {
|
||||
const { connected } = useEventSource('/sse/window-message', {
|
||||
enabled: enabled && isIframe,
|
||||
onMessage: (event) => {
|
||||
try {
|
||||
|
||||
@@ -7,9 +7,7 @@ import React, { useState } from 'react';
|
||||
import { Button } from '../components/ui/button';
|
||||
import { Input } from '../components/ui/input';
|
||||
import { Label } from '../components/ui/label';
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '../components/ui/select';
|
||||
import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle, DialogFooter } from '../components/ui/dialog';
|
||||
import { Separator } from '../components/ui/separator';
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from '../components/ui/tabs';
|
||||
|
||||
interface AddMCPServerModalProps {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useParams } from 'react-router-dom';
|
||||
import { Eye, AlertCircle } from 'lucide-react';
|
||||
import { ChatMessages } from '../chat/ChatMessages';
|
||||
import type { ChatMessage } from '../types';
|
||||
import { Button } from '../components/ui/button';
|
||||
|
||||
interface SharedThreadData {
|
||||
@@ -11,18 +11,13 @@ interface SharedThreadData {
|
||||
title: string;
|
||||
created_at: string;
|
||||
};
|
||||
messages: Array<{
|
||||
id: string;
|
||||
role: string;
|
||||
content: string;
|
||||
timestamp: string;
|
||||
toolCalls?: any[];
|
||||
}>;
|
||||
messages: ChatMessage[];
|
||||
read_only: boolean;
|
||||
}
|
||||
|
||||
export function SharedThread() {
|
||||
const { token } = useParams<{ token: string }>();
|
||||
// Served at /shared/{token} (server.py); this app has no client-side router.
|
||||
const token = decodeURIComponent(window.location.pathname.split('/shared/')[1]?.split('/')[0] ?? '');
|
||||
const [threadData, setThreadData] = useState<SharedThreadData | null>(null);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [error, setError] = useState<string>('');
|
||||
@@ -128,10 +123,13 @@ export function SharedThread() {
|
||||
<div className="bg-white rounded-lg shadow-sm border border-gray-200 overflow-hidden">
|
||||
{threadData.messages.length > 0 ? (
|
||||
<div className="divide-y divide-gray-100">
|
||||
<ChatMessages
|
||||
<ChatMessages
|
||||
messages={threadData.messages}
|
||||
sessionId={threadData.thread_id}
|
||||
isSharedView={true}
|
||||
currentResponse=""
|
||||
thinkingSteps={[]}
|
||||
toolCalls={[]}
|
||||
isStreaming={false}
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
|
||||
@@ -0,0 +1,112 @@
|
||||
# Spike #293 — BeautifulUI primitives in PraisonAIUI
|
||||
|
||||
Time-boxed evaluation proving BeautifulUI's AI-native primitives can build inside
|
||||
PraisonAIUI's React 19 + Vite + Tailwind v4 toolchain and render against **mock,
|
||||
PraisonAIUI-shaped** agent event streams.
|
||||
|
||||
**This folder is a spike. It is intentionally isolated from the shipped bundle:**
|
||||
nothing here is imported by `src/main.tsx`, and none of it is synced into
|
||||
`src/praisonaiui/templates/frontend`. Removing `src/frontend/src/spike/` and
|
||||
`src/frontend/spike.html` fully reverts the spike.
|
||||
|
||||
## How to run
|
||||
|
||||
```bash
|
||||
cd src/frontend
|
||||
npm install
|
||||
npm run dev # then open http://localhost:5173/spike.html
|
||||
```
|
||||
|
||||
`SpikePage.tsx` drives the three primitives from `mockEvents.ts`, which uses the
|
||||
real `SSEEvent` contract from `src/types.ts` (`run_content` deltas,
|
||||
`reasoning_step` / `tool_call_started` / `tool_call_completed`, and an
|
||||
`approval_request` payload shaped like a row from `GET /api/approvals`).
|
||||
|
||||
## What was vendored (upstream pin)
|
||||
|
||||
- Registry index: `https://www.beautifului.dev/r/registry.json`
|
||||
- Component JSON: `https://www.beautifului.dev/r/<name>.json`
|
||||
- Upstream repo: `slev12397/beautiful-ui` @ **`44a274e598395ab61e7c96c26fda2758780253b7`** (2026-09-22)
|
||||
- License: **MIT** (upstream repo)
|
||||
|
||||
| Primitive | Registry item | Source | Registry deps | npm deps |
|
||||
|---|---|---|---|---|
|
||||
| StreamingText | `streaming-text` | `beautifui/primitives/StreamingText.tsx` (10.3 KB) | foundation | **none** (`react` only) |
|
||||
| ThinkingState | `thinking-state` | `beautifui/primitives/ThinkingState.tsx` (11.3 KB) | foundation | **none** (`react` only) |
|
||||
| ApprovalCard | `approval-card` | `beautifui/primitives/ApprovalCard.tsx` (16.5 KB) | foundation, button, glide-menu | **none** (`react` only) |
|
||||
| Button (dep) | `button` | `beautifui/atoms/Button.tsx` (2.0 KB) | foundation | `class-variance-authority` *(already a PraisonAIUI dep)* |
|
||||
| GlideMenu (dep) | `glide-menu` | `beautifui/primitives/GlideMenu.tsx` (1.9 KB) | foundation | **none** (`react` only) |
|
||||
| foundation | `foundation` | `beautifui/foundation.css` (17.5 KB) | — | `shadow-plugin` *(see deviation)* |
|
||||
|
||||
### npm packages pulled by the registry & licence check
|
||||
|
||||
For the three chosen primitives (+ their two registry deps) the registry pulls
|
||||
**zero new runtime npm packages**. The only npm dependency any of them declares is
|
||||
`class-variance-authority` (via `button`), which PraisonAIUI already ships.
|
||||
|
||||
- `liveline`, `glimm` — **not pulled.** These are referenced only by chart-style
|
||||
components (InsightCards) that we did not adopt. No licence review needed for this spike.
|
||||
- `@central-icons-react` — **avoided.** All three primitives render icons as
|
||||
**inline SVG** (no icon package at all), so no `lucide-react` substitution was
|
||||
even required. This is the cleanest possible outcome for the icon concern.
|
||||
|
||||
## Deviations from a raw `shadcn add`
|
||||
|
||||
1. **`shadow-plugin` removed.** `foundation.css` imports `shadow-plugin/unprefixed`
|
||||
(an extra Tailwind plugin dependency). To keep the spike lightweight we replaced
|
||||
it with a small local `@theme` block defining the `--shadow-*` scale. Visual
|
||||
shadows are approximate but faithful.
|
||||
2. **Unbalanced-brace fix (upstream bug).** The flattened `foundation.css` emits a
|
||||
**stray extra `}`** around `.records-footer-hint` (a `@media` block that lost its
|
||||
opening line in the registry export). This makes Tailwind v4 fail with
|
||||
`Missing opening {`. We removed the stray brace. **This is a real upstream blocker
|
||||
worth reporting to `beautiful-ui`.**
|
||||
3. **Imports rewritten** from `@/components/*` to `@/spike/beautifui/*` so the spike
|
||||
stays isolated and cannot collide with the existing shadcn `components/ui/button.tsx`.
|
||||
4. **`"use client"` directives dropped** (Vite SPA, not RSC).
|
||||
|
||||
## Build result
|
||||
|
||||
`vite build` of the spike entry (`spike.html`) succeeds:
|
||||
|
||||
```
|
||||
✓ 39 modules transformed
|
||||
spike.css 97.82 kB │ gzip: 17.48 kB (Tailwind + foundation)
|
||||
spike.js 247.94 kB │ gzip: 78.52 kB (React runtime + 3 primitives + 2 deps)
|
||||
```
|
||||
|
||||
- **Bundle size:** the primitives are cheap — self-contained TSX with no extra npm
|
||||
deps. The JS weight is dominated by the React runtime; the incremental cost of the
|
||||
three primitives is on the order of ~40 KB raw / low-teens KB gzip. The CSS is
|
||||
full Tailwind + the ~17 KB foundation token/keyframe layer.
|
||||
- **Motion / `prefers-reduced-motion`:** foundation ships two
|
||||
`@media (prefers-reduced-motion: reduce)` guards (global animation kill-switch +
|
||||
a StreamText-specific one). Motion is handled responsibly out of the box.
|
||||
- **Dark mode parity:** foundation defines its **own** token namespace (`--page`,
|
||||
`--canvas`, `--ink`, `--accent`, …) with its own `.dark` block, *separate* from
|
||||
PraisonAIUI's shadcn tokens (`--background`/`--foreground` via `hsl(var(--…))`).
|
||||
Good news: **no token collision** with the dashboard. Trade-off: the primitives do
|
||||
**not** automatically inherit `chat.css`/YAML-driven theme colours — they'd need a
|
||||
token bridge (map `--ink`→`--foreground`, `--surface`→`--card`, `--accent`→`--primary`)
|
||||
before they visually match the shipped chat surface in both themes.
|
||||
|
||||
## Assessment
|
||||
|
||||
**Fit score: 7/10** for a spike, with caveats for production migration.
|
||||
|
||||
**Blockers / gaps:**
|
||||
- Upstream `foundation.css` brace bug (fixed here; report upstream).
|
||||
- No automatic theme inheritance — needs a token bridge to match `chat.css` dark/light.
|
||||
- Primitives embed **demo content** (ice-cream sales copy, fake sources). They accept
|
||||
props (`content`, `rows`, `questions`, `onSubmitted`, …) so they *can* be driven by
|
||||
real events — proven in `SpikePage` — but the built-in defaults must be replaced.
|
||||
- PraisonAIUI already has native equivalents: `chat/ThinkingSteps.tsx`,
|
||||
`chat/ToolCallDisplay.tsx`, streaming in `chat/ChatMessages.tsx`. Adopting
|
||||
BeautifulUI should be weighed against enhancing these rather than duplicating them.
|
||||
|
||||
**Recommended primitive order for migration (lowest risk → highest):**
|
||||
1. **StreamingText** — pure token-stream UX, `react`-only, maps 1:1 to `run_content`.
|
||||
2. **ThinkingState** — reasoning/tool trace, `react`-only, maps to
|
||||
`reasoning_step` + `tool_call_*`; overlaps existing `ThinkingSteps.tsx`.
|
||||
3. **ApprovalCard** — highest value (HITL) but most surface: pulls `button` +
|
||||
`glide-menu` and needs wiring to `POST /api/approvals/{id}/resolve`.
|
||||
@@ -0,0 +1,90 @@
|
||||
/* Spike #293 — BeautifulUI primitives driven by PraisonAIUI-shaped mock events.
|
||||
*
|
||||
* Dev-only harness. Run with `npm run dev` and open /spike.html. This page is
|
||||
* NOT imported by the shipped library entry (main.tsx) and is NOT synced into
|
||||
* src/praisonaiui/templates/frontend — per the issue's non-goals it must not
|
||||
* touch the production bundle.
|
||||
*/
|
||||
import { useEffect, useState } from "react";
|
||||
import StreamingText from "@/spike/beautifui/primitives/StreamingText";
|
||||
import ThinkingState from "@/spike/beautifui/primitives/ThinkingState";
|
||||
import ApprovalCard from "@/spike/beautifui/primitives/ApprovalCard";
|
||||
import {
|
||||
streamingTimeline,
|
||||
tokensFromDeltas,
|
||||
rowsFromReasoning,
|
||||
REASONING_STREAM,
|
||||
APPROVAL_REQUEST,
|
||||
} from "@/spike/mockEvents";
|
||||
|
||||
function Panel({ title, children }: { title: string; children: React.ReactNode }) {
|
||||
return (
|
||||
<section style={{ marginBottom: 40 }}>
|
||||
<h2 style={{ font: "600 13px/1.4 ui-monospace, monospace", opacity: 0.6, marginBottom: 12 }}>{title}</h2>
|
||||
{children}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
export default function SpikePage() {
|
||||
const [dark, setDark] = useState(false);
|
||||
const [log, setLog] = useState<string[]>([]);
|
||||
|
||||
useEffect(() => {
|
||||
document.documentElement.classList.toggle("dark", dark);
|
||||
}, [dark]);
|
||||
|
||||
// Emulate the SSE timeline so we exercise real delta pacing (logs only —
|
||||
// the primitives below are fed the fully-resolved token/row arrays).
|
||||
useEffect(() => {
|
||||
const frames = streamingTimeline();
|
||||
const timers = frames.map((f) =>
|
||||
setTimeout(() => setLog((l) => [...l.slice(-4), `${f.event.type}: ${f.event.token ?? ""}`]), f.at),
|
||||
);
|
||||
return () => timers.forEach(clearTimeout);
|
||||
}, []);
|
||||
|
||||
const tokens = tokensFromDeltas(streamingTimeline());
|
||||
const rows = rowsFromReasoning(REASONING_STREAM);
|
||||
|
||||
return (
|
||||
<div style={{ maxWidth: 720, margin: "0 auto", padding: 32, fontFamily: "system-ui, sans-serif" }}>
|
||||
<header style={{ display: "flex", justifyContent: "space-between", alignItems: "center", marginBottom: 32 }}>
|
||||
<div>
|
||||
<h1 style={{ font: "700 20px/1.2 system-ui" }}>BeautifulUI spike</h1>
|
||||
<p style={{ opacity: 0.6, fontSize: 13 }}>Issue #293 · mock PraisonAIUI event streams</p>
|
||||
</div>
|
||||
<button onClick={() => setDark((d) => !d)} style={{ padding: "6px 12px", borderRadius: 8 }}>
|
||||
{dark ? "☀ light" : "🌙 dark"}
|
||||
</button>
|
||||
</header>
|
||||
|
||||
<Panel title="StreamingText ← run_content deltas">
|
||||
<StreamingText content={tokens} loop={false} sources={[]} followUps={[]} />
|
||||
</Panel>
|
||||
|
||||
<Panel title="ThinkingState ← reasoning_step / tool_call_*">
|
||||
<ThinkingState rows={rows} active="Thinking" done={`Ran ${rows.length} steps`} />
|
||||
</Panel>
|
||||
|
||||
<Panel title="ApprovalCard ← approval_request (/api/approvals)">
|
||||
<ApprovalCard
|
||||
questions={APPROVAL_REQUEST.questions}
|
||||
onSubmitted={(answers) =>
|
||||
// In production this maps to POST /api/approvals/{id}/resolve.
|
||||
setLog((l) => [
|
||||
...l.slice(-4),
|
||||
`resolve ${APPROVAL_REQUEST.id}: ${JSON.stringify(answers)}`,
|
||||
])
|
||||
}
|
||||
/>
|
||||
</Panel>
|
||||
|
||||
<Panel title="event log (last 5)">
|
||||
<pre style={{ font: "12px/1.5 ui-monospace, monospace", opacity: 0.7, whiteSpace: "pre-wrap" }}>
|
||||
{log.join("\n") || "…"}
|
||||
</pre>
|
||||
</Panel>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
/* Vendored from BeautifulUI registry: https://www.beautifului.dev/r/button.json
|
||||
* Spike #293 — DO NOT ship in templates/frontend. Imports rewritten to @/spike/beautifui/*.
|
||||
* License: MIT (upstream https://github.com/slev12397/beautiful-ui). */
|
||||
import type { ButtonHTMLAttributes } from "react";
|
||||
import { cva, type VariantProps } from "class-variance-authority";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
const filledShadow = "shadow-[inset_0_1px_0_rgba(255,255,255,0.14)]";
|
||||
|
||||
/* Pill-shaped by default — the app's core button style. Explicit symmetric
|
||||
* padding (not a fixed height) so the top/bottom spacing is always equal. */
|
||||
export const buttonVariants = cva(
|
||||
`inline-flex items-center justify-center font-medium select-none
|
||||
transition-[transform,background-color,opacity] duration-150 ease-out
|
||||
active:scale-[0.96] disabled:opacity-50 disabled:pointer-events-none`,
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
primary: `bg-ink text-canvas hover:opacity-90 dark:bg-ink dark:text-canvas ${filledShadow}`,
|
||||
secondary: "bg-surface text-ink shadow-btn hover:bg-inset aria-expanded:bg-hover",
|
||||
ghost: "bg-hover-2 text-ink hover:bg-line-strong",
|
||||
accent: `bg-accent text-white hover:bg-accent-ink ${filledShadow}`,
|
||||
success: `bg-green text-white hover:brightness-95 ${filledShadow}`,
|
||||
/* transparent until hovered — for dense toolbars/action rows */
|
||||
quiet: "text-ink hover:bg-hover",
|
||||
},
|
||||
size: {
|
||||
/* compact toolbar pill — fixed height, lighter weight */
|
||||
xs: "h-7 rounded-full px-2.5 text-[12px] font-normal leading-none gap-1",
|
||||
/* canonical action pill — 27px tall, roomy sides */
|
||||
sm: "h-[27px] px-3 text-[13px] leading-none rounded-full gap-1.5",
|
||||
md: "px-4 py-[9px] text-sm leading-none rounded-full gap-2",
|
||||
},
|
||||
},
|
||||
defaultVariants: { variant: "secondary", size: "md" },
|
||||
},
|
||||
);
|
||||
|
||||
export type ButtonVariant = NonNullable<VariantProps<typeof buttonVariants>["variant"]>;
|
||||
|
||||
export function Button({
|
||||
variant,
|
||||
size,
|
||||
className,
|
||||
...props
|
||||
}: ButtonHTMLAttributes<HTMLButtonElement> & VariantProps<typeof buttonVariants>) {
|
||||
return <button className={cn(buttonVariants({ variant, size }), className)} {...props} />;
|
||||
}
|
||||
+659
@@ -0,0 +1,659 @@
|
||||
/* Vendored from BeautifulUI foundation: https://www.beautifului.dev/r/foundation.json
|
||||
* Spike #293. shadow-plugin dependency neutralized (see note below). License: MIT. */
|
||||
@import "tailwindcss";
|
||||
/* shadow-plugin (flornkm) — smooth, layered shadow stacks. The unprefixed
|
||||
* entry maps them onto Tailwind's --shadow-xs…2xl scale; our semantic tokens
|
||||
* below build on those values so the whole app picks up the smooth shadows. */
|
||||
/* @import "shadow-plugin/unprefixed"; -- removed for spike */
|
||||
/* SPIKE deviation (#293): upstream imports `shadow-plugin/unprefixed` (extra npm dep).
|
||||
* To keep the spike lightweight we define the --shadow-* scale locally instead. */
|
||||
@theme inline {
|
||||
--shadow-2xs: 0 1px 0 0 oklch(0 0 0 / 0.03);
|
||||
--shadow-xs: 0 1px 2px 0 oklch(0 0 0 / 0.05);
|
||||
--shadow-sm: 0 1px 2px 0 oklch(0 0 0 / 0.06), 0 1px 1px -1px oklch(0 0 0 / 0.04);
|
||||
--shadow-md: 0 2px 4px -1px oklch(0 0 0 / 0.07), 0 1px 2px -1px oklch(0 0 0 / 0.05);
|
||||
--shadow-lg: 0 8px 16px -4px oklch(0 0 0 / 0.10), 0 2px 4px -2px oklch(0 0 0 / 0.06);
|
||||
--shadow-xl: 0 16px 32px -8px oklch(0 0 0 / 0.12);
|
||||
--shadow-2xl: 0 24px 48px -12px oklch(0 0 0 / 0.18);
|
||||
}
|
||||
|
||||
|
||||
@custom-variant dark (&:where(.dark, .dark *));
|
||||
|
||||
/* GENERIC FOUNDATION — tokens, @theme, base rules, the primitive-*
|
||||
* spacing utilities, and shared keyframes below are needed by EVERY
|
||||
* component. Blocks tagged COMPONENT-SPECIFIC further down belong to
|
||||
* one primitive each. */
|
||||
/* ──────────────────────────────────────────────────────────
|
||||
* Beautiful UI — design tokens
|
||||
* Cool near-white canvas, white cards, hairline borders, layered
|
||||
* single-digit-opacity shadows, a neutral ink ramp, and semantic
|
||||
* color used sparingly as a condiment.
|
||||
* ────────────────────────────────────────────────────────── */
|
||||
|
||||
:root {
|
||||
/* surfaces — cool, blue-tinted neutrals */
|
||||
--page: oklch(0.985 0.001 286.376);
|
||||
--canvas: oklch(0.961 0.002 247.84);
|
||||
--surface: oklch(1 0 0);
|
||||
--inset: oklch(0.979 0.002 247.839);
|
||||
--hover: oklch(0.97 0.002 247.839);
|
||||
--hover-2: oklch(0.933 0.003 247.86);
|
||||
|
||||
/* ink ramp */
|
||||
--ink: oklch(0.247 0.006 258.361);
|
||||
--ink-2: oklch(0.506 0.01 264.477);
|
||||
--ink-3: oklch(0.695 0.009 264.505);
|
||||
|
||||
/* borders — solid and crisp, not alpha */
|
||||
--line: oklch(0.946 0.003 264.542);
|
||||
--line-strong: oklch(0.912 0.005 258.326);
|
||||
--line-soft: oklch(0.966 0.002 264.542);
|
||||
--grid-line: color-mix(in srgb, var(--line) 78%, transparent);
|
||||
--field: oklch(0.961 0.001 286.375);
|
||||
--stripe: oklch(0.405 0 0 / 0.075);
|
||||
--stripe-bg: oklch(0.97 0 0);
|
||||
|
||||
/* accent */
|
||||
--accent: oklch(0.626 0.205 254.947);
|
||||
--accent-ink: oklch(0.556 0.187 255.617);
|
||||
--accent-tint: oklch(0.96 0.019 252.878);
|
||||
|
||||
/* semantic */
|
||||
--green: oklch(0.603 0.155 150.883);
|
||||
--green-tint: oklch(0.958 0.017 159.118);
|
||||
--orange: oklch(0.689 0.179 49.902);
|
||||
--orange-tint: oklch(0.964 0.021 67.581);
|
||||
--red: oklch(0.621 0.192 23.042);
|
||||
--red-tint: oklch(0.956 0.017 17.462);
|
||||
|
||||
/* chart overlays — neutral system colors with enough contrast on either theme */
|
||||
--tooltip-bg: oklch(0.272 0.008 264.435);
|
||||
--tooltip-fg: oklch(0.976 0.002 247.839);
|
||||
--tooltip-muted: oklch(0.731 0.008 260.731);
|
||||
--tooltip-border: oklch(0.356 0.007 264.474);
|
||||
|
||||
/* shadows — our solid hairline ring + shadow-plugin's smooth layered stacks
|
||||
* (--shadow-xs…lg come from shadow-plugin/unprefixed) so every surface reads
|
||||
* consistently across the app. */
|
||||
--shadow-hairline: 0 0 0 1px var(--line);
|
||||
--shadow-btn: 0 0 0 1px var(--line-strong), var(--shadow-xs);
|
||||
--shadow-card: 0 0 0 1px var(--line), var(--shadow-sm);
|
||||
--shadow-raised: 0 0 0 1px var(--line), var(--shadow-md);
|
||||
--shadow-overlay: 0 0 0 1px var(--line), var(--shadow-lg);
|
||||
--shadow-inset-field: inset 0 1px 2px oklch(0 0 0 / 0.12);
|
||||
}
|
||||
|
||||
.dark {
|
||||
--page: oklch(0.209 0.004 264.477);
|
||||
--canvas: oklch(0.231 0.004 264.487);
|
||||
--surface: oklch(0.26 0.006 271.191);
|
||||
--inset: oklch(0.243 0.004 264.492);
|
||||
--hover: oklch(0.289 0.006 271.22);
|
||||
--hover-2: oklch(0.318 0.007 274.747);
|
||||
|
||||
--ink: oklch(0.964 0.002 247.839);
|
||||
--ink-2: oklch(0.731 0.008 260.731);
|
||||
--ink-3: oklch(0.541 0.01 264.484);
|
||||
|
||||
--line: oklch(0.308 0.006 258.354);
|
||||
--line-strong: oklch(0.356 0.007 264.474);
|
||||
--line-soft: oklch(0.278 0.006 258.354);
|
||||
--field: oklch(0.293 0.006 271.223);
|
||||
--stripe: oklch(1 0 0 / 0.055);
|
||||
--stripe-bg: oklch(0.226 0.004 264.485);
|
||||
|
||||
--accent: oklch(0.68 0.173 253.301);
|
||||
--accent-ink: oklch(0.788 0.113 248.33);
|
||||
--accent-tint: oklch(0.68 0.173 253.301 / 0.16);
|
||||
|
||||
--green: oklch(0.705 0.154 153.814);
|
||||
--green-tint: oklch(0.705 0.154 153.814 / 0.14);
|
||||
--orange: oklch(0.746 0.156 55.642);
|
||||
--orange-tint: oklch(0.746 0.156 55.642 / 0.14);
|
||||
--red: oklch(0.666 0.18 21.433);
|
||||
--red-tint: oklch(0.666 0.18 21.433 / 0.14);
|
||||
|
||||
--tooltip-bg: oklch(0.182 0.004 264.459);
|
||||
--tooltip-fg: oklch(0.964 0.002 247.839);
|
||||
--tooltip-muted: oklch(0.731 0.008 260.731);
|
||||
--tooltip-border: oklch(0.308 0.006 258.354);
|
||||
|
||||
--shadow-hairline: 0 0 0 1px var(--line);
|
||||
/* Elevation rings go to a subtle *light* hairline in dark mode: the ring
|
||||
* paints over the surface behind the element, so a low-alpha white edge
|
||||
* dissolves into the shadow as one continuous stroke (per smooth-shadow-ring),
|
||||
* graduated by how far the surface floats. */
|
||||
--shadow-btn: 0 0 0 1px oklch(1 0 0 / 0.1), 0 1px 2px oklch(0 0 0 / 0.3);
|
||||
--shadow-card:
|
||||
0 0 0 1px oklch(1 0 0 / 0.11), 0 1px 2px oklch(0 0 0 / 0.2),
|
||||
0 2px 6px oklch(0 0 0 / 0.2);
|
||||
--shadow-raised:
|
||||
0 0 0 1px oklch(1 0 0 / 0.13), 0 2px 10px oklch(0 0 0 / 0.22);
|
||||
--shadow-overlay:
|
||||
0 0 0 1px oklch(1 0 0 / 0.15), 0 8px 28px oklch(0 0 0 / 0.34);
|
||||
--shadow-inset-field: inset 0 1px 2px oklch(0 0 0 / 0.4);
|
||||
}
|
||||
|
||||
@theme inline {
|
||||
/* colors */
|
||||
--color-page: var(--page);
|
||||
--color-canvas: var(--canvas);
|
||||
--color-surface: var(--surface);
|
||||
--color-inset: var(--inset);
|
||||
--color-ink: var(--ink);
|
||||
--color-ink-2: var(--ink-2);
|
||||
--color-ink-3: var(--ink-3);
|
||||
--color-hover: var(--hover);
|
||||
--color-hover-2: var(--hover-2);
|
||||
--color-field: var(--field);
|
||||
--color-line: var(--line);
|
||||
--color-line-strong: var(--line-strong);
|
||||
--color-accent: var(--accent);
|
||||
--color-accent-ink: var(--accent-ink);
|
||||
--color-accent-tint: var(--accent-tint);
|
||||
--color-green: var(--green);
|
||||
--color-green-tint: var(--green-tint);
|
||||
--color-orange: var(--orange);
|
||||
--color-orange-tint: var(--orange-tint);
|
||||
--color-red: var(--red);
|
||||
--color-red-tint: var(--red-tint);
|
||||
|
||||
/* shadows */
|
||||
--shadow-hairline: var(--shadow-hairline);
|
||||
--shadow-btn: var(--shadow-btn);
|
||||
--shadow-inset-field: var(--shadow-inset-field);
|
||||
--shadow-card: var(--shadow-card);
|
||||
--shadow-raised: var(--shadow-raised);
|
||||
--shadow-overlay: var(--shadow-overlay);
|
||||
|
||||
/* type */
|
||||
--font-sans: var(--font-inter), ui-sans-serif, system-ui, sans-serif;
|
||||
--font-mono: var(--font-mono-face), ui-monospace, "SF Mono", monospace;
|
||||
|
||||
/* radii — chips 6, controls 8, cards 10, windows 14, pills 999 */
|
||||
--radius-chip: 6px;
|
||||
--radius-control: 8px;
|
||||
--radius-card: 10px;
|
||||
--radius-window: 14px;
|
||||
|
||||
/* easing — strong curves; built-ins are too weak */
|
||||
--ease-out-strong: cubic-bezier(0.23, 1, 0.32, 1);
|
||||
--ease-in-out-strong: cubic-bezier(0.77, 0, 0.175, 1);
|
||||
--ease-link: cubic-bezier(0.16, 1, 0.3, 1);
|
||||
}
|
||||
|
||||
/* ── base ─────────────────────────────────────────────── */
|
||||
|
||||
html {
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
text-rendering: optimizeLegibility;
|
||||
}
|
||||
|
||||
body {
|
||||
background-color: var(--stripe-bg);
|
||||
background-image: repeating-linear-gradient(
|
||||
-45deg,
|
||||
transparent 0,
|
||||
transparent 7px,
|
||||
var(--stripe) 7px,
|
||||
var(--stripe) 8px
|
||||
);
|
||||
/* Pin the stripe pattern to the viewport so it stays put on scroll. */
|
||||
background-attachment: fixed;
|
||||
color: var(--ink);
|
||||
font-feature-settings: "cv11", "ss01";
|
||||
font-size: 14px;
|
||||
letter-spacing: -0.01em;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
::selection {
|
||||
background: var(--accent-tint);
|
||||
color: var(--ink);
|
||||
}
|
||||
|
||||
/* ── interface hygiene (interfaces.dev cheat sheet) ────── */
|
||||
|
||||
/* headings balance; descriptions avoid orphans (skip long-form — we have none) */
|
||||
h1,
|
||||
h2,
|
||||
h3,
|
||||
h4 {
|
||||
text-wrap: balance;
|
||||
}
|
||||
|
||||
p {
|
||||
text-wrap: pretty;
|
||||
}
|
||||
|
||||
/* underlines clear descenders */
|
||||
a {
|
||||
text-underline-position: from-font;
|
||||
text-decoration-skip-ink: auto;
|
||||
}
|
||||
|
||||
/* theme switches swap every token at once — freeze transitions so the flip
|
||||
* is one clean repaint instead of hundreds of mismatched color fades */
|
||||
.theme-switching *,
|
||||
.theme-switching *::before,
|
||||
.theme-switching *::after {
|
||||
transition: none !important;
|
||||
}
|
||||
|
||||
/* touch: :hover sticks after a tap, so neutralize row/header hovers there */
|
||||
@media (hover: none) {
|
||||
.records-row:hover > .records-cell {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.records-row:hover > td.is-colsel {
|
||||
background: color-mix(in srgb, var(--accent) 4%, var(--surface));
|
||||
}
|
||||
|
||||
.records-header-button:hover {
|
||||
background: transparent;
|
||||
color: var(--ink-2);
|
||||
}
|
||||
}
|
||||
|
||||
/* Keep the component nav scrollable but hide its scrollbar. */
|
||||
.component-nav-scroll {
|
||||
scrollbar-width: none; /* Firefox */
|
||||
-ms-overflow-style: none; /* legacy Edge */
|
||||
}
|
||||
.component-nav-scroll::-webkit-scrollbar {
|
||||
display: none; /* WebKit */
|
||||
}
|
||||
|
||||
@media (min-width: 64rem) {
|
||||
.component-nav-scroll {
|
||||
-webkit-mask-image: linear-gradient(
|
||||
to bottom,
|
||||
oklch(0 0 0) 0,
|
||||
oklch(0 0 0) calc(100% - 6rem),
|
||||
transparent 100%
|
||||
);
|
||||
mask-image: linear-gradient(
|
||||
to bottom,
|
||||
oklch(0 0 0) 0,
|
||||
oklch(0 0 0) calc(100% - 6rem),
|
||||
transparent 100%
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/* Mobile demo gestures should operate controls and visualizations rather than
|
||||
* selecting surrounding labels. Text entry and code remain intentionally
|
||||
* selectable. */
|
||||
@media (hover: none) and (pointer: coarse) {
|
||||
.primitive-showcase {
|
||||
-webkit-user-select: none;
|
||||
user-select: none;
|
||||
-webkit-touch-callout: none;
|
||||
}
|
||||
|
||||
.primitive-showcase :is(input, textarea, [contenteditable="true"], pre, code) {
|
||||
-webkit-user-select: text;
|
||||
user-select: text;
|
||||
-webkit-touch-callout: default;
|
||||
}
|
||||
|
||||
.primitive-showcase :is(a, button, [role="button"]) {
|
||||
-webkit-tap-highlight-color: transparent;
|
||||
}
|
||||
|
||||
.primitive-showcase :is(a, button, [role="button"]):focus-visible {
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.primitive-showcase :is(img, svg, canvas) {
|
||||
-webkit-user-drag: none;
|
||||
}
|
||||
|
||||
.insight-chart-stage {
|
||||
touch-action: pan-y;
|
||||
}
|
||||
}
|
||||
|
||||
/* Tailwind v4 resets buttons to cursor:default — anything
|
||||
* clickable must invite the click */
|
||||
button:not(:disabled),
|
||||
[role="button"]:not([aria-disabled="true"]),
|
||||
label:has(input),
|
||||
a {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
|
||||
:focus-visible {
|
||||
outline: 2px solid var(--accent);
|
||||
outline-offset: 2px;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
input:focus-visible,
|
||||
textarea:focus-visible,
|
||||
select:focus-visible,
|
||||
[contenteditable]:focus-visible {
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.animated-underline {
|
||||
position: relative;
|
||||
display: inline-block;
|
||||
}
|
||||
|
||||
.animated-underline::after {
|
||||
position: absolute;
|
||||
right: 0;
|
||||
bottom: -1px;
|
||||
left: 0;
|
||||
height: 1px;
|
||||
content: "";
|
||||
background: currentColor;
|
||||
transform: scaleX(0);
|
||||
transform-origin: left center;
|
||||
transition: transform 280ms var(--ease-link);
|
||||
}
|
||||
|
||||
a:hover .animated-underline::after,
|
||||
a:focus-visible .animated-underline::after {
|
||||
transform: scaleX(1);
|
||||
}
|
||||
|
||||
/* ══════════════════════════════════════════════════════════
|
||||
* COMPONENT-SPECIFIC — StreamingText / ContextCards
|
||||
* Favicon source avatars.
|
||||
* Copy this block only alongside StreamingText / ContextCards; it is not part of the
|
||||
* generic foundation above.
|
||||
* ══════════════════════════════════════════════════════════ */
|
||||
.source-avatar {
|
||||
box-shadow: 0 0 0 1px oklch(0.21 0.034 263.436 / 0.1);
|
||||
}
|
||||
|
||||
.dark .source-avatar {
|
||||
box-shadow: 0 0 0 1px oklch(1 0 0 / 0.08);
|
||||
}
|
||||
|
||||
/* ── primitive spacing system ─────────────────────────────
|
||||
* Shared optical offsets for small demo cards. Use these
|
||||
* instead of hand-tuning 10/12/14px per component.
|
||||
*/
|
||||
|
||||
.primitive-card-pad {
|
||||
padding: 12px;
|
||||
}
|
||||
|
||||
.primitive-card-bar {
|
||||
padding: 10px 12px;
|
||||
}
|
||||
|
||||
.primitive-card-footer {
|
||||
padding: 10px;
|
||||
}
|
||||
|
||||
.primitive-table-cell {
|
||||
padding: 10px 12px;
|
||||
}
|
||||
|
||||
.primitive-icon-button {
|
||||
display: inline-flex;
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border-radius: var(--radius-control);
|
||||
}
|
||||
|
||||
/* ── shared keyframes ─────────────────────────────────── */
|
||||
|
||||
@keyframes shimmer-text {
|
||||
from {
|
||||
background-position: 150% center;
|
||||
}
|
||||
to {
|
||||
background-position: -50% center;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes fade-up {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(8px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
.home-reveal {
|
||||
will-change: opacity, transform, filter;
|
||||
}
|
||||
|
||||
/* The sidebar is one persistent 224px tree clipped by a 52px shell.
|
||||
* Every rail glyph remains centered at x=26 in both states. */
|
||||
.records-footer-hint {
|
||||
display: none;
|
||||
}
|
||||
/* SPIKE fix (#293): upstream foundation.css emits a stray extra `}` here — a
|
||||
* flattened @media block that lost its opening line. Removed so Tailwind v4
|
||||
* can parse the stylesheet. See spike report. */
|
||||
|
||||
/* ── filter table electric status colors ─────────────────── */
|
||||
|
||||
/* ══════════════════════════════════════════════════════════
|
||||
* COMPONENT-SPECIFIC — FilterTable
|
||||
* Electric status-pill colors.
|
||||
* Copy this block only alongside FilterTable; it is not part of the
|
||||
* generic foundation above.
|
||||
* ══════════════════════════════════════════════════════════ */
|
||||
.filter-status-todo,
|
||||
.filter-status-progress,
|
||||
.filter-status-done {
|
||||
border: 1px solid transparent;
|
||||
}
|
||||
|
||||
/* ══════════════════════════════════════════════════════════
|
||||
* COMPONENT-SPECIFIC — InsightCards
|
||||
* Liveline chart tooltip + cursor.
|
||||
* Copy this block only alongside InsightCards; it is not part of the
|
||||
* generic foundation above.
|
||||
* ══════════════════════════════════════════════════════════ */
|
||||
.insight-chart-stage {
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.insight-chart-cursor {
|
||||
position: absolute;
|
||||
z-index: 4;
|
||||
top: 0;
|
||||
bottom: 0;
|
||||
width: 1px;
|
||||
background: var(--ink);
|
||||
opacity: 0.26;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.insight-chart-tooltip-anchor {
|
||||
position: absolute;
|
||||
z-index: 5;
|
||||
top: 8px;
|
||||
transform: translateX(-50%);
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.insight-chart-tooltip {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
padding: 5px 9px;
|
||||
border: 1px solid var(--line-strong);
|
||||
border-radius: 8px;
|
||||
color: var(--tooltip-fg);
|
||||
background: var(--tooltip-bg);
|
||||
box-shadow: var(--shadow-overlay);
|
||||
font-size: 11.5px;
|
||||
font-variant-numeric: tabular-nums;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.insight-chart-tooltip-item {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.insight-chart-tooltip-dot {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
flex: 0 0 8px;
|
||||
border-radius: 50%;
|
||||
}
|
||||
|
||||
/* Status pills share the same base-hue → color-mix derivation as .records-tag,
|
||||
* so both components stay consistent across light and dark. */
|
||||
.filter-status-todo {
|
||||
--tag-base: oklch(0.757 0.153 66.401);
|
||||
}
|
||||
.filter-status-progress {
|
||||
--tag-base: oklch(0.671 0.118 219.351);
|
||||
}
|
||||
.filter-status-done {
|
||||
--tag-base: oklch(0.652 0.131 162.865);
|
||||
}
|
||||
|
||||
.filter-status-todo,
|
||||
.filter-status-progress,
|
||||
.filter-status-done {
|
||||
color: color-mix(in srgb, var(--tag-base) 92%, var(--ink));
|
||||
background: color-mix(in srgb, var(--tag-base) 20%, var(--surface));
|
||||
border-color: color-mix(in srgb, var(--tag-base) 34%, var(--surface));
|
||||
}
|
||||
|
||||
/* Dark mode — a richer, saturated same-hue fill
|
||||
* with bright same-hue text and no visible border (light mode keeps the soft
|
||||
* tinted pastels above). Applies to both the records grid and status pills. */
|
||||
.dark .records-tag,
|
||||
.dark .filter-status-todo,
|
||||
.dark .filter-status-progress,
|
||||
.dark .filter-status-done {
|
||||
color: color-mix(in srgb, var(--tag-base) 86%, white);
|
||||
background: color-mix(in srgb, var(--tag-base) 34%, var(--surface));
|
||||
border-color: color-mix(in srgb, var(--tag-base) 34%, var(--surface));
|
||||
}
|
||||
|
||||
@keyframes fade-in {
|
||||
from {
|
||||
opacity: 0;
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes eq-bounce {
|
||||
0%,
|
||||
100% {
|
||||
transform: scaleY(0.35);
|
||||
}
|
||||
50% {
|
||||
transform: scaleY(1);
|
||||
}
|
||||
}
|
||||
|
||||
/* ── StreamText primitive ─────────────────────────────────
|
||||
* Fast character reveal: the newest few characters carry a
|
||||
* soft, masked blur so the leading edge resolves smoothly as
|
||||
* text flows in. The caret holds solid while streaming, then
|
||||
* blinks once the string settles. */
|
||||
/* ══════════════════════════════════════════════════════════
|
||||
* COMPONENT-SPECIFIC — StreamText
|
||||
* The streaming caret + tail.
|
||||
* Copy this block only alongside StreamText; it is not part of the
|
||||
* generic foundation above.
|
||||
* ══════════════════════════════════════════════════════════ */
|
||||
.stream-tail {
|
||||
filter: blur(1.6px);
|
||||
-webkit-mask-image: linear-gradient(to right, oklch(0 0 0) 20%, oklch(0 0 0 / 0.2));
|
||||
mask-image: linear-gradient(to right, oklch(0 0 0) 20%, oklch(0 0 0 / 0.2));
|
||||
}
|
||||
|
||||
.stream-caret {
|
||||
display: inline-block;
|
||||
width: 2px;
|
||||
height: 1.05em;
|
||||
margin-left: 1.5px;
|
||||
translate: 0 -0.5px;
|
||||
border-radius: 1px;
|
||||
background: var(--ink);
|
||||
vertical-align: text-bottom;
|
||||
animation: caret-blink 1s step-end infinite;
|
||||
}
|
||||
|
||||
.stream-caret.is-streaming {
|
||||
animation: none;
|
||||
}
|
||||
|
||||
@keyframes caret-blink {
|
||||
0%,
|
||||
100% {
|
||||
opacity: 1;
|
||||
}
|
||||
50% {
|
||||
opacity: 0;
|
||||
}
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.stream-caret {
|
||||
animation: none;
|
||||
}
|
||||
.stream-tail {
|
||||
filter: none;
|
||||
-webkit-mask-image: none;
|
||||
mask-image: none;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes pop-in {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: scale(0.95);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: scale(1);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes spin {
|
||||
to {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
|
||||
/* pixel-grid loader — brighten plateau, staggered per cell */
|
||||
@keyframes pixel-on {
|
||||
0%, 100% {
|
||||
opacity: 0.15;
|
||||
}
|
||||
18%, 42% {
|
||||
opacity: 1;
|
||||
}
|
||||
62% {
|
||||
opacity: 0.15;
|
||||
}
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
*,
|
||||
*::before,
|
||||
*::after {
|
||||
animation-duration: 0.01ms !important;
|
||||
animation-iteration-count: 1 !important;
|
||||
transition-duration: 0.01ms !important;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,420 @@
|
||||
/* Vendored from BeautifulUI registry: https://www.beautifului.dev/r/approval-card.json
|
||||
* Spike #293 — DO NOT ship in templates/frontend. Imports rewritten to @/spike/beautifui/*.
|
||||
* License: MIT (upstream https://github.com/slev12397/beautiful-ui). */
|
||||
import { useEffect, useLayoutEffect, useRef, useState, type CSSProperties } from "react";
|
||||
import { Button } from "@/spike/beautifui/atoms/Button";
|
||||
import GlideMenu from "@/spike/beautifui/primitives/GlideMenu";
|
||||
|
||||
/* ─────────────────────────────────────────────────────────
|
||||
* APPROVAL CARD (human-in-the-loop)
|
||||
* One question at a time. The stack slides vertically as you
|
||||
* move between questions (the card's height animates to fit),
|
||||
* the step counter rolls like an odometer, and the footer uses
|
||||
* pill actions — a quiet Skip and a dark Continue with a ⏎.
|
||||
* Single-choice answers auto-advance; multi-select waits.
|
||||
* ───────────────────────────────────────────────────────── */
|
||||
|
||||
export type ApprovalQuestion = {
|
||||
q: string;
|
||||
type: "radio" | "check";
|
||||
options: string[];
|
||||
};
|
||||
|
||||
const QUESTIONS: ApprovalQuestion[] = [
|
||||
{
|
||||
q: "How many flavors should we launch?",
|
||||
type: "radio",
|
||||
options: ["Three (core line)", "Five (full case)", "Just one hero"],
|
||||
},
|
||||
{
|
||||
q: "Which mix-ins should we stock?",
|
||||
type: "check",
|
||||
options: ["Chocolate chips", "Waffle bits", "Sprinkles"],
|
||||
},
|
||||
{
|
||||
q: "Which market do we enter first?",
|
||||
type: "radio",
|
||||
options: ["Food trucks", "Grocery freezers", "Scoop shops"],
|
||||
},
|
||||
];
|
||||
|
||||
export type ApprovalLabels = {
|
||||
skip: string;
|
||||
continue: string;
|
||||
send: string;
|
||||
customPlaceholder: string;
|
||||
sentMessage: string;
|
||||
};
|
||||
|
||||
const DEFAULT_LABELS: ApprovalLabels = {
|
||||
skip: "Skip",
|
||||
continue: "Continue",
|
||||
send: "Send",
|
||||
customPlaceholder: "Something else…",
|
||||
sentMessage: "Answers sent",
|
||||
};
|
||||
|
||||
const ROLL_MS = 400;
|
||||
const SLIDE = "360ms cubic-bezier(0.22, 1, 0.36, 1)";
|
||||
|
||||
/* odometer digits — each character that changes rolls up (or down) */
|
||||
function RollingDigits({ value }: { value: string }) {
|
||||
const prevRef = useRef(value);
|
||||
const [oldVal, setOldVal] = useState(value);
|
||||
const [newVal, setNewVal] = useState(value);
|
||||
const [rolling, setRolling] = useState(false);
|
||||
const [shifted, setShifted] = useState(false);
|
||||
const [dir, setDir] = useState<"up" | "down">("up");
|
||||
|
||||
useEffect(() => {
|
||||
if (prevRef.current === value) return;
|
||||
const from = prevRef.current;
|
||||
prevRef.current = value;
|
||||
const fromN = parseInt(from, 10);
|
||||
const toN = parseInt(value, 10);
|
||||
setDir(Number.isFinite(fromN) && Number.isFinite(toN) && toN < fromN ? "down" : "up");
|
||||
setOldVal(from);
|
||||
setNewVal(value);
|
||||
setRolling(true);
|
||||
setShifted(false);
|
||||
|
||||
let raf2 = 0;
|
||||
const raf1 = requestAnimationFrame(() => {
|
||||
raf2 = requestAnimationFrame(() => setShifted(true));
|
||||
});
|
||||
const done = setTimeout(() => {
|
||||
setRolling(false);
|
||||
setOldVal(value);
|
||||
setShifted(false);
|
||||
}, ROLL_MS);
|
||||
|
||||
return () => {
|
||||
cancelAnimationFrame(raf1);
|
||||
cancelAnimationFrame(raf2);
|
||||
clearTimeout(done);
|
||||
};
|
||||
}, [value]);
|
||||
|
||||
const chars = rolling ? newVal : oldVal;
|
||||
|
||||
return (
|
||||
<>
|
||||
{Array.from({ length: chars.length }, (_, i) => {
|
||||
const o = oldVal[i] ?? "";
|
||||
const n = chars[i] ?? "";
|
||||
if (!rolling || o === n) {
|
||||
return <span key={`${i}-${n}`}>{n}</span>;
|
||||
}
|
||||
const top = dir === "down" ? n : o;
|
||||
const bottom = dir === "down" ? o : n;
|
||||
const restY = dir === "down" ? "0" : "-1em";
|
||||
const startY = dir === "down" ? "-1em" : "0";
|
||||
return (
|
||||
<span
|
||||
key={`${i}-${o}-${n}-${dir}`}
|
||||
style={{ display: "inline-block", position: "relative", overflow: "hidden", height: "1em", lineHeight: "1em", verticalAlign: "-0.05em" }}
|
||||
>
|
||||
<span
|
||||
style={{
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
transition: "transform 350ms cubic-bezier(0.4, 0, 0.2, 1)",
|
||||
transform: `translateY(${shifted ? restY : startY})`,
|
||||
}}
|
||||
>
|
||||
<span style={{ height: "1em", lineHeight: "1em" }}>{top}</span>
|
||||
<span style={{ height: "1em", lineHeight: "1em" }}>{bottom}</span>
|
||||
</span>
|
||||
</span>
|
||||
);
|
||||
})}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function Ico({ path, size = 14, sw = 2 }: { path: React.ReactNode; size?: number; sw?: number }) {
|
||||
return (
|
||||
<svg width={size} height={size} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={sw} strokeLinecap="round" strokeLinejoin="round" aria-hidden>
|
||||
{path}
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
export default function ApprovalCard({
|
||||
questions = QUESTIONS,
|
||||
labels,
|
||||
onSubmitted,
|
||||
onAnswerChange,
|
||||
resettable = true,
|
||||
}: {
|
||||
questions?: ApprovalQuestion[];
|
||||
labels?: Partial<ApprovalLabels>;
|
||||
onSubmitted?: (answers: Record<number, number[]>) => void;
|
||||
onAnswerChange?: (questionIndex: number, answer: number[]) => void;
|
||||
resettable?: boolean;
|
||||
variant?: string;
|
||||
} = {}) {
|
||||
const t = { ...DEFAULT_LABELS, ...labels };
|
||||
const [qi, setQi] = useState(0);
|
||||
const [answers, setAnswers] = useState<Record<number, number[]>>({});
|
||||
const [custom, setCustom] = useState<Record<number, string>>({});
|
||||
const [sent, setSent] = useState(false);
|
||||
const [open, setOpen] = useState(true);
|
||||
|
||||
const advanceTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
const questionRefs = useRef<(HTMLDivElement | null)[]>([]);
|
||||
const measured = useRef(false);
|
||||
const [viewportH, setViewportH] = useState<number | undefined>(undefined);
|
||||
const [trackY, setTrackY] = useState(0);
|
||||
const [animate, setAnimate] = useState(false);
|
||||
// Until the first question is measured, render only the active one so the
|
||||
// initial (and SSR) height is Q1's height — not all questions stacked, which
|
||||
// would flash to full height and then shrink on mount.
|
||||
const [ready, setReady] = useState(false);
|
||||
|
||||
const last = qi === questions.length - 1;
|
||||
const selected = answers[qi] ?? [];
|
||||
const hasAnswer = selected.length > 0 || Boolean(custom[qi]?.trim());
|
||||
|
||||
const sync = (withAnim: boolean) => {
|
||||
const item = questionRefs.current[qi];
|
||||
if (!item) return;
|
||||
const reduce = typeof window !== "undefined" && window.matchMedia("(prefers-reduced-motion: reduce)").matches;
|
||||
setViewportH(item.offsetHeight);
|
||||
setTrackY(item.offsetTop);
|
||||
setAnimate(withAnim && !reduce);
|
||||
};
|
||||
|
||||
useLayoutEffect(() => {
|
||||
const withAnim = measured.current;
|
||||
measured.current = true;
|
||||
sync(withAnim);
|
||||
setReady(true);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [qi, answers, custom, open, sent]);
|
||||
|
||||
useEffect(() => {
|
||||
const id = requestAnimationFrame(() => sync(measured.current));
|
||||
return () => cancelAnimationFrame(id);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [qi]);
|
||||
|
||||
useEffect(() => () => { if (advanceTimer.current) clearTimeout(advanceTimer.current); }, []);
|
||||
|
||||
const goTo = (next: number) => {
|
||||
if (advanceTimer.current) clearTimeout(advanceTimer.current);
|
||||
setQi(Math.min(Math.max(next, 0), questions.length - 1));
|
||||
};
|
||||
|
||||
const send = () => {
|
||||
if (advanceTimer.current) clearTimeout(advanceTimer.current);
|
||||
setSent(true);
|
||||
onSubmitted?.(answers);
|
||||
};
|
||||
|
||||
const advance = () => {
|
||||
if (last) send();
|
||||
else goTo(qi + 1);
|
||||
};
|
||||
|
||||
const toggle = (index: number) => {
|
||||
const type = questions[qi].type;
|
||||
setAnswers((current) => {
|
||||
const picked = current[qi] ?? [];
|
||||
const next = type === "radio"
|
||||
? [index]
|
||||
: picked.includes(index)
|
||||
? picked.filter((item) => item !== index)
|
||||
: [...picked, index];
|
||||
onAnswerChange?.(qi, next);
|
||||
return { ...current, [qi]: next };
|
||||
});
|
||||
if (type === "radio") {
|
||||
setCustom((current) => ({ ...current, [qi]: "" }));
|
||||
if (advanceTimer.current) clearTimeout(advanceTimer.current);
|
||||
advanceTimer.current = setTimeout(() => {
|
||||
if (last) send();
|
||||
else setQi((current) => Math.min(questions.length - 1, current + 1));
|
||||
}, 480);
|
||||
}
|
||||
};
|
||||
|
||||
const reset = () => {
|
||||
setQi(0);
|
||||
setAnswers({});
|
||||
setCustom({});
|
||||
setSent(false);
|
||||
setOpen(true);
|
||||
measured.current = false;
|
||||
};
|
||||
|
||||
if (!open) {
|
||||
return (
|
||||
<button type="button" onClick={() => setOpen(true)} className="rounded-control bg-surface px-3 py-2 text-[12.5px] font-medium text-ink shadow-btn transition-colors duration-150 hover:bg-hover">
|
||||
Open approval
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
if (sent) {
|
||||
return (
|
||||
<div className="flex w-full max-w-80 items-center gap-3" style={{ animation: "pop-in 260ms cubic-bezier(0.23,1,0.32,1) both" }}>
|
||||
<span className="inline-flex items-center gap-1.5 rounded-full bg-green-tint py-1 pr-2.5 pl-1 text-[12.5px] font-medium text-green">
|
||||
<span className="flex size-4.5 items-center justify-center rounded-full bg-green text-white">
|
||||
<svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="3" strokeLinecap="round" strokeLinejoin="round"><path d="M20 6L9 17l-5-5" /></svg>
|
||||
</span>
|
||||
{t.sentMessage}
|
||||
</span>
|
||||
{resettable && (
|
||||
<button type="button" onClick={reset} className="text-[12px] font-medium text-ink-3 transition-colors duration-150 hover:text-ink">
|
||||
Start over
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="w-full max-w-80">
|
||||
<div className="relative overflow-hidden rounded-card bg-surface shadow-card" style={{ animation: "fade-up 380ms cubic-bezier(0.23,1,0.32,1) both" }}>
|
||||
<button
|
||||
type="button"
|
||||
aria-label="Dismiss"
|
||||
onClick={() => setOpen(false)}
|
||||
className="primitive-icon-button absolute right-2.5 top-2.5 z-10 text-ink-3 transition-colors duration-100 hover:bg-hover hover:text-ink"
|
||||
>
|
||||
<Ico size={14} sw={2.2} path={<path d="M18 6L6 18M6 6l12 12" />} />
|
||||
</button>
|
||||
<div className="primitive-card-pad">
|
||||
{/* the question itself is the heading */}
|
||||
<div
|
||||
className="overflow-hidden"
|
||||
style={{ height: viewportH, transition: animate ? `height ${SLIDE}` : undefined }}
|
||||
aria-live="polite"
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
gap: 26,
|
||||
transform: `translate3d(0, ${-trackY}px, 0)`,
|
||||
transition: animate ? `transform ${SLIDE}` : undefined,
|
||||
willChange: "transform",
|
||||
}}
|
||||
>
|
||||
{questions.map((question, qIdx) => {
|
||||
const active = qIdx === qi;
|
||||
// Before the first measure, mount only the active question so the
|
||||
// card opens at its real height instead of flashing to full height.
|
||||
if (!ready && !active) return null;
|
||||
const picked = answers[qIdx] ?? [];
|
||||
const questionStyle: CSSProperties = {
|
||||
opacity: active ? 1 : 0,
|
||||
transition: animate ? `opacity ${SLIDE}` : undefined,
|
||||
pointerEvents: active ? undefined : "none",
|
||||
};
|
||||
return (
|
||||
<div
|
||||
key={qIdx}
|
||||
ref={(el) => { questionRefs.current[qIdx] = el; }}
|
||||
aria-hidden={active ? undefined : true}
|
||||
style={questionStyle}
|
||||
>
|
||||
<div className="pr-7 text-[14px] font-medium text-ink">{question.q}</div>
|
||||
<GlideMenu className="mt-2.5 flex flex-col gap-1" highlightClassName="inset-x-0 rounded-control bg-hover">
|
||||
{question.options.map((option, i) => {
|
||||
const on = picked.includes(i);
|
||||
return (
|
||||
<button
|
||||
key={option}
|
||||
type="button"
|
||||
data-menu-row
|
||||
aria-pressed={on}
|
||||
tabIndex={active ? 0 : -1}
|
||||
onClick={() => { if (active) toggle(i); }}
|
||||
className="relative z-10 flex items-center gap-1.5 rounded-control pl-1 pr-2 py-1 text-left transition-colors duration-100"
|
||||
>
|
||||
<span
|
||||
className={`flex size-4 shrink-0 items-center justify-center transition-colors duration-200
|
||||
${question.type === "radio" ? "rounded-full" : "rounded-[5px]"}
|
||||
${on ? "bg-ink text-canvas" : "shadow-[inset_0_0_0_1.5px_var(--line-strong)] text-transparent"}`}
|
||||
>
|
||||
{question.type === "radio" ? (
|
||||
<span className="size-1.5 rounded-full bg-canvas transition-transform duration-200" style={{ transform: on ? "scale(1)" : "scale(0)" }} />
|
||||
) : (
|
||||
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="3" strokeLinecap="round" strokeLinejoin="round"><path d="M20 6L9 17l-5-5" /></svg>
|
||||
)}
|
||||
</span>
|
||||
<span className={`text-[13px] leading-none transition-colors duration-200 ${on ? "text-ink" : "text-ink-2"}`}>
|
||||
{option}
|
||||
</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
<label data-menu-row className="relative z-10 flex items-center gap-1.5 rounded-control pl-1 pr-2 py-1 transition-colors duration-100">
|
||||
<input
|
||||
value={custom[qIdx] ?? ""}
|
||||
tabIndex={active ? 0 : -1}
|
||||
onChange={(event) => {
|
||||
if (!active) return;
|
||||
setCustom((current) => ({ ...current, [qIdx]: event.target.value }));
|
||||
if (question.type === "radio") setAnswers((current) => ({ ...current, [qIdx]: [] }));
|
||||
}}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === "Enter" && hasAnswer) {
|
||||
event.preventDefault();
|
||||
advance();
|
||||
}
|
||||
}}
|
||||
placeholder={t.customPlaceholder}
|
||||
aria-label="Custom answer"
|
||||
className="min-w-0 flex-1 bg-transparent pl-1.5 text-[13px] text-ink outline-none placeholder:text-ink-3"
|
||||
/>
|
||||
</label>
|
||||
</GlideMenu>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* footer — step nav (rolling counter) + pill actions */}
|
||||
<div className="primitive-card-footer flex items-center justify-between gap-3">
|
||||
<div className="flex items-center gap-1 text-ink-3">
|
||||
<button
|
||||
type="button"
|
||||
aria-label="Previous question"
|
||||
disabled={qi <= 0}
|
||||
onClick={() => goTo(qi - 1)}
|
||||
className="flex size-[18px] items-center justify-center rounded-[5px] transition-colors duration-100 enabled:hover:text-ink disabled:opacity-30"
|
||||
>
|
||||
<Ico size={14} path={<path d="M18 15l-6-6-6 6" />} />
|
||||
</button>
|
||||
<span className="inline-flex items-center text-[12px] font-medium tabular-nums text-ink-3" style={{ letterSpacing: "-0.1px", lineHeight: 1 }}>
|
||||
<RollingDigits value={`${qi + 1} / ${questions.length}`} />
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
aria-label="Next question"
|
||||
disabled={last}
|
||||
onClick={() => goTo(qi + 1)}
|
||||
className="flex size-[18px] items-center justify-center rounded-[5px] transition-colors duration-100 enabled:hover:text-ink disabled:opacity-30"
|
||||
>
|
||||
<Ico size={14} path={<path d="M6 9l6 6 6-6" />} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="-mr-0.5 flex items-center gap-1.5">
|
||||
<Button variant="ghost" size="sm" onClick={() => (last ? setOpen(false) : goTo(qi + 1))}>
|
||||
{t.skip}
|
||||
</Button>
|
||||
<Button variant="accent" size="sm" disabled={!hasAnswer} onClick={advance}>
|
||||
{last ? t.send : t.continue}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
/* Vendored from BeautifulUI registry: https://www.beautifului.dev/r/glide-menu.json
|
||||
* Spike #293 — DO NOT ship in templates/frontend. Imports rewritten to @/spike/beautifui/*.
|
||||
* License: MIT (upstream https://github.com/slev12397/beautiful-ui). */
|
||||
import { useRef, useState, type ReactNode } from "react";
|
||||
|
||||
type GlideMenuProps = {
|
||||
children: ReactNode;
|
||||
className?: string;
|
||||
highlightClassName?: string;
|
||||
rowSelector?: string;
|
||||
};
|
||||
|
||||
/** A single hover layer that glides between interactive menu rows. */
|
||||
export default function GlideMenu({
|
||||
children,
|
||||
className = "",
|
||||
highlightClassName = "inset-x-0 rounded-[8px] bg-hover",
|
||||
rowSelector = "[data-menu-row]",
|
||||
}: GlideMenuProps) {
|
||||
const ref = useRef<HTMLDivElement>(null);
|
||||
const [box, setBox] = useState<{ top: number; height: number } | null>(null);
|
||||
const [visible, setVisible] = useState(false);
|
||||
|
||||
const moveTo = (target: EventTarget | null) => {
|
||||
const container = ref.current;
|
||||
if (!(target instanceof Element) || !container) return;
|
||||
const row = target.closest(rowSelector);
|
||||
if (!(row instanceof HTMLElement) || !container.contains(row)) return;
|
||||
const containerRect = container.getBoundingClientRect();
|
||||
const rowRect = row.getBoundingClientRect();
|
||||
setBox({ top: rowRect.top - containerRect.top, height: rowRect.height });
|
||||
setVisible(true);
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={ref}
|
||||
onMouseOver={(event) => moveTo(event.target)}
|
||||
onMouseLeave={() => setVisible(false)}
|
||||
onFocusCapture={(event) => moveTo(event.target)}
|
||||
onBlurCapture={(event) => {
|
||||
if (!ref.current?.contains(event.relatedTarget as Node | null)) setVisible(false);
|
||||
}}
|
||||
className={`group/glide-menu relative ${className}`}
|
||||
>
|
||||
<span
|
||||
aria-hidden
|
||||
className={`pointer-events-none absolute ${highlightClassName}`}
|
||||
style={{
|
||||
top: box?.top ?? 0,
|
||||
height: box?.height ?? 0,
|
||||
opacity: box && visible ? 1 : 0,
|
||||
transition:
|
||||
"top 220ms cubic-bezier(0.23,1,0.32,1), height 220ms cubic-bezier(0.23,1,0.32,1), opacity 150ms ease",
|
||||
}}
|
||||
/>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,253 @@
|
||||
/* Vendored from BeautifulUI registry: https://www.beautifului.dev/r/streaming-text.json
|
||||
* Spike #293 — DO NOT ship in templates/frontend. Imports rewritten to @/spike/beautifui/*.
|
||||
* License: MIT (upstream https://github.com/slev12397/beautiful-ui). */
|
||||
import { useEffect, useState } from "react";
|
||||
|
||||
/* ─────────────────────────────────────────────────────────
|
||||
* STREAMING TEXT
|
||||
* Words resolve out of blur, inline citations appear in
|
||||
* context, then actions and follow-up prompts become usable.
|
||||
* ───────────────────────────────────────────────────────── */
|
||||
|
||||
const WORD_MS = 55;
|
||||
const HOLD_MS = 3400;
|
||||
|
||||
/* one streamed word, or a `cite` placeholder that renders an inline source chip */
|
||||
export type StreamingToken = { text: string; cite?: boolean };
|
||||
|
||||
const TOKENS: StreamingToken[] = [
|
||||
..."Pistachio is your fastest-growing flavor — sales are up 23% this month and margins beat vanilla by 8 points."
|
||||
.split(" ")
|
||||
.map((text) => ({ text })),
|
||||
{ text: "", cite: true },
|
||||
..."Stone-fruit flavors are trending in the same range."
|
||||
.split(" ")
|
||||
.map((text) => ({ text })),
|
||||
];
|
||||
|
||||
const FOLLOW_UPS = [
|
||||
"Which flavors sell best in winter",
|
||||
"Compare gelato and soft serve margins",
|
||||
];
|
||||
|
||||
const SOURCE_IMAGES = {
|
||||
scoop:
|
||||
"data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 64 64'%3E%3Crect width='64' height='64' rx='16' fill='%231f7a5f'/%3E%3Cpath d='M20 36c0 7 5.4 12 12 12s12-5 12-12H20Z' fill='%23fff'/%3E%3Ccircle cx='32' cy='25' r='11' fill='%23bff3dd'/%3E%3Cpath d='M24 24c4-7 13-7 17 0' fill='none' stroke='%231f7a5f' stroke-width='4' stroke-linecap='round'/%3E%3C/svg%3E",
|
||||
trends:
|
||||
"data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 64 64'%3E%3Crect width='64' height='64' rx='16' fill='%232f6fec'/%3E%3Cpath d='M15 43 27 31l8 7 14-18' fill='none' stroke='%23fff' stroke-width='7' stroke-linecap='round' stroke-linejoin='round'/%3E%3Ccircle cx='49' cy='20' r='5' fill='%23bfe0ff'/%3E%3C/svg%3E",
|
||||
market:
|
||||
"data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 64 64'%3E%3Crect width='64' height='64' rx='16' fill='%23e56d24'/%3E%3Cpath d='M17 45V25h8v20h-8Zm11 0V16h8v29h-8Zm11 0V30h8v15h-8Z' fill='%23fff'/%3E%3Cpath d='M16 49h32' stroke='%23ffd6b8' stroke-width='4' stroke-linecap='round'/%3E%3C/svg%3E",
|
||||
};
|
||||
|
||||
/* one cited source rendered as an inline chip and in the sources list */
|
||||
export type StreamingSource = { name: string; domain: string; href: string; image: string };
|
||||
|
||||
const SOURCES: StreamingSource[] = [
|
||||
{ name: "Scoop Data", domain: "scoopdata.io", href: "https://scoopdata.io/", image: SOURCE_IMAGES.scoop },
|
||||
{ name: "Trends Index", domain: "trends.google.com", href: "https://trends.google.com/trends/", image: SOURCE_IMAGES.trends },
|
||||
{ name: "Market Basket", domain: "marketbasket.io", href: "https://marketbasket.io/", image: SOURCE_IMAGES.market },
|
||||
];
|
||||
|
||||
function sourceImage(source: StreamingSource) {
|
||||
return source.image;
|
||||
}
|
||||
|
||||
function SourceChip({ source }: { source?: StreamingSource }) {
|
||||
if (!source) return null;
|
||||
return (
|
||||
<a
|
||||
href={source.href}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className="ml-0 mr-1 inline-flex h-4.5 translate-y-[-1px] items-center gap-1 rounded-[5px]
|
||||
bg-inset pr-[3px] pl-[3px] align-middle font-mono text-[10.5px] text-ink-2 shadow-hairline
|
||||
transition-colors duration-150 hover:bg-hover hover:text-ink"
|
||||
style={{ animation: "pop-in 250ms cubic-bezier(0.23,1,0.32,1) both" }}
|
||||
>
|
||||
<img src={sourceImage(source)} alt="" className="source-avatar size-3 rounded-[3px]" />
|
||||
<span>{source.domain}</span>
|
||||
</a>
|
||||
);
|
||||
}
|
||||
|
||||
const ACTION_ICONS: React.ReactNode[] = [
|
||||
<g key="copy"><rect x="9" y="9" width="12" height="12" rx="2.5" /><path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1" /></g>,
|
||||
<path key="retry" d="M21 12a9 9 0 1 1-2.64-6.36M21 3v6h-6" />,
|
||||
<path key="up" d="M7 10v12M15 5.88L14 10h5.83a2 2 0 0 1 1.92 2.56l-2.33 8A2 2 0 0 1 17.5 22H4a2 2 0 0 1-2-2v-8a2 2 0 0 1 2-2h2.76a2 2 0 0 0 1.79-1.11L12 2a3.13 3.13 0 0 1 3 3.88z" />,
|
||||
<path key="down" d="M17 14V2M9 18.12L10 14H4.17a2 2 0 0 1-1.92-2.56l2.33-8A2 2 0 0 1 6.5 2H20a2 2 0 0 1 2 2v8a2 2 0 0 1-2 2h-2.76a2 2 0 0 0-1.79 1.11L12 22a3.13 3.13 0 0 1-3-3.88z" />,
|
||||
];
|
||||
|
||||
export type StreamingLabels = {
|
||||
/** label on the collapsed sources toggle */
|
||||
sources: string;
|
||||
/** heading above the follow-up prompts */
|
||||
followUps: string;
|
||||
};
|
||||
|
||||
const DEFAULT_LABELS: StreamingLabels = {
|
||||
sources: "10 sources",
|
||||
followUps: "Follow-ups",
|
||||
};
|
||||
|
||||
export default function StreamingText({
|
||||
content = TOKENS,
|
||||
sources = SOURCES,
|
||||
followUps = FOLLOW_UPS,
|
||||
labels,
|
||||
loop = true,
|
||||
fill = false,
|
||||
onDone,
|
||||
onFollowUp,
|
||||
}: {
|
||||
variant?: string;
|
||||
/** the streamed tokens; `cite` tokens render an inline source chip */
|
||||
content?: StreamingToken[];
|
||||
/** cited sources shown in the chip, avatar stack, and expanded list */
|
||||
sources?: StreamingSource[];
|
||||
/** follow-up prompt suggestions shown once the stream completes */
|
||||
followUps?: string[];
|
||||
/** prominent copy strings */
|
||||
labels?: Partial<StreamingLabels>;
|
||||
/** restart the stream after a hold; turn off when embedding in a real thread */
|
||||
loop?: boolean;
|
||||
/** fill the parent width instead of the gallery's fixed measure */
|
||||
fill?: boolean;
|
||||
onDone?: () => void;
|
||||
/** fired when a follow-up prompt is chosen */
|
||||
onFollowUp?: (text: string, index: number) => void;
|
||||
} = {}) {
|
||||
const l = { ...DEFAULT_LABELS, ...labels };
|
||||
const [count, setCount] = useState(0);
|
||||
const [sourcesOpen, setSourcesOpen] = useState(false);
|
||||
const done = count >= content.length;
|
||||
|
||||
useEffect(() => {
|
||||
if (done && !loop) {
|
||||
onDone?.();
|
||||
return;
|
||||
}
|
||||
const t = setTimeout(
|
||||
() => setCount((c) => (c >= content.length ? 0 : c + 1)),
|
||||
done ? HOLD_MS : WORD_MS,
|
||||
);
|
||||
return () => clearTimeout(t);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [count, done, loop]);
|
||||
|
||||
return (
|
||||
<div className={fill ? "w-full" : "min-h-[15.5rem] w-full max-w-95"}>
|
||||
<p className="text-[13px] leading-relaxed text-ink">
|
||||
{content.slice(0, count).map((token, i) =>
|
||||
token.cite ? (
|
||||
<SourceChip key={i} source={sources[0]} />
|
||||
) : (
|
||||
<span key={i} className="inline">
|
||||
{token.text}{" "}
|
||||
</span>
|
||||
),
|
||||
)}
|
||||
{!done && (
|
||||
<span
|
||||
className="ml-0.5 inline-block h-3 w-0.5 translate-y-0.5 rounded-full bg-ink"
|
||||
style={{ animation: "fade-in 150ms ease-out both" }}
|
||||
/>
|
||||
)}
|
||||
</p>
|
||||
|
||||
{/* action icons row */}
|
||||
<div
|
||||
className="mt-2 flex items-center gap-0.5 transition-opacity duration-400"
|
||||
style={{ opacity: done ? 1 : 0, pointerEvents: done ? "auto" : "none" }}
|
||||
>
|
||||
{ACTION_ICONS.map((icon, i) => (
|
||||
<button
|
||||
key={i}
|
||||
type="button"
|
||||
aria-label="Action"
|
||||
className="flex size-6 items-center justify-center rounded-[6px] text-ink-3
|
||||
transition-colors duration-100 hover:bg-hover-2 hover:text-ink-2"
|
||||
>
|
||||
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round">
|
||||
{icon}
|
||||
</svg>
|
||||
</button>
|
||||
))}
|
||||
<button
|
||||
type="button"
|
||||
aria-expanded={sourcesOpen}
|
||||
onClick={() => setSourcesOpen((current) => !current)}
|
||||
className="ml-1.5 flex items-center gap-1.5 rounded-[6px] px-1 py-0.5 text-left transition-colors duration-150 hover:bg-hover"
|
||||
>
|
||||
<span className="flex -space-x-1">
|
||||
{sources.map((source) => (
|
||||
<img
|
||||
key={source.domain}
|
||||
src={sourceImage(source)}
|
||||
alt=""
|
||||
className="source-avatar size-3.5 rounded-full bg-surface shadow-[0_0_0_1.5px_var(--canvas)]"
|
||||
/>
|
||||
))}
|
||||
</span>
|
||||
<span className="text-[12px] text-ink-2">{l.sources}</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div
|
||||
className="grid transition-[grid-template-rows,opacity] duration-300"
|
||||
style={{
|
||||
gridTemplateRows: done && sourcesOpen ? "1fr" : "0fr",
|
||||
opacity: done && sourcesOpen ? 1 : 0,
|
||||
transitionTimingFunction: "cubic-bezier(0.23, 1, 0.32, 1)",
|
||||
}}
|
||||
>
|
||||
<div className="overflow-hidden">
|
||||
<div className="mt-1.5 flex flex-col rounded-[10px] bg-inset p-1 shadow-hairline">
|
||||
{sources.map((source) => (
|
||||
<a
|
||||
key={source.domain}
|
||||
href={source.href}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className="flex items-center gap-2 rounded-[6px] px-1.5 py-1 text-[12px] text-ink-2 transition-colors duration-150 hover:bg-hover hover:text-ink"
|
||||
>
|
||||
<img src={sourceImage(source)} alt="" className="source-avatar size-4 rounded-[4px]" />
|
||||
<span className="animated-underline">{source.name}</span>
|
||||
<span className="ml-auto font-mono text-[10.5px] text-ink-3">{source.domain}</span>
|
||||
</a>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* follow-ups */}
|
||||
<div
|
||||
className="mt-2.5 transition-opacity duration-400"
|
||||
style={{ opacity: done ? 1 : 0, pointerEvents: done ? "auto" : "none" }}
|
||||
>
|
||||
<p className="text-[12px] font-medium text-ink-2">{l.followUps}</p>
|
||||
<div className="mt-0.5 flex flex-col">
|
||||
{followUps.map((text, i) => (
|
||||
<button
|
||||
key={text}
|
||||
onClick={() => onFollowUp?.(text, i)}
|
||||
className="-mx-1.5 flex items-center gap-2 rounded-[7px] border-b border-line
|
||||
px-1.5 py-1.5 text-left text-[12.5px] text-ink transition-colors
|
||||
duration-100 hover:bg-hover-2"
|
||||
style={
|
||||
done
|
||||
? { animation: `fade-up 350ms cubic-bezier(0.23,1,0.32,1) ${i * 90}ms both` }
|
||||
: { opacity: 0 }
|
||||
}
|
||||
>
|
||||
<svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="var(--ink-3)" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" className="shrink-0">
|
||||
<path d="M9 10l-5 5 5 5" />
|
||||
<path d="M20 4v7a4 4 0 0 1-4 4H4" />
|
||||
</svg>
|
||||
{text}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,302 @@
|
||||
/* Vendored from BeautifulUI registry: https://www.beautifului.dev/r/thinking-state.json
|
||||
* Spike #293 — DO NOT ship in templates/frontend. Imports rewritten to @/spike/beautifui/*.
|
||||
* License: MIT (upstream https://github.com/slev12397/beautiful-ui). */
|
||||
import { useEffect, useLayoutEffect, useRef, useState, type ReactNode } from "react";
|
||||
|
||||
/* ─────────────────────────────────────────────────────────
|
||||
* THINKING — expandable agent trace, four variants
|
||||
*
|
||||
* Steps step list with spinner → muted checks
|
||||
* Reasoning prose reasoning that expands, then settles
|
||||
* Search web-search trace: query + sources read
|
||||
* Coding tool trace: files read, edits, commands
|
||||
*
|
||||
* The trace runs once, settles, and remains expandable.
|
||||
* ───────────────────────────────────────────────────────── */
|
||||
|
||||
const STAGES = [800, 600, 1800, 2600, 1600];
|
||||
|
||||
function useSequence(steps: number[]) {
|
||||
const [stage, setStage] = useState(0);
|
||||
useEffect(() => {
|
||||
if (stage >= steps.length - 1) return;
|
||||
const t = setTimeout(() => setStage((s) => s + 1), steps[stage]);
|
||||
return () => clearTimeout(t);
|
||||
}, [stage, steps]);
|
||||
return stage;
|
||||
}
|
||||
|
||||
type Row = {
|
||||
primary: string;
|
||||
secondary?: string;
|
||||
mono?: boolean;
|
||||
add?: number;
|
||||
del?: number;
|
||||
href?: string;
|
||||
};
|
||||
|
||||
const VARIANTS: Record<
|
||||
string,
|
||||
{ active: string; done: string; rows: Row[]; query?: string }
|
||||
> = {
|
||||
Steps: {
|
||||
active: "Thinking",
|
||||
done: "Thought for 4 seconds",
|
||||
rows: [
|
||||
{ primary: "Reading flavor briefs" },
|
||||
{ primary: "Scanning supplier lists" },
|
||||
{ primary: "Comparing tasting notes", secondary: "6 flavors" },
|
||||
{ primary: "Writing the scoop report" },
|
||||
],
|
||||
},
|
||||
Reasoning: {
|
||||
active: "Thinking",
|
||||
done: "Thought for 4 seconds",
|
||||
rows: [
|
||||
{ primary: "Summer demand spikes for stone-fruit flavors — peach and apricot lead." },
|
||||
{ primary: "I should check cone inventory before promoting a waffle-bowl special." },
|
||||
],
|
||||
},
|
||||
Search: {
|
||||
active: "Searching the web",
|
||||
done: "Searched the web",
|
||||
query: "best waffle cone supplier",
|
||||
rows: [
|
||||
{ primary: "Joy Cone", secondary: "joycone.com", href: "https://joycone.com/fs_products/waffle-cones/" },
|
||||
{ primary: "WebstaurantStore", secondary: "webstaurantstore.com", href: "https://www.webstaurantstore.com/ice-cream-shop-supplies.html" },
|
||||
{ primary: "The Konery", secondary: "thekonery.com", href: "https://www.thekonery.com/" },
|
||||
],
|
||||
},
|
||||
Coding: {
|
||||
active: "Running tools",
|
||||
done: "Ran 3 tools",
|
||||
rows: [
|
||||
{ primary: "Read", secondary: "flavors.ts", mono: true },
|
||||
{ primary: "Edit", secondary: "ChurnSchedule.tsx", mono: true, add: 74, del: 41 },
|
||||
{ primary: "Run", secondary: "npm run freeze", mono: true },
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
function Dot({ tone }: { tone: string }) {
|
||||
return (
|
||||
<span className={`flex size-3.5 shrink-0 items-center justify-center rounded-full text-white ${tone}`}>
|
||||
<svg width="9" height="9" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5">
|
||||
<circle cx="12" cy="12" r="9" />
|
||||
<path d="M3.5 12h17M12 3a14 14 0 0 1 0 18M12 3a14 14 0 0 0 0 18" />
|
||||
</svg>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
const TONES = ["bg-accent", "bg-orange", "bg-green"];
|
||||
|
||||
export default function ThinkingState({
|
||||
variant = "Steps",
|
||||
onSettled,
|
||||
rows,
|
||||
active,
|
||||
done,
|
||||
icon,
|
||||
}: {
|
||||
variant?: string;
|
||||
onSettled?: () => void;
|
||||
/** override the built-in trace content (keeps the primitive reusable) */
|
||||
rows?: Row[];
|
||||
active?: string;
|
||||
done?: string;
|
||||
/** override the header glyph (defaults to the sparkle) */
|
||||
icon?: ReactNode;
|
||||
}) {
|
||||
const stage = useSequence(STAGES);
|
||||
const [manualExpanded, setManualExpanded] = useState<boolean | null>(null);
|
||||
const [selectedTool, setSelectedTool] = useState<string | null>(null);
|
||||
const base = VARIANTS[variant] ?? VARIANTS.Steps;
|
||||
const v = {
|
||||
...base,
|
||||
rows: rows ?? base.rows,
|
||||
active: active ?? base.active,
|
||||
done: done ?? base.done,
|
||||
};
|
||||
const autoExpanded = stage >= 1 && stage < 4;
|
||||
const expanded = manualExpanded ?? autoExpanded;
|
||||
const working = stage < 3;
|
||||
const visible = stage < 2 ? 0 : stage === 2 ? Math.min(2, v.rows.length) : v.rows.length;
|
||||
const traceRef = useRef<HTMLDivElement>(null);
|
||||
const [lineHeight, setLineHeight] = useState(0);
|
||||
useLayoutEffect(() => {
|
||||
if (traceRef.current) setLineHeight(traceRef.current.offsetHeight);
|
||||
}, [visible, expanded, variant, stage]);
|
||||
|
||||
/* let embedders sequence content after the trace settles */
|
||||
const settledRef = useRef(false);
|
||||
useEffect(() => {
|
||||
if (working || settledRef.current) return;
|
||||
settledRef.current = true;
|
||||
onSettled?.();
|
||||
}, [working, onSettled]);
|
||||
|
||||
return (
|
||||
<div
|
||||
key={variant}
|
||||
className="flex w-full max-w-95 flex-col"
|
||||
style={{
|
||||
minHeight: working || expanded ? 176 : undefined,
|
||||
transition: "min-height 400ms cubic-bezier(0.23,1,0.32,1)",
|
||||
}}
|
||||
>
|
||||
{/* header — shared across variants */}
|
||||
<button
|
||||
type="button"
|
||||
aria-expanded={expanded}
|
||||
onClick={() => setManualExpanded((current) => !(current ?? autoExpanded))}
|
||||
className="-mx-1.5 flex w-fit items-center gap-2 rounded-control px-1.5 py-1
|
||||
transition-colors duration-100 hover:bg-hover-2"
|
||||
>
|
||||
{icon ? (
|
||||
<span className="flex shrink-0 transition-colors duration-200" style={{ color: working ? "var(--ink-2)" : "var(--ink-3)" }}>
|
||||
{icon}
|
||||
</span>
|
||||
) : (
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill={working ? "var(--ink-2)" : "var(--ink-3)"}>
|
||||
<path d="M12 2l2.4 7.2L22 12l-7.6 2.8L12 22l-2.4-7.2L2 12l7.6-2.8z" />
|
||||
</svg>
|
||||
)}
|
||||
<span role="status" className="contents">
|
||||
{working ? (
|
||||
<span
|
||||
className="bg-clip-text text-[13px] font-medium whitespace-nowrap text-transparent"
|
||||
style={{
|
||||
backgroundImage:
|
||||
"linear-gradient(90deg, var(--ink-3) 35%, var(--ink) 50%, var(--ink-3) 65%)",
|
||||
backgroundSize: "200% 100%",
|
||||
animation: "shimmer-text 1.4s linear infinite",
|
||||
}}
|
||||
>
|
||||
{v.active}
|
||||
</span>
|
||||
) : (
|
||||
<span
|
||||
className="text-[13px] font-medium whitespace-nowrap text-ink-2"
|
||||
style={{ animation: "fade-in 350ms ease-out both" }}
|
||||
>
|
||||
{v.done}
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
<svg
|
||||
width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="var(--ink-3)" strokeWidth="2.2" strokeLinecap="round" strokeLinejoin="round"
|
||||
className="transition-transform duration-300"
|
||||
style={{ transform: expanded ? "rotate(180deg)" : "rotate(0)" }}
|
||||
>
|
||||
<path d="M6 9l6 6 6-6" />
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
{/* expandable trace */}
|
||||
<div
|
||||
className="grid transition-[grid-template-rows,opacity] duration-400"
|
||||
style={{
|
||||
gridTemplateRows: expanded ? "1fr" : "0fr",
|
||||
opacity: expanded ? 1 : 0,
|
||||
transitionTimingFunction: "cubic-bezier(0.23, 1, 0.32, 1)",
|
||||
}}
|
||||
>
|
||||
<div className="overflow-hidden">
|
||||
<div className="relative mt-1 ml-[5px] pl-4">
|
||||
<span
|
||||
aria-hidden
|
||||
className="absolute left-[3px] w-px bg-line"
|
||||
style={{ top: -8, height: lineHeight ? lineHeight - 2 : 0, transition: "height 500ms cubic-bezier(0.23,1,0.32,1)" }}
|
||||
/>
|
||||
<div ref={traceRef} className="flex flex-col gap-1 py-1">
|
||||
{v.query && (
|
||||
<div className="flex h-6 items-center gap-2 px-1.5" style={{ animation: expanded ? "fade-up 300ms cubic-bezier(0.23,1,0.32,1) both" : undefined }}>
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="var(--ink-3)" strokeWidth="2" strokeLinecap="round" className="shrink-0">
|
||||
<circle cx="11" cy="11" r="7" />
|
||||
<path d="M21 21l-4.3-4.3" />
|
||||
</svg>
|
||||
<span className="text-[12.5px] text-ink-2">{v.query}</span>
|
||||
</div>
|
||||
)}
|
||||
{v.rows.slice(0, visible).map((row, i) => {
|
||||
const content = (
|
||||
<>
|
||||
{variant === "Search" && <Dot tone={TONES[i % 3]} />}
|
||||
{variant === "Steps" && (
|
||||
i < visible - 1 || !working ? (
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="var(--ink-3)" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round" className="shrink-0">
|
||||
<path d="M20 6L9 17l-5-5" />
|
||||
</svg>
|
||||
) : (
|
||||
<span className="size-3 shrink-0 rounded-full border-[1.5px] border-line-strong border-t-ink-2" style={{ animation: "spin 700ms linear infinite" }} />
|
||||
)
|
||||
)}
|
||||
<span className={`min-w-0 truncate text-[12.5px] ${variant === "Reasoning" ? "whitespace-normal leading-relaxed text-ink-2" : "font-medium text-ink"} ${variant === "Search" ? "animated-underline" : ""}`}>
|
||||
{row.primary}
|
||||
</span>
|
||||
{row.secondary && (
|
||||
<span className={`shrink-0 text-[11.5px] text-ink-3 ${row.mono ? "font-mono" : ""}`}>
|
||||
{row.secondary}
|
||||
</span>
|
||||
)}
|
||||
{row.add !== undefined && (
|
||||
<span className="shrink-0 font-mono text-[11px] tabular-nums">
|
||||
<span className="text-green">+{row.add}</span>{" "}
|
||||
<span className="text-red">−{row.del}</span>
|
||||
</span>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
const rowClass = "flex min-h-7 w-full items-center gap-2 rounded-[6px] px-1.5 py-0.5 text-left";
|
||||
const animation = { animation: `fade-up 320ms cubic-bezier(0.23,1,0.32,1) ${i * 120}ms both` };
|
||||
|
||||
if (variant === "Search") {
|
||||
return (
|
||||
<a
|
||||
key={row.primary}
|
||||
href={row.href}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className={`${rowClass} transition-colors duration-150 hover:bg-hover`}
|
||||
style={animation}
|
||||
>
|
||||
{content}
|
||||
</a>
|
||||
);
|
||||
}
|
||||
|
||||
if (variant === "Coding") {
|
||||
const selected = selectedTool === row.primary;
|
||||
return (
|
||||
<button
|
||||
key={row.primary}
|
||||
type="button"
|
||||
aria-pressed={selected}
|
||||
onClick={() => setSelectedTool(selected ? null : row.primary)}
|
||||
className={`${rowClass} transition-colors duration-150 ${selected ? "bg-inset" : "hover:bg-hover"}`}
|
||||
style={animation}
|
||||
>
|
||||
{content}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div key={row.primary} className={rowClass} style={animation}>
|
||||
{content}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
{variant === "Search" && stage >= 3 && (
|
||||
<span className="text-[12px] text-ink-3" style={{ animation: "fade-in 300ms ease-out both" }}>
|
||||
+7 more
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
/* Spike #293 — dev-only entry. Not part of the production bundle. */
|
||||
import { StrictMode } from "react";
|
||||
import { createRoot } from "react-dom/client";
|
||||
import "@/spike/beautifui/foundation.css";
|
||||
import SpikePage from "@/spike/SpikePage";
|
||||
|
||||
createRoot(document.getElementById("root")!).render(
|
||||
<StrictMode>
|
||||
<SpikePage />
|
||||
</StrictMode>,
|
||||
);
|
||||
@@ -0,0 +1,91 @@
|
||||
/* Spike #293 — PraisonAIUI-shaped mock event stream.
|
||||
*
|
||||
* These events use the real `SSEEvent` contract from `@/types` so the spike
|
||||
* proves the BeautifulUI primitives can render against the *actual* event
|
||||
* vocabulary the chat surface emits (see src/praisonaiui/templates/frontend/
|
||||
* plugins/views/chat.js and RunEventType in src/frontend/src/types.ts).
|
||||
*
|
||||
* Nothing here talks to a live socket — that is an explicit non-goal of the
|
||||
* spike. The timeline below is replayed by SpikePage with setTimeout to
|
||||
* emulate SSE deltas / tool lifecycle / a HITL approval request.
|
||||
*/
|
||||
import type { SSEEvent } from "@/types";
|
||||
|
||||
/** A scripted SSE frame plus the delay (ms) before it fires. */
|
||||
export type MockFrame = { at: number; event: SSEEvent };
|
||||
|
||||
/** approval_request payload shaped like a row from GET /api/approvals. */
|
||||
export type ApprovalRequest = {
|
||||
id: string;
|
||||
tool: string;
|
||||
args: Record<string, unknown>;
|
||||
questions: { q: string; type: "radio" | "check"; options: string[] }[];
|
||||
};
|
||||
|
||||
/* SSE-style text deltas — streamed word-by-word as `run_content`. */
|
||||
const ANSWER =
|
||||
"Pistachio is your fastest-growing flavor — sales are up 23% this month " +
|
||||
"and margins beat vanilla by 8 points. Stone-fruit flavors trend in the same range.";
|
||||
|
||||
function contentDeltas(startAt: number, stepMs = 55): MockFrame[] {
|
||||
const words = ANSWER.split(" ");
|
||||
return words.map((w, i) => ({
|
||||
at: startAt + i * stepMs,
|
||||
event: { type: "run_content", token: w + " ", content: w + " " } as SSEEvent,
|
||||
}));
|
||||
}
|
||||
|
||||
const LAST_DELTA_AT = 300 + ANSWER.split(" ").length * 55;
|
||||
|
||||
/* Reasoning + tool lifecycle, mapped to ThinkingState rows. */
|
||||
export const REASONING_STREAM: SSEEvent[] = [
|
||||
{ type: "reasoning_started" },
|
||||
{ type: "reasoning_step", step: "Reading flavor briefs", step_number: 1 },
|
||||
{ type: "tool_call_started", name: "sql.query", tool_call_id: "t1", args: { table: "sales" } },
|
||||
{ type: "tool_call_completed", name: "sql.query", tool_call_id: "t1", formatted_result: "6 flavors" },
|
||||
{ type: "reasoning_step", step: "Comparing tasting notes", step_number: 2 },
|
||||
{ type: "tool_call_started", name: "trends.lookup", tool_call_id: "t2", args: { window: "30d" } },
|
||||
{ type: "tool_call_completed", name: "trends.lookup", tool_call_id: "t2", formatted_result: "+23%" },
|
||||
{ type: "reasoning_completed" },
|
||||
];
|
||||
|
||||
/* approval_request payload — the /api/approvals shape a HITL gate would push. */
|
||||
export const APPROVAL_REQUEST: ApprovalRequest = {
|
||||
id: "appr_9f2",
|
||||
tool: "menu.publish",
|
||||
args: { flavors: 3, season: "winter" },
|
||||
questions: [
|
||||
{ q: "How many flavors should we launch?", type: "radio", options: ["Three (core line)", "Five (full case)", "Just one hero"] },
|
||||
{ q: "Which mix-ins should we stock?", type: "check", options: ["Chocolate chips", "Waffle bits", "Sprinkles"] },
|
||||
],
|
||||
};
|
||||
|
||||
/** Full scripted timeline for the streaming panel. */
|
||||
export function streamingTimeline(): MockFrame[] {
|
||||
return [
|
||||
{ at: 0, event: { type: "run_started" } as SSEEvent },
|
||||
...contentDeltas(300),
|
||||
{ at: LAST_DELTA_AT + 100, event: { type: "run_completed" } as SSEEvent },
|
||||
];
|
||||
}
|
||||
|
||||
/* ── Adapters: PraisonAIUI events → BeautifulUI primitive props ── */
|
||||
|
||||
/** run_content deltas → StreamingText tokens (`cite` marks a source chip slot). */
|
||||
export function tokensFromDeltas(frames: MockFrame[]): { text: string; cite?: boolean }[] {
|
||||
return frames
|
||||
.filter((f) => f.event.type === "run_content")
|
||||
.map((f) => ({ text: (f.event.token ?? f.event.content ?? "").trim() }));
|
||||
}
|
||||
|
||||
/** reasoning_step / tool_call_* → ThinkingState rows. */
|
||||
export function rowsFromReasoning(events: SSEEvent[]): { primary: string; secondary?: string; mono?: boolean }[] {
|
||||
const rows: { primary: string; secondary?: string; mono?: boolean }[] = [];
|
||||
for (const e of events) {
|
||||
if (e.type === "reasoning_step" && e.step) rows.push({ primary: e.step });
|
||||
else if (e.type === "tool_call_started" && e.name) rows.push({ primary: e.name, mono: true });
|
||||
else if (e.type === "tool_call_completed" && e.name)
|
||||
rows.push({ primary: e.name, secondary: e.formatted_result, mono: true });
|
||||
}
|
||||
return rows;
|
||||
}
|
||||
@@ -1,3 +1,3 @@
|
||||
"""Version information."""
|
||||
|
||||
__version__ = "0.3.144"
|
||||
__version__ = "0.3.151"
|
||||
@@ -80,6 +80,7 @@ def auto_register_defaults() -> None:
|
||||
(".tts", "TTSFeature"),
|
||||
(".usage", "UsageFeature"),
|
||||
(".realtime", "RealtimeFeature"),
|
||||
(".replay", "ReplayFeature"),
|
||||
(".workflows", "WorkflowsFeature"),
|
||||
(".guardrails", "GuardrailsFeature"),
|
||||
(".eval", "EvalFeature"),
|
||||
|
||||
@@ -0,0 +1,517 @@
|
||||
"""Stdlib reader for PraisonAI context-trace JSONL files (Replay contract v1, section 1).
|
||||
|
||||
One file per run at ``<traces dir>/<run_or_session_id>.jsonl``; one JSON object
|
||||
(``ContextEvent.to_dict()``) per line, in append order. Readers must tolerate:
|
||||
|
||||
* a partially written last line (it is skipped until it is complete);
|
||||
* unknown fields and unknown ``event_type`` values (passed through untouched);
|
||||
* schema 1.0 files (no ``run_id`` / ``parent_seq`` / ``node_id``).
|
||||
|
||||
Shared by the ``replay`` feature (runs, events, SSE tail) and the ``tracing``
|
||||
feature (spans from start/end pairs). No third-party imports.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import threading
|
||||
import time
|
||||
from pathlib import Path
|
||||
from typing import Any, Dict, Iterable, List, Optional, Tuple
|
||||
|
||||
__all__ = [
|
||||
"RUN_ID_RE",
|
||||
"LIVE_WINDOW_S",
|
||||
"traces_dir",
|
||||
"is_valid_run_id",
|
||||
"run_path",
|
||||
"list_run_files",
|
||||
"read_events",
|
||||
"TailReader",
|
||||
"EndTracker",
|
||||
"find_graph",
|
||||
"summarize_run",
|
||||
"build_spans",
|
||||
]
|
||||
|
||||
#: Allowed run ids: one path segment, no separators (path-traversal safe).
|
||||
RUN_ID_RE = re.compile(r"^[A-Za-z0-9._-]+$")
|
||||
|
||||
#: A run without an end marker counts as live while its file changed this recently.
|
||||
LIVE_WINDOW_S = 15 * 60
|
||||
|
||||
#: start event type -> the end event type that closes it.
|
||||
START_END: Dict[str, str] = {
|
||||
"session_start": "session_end",
|
||||
"workflow_start": "workflow_end",
|
||||
"task_start": "task_end",
|
||||
"step_start": "step_end",
|
||||
"agent_start": "agent_end",
|
||||
"tool_call_start": "tool_call_end",
|
||||
"llm_request": "llm_response",
|
||||
"approval_requested": "approval_decided",
|
||||
}
|
||||
END_START: Dict[str, str] = {v: k for k, v in START_END.items()}
|
||||
|
||||
_SPAN_KIND = {
|
||||
"session_start": "session",
|
||||
"workflow_start": "workflow",
|
||||
"task_start": "task",
|
||||
"step_start": "step",
|
||||
"agent_start": "agent",
|
||||
"tool_call_start": "tool",
|
||||
"llm_request": "llm",
|
||||
"approval_requested": "approval",
|
||||
}
|
||||
|
||||
|
||||
# ── Location ──────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def traces_dir() -> Path:
|
||||
"""Resolve the traces directory.
|
||||
|
||||
Precedence: ``$PRAISONAI_TRACES_DIR`` > ``$PRAISON_HOME/traces`` > ``~/.praison/traces``.
|
||||
Never creates the directory (this module is read-only).
|
||||
"""
|
||||
override = os.environ.get("PRAISONAI_TRACES_DIR")
|
||||
if override:
|
||||
return Path(override).expanduser()
|
||||
home = os.environ.get("PRAISON_HOME")
|
||||
base = Path(home).expanduser() if home else Path.home() / ".praison"
|
||||
return base / "traces"
|
||||
|
||||
|
||||
def is_valid_run_id(run_id: Any) -> bool:
|
||||
"""True when *run_id* is a single safe path segment."""
|
||||
return (
|
||||
isinstance(run_id, str)
|
||||
and 0 < len(run_id) <= 200
|
||||
and run_id not in (".", "..")
|
||||
and bool(RUN_ID_RE.match(run_id))
|
||||
)
|
||||
|
||||
|
||||
def run_path(run_id: str, directory: Optional[Path] = None) -> Optional[Path]:
|
||||
"""Return the JSONL path for *run_id*, or ``None`` when the id is unsafe.
|
||||
|
||||
The resolved path is additionally checked to sit directly inside the
|
||||
traces directory, so a symlinked or odd id can never escape it.
|
||||
"""
|
||||
if not is_valid_run_id(run_id):
|
||||
return None
|
||||
base = directory or traces_dir()
|
||||
candidate = base / f"{run_id}.jsonl"
|
||||
try:
|
||||
if candidate.resolve().parent != base.resolve():
|
||||
return None
|
||||
except OSError:
|
||||
return None
|
||||
return candidate
|
||||
|
||||
|
||||
def list_run_files(directory: Optional[Path] = None) -> List[Path]:
|
||||
"""All ``*.jsonl`` files with a valid id, newest (mtime) first."""
|
||||
base = directory or traces_dir()
|
||||
if not base.is_dir():
|
||||
return []
|
||||
items: List[Tuple[float, Path]] = []
|
||||
for p in base.iterdir():
|
||||
if p.suffix != ".jsonl" or not is_valid_run_id(p.stem):
|
||||
continue
|
||||
try:
|
||||
st = p.stat()
|
||||
except OSError:
|
||||
continue
|
||||
if p.is_file():
|
||||
items.append((st.st_mtime, p))
|
||||
items.sort(key=lambda it: (it[0], it[1].name), reverse=True)
|
||||
return [p for _, p in items]
|
||||
|
||||
|
||||
# ── Reading ───────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def _parse_line(raw: bytes) -> Optional[Dict[str, Any]]:
|
||||
raw = raw.strip()
|
||||
if not raw:
|
||||
return None
|
||||
try:
|
||||
obj = json.loads(raw.decode("utf-8"))
|
||||
except (UnicodeDecodeError, ValueError):
|
||||
return None
|
||||
return obj if isinstance(obj, dict) else None
|
||||
|
||||
|
||||
class TailReader:
|
||||
"""Incrementally read complete JSON lines from a growing file.
|
||||
|
||||
Keeps a byte offset; each :meth:`poll` returns only events whose line is
|
||||
complete. A trailing fragment without a newline is consumed only when it
|
||||
already parses as a JSON object (a proper prefix of a JSON object never
|
||||
does), so a half-written line is never surfaced. If the file shrinks
|
||||
(the writer truncates on restart), reading restarts from the top.
|
||||
"""
|
||||
|
||||
def __init__(self, path: Path) -> None:
|
||||
self.path = Path(path)
|
||||
self.offset = 0
|
||||
|
||||
def poll(self) -> List[Dict[str, Any]]:
|
||||
try:
|
||||
size = self.path.stat().st_size
|
||||
except OSError:
|
||||
return []
|
||||
if size < self.offset:
|
||||
self.offset = 0
|
||||
if size == self.offset:
|
||||
return []
|
||||
try:
|
||||
with open(self.path, "rb") as fh:
|
||||
fh.seek(self.offset)
|
||||
chunk = fh.read(size - self.offset)
|
||||
except OSError:
|
||||
return []
|
||||
events: List[Dict[str, Any]] = []
|
||||
consumed = 0
|
||||
start = 0
|
||||
while True:
|
||||
nl = chunk.find(b"\n", start)
|
||||
if nl < 0:
|
||||
break
|
||||
ev = _parse_line(chunk[start:nl])
|
||||
if ev is not None:
|
||||
events.append(ev)
|
||||
start = nl + 1
|
||||
consumed = start
|
||||
tail = chunk[start:]
|
||||
if tail.strip():
|
||||
ev = _parse_line(tail)
|
||||
if ev is not None: # complete object, newline not written yet
|
||||
events.append(ev)
|
||||
consumed = len(chunk)
|
||||
self.offset += consumed
|
||||
return events
|
||||
|
||||
|
||||
_CACHE_LOCK = threading.Lock()
|
||||
_EVENT_CACHE: Dict[str, Tuple[Tuple[int, int], List[Dict[str, Any]]]] = {}
|
||||
_SUMMARY_CACHE: Dict[str, Tuple[Tuple[int, int, int], Dict[str, Any]]] = {}
|
||||
_EVENT_CACHE_MAX = 8
|
||||
_SUMMARY_CACHE_MAX = 512
|
||||
|
||||
|
||||
def _sig(path: Path) -> Optional[Tuple[int, int]]:
|
||||
try:
|
||||
st = path.stat()
|
||||
except OSError:
|
||||
return None
|
||||
return (st.st_mtime_ns, st.st_size)
|
||||
|
||||
|
||||
def _seq(ev: Dict[str, Any], default: int = -1) -> int:
|
||||
s = ev.get("sequence_num")
|
||||
return s if isinstance(s, int) and not isinstance(s, bool) else default
|
||||
|
||||
|
||||
def read_events(path: Path, after_seq: Optional[int] = None) -> List[Dict[str, Any]]:
|
||||
"""All complete events in *path* (append order), optionally ``sequence_num > after_seq``."""
|
||||
path = Path(path)
|
||||
sig = _sig(path)
|
||||
if sig is None:
|
||||
return []
|
||||
key = str(path)
|
||||
with _CACHE_LOCK:
|
||||
hit = _EVENT_CACHE.get(key)
|
||||
if hit and hit[0] == sig:
|
||||
events = hit[1]
|
||||
else:
|
||||
events = TailReader(path).poll()
|
||||
with _CACHE_LOCK:
|
||||
_EVENT_CACHE.pop(key, None)
|
||||
_EVENT_CACHE[key] = (sig, events)
|
||||
while len(_EVENT_CACHE) > _EVENT_CACHE_MAX:
|
||||
_EVENT_CACHE.pop(next(iter(_EVENT_CACHE)))
|
||||
if after_seq is None:
|
||||
return list(events)
|
||||
return [ev for ev in events if _seq(ev) > after_seq]
|
||||
|
||||
|
||||
# ── Interpretation ────────────────────────────────────────────────────
|
||||
|
||||
|
||||
class EndTracker:
|
||||
"""Decide when a run has finished, feeding events one at a time.
|
||||
|
||||
The outermost scope is the first ``session_start`` or, failing that, the
|
||||
first top-level ``workflow_start``. The run has ended once that scope's
|
||||
end event arrives (``session_end`` always ends it).
|
||||
"""
|
||||
|
||||
def __init__(self) -> None:
|
||||
self.outer: Optional[Tuple[str, int]] = None
|
||||
self.first_workflow: Optional[int] = None
|
||||
self.ended = False
|
||||
self.status: Optional[str] = None
|
||||
self.ended_at: Optional[float] = None
|
||||
|
||||
def feed(self, ev: Dict[str, Any]) -> None:
|
||||
et = ev.get("event_type")
|
||||
seq = _seq(ev)
|
||||
if et == "session_start" and self.outer is None:
|
||||
self.outer = ("session", seq)
|
||||
elif et == "workflow_start":
|
||||
if self.first_workflow is None:
|
||||
self.first_workflow = seq
|
||||
if self.outer is None and ev.get("parent_seq") is None:
|
||||
self.outer = ("workflow", seq)
|
||||
elif et == "workflow_end":
|
||||
data = ev.get("data") or {}
|
||||
ps = ev.get("parent_seq")
|
||||
if ps is None or ps == self.first_workflow:
|
||||
if isinstance(data, dict) and data.get("status"):
|
||||
self.status = str(data["status"])
|
||||
if self.outer is None or (self.outer[0] == "workflow" and ps in (None, self.outer[1])):
|
||||
self._end(ev)
|
||||
elif et == "session_end":
|
||||
self._end(ev)
|
||||
|
||||
def _end(self, ev: Dict[str, Any]) -> None:
|
||||
self.ended = True
|
||||
ts = ev.get("timestamp")
|
||||
if isinstance(ts, (int, float)):
|
||||
self.ended_at = float(ts)
|
||||
|
||||
|
||||
def find_graph(events: Iterable[Dict[str, Any]]) -> Optional[Dict[str, Any]]:
|
||||
"""The graph from the first ``workflow_start`` carrying one, else ``None``."""
|
||||
for ev in events:
|
||||
if ev.get("event_type") == "workflow_start":
|
||||
data = ev.get("data")
|
||||
if isinstance(data, dict) and isinstance(data.get("graph"), dict):
|
||||
return data["graph"]
|
||||
return None
|
||||
|
||||
|
||||
def _num(v: Any) -> float:
|
||||
return float(v) if isinstance(v, (int, float)) and not isinstance(v, bool) else 0.0
|
||||
|
||||
|
||||
def summarize_run(path: Path, now: Optional[float] = None) -> Optional[Dict[str, Any]]:
|
||||
"""Run-picker row for one trace file (cached on mtime/size)."""
|
||||
path = Path(path)
|
||||
try:
|
||||
st = path.stat()
|
||||
except OSError:
|
||||
return None
|
||||
now = time.time() if now is None else now
|
||||
sig = (st.st_mtime_ns, st.st_size, int(now - st.st_mtime < LIVE_WINDOW_S))
|
||||
key = str(path)
|
||||
with _CACHE_LOCK:
|
||||
hit = _SUMMARY_CACHE.get(key)
|
||||
if hit and hit[0] == sig:
|
||||
return dict(hit[1])
|
||||
|
||||
events = read_events(path)
|
||||
tracker = EndTracker()
|
||||
agents: List[str] = []
|
||||
cost = 0.0
|
||||
session_id = None
|
||||
event_run_id = None
|
||||
started_at = None
|
||||
kind, name, process = "session", None, None
|
||||
schema = None
|
||||
for ev in events:
|
||||
tracker.feed(ev)
|
||||
if session_id is None and ev.get("session_id"):
|
||||
session_id = ev.get("session_id")
|
||||
if event_run_id is None and ev.get("run_id"):
|
||||
event_run_id = ev.get("run_id")
|
||||
if schema is None and ev.get("schema_version"):
|
||||
schema = str(ev.get("schema_version"))
|
||||
ts = ev.get("timestamp")
|
||||
if started_at is None and isinstance(ts, (int, float)):
|
||||
started_at = float(ts)
|
||||
agent = ev.get("agent_name")
|
||||
if isinstance(agent, str) and agent and agent not in agents:
|
||||
agents.append(agent)
|
||||
cost += _num(ev.get("cost_usd"))
|
||||
if ev.get("event_type") == "workflow_start" and name is None:
|
||||
data = ev.get("data") if isinstance(ev.get("data"), dict) else {}
|
||||
kind = str(data.get("kind") or "workflow")
|
||||
name = data.get("name")
|
||||
process = data.get("process")
|
||||
live = (not tracker.ended) and (now - st.st_mtime) < LIVE_WINDOW_S
|
||||
if tracker.ended:
|
||||
status = tracker.status or "completed"
|
||||
else:
|
||||
status = "running" if live else "incomplete"
|
||||
summary: Dict[str, Any] = {
|
||||
"run_id": path.stem,
|
||||
"session_id": session_id,
|
||||
"path": str(path),
|
||||
"started_at": started_at,
|
||||
"status": status,
|
||||
"kind": kind,
|
||||
"name": name or (agents[0] if agents else path.stem),
|
||||
"agents": agents,
|
||||
"events": len(events),
|
||||
"cost_usd": round(cost, 6),
|
||||
"live": live,
|
||||
"schema_version": schema,
|
||||
}
|
||||
if tracker.ended_at is not None:
|
||||
summary["ended_at"] = tracker.ended_at
|
||||
if process:
|
||||
summary["process"] = process
|
||||
if event_run_id and event_run_id != path.stem:
|
||||
summary["event_run_id"] = event_run_id
|
||||
with _CACHE_LOCK:
|
||||
_SUMMARY_CACHE[key] = (sig, summary)
|
||||
while len(_SUMMARY_CACHE) > _SUMMARY_CACHE_MAX:
|
||||
_SUMMARY_CACHE.pop(next(iter(_SUMMARY_CACHE)))
|
||||
return dict(summary)
|
||||
|
||||
|
||||
def _span_name(ev: Dict[str, Any]) -> str:
|
||||
et = ev.get("event_type")
|
||||
data = ev.get("data") if isinstance(ev.get("data"), dict) else {}
|
||||
agent = ev.get("agent_name") or ""
|
||||
if et == "tool_call_start":
|
||||
return str(data.get("tool_name") or "tool")
|
||||
if et == "llm_request":
|
||||
model = data.get("model")
|
||||
return f"llm {model}" if model else "llm"
|
||||
if et == "approval_requested":
|
||||
return f"approval {data.get('tool') or ''}".strip()
|
||||
if et in ("task_start", "step_start", "workflow_start"):
|
||||
return str(data.get("name") or ev.get("node_id") or et.split("_")[0])
|
||||
if et == "agent_start":
|
||||
return agent or "agent"
|
||||
return "session"
|
||||
|
||||
|
||||
def _match_key(ev: Dict[str, Any]) -> Tuple[Any, ...]:
|
||||
"""Identity used to pair a start with its end when ``parent_seq`` is absent."""
|
||||
et = ev.get("event_type")
|
||||
data = ev.get("data") if isinstance(ev.get("data"), dict) else {}
|
||||
base = END_START.get(et, et)
|
||||
extra: Any = None
|
||||
if base == "tool_call_start":
|
||||
extra = data.get("tool_name")
|
||||
elif base == "approval_requested":
|
||||
extra = data.get("approval_id")
|
||||
elif base in ("task_start", "step_start"):
|
||||
extra = ev.get("node_id") or data.get("name")
|
||||
agent = ev.get("agent_name") or ""
|
||||
if base in ("approval_requested", "session_start", "workflow_start"):
|
||||
agent = "" # decisions/ends are often emitted by a different emitter name
|
||||
return (base, agent, ev.get("branch_id"), extra)
|
||||
|
||||
|
||||
def build_spans(events: List[Dict[str, Any]], trace_id: str) -> List[Dict[str, Any]]:
|
||||
"""Spans from start/end pairs.
|
||||
|
||||
Schema 1.1: an end event's ``parent_seq`` names its start; a start's
|
||||
``parent_seq`` names the enclosing span. Schema 1.0 (no ``parent_seq``):
|
||||
ends pair with the oldest open start of the same kind/agent/tool, and the
|
||||
parent is the innermost span open when the start was seen (sequence order).
|
||||
``llm_response`` is a point event whose ``parent_seq`` is its enclosing
|
||||
scope, so LLM spans always pair by order.
|
||||
"""
|
||||
ordered = sorted(enumerate(events), key=lambda it: (_seq(it[1], it[0]), it[0]))
|
||||
spans: Dict[int, Dict[str, Any]] = {}
|
||||
start_type: Dict[int, str] = {}
|
||||
start_key: Dict[int, Tuple[Any, ...]] = {}
|
||||
open_by_key: Dict[Tuple[Any, ...], List[int]] = {}
|
||||
stack: List[int] = []
|
||||
order: List[int] = []
|
||||
|
||||
for idx, ev in ordered:
|
||||
et = ev.get("event_type")
|
||||
seq = _seq(ev, idx)
|
||||
ts = _num(ev.get("timestamp"))
|
||||
if et in START_END:
|
||||
parent = ev.get("parent_seq")
|
||||
if not (isinstance(parent, int) and parent in spans):
|
||||
parent = next((s for s in reversed(stack) if spans[s]["status"] == "open"), None)
|
||||
data = ev.get("data") if isinstance(ev.get("data"), dict) else {}
|
||||
span = {
|
||||
"id": f"{trace_id}:{seq}",
|
||||
"trace_id": trace_id,
|
||||
"parent_span_id": f"{trace_id}:{parent}" if parent is not None else None,
|
||||
"agent_id": ev.get("agent_name") or "",
|
||||
"name": _span_name(ev),
|
||||
"kind": _SPAN_KIND[et],
|
||||
"status": "open",
|
||||
"start_time": ts,
|
||||
"end_time": None,
|
||||
"duration_ms": None,
|
||||
"timestamp": ts,
|
||||
"attributes": {
|
||||
k: v
|
||||
for k, v in {
|
||||
"node_id": ev.get("node_id"),
|
||||
"branch_id": ev.get("branch_id"),
|
||||
"start_seq": seq,
|
||||
"input": data.get("tool_args") if et == "tool_call_start" else None,
|
||||
}.items()
|
||||
if v is not None
|
||||
},
|
||||
"events": [],
|
||||
}
|
||||
spans[seq] = span
|
||||
start_type[seq] = et
|
||||
start_key[seq] = _match_key(ev)
|
||||
order.append(seq)
|
||||
stack.append(seq)
|
||||
open_by_key.setdefault(start_key[seq], []).append(seq)
|
||||
continue
|
||||
if et not in END_START:
|
||||
continue
|
||||
start_seq = None
|
||||
ps = ev.get("parent_seq")
|
||||
key = _match_key(ev)
|
||||
if (
|
||||
et != "llm_response"
|
||||
and isinstance(ps, int)
|
||||
and ps in spans
|
||||
and spans[ps]["status"] == "open"
|
||||
and START_END.get(start_type[ps]) == et
|
||||
):
|
||||
start_seq = ps
|
||||
else:
|
||||
queue = open_by_key.get(key) or []
|
||||
while queue and spans[queue[0]]["status"] != "open":
|
||||
queue.pop(0)
|
||||
if queue:
|
||||
start_seq = queue[0]
|
||||
if start_seq is None:
|
||||
continue
|
||||
q = open_by_key.get(start_key[start_seq])
|
||||
if q and start_seq in q:
|
||||
q.remove(start_seq)
|
||||
span = spans[start_seq]
|
||||
data = ev.get("data") if isinstance(ev.get("data"), dict) else {}
|
||||
dur = data.get("duration_ms")
|
||||
span["end_time"] = ts
|
||||
span["duration_ms"] = (
|
||||
float(dur) if isinstance(dur, (int, float)) and not isinstance(dur, bool)
|
||||
else round(max(0.0, ts - span["start_time"]) * 1000, 3)
|
||||
)
|
||||
failed = bool(data.get("error")) or data.get("status") == "failed" or data.get("approved") is False
|
||||
span["status"] = "error" if failed else "ok"
|
||||
attrs = span["attributes"]
|
||||
for k in ("cost_usd", "prompt_tokens", "completion_tokens"):
|
||||
v = ev.get(k)
|
||||
if isinstance(v, (int, float)) and not isinstance(v, bool) and v:
|
||||
attrs[k] = v
|
||||
if data.get("error"):
|
||||
attrs["error"] = data.get("error")
|
||||
if start_seq in stack:
|
||||
stack.remove(start_seq)
|
||||
|
||||
return [spans[s] for s in order]
|
||||
|
||||
@@ -13,6 +13,7 @@ from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
import threading
|
||||
import time
|
||||
import uuid
|
||||
from abc import ABC, abstractmethod
|
||||
@@ -66,7 +67,13 @@ class ApprovalProtocol(ABC):
|
||||
@abstractmethod
|
||||
def approve(
|
||||
self, approval_id: str, reason: str = "", approver: str = "user", always: bool = False
|
||||
) -> Optional[Dict[str, Any]]: ...
|
||||
) -> Optional[Dict[str, Any]]:
|
||||
"""Approve a pending request.
|
||||
|
||||
Implementations may also accept ``modified_args: dict`` (replacement
|
||||
tool arguments); the HTTP handler only passes it when the client sent it.
|
||||
"""
|
||||
...
|
||||
|
||||
@abstractmethod
|
||||
def deny(
|
||||
@@ -86,6 +93,66 @@ class ApprovalProtocol(ABC):
|
||||
return {"status": "ok", "provider": self.__class__.__name__}
|
||||
|
||||
|
||||
# ── Decision waiter ──────────────────────────────────────────────────
|
||||
|
||||
|
||||
class _DecisionWaiter:
|
||||
"""One pending decision that both threads and coroutines can wait on.
|
||||
|
||||
``set()`` may be called from any thread (normally the server's event loop
|
||||
handling ``POST /api/approvals/{id}/approve``); sync waiters block on a
|
||||
``threading.Event`` and async waiters get their future resolved on their
|
||||
own loop via ``call_soon_threadsafe``, so neither blocks the other.
|
||||
"""
|
||||
|
||||
def __init__(self) -> None:
|
||||
self._event = threading.Event()
|
||||
self._lock = threading.Lock()
|
||||
self._result: Optional[Dict[str, Any]] = None
|
||||
self._futures: List[tuple] = []
|
||||
|
||||
@property
|
||||
def done(self) -> bool:
|
||||
return self._event.is_set()
|
||||
|
||||
def set(self, entry: Dict[str, Any]) -> None:
|
||||
with self._lock:
|
||||
if self._event.is_set():
|
||||
return
|
||||
self._result = entry
|
||||
self._event.set()
|
||||
futures, self._futures = self._futures, []
|
||||
for loop, fut in futures:
|
||||
try:
|
||||
loop.call_soon_threadsafe(_resolve_future, fut, entry)
|
||||
except RuntimeError: # loop already closed
|
||||
pass
|
||||
|
||||
def wait(self, timeout: Optional[float]) -> Optional[Dict[str, Any]]:
|
||||
return self._result if self._event.wait(timeout) else None
|
||||
|
||||
async def wait_async(self, timeout: Optional[float]) -> Optional[Dict[str, Any]]:
|
||||
loop = asyncio.get_running_loop()
|
||||
fut = loop.create_future()
|
||||
with self._lock:
|
||||
if self._event.is_set():
|
||||
return self._result
|
||||
self._futures.append((loop, fut))
|
||||
try:
|
||||
return await asyncio.wait_for(fut, timeout)
|
||||
except asyncio.TimeoutError:
|
||||
return None
|
||||
finally:
|
||||
with self._lock:
|
||||
if (loop, fut) in self._futures:
|
||||
self._futures.remove((loop, fut))
|
||||
|
||||
|
||||
def _resolve_future(fut: "asyncio.Future", value: Any) -> None:
|
||||
if not fut.done():
|
||||
fut.set_result(value)
|
||||
|
||||
|
||||
# ── Simple Approval Manager ──────────────────────────────────────────
|
||||
|
||||
|
||||
@@ -95,6 +162,8 @@ class SimpleApprovalManager(ApprovalProtocol):
|
||||
def __init__(self) -> None:
|
||||
self._pending: Dict[str, Dict[str, Any]] = {}
|
||||
self._history: deque = deque(maxlen=500)
|
||||
self._lock = threading.RLock()
|
||||
self._waiters: Dict[str, _DecisionWaiter] = {}
|
||||
self._policies: Dict[str, Any] = {
|
||||
"auto_approve_tools": [],
|
||||
"always_deny_tools": [],
|
||||
@@ -136,44 +205,67 @@ class SimpleApprovalManager(ApprovalProtocol):
|
||||
entry.get("agent_name", ""),
|
||||
entry.get("risk_level", "medium"),
|
||||
)
|
||||
if auto:
|
||||
entry["status"] = auto
|
||||
entry["resolved_at"] = time.time()
|
||||
entry["approver"] = "auto-policy"
|
||||
entry["reason"] = f"Auto-{auto} by policy"
|
||||
self._history.appendleft(entry)
|
||||
else:
|
||||
self._pending[approval_id] = entry
|
||||
with self._lock:
|
||||
if auto:
|
||||
entry["status"] = auto
|
||||
entry["resolved_at"] = time.time()
|
||||
entry["approver"] = "auto-policy"
|
||||
entry["reason"] = f"Auto-{auto} by policy"
|
||||
self._history.appendleft(entry)
|
||||
else:
|
||||
self._pending[approval_id] = entry
|
||||
self._waiters.setdefault(approval_id, _DecisionWaiter())
|
||||
return entry
|
||||
|
||||
def waiter(self, approval_id: str) -> Optional[_DecisionWaiter]:
|
||||
"""The waiter for a pending request (``None`` once it is resolved)."""
|
||||
with self._lock:
|
||||
return self._waiters.get(approval_id)
|
||||
|
||||
def _signal(self, approval_id: str, entry: Dict[str, Any]) -> None:
|
||||
waiter = self._waiters.pop(approval_id, None)
|
||||
if waiter is not None:
|
||||
waiter.set(entry)
|
||||
|
||||
def approve(
|
||||
self, approval_id: str, reason: str = "", approver: str = "user", always: bool = False
|
||||
self,
|
||||
approval_id: str,
|
||||
reason: str = "",
|
||||
approver: str = "user",
|
||||
always: bool = False,
|
||||
modified_args: Optional[Dict[str, Any]] = None,
|
||||
) -> Optional[Dict[str, Any]]:
|
||||
if approval_id not in self._pending:
|
||||
return None
|
||||
entry = self._pending.pop(approval_id)
|
||||
entry["status"] = "approved"
|
||||
entry["reason"] = reason
|
||||
entry["resolved_at"] = time.time()
|
||||
entry["approver"] = approver
|
||||
if always and entry["tool_name"] not in self._policies["auto_approve_tools"]:
|
||||
self._policies["auto_approve_tools"].append(entry["tool_name"])
|
||||
self._history.appendleft(entry)
|
||||
with self._lock:
|
||||
if approval_id not in self._pending:
|
||||
return None
|
||||
entry = self._pending.pop(approval_id)
|
||||
entry["status"] = "approved"
|
||||
entry["reason"] = reason
|
||||
entry["resolved_at"] = time.time()
|
||||
entry["approver"] = approver
|
||||
if modified_args is not None:
|
||||
entry["modified_args"] = dict(modified_args)
|
||||
if always and entry["tool_name"] not in self._policies["auto_approve_tools"]:
|
||||
self._policies["auto_approve_tools"].append(entry["tool_name"])
|
||||
self._history.appendleft(entry)
|
||||
self._signal(approval_id, entry)
|
||||
return entry
|
||||
|
||||
def deny(
|
||||
self, approval_id: str, reason: str = "", approver: str = "user", always: bool = False
|
||||
) -> Optional[Dict[str, Any]]:
|
||||
if approval_id not in self._pending:
|
||||
return None
|
||||
entry = self._pending.pop(approval_id)
|
||||
entry["status"] = "denied"
|
||||
entry["reason"] = reason
|
||||
entry["resolved_at"] = time.time()
|
||||
entry["approver"] = approver
|
||||
if always and entry["tool_name"] not in self._policies["always_deny_tools"]:
|
||||
self._policies["always_deny_tools"].append(entry["tool_name"])
|
||||
self._history.appendleft(entry)
|
||||
with self._lock:
|
||||
if approval_id not in self._pending:
|
||||
return None
|
||||
entry = self._pending.pop(approval_id)
|
||||
entry["status"] = "denied"
|
||||
entry["reason"] = reason
|
||||
entry["resolved_at"] = time.time()
|
||||
entry["approver"] = approver
|
||||
if always and entry["tool_name"] not in self._policies["always_deny_tools"]:
|
||||
self._policies["always_deny_tools"].append(entry["tool_name"])
|
||||
self._history.appendleft(entry)
|
||||
self._signal(approval_id, entry)
|
||||
return entry
|
||||
|
||||
def get(self, approval_id: str) -> Optional[Dict[str, Any]]:
|
||||
@@ -237,9 +329,14 @@ class SDKApprovalManager(ApprovalProtocol):
|
||||
return result
|
||||
|
||||
def approve(
|
||||
self, approval_id: str, reason: str = "", approver: str = "user", always: bool = False
|
||||
self,
|
||||
approval_id: str,
|
||||
reason: str = "",
|
||||
approver: str = "user",
|
||||
always: bool = False,
|
||||
modified_args: Optional[Dict[str, Any]] = None,
|
||||
) -> Optional[Dict[str, Any]]:
|
||||
entry = self._simple.approve(approval_id, reason, approver, always)
|
||||
entry = self._simple.approve(approval_id, reason, approver, always, modified_args)
|
||||
if entry and always and entry.get("tool_name"):
|
||||
self._registry.add_requirement(entry["tool_name"], entry.get("risk_level", "high"))
|
||||
return entry
|
||||
@@ -273,31 +370,121 @@ class SDKApprovalManager(ApprovalProtocol):
|
||||
|
||||
|
||||
class _UIStoreBackend:
|
||||
"""Routes agent approval requests into the UI SimpleApprovalManager."""
|
||||
"""Routes agent approval requests into the UI store and waits for a human.
|
||||
|
||||
def __init__(self, store: SimpleApprovalManager) -> None:
|
||||
Each request becomes a pending entry keyed by ``request.approval_id`` and
|
||||
the caller blocks (sync: on a ``threading.Event``; async: on a future of
|
||||
its own loop) until ``POST /api/approvals/{id}/approve|deny`` resolves it.
|
||||
No answer within the timeout is a denial (fail-closed) and the entry is
|
||||
resolved as ``denied`` with ``approver="timeout"`` so the queue stays honest.
|
||||
"""
|
||||
|
||||
def __init__(self, store: SimpleApprovalManager, timeout: Optional[float] = None) -> None:
|
||||
self._store = store
|
||||
self._timeout = timeout
|
||||
|
||||
def request_approval_sync(self, request) -> Any:
|
||||
def _effective_timeout(self, timeout: Optional[float]) -> float:
|
||||
if timeout is not None:
|
||||
return float(timeout)
|
||||
if self._timeout is not None:
|
||||
return float(self._timeout)
|
||||
try:
|
||||
from praisonaiagents.approval import get_approval_registry
|
||||
|
||||
return float(getattr(get_approval_registry(), "timeout", 300.0))
|
||||
except Exception:
|
||||
return 300.0
|
||||
|
||||
def _submit(self, request) -> Dict[str, Any]:
|
||||
entry: Dict[str, Any] = {
|
||||
"tool_name": request.tool_name,
|
||||
"agent_name": getattr(request, "agent_name", "") or "",
|
||||
"risk_level": request.risk_level or "medium",
|
||||
"arguments": request.arguments or {},
|
||||
}
|
||||
approval_id = getattr(request, "approval_id", None)
|
||||
if approval_id:
|
||||
entry["id"] = str(approval_id)
|
||||
session_id = getattr(request, "session_id", None)
|
||||
if session_id:
|
||||
entry["session_id"] = session_id
|
||||
return self._store.request_approval(entry)
|
||||
|
||||
@staticmethod
|
||||
def _decision(entry: Optional[Dict[str, Any]]) -> Any:
|
||||
from praisonaiagents.approval.protocols import ApprovalDecision
|
||||
|
||||
entry = self._store.request_approval(
|
||||
{
|
||||
"tool_name": request.tool_name,
|
||||
"agent_name": getattr(request, "agent_name", "") or "",
|
||||
"risk_level": request.risk_level or "medium",
|
||||
"arguments": request.arguments or {},
|
||||
}
|
||||
if entry is None:
|
||||
return ApprovalDecision(
|
||||
approved=False, reason="Timed out waiting for UI approval", approver="timeout"
|
||||
)
|
||||
if entry.get("status") == "approved":
|
||||
return ApprovalDecision(
|
||||
approved=True,
|
||||
reason=entry.get("reason", ""),
|
||||
modified_args=dict(entry.get("modified_args") or {}),
|
||||
approver=entry.get("approver"),
|
||||
)
|
||||
return ApprovalDecision(
|
||||
approved=False,
|
||||
reason=entry.get("reason") or "denied",
|
||||
approver=entry.get("approver"),
|
||||
)
|
||||
status = entry.get("status", "pending")
|
||||
if status == "approved":
|
||||
return ApprovalDecision(approved=True, reason=entry.get("reason", ""))
|
||||
if status == "denied":
|
||||
return ApprovalDecision(approved=False, reason=entry.get("reason", "denied"))
|
||||
return ApprovalDecision(approved=False, reason="Awaiting UI approval")
|
||||
|
||||
def _expire(
|
||||
self,
|
||||
approval_id: str,
|
||||
reason: str = "Timed out waiting for UI approval",
|
||||
approver: str = "timeout",
|
||||
) -> Optional[Dict[str, Any]]:
|
||||
"""Deny a request nobody answered; return the entry if a human won the race."""
|
||||
denied = self._store.deny(approval_id, reason=reason, approver=approver)
|
||||
if denied is not None:
|
||||
return None
|
||||
return self._store.get(approval_id)
|
||||
|
||||
def request_approval_sync(self, request, timeout: Optional[float] = None) -> Any:
|
||||
from praisonaiagents.approval.protocols import ApprovalDecision
|
||||
|
||||
entry = self._submit(request)
|
||||
if entry.get("status") != "pending":
|
||||
return self._decision(entry)
|
||||
approval_id = entry["id"]
|
||||
try:
|
||||
asyncio.get_running_loop()
|
||||
except RuntimeError:
|
||||
pass
|
||||
else:
|
||||
# Blocking here would freeze this thread's event loop (and with it,
|
||||
# possibly the very server that has to deliver the answer).
|
||||
reason = "UI approval cannot block an event-loop thread; use the async approval path"
|
||||
self._expire(approval_id, reason=reason, approver="ui")
|
||||
return ApprovalDecision(approved=False, reason=reason, approver="ui")
|
||||
waiter = self._store.waiter(approval_id)
|
||||
result = waiter.wait(self._effective_timeout(timeout)) if waiter else self._store.get(approval_id)
|
||||
if result is None:
|
||||
result = self._expire(approval_id)
|
||||
return self._decision(result)
|
||||
|
||||
async def request_approval(self, request) -> Any:
|
||||
return self.request_approval_sync(request)
|
||||
entry = self._submit(request)
|
||||
if entry.get("status") != "pending":
|
||||
return self._decision(entry)
|
||||
approval_id = entry["id"]
|
||||
waiter = self._store.waiter(approval_id)
|
||||
try:
|
||||
result = (
|
||||
await waiter.wait_async(self._effective_timeout(None))
|
||||
if waiter
|
||||
else self._store.get(approval_id)
|
||||
)
|
||||
except asyncio.CancelledError:
|
||||
# The registry's own wait_for timed out (or the turn was cancelled).
|
||||
self._expire(approval_id)
|
||||
raise
|
||||
if result is None:
|
||||
result = self._expire(approval_id)
|
||||
return self._decision(result)
|
||||
|
||||
|
||||
# ── Manager singleton ────────────────────────────────────────────────
|
||||
@@ -449,11 +636,15 @@ class ApprovalsFeature(BaseFeatureProtocol):
|
||||
mgr = get_approval_manager()
|
||||
approval_id = request.path_params["approval_id"]
|
||||
body = await request.json() if request.headers.get("content-length") else {}
|
||||
modified_args = body.get("modified_args")
|
||||
if modified_args is not None and not isinstance(modified_args, dict):
|
||||
return JSONResponse({"error": "modified_args must be an object"}, status_code=400)
|
||||
entry = mgr.approve(
|
||||
approval_id,
|
||||
reason=body.get("reason", ""),
|
||||
approver=body.get("approver", "user"),
|
||||
always=body.get("always", False),
|
||||
**({"modified_args": modified_args} if modified_args is not None else {}),
|
||||
)
|
||||
if entry is None:
|
||||
return JSONResponse({"error": "Approval not found"}, status_code=404)
|
||||
|
||||
@@ -652,13 +652,24 @@ class ChannelsFeature(BaseFeatureProtocol):
|
||||
if hasattr(bot, "_session") and hasattr(bot._session, "chat"):
|
||||
_original_chat = bot._session.chat
|
||||
|
||||
async def _wrapped_chat(agent: Any, user_id: str, text: str) -> str:
|
||||
async def _wrapped_chat(
|
||||
agent: Any,
|
||||
user_id: str,
|
||||
text: str,
|
||||
*args: Any,
|
||||
**kwargs: Any,
|
||||
) -> str:
|
||||
"""Wrap agent chat to broadcast responses AND intermediate
|
||||
tool/step events to the Chat UI.
|
||||
|
||||
Hooks into the agent's StreamEventEmitter (same pattern as
|
||||
PraisonAIProvider._run_direct_mode) to capture tool_call,
|
||||
reasoning, and content events during execution.
|
||||
|
||||
Any extra positional/keyword arguments (e.g. ``chat_id``,
|
||||
``platform``, ``thread_id``) are forwarded untouched to the
|
||||
wrapped session ``chat`` so per-conversation memory/session
|
||||
continuity is preserved instead of raising ``TypeError``.
|
||||
"""
|
||||
from .chat import _enrich_tool_payload, get_chat_manager
|
||||
|
||||
@@ -831,7 +842,7 @@ class ChannelsFeature(BaseFeatureProtocol):
|
||||
drain_task = asyncio.create_task(_drain_events())
|
||||
|
||||
try:
|
||||
response = await _original_chat(agent, user_id, text)
|
||||
response = await _original_chat(agent, user_id, text, *args, **kwargs)
|
||||
finally:
|
||||
# Signal drain to stop and clean up callback
|
||||
try:
|
||||
|
||||
@@ -0,0 +1,441 @@
|
||||
"""PraisonAI Replay feature: read-only access to recorded agent runs.
|
||||
|
||||
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``;
|
||||
``?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``,
|
||||
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, 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 FileResponse, HTMLResponse, JSONResponse, StreamingResponse
|
||||
from starlette.routing import Route
|
||||
|
||||
from . import _trace_jsonl as tj
|
||||
from ._base import BaseFeatureProtocol
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
#: Page id of the dashboard view; open ``/replay?run=<id>`` (or ``/#/replay?run=<id>``).
|
||||
REPLAY_PAGE_ID = "replay"
|
||||
|
||||
#: Replay's own mark (three lanes and a cursor), used as the page's ``icon_svg``.
|
||||
REPLAY_MARK_SVG = (
|
||||
'<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16" width="1em" height="1em" '
|
||||
'aria-hidden="true" focusable="false" '
|
||||
'style="display:inline-block;vertical-align:-0.125em">'
|
||||
'<rect x="1" y="3" width="8" height="2.2" rx="1.1" fill="currentColor"/>'
|
||||
'<rect x="3" y="7" width="10" height="2.2" rx="1.1" fill="currentColor"/>'
|
||||
'<rect x="1" y="11" width="6" height="2.2" rx="1.1" fill="currentColor"/>'
|
||||
'<rect x="11.2" y="1" width="1.6" height="14" rx="0.8" fill="currentColor" opacity="0.55"/>'
|
||||
"</svg>"
|
||||
)
|
||||
|
||||
#: Pages a replay-only app should whitelist (Replay first, then its companions).
|
||||
REPLAY_PAGES = ("replay", "approvals", "traces")
|
||||
|
||||
_VIEWS_DIR = Path(__file__).resolve().parent.parent / "templates" / "frontend" / "plugins" / "views"
|
||||
#: Stable on-disk locations of the renderer (also served at /plugins/views/...).
|
||||
REPLAY_CORE_JS = _VIEWS_DIR / "replay-core.js"
|
||||
REPLAY_VIEW_JS = _VIEWS_DIR / "replay.js"
|
||||
|
||||
|
||||
def configure_replay_app() -> None:
|
||||
"""Configure aiui as a Replay-first dashboard (call before the server starts).
|
||||
|
||||
Equivalent to ``aiui.set_style("dashboard")`` plus
|
||||
``aiui.set_pages(["replay", "approvals", "traces"])``; Replay sorts first,
|
||||
so ``/`` lands on it. Used by ``praisonaiui/replay_app.py``.
|
||||
"""
|
||||
from praisonaiui.server import set_pages, set_style
|
||||
|
||||
set_style("dashboard")
|
||||
set_pages(list(REPLAY_PAGES))
|
||||
|
||||
|
||||
def _json_for_script(obj: Any) -> str:
|
||||
"""JSON that is safe inside an inline ``<script>`` element."""
|
||||
text = json.dumps(obj, default=str, separators=(",", ":"))
|
||||
return (
|
||||
text.replace("<", "\\u003c")
|
||||
.replace(">", "\\u003e")
|
||||
.replace("&", "\\u0026")
|
||||
.replace("\u2028", "\\u2028")
|
||||
.replace("\u2029", "\\u2029")
|
||||
)
|
||||
|
||||
|
||||
def standalone_html(
|
||||
events: List[Dict[str, Any]],
|
||||
graph: Optional[Dict[str, Any]] = None,
|
||||
title: str = "PraisonAI Replay",
|
||||
) -> str:
|
||||
"""One self-contained HTML page (no CDN, no network) rendering *events*.
|
||||
|
||||
Inlines ``replay-core.js`` and ``replay.js`` and sets
|
||||
``window.__REPLAY_DATA__ = {events, graph}``; the view boots itself.
|
||||
Approval buttons are hidden because there is no server to answer them.
|
||||
"""
|
||||
core = REPLAY_CORE_JS.read_text(encoding="utf-8").replace("</script", "<\\/script")
|
||||
view = REPLAY_VIEW_JS.read_text(encoding="utf-8").replace("</script", "<\\/script")
|
||||
if graph is None:
|
||||
graph = tj.find_graph(events)
|
||||
data = _json_for_script({"events": list(events), "graph": graph})
|
||||
safe_title = (
|
||||
str(title).replace("&", "&").replace("<", "<").replace(">", ">")
|
||||
)
|
||||
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>{safe_title}</title>\n"
|
||||
"<style>body{margin:0;font-family:system-ui,-apple-system,'Segoe UI',sans-serif}</style>\n"
|
||||
"</head>\n<body>\n<main id=\"replay-root\"></main>\n"
|
||||
f"<script>window.__REPLAY_DATA__ = {data};</script>\n"
|
||||
f"<script type=\"module\">\n{core}\n</script>\n"
|
||||
f"<script type=\"module\">\n{view}\n</script>\n"
|
||||
"</body>\n</html>\n"
|
||||
)
|
||||
|
||||
|
||||
def _bad_request(msg: str) -> JSONResponse:
|
||||
return JSONResponse({"error": msg}, status_code=400)
|
||||
|
||||
|
||||
def _parse_after_seq(request: Request) -> tuple[Optional[int], Optional[JSONResponse]]:
|
||||
"""``?after_seq=N``; an SSE reconnect's ``Last-Event-ID`` wins when it is later."""
|
||||
values = []
|
||||
for raw, strict in (
|
||||
(request.query_params.get("after_seq"), True),
|
||||
(request.headers.get("last-event-id"), False),
|
||||
):
|
||||
if raw in (None, ""):
|
||||
continue
|
||||
try:
|
||||
values.append(int(raw))
|
||||
except (TypeError, ValueError):
|
||||
if strict:
|
||||
return None, _bad_request("after_seq must be an integer")
|
||||
return (max(values) if values else None), None
|
||||
|
||||
|
||||
def _resolve(request: Request) -> tuple[Optional[Path], Optional[JSONResponse]]:
|
||||
run_id = request.path_params.get("run_id", "")
|
||||
path = tj.run_path(run_id)
|
||||
if path is None:
|
||||
return None, _bad_request("invalid run id (allowed: A-Z a-z 0-9 . _ -)")
|
||||
if not path.is_file():
|
||||
return None, JSONResponse({"error": "run not found"}, status_code=404)
|
||||
return path, None
|
||||
|
||||
|
||||
def _seq_of(ev: Dict[str, Any]) -> Optional[int]:
|
||||
s = ev.get("sequence_num")
|
||||
return s if isinstance(s, int) and not isinstance(s, bool) else None
|
||||
|
||||
|
||||
class ReplayFeature(BaseFeatureProtocol):
|
||||
"""Recorded-run timeline, map and live tail (PraisonAI Replay)."""
|
||||
|
||||
feature_name = "replay"
|
||||
feature_description = "Replay recorded agent runs (timeline, map, live tail)"
|
||||
|
||||
# Stream timings; instance attributes so tests can shorten them.
|
||||
poll_interval = 0.25
|
||||
ping_interval = 15.0
|
||||
quiet_period = 2.0
|
||||
|
||||
@property
|
||||
def name(self) -> str:
|
||||
return self.feature_name
|
||||
|
||||
@property
|
||||
def description(self) -> str:
|
||||
return self.feature_description
|
||||
|
||||
def routes(self) -> List[Route]:
|
||||
return [
|
||||
Route("/api/replay/runs", self._runs, methods=["GET"]),
|
||||
Route("/api/replay/runs/{run_id}/events", self._events, methods=["GET"]),
|
||||
Route("/api/replay/runs/{run_id}/stream", self._stream, methods=["GET"]),
|
||||
]
|
||||
|
||||
async def health(self) -> Dict[str, Any]:
|
||||
d = tj.traces_dir()
|
||||
return {
|
||||
"status": "ok",
|
||||
"feature": self.name,
|
||||
"traces_dir": str(d),
|
||||
"runs": len(tj.list_run_files(d)),
|
||||
}
|
||||
|
||||
def cli_commands(self) -> List[Dict[str, Any]]:
|
||||
return [
|
||||
{
|
||||
"name": "replay",
|
||||
"help": "List recorded runs (PraisonAI Replay)",
|
||||
"commands": {"list": {"help": "List recorded runs", "handler": self._cli_list}},
|
||||
}
|
||||
]
|
||||
|
||||
# ── data ──────────────────────────────────────────────────────────
|
||||
|
||||
@staticmethod
|
||||
def list_runs(limit: int = 200) -> List[Dict[str, Any]]:
|
||||
rows = []
|
||||
now = time.time()
|
||||
for path in tj.list_run_files()[: max(1, limit)]:
|
||||
row = tj.summarize_run(path, now=now)
|
||||
if row is not None:
|
||||
rows.append(row)
|
||||
rows.sort(key=lambda r: (r.get("started_at") or 0.0, r["run_id"]), reverse=True)
|
||||
return rows
|
||||
|
||||
def _cli_list(self) -> str:
|
||||
rows = self.list_runs()
|
||||
if not rows:
|
||||
return f"No recorded runs in {tj.traces_dir()}"
|
||||
lines = [f"Runs ({len(rows)}):"]
|
||||
for r in rows:
|
||||
live = " LIVE" if r.get("live") else ""
|
||||
lines.append(
|
||||
f" {r['run_id']} {r.get('name')} {r['status']}{live} "
|
||||
f"events={r['events']} ${r['cost_usd']:.4f}"
|
||||
)
|
||||
return "\n".join(lines)
|
||||
|
||||
# ── handlers ──────────────────────────────────────────────────────
|
||||
|
||||
async def _runs(self, request: Request) -> JSONResponse:
|
||||
try:
|
||||
limit = int(request.query_params.get("limit", "200"))
|
||||
except ValueError:
|
||||
return _bad_request("limit must be an integer")
|
||||
rows = await run_in_threadpool(self.list_runs, limit)
|
||||
return JSONResponse(rows)
|
||||
|
||||
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:
|
||||
return err
|
||||
|
||||
def load() -> Dict[str, Any]:
|
||||
all_events = tj.read_events(path)
|
||||
events = all_events if after_seq is None else tj.read_events(path, after_seq)
|
||||
return {
|
||||
"events": events,
|
||||
"graph": tj.find_graph(all_events),
|
||||
"run": tj.summarize_run(path),
|
||||
}
|
||||
|
||||
return JSONResponse(await run_in_threadpool(load))
|
||||
|
||||
async def _stream(self, request: Request) -> StreamingResponse:
|
||||
path, err = _resolve(request)
|
||||
if err is not None:
|
||||
return err # type: ignore[return-value]
|
||||
after_seq, err = _parse_after_seq(request)
|
||||
if err is not None:
|
||||
return err # type: ignore[return-value]
|
||||
|
||||
poll, ping, quiet = self.poll_interval, self.ping_interval, self.quiet_period
|
||||
|
||||
async def gen():
|
||||
tail = tj.TailReader(path)
|
||||
tracker = tj.EndTracker()
|
||||
last_seq = after_seq
|
||||
loop = asyncio.get_running_loop()
|
||||
last_activity = last_ping = loop.time()
|
||||
yield "retry: 3000\n\n"
|
||||
while True:
|
||||
events = await run_in_threadpool(tail.poll)
|
||||
now = loop.time()
|
||||
if events:
|
||||
last_activity = now
|
||||
for ev in events:
|
||||
tracker.feed(ev)
|
||||
seq = _seq_of(ev)
|
||||
if last_seq is not None and seq is not None and seq <= last_seq:
|
||||
continue
|
||||
if seq is not None:
|
||||
last_seq = seq
|
||||
sid = f"id: {seq}\n" if seq is not None else ""
|
||||
yield f"{sid}event: ctx\ndata: {json.dumps(ev, default=str)}\n\n"
|
||||
idle = now - last_activity
|
||||
if tracker.ended and idle >= quiet:
|
||||
yield "event: end\ndata: {}\n\n"
|
||||
return
|
||||
if not path.exists() or idle >= tj.LIVE_WINDOW_S:
|
||||
yield 'event: end\ndata: {"reason": "stale"}\n\n'
|
||||
return
|
||||
if now - last_ping >= ping:
|
||||
last_ping = now
|
||||
yield "event: ping\ndata: {}\n\n"
|
||||
await asyncio.sleep(poll)
|
||||
|
||||
return StreamingResponse(
|
||||
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 (
|
||||
"<!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)
|
||||
@@ -2,8 +2,8 @@
|
||||
|
||||
Architecture:
|
||||
TracingProtocol (ABC) <- any backend implements this
|
||||
├── SimpleTracingManager <- default in-memory (no deps)
|
||||
└── SDKTracingManager <- wraps praisonaiagents.obs + trace
|
||||
├── SimpleTracingManager <- in-memory (no deps)
|
||||
└── JsonlTracingManager <- recorded runs (context-trace JSONL) + in-memory
|
||||
|
||||
PraisonAITracing (BaseFeatureProtocol)
|
||||
└── delegates to active TracingProtocol implementation
|
||||
@@ -110,56 +110,112 @@ class SimpleTracingManager(TracingProtocol):
|
||||
}
|
||||
|
||||
|
||||
# ── SDK Tracing Manager ──────────────────────────────────────────────
|
||||
# ── JSONL Tracing Manager ────────────────────────────────────────────
|
||||
|
||||
|
||||
class SDKTracingManager(TracingProtocol):
|
||||
"""Wraps praisonaiagents.obs + trace for production use."""
|
||||
class JsonlTracingManager(TracingProtocol):
|
||||
"""Reads recorded runs from the context-trace JSONL directory.
|
||||
|
||||
One ``<id>.jsonl`` file is one trace; spans come from start/end event
|
||||
pairs (``parent_seq`` on schema 1.1, sequence order on 1.0). The
|
||||
directory is ``$PRAISONAI_TRACES_DIR`` > ``$PRAISON_HOME/traces`` >
|
||||
``~/.praison/traces`` (shared with the ``replay`` feature). Traces and
|
||||
spans posted to ``/api/traces/record`` stay in memory alongside.
|
||||
"""
|
||||
|
||||
#: Upper bound on files scanned for an unfiltered span listing.
|
||||
MAX_FILES_FOR_SPANS = 20
|
||||
|
||||
def __init__(self) -> None:
|
||||
self._trace_available = False
|
||||
self._obs_available = False
|
||||
try:
|
||||
from praisonaiagents.trace import ActionEvent # noqa: F401
|
||||
|
||||
self._trace_available = True
|
||||
except (ImportError, AttributeError):
|
||||
pass
|
||||
try:
|
||||
from praisonaiagents.obs import Span, Trace # noqa: F401
|
||||
|
||||
self._obs_available = True
|
||||
except ImportError:
|
||||
pass
|
||||
if not (self._trace_available or self._obs_available):
|
||||
raise ImportError("Neither praisonaiagents.trace nor praisonaiagents.obs available")
|
||||
self._simple = SimpleTracingManager()
|
||||
logger.info(
|
||||
"SDKTracingManager initialized (trace=%s, obs=%s)",
|
||||
self._trace_available,
|
||||
self._obs_available,
|
||||
|
||||
# -- JSONL side ---------------------------------------------------
|
||||
|
||||
@staticmethod
|
||||
def _trace_from_file(path) -> Optional[Dict[str, Any]]:
|
||||
from . import _trace_jsonl as tj
|
||||
|
||||
row = tj.summarize_run(path)
|
||||
if row is None:
|
||||
return None
|
||||
events = tj.read_events(path)
|
||||
started = row.get("started_at") or 0.0
|
||||
ended = row.get("ended_at")
|
||||
last_ts = max(
|
||||
(e["timestamp"] for e in events if isinstance(e.get("timestamp"), (int, float))),
|
||||
default=started,
|
||||
)
|
||||
agents = row.get("agents") or []
|
||||
return {
|
||||
"id": row["run_id"],
|
||||
"agent_id": agents[0] if agents else "",
|
||||
"agents": agents,
|
||||
"name": row.get("name") or row["run_id"],
|
||||
"status": row["status"],
|
||||
"start_time": started,
|
||||
"end_time": ended,
|
||||
"timestamp": started,
|
||||
"duration_ms": round(((ended or last_ts) - started) * 1000, 3),
|
||||
"span_count": len(tj.build_spans(events, row["run_id"])),
|
||||
"source": "jsonl",
|
||||
"metadata": {
|
||||
"path": row["path"],
|
||||
"session_id": row.get("session_id"),
|
||||
"kind": row.get("kind"),
|
||||
"events": row["events"],
|
||||
"cost_usd": row["cost_usd"],
|
||||
"live": row["live"],
|
||||
},
|
||||
}
|
||||
|
||||
@staticmethod
|
||||
def _files():
|
||||
from . import _trace_jsonl as tj
|
||||
|
||||
return tj.list_run_files()
|
||||
|
||||
def list_traces(
|
||||
self, *, limit: int = 50, agent_id: Optional[str] = None
|
||||
) -> List[Dict[str, Any]]:
|
||||
sdk_traces = self._list_traces_from_sdk(limit=limit, agent_id=agent_id)
|
||||
if sdk_traces is not None:
|
||||
return sdk_traces
|
||||
return self._simple.list_traces(limit=limit, agent_id=agent_id)
|
||||
items: List[Dict[str, Any]] = []
|
||||
for path in self._files():
|
||||
if len(items) >= limit:
|
||||
break
|
||||
trace = self._trace_from_file(path)
|
||||
if trace is None:
|
||||
continue
|
||||
if agent_id and agent_id not in trace["agents"]:
|
||||
continue
|
||||
items.append(trace)
|
||||
remaining = max(0, limit - len(items))
|
||||
if remaining:
|
||||
items.extend(self._simple.list_traces(limit=remaining, agent_id=agent_id))
|
||||
return items
|
||||
|
||||
def list_spans(
|
||||
self, *, limit: int = 100, trace_id: Optional[str] = None
|
||||
) -> List[Dict[str, Any]]:
|
||||
sdk_spans = self._list_spans_from_sdk(limit=limit, trace_id=trace_id)
|
||||
if sdk_spans is not None:
|
||||
return sdk_spans
|
||||
return self._simple.list_spans(limit=limit, trace_id=trace_id)
|
||||
from . import _trace_jsonl as tj
|
||||
|
||||
if trace_id:
|
||||
path = tj.run_path(trace_id)
|
||||
if path is not None and path.is_file():
|
||||
return tj.build_spans(tj.read_events(path), trace_id)[:limit]
|
||||
return self._simple.list_spans(limit=limit, trace_id=trace_id)
|
||||
spans: List[Dict[str, Any]] = []
|
||||
for path in self._files()[: self.MAX_FILES_FOR_SPANS]:
|
||||
spans.extend(tj.build_spans(tj.read_events(path), path.stem))
|
||||
if len(spans) >= limit:
|
||||
return spans[:limit]
|
||||
spans.extend(self._simple.list_spans(limit=limit - len(spans)))
|
||||
return spans[:limit]
|
||||
|
||||
def get_trace(self, trace_id: str) -> Optional[Dict[str, Any]]:
|
||||
sdk_trace = self._get_trace_from_sdk(trace_id)
|
||||
if sdk_trace is not None:
|
||||
return sdk_trace
|
||||
from . import _trace_jsonl as tj
|
||||
|
||||
path = tj.run_path(trace_id)
|
||||
if path is not None and path.is_file():
|
||||
return self._trace_from_file(path)
|
||||
return self._simple.get_trace(trace_id)
|
||||
|
||||
def record_trace(self, trace: Dict[str, Any]) -> str:
|
||||
@@ -168,69 +224,34 @@ class SDKTracingManager(TracingProtocol):
|
||||
def record_span(self, span: Dict[str, Any]) -> str:
|
||||
return self._simple.record_span(span)
|
||||
|
||||
def _list_traces_from_sdk(
|
||||
self, *, limit: int, agent_id: Optional[str]
|
||||
) -> Optional[List[Dict[str, Any]]]:
|
||||
try:
|
||||
from praisonaiagents import trace as trace_module
|
||||
|
||||
getter = getattr(trace_module, "list_traces", None)
|
||||
if not callable(getter):
|
||||
return None
|
||||
items = getter(limit=limit, agent_id=agent_id)
|
||||
return items if isinstance(items, list) else None
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
def _list_spans_from_sdk(
|
||||
self, *, limit: int, trace_id: Optional[str]
|
||||
) -> Optional[List[Dict[str, Any]]]:
|
||||
try:
|
||||
from praisonaiagents import trace as trace_module
|
||||
|
||||
getter = getattr(trace_module, "list_spans", None)
|
||||
if not callable(getter):
|
||||
return None
|
||||
items = getter(limit=limit, trace_id=trace_id)
|
||||
return items if isinstance(items, list) else None
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
def _get_trace_from_sdk(self, trace_id: str) -> Optional[Dict[str, Any]]:
|
||||
try:
|
||||
from praisonaiagents import trace as trace_module
|
||||
|
||||
getter = getattr(trace_module, "get_trace", None)
|
||||
if not callable(getter):
|
||||
return None
|
||||
trace = getter(trace_id)
|
||||
return trace if isinstance(trace, dict) else None
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
def health(self) -> Dict[str, Any]:
|
||||
from . import _trace_jsonl as tj
|
||||
|
||||
h = self._simple.health()
|
||||
h["provider"] = "SDKTracingManager"
|
||||
h["trace_available"] = self._trace_available
|
||||
h["obs_available"] = self._obs_available
|
||||
files = self._files()
|
||||
h["provider"] = self.__class__.__name__
|
||||
h["source"] = "jsonl" if files else "memory"
|
||||
h["traces_dir"] = str(tj.traces_dir())
|
||||
h["jsonl_traces"] = len(files)
|
||||
return h
|
||||
|
||||
|
||||
#: Deprecated alias. The old class looked up ``praisonaiagents.trace.list_traces``
|
||||
#: / ``list_spans`` / ``get_trace``, which never existed, so it always fell back
|
||||
#: to the in-memory store. Recorded runs are JSONL files; read those instead.
|
||||
SDKTracingManager = JsonlTracingManager
|
||||
|
||||
|
||||
# ── Manager singleton ────────────────────────────────────────────────
|
||||
|
||||
_tracing_manager: Optional[TracingProtocol] = None
|
||||
|
||||
|
||||
def get_tracing_manager() -> TracingProtocol:
|
||||
"""Get the active tracing manager (SDK-first, fallback to Simple)."""
|
||||
"""Get the active tracing manager (recorded JSONL runs + in-memory records)."""
|
||||
global _tracing_manager
|
||||
if _tracing_manager is None:
|
||||
try:
|
||||
_tracing_manager = SDKTracingManager()
|
||||
logger.info("Using SDKTracingManager")
|
||||
except Exception as e:
|
||||
logger.debug("SDKTracingManager init failed (%s), using SimpleTracingManager", e)
|
||||
_tracing_manager = SimpleTracingManager()
|
||||
_tracing_manager = JsonlTracingManager()
|
||||
return _tracing_manager
|
||||
|
||||
|
||||
@@ -301,12 +322,10 @@ class TracingFeature(BaseFeatureProtocol):
|
||||
h = mgr.health()
|
||||
lines = ["Tracing Status:"]
|
||||
lines.append(f" Provider: {h.get('provider', 'unknown')}")
|
||||
if "source" in h:
|
||||
lines.append(f" Source: {h['source']} ({h.get('traces_dir', '')})")
|
||||
lines.append(f" Total traces: {h.get('total_traces', 0)}")
|
||||
lines.append(f" Total spans: {h.get('total_spans', 0)}")
|
||||
if "trace_available" in h:
|
||||
lines.append(f" SDK trace: {'available' if h['trace_available'] else 'unavailable'}")
|
||||
if "obs_available" in h:
|
||||
lines.append(f" SDK obs: {'available' if h['obs_available'] else 'unavailable'}")
|
||||
return "\n".join(lines)
|
||||
|
||||
def _cli_list(self, limit: int = 20) -> str:
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
"""Ready-made aiui app that opens straight to PraisonAI Replay.
|
||||
|
||||
Usage (what ``praisonai replay view`` runs)::
|
||||
|
||||
aiui run "$(python -c 'import praisonaiui.replay_app as m; print(m.__file__)')" \
|
||||
--style dashboard --host 127.0.0.1 --port 8090
|
||||
|
||||
then open ``http://127.0.0.1:8090/replay?run=<run_id>`` (``/#/replay?run=<run_id>``
|
||||
is accepted too). The trace directory follows ``PRAISONAI_TRACES_DIR`` >
|
||||
``PRAISON_HOME/traces`` > ``~/.praison/traces``.
|
||||
"""
|
||||
|
||||
from praisonaiui.features.replay import configure_replay_app
|
||||
|
||||
configure_replay_app()
|
||||
@@ -1303,9 +1303,11 @@ async def patch_session(request: Request) -> JSONResponse:
|
||||
body = await request.json()
|
||||
except Exception:
|
||||
return JSONResponse({"error": "Invalid JSON"}, status_code=400)
|
||||
title = body.get("title")
|
||||
title = body.get("title") if isinstance(body, dict) else None
|
||||
if title is not None:
|
||||
await _datastore.update_session(session_id, title=title)
|
||||
if not isinstance(title, str) or not title.strip():
|
||||
return JSONResponse({"error": "title must be a non-empty string"}, status_code=400)
|
||||
await _datastore.update_session(session_id, title=title.strip()[:200])
|
||||
return JSONResponse({"status": "updated", "session_id": session_id})
|
||||
|
||||
|
||||
@@ -3280,6 +3282,8 @@ def create_app(
|
||||
routes.extend(feature.routes())
|
||||
|
||||
# Register built-in dashboard pages via the same protocol
|
||||
from praisonaiui.features.replay import REPLAY_MARK_SVG
|
||||
|
||||
_builtin_pages = [
|
||||
{
|
||||
"id": "overview",
|
||||
@@ -3296,6 +3300,7 @@ def create_app(
|
||||
"group": "Agent",
|
||||
"description": "AI agent chat",
|
||||
"order": 1,
|
||||
"layout": "full-bleed",
|
||||
},
|
||||
{
|
||||
"id": "chat-canvas",
|
||||
@@ -3304,6 +3309,7 @@ def create_app(
|
||||
"group": "Agent",
|
||||
"description": "Chat with live A2UI preview panel",
|
||||
"order": 2,
|
||||
"layout": "full-bleed",
|
||||
},
|
||||
{
|
||||
"id": "canvas",
|
||||
@@ -3345,6 +3351,15 @@ def create_app(
|
||||
"description": "Run flight recorder — timeline debugger",
|
||||
"order": 22,
|
||||
},
|
||||
{
|
||||
"id": "replay",
|
||||
"title": "Replay",
|
||||
"icon": "⏮️",
|
||||
"icon_svg": REPLAY_MARK_SVG,
|
||||
"group": "Control",
|
||||
"description": "Recorded agent runs, step by step: who did what, when, and at what cost",
|
||||
"order": 21,
|
||||
},
|
||||
{
|
||||
"id": "usage",
|
||||
"title": "Usage",
|
||||
@@ -3582,6 +3597,7 @@ def create_app(
|
||||
_page_api_overrides = {
|
||||
"sessions": "/sessions",
|
||||
"runs": "/api/sessions",
|
||||
"replay": "/api/replay/runs",
|
||||
"cron": "/api/schedules",
|
||||
"channels": "/api/channels",
|
||||
"mcp": "/api/mcp/servers",
|
||||
|
||||
@@ -147,6 +147,7 @@ const BUILTIN_VIEWS = {
|
||||
agents: '/plugins/views/agents.js',
|
||||
sessions: '/plugins/views/sessions.js',
|
||||
runs: '/plugins/views/run-recorder.js',
|
||||
replay: '/plugins/views/replay.js',
|
||||
memory: '/plugins/views/memory.js',
|
||||
knowledge: '/plugins/views/knowledge.js',
|
||||
logs: '/plugins/views/logs.js',
|
||||
@@ -377,8 +378,12 @@ const DASHBOARD_STYLE = `
|
||||
.db-main { flex: 1; padding: 32px 40px; overflow-y: auto; min-width: 0; }
|
||||
.db-main.db-clean { padding: 0; display: flex; flex-direction: column; overflow: hidden; height: 100vh; }
|
||||
.db-main.db-clean > #db-page-content { flex: 1; display: flex; flex-direction: column; min-height: 0; }
|
||||
/* Per-page full bleed (page.layout === 'full-bleed'): view fills the main column. */
|
||||
.db-main.db-fill { display: flex; flex-direction: column; overflow: hidden; height: 100vh; box-sizing: border-box; }
|
||||
.db-main.db-fill > #db-page-content { flex: 1; display: flex; flex-direction: column; min-height: 0; }
|
||||
.db-page-header { margin-bottom: 28px; }
|
||||
.db-page-title { font-size: 26px; font-weight: 700; letter-spacing: -0.02em; margin: 0 0 4px; }
|
||||
.db-page-icon svg, .db-nav-icon svg { display: inline-block; vertical-align: -0.125em; }
|
||||
.db-page-desc { color: var(--db-text-dim); font-size: 14px; margin: 0; }
|
||||
|
||||
/* Generic data viewer */
|
||||
@@ -457,10 +462,20 @@ const DASHBOARD_STYLE = `
|
||||
.db-mobile-overlay { display: none; position: fixed; inset: 0; z-index: 35; background: rgba(0,0,0,0.5); }
|
||||
@media (max-width: 1023px) {
|
||||
.db-mobile-header { display: flex; }
|
||||
.db-sidebar { position: fixed; z-index: 45; transform: translateX(-100%); transition: transform 0.2s ease; }
|
||||
/* !important: buildSidebar() sets an inline position:relative for its toggle,
|
||||
which otherwise keeps the hidden sidebar's 260px in the flex row. */
|
||||
.db-sidebar { position: fixed !important; top: 0; left: 0; z-index: 45; transform: translateX(-100%); transition: transform 0.2s ease; }
|
||||
.db-sidebar.db-sidebar-open { transform: translateX(0); }
|
||||
.db-mobile-overlay.db-visible { display: block; }
|
||||
.db-main { padding-top: 48px; }
|
||||
/* The shell header slot sits in the same flex row; give it its own line. */
|
||||
#root.db-active { flex-wrap: wrap; }
|
||||
#db-shell-header-slot { flex: 0 0 100%; margin-top: 48px; }
|
||||
#db-shell-header-slot + .db-main { padding-top: 16px; }
|
||||
}
|
||||
@media (max-width: 600px) {
|
||||
.db-main { padding: 64px 16px 16px; }
|
||||
#db-shell-header-slot + .db-main { padding-top: 16px; }
|
||||
}
|
||||
.db-card {
|
||||
background: var(--db-card-bg); border: 1px solid var(--db-border);
|
||||
@@ -1752,6 +1767,13 @@ async function buildDashboard() {
|
||||
renderSlot('shell:header', shellHeader);
|
||||
if (shellHeader.children.length) root.insertBefore(shellHeader, main);
|
||||
|
||||
// Hash deep links (e.g. /#/replay?run=<id> from `praisonai replay view`)
|
||||
// become the path form (/replay?run=<id>) before the page is resolved.
|
||||
const hashRoute = location.hash.match(/^#\/([A-Za-z0-9_-]+)(\?[^#]*)?$/);
|
||||
if (hashRoute && pagesData.find(p => p.id === hashRoute[1])) {
|
||||
history.replaceState(null, '', `/${hashRoute[1]}${hashRoute[2] || ''}`);
|
||||
}
|
||||
|
||||
// Resolve initial page from URL path or default to first page
|
||||
const pathId = location.pathname.replace(/^\//, '') || '';
|
||||
const initialPage = pathId && pagesData.find(p => p.id === pathId)
|
||||
@@ -1768,6 +1790,29 @@ async function buildDashboard() {
|
||||
});
|
||||
}
|
||||
|
||||
// Pages may carry an inline SVG mark (`icon_svg`) next to the text `icon`.
|
||||
// It is parsed as SVG and dropped if it holds scripts, foreignObject,
|
||||
// event handlers or javascript: URLs; otherwise the text icon is used.
|
||||
function safeSvgIcon(markup) {
|
||||
if (typeof markup !== 'string' || !markup.trim().startsWith('<svg')) return null;
|
||||
try {
|
||||
const doc = new DOMParser().parseFromString(markup, 'image/svg+xml');
|
||||
const svg = doc.documentElement;
|
||||
if (!svg || svg.nodeName !== 'svg' || doc.querySelector('parsererror, script, foreignObject')) return null;
|
||||
for (const el of [svg, ...svg.querySelectorAll('*')]) {
|
||||
for (const a of [...el.attributes]) {
|
||||
if (/^on/i.test(a.name) || /javascript:/i.test(a.value)) return null;
|
||||
}
|
||||
}
|
||||
return document.importNode(svg, true);
|
||||
} catch (e) { return null; }
|
||||
}
|
||||
|
||||
function setPageIcon(span, page, fallback) {
|
||||
const svg = safeSvgIcon(page.icon_svg);
|
||||
if (svg) { span.textContent = ''; span.appendChild(svg); } else span.textContent = page.icon || fallback;
|
||||
}
|
||||
|
||||
function buildSidebar(pages) {
|
||||
const sidebar = document.createElement('nav');
|
||||
sidebar.className = 'db-sidebar';
|
||||
@@ -1866,7 +1911,7 @@ function buildSidebar(pages) {
|
||||
item.dataset.navId = page.id;
|
||||
const iconSpan = document.createElement('span');
|
||||
iconSpan.className = 'db-nav-icon';
|
||||
iconSpan.textContent = page.icon || '📄';
|
||||
setPageIcon(iconSpan, page, '📄');
|
||||
item.appendChild(iconSpan);
|
||||
item.appendChild(document.createTextNode(' ' + page.title));
|
||||
item.addEventListener('click', () => selectPage(page.id));
|
||||
@@ -1888,7 +1933,7 @@ function buildSidebar(pages) {
|
||||
btn.className = 'db-nav-item';
|
||||
const iconSpan = document.createElement('span');
|
||||
iconSpan.className = 'db-nav-icon';
|
||||
iconSpan.textContent = page.icon || '⚙';
|
||||
setPageIcon(iconSpan, page, '⚙');
|
||||
btn.appendChild(iconSpan);
|
||||
btn.appendChild(document.createTextNode(' ' + page.title));
|
||||
btn.addEventListener('click', () => {
|
||||
@@ -1950,14 +1995,18 @@ async function selectPage(pageId) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Full-bleed pages (e.g. chat) fill the main column and carry their own chrome
|
||||
const fullBleed = page.layout === 'full-bleed';
|
||||
main.classList.toggle('db-fill', fullBleed);
|
||||
|
||||
// Set page header (controlled by dashboard config protocol)
|
||||
const showPageHeader = main.dataset.showPageHeader !== 'false';
|
||||
const showPageHeader = main.dataset.showPageHeader !== 'false' && !fullBleed;
|
||||
const toolbarSlotId = `page:${pageId}:toolbar`;
|
||||
const headerHtml = showPageHeader ? `
|
||||
<div class="db-page-header">
|
||||
<div style="display:flex;align-items:flex-start;justify-content:space-between;gap:12px;flex-wrap:wrap">
|
||||
<div>
|
||||
<h1 class="db-page-title">${page.icon || ''} ${page.title}</h1>
|
||||
<h1 class="db-page-title">${page.icon_svg ? '<span class="db-page-icon"></span>' : (page.icon || '')} ${page.title}</h1>
|
||||
${page.description ? `<p class="db-page-desc">${page.description}</p>` : ''}
|
||||
</div>
|
||||
<div id="db-page-toolbar-slot" class="db-slot-host" data-slot="${toolbarSlotId}"></div>
|
||||
@@ -1965,6 +2014,8 @@ async function selectPage(pageId) {
|
||||
</div>
|
||||
` : '';
|
||||
main.innerHTML = `${headerHtml}<div data-page="${pageId}" id="db-page-content"></div>`;
|
||||
const pageIcon = main.querySelector('.db-page-icon');
|
||||
if (pageIcon) setPageIcon(pageIcon, page, '');
|
||||
if (showPageHeader) {
|
||||
const toolbarHost = document.getElementById('db-page-toolbar-slot');
|
||||
if (toolbarHost && SLOT_REGISTRY[toolbarSlotId]) {
|
||||
|
||||
@@ -164,6 +164,20 @@ function renderProgressBar(percentage) {
|
||||
`;
|
||||
}
|
||||
|
||||
function escapeAttr(value) {
|
||||
return String(value).replace(/&/g, '&').replace(/"/g, '"').replace(/</g, '<').replace(/>/g, '>');
|
||||
}
|
||||
|
||||
// Navigate to Chat and load the job's linked session (same event the
|
||||
// session-search palette and run recorder use).
|
||||
function openJobSession(sessionId) {
|
||||
if (!sessionId) return;
|
||||
const nav = (typeof window.aiui?.selectPage === 'function') ? window.aiui.selectPage('chat') : null;
|
||||
Promise.resolve(nav).finally(() => {
|
||||
window.dispatchEvent(new CustomEvent('aiui:session-select', { detail: { sessionId } }));
|
||||
});
|
||||
}
|
||||
|
||||
function renderJobRow(job) {
|
||||
const status = job.status || 'queued';
|
||||
const isTerminal = ['succeeded', 'failed', 'cancelled'].includes(status);
|
||||
@@ -199,6 +213,7 @@ function renderJobRow(job) {
|
||||
` : `
|
||||
${_jobsCfg.backend !== 'praisonai' ? `<button class="aiui-btn aiui-btn-sm aiui-btn-delete" onclick="window.aiuiDeleteJob('${job.id}')" title="Delete">🗑️</button>` : ''}
|
||||
`}
|
||||
${job.session_id ? `<button class="aiui-btn aiui-btn-sm aiui-btn-open-session" data-session-id="${escapeAttr(job.session_id)}" onclick="window.aiuiOpenJobSession(this.dataset.sessionId)" title="Open session in Chat">💬</button>` : ''}
|
||||
<button class="aiui-btn aiui-btn-sm aiui-btn-view" onclick="window.aiuiViewJob('${job.id}')" title="View Details">
|
||||
👁️
|
||||
</button>
|
||||
@@ -599,6 +614,7 @@ function checkForJobsPage(root) {
|
||||
window.aiuiCancelJob = cancelJob;
|
||||
window.aiuiDeleteJob = deleteJob;
|
||||
window.aiuiViewJob = viewJob;
|
||||
window.aiuiOpenJobSession = openJobSession;
|
||||
|
||||
export default {
|
||||
name: 'jobs',
|
||||
|
||||
@@ -1,65 +1,154 @@
|
||||
/**
|
||||
* Approvals View — execution approval queue
|
||||
* API: /api/approvals
|
||||
*
|
||||
* Pending items render as an Approval Card (human-in-the-loop): risk, question,
|
||||
* optional preset options / custom answer / confidence copy — surfaced only when
|
||||
* the API supplies them, so the card stays a plain approve/deny prompt otherwise.
|
||||
*/
|
||||
import { isSkillApproval } from '/plugins/views/_helpers.js';
|
||||
import { isSkillApproval, esc, emptyState } from '/plugins/views/_helpers.js';
|
||||
|
||||
const RISK = {
|
||||
low: { icon: '✅', color: '#22c55e' },
|
||||
medium: { icon: '⚠️', color: '#eab308' },
|
||||
high: { icon: '🟠', color: '#f97316' },
|
||||
critical: { icon: '🔴', color: '#ef4444' },
|
||||
};
|
||||
|
||||
/**
|
||||
* Normalise a raw /api/approvals item into an ApprovalPromptModel-shaped object.
|
||||
* Keeps the field-mapping in one place; only carries what the API actually sends.
|
||||
*/
|
||||
function toApprovalModel(a) {
|
||||
const id = a.id || a.approval_id || '';
|
||||
const risk = String(a.risk_level || '').toLowerCase();
|
||||
const options = Array.isArray(a.options)
|
||||
? a.options.map(o => (typeof o === 'string' ? { value: o, label: o } : { value: o.value ?? o.id ?? o.label, label: o.label ?? String(o.value ?? o.id) }))
|
||||
: [];
|
||||
const confidence = typeof a.confidence === 'number' ? Math.round(a.confidence * (a.confidence <= 1 ? 100 : 1)) : null;
|
||||
return {
|
||||
id,
|
||||
title: a.tool_name || a.action || 'Unknown',
|
||||
question: a.reason || a.description || a.question || '',
|
||||
agent: a.agent_name || '',
|
||||
risk,
|
||||
riskMeta: RISK[risk] || { icon: '❓', color: 'var(--db-text-dim)' },
|
||||
createdAt: a.created_at ? new Date(a.created_at * 1000).toLocaleString() : '',
|
||||
options,
|
||||
allowCustom: a.allow_custom_answer === true || (options.length === 0 && a.custom_answer !== false && !!a.question),
|
||||
confidence,
|
||||
isSkill: isSkillApproval(a),
|
||||
};
|
||||
}
|
||||
|
||||
function approvalCard(m) {
|
||||
const optionsHtml = m.options.length
|
||||
? `<div class="db-approval-options" role="radiogroup" aria-label="Response options" style="display:flex;flex-wrap:wrap;gap:6px;margin-top:10px">
|
||||
${m.options.map((o, i) => `
|
||||
<label style="display:inline-flex;align-items:center;gap:6px;font-size:12px;padding:4px 10px;border:1px solid var(--db-border);border-radius:6px;cursor:pointer">
|
||||
<input type="radio" name="opt-${esc(m.id)}" value="${esc(o.value)}" ${i === 0 ? 'checked' : ''}> ${esc(o.label)}
|
||||
</label>`).join('')}
|
||||
</div>`
|
||||
: '';
|
||||
const customHtml = m.allowCustom
|
||||
? `<input class="approv-custom" data-id="${esc(m.id)}" type="text" placeholder="Custom answer (optional)"
|
||||
aria-label="Custom answer for ${esc(m.title)}"
|
||||
style="margin-top:10px;width:100%;padding:6px 10px;font-size:12px;background:var(--db-bg);color:var(--db-text);border:1px solid var(--db-border);border-radius:6px">`
|
||||
: '';
|
||||
const confidenceHtml = m.confidence != null
|
||||
? `<div style="font-size:11px;color:var(--db-text-dim);margin-top:6px">Model confidence: ${m.confidence}%</div>`
|
||||
: '';
|
||||
const skillHtml = m.isSkill
|
||||
? `<a href="/skills?tab=pending&approval_id=${encodeURIComponent(m.id)}" data-skill-studio="1" style="display:inline-block;margin-top:8px;font-size:11px;color:var(--db-accent);text-decoration:none">Skill write? Open Skills Studio →</a>`
|
||||
: '';
|
||||
|
||||
return `
|
||||
<div class="db-card db-approval-card" data-id="${esc(m.id)}" role="group"
|
||||
aria-label="Approval request: ${esc(m.title)}"
|
||||
style="margin-bottom:12px;padding:16px 20px;border-left:3px solid ${m.riskMeta.color}">
|
||||
<div style="display:flex;align-items:flex-start;justify-content:space-between;gap:12px">
|
||||
<div style="flex:1;min-width:0">
|
||||
<div style="font-weight:600;font-size:14px">${m.riskMeta.icon} ${esc(m.title)}</div>
|
||||
${m.question ? `<div style="font-size:13px;color:var(--db-text);margin-top:6px">${esc(m.question)}</div>` : ''}
|
||||
<div style="font-size:12px;color:var(--db-text-dim);margin-top:4px">Agent: ${esc(m.agent) || '—'}${m.risk ? ` · risk: ${esc(m.risk)}` : ''}</div>
|
||||
${m.createdAt ? `<div style="font-size:11px;color:var(--db-text-dim)">${esc(m.createdAt)}</div>` : ''}
|
||||
${optionsHtml}
|
||||
${customHtml}
|
||||
${confidenceHtml}
|
||||
${skillHtml}
|
||||
</div>
|
||||
<div style="display:flex;gap:8px;flex-shrink:0">
|
||||
<button class="approv-yes" data-id="${esc(m.id)}" aria-label="Approve ${esc(m.title)}"
|
||||
style="padding:6px 16px;background:rgba(34,197,94,0.15);color:#22c55e;border:1px solid rgba(34,197,94,0.3);border-radius:6px;cursor:pointer;font-size:12px">✓ Approve</button>
|
||||
<button class="approv-no" data-id="${esc(m.id)}" aria-label="Deny ${esc(m.title)}"
|
||||
style="padding:6px 16px;background:rgba(239,68,68,0.15);color:#ef4444;border:1px solid rgba(239,68,68,0.3);border-radius:6px;cursor:pointer;font-size:12px">✗ Deny</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
export async function render(container) {
|
||||
container.innerHTML = '<div class="db-loading"><div class="db-spinner"></div></div>';
|
||||
|
||||
let pending = [], history = [], policies = {};
|
||||
try { const r = await fetch('/api/approvals/pending'); pending = (await r.json()).approvals || []; } catch(e) {}
|
||||
try { const r = await fetch('/api/approvals/history'); history = (await r.json()).approvals || (await r.json()).history || []; } catch(e) {}
|
||||
try { const r = await fetch('/api/approvals/history'); const d = await r.json(); history = d.approvals || d.history || []; } catch(e) {}
|
||||
try { const r = await fetch('/api/approvals/policies'); policies = await r.json(); } catch(e) {}
|
||||
|
||||
const riskIcons = { low: '✅', medium: '⚠️', high: '🟠', critical: '🔴' };
|
||||
const models = pending.map(toApprovalModel);
|
||||
|
||||
container.innerHTML = `
|
||||
<div class="db-columns" style="grid-template-columns:repeat(3,1fr);margin-bottom:24px">
|
||||
<div class="db-card"><div class="db-card-title">Pending</div><div class="db-card-value" style="color:#eab308">${pending.length}</div></div>
|
||||
<div class="db-card"><div class="db-card-title">Risk Threshold</div><div class="db-card-value" style="font-size:18px">${policies.risk_threshold || 'high'}</div></div>
|
||||
<div class="db-card"><div class="db-card-title">Pending</div><div class="db-card-value" style="color:#eab308">${models.length}</div></div>
|
||||
<div class="db-card"><div class="db-card-title">Risk Threshold</div><div class="db-card-value" style="font-size:18px">${esc(policies.risk_threshold || 'high')}</div></div>
|
||||
<div class="db-card"><div class="db-card-title">Auto-Approve Tools</div><div class="db-card-value" style="font-size:18px">${(policies.auto_approve_tools || []).length}</div></div>
|
||||
</div>
|
||||
|
||||
<h3 style="margin:0 0 12px;font-size:15px;font-weight:600">Pending Approvals</h3>
|
||||
<div id="approv-pending" style="margin-bottom:28px">
|
||||
${pending.length === 0 ? '<div class="db-viewer"><pre>No pending approvals</pre></div>' : ''}
|
||||
${models.length === 0 ? emptyState({ icon: '✅', title: 'No pending approvals', body: 'All clear — nothing needs your decision.' }) : models.map(approvalCard).join('')}
|
||||
</div>
|
||||
|
||||
<h3 style="margin:0 0 12px;font-size:15px;font-weight:600">Recent History</h3>
|
||||
<div id="approv-history"></div>
|
||||
`;
|
||||
|
||||
const pendingEl = container.querySelector('#approv-pending');
|
||||
pending.forEach(a => {
|
||||
const card = document.createElement('div');
|
||||
card.className = 'db-card';
|
||||
card.style.cssText = 'margin-bottom:10px;padding:16px 20px;display:flex;align-items:center;justify-content:space-between';
|
||||
const isSkill = isSkillApproval(a);
|
||||
card.innerHTML = `
|
||||
<div style="flex:1">
|
||||
<div style="font-weight:500;font-size:14px">${riskIcons[a.risk_level] || '❓'} ${a.tool_name || a.action || 'Unknown'}</div>
|
||||
<div style="font-size:12px;color:var(--db-text-dim);margin-top:4px">Agent: ${a.agent_name || '—'} · ${a.reason || ''}</div>
|
||||
<div style="font-size:11px;color:var(--db-text-dim)">${a.created_at ? new Date(a.created_at * 1000).toLocaleString() : ''}</div>
|
||||
${isSkill ? `<a href="/skills?tab=pending&approval_id=${encodeURIComponent(a.id || a.approval_id || '')}" data-skill-studio="1" style="font-size:11px;color:var(--db-accent);text-decoration:none">Skill write? Open Skills Studio →</a>` : ''}
|
||||
</div>
|
||||
<div style="display:flex;gap:8px">
|
||||
<button class="approv-yes" data-id="${a.id || a.approval_id}" style="padding:6px 16px;background:rgba(34,197,94,0.15);color:#22c55e;border:1px solid rgba(34,197,94,0.3);border-radius:6px;cursor:pointer;font-size:12px">✓ Approve</button>
|
||||
<button class="approv-no" data-id="${a.id || a.approval_id}" style="padding:6px 16px;background:rgba(239,68,68,0.15);color:#ef4444;border:1px solid rgba(239,68,68,0.3);border-radius:6px;cursor:pointer;font-size:12px">✗ Deny</button>
|
||||
</div>
|
||||
`;
|
||||
pendingEl.appendChild(card);
|
||||
});
|
||||
function collectExtra(id) {
|
||||
const card = container.querySelector(`.db-approval-card[data-id="${CSS.escape(id)}"]`);
|
||||
const body = {};
|
||||
if (!card) return body;
|
||||
const opt = card.querySelector(`input[name="opt-${CSS.escape(id)}"]:checked`);
|
||||
if (opt) body.option = opt.value;
|
||||
const custom = card.querySelector('.approv-custom');
|
||||
if (custom && custom.value.trim()) body.custom_answer = custom.value.trim();
|
||||
// Also surface the human's choice as `reason` — the only extra field the
|
||||
// approve/deny endpoints persist today — so the answer isn't dropped while
|
||||
// `option`/`custom_answer` stay for a future richer backend.
|
||||
const reason = body.custom_answer || body.option;
|
||||
if (reason != null && reason !== '') body.reason = String(reason);
|
||||
return body;
|
||||
}
|
||||
|
||||
container.querySelectorAll('.approv-yes').forEach(b => b.addEventListener('click', async () => { try { await fetch(`/api/approvals/${b.dataset.id}/approve`, {method:'POST'}); render(container); } catch(e){} }));
|
||||
container.querySelectorAll('.approv-no').forEach(b => b.addEventListener('click', async () => { try { await fetch(`/api/approvals/${b.dataset.id}/deny`, {method:'POST'}); render(container); } catch(e){} }));
|
||||
async function decide(id, action) {
|
||||
try {
|
||||
await fetch(`/api/approvals/${encodeURIComponent(id)}/${action}`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(collectExtra(id)),
|
||||
});
|
||||
render(container);
|
||||
} catch(e) {}
|
||||
}
|
||||
|
||||
container.querySelectorAll('.approv-yes').forEach(b => b.addEventListener('click', () => decide(b.dataset.id, 'approve')));
|
||||
container.querySelectorAll('.approv-no').forEach(b => b.addEventListener('click', () => decide(b.dataset.id, 'deny')));
|
||||
|
||||
const historyEl = container.querySelector('#approv-history');
|
||||
(Array.isArray(history) ? history : []).slice(0, 20).forEach(h => {
|
||||
const div = document.createElement('div');
|
||||
div.style.cssText = 'padding:8px 0;border-bottom:1px solid var(--db-border);font-size:13px;display:flex;justify-content:space-between';
|
||||
const approved = h.status === 'approved' || h.approved;
|
||||
div.innerHTML = `<span>${h.tool_name || h.action || '?'} — ${h.agent_name || ''}</span><span style="color:${approved ? '#22c55e' : '#ef4444'}">${approved ? '✓ approved' : '✗ denied'}</span>`;
|
||||
div.innerHTML = `<span>${esc(h.tool_name || h.action || '?')} — ${esc(h.agent_name || '')}</span><span style="color:${approved ? '#22c55e' : '#ef4444'}">${approved ? '✓ approved' : '✗ denied'}</span>`;
|
||||
historyEl.appendChild(div);
|
||||
});
|
||||
if (!history.length) historyEl.innerHTML = '<div style="font-size:13px;color:var(--db-text-dim)">No history</div>';
|
||||
|
||||
@@ -21,6 +21,7 @@ function injectStyles() {
|
||||
overflow: hidden;
|
||||
}
|
||||
.db-clean .chat-canvas-root { height: 100vh; }
|
||||
.db-fill .chat-canvas-root { flex: 1; min-height: 0; height: auto; }
|
||||
.chat-canvas-chat {
|
||||
flex: 1 1 60%;
|
||||
min-width: 0;
|
||||
|
||||
@@ -19,6 +19,7 @@ let isStreaming = false;
|
||||
let streamingSessionId = null; // tracks which session is actively streaming
|
||||
let currentRunId = null;
|
||||
let agents = [];
|
||||
let allSessions = []; // last fetched session list, filtered client-side by the rail search
|
||||
let messageQueue = [];
|
||||
let focusMode = false;
|
||||
let sessionSelectHandler = null;
|
||||
@@ -221,6 +222,11 @@ function injectStyles() {
|
||||
style.textContent = `
|
||||
.chat-root { display:flex; height:calc(100vh - 140px); gap:0; border-radius:12px; overflow:hidden; border:1px solid var(--db-border); background:var(--db-card-bg); }
|
||||
.db-clean .chat-root { height:100%; border-radius:0; border:none; }
|
||||
.db-fill .chat-root { flex:1; min-height:0; height:auto; }
|
||||
@media (max-width:900px) {
|
||||
.db-fill .chat-root { flex-direction:column; }
|
||||
.db-fill .chat-sidebar-panel { width:100%; min-width:0; max-height:40vh; border-right:none; border-bottom:1px solid var(--db-border); }
|
||||
}
|
||||
.chat-root.focus-mode .chat-sidebar-panel { display:none; }
|
||||
.chat-root.focus-mode .chat-main-panel { border-left:none; }
|
||||
|
||||
@@ -230,12 +236,17 @@ function injectStyles() {
|
||||
.chat-sidebar-agent { padding:10px 16px; border-bottom:1px solid var(--db-border); }
|
||||
.chat-sidebar-agent label { font-size:11px; color:var(--db-text-dim); display:block; margin-bottom:4px; text-transform:uppercase; font-weight:600; letter-spacing:.5px; }
|
||||
.chat-sidebar-agent select { width:100%; padding:6px 10px; background:var(--db-card-bg); border:1px solid var(--db-border); border-radius:6px; color:var(--db-text); font-size:13px; }
|
||||
.chat-sess-search { padding:8px 16px 0; }
|
||||
.chat-sess-search input { width:100%; box-sizing:border-box; padding:6px 10px; background:var(--db-card-bg); border:1px solid var(--db-border); border-radius:6px; color:var(--db-text); font-size:13px; }
|
||||
.chat-sess-list { flex:1; overflow-y:auto; padding:8px; }
|
||||
.chat-sess-item { padding:10px 12px; border-radius:8px; cursor:pointer; margin-bottom:4px; font-size:13px; transition:background .15s; display:flex; flex-direction:column; gap:2px; }
|
||||
.chat-sess-item:hover { background:rgba(var(--db-accent-rgb,100,100,255),.08); }
|
||||
.chat-sess-item.active { background:rgba(var(--db-accent-rgb,100,100,255),.15); }
|
||||
.chat-sess-item .sess-title { font-weight:500; white-space:nowrap; overflow:hidden; text-overflow:ellipsis; }
|
||||
.chat-sess-item .sess-meta { font-size:11px; color:var(--db-text-dim); }
|
||||
.chat-sess-item { position:relative; }
|
||||
.chat-sess-item .sess-rename { position:absolute; top:6px; right:6px; display:none; background:none; border:none; color:var(--db-text-dim); cursor:pointer; font-size:12px; padding:2px 4px; }
|
||||
.chat-sess-item:hover .sess-rename, .chat-sess-item .sess-rename:focus { display:block; }
|
||||
|
||||
.chat-main-panel { flex:1; display:flex; flex-direction:column; min-width:0; }
|
||||
.chat-top-bar { padding:10px 16px; border-bottom:1px solid var(--db-border); display:flex; align-items:center; justify-content:space-between; gap:10px; }
|
||||
@@ -812,6 +823,9 @@ export async function render(container) {
|
||||
<label>Agent</label>
|
||||
<select id="chat-agent-selector"><option value="">Auto (default)</option></select>
|
||||
</div>
|
||||
<div class="chat-sess-search">
|
||||
<input type="search" id="chat-session-search" placeholder="Search sessions…" aria-label="Search sessions" autocomplete="off">
|
||||
</div>
|
||||
<div class="chat-sess-list" id="chat-sessions-list"></div>
|
||||
</div>
|
||||
<div class="chat-main-panel">
|
||||
@@ -892,6 +906,8 @@ export async function render(container) {
|
||||
const agentSelector = document.getElementById('chat-agent-selector');
|
||||
const attachBtn = document.getElementById('chat-attach-btn');
|
||||
const fileInput = document.getElementById('chat-file-input');
|
||||
const sessionSearch = document.getElementById('chat-session-search');
|
||||
if (sessionSearch) sessionSearch.addEventListener('input', renderSessionList);
|
||||
// Guard: if DOM elements aren't present yet (race condition), bail out
|
||||
if (!input || !sendBtn) {
|
||||
console.warn('[chat] DOM elements not ready — skipping event binding');
|
||||
@@ -1039,47 +1055,92 @@ async function loadSessions() {
|
||||
resp = await fetch('/sessions');
|
||||
}
|
||||
const data = await resp.json();
|
||||
const sessions = data.sessions || data || [];
|
||||
const list = document.getElementById('chat-sessions-list');
|
||||
if (!list) return;
|
||||
list.innerHTML = '';
|
||||
if (sessions.length === 0) {
|
||||
list.innerHTML = '<div style="padding:16px;text-align:center;color:var(--db-text-dim);font-size:12px">No sessions yet</div>';
|
||||
return;
|
||||
}
|
||||
sessions.slice(0, 30).forEach((s) => {
|
||||
const el = document.createElement('div');
|
||||
const id = s.id || s.session_id || '';
|
||||
el.className = 'chat-sess-item' + (id === currentSessionId ? ' active' : '');
|
||||
// Show platform icon for channel sessions (e.g. Slack with SVG)
|
||||
const platformName = s.platform || '';
|
||||
const platSvg = _getPlatformSVG(platformName);
|
||||
let title;
|
||||
let titlePrefix = '';
|
||||
if (platformName) {
|
||||
const displayName = platformName.charAt(0).toUpperCase() + platformName.slice(1);
|
||||
if (platSvg) {
|
||||
titlePrefix = '<span class="sess-platform-icon ' + _getPlatformClass(platformName) + '">' + platSvg + '</span>';
|
||||
}
|
||||
title = displayName;
|
||||
} else {
|
||||
title = s.name || s.title || s.label || id.substring(0, 12) + '…';
|
||||
}
|
||||
const agent = s.agent_id || s.agent || '';
|
||||
const time = timeAgo(s.created_at || s.updated_at);
|
||||
el.innerHTML = `
|
||||
<div class="sess-title">${titlePrefix}${escapeHtml(title)}</div>
|
||||
<div class="sess-meta">${agent ? '🤖 ' + escapeHtml(agent) + ' · ' : ''}${time}</div>
|
||||
`;
|
||||
el.title = id;
|
||||
el.addEventListener('click', () => loadSession(id));
|
||||
list.appendChild(el);
|
||||
});
|
||||
const fetched = data.sessions || data || [];
|
||||
allSessions = Array.isArray(fetched) ? fetched : [];
|
||||
renderSessionList();
|
||||
} catch (e) {
|
||||
console.warn('[Chat] Failed to load sessions:', e);
|
||||
}
|
||||
}
|
||||
|
||||
function _sessionMatches(s, q) {
|
||||
const fields = [s.id, s.session_id, s.name, s.title, s.label, s.agent_id, s.agent, s.platform];
|
||||
return fields.some((v) => typeof v === 'string' && v.toLowerCase().includes(q));
|
||||
}
|
||||
|
||||
function renderSessionList() {
|
||||
const list = document.getElementById('chat-sessions-list');
|
||||
if (!list) return;
|
||||
const input = document.getElementById('chat-session-search');
|
||||
const q = (input && input.value || '').trim().toLowerCase();
|
||||
const sessions = q ? allSessions.filter((s) => _sessionMatches(s, q)) : allSessions;
|
||||
list.innerHTML = '';
|
||||
if (sessions.length === 0) {
|
||||
const msg = q ? 'No matching sessions' : 'No sessions yet';
|
||||
list.innerHTML = '<div style="padding:16px;text-align:center;color:var(--db-text-dim);font-size:12px">' + msg + '</div>';
|
||||
return;
|
||||
}
|
||||
sessions.slice(0, 30).forEach((s) => {
|
||||
const el = document.createElement('div');
|
||||
const id = s.id || s.session_id || '';
|
||||
el.className = 'chat-sess-item' + (id === currentSessionId ? ' active' : '');
|
||||
// Show platform icon for channel sessions (e.g. Slack with SVG)
|
||||
const platformName = s.platform || '';
|
||||
const platSvg = _getPlatformSVG(platformName);
|
||||
let title;
|
||||
let titlePrefix = '';
|
||||
if (platformName) {
|
||||
const displayName = platformName.charAt(0).toUpperCase() + platformName.slice(1);
|
||||
if (platSvg) {
|
||||
titlePrefix = '<span class="sess-platform-icon ' + _getPlatformClass(platformName) + '">' + platSvg + '</span>';
|
||||
}
|
||||
title = displayName;
|
||||
} else {
|
||||
title = s.name || s.title || s.label || id.substring(0, 12) + '…';
|
||||
}
|
||||
const agent = s.agent_id || s.agent || '';
|
||||
const time = timeAgo(s.created_at || s.updated_at);
|
||||
el.innerHTML = `
|
||||
<div class="sess-title">${titlePrefix}${escapeHtml(title)}</div>
|
||||
<div class="sess-meta">${agent ? '🤖 ' + escapeHtml(agent) + ' · ' : ''}${time}</div>
|
||||
`;
|
||||
el.title = id;
|
||||
el.addEventListener('click', () => loadSession(id));
|
||||
if (!platformName && id) {
|
||||
const btn = document.createElement('button');
|
||||
btn.className = 'sess-rename';
|
||||
btn.title = 'Rename session';
|
||||
btn.setAttribute('aria-label', 'Rename session');
|
||||
btn.textContent = '✎';
|
||||
btn.addEventListener('click', (e) => { e.stopPropagation(); renameSession(id, title); });
|
||||
el.appendChild(btn);
|
||||
}
|
||||
list.appendChild(el);
|
||||
});
|
||||
}
|
||||
|
||||
async function renameSession(sessionId, currentTitle) {
|
||||
const next = (window.prompt('Rename session', currentTitle || '') || '').trim();
|
||||
if (!next || next === currentTitle) return;
|
||||
try {
|
||||
const resp = await fetch('/sessions/' + encodeURIComponent(sessionId), {
|
||||
method: 'PATCH',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ title: next }),
|
||||
});
|
||||
if (!resp.ok) throw new Error('HTTP ' + resp.status);
|
||||
const s = allSessions.find((x) => (x.id || x.session_id) === sessionId);
|
||||
if (s) { s.title = next; delete s.name; }
|
||||
renderSessionList();
|
||||
if (sessionId === currentSessionId) {
|
||||
const header = document.getElementById('chat-header-title');
|
||||
if (header) header.textContent = next;
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn('[Chat] Failed to rename session:', e);
|
||||
}
|
||||
}
|
||||
|
||||
async function loadSession(sessionId) {
|
||||
currentSessionId = sessionId;
|
||||
refreshStreamingUI();
|
||||
@@ -1131,7 +1192,8 @@ async function loadSession(sessionId) {
|
||||
title.textContent = (sessionIcon || '📨') + ' ' + displayName;
|
||||
}
|
||||
} else if (title) {
|
||||
title.textContent = 'Session ' + sessionId.substring(0, 8);
|
||||
const cached = allSessions.find((x) => (x.id || x.session_id) === sessionId);
|
||||
title.textContent = (cached && (cached.name || cached.title)) || ('Session ' + sessionId.substring(0, 8));
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn('[Chat] Failed to load session:', e);
|
||||
|
||||
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,524 @@
|
||||
"""Browser smoke + axe accessibility check for the Replay view (dev-only, never shipped).
|
||||
|
||||
Opt-in: RUN_BROWSER_TESTS=1 AXE_CORE_JS=/path/to/axe.min.js pytest tests/browser -o addopts=""
|
||||
|
||||
* Needs the ``playwright`` Python package and a Chrome install (``channel="chrome"``)
|
||||
or a Playwright Chromium (``python -m playwright install chromium``).
|
||||
* axe-core (MPL-2.0) is loaded from ``AXE_CORE_JS`` (e.g. ``npm i axe-core`` in
|
||||
any scratch dir); the axe checks are skipped when it is not provided.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
import shutil
|
||||
import socket
|
||||
import threading
|
||||
import time
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
if os.environ.get("RUN_BROWSER_TESTS") != "1":
|
||||
pytest.skip("browser tests are opt-in (RUN_BROWSER_TESTS=1)", allow_module_level=True)
|
||||
|
||||
sync_api = pytest.importorskip("playwright.sync_api")
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[2]
|
||||
FIXTURES = ROOT / "tests" / "fixtures" / "replay"
|
||||
AXE = os.environ.get("AXE_CORE_JS")
|
||||
WCAG_TAGS = ["wcag2a", "wcag2aa", "wcag21a", "wcag21aa", "wcag22aa"]
|
||||
|
||||
|
||||
def _events(name: str) -> list[dict]:
|
||||
return [json.loads(line) for line in (FIXTURES / f"{name}.jsonl").read_text(encoding="utf-8").splitlines() if line.strip()]
|
||||
|
||||
|
||||
@pytest.fixture(scope="module")
|
||||
def browser():
|
||||
with sync_api.sync_playwright() as p:
|
||||
try:
|
||||
b = p.chromium.launch(channel="chrome")
|
||||
except Exception:
|
||||
try:
|
||||
b = p.chromium.launch()
|
||||
except Exception as exc: # no browser available
|
||||
pytest.skip(f"no Chromium/Chrome available: {exc}")
|
||||
yield b
|
||||
b.close()
|
||||
|
||||
|
||||
def _page(browser, scheme: str = "light", reduced: bool = False):
|
||||
page = browser.new_page(
|
||||
viewport={"width": 1366, "height": 1000},
|
||||
color_scheme=scheme,
|
||||
reduced_motion="reduce" if reduced else "no-preference",
|
||||
)
|
||||
errors: list[str] = []
|
||||
page.on("pageerror", lambda e: errors.append(str(e)))
|
||||
page.on("console", lambda m: errors.append(m.text) if m.type == "error" else None)
|
||||
return page, errors
|
||||
|
||||
|
||||
def _axe(page, selector: str = ".rp-root") -> list[dict]:
|
||||
if not AXE:
|
||||
return []
|
||||
page.add_script_tag(path=AXE)
|
||||
result = page.evaluate(
|
||||
"""async ([sel, tags]) => {
|
||||
const r = await axe.run(document.querySelector(sel), {runOnly: {type: 'tag', values: tags}});
|
||||
return r.violations.map(v => ({id: v.id, impact: v.impact, help: v.help, nodes: v.nodes.slice(0, 3).map(n => n.target.join(' '))}));
|
||||
}""",
|
||||
[selector, WCAG_TAGS],
|
||||
)
|
||||
return result
|
||||
|
||||
|
||||
def _standalone(tmp_path: Path, name: str) -> str:
|
||||
from praisonaiui.features.replay import standalone_html
|
||||
|
||||
out = tmp_path / f"{name}.html"
|
||||
out.write_text(standalone_html(_events(name), title=name), encoding="utf-8")
|
||||
return out.as_uri()
|
||||
|
||||
|
||||
def _current_index(page) -> int:
|
||||
return int(page.get_attribute('.rp-step[aria-current="step"]', "data-i"))
|
||||
|
||||
|
||||
@pytest.mark.parametrize("name", ["team_handoff", "flow_parallel_loop", "legacy_v10", "team_outside"])
|
||||
def test_standalone_steps_with_arrow_keys(browser, tmp_path, name):
|
||||
page, errors = _page(browser)
|
||||
page.goto(_standalone(tmp_path, name))
|
||||
page.wait_for_selector(".rp-step")
|
||||
total = page.locator(".rp-step").count()
|
||||
assert total > 5
|
||||
assert _current_index(page) == total - 1 # opens at the end (whole run visible)
|
||||
|
||||
page.locator(".rp-step").nth(total - 1).focus()
|
||||
page.keyboard.press("Home")
|
||||
assert _current_index(page) == 0
|
||||
for _ in range(3):
|
||||
page.keyboard.press("ArrowRight")
|
||||
assert _current_index(page) == 3
|
||||
page.keyboard.press("ArrowLeft")
|
||||
assert _current_index(page) == 2
|
||||
# focus follows the cursor in the step list, the scrubber and the drawer agree
|
||||
assert page.evaluate("document.activeElement.getAttribute('data-i')") == "2"
|
||||
assert page.input_value("#rp-scrub-range") == "2"
|
||||
step_text = page.locator('.rp-step[aria-current="step"] .rp-step-text').inner_text()
|
||||
assert page.locator(".rp-drawer h3").inner_text() == step_text
|
||||
# replay: later blocks are drawn as "not reached yet"
|
||||
assert page.locator(".rp-block.rp-future").count() > 0
|
||||
page.keyboard.press("End")
|
||||
assert _current_index(page) == total - 1
|
||||
assert page.locator(".rp-block.rp-future").count() == 0
|
||||
assert not errors, errors
|
||||
|
||||
|
||||
def test_standalone_drawer_tabs_map_and_axe(browser, tmp_path):
|
||||
page, errors = _page(browser)
|
||||
page.goto(_standalone(tmp_path, "team_handoff"))
|
||||
page.wait_for_selector(".rp-step")
|
||||
# select the hand-off step and read the Why tab
|
||||
page.locator(".rp-step", has_text="handed off to Writer").click()
|
||||
page.locator('[role="tab"]', has_text="Why").click()
|
||||
why = page.locator("#rp-tabpanel").inner_text()
|
||||
assert "Hand-off reason" in why and "Facts gathered" in why
|
||||
# keyboard on the tablist moves between tabs
|
||||
page.keyboard.press("ArrowRight")
|
||||
assert page.get_attribute('[role="tab"][aria-selected="true"]', "id") == "rp-tab-raw"
|
||||
assert '"event_type": "agent_handoff"' in page.locator("#rp-tabpanel pre").inner_text()
|
||||
# manager's task snapshot shows up for the delegated task
|
||||
page.locator(".rp-step", has_text="Task 'research' went to Researcher").click()
|
||||
page.locator('[role="tab"]', has_text="Why").click()
|
||||
assert "Manager's task snapshot" in page.locator("#rp-tabpanel").inner_text()
|
||||
# approval evidence
|
||||
page.locator(".rp-step", has_text="You approved write_file").click()
|
||||
assert "Decided by" in page.locator("#rp-tabpanel").inner_text()
|
||||
|
||||
violations = _axe(page)
|
||||
assert violations == [], json.dumps(violations, indent=2)
|
||||
|
||||
page.click("[data-key='overlay']")
|
||||
page.click("[data-key='view:map']")
|
||||
page.wait_for_selector(".rp-node")
|
||||
assert page.locator(".rp-node").count() == 10
|
||||
labels = page.eval_on_selector_all(".rp-edge text", "els => els.map(e => e.textContent)")
|
||||
assert "hands off" in labels and any(t.startswith("delegates") for t in labels)
|
||||
page.locator(".rp-node").first.focus()
|
||||
page.keyboard.press("ArrowDown")
|
||||
page.keyboard.press("Enter")
|
||||
assert page.locator(".rp-drawer h3").inner_text()
|
||||
violations = _axe(page)
|
||||
assert violations == [], json.dumps(violations, indent=2)
|
||||
|
||||
page.select_option("[data-key='theme']", "dark")
|
||||
assert page.get_attribute(".rp-root", "data-theme") == "dark"
|
||||
violations = _axe(page)
|
||||
assert violations == [], json.dumps(violations, indent=2)
|
||||
page.click("[data-key='view:timeline']")
|
||||
violations = _axe(page)
|
||||
assert violations == [], json.dumps(violations, indent=2)
|
||||
assert not errors, errors
|
||||
|
||||
|
||||
def test_reduced_motion_has_no_animation(browser, tmp_path):
|
||||
page, _ = _page(browser, reduced=True)
|
||||
page.goto(_standalone(tmp_path, "flow_parallel_loop"))
|
||||
page.wait_for_selector(".rp-step")
|
||||
names = page.evaluate(
|
||||
"[...document.querySelectorAll('.rp-root *')].map(e => getComputedStyle(e).animationName).filter(n => n && n !== 'none')"
|
||||
)
|
||||
assert names == []
|
||||
|
||||
|
||||
def _boxes(page, selector: str) -> list[dict]:
|
||||
return page.eval_on_selector_all(
|
||||
selector,
|
||||
"els => els.map(e => { const r = e.getBoundingClientRect(); return {x: r.x, y: r.y, w: r.width, h: r.height, id: e.closest('[data-block]')?.getAttribute('data-block')}; })",
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("name", ["team_outside", "team_handoff", "flow_parallel_loop"])
|
||||
def test_timeline_blocks_never_overlap(browser, tmp_path, name):
|
||||
"""An LLM span encloses its tool calls in real runs; tools go on their own row."""
|
||||
page, errors = _page(browser)
|
||||
page.goto(_standalone(tmp_path, name))
|
||||
page.wait_for_selector(".rp-block")
|
||||
boxes = _boxes(page, ".rp-block .rp-body")
|
||||
for i, a in enumerate(boxes):
|
||||
for b in boxes[i + 1:]:
|
||||
apart = a["x"] + a["w"] <= b["x"] + 0.5 or b["x"] + b["w"] <= a["x"] + 0.5 or a["y"] + a["h"] <= b["y"] + 0.5 or b["y"] + b["h"] <= a["y"] + 0.5
|
||||
assert apart, f"{a['id']} overlaps {b['id']}"
|
||||
# a label that does not fit is ellipsized, and the full text is in the title and name
|
||||
llm = page.locator(".rp-block.rp-k-llm").first
|
||||
name_attr = llm.get_attribute("aria-label")
|
||||
title = llm.locator("title").text_content()
|
||||
assert name_attr.startswith(title)
|
||||
llm.focus()
|
||||
assert page.locator(".rp-tip").get_attribute("visibility") == "visible"
|
||||
assert page.locator(".rp-tip text").text_content() == title
|
||||
assert not errors, errors
|
||||
|
||||
|
||||
def test_why_tab_by_role_and_keyboard(browser, tmp_path):
|
||||
page, errors = _page(browser)
|
||||
page.goto(_standalone(tmp_path, "team_outside"))
|
||||
page.wait_for_selector(".rp-step")
|
||||
page.locator(".rp-step", has_text="Triage handed off to Billing").click()
|
||||
page.get_by_role("tab", name="Why").click()
|
||||
assert page.get_by_role("tab", name="Why").get_attribute("aria-selected") == "true"
|
||||
why = page.get_by_role("tabpanel").inner_text()
|
||||
assert "Hand-off reason" in why and "Billing question" in why and "Context passed" in why
|
||||
# the task input
|
||||
page.locator(".rp-step", has_text="Task 'research' went to Researcher").click()
|
||||
assert "Task description" in page.get_by_role("tabpanel").inner_text()
|
||||
assert "Get one fact about electric vehicles" in page.get_by_role("tabpanel").inner_text()
|
||||
# keyboard: focus the selected tab, arrow keys move between tabs
|
||||
page.get_by_role("tab", name="Why").focus()
|
||||
page.keyboard.press("ArrowLeft")
|
||||
assert page.get_by_role("tab", name="What happened").get_attribute("aria-selected") == "true"
|
||||
page.keyboard.press("ArrowRight")
|
||||
assert page.get_by_role("tab", name="Why").get_attribute("aria-selected") == "true"
|
||||
assert page.evaluate("document.activeElement.getAttribute('role')") == "tab"
|
||||
# route chosen and approval decision
|
||||
page.goto(_standalone(tmp_path, "flow_parallel_loop"))
|
||||
page.wait_for_selector(".rp-step")
|
||||
page.locator(".rp-step", has_text="chose route 'publish'").click()
|
||||
page.get_by_role("tab", name="Why").click()
|
||||
assert "Route chosen" in page.get_by_role("tabpanel").inner_text()
|
||||
page.goto(_standalone(tmp_path, "team_handoff"))
|
||||
page.wait_for_selector(".rp-step")
|
||||
page.locator(".rp-step", has_text="You approved write_file").click()
|
||||
page.get_by_role("tab", name="Why").click()
|
||||
panel = page.get_by_role("tabpanel").inner_text()
|
||||
assert "Decision" in panel and "Approved with edited arguments" in panel
|
||||
assert not errors, errors
|
||||
|
||||
|
||||
def test_theme_follows_system_with_override(browser, tmp_path):
|
||||
url = _standalone(tmp_path, "team_handoff")
|
||||
bg = "getComputedStyle(document.querySelector('.rp-root')).backgroundColor"
|
||||
page, _ = _page(browser, scheme="dark")
|
||||
page.goto(url)
|
||||
page.wait_for_selector(".rp-step")
|
||||
assert page.get_attribute(".rp-root", "data-theme") == "system"
|
||||
assert page.evaluate(bg) == "rgb(22, 23, 27)"
|
||||
page.select_option("[data-key='theme']", "light")
|
||||
assert page.evaluate(bg) == "rgb(255, 255, 255)"
|
||||
page.close()
|
||||
page, _ = _page(browser, scheme="light")
|
||||
page.goto(url)
|
||||
page.wait_for_selector(".rp-step")
|
||||
assert page.evaluate(bg) == "rgb(255, 255, 255)"
|
||||
|
||||
|
||||
def test_map_fits_on_open_and_groups_outside_agents(browser, tmp_path):
|
||||
page, errors = _page(browser)
|
||||
page.goto(_standalone(tmp_path, "team_outside"))
|
||||
page.wait_for_selector(".rp-step")
|
||||
page.get_by_role("button", name="Map").click()
|
||||
page.wait_for_selector(".rp-node")
|
||||
box = page.evaluate("(() => { const c = document.querySelector('.rp-canvas'); const r = c.getBoundingClientRect(); return {l: r.left, r: r.right, sw: c.scrollWidth, cw: c.clientWidth}; })()")
|
||||
assert box["sw"] <= box["cw"] + 1, "map overflows on open"
|
||||
for n in _boxes(page, ".rp-node .rp-card"):
|
||||
assert box["l"] - 1 <= n["x"] and n["x"] + n["w"] <= box["r"] + 1
|
||||
assert "Outside the team" in page.locator(".rp-group text").text_content()
|
||||
outside = page.get_by_role("group", name="Outside the team")
|
||||
assert outside.locator(".rp-node").count() == 3
|
||||
labels = page.eval_on_selector_all(".rp-edge text", "els => els.map(e => e.textContent)")
|
||||
assert "hands off" in labels
|
||||
before = float(page.get_attribute(".rp-canvas svg", "width"))
|
||||
page.get_by_role("button", name="Zoom in").click()
|
||||
assert float(page.get_attribute(".rp-canvas svg", "width")) == pytest.approx(before * 1.5, abs=2)
|
||||
page.get_by_role("button", name="Fit").click()
|
||||
assert float(page.get_attribute(".rp-canvas svg", "width")) == pytest.approx(before, abs=2)
|
||||
assert not errors, errors
|
||||
|
||||
|
||||
@pytest.mark.parametrize("name", ["team_outside", "flow_parallel_loop"])
|
||||
def test_phone_width_has_no_page_scroll(browser, tmp_path, name):
|
||||
page, errors = _page(browser)
|
||||
page.set_viewport_size({"width": 400, "height": 860})
|
||||
page.goto(_standalone(tmp_path, name))
|
||||
page.wait_for_selector(".rp-step")
|
||||
assert page.evaluate("document.documentElement.scrollWidth") <= 400
|
||||
r = page.evaluate("(() => { const b = document.querySelector('.rp-root').getBoundingClientRect(); return [b.left, b.right]; })()")
|
||||
assert r[0] == pytest.approx(16, abs=1) and r[1] == pytest.approx(384, abs=1)
|
||||
page.get_by_role("button", name="Map").click()
|
||||
assert page.evaluate("document.documentElement.scrollWidth") <= 400
|
||||
assert not errors, errors
|
||||
|
||||
|
||||
@pytest.mark.parametrize("order", ["core-first", "view-first"])
|
||||
def test_wrapper_style_export_boots(browser, tmp_path, order):
|
||||
"""The wrapper inlines both files as module scripts into #replay-root (which holds
|
||||
a fallback table) after window.__REPLAY_DATA__; the view must replace it."""
|
||||
views = ROOT / "src" / "praisonaiui" / "templates" / "frontend" / "plugins" / "views"
|
||||
core = (views / "replay-core.js").read_text(encoding="utf-8").replace("</", "<\\/")
|
||||
view = (views / "replay.js").read_text(encoding="utf-8").replace("</", "<\\/")
|
||||
scripts = [core, view] if order == "core-first" else [view, core]
|
||||
data = json.dumps({"run_id": "team-outside-01", "events": _events("team_outside"), "graph": None}).replace("<", "\\u003c")
|
||||
html = (
|
||||
"<!doctype html><html lang=en><head><meta charset=utf-8><title>PraisonAI Replay: x</title></head><body>"
|
||||
"<div id=replay-root><table><tr><td>fallback</td></tr></table></div>"
|
||||
f"<script id=replay-data>window.__REPLAY_DATA__ = {data};</script>"
|
||||
+ "".join(f"<script type=module>\n{js}\n</script>" for js in scripts)
|
||||
+ "</body></html>"
|
||||
)
|
||||
out = tmp_path / f"export-{order}.html"
|
||||
out.write_text(html, encoding="utf-8")
|
||||
page, errors = _page(browser)
|
||||
page.goto(out.as_uri())
|
||||
page.wait_for_selector(".rp-step")
|
||||
assert page.locator("#replay-root table").count() == 0
|
||||
assert page.locator(".rp-title").inner_text() == "team-outside-01"
|
||||
# graph:null -> taken from workflow_start.data.graph
|
||||
page.get_by_role("button", name="Map").click()
|
||||
assert page.locator(".rp-node").count() == 10
|
||||
assert page.locator(".rp-ask").count() == 0
|
||||
assert not errors, errors
|
||||
|
||||
|
||||
# ── dashboard + live run + approval through the You lane ─────────────
|
||||
|
||||
|
||||
_PROVIDER_KEYS = ("OPENAI_API_KEY", "ANTHROPIC_API_KEY", "GEMINI_API_KEY", "GOOGLE_API_KEY", "GROQ_API_KEY")
|
||||
|
||||
|
||||
def _no_provider_keys(monkeypatch) -> None:
|
||||
"""A real aiui server runs startup hooks; keep them from calling paid APIs."""
|
||||
for key in _PROVIDER_KEYS:
|
||||
monkeypatch.delenv(key, raising=False)
|
||||
|
||||
|
||||
def _free_port() -> int:
|
||||
with socket.socket() as s:
|
||||
s.bind(("127.0.0.1", 0))
|
||||
return s.getsockname()[1]
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def live_server(tmp_path, monkeypatch):
|
||||
import uvicorn
|
||||
|
||||
import praisonaiui.server as server
|
||||
from praisonaiui.features import approvals
|
||||
from praisonaiui.features.replay import configure_replay_app
|
||||
|
||||
traces = tmp_path / "traces"
|
||||
traces.mkdir()
|
||||
for name in ("team_handoff", "flow_parallel_loop"):
|
||||
shutil.copy(FIXTURES / f"{name}.jsonl", traces / f"{name}.jsonl")
|
||||
lines = (FIXTURES / "team_handoff.jsonl").read_text(encoding="utf-8").splitlines(keepends=True)
|
||||
cut = next(i for i, line in enumerate(lines) if '"approval_decided"' in line)
|
||||
live = traces / "live-team.jsonl"
|
||||
live.write_text("".join(lines[:cut]), encoding="utf-8")
|
||||
monkeypatch.setenv("PRAISONAI_TRACES_DIR", str(traces))
|
||||
monkeypatch.setenv("AIUI_DATA_DIR", str(tmp_path / "aiui-data"))
|
||||
monkeypatch.setenv("PRAISONAI_HOME", str(tmp_path / "praisonai-home"))
|
||||
_no_provider_keys(monkeypatch)
|
||||
store = approvals.SimpleApprovalManager()
|
||||
monkeypatch.setattr(approvals, "_approval_manager", store)
|
||||
|
||||
server.reset_state()
|
||||
configure_replay_app()
|
||||
app = server.create_app()
|
||||
port = _free_port()
|
||||
srv = uvicorn.Server(uvicorn.Config(app, 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)
|
||||
yield {"url": f"http://127.0.0.1:{port}", "store": store, "live": live, "lines": lines, "cut": cut}
|
||||
srv.should_exit = True
|
||||
t.join(timeout=5)
|
||||
server.reset_state()
|
||||
|
||||
|
||||
def test_dashboard_live_run_and_approval(browser, live_server):
|
||||
pytest.importorskip("praisonaiagents.approval.protocols")
|
||||
from praisonaiagents.approval.protocols import ApprovalRequest
|
||||
|
||||
from praisonaiui.features.approvals import _UIStoreBackend
|
||||
|
||||
store = live_server["store"]
|
||||
backend = _UIStoreBackend(store, timeout=30)
|
||||
box = {}
|
||||
req = ApprovalRequest(tool_name="write_file", arguments={"path": "draft.md", "content": "hello"},
|
||||
risk_level="high", agent_name="Writer", approval_id="appr-0001")
|
||||
t = threading.Thread(target=lambda: box.setdefault("d", backend.request_approval_sync(req)), daemon=True)
|
||||
t.start()
|
||||
|
||||
page, errors = _page(browser)
|
||||
page.goto(f"{live_server['url']}/#/replay?run=live-team")
|
||||
page.wait_for_selector(".rp-run")
|
||||
assert page.evaluate("location.pathname + location.search") == "/replay?run=live-team"
|
||||
assert page.locator(".rp-run[aria-current='true']").inner_text().startswith("Solar sail brief")
|
||||
assert page.locator(".rp-badge.rp-b-live").count() >= 1
|
||||
|
||||
card = page.locator(".rp-ask")
|
||||
card.wait_for()
|
||||
assert "Writer asks to run write_file" in card.inner_text()
|
||||
page.get_by_role("button", name="Edit arguments…").click()
|
||||
ta = page.locator(".rp-ask textarea")
|
||||
ta.wait_for()
|
||||
assert json.loads(ta.input_value()) == {"path": "draft.md", "content": "hello"} # server-held args, not redacted
|
||||
ta.fill("{not json")
|
||||
assert ta.get_attribute("aria-invalid") == "true"
|
||||
assert "Not valid JSON" in page.locator(".rp-ask .rp-err").inner_text()
|
||||
ta.fill('{"path": "docs/draft.md", "content": "hello"}')
|
||||
assert ta.get_attribute("aria-invalid") == "false"
|
||||
violations = _axe(page)
|
||||
assert violations == [], json.dumps(violations, indent=2)
|
||||
page.get_by_role("button", name="Approve with these arguments").click()
|
||||
t.join(timeout=10)
|
||||
decision = box["d"]
|
||||
assert decision.approved is True
|
||||
assert decision.modified_args == {"path": "docs/draft.md", "content": "hello"}
|
||||
page.locator(".rp-ask [role='status']").wait_for()
|
||||
|
||||
# the run continues: the rest of the file streams in over SSE
|
||||
with open(live_server["live"], "a", encoding="utf-8") as fh:
|
||||
fh.write("".join(live_server["lines"][live_server["cut"]:]))
|
||||
page.wait_for_selector(".rp-step:has-text('You approved write_file')", timeout=10000)
|
||||
page.wait_for_selector(".rp-step:has-text('Recording ended.')", timeout=10000)
|
||||
assert page.locator(".rp-ask").count() == 0
|
||||
page.wait_for_selector(".rp-bar .rp-b-completed", timeout=15000)
|
||||
assert not [e for e in errors if "favicon" not in e], errors
|
||||
|
||||
|
||||
def test_empty_state_explains_how_to_record(browser, tmp_path, monkeypatch):
|
||||
import uvicorn
|
||||
|
||||
import praisonaiui.server as server
|
||||
from praisonaiui.features.replay import configure_replay_app
|
||||
|
||||
monkeypatch.setenv("PRAISONAI_TRACES_DIR", str(tmp_path / "empty"))
|
||||
monkeypatch.setenv("AIUI_DATA_DIR", str(tmp_path / "aiui-data"))
|
||||
monkeypatch.setenv("PRAISONAI_HOME", str(tmp_path / "praisonai-home"))
|
||||
_no_provider_keys(monkeypatch)
|
||||
server.reset_state()
|
||||
configure_replay_app()
|
||||
app = server.create_app()
|
||||
port = _free_port()
|
||||
srv = uvicorn.Server(uvicorn.Config(app, host="127.0.0.1", port=port, log_level="warning"))
|
||||
th = threading.Thread(target=srv.run, daemon=True)
|
||||
th.start()
|
||||
try:
|
||||
for _ in range(100):
|
||||
if srv.started:
|
||||
break
|
||||
time.sleep(0.05)
|
||||
page, _ = _page(browser)
|
||||
page.goto(f"http://127.0.0.1:{port}/")
|
||||
page.wait_for_selector(".rp-empty")
|
||||
text = page.locator(".rp-empty").inner_text()
|
||||
assert "praisonai replay view agents.yaml" in text
|
||||
assert "with record(live=True):" in text
|
||||
violations = _axe(page)
|
||||
assert violations == [], json.dumps(violations, indent=2)
|
||||
finally:
|
||||
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)
|
||||
+42
@@ -0,0 +1,42 @@
|
||||
{"schema_version":"1.1","event_type":"session_start","timestamp":1767225600.0,"session_id":"sess-flow-01","agent_name":"","sequence_num":0,"messages_count":0,"tokens_used":0,"tokens_budget":0,"data":{},"prompt_tokens":0,"completion_tokens":0,"cost_usd":0.0,"run_id":"flow-loop-01"}
|
||||
{"schema_version":"1.1","event_type":"workflow_start","timestamp":1767225600.02,"session_id":"sess-flow-01","agent_name":"","sequence_num":1,"messages_count":0,"tokens_used":0,"tokens_budget":0,"data":{"kind":"flow","name":"Weekly digest","process":"flow","graph":{"schema":"praisonai.graph/1","kind":"flow","name":"Weekly digest","process":"flow","nodes":[{"id":"start:begin","kind":"start","label":"Brief","parent":null,"meta":{}},{"id":"step:plan","kind":"step","label":"plan","parent":null,"meta":{"agent":"Planner"}},{"id":"step:gather","kind":"fork","label":"gather","parent":null,"meta":{"pattern":"parallel"}},{"id":"step:news","kind":"step","label":"news","parent":"step:gather","meta":{"agent":"Fetcher"}},{"id":"step:papers","kind":"step","label":"papers","parent":"step:gather","meta":{"agent":"Fetcher"}},{"id":"step:merge","kind":"join","label":"merge","parent":null,"meta":{}},{"id":"step:review_loop","kind":"step","label":"review_loop","parent":null,"meta":{"pattern":"loop"}},{"id":"step:polish","kind":"step","label":"polish","parent":"step:review_loop","meta":{"agent":"Editor"}},{"id":"step:decide","kind":"decision","label":"decide","parent":null,"meta":{"pattern":"route"}},{"id":"step:publish","kind":"step","label":"publish","parent":null,"meta":{"agent":"Publisher"}},{"id":"step:archive","kind":"step","label":"archive","parent":null,"meta":{}},{"id":"end:done","kind":"end","label":"Delivered","parent":null,"meta":{}}],"edges":[{"id":"start:begin->step:plan:next","source":"start:begin","target":"step:plan","kind":"next","label":null},{"id":"step:plan->step:gather:next","source":"step:plan","target":"step:gather","kind":"next","label":null},{"id":"step:gather->step:news:parallel","source":"step:gather","target":"step:news","kind":"parallel","label":null},{"id":"step:gather->step:papers:parallel","source":"step:gather","target":"step:papers","kind":"parallel","label":null},{"id":"step:news->step:merge:next","source":"step:news","target":"step:merge","kind":"next","label":null},{"id":"step:papers->step:merge:next","source":"step:papers","target":"step:merge","kind":"next","label":null},{"id":"step:merge->step:review_loop:next","source":"step:merge","target":"step:review_loop","kind":"next","label":null},{"id":"step:review_loop->step:polish:loop","source":"step:review_loop","target":"step:polish","kind":"loop","label":"until approved"},{"id":"step:polish->step:review_loop:loop","source":"step:polish","target":"step:review_loop","kind":"loop","label":null},{"id":"step:review_loop->step:decide:next","source":"step:review_loop","target":"step:decide","kind":"next","label":null},{"id":"step:decide->step:publish:routes","source":"step:decide","target":"step:publish","kind":"routes","label":"publish"},{"id":"step:decide->step:archive:routes","source":"step:decide","target":"step:archive","kind":"routes","label":"archive"},{"id":"step:publish->end:done:next","source":"step:publish","target":"end:done","kind":"next","label":null},{"id":"step:archive->end:done:next","source":"step:archive","target":"end:done","kind":"next","label":null}]}},"prompt_tokens":0,"completion_tokens":0,"cost_usd":0.0,"run_id":"flow-loop-01","parent_seq":0}
|
||||
{"schema_version":"1.1","event_type":"step_start","timestamp":1767225600.05,"session_id":"sess-flow-01","agent_name":"Planner","sequence_num":2,"messages_count":0,"tokens_used":0,"tokens_budget":0,"data":{"name":"plan"},"prompt_tokens":0,"completion_tokens":0,"cost_usd":0.0,"run_id":"flow-loop-01","parent_seq":1,"node_id":"step:plan"}
|
||||
{"schema_version":"1.1","event_type":"agent_start","timestamp":1767225600.06,"session_id":"sess-flow-01","agent_name":"Planner","sequence_num":3,"messages_count":0,"tokens_used":0,"tokens_budget":0,"data":{},"prompt_tokens":0,"completion_tokens":0,"cost_usd":0.0,"run_id":"flow-loop-01","parent_seq":2,"node_id":"agent:Planner"}
|
||||
{"schema_version":"1.1","event_type":"llm_request","timestamp":1767225600.1,"session_id":"sess-flow-01","agent_name":"Planner","sequence_num":4,"messages_count":3,"tokens_used":150,"tokens_budget":128000,"data":{"model":"gpt-4o-mini"},"prompt_tokens":0,"completion_tokens":0,"cost_usd":0.0,"run_id":"flow-loop-01","parent_seq":3,"node_id":"agent:Planner"}
|
||||
{"schema_version":"1.1","event_type":"llm_response","timestamp":1767225600.9,"session_id":"sess-flow-01","agent_name":"Planner","sequence_num":5,"messages_count":0,"tokens_used":0,"tokens_budget":0,"data":{"response_tokens":60,"duration_ms":800.0,"finish_reason":"stop","model":"gpt-4o-mini"},"prompt_tokens":150,"completion_tokens":60,"cost_usd":5.9e-05,"run_id":"flow-loop-01","parent_seq":3,"node_id":"agent:Planner"}
|
||||
{"schema_version":"1.1","event_type":"agent_end","timestamp":1767225600.95,"session_id":"sess-flow-01","agent_name":"Planner","sequence_num":6,"messages_count":0,"tokens_used":0,"tokens_budget":0,"data":{},"prompt_tokens":0,"completion_tokens":0,"cost_usd":0.0,"run_id":"flow-loop-01","parent_seq":3,"node_id":"agent:Planner"}
|
||||
{"schema_version":"1.1","event_type":"step_end","timestamp":1767225601.0,"session_id":"sess-flow-01","agent_name":"Planner","sequence_num":7,"messages_count":0,"tokens_used":0,"tokens_budget":0,"data":{"status":"completed","duration_ms":950.0,"output_preview":"Gather news and papers."},"prompt_tokens":0,"completion_tokens":0,"cost_usd":0.0,"run_id":"flow-loop-01","parent_seq":2,"node_id":"step:plan"}
|
||||
{"schema_version":"1.1","event_type":"step_start","timestamp":1767225601.05,"session_id":"sess-flow-01","agent_name":"","sequence_num":8,"messages_count":0,"tokens_used":0,"tokens_budget":0,"data":{"name":"gather","pattern":"parallel"},"prompt_tokens":0,"completion_tokens":0,"cost_usd":0.0,"run_id":"flow-loop-01","parent_seq":1,"node_id":"step:gather"}
|
||||
{"schema_version":"1.1","event_type":"step_start","timestamp":1767225601.1,"session_id":"sess-flow-01","agent_name":"Fetcher","sequence_num":9,"messages_count":0,"tokens_used":0,"tokens_budget":0,"data":{"name":"news"},"prompt_tokens":0,"completion_tokens":0,"cost_usd":0.0,"branch_id":"gather/0","run_id":"flow-loop-01","parent_seq":8,"node_id":"step:news"}
|
||||
{"schema_version":"1.1","event_type":"step_start","timestamp":1767225601.1,"session_id":"sess-flow-01","agent_name":"Fetcher","sequence_num":10,"messages_count":0,"tokens_used":0,"tokens_budget":0,"data":{"name":"papers"},"prompt_tokens":0,"completion_tokens":0,"cost_usd":0.0,"branch_id":"gather/1","run_id":"flow-loop-01","parent_seq":8,"node_id":"step:papers"}
|
||||
{"schema_version":"1.1","event_type":"tool_call_start","timestamp":1767225601.15,"session_id":"sess-flow-01","agent_name":"Fetcher","sequence_num":11,"messages_count":0,"tokens_used":0,"tokens_budget":0,"data":{"tool_name":"fetch_rss","tool_args":{"url":"https://example.org/news.rss"}},"prompt_tokens":0,"completion_tokens":0,"cost_usd":0.0,"branch_id":"gather/0","run_id":"flow-loop-01","parent_seq":9,"node_id":"tool:Fetcher/fetch_rss"}
|
||||
{"schema_version":"1.1","event_type":"tool_call_start","timestamp":1767225601.16,"session_id":"sess-flow-01","agent_name":"Fetcher","sequence_num":12,"messages_count":0,"tokens_used":0,"tokens_budget":0,"data":{"tool_name":"search_papers","tool_args":{"query":"agent tracing"}},"prompt_tokens":0,"completion_tokens":0,"cost_usd":0.0,"branch_id":"gather/1","run_id":"flow-loop-01","parent_seq":10,"node_id":"tool:Fetcher/search_papers"}
|
||||
{"schema_version":"1.1","event_type":"tool_call_end","timestamp":1767225602.4,"session_id":"sess-flow-01","agent_name":"Fetcher","sequence_num":13,"messages_count":0,"tokens_used":0,"tokens_budget":0,"data":{"tool_name":"fetch_rss","result":"12 items","duration_ms":1250.0,"error":null},"prompt_tokens":0,"completion_tokens":0,"cost_usd":0.0,"branch_id":"gather/0","run_id":"flow-loop-01","parent_seq":11,"node_id":"tool:Fetcher/fetch_rss"}
|
||||
{"schema_version":"1.1","event_type":"tool_call_end","timestamp":1767225603.0,"session_id":"sess-flow-01","agent_name":"Fetcher","sequence_num":14,"messages_count":0,"tokens_used":0,"tokens_budget":0,"data":{"tool_name":"search_papers","result":"5 papers","duration_ms":1840.0,"error":null},"prompt_tokens":0,"completion_tokens":0,"cost_usd":0.001,"branch_id":"gather/1","run_id":"flow-loop-01","parent_seq":12,"node_id":"tool:Fetcher/search_papers"}
|
||||
{"schema_version":"1.1","event_type":"step_end","timestamp":1767225602.45,"session_id":"sess-flow-01","agent_name":"Fetcher","sequence_num":15,"messages_count":0,"tokens_used":0,"tokens_budget":0,"data":{"status":"completed","duration_ms":1350.0,"output_preview":"12 items"},"prompt_tokens":0,"completion_tokens":0,"cost_usd":0.0,"branch_id":"gather/0","run_id":"flow-loop-01","parent_seq":9,"node_id":"step:news"}
|
||||
{"schema_version":"1.1","event_type":"step_end","timestamp":1767225603.05,"session_id":"sess-flow-01","agent_name":"Fetcher","sequence_num":16,"messages_count":0,"tokens_used":0,"tokens_budget":0,"data":{"status":"completed","duration_ms":1950.0,"output_preview":"5 papers"},"prompt_tokens":0,"completion_tokens":0,"cost_usd":0.0,"branch_id":"gather/1","run_id":"flow-loop-01","parent_seq":10,"node_id":"step:papers"}
|
||||
{"schema_version":"1.1","event_type":"step_end","timestamp":1767225603.1,"session_id":"sess-flow-01","agent_name":"","sequence_num":17,"messages_count":0,"tokens_used":0,"tokens_budget":0,"data":{"status":"completed","duration_ms":2050.0},"prompt_tokens":0,"completion_tokens":0,"cost_usd":0.0,"run_id":"flow-loop-01","parent_seq":8,"node_id":"step:gather"}
|
||||
{"schema_version":"1.1","event_type":"context_rebalance","timestamp":1767225603.12,"session_id":"sess-flow-01","agent_name":"","sequence_num":18,"messages_count":0,"tokens_used":0,"tokens_budget":0,"data":{"note":"future event type"},"prompt_tokens":0,"completion_tokens":0,"cost_usd":0.0,"run_id":"flow-loop-01","parent_seq":1,"future_field":{"x":1}}
|
||||
{"schema_version":"1.1","event_type":"step_start","timestamp":1767225603.15,"session_id":"sess-flow-01","agent_name":"","sequence_num":19,"messages_count":0,"tokens_used":0,"tokens_budget":0,"data":{"name":"review_loop","pattern":"loop"},"prompt_tokens":0,"completion_tokens":0,"cost_usd":0.0,"run_id":"flow-loop-01","parent_seq":1,"node_id":"step:review_loop"}
|
||||
{"schema_version":"1.1","event_type":"step_start","timestamp":1767225603.2,"session_id":"sess-flow-01","agent_name":"Editor","sequence_num":20,"messages_count":0,"tokens_used":0,"tokens_budget":0,"data":{"name":"polish","iteration":1},"prompt_tokens":0,"completion_tokens":0,"cost_usd":0.0,"run_id":"flow-loop-01","parent_seq":19,"node_id":"step:polish"}
|
||||
{"schema_version":"1.1","event_type":"llm_request","timestamp":1767225603.22,"session_id":"sess-flow-01","agent_name":"Editor","sequence_num":21,"messages_count":3,"tokens_used":400,"tokens_budget":128000,"data":{"model":"gpt-4o-mini"},"prompt_tokens":0,"completion_tokens":0,"cost_usd":0.0,"run_id":"flow-loop-01","parent_seq":20,"node_id":"agent:Editor"}
|
||||
{"schema_version":"1.1","event_type":"llm_response","timestamp":1767225603.92,"session_id":"sess-flow-01","agent_name":"Editor","sequence_num":22,"messages_count":0,"tokens_used":0,"tokens_budget":0,"data":{"response_tokens":90,"duration_ms":700.0,"finish_reason":"stop","model":"gpt-4o-mini"},"prompt_tokens":400,"completion_tokens":90,"cost_usd":0.000114,"run_id":"flow-loop-01","parent_seq":20,"node_id":"agent:Editor"}
|
||||
{"schema_version":"1.1","event_type":"step_end","timestamp":1767225603.95,"session_id":"sess-flow-01","agent_name":"Editor","sequence_num":23,"messages_count":0,"tokens_used":0,"tokens_budget":0,"data":{"status":"completed","duration_ms":750.0,"output_preview":"Draft v2"},"prompt_tokens":0,"completion_tokens":0,"cost_usd":0.0,"run_id":"flow-loop-01","parent_seq":20,"node_id":"step:polish"}
|
||||
{"schema_version":"1.1","event_type":"step_start","timestamp":1767225604.0,"session_id":"sess-flow-01","agent_name":"Editor","sequence_num":24,"messages_count":0,"tokens_used":0,"tokens_budget":0,"data":{"name":"polish","iteration":2},"prompt_tokens":0,"completion_tokens":0,"cost_usd":0.0,"run_id":"flow-loop-01","parent_seq":19,"node_id":"step:polish"}
|
||||
{"schema_version":"1.1","event_type":"llm_request","timestamp":1767225604.02,"session_id":"sess-flow-01","agent_name":"Editor","sequence_num":25,"messages_count":3,"tokens_used":450,"tokens_budget":128000,"data":{"model":"gpt-4o-mini"},"prompt_tokens":0,"completion_tokens":0,"cost_usd":0.0,"run_id":"flow-loop-01","parent_seq":24,"node_id":"agent:Editor"}
|
||||
{"schema_version":"1.1","event_type":"llm_response","timestamp":1767225604.72,"session_id":"sess-flow-01","agent_name":"Editor","sequence_num":26,"messages_count":0,"tokens_used":0,"tokens_budget":0,"data":{"response_tokens":90,"duration_ms":700.0,"finish_reason":"stop","model":"gpt-4o-mini"},"prompt_tokens":450,"completion_tokens":90,"cost_usd":0.00012150000000000001,"run_id":"flow-loop-01","parent_seq":24,"node_id":"agent:Editor"}
|
||||
{"schema_version":"1.1","event_type":"step_end","timestamp":1767225604.75,"session_id":"sess-flow-01","agent_name":"Editor","sequence_num":27,"messages_count":0,"tokens_used":0,"tokens_budget":0,"data":{"status":"completed","duration_ms":750.0,"output_preview":"Draft v3"},"prompt_tokens":0,"completion_tokens":0,"cost_usd":0.0,"run_id":"flow-loop-01","parent_seq":24,"node_id":"step:polish"}
|
||||
{"schema_version":"1.1","event_type":"step_start","timestamp":1767225604.8,"session_id":"sess-flow-01","agent_name":"Editor","sequence_num":28,"messages_count":0,"tokens_used":0,"tokens_budget":0,"data":{"name":"polish","iteration":3},"prompt_tokens":0,"completion_tokens":0,"cost_usd":0.0,"run_id":"flow-loop-01","parent_seq":19,"node_id":"step:polish"}
|
||||
{"schema_version":"1.1","event_type":"llm_request","timestamp":1767225604.82,"session_id":"sess-flow-01","agent_name":"Editor","sequence_num":29,"messages_count":3,"tokens_used":500,"tokens_budget":128000,"data":{"model":"gpt-4o-mini"},"prompt_tokens":0,"completion_tokens":0,"cost_usd":0.0,"run_id":"flow-loop-01","parent_seq":28,"node_id":"agent:Editor"}
|
||||
{"schema_version":"1.1","event_type":"llm_response","timestamp":1767225605.52,"session_id":"sess-flow-01","agent_name":"Editor","sequence_num":30,"messages_count":0,"tokens_used":0,"tokens_budget":0,"data":{"response_tokens":90,"duration_ms":700.0,"finish_reason":"stop","model":"gpt-4o-mini"},"prompt_tokens":500,"completion_tokens":90,"cost_usd":0.00012900000000000002,"run_id":"flow-loop-01","parent_seq":28,"node_id":"agent:Editor"}
|
||||
{"schema_version":"1.1","event_type":"step_end","timestamp":1767225605.55,"session_id":"sess-flow-01","agent_name":"Editor","sequence_num":31,"messages_count":0,"tokens_used":0,"tokens_budget":0,"data":{"status":"completed","duration_ms":750.0,"output_preview":"Draft v4"},"prompt_tokens":0,"completion_tokens":0,"cost_usd":0.0,"run_id":"flow-loop-01","parent_seq":28,"node_id":"step:polish"}
|
||||
{"schema_version":"1.1","event_type":"step_end","timestamp":1767225605.6,"session_id":"sess-flow-01","agent_name":"","sequence_num":32,"messages_count":0,"tokens_used":0,"tokens_budget":0,"data":{"status":"completed","duration_ms":2450.0},"prompt_tokens":0,"completion_tokens":0,"cost_usd":0.0,"run_id":"flow-loop-01","parent_seq":19,"node_id":"step:review_loop"}
|
||||
{"schema_version":"1.1","event_type":"step_start","timestamp":1767225605.65,"session_id":"sess-flow-01","agent_name":"","sequence_num":33,"messages_count":0,"tokens_used":0,"tokens_budget":0,"data":{"name":"decide","pattern":"route"},"prompt_tokens":0,"completion_tokens":0,"cost_usd":0.0,"run_id":"flow-loop-01","parent_seq":1,"node_id":"step:decide"}
|
||||
{"schema_version":"1.1","event_type":"agent_handoff","timestamp":1767225605.7,"session_id":"sess-flow-01","agent_name":"Editor","sequence_num":34,"messages_count":0,"tokens_used":0,"tokens_budget":0,"data":{"from_agent":"Editor","to_agent":"Publisher","kind":"route","reason":"Draft approved by reviewer score 0.92","context_passed":{"score":0.92}},"prompt_tokens":0,"completion_tokens":0,"cost_usd":0.0,"run_id":"flow-loop-01","parent_seq":33,"node_id":"step:decide"}
|
||||
{"schema_version":"1.1","event_type":"step_end","timestamp":1767225605.75,"session_id":"sess-flow-01","agent_name":"","sequence_num":35,"messages_count":0,"tokens_used":0,"tokens_budget":0,"data":{"status":"completed","duration_ms":100.0,"route":"publish"},"prompt_tokens":0,"completion_tokens":0,"cost_usd":0.0,"run_id":"flow-loop-01","parent_seq":33,"node_id":"step:decide"}
|
||||
{"schema_version":"1.1","event_type":"step_start","timestamp":1767225605.8,"session_id":"sess-flow-01","agent_name":"Publisher","sequence_num":36,"messages_count":0,"tokens_used":0,"tokens_budget":0,"data":{"name":"publish"},"prompt_tokens":0,"completion_tokens":0,"cost_usd":0.0,"run_id":"flow-loop-01","parent_seq":1,"node_id":"step:publish"}
|
||||
{"schema_version":"1.1","event_type":"tool_call_start","timestamp":1767225605.85,"session_id":"sess-flow-01","agent_name":"Publisher","sequence_num":37,"messages_count":0,"tokens_used":0,"tokens_budget":0,"data":{"tool_name":"post_to_blog","tool_args":{"title":"Weekly digest"}},"prompt_tokens":0,"completion_tokens":0,"cost_usd":0.0,"run_id":"flow-loop-01","parent_seq":36,"node_id":"tool:Publisher/post_to_blog"}
|
||||
{"schema_version":"1.1","event_type":"tool_call_end","timestamp":1767225606.45,"session_id":"sess-flow-01","agent_name":"Publisher","sequence_num":38,"messages_count":0,"tokens_used":0,"tokens_budget":0,"data":{"tool_name":"post_to_blog","result":"posted /digest/42","duration_ms":600.0,"error":null},"prompt_tokens":0,"completion_tokens":0,"cost_usd":0.0,"run_id":"flow-loop-01","parent_seq":37,"node_id":"tool:Publisher/post_to_blog"}
|
||||
{"schema_version":"1.1","event_type":"step_end","timestamp":1767225606.5,"session_id":"sess-flow-01","agent_name":"Publisher","sequence_num":39,"messages_count":0,"tokens_used":0,"tokens_budget":0,"data":{"status":"completed","duration_ms":700.0,"output_preview":"posted"},"prompt_tokens":0,"completion_tokens":0,"cost_usd":0.0,"run_id":"flow-loop-01","parent_seq":36,"node_id":"step:publish"}
|
||||
{"schema_version":"1.1","event_type":"workflow_end","timestamp":1767225606.55,"session_id":"sess-flow-01","agent_name":"","sequence_num":40,"messages_count":0,"tokens_used":0,"tokens_budget":0,"data":{"status":"completed","duration_ms":6530.0},"prompt_tokens":0,"completion_tokens":0,"cost_usd":0.0,"run_id":"flow-loop-01","parent_seq":1}
|
||||
{"schema_version":"1.1","event_type":"session_end","timestamp":1767225606.6,"session_id":"sess-flow-01","agent_name":"","sequence_num":41,"messages_count":0,"tokens_used":0,"tokens_budget":0,"data":{},"prompt_tokens":0,"completion_tokens":0,"cost_usd":0.0,"run_id":"flow-loop-01","parent_seq":0}
|
||||
+16
@@ -0,0 +1,16 @@
|
||||
{"schema_version":"1.0","event_type":"session_start","timestamp":1767225600.0,"session_id":"legacy-sess-10","agent_name":"","sequence_num":0,"messages_count":0,"tokens_used":0,"tokens_budget":0,"data":{},"prompt_tokens":0,"completion_tokens":0,"cost_usd":0.0}
|
||||
{"schema_version":"1.0","event_type":"agent_start","timestamp":1767225600.1,"session_id":"legacy-sess-10","agent_name":"Assistant","sequence_num":1,"messages_count":0,"tokens_used":0,"tokens_budget":0,"data":{"role":"Assistant"},"prompt_tokens":0,"completion_tokens":0,"cost_usd":0.0}
|
||||
{"schema_version":"1.0","event_type":"message_added","timestamp":1767225600.15,"session_id":"legacy-sess-10","agent_name":"Assistant","sequence_num":2,"messages_count":1,"tokens_used":0,"tokens_budget":0,"data":{"role":"user","content":"What is 17 * 23?"},"prompt_tokens":0,"completion_tokens":0,"cost_usd":0.0}
|
||||
{"schema_version":"1.0","event_type":"llm_request","timestamp":1767225600.2,"session_id":"legacy-sess-10","agent_name":"Assistant","sequence_num":3,"messages_count":3,"tokens_used":90,"tokens_budget":128000,"data":{"model":"gpt-4o-mini"},"prompt_tokens":0,"completion_tokens":0,"cost_usd":0.0,"parent_seq":null,"node_id":null}
|
||||
{"schema_version":"1.0","event_type":"llm_response","timestamp":1767225600.7,"session_id":"legacy-sess-10","agent_name":"Assistant","sequence_num":4,"messages_count":0,"tokens_used":0,"tokens_budget":0,"data":{"response_tokens":20,"duration_ms":500.0,"finish_reason":"tool_calls","model":"gpt-4o-mini"},"prompt_tokens":90,"completion_tokens":20,"cost_usd":2.6e-05,"parent_seq":null,"node_id":null}
|
||||
{"schema_version":"1.0","event_type":"tool_call_start","timestamp":1767225600.75,"session_id":"legacy-sess-10","agent_name":"Assistant","sequence_num":5,"messages_count":0,"tokens_used":0,"tokens_budget":0,"data":{"tool_name":"calculator","tool_args":{"expression":"17*23"}},"prompt_tokens":0,"completion_tokens":0,"cost_usd":0.0,"parent_seq":null,"node_id":null}
|
||||
{"schema_version":"1.0","event_type":"tool_call_end","timestamp":1767225600.76,"session_id":"legacy-sess-10","agent_name":"Assistant","sequence_num":6,"messages_count":0,"tokens_used":0,"tokens_budget":0,"data":{"tool_name":"calculator","result":"391","duration_ms":10.0,"error":null},"prompt_tokens":0,"completion_tokens":0,"cost_usd":0.0,"parent_seq":null,"node_id":null}
|
||||
{"schema_version":"1.0","event_type":"llm_request","timestamp":1767225600.8,"session_id":"legacy-sess-10","agent_name":"Assistant","sequence_num":7,"messages_count":3,"tokens_used":130,"tokens_budget":128000,"data":{"model":"gpt-4o-mini"},"prompt_tokens":0,"completion_tokens":0,"cost_usd":0.0,"parent_seq":null,"node_id":null}
|
||||
{"schema_version":"1.0","event_type":"llm_response","timestamp":1767225601.2,"session_id":"legacy-sess-10","agent_name":"Assistant","sequence_num":8,"messages_count":0,"tokens_used":0,"tokens_budget":0,"data":{"response_tokens":12,"duration_ms":400.0,"finish_reason":"stop","model":"gpt-4o-mini"},"prompt_tokens":130,"completion_tokens":12,"cost_usd":2.7e-05,"parent_seq":null,"node_id":null}
|
||||
{"schema_version":"1.0","event_type":"agent_handoff","timestamp":1767225601.25,"session_id":"legacy-sess-10","agent_name":"Assistant","sequence_num":9,"messages_count":0,"tokens_used":0,"tokens_budget":0,"data":{"from_agent":"Assistant","to_agent":"Checker","reason":"Double-check the arithmetic","context_passed":{"answer":391}},"prompt_tokens":0,"completion_tokens":0,"cost_usd":0.0}
|
||||
{"schema_version":"1.0","event_type":"agent_end","timestamp":1767225601.26,"session_id":"legacy-sess-10","agent_name":"Assistant","sequence_num":10,"messages_count":0,"tokens_used":0,"tokens_budget":0,"data":{},"prompt_tokens":0,"completion_tokens":0,"cost_usd":0.0}
|
||||
{"schema_version":"1.0","event_type":"agent_start","timestamp":1767225601.3,"session_id":"legacy-sess-10","agent_name":"Checker","sequence_num":11,"messages_count":0,"tokens_used":0,"tokens_budget":0,"data":{"role":"Checker"},"prompt_tokens":0,"completion_tokens":0,"cost_usd":0.0}
|
||||
{"schema_version":"1.0","event_type":"llm_request","timestamp":1767225601.35,"session_id":"legacy-sess-10","agent_name":"Checker","sequence_num":12,"messages_count":3,"tokens_used":60,"tokens_budget":128000,"data":{"model":"gpt-4o-mini"},"prompt_tokens":0,"completion_tokens":0,"cost_usd":0.0,"parent_seq":null,"node_id":null}
|
||||
{"schema_version":"1.0","event_type":"llm_response","timestamp":1767225601.8,"session_id":"legacy-sess-10","agent_name":"Checker","sequence_num":13,"messages_count":0,"tokens_used":0,"tokens_budget":0,"data":{"response_tokens":8,"duration_ms":450.0,"finish_reason":"stop","model":"gpt-4o-mini"},"prompt_tokens":60,"completion_tokens":8,"cost_usd":1.4e-05,"parent_seq":null,"node_id":null}
|
||||
{"schema_version":"1.0","event_type":"agent_end","timestamp":1767225601.85,"session_id":"legacy-sess-10","agent_name":"Checker","sequence_num":14,"messages_count":0,"tokens_used":0,"tokens_budget":0,"data":{},"prompt_tokens":0,"completion_tokens":0,"cost_usd":0.0}
|
||||
{"schema_version":"1.0","event_type":"session_end","timestamp":1767225601.9,"session_id":"legacy-sess-10","agent_name":"","sequence_num":15,"messages_count":0,"tokens_used":0,"tokens_budget":0,"data":{},"prompt_tokens":0,"completion_tokens":0,"cost_usd":0.0}
|
||||
+388
@@ -0,0 +1,388 @@
|
||||
"""Regenerate the PraisonAI Replay JSONL fixtures (contract v1, schema 1.1 + 1.0).
|
||||
|
||||
Run from the repo root::
|
||||
|
||||
python tests/fixtures/replay/make_fixtures.py
|
||||
|
||||
The output is deterministic (fixed timestamps, no uuids) so the files are
|
||||
committed and the tests compare against exact values.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from pathlib import Path
|
||||
|
||||
HERE = Path(__file__).resolve().parent
|
||||
T0 = 1767225600.0 # 2026-01-01T00:00:00Z
|
||||
|
||||
|
||||
class _Run:
|
||||
"""Tiny emitter that mimics ContextTraceEmitter's to_dict() output."""
|
||||
|
||||
def __init__(self, session_id: str, run_id: str | None, schema: str = "1.1") -> None:
|
||||
self.session_id = session_id
|
||||
self.run_id = run_id
|
||||
self.schema = schema
|
||||
self.seq = 0
|
||||
self.events: list[dict] = []
|
||||
|
||||
def emit(self, event_type: str, t: float, agent: str = "", data: dict | None = None, **kw) -> int:
|
||||
ev = {
|
||||
"schema_version": self.schema,
|
||||
"event_type": event_type,
|
||||
"timestamp": round(T0 + t, 3),
|
||||
"session_id": self.session_id,
|
||||
"agent_name": agent,
|
||||
"sequence_num": self.seq,
|
||||
"messages_count": kw.pop("messages_count", 0),
|
||||
"tokens_used": kw.pop("tokens_used", 0),
|
||||
"tokens_budget": kw.pop("tokens_budget", 0),
|
||||
"data": data or {},
|
||||
"prompt_tokens": kw.pop("prompt_tokens", 0),
|
||||
"completion_tokens": kw.pop("completion_tokens", 0),
|
||||
"cost_usd": kw.pop("cost_usd", 0.0),
|
||||
}
|
||||
for key in ("branch_id", "content_hash"):
|
||||
if kw.get(key) is not None:
|
||||
ev[key] = kw.pop(key)
|
||||
else:
|
||||
kw.pop(key, None)
|
||||
if self.schema != "1.0":
|
||||
if self.run_id:
|
||||
ev["run_id"] = self.run_id
|
||||
for key in ("parent_seq", "node_id"):
|
||||
if kw.get(key) is not None:
|
||||
ev[key] = kw.pop(key)
|
||||
else:
|
||||
kw.pop(key, None)
|
||||
ev.update(kw) # unknown/extra fields pass straight through
|
||||
self.events.append(ev)
|
||||
self.seq += 1
|
||||
return ev["sequence_num"]
|
||||
|
||||
def llm(self, t: float, agent: str, dur: float, parent: int | None, *, prompt: int, completion: int,
|
||||
cost: float, model: str = "gpt-4o-mini", finish: str = "stop", content: str = "",
|
||||
branch: str | None = None, node: str | None = None) -> None:
|
||||
self.emit("llm_request", t, agent, {"model": model}, parent_seq=parent, messages_count=3,
|
||||
tokens_used=prompt, tokens_budget=128000, branch_id=branch, node_id=node)
|
||||
data = {"response_tokens": completion, "duration_ms": round(dur * 1000, 1),
|
||||
"finish_reason": finish, "model": model}
|
||||
if content:
|
||||
data["response_content"] = content
|
||||
self.emit("llm_response", t + dur, agent, data, parent_seq=parent, prompt_tokens=prompt,
|
||||
completion_tokens=completion, cost_usd=cost, branch_id=branch, node_id=node)
|
||||
|
||||
def tool(self, t: float, agent: str, name: str, args: dict, dur: float, parent: int | None, *,
|
||||
result: str, cost: float = 0.0, error: str | None = None, branch: str | None = None) -> None:
|
||||
node = f"tool:{agent}/{name}" if self.schema != "1.0" else None
|
||||
s = self.emit("tool_call_start", t, agent, {"tool_name": name, "tool_args": args},
|
||||
parent_seq=parent, node_id=node, branch_id=branch)
|
||||
self.emit("tool_call_end", t + dur, agent,
|
||||
{"tool_name": name, "result": result, "duration_ms": round(dur * 1000, 1), "error": error},
|
||||
parent_seq=s if self.schema != "1.0" else None, node_id=node, cost_usd=cost, branch_id=branch)
|
||||
|
||||
def write(self, name: str, *, partial_tail: str | None = None) -> None:
|
||||
path = HERE / name
|
||||
with open(path, "w", encoding="utf-8", newline="\n") as fh:
|
||||
for ev in self.events:
|
||||
fh.write(json.dumps(ev, separators=(",", ":")) + "\n")
|
||||
if partial_tail:
|
||||
fh.write(partial_tail)
|
||||
print(f"wrote {path.name}: {len(self.events)} events")
|
||||
|
||||
|
||||
def team_handoff() -> None:
|
||||
"""Hierarchical 2-agent team: Manager delegates, Researcher hands off to Writer.
|
||||
|
||||
Researcher calls web_search three times in a row (collapses to x3); Writer
|
||||
asks for approval to run write_file, the human approves with edited args.
|
||||
"""
|
||||
r = _Run("sess-team-01", "team-handoff-01")
|
||||
graph = {
|
||||
"schema": "praisonai.graph/1", "kind": "team", "name": "Solar sail brief", "process": "hierarchical",
|
||||
"nodes": [
|
||||
{"id": "start:begin", "kind": "start", "label": "Brief", "parent": None, "meta": {}},
|
||||
{"id": "manager:Manager", "kind": "manager", "label": "Manager", "parent": None, "meta": {}},
|
||||
{"id": "task:research", "kind": "task", "label": "research", "parent": None, "meta": {}},
|
||||
{"id": "task:write", "kind": "task", "label": "write", "parent": None, "meta": {}},
|
||||
{"id": "agent:Researcher", "kind": "agent", "label": "Researcher", "parent": None, "meta": {}},
|
||||
{"id": "agent:Writer", "kind": "agent", "label": "Writer", "parent": None, "meta": {}},
|
||||
{"id": "tool:Researcher/web_search", "kind": "tool", "label": "web_search", "parent": None, "meta": {}},
|
||||
{"id": "tool:Writer/write_file", "kind": "tool", "label": "write_file", "parent": None, "meta": {}},
|
||||
{"id": "human:you", "kind": "human", "label": "You", "parent": None, "meta": {}},
|
||||
{"id": "end:done", "kind": "end", "label": "Delivered", "parent": None, "meta": {}},
|
||||
],
|
||||
"edges": [
|
||||
{"id": "start:begin->manager:Manager:next", "source": "start:begin", "target": "manager:Manager", "kind": "next", "label": None},
|
||||
{"id": "manager:Manager->task:research:delegates", "source": "manager:Manager", "target": "task:research", "kind": "delegates", "label": None},
|
||||
{"id": "manager:Manager->task:write:delegates", "source": "manager:Manager", "target": "task:write", "kind": "delegates", "label": None},
|
||||
{"id": "task:write->task:research:depends_on", "source": "task:write", "target": "task:research", "kind": "depends_on", "label": None},
|
||||
{"id": "task:research->agent:Researcher:assigned", "source": "task:research", "target": "agent:Researcher", "kind": "assigned", "label": None},
|
||||
{"id": "task:write->agent:Writer:assigned", "source": "task:write", "target": "agent:Writer", "kind": "assigned", "label": None},
|
||||
{"id": "agent:Researcher->agent:Writer:handoff", "source": "agent:Researcher", "target": "agent:Writer", "kind": "handoff", "label": None},
|
||||
{"id": "agent:Researcher->tool:Researcher/web_search:uses", "source": "agent:Researcher", "target": "tool:Researcher/web_search", "kind": "uses", "label": None},
|
||||
{"id": "agent:Writer->tool:Writer/write_file:uses", "source": "agent:Writer", "target": "tool:Writer/write_file", "kind": "uses", "label": None},
|
||||
{"id": "human:you->tool:Writer/write_file:reviews", "source": "human:you", "target": "tool:Writer/write_file", "kind": "reviews", "label": None},
|
||||
{"id": "agent:Writer->end:done:next", "source": "agent:Writer", "target": "end:done", "kind": "next", "label": None},
|
||||
],
|
||||
}
|
||||
s0 = r.emit("session_start", 0.0, "", {"source": "record"})
|
||||
w = r.emit("workflow_start", 0.05, "", {"kind": "team", "name": "Solar sail brief", "process": "hierarchical",
|
||||
"graph": graph}, parent_seq=s0)
|
||||
r.emit("message_added", 0.1, "", {"role": "user", "content": "Write a short brief on solar sails."}, parent_seq=w)
|
||||
r.llm(0.2, "Manager", 0.6, w, prompt=210, completion=48, cost=0.000060, node="manager:Manager",
|
||||
content="Delegate research first, then writing.")
|
||||
r.emit("agent_handoff", 0.85, "Manager", {
|
||||
"from_agent": "Manager", "to_agent": "Researcher", "kind": "delegate",
|
||||
"reason": "Research has to come before writing.",
|
||||
"context_passed": {"task_snapshot": {"task": "research", "description": "Find three facts about solar sails",
|
||||
"expected_output": "3 bullet facts", "status": "not started"}},
|
||||
}, parent_seq=w, node_id="manager:Manager")
|
||||
t1 = r.emit("task_start", 0.9, "Researcher", {"name": "research", "agent": "Researcher",
|
||||
"description_preview": "Find three facts about solar sails"},
|
||||
parent_seq=w, node_id="task:research")
|
||||
a1 = r.emit("agent_start", 0.95, "Researcher", {"role": "Researcher", "goal": "Find facts"},
|
||||
parent_seq=t1, node_id="agent:Researcher")
|
||||
r.emit("message_added", 1.0, "Researcher", {"role": "user", "content": "Find three facts about solar sails"},
|
||||
parent_seq=a1)
|
||||
r.llm(1.05, "Researcher", 0.9, a1, prompt=320, completion=40, cost=0.000072, finish="tool_calls",
|
||||
node="agent:Researcher")
|
||||
r.tool(2.0, "Researcher", "web_search", {"query": "solar sail facts"}, 1.2, a1,
|
||||
result="IKAROS (2010) was the first interplanetary solar sail.", cost=0.002)
|
||||
r.tool(3.25, "Researcher", "web_search", {"query": "solar sail missions"}, 1.1, a1,
|
||||
result="LightSail 2 raised its orbit using sunlight in 2019.", cost=0.002)
|
||||
r.tool(4.4, "Researcher", "web_search", {"query": "solar sail physics"}, 0.9, a1,
|
||||
result="Photon pressure at 1 AU is about 9 micronewtons per square metre.", cost=0.002)
|
||||
r.llm(5.35, "Researcher", 1.4, a1, prompt=880, completion=160, cost=0.000228, node="agent:Researcher",
|
||||
content="1. IKAROS ... 2. LightSail 2 ... 3. ~9 uN/m^2")
|
||||
r.emit("agent_end", 6.8, "Researcher", {"status": "completed"}, parent_seq=a1, node_id="agent:Researcher")
|
||||
r.emit("task_end", 6.85, "Researcher", {"status": "completed", "retries": 0, "duration_ms": 5950.0,
|
||||
"output_preview": "1. IKAROS ... 2. LightSail 2 ... 3. ~9 uN/m^2"},
|
||||
parent_seq=t1, node_id="task:research")
|
||||
r.emit("agent_handoff", 6.9, "Researcher", {
|
||||
"from_agent": "Researcher", "to_agent": "Writer", "kind": "handoff",
|
||||
"reason": "Facts gathered; needs drafting.",
|
||||
"context_passed": {"facts": ["IKAROS 2010", "LightSail 2 2019", "~9 uN/m^2 at 1 AU"]},
|
||||
}, parent_seq=w, node_id="agent:Researcher")
|
||||
t2 = r.emit("task_start", 6.95, "Writer", {"name": "write", "agent": "Writer",
|
||||
"description_preview": "Draft a 100-word brief into draft.md"},
|
||||
parent_seq=w, node_id="task:write")
|
||||
a2 = r.emit("agent_start", 7.0, "Writer", {"role": "Writer"}, parent_seq=t2, node_id="agent:Writer")
|
||||
r.llm(7.05, "Writer", 1.6, a2, prompt=640, completion=220, cost=0.000228, finish="tool_calls",
|
||||
node="agent:Writer")
|
||||
ap = r.emit("approval_requested", 8.7, "Writer", {
|
||||
"approval_id": "appr-0001", "tool": "write_file",
|
||||
"args": {"path": "draft.md", "content": "[REDACTED]"}, "risk": "high", "kind": "tool_call",
|
||||
}, parent_seq=a2, node_id="tool:Writer/write_file")
|
||||
r.emit("approval_decided", 11.2, "", {
|
||||
"approval_id": "appr-0001", "approved": True, "reason": "Use the docs folder.",
|
||||
"approver": "user", "modified": True,
|
||||
}, parent_seq=ap, node_id="tool:Writer/write_file")
|
||||
r.tool(11.25, "Writer", "write_file", {"path": "docs/draft.md", "content": "[REDACTED]"}, 0.05, a2,
|
||||
result="Wrote 642 bytes to docs/draft.md")
|
||||
r.llm(11.35, "Writer", 0.7, a2, prompt=900, completion=30, cost=0.000153, node="agent:Writer",
|
||||
content="Saved the brief to docs/draft.md.")
|
||||
r.emit("agent_end", 12.1, "Writer", {"status": "completed"}, parent_seq=a2, node_id="agent:Writer")
|
||||
r.emit("task_end", 12.15, "Writer", {"status": "completed", "retries": 0, "duration_ms": 5200.0,
|
||||
"output_preview": "Saved the brief to docs/draft.md."},
|
||||
parent_seq=t2, node_id="task:write")
|
||||
r.emit("workflow_end", 12.2, "", {"status": "completed", "duration_ms": 12150.0}, parent_seq=w)
|
||||
r.emit("session_end", 12.25, "", {}, parent_seq=s0)
|
||||
r.write("team_handoff.jsonl")
|
||||
|
||||
|
||||
def flow_parallel_loop() -> None:
|
||||
"""AgentFlow: plan -> parallel(gather news, papers) -> loop(polish x3) -> route(decide) -> publish.
|
||||
|
||||
Both parallel branches run the same agent (Fetcher) so they must appear as
|
||||
two sub-lanes. Includes an unknown event type and an unknown field to prove
|
||||
readers tolerate them.
|
||||
"""
|
||||
r = _Run("sess-flow-01", "flow-loop-01")
|
||||
graph = {
|
||||
"schema": "praisonai.graph/1", "kind": "flow", "name": "Weekly digest", "process": "flow",
|
||||
"nodes": [
|
||||
{"id": "start:begin", "kind": "start", "label": "Brief", "parent": None, "meta": {}},
|
||||
{"id": "step:plan", "kind": "step", "label": "plan", "parent": None, "meta": {"agent": "Planner"}},
|
||||
{"id": "step:gather", "kind": "fork", "label": "gather", "parent": None, "meta": {"pattern": "parallel"}},
|
||||
{"id": "step:news", "kind": "step", "label": "news", "parent": "step:gather", "meta": {"agent": "Fetcher"}},
|
||||
{"id": "step:papers", "kind": "step", "label": "papers", "parent": "step:gather", "meta": {"agent": "Fetcher"}},
|
||||
{"id": "step:merge", "kind": "join", "label": "merge", "parent": None, "meta": {}},
|
||||
{"id": "step:review_loop", "kind": "step", "label": "review_loop", "parent": None, "meta": {"pattern": "loop"}},
|
||||
{"id": "step:polish", "kind": "step", "label": "polish", "parent": "step:review_loop", "meta": {"agent": "Editor"}},
|
||||
{"id": "step:decide", "kind": "decision", "label": "decide", "parent": None, "meta": {"pattern": "route"}},
|
||||
{"id": "step:publish", "kind": "step", "label": "publish", "parent": None, "meta": {"agent": "Publisher"}},
|
||||
{"id": "step:archive", "kind": "step", "label": "archive", "parent": None, "meta": {}},
|
||||
{"id": "end:done", "kind": "end", "label": "Delivered", "parent": None, "meta": {}},
|
||||
],
|
||||
"edges": [
|
||||
{"id": "start:begin->step:plan:next", "source": "start:begin", "target": "step:plan", "kind": "next", "label": None},
|
||||
{"id": "step:plan->step:gather:next", "source": "step:plan", "target": "step:gather", "kind": "next", "label": None},
|
||||
{"id": "step:gather->step:news:parallel", "source": "step:gather", "target": "step:news", "kind": "parallel", "label": None},
|
||||
{"id": "step:gather->step:papers:parallel", "source": "step:gather", "target": "step:papers", "kind": "parallel", "label": None},
|
||||
{"id": "step:news->step:merge:next", "source": "step:news", "target": "step:merge", "kind": "next", "label": None},
|
||||
{"id": "step:papers->step:merge:next", "source": "step:papers", "target": "step:merge", "kind": "next", "label": None},
|
||||
{"id": "step:merge->step:review_loop:next", "source": "step:merge", "target": "step:review_loop", "kind": "next", "label": None},
|
||||
{"id": "step:review_loop->step:polish:loop", "source": "step:review_loop", "target": "step:polish", "kind": "loop", "label": "until approved"},
|
||||
{"id": "step:polish->step:review_loop:loop", "source": "step:polish", "target": "step:review_loop", "kind": "loop", "label": None},
|
||||
{"id": "step:review_loop->step:decide:next", "source": "step:review_loop", "target": "step:decide", "kind": "next", "label": None},
|
||||
{"id": "step:decide->step:publish:routes", "source": "step:decide", "target": "step:publish", "kind": "routes", "label": "publish"},
|
||||
{"id": "step:decide->step:archive:routes", "source": "step:decide", "target": "step:archive", "kind": "routes", "label": "archive"},
|
||||
{"id": "step:publish->end:done:next", "source": "step:publish", "target": "end:done", "kind": "next", "label": None},
|
||||
{"id": "step:archive->end:done:next", "source": "step:archive", "target": "end:done", "kind": "next", "label": None},
|
||||
],
|
||||
}
|
||||
s0 = r.emit("session_start", 0.0, "", {})
|
||||
w = r.emit("workflow_start", 0.02, "", {"kind": "flow", "name": "Weekly digest", "process": "flow",
|
||||
"graph": graph}, parent_seq=s0)
|
||||
sp = r.emit("step_start", 0.05, "Planner", {"name": "plan"}, parent_seq=w, node_id="step:plan")
|
||||
a = r.emit("agent_start", 0.06, "Planner", {}, parent_seq=sp, node_id="agent:Planner")
|
||||
r.llm(0.1, "Planner", 0.8, a, prompt=150, completion=60, cost=0.000059, node="agent:Planner")
|
||||
r.emit("agent_end", 0.95, "Planner", {}, parent_seq=a, node_id="agent:Planner")
|
||||
r.emit("step_end", 1.0, "Planner", {"status": "completed", "duration_ms": 950.0,
|
||||
"output_preview": "Gather news and papers."}, parent_seq=sp, node_id="step:plan")
|
||||
g = r.emit("step_start", 1.05, "", {"name": "gather", "pattern": "parallel"}, parent_seq=w, node_id="step:gather")
|
||||
# two branches interleaved in time, same agent
|
||||
b0 = r.emit("step_start", 1.1, "Fetcher", {"name": "news"}, parent_seq=g, node_id="step:news", branch_id="gather/0")
|
||||
b1 = r.emit("step_start", 1.1, "Fetcher", {"name": "papers"}, parent_seq=g, node_id="step:papers", branch_id="gather/1")
|
||||
r.emit("tool_call_start", 1.15, "Fetcher", {"tool_name": "fetch_rss", "tool_args": {"url": "https://example.org/news.rss"}},
|
||||
parent_seq=b0, node_id="tool:Fetcher/fetch_rss", branch_id="gather/0")
|
||||
r.emit("tool_call_start", 1.16, "Fetcher", {"tool_name": "search_papers", "tool_args": {"query": "agent tracing"}},
|
||||
parent_seq=b1, node_id="tool:Fetcher/search_papers", branch_id="gather/1")
|
||||
r.emit("tool_call_end", 2.4, "Fetcher", {"tool_name": "fetch_rss", "result": "12 items", "duration_ms": 1250.0, "error": None},
|
||||
parent_seq=r.seq - 2, node_id="tool:Fetcher/fetch_rss", branch_id="gather/0")
|
||||
r.emit("tool_call_end", 3.0, "Fetcher", {"tool_name": "search_papers", "result": "5 papers", "duration_ms": 1840.0, "error": None},
|
||||
parent_seq=r.seq - 2, node_id="tool:Fetcher/search_papers", branch_id="gather/1", cost_usd=0.001)
|
||||
r.emit("step_end", 2.45, "Fetcher", {"status": "completed", "duration_ms": 1350.0, "output_preview": "12 items"},
|
||||
parent_seq=b0, node_id="step:news", branch_id="gather/0")
|
||||
r.emit("step_end", 3.05, "Fetcher", {"status": "completed", "duration_ms": 1950.0, "output_preview": "5 papers"},
|
||||
parent_seq=b1, node_id="step:papers", branch_id="gather/1")
|
||||
r.emit("step_end", 3.1, "", {"status": "completed", "duration_ms": 2050.0}, parent_seq=g, node_id="step:gather")
|
||||
# an event type this reader has never heard of, plus an unknown field
|
||||
r.emit("context_rebalance", 3.12, "", {"note": "future event type"}, parent_seq=w, future_field={"x": 1})
|
||||
lp = r.emit("step_start", 3.15, "", {"name": "review_loop", "pattern": "loop"}, parent_seq=w, node_id="step:review_loop")
|
||||
t = 3.2
|
||||
for i in range(3):
|
||||
ps = r.emit("step_start", t, "Editor", {"name": "polish", "iteration": i + 1}, parent_seq=lp, node_id="step:polish")
|
||||
r.llm(t + 0.02, "Editor", 0.7, ps, prompt=400 + 50 * i, completion=90, cost=0.000114 + 0.0000075 * i,
|
||||
node="agent:Editor")
|
||||
r.emit("step_end", t + 0.75, "Editor", {"status": "completed", "duration_ms": 750.0,
|
||||
"output_preview": f"Draft v{i + 2}"}, parent_seq=ps, node_id="step:polish")
|
||||
t += 0.8
|
||||
r.emit("step_end", t, "", {"status": "completed", "duration_ms": round((t - 3.15) * 1000, 1)},
|
||||
parent_seq=lp, node_id="step:review_loop")
|
||||
d = r.emit("step_start", t + 0.05, "", {"name": "decide", "pattern": "route"}, parent_seq=w, node_id="step:decide")
|
||||
r.emit("agent_handoff", t + 0.1, "Editor", {"from_agent": "Editor", "to_agent": "Publisher", "kind": "route",
|
||||
"reason": "Draft approved by reviewer score 0.92",
|
||||
"context_passed": {"score": 0.92}}, parent_seq=d, node_id="step:decide")
|
||||
r.emit("step_end", t + 0.15, "", {"status": "completed", "duration_ms": 100.0, "route": "publish"},
|
||||
parent_seq=d, node_id="step:decide")
|
||||
pb = r.emit("step_start", t + 0.2, "Publisher", {"name": "publish"}, parent_seq=w, node_id="step:publish")
|
||||
r.tool(t + 0.25, "Publisher", "post_to_blog", {"title": "Weekly digest"}, 0.6, pb, result="posted /digest/42")
|
||||
r.emit("step_end", t + 0.9, "Publisher", {"status": "completed", "duration_ms": 700.0, "output_preview": "posted"},
|
||||
parent_seq=pb, node_id="step:publish")
|
||||
r.emit("workflow_end", t + 0.95, "", {"status": "completed", "duration_ms": round((t + 0.93) * 1000, 1)}, parent_seq=w)
|
||||
r.emit("session_end", t + 1.0, "", {}, parent_seq=s0)
|
||||
r.write("flow_parallel_loop.jsonl")
|
||||
|
||||
|
||||
def legacy_v10() -> None:
|
||||
"""A schema 1.0 file: no run_id, parent_seq, node_id; no workflow events."""
|
||||
r = _Run("legacy-sess-10", None, schema="1.0")
|
||||
r.emit("session_start", 0.0, "", {})
|
||||
r.emit("agent_start", 0.1, "Assistant", {"role": "Assistant"})
|
||||
r.emit("message_added", 0.15, "Assistant", {"role": "user", "content": "What is 17 * 23?"}, messages_count=1)
|
||||
r.llm(0.2, "Assistant", 0.5, None, prompt=90, completion=20, cost=0.000026, finish="tool_calls")
|
||||
r.tool(0.75, "Assistant", "calculator", {"expression": "17*23"}, 0.01, None, result="391")
|
||||
r.llm(0.8, "Assistant", 0.4, None, prompt=130, completion=12, cost=0.000027)
|
||||
r.emit("agent_handoff", 1.25, "Assistant", {"from_agent": "Assistant", "to_agent": "Checker",
|
||||
"reason": "Double-check the arithmetic", "context_passed": {"answer": 391}})
|
||||
r.emit("agent_end", 1.26, "Assistant", {})
|
||||
r.emit("agent_start", 1.3, "Checker", {"role": "Checker"})
|
||||
r.llm(1.35, "Checker", 0.45, None, prompt=60, completion=8, cost=0.000014)
|
||||
r.emit("agent_end", 1.85, "Checker", {})
|
||||
r.emit("session_end", 1.9, "", {})
|
||||
r.write("legacy_v10.jsonl")
|
||||
|
||||
|
||||
def team_outside() -> None:
|
||||
"""Shape of a real recorded run: each agent's LLM span encloses its tool
|
||||
calls (llm_request -> tool -> llm_response), and after the team finishes a
|
||||
Triage agent hands off to Billing, neither of which is in the team graph.
|
||||
"""
|
||||
r = _Run("sess-outside-01", "team-outside-01")
|
||||
graph = {
|
||||
"schema": "praisonai.graph/1", "kind": "team", "name": "EV brief", "process": "sequential",
|
||||
"nodes": [
|
||||
{"id": "start:begin", "kind": "start", "label": "Brief", "parent": None, "meta": {}},
|
||||
{"id": "task:research", "kind": "task", "label": "research", "parent": None, "meta": {}},
|
||||
{"id": "task:write", "kind": "task", "label": "write", "parent": None, "meta": {}},
|
||||
{"id": "agent:Researcher", "kind": "agent", "label": "Researcher", "parent": None, "meta": {}},
|
||||
{"id": "agent:Writer", "kind": "agent", "label": "Writer", "parent": None, "meta": {}},
|
||||
{"id": "tool:Researcher/lookup_fact", "kind": "tool", "label": "lookup_fact", "parent": None, "meta": {}},
|
||||
{"id": "end:done", "kind": "end", "label": "Delivered", "parent": None, "meta": {}},
|
||||
],
|
||||
"edges": [
|
||||
{"id": "start:begin->task:research:next", "source": "start:begin", "target": "task:research", "kind": "next", "label": None},
|
||||
{"id": "task:research->task:write:next", "source": "task:research", "target": "task:write", "kind": "next", "label": None},
|
||||
{"id": "task:research->agent:Researcher:assigned", "source": "task:research", "target": "agent:Researcher", "kind": "assigned", "label": None},
|
||||
{"id": "task:write->agent:Writer:assigned", "source": "task:write", "target": "agent:Writer", "kind": "assigned", "label": None},
|
||||
{"id": "agent:Researcher->tool:Researcher/lookup_fact:uses", "source": "agent:Researcher", "target": "tool:Researcher/lookup_fact", "kind": "uses", "label": None},
|
||||
{"id": "task:write->end:done:next", "source": "task:write", "target": "end:done", "kind": "next", "label": None},
|
||||
],
|
||||
}
|
||||
s0 = r.emit("session_start", 0.0, "", {})
|
||||
w = r.emit("workflow_start", 0.01, "", {"kind": "team", "name": "EV brief", "process": "sequential", "graph": graph}, parent_seq=s0)
|
||||
t1 = r.emit("task_start", 0.02, "Researcher", {"name": "research", "agent": "Researcher",
|
||||
"description_preview": "Get one fact about electric vehicles"},
|
||||
parent_seq=w, node_id="task:research")
|
||||
a1 = r.emit("agent_start", 0.03, "Researcher", {}, parent_seq=t1, node_id="agent:Researcher")
|
||||
r.emit("llm_request", 0.05, "Researcher", {"model": "gpt-4o-mini"}, parent_seq=a1, node_id="agent:Researcher")
|
||||
ts = r.emit("tool_call_start", 2.6, "Researcher", {"tool_name": "lookup_fact", "tool_args": {"topic": "electric vehicles"}},
|
||||
parent_seq=a1, node_id="tool:Researcher/lookup_fact")
|
||||
r.emit("tool_call_end", 3.5, "Researcher", {"tool_name": "lookup_fact", "result": "EV sales passed 17 million in 2024",
|
||||
"duration_ms": 880.0, "error": None},
|
||||
parent_seq=ts, node_id="tool:Researcher/lookup_fact")
|
||||
r.emit("llm_response", 5.0, "Researcher", {"response_tokens": 14, "duration_ms": 4950.0, "model": "gpt-4o-mini",
|
||||
"response_content": "EV sales passed 17 million in 2024."},
|
||||
parent_seq=a1, node_id="agent:Researcher", prompt_tokens=310, completion_tokens=14, cost_usd=0.000055)
|
||||
r.emit("agent_end", 5.01, "Researcher", {}, parent_seq=a1, node_id="agent:Researcher")
|
||||
r.emit("task_end", 5.02, "Researcher", {"status": "completed", "retries": 0, "duration_ms": 5000.0,
|
||||
"output_preview": "EV sales passed 17 million in 2024."}, parent_seq=t1, node_id="task:research")
|
||||
t2 = r.emit("task_start", 5.03, "Writer", {"name": "write", "agent": "Writer",
|
||||
"description_preview": "Write one sentence using the fact"},
|
||||
parent_seq=w, node_id="task:write")
|
||||
a2 = r.emit("agent_start", 5.04, "Writer", {}, parent_seq=t2, node_id="agent:Writer")
|
||||
r.llm(5.05, "Writer", 1.05, a2, prompt=200, completion=25, cost=0.000045, node="agent:Writer",
|
||||
content="Global EV sales passed 17 million in 2024.")
|
||||
r.emit("agent_end", 6.11, "Writer", {}, parent_seq=a2, node_id="agent:Writer")
|
||||
r.emit("task_end", 6.12, "Writer", {"status": "completed", "retries": 0, "duration_ms": 1090.0,
|
||||
"output_preview": "Global EV sales passed 17 million in 2024."}, parent_seq=t2, node_id="task:write")
|
||||
r.emit("workflow_end", 6.13, "", {"status": "completed", "duration_ms": 6120.0}, parent_seq=w)
|
||||
a3 = r.emit("agent_start", 6.2, "Triage", {}, parent_seq=s0, node_id="agent:Triage")
|
||||
r.emit("llm_request", 6.25, "Triage", {"model": "gpt-4o-mini"}, parent_seq=a3, node_id="agent:Triage")
|
||||
tt = r.emit("tool_call_start", 7.7, "Triage", {"tool_name": "transfer_to_billing", "tool_args": {}},
|
||||
parent_seq=a3, node_id="tool:Triage/transfer_to_billing")
|
||||
r.emit("agent_handoff", 7.71, "Triage", {"from_agent": "Triage", "to_agent": "Billing", "kind": "handoff",
|
||||
"reason": "Billing question", "context_passed": {"context_policy": "summary"}},
|
||||
parent_seq=tt, node_id="agent:Billing")
|
||||
a4 = r.emit("agent_start", 7.72, "Billing", {}, parent_seq=tt, node_id="agent:Billing")
|
||||
r.llm(7.75, "Billing", 0.92, a4, prompt=150, completion=19, cost=0.00003, node="agent:Billing",
|
||||
content="Please check your statement for a duplicate charge.")
|
||||
r.emit("agent_end", 8.68, "Billing", {}, parent_seq=a4, node_id="agent:Billing")
|
||||
r.emit("tool_call_end", 8.7, "Triage", {"tool_name": "transfer_to_billing", "result": "Handoff successful",
|
||||
"duration_ms": 1000.0, "error": None},
|
||||
parent_seq=tt, node_id="tool:Triage/transfer_to_billing")
|
||||
r.emit("llm_response", 9.6, "Triage", {"response_tokens": 22, "duration_ms": 3350.0, "model": "gpt-4o-mini"},
|
||||
parent_seq=a3, node_id="agent:Triage", prompt_tokens=240, completion_tokens=22, cost_usd=0.00005)
|
||||
r.emit("agent_end", 9.61, "Triage", {}, parent_seq=a3, node_id="agent:Triage")
|
||||
r.emit("session_end", 9.7, "", {}, parent_seq=s0)
|
||||
r.write("team_outside.jsonl")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
team_handoff()
|
||||
flow_parallel_loop()
|
||||
legacy_v10()
|
||||
team_outside()
|
||||
+36
@@ -0,0 +1,36 @@
|
||||
{"schema_version":"1.1","event_type":"session_start","timestamp":1767225600.0,"session_id":"sess-team-01","agent_name":"","sequence_num":0,"messages_count":0,"tokens_used":0,"tokens_budget":0,"data":{"source":"record"},"prompt_tokens":0,"completion_tokens":0,"cost_usd":0.0,"run_id":"team-handoff-01"}
|
||||
{"schema_version":"1.1","event_type":"workflow_start","timestamp":1767225600.05,"session_id":"sess-team-01","agent_name":"","sequence_num":1,"messages_count":0,"tokens_used":0,"tokens_budget":0,"data":{"kind":"team","name":"Solar sail brief","process":"hierarchical","graph":{"schema":"praisonai.graph/1","kind":"team","name":"Solar sail brief","process":"hierarchical","nodes":[{"id":"start:begin","kind":"start","label":"Brief","parent":null,"meta":{}},{"id":"manager:Manager","kind":"manager","label":"Manager","parent":null,"meta":{}},{"id":"task:research","kind":"task","label":"research","parent":null,"meta":{}},{"id":"task:write","kind":"task","label":"write","parent":null,"meta":{}},{"id":"agent:Researcher","kind":"agent","label":"Researcher","parent":null,"meta":{}},{"id":"agent:Writer","kind":"agent","label":"Writer","parent":null,"meta":{}},{"id":"tool:Researcher/web_search","kind":"tool","label":"web_search","parent":null,"meta":{}},{"id":"tool:Writer/write_file","kind":"tool","label":"write_file","parent":null,"meta":{}},{"id":"human:you","kind":"human","label":"You","parent":null,"meta":{}},{"id":"end:done","kind":"end","label":"Delivered","parent":null,"meta":{}}],"edges":[{"id":"start:begin->manager:Manager:next","source":"start:begin","target":"manager:Manager","kind":"next","label":null},{"id":"manager:Manager->task:research:delegates","source":"manager:Manager","target":"task:research","kind":"delegates","label":null},{"id":"manager:Manager->task:write:delegates","source":"manager:Manager","target":"task:write","kind":"delegates","label":null},{"id":"task:write->task:research:depends_on","source":"task:write","target":"task:research","kind":"depends_on","label":null},{"id":"task:research->agent:Researcher:assigned","source":"task:research","target":"agent:Researcher","kind":"assigned","label":null},{"id":"task:write->agent:Writer:assigned","source":"task:write","target":"agent:Writer","kind":"assigned","label":null},{"id":"agent:Researcher->agent:Writer:handoff","source":"agent:Researcher","target":"agent:Writer","kind":"handoff","label":null},{"id":"agent:Researcher->tool:Researcher/web_search:uses","source":"agent:Researcher","target":"tool:Researcher/web_search","kind":"uses","label":null},{"id":"agent:Writer->tool:Writer/write_file:uses","source":"agent:Writer","target":"tool:Writer/write_file","kind":"uses","label":null},{"id":"human:you->tool:Writer/write_file:reviews","source":"human:you","target":"tool:Writer/write_file","kind":"reviews","label":null},{"id":"agent:Writer->end:done:next","source":"agent:Writer","target":"end:done","kind":"next","label":null}]}},"prompt_tokens":0,"completion_tokens":0,"cost_usd":0.0,"run_id":"team-handoff-01","parent_seq":0}
|
||||
{"schema_version":"1.1","event_type":"message_added","timestamp":1767225600.1,"session_id":"sess-team-01","agent_name":"","sequence_num":2,"messages_count":0,"tokens_used":0,"tokens_budget":0,"data":{"role":"user","content":"Write a short brief on solar sails."},"prompt_tokens":0,"completion_tokens":0,"cost_usd":0.0,"run_id":"team-handoff-01","parent_seq":1}
|
||||
{"schema_version":"1.1","event_type":"llm_request","timestamp":1767225600.2,"session_id":"sess-team-01","agent_name":"Manager","sequence_num":3,"messages_count":3,"tokens_used":210,"tokens_budget":128000,"data":{"model":"gpt-4o-mini"},"prompt_tokens":0,"completion_tokens":0,"cost_usd":0.0,"run_id":"team-handoff-01","parent_seq":1,"node_id":"manager:Manager"}
|
||||
{"schema_version":"1.1","event_type":"llm_response","timestamp":1767225600.8,"session_id":"sess-team-01","agent_name":"Manager","sequence_num":4,"messages_count":0,"tokens_used":0,"tokens_budget":0,"data":{"response_tokens":48,"duration_ms":600.0,"finish_reason":"stop","model":"gpt-4o-mini","response_content":"Delegate research first, then writing."},"prompt_tokens":210,"completion_tokens":48,"cost_usd":6e-05,"run_id":"team-handoff-01","parent_seq":1,"node_id":"manager:Manager"}
|
||||
{"schema_version":"1.1","event_type":"agent_handoff","timestamp":1767225600.85,"session_id":"sess-team-01","agent_name":"Manager","sequence_num":5,"messages_count":0,"tokens_used":0,"tokens_budget":0,"data":{"from_agent":"Manager","to_agent":"Researcher","kind":"delegate","reason":"Research has to come before writing.","context_passed":{"task_snapshot":{"task":"research","description":"Find three facts about solar sails","expected_output":"3 bullet facts","status":"not started"}}},"prompt_tokens":0,"completion_tokens":0,"cost_usd":0.0,"run_id":"team-handoff-01","parent_seq":1,"node_id":"manager:Manager"}
|
||||
{"schema_version":"1.1","event_type":"task_start","timestamp":1767225600.9,"session_id":"sess-team-01","agent_name":"Researcher","sequence_num":6,"messages_count":0,"tokens_used":0,"tokens_budget":0,"data":{"name":"research","agent":"Researcher","description_preview":"Find three facts about solar sails"},"prompt_tokens":0,"completion_tokens":0,"cost_usd":0.0,"run_id":"team-handoff-01","parent_seq":1,"node_id":"task:research"}
|
||||
{"schema_version":"1.1","event_type":"agent_start","timestamp":1767225600.95,"session_id":"sess-team-01","agent_name":"Researcher","sequence_num":7,"messages_count":0,"tokens_used":0,"tokens_budget":0,"data":{"role":"Researcher","goal":"Find facts"},"prompt_tokens":0,"completion_tokens":0,"cost_usd":0.0,"run_id":"team-handoff-01","parent_seq":6,"node_id":"agent:Researcher"}
|
||||
{"schema_version":"1.1","event_type":"message_added","timestamp":1767225601.0,"session_id":"sess-team-01","agent_name":"Researcher","sequence_num":8,"messages_count":0,"tokens_used":0,"tokens_budget":0,"data":{"role":"user","content":"Find three facts about solar sails"},"prompt_tokens":0,"completion_tokens":0,"cost_usd":0.0,"run_id":"team-handoff-01","parent_seq":7}
|
||||
{"schema_version":"1.1","event_type":"llm_request","timestamp":1767225601.05,"session_id":"sess-team-01","agent_name":"Researcher","sequence_num":9,"messages_count":3,"tokens_used":320,"tokens_budget":128000,"data":{"model":"gpt-4o-mini"},"prompt_tokens":0,"completion_tokens":0,"cost_usd":0.0,"run_id":"team-handoff-01","parent_seq":7,"node_id":"agent:Researcher"}
|
||||
{"schema_version":"1.1","event_type":"llm_response","timestamp":1767225601.95,"session_id":"sess-team-01","agent_name":"Researcher","sequence_num":10,"messages_count":0,"tokens_used":0,"tokens_budget":0,"data":{"response_tokens":40,"duration_ms":900.0,"finish_reason":"tool_calls","model":"gpt-4o-mini"},"prompt_tokens":320,"completion_tokens":40,"cost_usd":7.2e-05,"run_id":"team-handoff-01","parent_seq":7,"node_id":"agent:Researcher"}
|
||||
{"schema_version":"1.1","event_type":"tool_call_start","timestamp":1767225602.0,"session_id":"sess-team-01","agent_name":"Researcher","sequence_num":11,"messages_count":0,"tokens_used":0,"tokens_budget":0,"data":{"tool_name":"web_search","tool_args":{"query":"solar sail facts"}},"prompt_tokens":0,"completion_tokens":0,"cost_usd":0.0,"run_id":"team-handoff-01","parent_seq":7,"node_id":"tool:Researcher/web_search"}
|
||||
{"schema_version":"1.1","event_type":"tool_call_end","timestamp":1767225603.2,"session_id":"sess-team-01","agent_name":"Researcher","sequence_num":12,"messages_count":0,"tokens_used":0,"tokens_budget":0,"data":{"tool_name":"web_search","result":"IKAROS (2010) was the first interplanetary solar sail.","duration_ms":1200.0,"error":null},"prompt_tokens":0,"completion_tokens":0,"cost_usd":0.002,"run_id":"team-handoff-01","parent_seq":11,"node_id":"tool:Researcher/web_search"}
|
||||
{"schema_version":"1.1","event_type":"tool_call_start","timestamp":1767225603.25,"session_id":"sess-team-01","agent_name":"Researcher","sequence_num":13,"messages_count":0,"tokens_used":0,"tokens_budget":0,"data":{"tool_name":"web_search","tool_args":{"query":"solar sail missions"}},"prompt_tokens":0,"completion_tokens":0,"cost_usd":0.0,"run_id":"team-handoff-01","parent_seq":7,"node_id":"tool:Researcher/web_search"}
|
||||
{"schema_version":"1.1","event_type":"tool_call_end","timestamp":1767225604.35,"session_id":"sess-team-01","agent_name":"Researcher","sequence_num":14,"messages_count":0,"tokens_used":0,"tokens_budget":0,"data":{"tool_name":"web_search","result":"LightSail 2 raised its orbit using sunlight in 2019.","duration_ms":1100.0,"error":null},"prompt_tokens":0,"completion_tokens":0,"cost_usd":0.002,"run_id":"team-handoff-01","parent_seq":13,"node_id":"tool:Researcher/web_search"}
|
||||
{"schema_version":"1.1","event_type":"tool_call_start","timestamp":1767225604.4,"session_id":"sess-team-01","agent_name":"Researcher","sequence_num":15,"messages_count":0,"tokens_used":0,"tokens_budget":0,"data":{"tool_name":"web_search","tool_args":{"query":"solar sail physics"}},"prompt_tokens":0,"completion_tokens":0,"cost_usd":0.0,"run_id":"team-handoff-01","parent_seq":7,"node_id":"tool:Researcher/web_search"}
|
||||
{"schema_version":"1.1","event_type":"tool_call_end","timestamp":1767225605.3,"session_id":"sess-team-01","agent_name":"Researcher","sequence_num":16,"messages_count":0,"tokens_used":0,"tokens_budget":0,"data":{"tool_name":"web_search","result":"Photon pressure at 1 AU is about 9 micronewtons per square metre.","duration_ms":900.0,"error":null},"prompt_tokens":0,"completion_tokens":0,"cost_usd":0.002,"run_id":"team-handoff-01","parent_seq":15,"node_id":"tool:Researcher/web_search"}
|
||||
{"schema_version":"1.1","event_type":"llm_request","timestamp":1767225605.35,"session_id":"sess-team-01","agent_name":"Researcher","sequence_num":17,"messages_count":3,"tokens_used":880,"tokens_budget":128000,"data":{"model":"gpt-4o-mini"},"prompt_tokens":0,"completion_tokens":0,"cost_usd":0.0,"run_id":"team-handoff-01","parent_seq":7,"node_id":"agent:Researcher"}
|
||||
{"schema_version":"1.1","event_type":"llm_response","timestamp":1767225606.75,"session_id":"sess-team-01","agent_name":"Researcher","sequence_num":18,"messages_count":0,"tokens_used":0,"tokens_budget":0,"data":{"response_tokens":160,"duration_ms":1400.0,"finish_reason":"stop","model":"gpt-4o-mini","response_content":"1. IKAROS ... 2. LightSail 2 ... 3. ~9 uN/m^2"},"prompt_tokens":880,"completion_tokens":160,"cost_usd":0.000228,"run_id":"team-handoff-01","parent_seq":7,"node_id":"agent:Researcher"}
|
||||
{"schema_version":"1.1","event_type":"agent_end","timestamp":1767225606.8,"session_id":"sess-team-01","agent_name":"Researcher","sequence_num":19,"messages_count":0,"tokens_used":0,"tokens_budget":0,"data":{"status":"completed"},"prompt_tokens":0,"completion_tokens":0,"cost_usd":0.0,"run_id":"team-handoff-01","parent_seq":7,"node_id":"agent:Researcher"}
|
||||
{"schema_version":"1.1","event_type":"task_end","timestamp":1767225606.85,"session_id":"sess-team-01","agent_name":"Researcher","sequence_num":20,"messages_count":0,"tokens_used":0,"tokens_budget":0,"data":{"status":"completed","retries":0,"duration_ms":5950.0,"output_preview":"1. IKAROS ... 2. LightSail 2 ... 3. ~9 uN/m^2"},"prompt_tokens":0,"completion_tokens":0,"cost_usd":0.0,"run_id":"team-handoff-01","parent_seq":6,"node_id":"task:research"}
|
||||
{"schema_version":"1.1","event_type":"agent_handoff","timestamp":1767225606.9,"session_id":"sess-team-01","agent_name":"Researcher","sequence_num":21,"messages_count":0,"tokens_used":0,"tokens_budget":0,"data":{"from_agent":"Researcher","to_agent":"Writer","kind":"handoff","reason":"Facts gathered; needs drafting.","context_passed":{"facts":["IKAROS 2010","LightSail 2 2019","~9 uN/m^2 at 1 AU"]}},"prompt_tokens":0,"completion_tokens":0,"cost_usd":0.0,"run_id":"team-handoff-01","parent_seq":1,"node_id":"agent:Researcher"}
|
||||
{"schema_version":"1.1","event_type":"task_start","timestamp":1767225606.95,"session_id":"sess-team-01","agent_name":"Writer","sequence_num":22,"messages_count":0,"tokens_used":0,"tokens_budget":0,"data":{"name":"write","agent":"Writer","description_preview":"Draft a 100-word brief into draft.md"},"prompt_tokens":0,"completion_tokens":0,"cost_usd":0.0,"run_id":"team-handoff-01","parent_seq":1,"node_id":"task:write"}
|
||||
{"schema_version":"1.1","event_type":"agent_start","timestamp":1767225607.0,"session_id":"sess-team-01","agent_name":"Writer","sequence_num":23,"messages_count":0,"tokens_used":0,"tokens_budget":0,"data":{"role":"Writer"},"prompt_tokens":0,"completion_tokens":0,"cost_usd":0.0,"run_id":"team-handoff-01","parent_seq":22,"node_id":"agent:Writer"}
|
||||
{"schema_version":"1.1","event_type":"llm_request","timestamp":1767225607.05,"session_id":"sess-team-01","agent_name":"Writer","sequence_num":24,"messages_count":3,"tokens_used":640,"tokens_budget":128000,"data":{"model":"gpt-4o-mini"},"prompt_tokens":0,"completion_tokens":0,"cost_usd":0.0,"run_id":"team-handoff-01","parent_seq":23,"node_id":"agent:Writer"}
|
||||
{"schema_version":"1.1","event_type":"llm_response","timestamp":1767225608.65,"session_id":"sess-team-01","agent_name":"Writer","sequence_num":25,"messages_count":0,"tokens_used":0,"tokens_budget":0,"data":{"response_tokens":220,"duration_ms":1600.0,"finish_reason":"tool_calls","model":"gpt-4o-mini"},"prompt_tokens":640,"completion_tokens":220,"cost_usd":0.000228,"run_id":"team-handoff-01","parent_seq":23,"node_id":"agent:Writer"}
|
||||
{"schema_version":"1.1","event_type":"approval_requested","timestamp":1767225608.7,"session_id":"sess-team-01","agent_name":"Writer","sequence_num":26,"messages_count":0,"tokens_used":0,"tokens_budget":0,"data":{"approval_id":"appr-0001","tool":"write_file","args":{"path":"draft.md","content":"[REDACTED]"},"risk":"high","kind":"tool_call"},"prompt_tokens":0,"completion_tokens":0,"cost_usd":0.0,"run_id":"team-handoff-01","parent_seq":23,"node_id":"tool:Writer/write_file"}
|
||||
{"schema_version":"1.1","event_type":"approval_decided","timestamp":1767225611.2,"session_id":"sess-team-01","agent_name":"","sequence_num":27,"messages_count":0,"tokens_used":0,"tokens_budget":0,"data":{"approval_id":"appr-0001","approved":true,"reason":"Use the docs folder.","approver":"user","modified":true},"prompt_tokens":0,"completion_tokens":0,"cost_usd":0.0,"run_id":"team-handoff-01","parent_seq":26,"node_id":"tool:Writer/write_file"}
|
||||
{"schema_version":"1.1","event_type":"tool_call_start","timestamp":1767225611.25,"session_id":"sess-team-01","agent_name":"Writer","sequence_num":28,"messages_count":0,"tokens_used":0,"tokens_budget":0,"data":{"tool_name":"write_file","tool_args":{"path":"docs/draft.md","content":"[REDACTED]"}},"prompt_tokens":0,"completion_tokens":0,"cost_usd":0.0,"run_id":"team-handoff-01","parent_seq":23,"node_id":"tool:Writer/write_file"}
|
||||
{"schema_version":"1.1","event_type":"tool_call_end","timestamp":1767225611.3,"session_id":"sess-team-01","agent_name":"Writer","sequence_num":29,"messages_count":0,"tokens_used":0,"tokens_budget":0,"data":{"tool_name":"write_file","result":"Wrote 642 bytes to docs/draft.md","duration_ms":50.0,"error":null},"prompt_tokens":0,"completion_tokens":0,"cost_usd":0.0,"run_id":"team-handoff-01","parent_seq":28,"node_id":"tool:Writer/write_file"}
|
||||
{"schema_version":"1.1","event_type":"llm_request","timestamp":1767225611.35,"session_id":"sess-team-01","agent_name":"Writer","sequence_num":30,"messages_count":3,"tokens_used":900,"tokens_budget":128000,"data":{"model":"gpt-4o-mini"},"prompt_tokens":0,"completion_tokens":0,"cost_usd":0.0,"run_id":"team-handoff-01","parent_seq":23,"node_id":"agent:Writer"}
|
||||
{"schema_version":"1.1","event_type":"llm_response","timestamp":1767225612.05,"session_id":"sess-team-01","agent_name":"Writer","sequence_num":31,"messages_count":0,"tokens_used":0,"tokens_budget":0,"data":{"response_tokens":30,"duration_ms":700.0,"finish_reason":"stop","model":"gpt-4o-mini","response_content":"Saved the brief to docs/draft.md."},"prompt_tokens":900,"completion_tokens":30,"cost_usd":0.000153,"run_id":"team-handoff-01","parent_seq":23,"node_id":"agent:Writer"}
|
||||
{"schema_version":"1.1","event_type":"agent_end","timestamp":1767225612.1,"session_id":"sess-team-01","agent_name":"Writer","sequence_num":32,"messages_count":0,"tokens_used":0,"tokens_budget":0,"data":{"status":"completed"},"prompt_tokens":0,"completion_tokens":0,"cost_usd":0.0,"run_id":"team-handoff-01","parent_seq":23,"node_id":"agent:Writer"}
|
||||
{"schema_version":"1.1","event_type":"task_end","timestamp":1767225612.15,"session_id":"sess-team-01","agent_name":"Writer","sequence_num":33,"messages_count":0,"tokens_used":0,"tokens_budget":0,"data":{"status":"completed","retries":0,"duration_ms":5200.0,"output_preview":"Saved the brief to docs/draft.md."},"prompt_tokens":0,"completion_tokens":0,"cost_usd":0.0,"run_id":"team-handoff-01","parent_seq":22,"node_id":"task:write"}
|
||||
{"schema_version":"1.1","event_type":"workflow_end","timestamp":1767225612.2,"session_id":"sess-team-01","agent_name":"","sequence_num":34,"messages_count":0,"tokens_used":0,"tokens_budget":0,"data":{"status":"completed","duration_ms":12150.0},"prompt_tokens":0,"completion_tokens":0,"cost_usd":0.0,"run_id":"team-handoff-01","parent_seq":1}
|
||||
{"schema_version":"1.1","event_type":"session_end","timestamp":1767225612.25,"session_id":"sess-team-01","agent_name":"","sequence_num":35,"messages_count":0,"tokens_used":0,"tokens_budget":0,"data":{},"prompt_tokens":0,"completion_tokens":0,"cost_usd":0.0,"run_id":"team-handoff-01","parent_seq":0}
|
||||
+29
@@ -0,0 +1,29 @@
|
||||
{"schema_version":"1.1","event_type":"session_start","timestamp":1767225600.0,"session_id":"sess-outside-01","agent_name":"","sequence_num":0,"messages_count":0,"tokens_used":0,"tokens_budget":0,"data":{},"prompt_tokens":0,"completion_tokens":0,"cost_usd":0.0,"run_id":"team-outside-01"}
|
||||
{"schema_version":"1.1","event_type":"workflow_start","timestamp":1767225600.01,"session_id":"sess-outside-01","agent_name":"","sequence_num":1,"messages_count":0,"tokens_used":0,"tokens_budget":0,"data":{"kind":"team","name":"EV brief","process":"sequential","graph":{"schema":"praisonai.graph/1","kind":"team","name":"EV brief","process":"sequential","nodes":[{"id":"start:begin","kind":"start","label":"Brief","parent":null,"meta":{}},{"id":"task:research","kind":"task","label":"research","parent":null,"meta":{}},{"id":"task:write","kind":"task","label":"write","parent":null,"meta":{}},{"id":"agent:Researcher","kind":"agent","label":"Researcher","parent":null,"meta":{}},{"id":"agent:Writer","kind":"agent","label":"Writer","parent":null,"meta":{}},{"id":"tool:Researcher/lookup_fact","kind":"tool","label":"lookup_fact","parent":null,"meta":{}},{"id":"end:done","kind":"end","label":"Delivered","parent":null,"meta":{}}],"edges":[{"id":"start:begin->task:research:next","source":"start:begin","target":"task:research","kind":"next","label":null},{"id":"task:research->task:write:next","source":"task:research","target":"task:write","kind":"next","label":null},{"id":"task:research->agent:Researcher:assigned","source":"task:research","target":"agent:Researcher","kind":"assigned","label":null},{"id":"task:write->agent:Writer:assigned","source":"task:write","target":"agent:Writer","kind":"assigned","label":null},{"id":"agent:Researcher->tool:Researcher/lookup_fact:uses","source":"agent:Researcher","target":"tool:Researcher/lookup_fact","kind":"uses","label":null},{"id":"task:write->end:done:next","source":"task:write","target":"end:done","kind":"next","label":null}]}},"prompt_tokens":0,"completion_tokens":0,"cost_usd":0.0,"run_id":"team-outside-01","parent_seq":0}
|
||||
{"schema_version":"1.1","event_type":"task_start","timestamp":1767225600.02,"session_id":"sess-outside-01","agent_name":"Researcher","sequence_num":2,"messages_count":0,"tokens_used":0,"tokens_budget":0,"data":{"name":"research","agent":"Researcher","description_preview":"Get one fact about electric vehicles"},"prompt_tokens":0,"completion_tokens":0,"cost_usd":0.0,"run_id":"team-outside-01","parent_seq":1,"node_id":"task:research"}
|
||||
{"schema_version":"1.1","event_type":"agent_start","timestamp":1767225600.03,"session_id":"sess-outside-01","agent_name":"Researcher","sequence_num":3,"messages_count":0,"tokens_used":0,"tokens_budget":0,"data":{},"prompt_tokens":0,"completion_tokens":0,"cost_usd":0.0,"run_id":"team-outside-01","parent_seq":2,"node_id":"agent:Researcher"}
|
||||
{"schema_version":"1.1","event_type":"llm_request","timestamp":1767225600.05,"session_id":"sess-outside-01","agent_name":"Researcher","sequence_num":4,"messages_count":0,"tokens_used":0,"tokens_budget":0,"data":{"model":"gpt-4o-mini"},"prompt_tokens":0,"completion_tokens":0,"cost_usd":0.0,"run_id":"team-outside-01","parent_seq":3,"node_id":"agent:Researcher"}
|
||||
{"schema_version":"1.1","event_type":"tool_call_start","timestamp":1767225602.6,"session_id":"sess-outside-01","agent_name":"Researcher","sequence_num":5,"messages_count":0,"tokens_used":0,"tokens_budget":0,"data":{"tool_name":"lookup_fact","tool_args":{"topic":"electric vehicles"}},"prompt_tokens":0,"completion_tokens":0,"cost_usd":0.0,"run_id":"team-outside-01","parent_seq":3,"node_id":"tool:Researcher/lookup_fact"}
|
||||
{"schema_version":"1.1","event_type":"tool_call_end","timestamp":1767225603.5,"session_id":"sess-outside-01","agent_name":"Researcher","sequence_num":6,"messages_count":0,"tokens_used":0,"tokens_budget":0,"data":{"tool_name":"lookup_fact","result":"EV sales passed 17 million in 2024","duration_ms":880.0,"error":null},"prompt_tokens":0,"completion_tokens":0,"cost_usd":0.0,"run_id":"team-outside-01","parent_seq":5,"node_id":"tool:Researcher/lookup_fact"}
|
||||
{"schema_version":"1.1","event_type":"llm_response","timestamp":1767225605.0,"session_id":"sess-outside-01","agent_name":"Researcher","sequence_num":7,"messages_count":0,"tokens_used":0,"tokens_budget":0,"data":{"response_tokens":14,"duration_ms":4950.0,"model":"gpt-4o-mini","response_content":"EV sales passed 17 million in 2024."},"prompt_tokens":310,"completion_tokens":14,"cost_usd":5.5e-05,"run_id":"team-outside-01","parent_seq":3,"node_id":"agent:Researcher"}
|
||||
{"schema_version":"1.1","event_type":"agent_end","timestamp":1767225605.01,"session_id":"sess-outside-01","agent_name":"Researcher","sequence_num":8,"messages_count":0,"tokens_used":0,"tokens_budget":0,"data":{},"prompt_tokens":0,"completion_tokens":0,"cost_usd":0.0,"run_id":"team-outside-01","parent_seq":3,"node_id":"agent:Researcher"}
|
||||
{"schema_version":"1.1","event_type":"task_end","timestamp":1767225605.02,"session_id":"sess-outside-01","agent_name":"Researcher","sequence_num":9,"messages_count":0,"tokens_used":0,"tokens_budget":0,"data":{"status":"completed","retries":0,"duration_ms":5000.0,"output_preview":"EV sales passed 17 million in 2024."},"prompt_tokens":0,"completion_tokens":0,"cost_usd":0.0,"run_id":"team-outside-01","parent_seq":2,"node_id":"task:research"}
|
||||
{"schema_version":"1.1","event_type":"task_start","timestamp":1767225605.03,"session_id":"sess-outside-01","agent_name":"Writer","sequence_num":10,"messages_count":0,"tokens_used":0,"tokens_budget":0,"data":{"name":"write","agent":"Writer","description_preview":"Write one sentence using the fact"},"prompt_tokens":0,"completion_tokens":0,"cost_usd":0.0,"run_id":"team-outside-01","parent_seq":1,"node_id":"task:write"}
|
||||
{"schema_version":"1.1","event_type":"agent_start","timestamp":1767225605.04,"session_id":"sess-outside-01","agent_name":"Writer","sequence_num":11,"messages_count":0,"tokens_used":0,"tokens_budget":0,"data":{},"prompt_tokens":0,"completion_tokens":0,"cost_usd":0.0,"run_id":"team-outside-01","parent_seq":10,"node_id":"agent:Writer"}
|
||||
{"schema_version":"1.1","event_type":"llm_request","timestamp":1767225605.05,"session_id":"sess-outside-01","agent_name":"Writer","sequence_num":12,"messages_count":3,"tokens_used":200,"tokens_budget":128000,"data":{"model":"gpt-4o-mini"},"prompt_tokens":0,"completion_tokens":0,"cost_usd":0.0,"run_id":"team-outside-01","parent_seq":11,"node_id":"agent:Writer"}
|
||||
{"schema_version":"1.1","event_type":"llm_response","timestamp":1767225606.1,"session_id":"sess-outside-01","agent_name":"Writer","sequence_num":13,"messages_count":0,"tokens_used":0,"tokens_budget":0,"data":{"response_tokens":25,"duration_ms":1050.0,"finish_reason":"stop","model":"gpt-4o-mini","response_content":"Global EV sales passed 17 million in 2024."},"prompt_tokens":200,"completion_tokens":25,"cost_usd":4.5e-05,"run_id":"team-outside-01","parent_seq":11,"node_id":"agent:Writer"}
|
||||
{"schema_version":"1.1","event_type":"agent_end","timestamp":1767225606.11,"session_id":"sess-outside-01","agent_name":"Writer","sequence_num":14,"messages_count":0,"tokens_used":0,"tokens_budget":0,"data":{},"prompt_tokens":0,"completion_tokens":0,"cost_usd":0.0,"run_id":"team-outside-01","parent_seq":11,"node_id":"agent:Writer"}
|
||||
{"schema_version":"1.1","event_type":"task_end","timestamp":1767225606.12,"session_id":"sess-outside-01","agent_name":"Writer","sequence_num":15,"messages_count":0,"tokens_used":0,"tokens_budget":0,"data":{"status":"completed","retries":0,"duration_ms":1090.0,"output_preview":"Global EV sales passed 17 million in 2024."},"prompt_tokens":0,"completion_tokens":0,"cost_usd":0.0,"run_id":"team-outside-01","parent_seq":10,"node_id":"task:write"}
|
||||
{"schema_version":"1.1","event_type":"workflow_end","timestamp":1767225606.13,"session_id":"sess-outside-01","agent_name":"","sequence_num":16,"messages_count":0,"tokens_used":0,"tokens_budget":0,"data":{"status":"completed","duration_ms":6120.0},"prompt_tokens":0,"completion_tokens":0,"cost_usd":0.0,"run_id":"team-outside-01","parent_seq":1}
|
||||
{"schema_version":"1.1","event_type":"agent_start","timestamp":1767225606.2,"session_id":"sess-outside-01","agent_name":"Triage","sequence_num":17,"messages_count":0,"tokens_used":0,"tokens_budget":0,"data":{},"prompt_tokens":0,"completion_tokens":0,"cost_usd":0.0,"run_id":"team-outside-01","parent_seq":0,"node_id":"agent:Triage"}
|
||||
{"schema_version":"1.1","event_type":"llm_request","timestamp":1767225606.25,"session_id":"sess-outside-01","agent_name":"Triage","sequence_num":18,"messages_count":0,"tokens_used":0,"tokens_budget":0,"data":{"model":"gpt-4o-mini"},"prompt_tokens":0,"completion_tokens":0,"cost_usd":0.0,"run_id":"team-outside-01","parent_seq":17,"node_id":"agent:Triage"}
|
||||
{"schema_version":"1.1","event_type":"tool_call_start","timestamp":1767225607.7,"session_id":"sess-outside-01","agent_name":"Triage","sequence_num":19,"messages_count":0,"tokens_used":0,"tokens_budget":0,"data":{"tool_name":"transfer_to_billing","tool_args":{}},"prompt_tokens":0,"completion_tokens":0,"cost_usd":0.0,"run_id":"team-outside-01","parent_seq":17,"node_id":"tool:Triage/transfer_to_billing"}
|
||||
{"schema_version":"1.1","event_type":"agent_handoff","timestamp":1767225607.71,"session_id":"sess-outside-01","agent_name":"Triage","sequence_num":20,"messages_count":0,"tokens_used":0,"tokens_budget":0,"data":{"from_agent":"Triage","to_agent":"Billing","kind":"handoff","reason":"Billing question","context_passed":{"context_policy":"summary"}},"prompt_tokens":0,"completion_tokens":0,"cost_usd":0.0,"run_id":"team-outside-01","parent_seq":19,"node_id":"agent:Billing"}
|
||||
{"schema_version":"1.1","event_type":"agent_start","timestamp":1767225607.72,"session_id":"sess-outside-01","agent_name":"Billing","sequence_num":21,"messages_count":0,"tokens_used":0,"tokens_budget":0,"data":{},"prompt_tokens":0,"completion_tokens":0,"cost_usd":0.0,"run_id":"team-outside-01","parent_seq":19,"node_id":"agent:Billing"}
|
||||
{"schema_version":"1.1","event_type":"llm_request","timestamp":1767225607.75,"session_id":"sess-outside-01","agent_name":"Billing","sequence_num":22,"messages_count":3,"tokens_used":150,"tokens_budget":128000,"data":{"model":"gpt-4o-mini"},"prompt_tokens":0,"completion_tokens":0,"cost_usd":0.0,"run_id":"team-outside-01","parent_seq":21,"node_id":"agent:Billing"}
|
||||
{"schema_version":"1.1","event_type":"llm_response","timestamp":1767225608.67,"session_id":"sess-outside-01","agent_name":"Billing","sequence_num":23,"messages_count":0,"tokens_used":0,"tokens_budget":0,"data":{"response_tokens":19,"duration_ms":920.0,"finish_reason":"stop","model":"gpt-4o-mini","response_content":"Please check your statement for a duplicate charge."},"prompt_tokens":150,"completion_tokens":19,"cost_usd":3e-05,"run_id":"team-outside-01","parent_seq":21,"node_id":"agent:Billing"}
|
||||
{"schema_version":"1.1","event_type":"agent_end","timestamp":1767225608.68,"session_id":"sess-outside-01","agent_name":"Billing","sequence_num":24,"messages_count":0,"tokens_used":0,"tokens_budget":0,"data":{},"prompt_tokens":0,"completion_tokens":0,"cost_usd":0.0,"run_id":"team-outside-01","parent_seq":21,"node_id":"agent:Billing"}
|
||||
{"schema_version":"1.1","event_type":"tool_call_end","timestamp":1767225608.7,"session_id":"sess-outside-01","agent_name":"Triage","sequence_num":25,"messages_count":0,"tokens_used":0,"tokens_budget":0,"data":{"tool_name":"transfer_to_billing","result":"Handoff successful","duration_ms":1000.0,"error":null},"prompt_tokens":0,"completion_tokens":0,"cost_usd":0.0,"run_id":"team-outside-01","parent_seq":19,"node_id":"tool:Triage/transfer_to_billing"}
|
||||
{"schema_version":"1.1","event_type":"llm_response","timestamp":1767225609.6,"session_id":"sess-outside-01","agent_name":"Triage","sequence_num":26,"messages_count":0,"tokens_used":0,"tokens_budget":0,"data":{"response_tokens":22,"duration_ms":3350.0,"model":"gpt-4o-mini"},"prompt_tokens":240,"completion_tokens":22,"cost_usd":5e-05,"run_id":"team-outside-01","parent_seq":17,"node_id":"agent:Triage"}
|
||||
{"schema_version":"1.1","event_type":"agent_end","timestamp":1767225609.61,"session_id":"sess-outside-01","agent_name":"Triage","sequence_num":27,"messages_count":0,"tokens_used":0,"tokens_budget":0,"data":{},"prompt_tokens":0,"completion_tokens":0,"cost_usd":0.0,"run_id":"team-outside-01","parent_seq":17,"node_id":"agent:Triage"}
|
||||
{"schema_version":"1.1","event_type":"session_end","timestamp":1767225609.7,"session_id":"sess-outside-01","agent_name":"","sequence_num":28,"messages_count":0,"tokens_used":0,"tokens_budget":0,"data":{},"prompt_tokens":0,"completion_tokens":0,"cost_usd":0.0,"run_id":"team-outside-01","parent_seq":0}
|
||||
@@ -0,0 +1,509 @@
|
||||
// node --test tests/js/replay-core.test.mjs
|
||||
// Pure-function tests for the PraisonAI Replay model (no DOM, no network).
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import { fileURLToPath, pathToFileURL } from 'node:url';
|
||||
|
||||
const here = path.dirname(fileURLToPath(import.meta.url));
|
||||
const root = path.resolve(here, '..', '..');
|
||||
const coreUrl = pathToFileURL(path.join(root, 'src/praisonaiui/templates/frontend/plugins/views/replay-core.js'));
|
||||
const core = await import(coreUrl.href);
|
||||
const { buildModel, narrate, layoutMap, deriveGraph, formatCost, formatDuration } = core;
|
||||
|
||||
function load(name) {
|
||||
const text = fs.readFileSync(path.join(root, 'tests/fixtures/replay', `${name}.jsonl`), 'utf8');
|
||||
const events = text.split('\n').filter(Boolean).map((l) => JSON.parse(l));
|
||||
const ws = events.find((e) => e.event_type === 'workflow_start');
|
||||
return { events, graph: ws && ws.data && ws.data.graph ? ws.data.graph : null };
|
||||
}
|
||||
|
||||
// ── buildModel: lanes ────────────────────────────────────────────────
|
||||
|
||||
test('team run: You and Manager lanes come first, agents follow graph order', () => {
|
||||
const { events, graph } = load('team_handoff');
|
||||
const m = buildModel(events, graph);
|
||||
assert.deepEqual(m.lanes.map((l) => l.id), ['human:you', 'manager:Manager', 'agent:Researcher', 'agent:Writer']);
|
||||
assert.deepEqual(m.lanes.map((l) => l.shape), ['diamond', 'square', 'circle', 'circle']);
|
||||
assert.equal(m.lanes[0].label, 'You');
|
||||
});
|
||||
|
||||
test('parallel branches of one agent become sub-lanes labelled by step', () => {
|
||||
const { events, graph } = load('flow_parallel_loop');
|
||||
const m = buildModel(events, graph);
|
||||
const subs = m.lanes.filter((l) => l.parent === 'agent:Fetcher');
|
||||
assert.deepEqual(subs.map((l) => [l.id, l.branch, l.label]), [
|
||||
['agent:Fetcher#gather/0', 'gather/0', 'news'],
|
||||
['agent:Fetcher#gather/1', 'gather/1', 'papers'],
|
||||
]);
|
||||
const tools = m.blocks.filter((b) => b.kind === 'tool' && b.agent === 'Fetcher');
|
||||
assert.deepEqual(tools.map((b) => b.lane), ['agent:Fetcher#gather/0', 'agent:Fetcher#gather/1']);
|
||||
// the two branches overlap in time: that is why they need their own rows
|
||||
assert.ok(tools[1].start < tools[0].end);
|
||||
});
|
||||
|
||||
test('a 1.0 legacy file still yields lanes, blocks and a hand-off', () => {
|
||||
const { events } = load('legacy_v10');
|
||||
const m = buildModel(events, null);
|
||||
assert.deepEqual(m.lanes.map((l) => l.id), ['human:you', 'agent:Assistant', 'agent:Checker']);
|
||||
assert.deepEqual(m.blocks.map((b) => `${b.kind}:${b.label}`), ['llm:gpt-4o-mini', 'tool:calculator', 'llm:gpt-4o-mini', 'llm:gpt-4o-mini']);
|
||||
assert.equal(m.arrows.length, 1);
|
||||
assert.equal(m.arrows[0].kind, 'handoff');
|
||||
assert.equal(m.chapters.length, 0);
|
||||
});
|
||||
|
||||
// ── buildModel: arrows ───────────────────────────────────────────────
|
||||
|
||||
test('delegate and hand-off arrows connect the right lanes', () => {
|
||||
const { events, graph } = load('team_handoff');
|
||||
const m = buildModel(events, graph);
|
||||
const flows = m.arrows.map((a) => `${a.kind}:${a.from}->${a.to}`);
|
||||
assert.deepEqual(flows, [
|
||||
'delegate:manager:Manager->agent:Researcher',
|
||||
'handoff:agent:Researcher->agent:Writer',
|
||||
'ask:agent:Writer->human:you',
|
||||
'answer:human:you->agent:Writer',
|
||||
]);
|
||||
assert.equal(m.arrows[0].label, 'delegates');
|
||||
assert.equal(m.arrows[3].label, 'approved');
|
||||
});
|
||||
|
||||
test('route arrows from a flow', () => {
|
||||
const { events, graph } = load('flow_parallel_loop');
|
||||
const m = buildModel(events, graph);
|
||||
assert.deepEqual(m.arrows.map((a) => [a.kind, a.from, a.to]), [['route', 'agent:Editor', 'agent:Publisher']]);
|
||||
});
|
||||
|
||||
// ── buildModel: xN collapse ──────────────────────────────────────────
|
||||
|
||||
test('three consecutive web_search calls collapse to one block x3', () => {
|
||||
const { events, graph } = load('team_handoff');
|
||||
const m = buildModel(events, graph);
|
||||
const ws = m.blocks.filter((b) => b.label === 'web_search');
|
||||
assert.equal(ws.length, 1);
|
||||
assert.equal(ws[0].count, 3);
|
||||
assert.equal(ws[0].members.length, 3);
|
||||
assert.ok(Math.abs(ws[0].cost - 0.006) < 1e-9);
|
||||
assert.ok(Math.abs(ws[0].durationMs - 3200) < 1e-6);
|
||||
// every member's events map to the collapsed block
|
||||
const lines = m.narration.filter((n) => n.text.includes('web_search'));
|
||||
assert.equal(lines.length, 3);
|
||||
assert.ok(lines.every((n) => n.blockId === ws[0].id));
|
||||
});
|
||||
|
||||
test('loop passes collapse: polish chapter x3 and Editor LLM block x3', () => {
|
||||
const { events, graph } = load('flow_parallel_loop');
|
||||
const m = buildModel(events, graph);
|
||||
const polish = m.chapters.filter((c) => c.nodeId === 'step:polish');
|
||||
assert.equal(polish.length, 1);
|
||||
assert.equal(polish[0].count, 3);
|
||||
assert.equal(polish[0].depth, 1);
|
||||
const editor = m.blocks.filter((b) => b.agent === 'Editor');
|
||||
assert.equal(editor.length, 1);
|
||||
assert.equal(editor[0].count, 3);
|
||||
assert.equal(editor[0].tokens, 490 + 540 + 590);
|
||||
});
|
||||
|
||||
test('non-adjacent repeats are not collapsed', () => {
|
||||
const { events, graph } = load('team_handoff');
|
||||
const m = buildModel(events, graph);
|
||||
const researcherLlm = m.blocks.filter((b) => b.agent === 'Researcher' && b.kind === 'llm');
|
||||
assert.equal(researcherLlm.length, 2); // llm, tool x3, llm
|
||||
assert.ok(researcherLlm.every((b) => b.count === 1));
|
||||
});
|
||||
|
||||
// ── buildModel: chapters, approvals, totals, live ────────────────────
|
||||
|
||||
test('chapters carry depth, rows and the chosen route', () => {
|
||||
const { events, graph } = load('flow_parallel_loop');
|
||||
const m = buildModel(events, graph);
|
||||
const byNode = Object.fromEntries(m.chapters.map((c) => [c.nodeId, c]));
|
||||
assert.equal(byNode['step:gather'].depth, 0);
|
||||
assert.equal(byNode['step:news'].depth, 1);
|
||||
assert.notEqual(byNode['step:news'].row, byNode['step:papers'].row); // overlapping -> own rows
|
||||
assert.equal(byNode['step:decide'].route, 'publish');
|
||||
assert.equal(m.chapterRows, 3);
|
||||
});
|
||||
|
||||
test('approval sits in the You lane with its decision', () => {
|
||||
const { events, graph } = load('team_handoff');
|
||||
const m = buildModel(events, graph);
|
||||
const ap = m.blocks.find((b) => b.kind === 'approval');
|
||||
assert.equal(ap.lane, 'human:you');
|
||||
assert.equal(ap.status, 'approved');
|
||||
assert.equal(ap.approvalId, 'appr-0001');
|
||||
assert.equal(m.pending.length, 0);
|
||||
});
|
||||
|
||||
test('a run cut before the decision has one pending approval and open blocks', () => {
|
||||
const { events, graph } = load('team_handoff');
|
||||
const cut = events.slice(0, events.findIndex((e) => e.event_type === 'approval_decided'));
|
||||
const m = buildModel(cut, graph);
|
||||
assert.equal(m.pending.length, 1);
|
||||
assert.equal(m.pending[0].approvalId, 'appr-0001');
|
||||
assert.equal(m.pending[0].open, true);
|
||||
const half = events.slice(0, events.findIndex((e) => e.event_type === 'tool_call_end'));
|
||||
const m2 = buildModel(half, graph);
|
||||
const running = m2.blocks.find((b) => b.kind === 'tool');
|
||||
assert.equal(running.open, true);
|
||||
assert.equal(running.status, 'running');
|
||||
assert.match(m2.narration.at(-1).text, /^Researcher is asking web_search for 'solar sail facts'…$/);
|
||||
});
|
||||
|
||||
test('totals per agent and for the run', () => {
|
||||
const { events, graph } = load('team_handoff');
|
||||
const m = buildModel(events, graph);
|
||||
assert.equal(m.totals.llm_calls, 5);
|
||||
assert.equal(m.totals.tool_calls, 4);
|
||||
assert.ok(Math.abs(m.totals.cost_usd - 0.006741) < 1e-9);
|
||||
const r = m.totals.agents['agent:Researcher'];
|
||||
assert.ok(Math.abs(r.cost_usd - (0.000072 + 0.006 + 0.000228)) < 1e-9);
|
||||
assert.equal(r.tool_calls, 3);
|
||||
assert.ok(Math.abs(m.totals.duration_ms - 12250) < 1e-6);
|
||||
});
|
||||
|
||||
test('sub-lane work rolls up to the parent agent total', () => {
|
||||
const { events, graph } = load('flow_parallel_loop');
|
||||
const m = buildModel(events, graph);
|
||||
const f = m.totals.agents['agent:Fetcher'];
|
||||
assert.equal(f.tool_calls, 2);
|
||||
assert.ok(Math.abs(f.cost_usd - 0.001) < 1e-9);
|
||||
assert.equal(m.totals.agents['agent:Fetcher#gather/0'], undefined);
|
||||
});
|
||||
|
||||
test('unknown event types and fields are tolerated and skipped', () => {
|
||||
const { events, graph } = load('flow_parallel_loop');
|
||||
assert.ok(events.some((e) => e.event_type === 'context_rebalance'));
|
||||
const m = buildModel(events, graph);
|
||||
assert.ok(!m.narration.some((n) => n.type === 'context_rebalance'));
|
||||
assert.equal(m.events.length, events.length);
|
||||
});
|
||||
|
||||
test('duplicates (same session:seq, e.g. after an SSE reconnect) are dropped; order follows sequence_num', () => {
|
||||
const { events, graph } = load('legacy_v10');
|
||||
const shuffled = [...events].reverse().concat(events.slice(0, 3));
|
||||
const m = buildModel(shuffled, graph);
|
||||
assert.equal(m.events.length, events.length);
|
||||
assert.deepEqual(m.events.map((e) => e.sequence_num), events.map((e) => e.sequence_num));
|
||||
});
|
||||
|
||||
test('empty input is fine', () => {
|
||||
const m = buildModel([], null);
|
||||
assert.equal(m.blocks.length, 0);
|
||||
assert.equal(m.narration.length, 0);
|
||||
assert.deepEqual(m.lanes.map((l) => l.id), ['human:you']);
|
||||
});
|
||||
|
||||
// ── narrate ──────────────────────────────────────────────────────────
|
||||
|
||||
const ev = (event_type, data = {}, extra = {}) => ({ event_type, data, agent_name: 'Researcher', timestamp: 100, sequence_num: 1, session_id: 's', ...extra });
|
||||
|
||||
test('narrate: tool call with its end gives duration and cost', () => {
|
||||
const start = ev('tool_call_start', { tool_name: 'web_search', tool_args: { query: 'X' } });
|
||||
const end = ev('tool_call_end', { tool_name: 'web_search', duration_ms: 1200 }, { cost_usd: 0.002, timestamp: 101.2 });
|
||||
assert.equal(narrate(start, end), "Researcher asked web_search for 'X' — 1.2 s, $0.002.");
|
||||
assert.equal(narrate(start), "Researcher is asking web_search for 'X'…");
|
||||
const failed = ev('tool_call_end', { tool_name: 'web_search', duration_ms: 300, error: 'HTTP 500' });
|
||||
assert.equal(narrate(start, failed), "Researcher asked web_search for 'X' — failed after 300 ms: HTTP 500.");
|
||||
});
|
||||
|
||||
test('narrate: hand-off kinds', () => {
|
||||
assert.equal(
|
||||
narrate(ev('agent_handoff', { from_agent: 'Researcher', to_agent: 'Writer', reason: 'Facts gathered.' })),
|
||||
"Researcher handed off to Writer: 'Facts gathered'.",
|
||||
);
|
||||
assert.equal(narrate(ev('agent_handoff', { from_agent: 'Manager', to_agent: 'Writer', kind: 'delegate' }, { agent_name: 'Manager' })), 'Manager delegated to Writer.');
|
||||
assert.equal(narrate(ev('agent_handoff', { from_agent: 'A', to_agent: 'B', kind: 'route' })), 'A routed to B.');
|
||||
assert.equal(narrate(ev('agent_handoff', { from_agent: 'A', to_agent: 'B', kind: 'spawn' })), 'A spawned B.');
|
||||
assert.equal(narrate(ev('agent_handoff', { from_agent: 'A', to_agent: 'B', kind: 'weird' })), 'A handed off to B.');
|
||||
});
|
||||
|
||||
test('narrate: LLM response with model, tokens, cost', () => {
|
||||
const r = ev('llm_response', { duration_ms: 900, model: 'gpt-4o-mini' }, { prompt_tokens: 120, completion_tokens: 40, cost_usd: 0.0004 });
|
||||
assert.equal(narrate(r), 'Researcher thought with gpt-4o-mini — 900 ms, 160 tokens, $0.0004.');
|
||||
});
|
||||
|
||||
test('narrate: approvals, human and automatic', () => {
|
||||
const req = ev('approval_requested', { approval_id: 'a1', tool: 'write_file', risk: 'high', kind: 'tool_call' }, { agent_name: 'Writer' });
|
||||
assert.equal(narrate(req), 'Writer asked you to approve write_file (high risk).');
|
||||
assert.equal(narrate(ev('approval_decided', { approved: true, approver: 'user', modified: true }), req), 'You approved write_file with edited arguments.');
|
||||
assert.equal(narrate(ev('approval_decided', { approved: true, approver: 'env' }), req), 'write_file was approved automatically (env).');
|
||||
assert.equal(narrate(ev('approval_decided', { approved: false, approver: 'user', reason: 'Not now' }), req), "You denied write_file: 'Not now'.");
|
||||
assert.equal(narrate(ev('approval_decided', { approved: false, approver: 'timeout' }), req), 'write_file was denied: no answer before the timeout.');
|
||||
});
|
||||
|
||||
test('narrate: tasks, steps, routes, workflow', () => {
|
||||
assert.equal(narrate(ev('task_start', { name: 'research', agent: 'Researcher' })), "Task 'research' went to Researcher.");
|
||||
assert.equal(narrate(ev('task_end', { status: 'failed', duration_ms: 2500, retries: 2 }, { node_id: 'task:research' })), "Task 'research' failed after 2.5 s, 2 retries.");
|
||||
assert.equal(narrate(ev('step_end', { route: 'publish' }), ev('step_start', { name: 'decide' })), "Step 'decide' chose route 'publish'.");
|
||||
assert.equal(narrate(ev('step_end', { status: 'cached' }, { node_id: 'step:fetch' })), "Step 'fetch' reused a cached result.");
|
||||
assert.equal(narrate(ev('step_start', { name: 'gather', pattern: 'parallel' })), "Step 'gather' fanned out in parallel.");
|
||||
assert.equal(narrate(ev('workflow_start', { kind: 'team', name: 'Brief', process: 'hierarchical' })), "Team 'Brief' started (hierarchical).");
|
||||
assert.equal(narrate(ev('workflow_end', { status: 'completed', duration_ms: 125000 })), 'The run completed after 2 min 05 s.');
|
||||
});
|
||||
|
||||
test('narrate: unknown and quiet events give empty strings', () => {
|
||||
assert.equal(narrate(ev('context_snapshot')), '');
|
||||
assert.equal(narrate(ev('something_new')), '');
|
||||
assert.equal(narrate(null), '');
|
||||
assert.equal(narrate(ev('message_added', { role: 'assistant', content: 'hi' })), '');
|
||||
assert.equal(narrate(ev('message_added', { role: 'user', content: 'Hello!' }, { agent_name: '' })), "You said: 'Hello'.");
|
||||
});
|
||||
|
||||
test('narrate is deterministic', () => {
|
||||
const { events, graph } = load('team_handoff');
|
||||
const a = buildModel(events, graph).narration.map((n) => n.text);
|
||||
const b = buildModel(events, graph).narration.map((n) => n.text);
|
||||
assert.deepEqual(a, b);
|
||||
assert.ok(a.includes("Researcher asked web_search for 'solar sail facts' — 1.2 s, $0.002."));
|
||||
});
|
||||
|
||||
test('formatters', () => {
|
||||
assert.equal(formatDuration(850), '850 ms');
|
||||
assert.equal(formatDuration(1200), '1.2 s');
|
||||
assert.equal(formatCost(0.002), '$0.002');
|
||||
assert.equal(formatCost(0.00001), '<$0.0001');
|
||||
assert.equal(formatCost(1.5), '$1.50');
|
||||
assert.equal(formatCost(0), '');
|
||||
});
|
||||
|
||||
// ── layoutMap / deriveGraph ──────────────────────────────────────────
|
||||
|
||||
test('layoutMap: layers flow left to right and every node gets a box', () => {
|
||||
const { graph } = load('team_handoff');
|
||||
const lay = layoutMap(graph);
|
||||
const byId = Object.fromEntries(lay.nodes.map((n) => [n.id, n]));
|
||||
assert.equal(lay.nodes.length, graph.nodes.length);
|
||||
assert.ok(byId['start:begin'].layer < byId['manager:Manager'].layer);
|
||||
assert.ok(byId['manager:Manager'].layer < byId['task:research'].layer);
|
||||
// depends_on (write depends on research) puts research first
|
||||
assert.ok(byId['task:research'].layer < byId['task:write'].layer);
|
||||
assert.ok(byId['agent:Researcher'].layer < byId['agent:Writer'].layer);
|
||||
for (const n of lay.nodes) {
|
||||
assert.ok(n.x >= 0 && n.y >= 0 && n.x + n.w <= lay.width && n.y + n.h <= lay.height, n.id);
|
||||
}
|
||||
assert.deepEqual([...new Set(lay.nodes.map((n) => n.shape))].sort(), ['chevron', 'circle', 'diamond', 'flag', 'note', 'rounded', 'square']);
|
||||
});
|
||||
|
||||
test('layoutMap: no two nodes overlap', () => {
|
||||
for (const name of ['team_handoff', 'flow_parallel_loop']) {
|
||||
const lay = layoutMap(load(name).graph);
|
||||
for (let i = 0; i < lay.nodes.length; i++) {
|
||||
for (let j = i + 1; j < lay.nodes.length; j++) {
|
||||
const a = lay.nodes[i];
|
||||
const b = lay.nodes[j];
|
||||
const apart = a.x + a.w <= b.x || b.x + b.w <= a.x || a.y + a.h <= b.y || b.y + b.h <= a.y;
|
||||
assert.ok(apart, `${a.id} overlaps ${b.id}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
test('layoutMap: a loop edge is a back edge, routed underneath, labels kept', () => {
|
||||
const lay = layoutMap(load('flow_parallel_loop').graph);
|
||||
const back = lay.edges.filter((e) => e.back);
|
||||
assert.deepEqual(back.map((e) => e.id), ['step:polish->step:review_loop:loop']);
|
||||
assert.equal(back[0].points.length, 4);
|
||||
const routes = lay.edges.filter((e) => e.kind === 'routes').map((e) => e.label).sort();
|
||||
assert.deepEqual(routes, ['archive', 'publish']);
|
||||
assert.equal(core.edgeText(lay.edges.find((e) => e.kind === 'routes' && e.label === 'publish')), 'routes: publish');
|
||||
});
|
||||
|
||||
test('layoutMap: deterministic and tolerant of bad input', () => {
|
||||
const g = load('flow_parallel_loop').graph;
|
||||
assert.deepEqual(layoutMap(g), layoutMap(g));
|
||||
const empty = layoutMap(null);
|
||||
assert.equal(empty.nodes.length, 0);
|
||||
const dangling = layoutMap({ nodes: [{ id: 'agent:A', kind: 'agent' }], edges: [{ source: 'agent:A', target: 'nope', kind: 'next' }] });
|
||||
assert.equal(dangling.edges.length, 0);
|
||||
});
|
||||
|
||||
test('deriveGraph: who talked to whom for a 1.0 file', () => {
|
||||
const g = deriveGraph(load('legacy_v10').events);
|
||||
assert.equal(g.derived, true);
|
||||
assert.deepEqual(g.nodes.map((n) => n.id).sort(), ['agent:Assistant', 'agent:Checker', 'tool:Assistant/calculator']);
|
||||
assert.deepEqual(g.edges.map((e) => e.id).sort(), [
|
||||
'agent:Assistant->agent:Checker:handoff',
|
||||
'agent:Assistant->tool:Assistant/calculator:uses',
|
||||
]);
|
||||
const lay = layoutMap(g);
|
||||
assert.equal(lay.nodes.length, 3);
|
||||
});
|
||||
|
||||
test('deriveGraph counts repeats and includes you for approvals', () => {
|
||||
const { events } = load('team_handoff');
|
||||
const g = deriveGraph(events);
|
||||
const ws = g.edges.find((e) => e.id === 'agent:Researcher->tool:Researcher/web_search:uses');
|
||||
assert.equal(ws.label, '×3');
|
||||
assert.ok(g.nodes.some((n) => n.id === 'human:you' && n.kind === 'human'));
|
||||
assert.ok(g.edges.some((e) => e.kind === 'delegates' && e.source === 'manager:Manager'));
|
||||
});
|
||||
|
||||
test('module also registers itself on globalThis for the standalone build', () => {
|
||||
assert.equal(typeof globalThis.PraisonReplayCore.buildModel, 'function');
|
||||
});
|
||||
|
||||
// ── observed graph / run map ─────────────────────────────────────────
|
||||
|
||||
test('observedGraph: agents seen in events but not in the graph are "outside"', () => {
|
||||
const { events, graph } = load('team_outside');
|
||||
const g = core.observedGraph(graph, events);
|
||||
assert.deepEqual([...g.outside].sort(), ['agent:Billing', 'agent:Triage', 'tool:Triage/transfer_to_billing']);
|
||||
// team members keep their recorded ids; nothing duplicated
|
||||
assert.equal(g.nodes.filter((n) => n.id === 'agent:Researcher').length, 1);
|
||||
assert.ok(g.edges.some((e) => e.id === 'agent:Triage->agent:Billing:handoff' && e.meta.observed));
|
||||
// the recorded uses-edge is not added twice
|
||||
assert.equal(g.edges.filter((e) => e.source === 'agent:Researcher' && e.target === 'tool:Researcher/lookup_fact').length, 1);
|
||||
});
|
||||
|
||||
test('observedGraph without a recorded graph is the derived map', () => {
|
||||
const { events } = load('legacy_v10');
|
||||
assert.equal(core.observedGraph(null, events).derived, true);
|
||||
});
|
||||
|
||||
test('layoutRunMap: outside agents form their own framed block under the team', () => {
|
||||
const { events, graph } = load('team_outside');
|
||||
const lay = core.layoutRunMap(graph, events);
|
||||
assert.equal(lay.groups.length, 1);
|
||||
const grp = lay.groups[0];
|
||||
assert.equal(grp.label, 'Outside the team');
|
||||
const inside = lay.nodes.filter((n) => !n.outside);
|
||||
const outside = lay.nodes.filter((n) => n.outside);
|
||||
assert.deepEqual(outside.map((n) => n.id).sort(), ['agent:Billing', 'agent:Triage', 'tool:Triage/transfer_to_billing']);
|
||||
const teamBottom = Math.max(...inside.map((n) => n.y + n.h));
|
||||
for (const n of outside) {
|
||||
assert.ok(n.y > teamBottom, `${n.id} sits below the team`);
|
||||
assert.ok(n.x >= grp.x && n.x + n.w <= grp.x + grp.w && n.y >= grp.y && n.y + n.h <= grp.y + grp.h, `${n.id} inside the frame`);
|
||||
}
|
||||
for (const n of lay.nodes) assert.ok(n.x + n.w <= lay.width && n.y + n.h <= lay.height, `${n.id} within bounds`);
|
||||
assert.ok(lay.edges.some((e) => e.id === 'agent:Triage->agent:Billing:handoff'));
|
||||
});
|
||||
|
||||
test('layoutRunMap: no outside group when the graph covers everyone', () => {
|
||||
const { events, graph } = load('team_handoff');
|
||||
const lay = core.layoutRunMap(graph, events);
|
||||
assert.equal(lay.groups.length, 0);
|
||||
assert.equal(lay.nodes.length, graph.nodes.length);
|
||||
});
|
||||
|
||||
test('real-run shape: LLM span encloses the tool call in the same lane', () => {
|
||||
const { events, graph } = load('team_outside');
|
||||
const m = buildModel(events, graph);
|
||||
const llm = m.blocks.find((b) => b.agent === 'Researcher' && b.kind === 'llm');
|
||||
const tool = m.blocks.find((b) => b.agent === 'Researcher' && b.kind === 'tool');
|
||||
assert.equal(llm.lane, tool.lane);
|
||||
assert.ok(llm.start < tool.start && tool.end < llm.end); // the view must put them on separate rows
|
||||
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,235 @@
|
||||
"""_UIStoreBackend waits for a human decision (contract v1 §5, approvals).
|
||||
|
||||
Before: request_approval_sync returned ``approved=False, "Awaiting UI
|
||||
approval"`` immediately, so a tool needing approval was always denied and the
|
||||
UI's Approve button did nothing for the agent; ``request.approval_id`` and
|
||||
``modified_args`` were dropped.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import threading
|
||||
import time
|
||||
|
||||
import pytest
|
||||
from starlette.applications import Starlette
|
||||
from starlette.testclient import TestClient
|
||||
|
||||
protocols = pytest.importorskip("praisonaiagents.approval.protocols")
|
||||
ApprovalRequest = protocols.ApprovalRequest
|
||||
|
||||
|
||||
def _request(approval_id: str = "appr-1", **kw) -> "ApprovalRequest":
|
||||
return ApprovalRequest(
|
||||
tool_name=kw.pop("tool_name", "write_file"),
|
||||
arguments=kw.pop("arguments", {"path": "draft.md"}),
|
||||
risk_level=kw.pop("risk_level", "high"),
|
||||
agent_name=kw.pop("agent_name", "Writer"),
|
||||
approval_id=approval_id,
|
||||
**kw,
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def store():
|
||||
from praisonaiui.features.approvals import SimpleApprovalManager
|
||||
|
||||
return SimpleApprovalManager()
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def backend(store):
|
||||
from praisonaiui.features.approvals import _UIStoreBackend
|
||||
|
||||
return _UIStoreBackend(store, timeout=5.0)
|
||||
|
||||
|
||||
def _wait_pending(store, approval_id: str, timeout: float = 3.0) -> dict:
|
||||
deadline = time.monotonic() + timeout
|
||||
while time.monotonic() < deadline:
|
||||
for entry in store.list_pending():
|
||||
if entry["id"] == approval_id:
|
||||
return entry
|
||||
time.sleep(0.01)
|
||||
raise AssertionError(f"{approval_id} never became pending")
|
||||
|
||||
|
||||
def _in_thread(fn, *args):
|
||||
box = {}
|
||||
|
||||
def run():
|
||||
box["result"] = fn(*args)
|
||||
|
||||
t = threading.Thread(target=run, daemon=True)
|
||||
t.start()
|
||||
return t, box
|
||||
|
||||
|
||||
def test_sync_blocks_until_approved_with_modified_args(store, backend):
|
||||
t, box = _in_thread(backend.request_approval_sync, _request("appr-1"))
|
||||
entry = _wait_pending(store, "appr-1") # honours request.approval_id
|
||||
assert entry["tool_name"] == "write_file" and entry["agent_name"] == "Writer"
|
||||
time.sleep(0.1)
|
||||
assert t.is_alive(), "backend must block while the decision is pending"
|
||||
store.approve("appr-1", reason="Use docs/", modified_args={"path": "docs/draft.md"})
|
||||
t.join(timeout=3)
|
||||
assert not t.is_alive()
|
||||
decision = box["result"]
|
||||
assert decision.approved is True
|
||||
assert decision.modified_args == {"path": "docs/draft.md"}
|
||||
assert decision.reason == "Use docs/"
|
||||
|
||||
|
||||
def test_sync_deny_resolves_with_reason(store, backend):
|
||||
t, box = _in_thread(backend.request_approval_sync, _request("appr-2"))
|
||||
_wait_pending(store, "appr-2")
|
||||
store.deny("appr-2", reason="Not today")
|
||||
t.join(timeout=3)
|
||||
assert box["result"].approved is False
|
||||
assert box["result"].reason == "Not today"
|
||||
|
||||
|
||||
def test_sync_timeout_denies_and_clears_queue(store):
|
||||
from praisonaiui.features.approvals import _UIStoreBackend
|
||||
|
||||
backend = _UIStoreBackend(store, timeout=0.2)
|
||||
started = time.monotonic()
|
||||
decision = backend.request_approval_sync(_request("appr-3"))
|
||||
assert time.monotonic() - started >= 0.2
|
||||
assert decision.approved is False
|
||||
assert decision.approver == "timeout"
|
||||
assert store.list_pending() == []
|
||||
assert store.get("appr-3")["status"] == "denied"
|
||||
|
||||
|
||||
def test_per_id_isolation(store, backend):
|
||||
t1, b1 = _in_thread(backend.request_approval_sync, _request("a"))
|
||||
t2, b2 = _in_thread(backend.request_approval_sync, _request("b"))
|
||||
_wait_pending(store, "a")
|
||||
_wait_pending(store, "b")
|
||||
store.approve("b")
|
||||
t2.join(timeout=3)
|
||||
assert b2["result"].approved is True
|
||||
assert t1.is_alive(), "deciding b must not release a"
|
||||
store.deny("a")
|
||||
t1.join(timeout=3)
|
||||
assert b1["result"].approved is False
|
||||
|
||||
|
||||
def test_async_waits_without_blocking_the_loop(store, backend):
|
||||
async def main():
|
||||
task = asyncio.create_task(backend.request_approval(_request("appr-4")))
|
||||
ticks = 0
|
||||
while not store.list_pending():
|
||||
ticks += 1
|
||||
await asyncio.sleep(0.01)
|
||||
# the loop keeps running while waiting
|
||||
for _ in range(5):
|
||||
ticks += 1
|
||||
await asyncio.sleep(0.01)
|
||||
assert not task.done()
|
||||
# decision arrives from another thread (the server handling POST /approve)
|
||||
threading.Timer(0.05, lambda: store.approve("appr-4", modified_args={"x": 1})).start()
|
||||
decision = await asyncio.wait_for(task, 3)
|
||||
return decision, ticks
|
||||
|
||||
decision, ticks = asyncio.run(main())
|
||||
assert decision.approved is True
|
||||
assert decision.modified_args == {"x": 1}
|
||||
assert ticks >= 5
|
||||
|
||||
|
||||
def test_async_decision_from_same_loop(store, backend):
|
||||
async def main():
|
||||
task = asyncio.create_task(backend.request_approval(_request("appr-5")))
|
||||
while not store.list_pending():
|
||||
await asyncio.sleep(0.01)
|
||||
store.approve("appr-5")
|
||||
return await asyncio.wait_for(task, 3)
|
||||
|
||||
assert asyncio.run(main()).approved is True
|
||||
|
||||
|
||||
def test_async_timeout_denies(store):
|
||||
from praisonaiui.features.approvals import _UIStoreBackend
|
||||
|
||||
backend = _UIStoreBackend(store, timeout=0.15)
|
||||
decision = asyncio.run(backend.request_approval(_request("appr-6")))
|
||||
assert decision.approved is False
|
||||
assert store.get("appr-6")["status"] == "denied"
|
||||
|
||||
|
||||
def test_async_cancel_by_registry_wait_for_expires_entry(store, backend):
|
||||
async def main():
|
||||
with pytest.raises(asyncio.TimeoutError):
|
||||
await asyncio.wait_for(backend.request_approval(_request("appr-7")), 0.1)
|
||||
|
||||
asyncio.run(main())
|
||||
assert store.list_pending() == []
|
||||
assert store.get("appr-7")["status"] == "denied"
|
||||
|
||||
|
||||
def test_sync_call_on_event_loop_thread_fails_closed_fast(store, backend):
|
||||
async def main():
|
||||
started = time.monotonic()
|
||||
decision = backend.request_approval_sync(_request("appr-8"))
|
||||
return decision, time.monotonic() - started
|
||||
|
||||
decision, elapsed = asyncio.run(main())
|
||||
assert decision.approved is False
|
||||
assert elapsed < 1.0
|
||||
assert store.list_pending() == []
|
||||
|
||||
|
||||
def test_auto_policy_still_resolves_immediately(store, backend):
|
||||
store.update_policies({"auto_approve_tools": ["write_file"]})
|
||||
decision = backend.request_approval_sync(_request("appr-9"))
|
||||
assert decision.approved is True
|
||||
assert decision.approver == "auto-policy"
|
||||
|
||||
|
||||
def test_approve_endpoint_passes_modified_args(store, backend, monkeypatch):
|
||||
from praisonaiui.features import approvals
|
||||
|
||||
monkeypatch.setattr(approvals, "_approval_manager", store)
|
||||
client = TestClient(Starlette(routes=approvals.ApprovalsFeature().routes()))
|
||||
t, box = _in_thread(backend.request_approval_sync, _request("appr-10"))
|
||||
_wait_pending(store, "appr-10")
|
||||
assert client.get("/api/approvals/appr-10").json()["arguments"] == {"path": "draft.md"}
|
||||
r = client.post("/api/approvals/appr-10/approve", json={"modified_args": {"path": "docs/x.md"}, "reason": "ok"})
|
||||
assert r.status_code == 200
|
||||
assert r.json()["modified_args"] == {"path": "docs/x.md"}
|
||||
t.join(timeout=3)
|
||||
assert box["result"].approved is True
|
||||
assert box["result"].modified_args == {"path": "docs/x.md"}
|
||||
|
||||
|
||||
def test_approve_endpoint_rejects_non_object_modified_args(store, monkeypatch):
|
||||
from praisonaiui.features import approvals
|
||||
|
||||
monkeypatch.setattr(approvals, "_approval_manager", store)
|
||||
store.request_approval({"id": "z", "tool_name": "t", "risk_level": "high"})
|
||||
client = TestClient(Starlette(routes=approvals.ApprovalsFeature().routes()))
|
||||
r = client.post("/api/approvals/z/approve", json={"modified_args": [1, 2]})
|
||||
assert r.status_code == 400
|
||||
assert store.get("z")["status"] == "pending"
|
||||
# plain approve without modified_args still works (and keeps old managers working)
|
||||
assert client.post("/api/approvals/z/approve", json={}).status_code == 200
|
||||
|
||||
|
||||
def test_sdk_manager_approve_forwards_modified_args():
|
||||
pytest.importorskip("praisonaiagents.approval")
|
||||
from praisonaiagents.approval import get_approval_registry
|
||||
|
||||
from praisonaiui.features.approvals import SDKApprovalManager
|
||||
|
||||
registry = get_approval_registry()
|
||||
previous = registry.get_backend()
|
||||
try:
|
||||
mgr = SDKApprovalManager()
|
||||
mgr._simple.request_approval({"id": "s1", "tool_name": "t", "risk_level": "high"})
|
||||
entry = mgr.approve("s1", modified_args={"k": "v"})
|
||||
assert entry["modified_args"] == {"k": "v"}
|
||||
finally:
|
||||
registry.set_backend(previous)
|
||||
@@ -0,0 +1,88 @@
|
||||
"""Regression coverage for issue #287: the channels chat bridge dropped
|
||||
extra keyword arguments (e.g. ``chat_id``, ``platform``) because the
|
||||
``_wrapped_chat`` wrapper installed over ``bot._session.chat`` had a fixed
|
||||
``(agent, user_id, text)`` signature.
|
||||
|
||||
When the bot internals invoked the session with per-conversation kwargs the
|
||||
wrapper raised ``TypeError: _wrapped_chat() got an unexpected keyword
|
||||
argument 'chat_id'`` and the bot silently failed to reply. The wrapper must
|
||||
forward any extra positional/keyword arguments untouched to the original
|
||||
session ``chat`` so memory/session continuity is preserved.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
import pytest
|
||||
|
||||
from praisonaiui.features.channels import ChannelsFeature
|
||||
|
||||
|
||||
class _FakeSession:
|
||||
"""Minimal session whose ``chat`` records forwarded arguments."""
|
||||
|
||||
def __init__(self) -> None:
|
||||
self.received_args: tuple = ()
|
||||
self.received_kwargs: dict = {}
|
||||
|
||||
async def chat(self, agent: Any, user_id: str, text: str, *args: Any, **kwargs: Any) -> str:
|
||||
self.received_args = args
|
||||
self.received_kwargs = dict(kwargs)
|
||||
return f"reply to {text}"
|
||||
|
||||
|
||||
class _FakeBot:
|
||||
def __init__(self) -> None:
|
||||
self._session = _FakeSession()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_wrapped_chat_forwards_kwargs() -> None:
|
||||
feature = ChannelsFeature()
|
||||
bot = _FakeBot()
|
||||
|
||||
feature._attach_chat_bridge("chan-1", bot, "telegram")
|
||||
|
||||
# The bridge replaced the session chat with the wrapper.
|
||||
assert bot._session.chat.__name__ == "_wrapped_chat"
|
||||
|
||||
reply = await bot._session.chat(
|
||||
object(),
|
||||
"user-42",
|
||||
"Hi",
|
||||
chat_id="987654321",
|
||||
platform="telegram",
|
||||
user_id_meta="987654321",
|
||||
)
|
||||
|
||||
assert reply == "reply to Hi"
|
||||
assert bot._session.received_kwargs["chat_id"] == "987654321"
|
||||
assert bot._session.received_kwargs["platform"] == "telegram"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_wrapped_chat_forwards_extra_positional_args() -> None:
|
||||
feature = ChannelsFeature()
|
||||
bot = _FakeBot()
|
||||
|
||||
feature._attach_chat_bridge("chan-2", bot, "slack")
|
||||
|
||||
reply = await bot._session.chat(object(), "user-7", "Hello", "extra")
|
||||
|
||||
assert reply == "reply to Hello"
|
||||
assert bot._session.received_args == ("extra",)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_wrapped_chat_still_works_without_kwargs() -> None:
|
||||
"""No-kwargs path (web UI / existing callers) must keep working."""
|
||||
feature = ChannelsFeature()
|
||||
bot = _FakeBot()
|
||||
|
||||
feature._attach_chat_bridge("chan-3", bot, "discord")
|
||||
|
||||
reply = await bot._session.chat(object(), "user-9", "Ping")
|
||||
|
||||
assert reply == "reply to Ping"
|
||||
assert bot._session.received_kwargs == {}
|
||||
@@ -0,0 +1,54 @@
|
||||
"""Regression tests for #322 — Chat must fill the dashboard main column.
|
||||
|
||||
Chat pages declare ``layout: "full-bleed"``; ``selectPage()`` then adds
|
||||
``db-fill`` to ``#db-main-content`` and skips the duplicate page header, and
|
||||
``.chat-root`` sizes from flex allocation instead of ``calc(100vh - 140px)``.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
from pathlib import Path
|
||||
|
||||
PLUGINS = (
|
||||
Path(__file__).resolve().parents[2]
|
||||
/ "src" / "praisonaiui" / "templates" / "frontend" / "plugins"
|
||||
)
|
||||
|
||||
|
||||
def _select_page_body() -> str:
|
||||
src = (PLUGINS / "dashboard.js").read_text(encoding="utf-8")
|
||||
m = re.search(r"async function selectPage\(pageId\)\s*\{(.*?)^}", src, re.DOTALL | re.MULTILINE)
|
||||
assert m
|
||||
return m.group(1)
|
||||
|
||||
|
||||
def test_chat_pages_declare_full_bleed():
|
||||
from starlette.testclient import TestClient
|
||||
|
||||
from praisonaiui.server import create_app, reset_state
|
||||
|
||||
reset_state() # isolate from page whitelists set by other tests
|
||||
pages = {p["id"]: p for p in TestClient(create_app()).get("/api/pages").json()["pages"]}
|
||||
for pid in ("chat", "chat-canvas"):
|
||||
assert pages[pid].get("layout") == "full-bleed"
|
||||
assert pages["overview"].get("layout") is None
|
||||
|
||||
|
||||
def test_select_page_toggles_fill_and_skips_header():
|
||||
body = _select_page_body()
|
||||
assert "page.layout === 'full-bleed'" in body
|
||||
assert "classList.toggle('db-fill'" in body
|
||||
assert re.search(r"showPageHeader = .*&& !fullBleed", body)
|
||||
|
||||
|
||||
def test_fill_css_defined():
|
||||
src = (PLUGINS / "dashboard.js").read_text(encoding="utf-8")
|
||||
assert ".db-main.db-fill > #db-page-content" in src
|
||||
|
||||
|
||||
def test_chat_root_fills_column_in_fill_mode():
|
||||
chat = (PLUGINS / "views" / "chat.js").read_text(encoding="utf-8")
|
||||
assert re.search(r"\.db-fill \.chat-root \{[^}]*flex:1;[^}]*min-height:0", chat)
|
||||
canvas = (PLUGINS / "views" / "chat-canvas.js").read_text(encoding="utf-8")
|
||||
assert ".db-fill .chat-canvas-root" in canvas
|
||||
@@ -0,0 +1,31 @@
|
||||
"""Tests for the in-rail session search in the chat view (issue #324).
|
||||
|
||||
The chat sidebar must expose a search input that filters the already
|
||||
fetched session list client-side, without refetching from the API.
|
||||
"""
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
CHAT = Path("src/praisonaiui/templates/frontend/plugins/views/chat.js")
|
||||
|
||||
|
||||
class TestChatSessionRailSearch:
|
||||
def test_search_input_rendered_in_rail(self):
|
||||
src = CHAT.read_text()
|
||||
assert 'id="chat-session-search"' in src
|
||||
assert src.index('id="chat-session-search"') < src.index('id="chat-sessions-list"')
|
||||
|
||||
def test_input_wired_to_client_side_render(self):
|
||||
src = CHAT.read_text()
|
||||
assert "sessionSearch.addEventListener('input', renderSessionList)" in src
|
||||
|
||||
def test_load_sessions_caches_and_delegates_render(self):
|
||||
src = CHAT.read_text()
|
||||
assert "allSessions = Array.isArray(fetched) ? fetched : [];" in src
|
||||
body = src.split("async function loadSessions()", 1)[1].split("\n}\n", 1)[0]
|
||||
assert "renderSessionList();" in body
|
||||
|
||||
def test_filter_matches_title_and_id_fields(self):
|
||||
src = CHAT.read_text()
|
||||
assert "function _sessionMatches(s, q)" in src
|
||||
assert "No matching sessions" in src
|
||||
@@ -0,0 +1,49 @@
|
||||
"""Issue #331 — rename a session from the chat rail via the existing PATCH route."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
from pathlib import Path
|
||||
|
||||
from starlette.applications import Starlette
|
||||
from starlette.routing import Route
|
||||
from starlette.testclient import TestClient
|
||||
|
||||
import praisonaiui.server as server
|
||||
from praisonaiui.datastore import MemoryDataStore
|
||||
|
||||
CHAT = Path("src/praisonaiui/templates/frontend/plugins/views/chat.js")
|
||||
|
||||
|
||||
def _client(monkeypatch):
|
||||
ds = MemoryDataStore()
|
||||
monkeypatch.setattr(server, "_datastore", ds)
|
||||
app = Starlette(routes=[Route("/sessions/{session_id}", server.patch_session, methods=["PATCH"])])
|
||||
sid = asyncio.run(ds.create_session())["id"]
|
||||
return TestClient(app), ds, sid
|
||||
|
||||
|
||||
def test_patch_renames_and_trims(monkeypatch):
|
||||
client, ds, sid = _client(monkeypatch)
|
||||
r = client.patch(f"/sessions/{sid}", json={"title": " My chat "})
|
||||
assert r.status_code == 200
|
||||
assert asyncio.run(ds.get_session(sid))["title"] == "My chat"
|
||||
|
||||
|
||||
def test_patch_rejects_blank_or_non_string_title(monkeypatch):
|
||||
client, _, sid = _client(monkeypatch)
|
||||
assert client.patch(f"/sessions/{sid}", json={"title": " "}).status_code == 400
|
||||
assert client.patch(f"/sessions/{sid}", json={"title": 123}).status_code == 400
|
||||
|
||||
|
||||
def test_patch_unknown_session_404(monkeypatch):
|
||||
client, _, _ = _client(monkeypatch)
|
||||
assert client.patch("/sessions/nope", json={"title": "x"}).status_code == 404
|
||||
|
||||
|
||||
def test_rail_has_rename_button_wired_to_patch():
|
||||
src = CHAT.read_text()
|
||||
assert "async function renameSession(sessionId, currentTitle)" in src
|
||||
assert "method: 'PATCH'" in src
|
||||
assert "renameSession(id, title)" in src
|
||||
assert "e.stopPropagation()" in src
|
||||
@@ -0,0 +1,31 @@
|
||||
"""Issue #323 — Jobs rows link to their chat session when ``session_id`` is set.
|
||||
|
||||
Source-parsing test, mirroring ``test_session_search_dashboard.py``.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
JOBS_JS = (
|
||||
Path(__file__).resolve().parents[2]
|
||||
/ "src" / "praisonaiui" / "templates" / "frontend" / "plugins" / "jobs.js"
|
||||
)
|
||||
|
||||
|
||||
def _src() -> str:
|
||||
return JOBS_JS.read_text(encoding="utf-8")
|
||||
|
||||
|
||||
def test_open_session_button_only_when_session_id():
|
||||
src = _src()
|
||||
assert "job.session_id ? `<button" in src
|
||||
assert 'data-session-id="${escapeAttr(job.session_id)}"' in src
|
||||
|
||||
|
||||
def test_open_session_reuses_session_select_event():
|
||||
src = _src()
|
||||
assert "function openJobSession(sessionId)" in src
|
||||
assert "selectPage('chat')" in src
|
||||
assert "'aiui:session-select'" in src
|
||||
assert "window.aiuiOpenJobSession = openJobSession;" in src
|
||||
@@ -0,0 +1,166 @@
|
||||
"""create_replay_app(): the Replay view mounted inside another app, with an optional Run box."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import shutil
|
||||
import threading
|
||||
import time
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
from starlette.applications import Starlette
|
||||
from starlette.routing import Mount
|
||||
from starlette.testclient import TestClient
|
||||
|
||||
FIXTURES = Path(__file__).resolve().parents[1] / "fixtures" / "replay"
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def traces(tmp_path, monkeypatch):
|
||||
d = tmp_path / "traces"
|
||||
d.mkdir()
|
||||
monkeypatch.setenv("PRAISONAI_TRACES_DIR", str(d))
|
||||
monkeypatch.delenv("PRAISON_HOME", raising=False)
|
||||
return d
|
||||
|
||||
|
||||
def _host(sub) -> TestClient:
|
||||
"""A host app with the replay app mounted at /replay, like app.mount() in FastAPI."""
|
||||
return TestClient(Starlette(routes=[Mount("/replay", app=sub)]))
|
||||
|
||||
|
||||
def _cfg(page: str) -> dict:
|
||||
line = next(l for l in page.splitlines() if l.startswith("render("))
|
||||
return json.loads(line[line.index(",") + 1 : line.rindex(")")].strip())
|
||||
|
||||
|
||||
def test_page_mounts_the_view_relative_to_the_mount(traces):
|
||||
from praisonaiui.features.replay import create_replay_app
|
||||
|
||||
c = _host(create_replay_app())
|
||||
r = c.get("/replay/")
|
||||
assert r.status_code == 200
|
||||
assert 'from "/replay/replay.js"' in r.text
|
||||
cfg = _cfg(r.text)
|
||||
assert cfg == {"base": "/replay", "approve": False} # no run list pin, no Run box
|
||||
assert "aiui" not in r.text.lower() # no dashboard around it
|
||||
for name in ("replay.js", "replay-core.js"):
|
||||
js = c.get(f"/replay/{name}")
|
||||
assert js.status_code == 200 and js.headers["content-type"].startswith("text/javascript")
|
||||
|
||||
|
||||
def test_page_pins_a_valid_run_and_ignores_a_bad_one(traces):
|
||||
from praisonaiui.features.replay import create_replay_app
|
||||
|
||||
c = _host(create_replay_app())
|
||||
assert _cfg(c.get("/replay/?run=run-abc").text)["run"] == "run-abc"
|
||||
page = c.get("/replay/?run=../../etc/passwd").text
|
||||
assert "run" not in _cfg(page) and "passwd" not in page
|
||||
|
||||
|
||||
def test_api_routes_answer_under_the_mount(traces):
|
||||
from praisonaiui.features.replay import create_replay_app
|
||||
|
||||
shutil.copy(FIXTURES / "team_handoff.jsonl", traces / "team_handoff.jsonl")
|
||||
c = _host(create_replay_app())
|
||||
rows = c.get("/replay/api/replay/runs").json()
|
||||
assert [r["run_id"] for r in rows] == ["team_handoff"]
|
||||
assert c.get("/replay/api/replay/runs/team_handoff/events").json()["events"]
|
||||
|
||||
|
||||
def test_events_wait_param_answers_a_missing_run_without_404(traces):
|
||||
from praisonaiui.features.replay import create_replay_app
|
||||
|
||||
c = _host(create_replay_app())
|
||||
assert c.get("/replay/api/replay/runs/run-later/events").status_code == 404
|
||||
r = c.get("/replay/api/replay/runs/run-later/events?wait=1")
|
||||
assert r.status_code == 200 and r.json() == {"events": [], "graph": None, "run": None}
|
||||
|
||||
|
||||
def test_no_run_route_without_a_runner(traces):
|
||||
from praisonaiui.features.replay import create_replay_app
|
||||
|
||||
c = _host(create_replay_app())
|
||||
assert c.post("/replay/api/replay/run", json={"input": "x"}).status_code in (404, 405)
|
||||
|
||||
|
||||
def test_run_box_calls_the_runner_with_a_new_run_id(traces):
|
||||
from praisonaiui.features.replay import create_replay_app
|
||||
|
||||
calls = []
|
||||
|
||||
def runner(text, run_id):
|
||||
(traces / f"{run_id}.jsonl").write_text(json.dumps({"event_type": "session_start", "sequence_num": 0, "timestamp": time.time()}) + "\n")
|
||||
calls.append((text, run_id))
|
||||
|
||||
c = _host(create_replay_app(runner, input="solar sails", placeholder="Topic"))
|
||||
cfg = _cfg(c.get("/replay/").text)
|
||||
assert cfg["runner"] == {"input": "solar sails", "placeholder": "Topic", "ask": True}
|
||||
r = c.post("/replay/api/replay/run", json={"input": "comets"})
|
||||
assert r.status_code == 200
|
||||
run_id = r.json()["run_id"]
|
||||
assert (traces / f"{run_id}.jsonl").is_file(), "answers once the trace exists"
|
||||
for _ in range(100):
|
||||
if calls:
|
||||
break
|
||||
time.sleep(0.01)
|
||||
assert calls == [("comets", run_id)]
|
||||
assert run_id.startswith("run-") and len(run_id) == 16
|
||||
|
||||
|
||||
def test_run_box_without_input_box(traces):
|
||||
from praisonaiui.features.replay import create_replay_app
|
||||
|
||||
cfg = _cfg(_host(create_replay_app(lambda t, r: None, input=None)).get("/replay/").text)
|
||||
assert cfg["runner"]["ask"] is False
|
||||
|
||||
|
||||
def test_run_rejects_non_json_bad_input_and_a_second_run(traces):
|
||||
from praisonaiui.features.replay import MAX_RUN_INPUT, create_replay_app
|
||||
|
||||
gate = threading.Event()
|
||||
c = _host(create_replay_app(lambda text, run_id: gate.wait(5)))
|
||||
assert c.post("/replay/api/replay/run", data="input=x", headers={"content-type": "application/x-www-form-urlencoded"}).status_code == 415
|
||||
assert c.post("/replay/api/replay/run", content="input=x", headers={"content-type": "text/plain"}).status_code == 415
|
||||
assert c.post("/replay/api/replay/run", json={"input": 3}).status_code == 400
|
||||
assert c.post("/replay/api/replay/run", json={"input": "x" * (MAX_RUN_INPUT + 1)}).status_code == 400
|
||||
assert c.post("/replay/api/replay/run", json={"input": "a"}).status_code == 200
|
||||
busy = c.post("/replay/api/replay/run", json={"input": "b"})
|
||||
assert busy.status_code == 409 and "already" in busy.json()["error"]
|
||||
gate.set()
|
||||
for _ in range(100): # the lock is released when the runner returns
|
||||
r = c.post("/replay/api/replay/run", json={"input": "c"})
|
||||
if r.status_code == 200:
|
||||
break
|
||||
time.sleep(0.02)
|
||||
assert r.status_code == 200
|
||||
|
||||
|
||||
def test_a_failing_runner_frees_the_run_box(traces, caplog):
|
||||
from praisonaiui.features.replay import create_replay_app
|
||||
|
||||
def boom(text, run_id):
|
||||
raise RuntimeError("no key")
|
||||
|
||||
c = _host(create_replay_app(boom))
|
||||
assert c.post("/replay/api/replay/run", json={"input": ""}).status_code == 200
|
||||
for _ in range(100):
|
||||
r = c.post("/replay/api/replay/run", json={"input": ""})
|
||||
if r.status_code == 200:
|
||||
break
|
||||
time.sleep(0.02)
|
||||
assert r.status_code == 200
|
||||
|
||||
|
||||
def test_async_runner_is_awaited(traces):
|
||||
from praisonaiui.features.replay import create_replay_app
|
||||
|
||||
done = threading.Event()
|
||||
|
||||
async def runner(text, run_id):
|
||||
done.set()
|
||||
|
||||
c = _host(create_replay_app(runner))
|
||||
assert c.post("/replay/api/replay/run", json={"input": ""}).status_code == 200
|
||||
assert done.wait(3)
|
||||
@@ -0,0 +1,439 @@
|
||||
"""PraisonAI Replay feature: runs listing, events, SSE tail, safety (contract v1 §1, §5)."""
|
||||
|
||||
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.testclient import TestClient
|
||||
|
||||
FIXTURES = Path(__file__).resolve().parents[1] / "fixtures" / "replay"
|
||||
NAMES = ("team_handoff", "flow_parallel_loop", "legacy_v10")
|
||||
|
||||
|
||||
def _lines(name: str) -> list[str]:
|
||||
return (FIXTURES / f"{name}.jsonl").read_text(encoding="utf-8").splitlines(keepends=True)
|
||||
|
||||
|
||||
@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
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def isolated_server(tmp_path, monkeypatch):
|
||||
"""create_app() writes a config store; keep it out of the developer's home."""
|
||||
monkeypatch.setenv("AIUI_DATA_DIR", str(tmp_path / "aiui-data"))
|
||||
monkeypatch.setenv("PRAISONAI_HOME", str(tmp_path / "praisonai-home"))
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def feature():
|
||||
from praisonaiui.features.replay import ReplayFeature
|
||||
|
||||
f = ReplayFeature()
|
||||
f.poll_interval = 0.02
|
||||
f.ping_interval = 0.15
|
||||
f.quiet_period = 0.3
|
||||
return f
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def client(feature):
|
||||
app = Starlette(routes=feature.routes())
|
||||
return TestClient(app)
|
||||
|
||||
|
||||
def _copy_all(d: Path) -> None:
|
||||
for name in NAMES:
|
||||
dst = d / f"{name}.jsonl"
|
||||
shutil.copy(FIXTURES / f"{name}.jsonl", dst)
|
||||
|
||||
|
||||
# ── traces dir resolution ────────────────────────────────────────────
|
||||
|
||||
|
||||
def test_traces_dir_precedence(tmp_path, monkeypatch):
|
||||
from praisonaiui.features import _trace_jsonl as tj
|
||||
|
||||
monkeypatch.delenv("PRAISONAI_TRACES_DIR", raising=False)
|
||||
monkeypatch.delenv("PRAISON_HOME", raising=False)
|
||||
monkeypatch.setenv("HOME", str(tmp_path / "home"))
|
||||
assert tj.traces_dir() == tmp_path / "home" / ".praison" / "traces"
|
||||
monkeypatch.setenv("PRAISON_HOME", str(tmp_path / "ph"))
|
||||
assert tj.traces_dir() == tmp_path / "ph" / "traces"
|
||||
monkeypatch.setenv("PRAISONAI_TRACES_DIR", str(tmp_path / "explicit"))
|
||||
assert tj.traces_dir() == tmp_path / "explicit"
|
||||
|
||||
|
||||
# ── /api/replay/runs ─────────────────────────────────────────────────
|
||||
|
||||
|
||||
def test_runs_lists_fixtures_newest_first(traces, client):
|
||||
_copy_all(traces)
|
||||
r = client.get("/api/replay/runs")
|
||||
assert r.status_code == 200
|
||||
rows = r.json()
|
||||
assert isinstance(rows, list)
|
||||
by_id = {row["run_id"]: row for row in rows}
|
||||
assert set(by_id) == set(NAMES)
|
||||
# newest first by started_at
|
||||
started = [row["started_at"] for row in rows]
|
||||
assert started == sorted(started, reverse=True)
|
||||
|
||||
team = by_id["team_handoff"]
|
||||
assert team["session_id"] == "sess-team-01"
|
||||
assert team["kind"] == "team"
|
||||
assert team["name"] == "Solar sail brief"
|
||||
assert team["status"] == "completed"
|
||||
assert team["live"] is False
|
||||
assert team["events"] == 36
|
||||
assert team["agents"] == ["Manager", "Researcher", "Writer"]
|
||||
assert team["cost_usd"] == pytest.approx(0.006741, abs=1e-9)
|
||||
assert team["ended_at"] == pytest.approx(1767225612.25)
|
||||
assert team["path"].endswith("team_handoff.jsonl")
|
||||
assert team["event_run_id"] == "team-handoff-01"
|
||||
|
||||
legacy = by_id["legacy_v10"] # schema 1.0: no workflow_start, still listed
|
||||
assert legacy["kind"] == "session"
|
||||
assert legacy["status"] == "completed"
|
||||
assert legacy["schema_version"] == "1.0"
|
||||
assert legacy["agents"] == ["Assistant", "Checker"]
|
||||
assert legacy["events"] == 16
|
||||
|
||||
|
||||
def test_runs_empty_or_missing_dir(tmp_path, monkeypatch, client):
|
||||
monkeypatch.setenv("PRAISONAI_TRACES_DIR", str(tmp_path / "nope"))
|
||||
r = client.get("/api/replay/runs")
|
||||
assert r.status_code == 200
|
||||
assert r.json() == []
|
||||
|
||||
|
||||
def test_run_without_end_marker_is_live(traces, client):
|
||||
lines = _lines("team_handoff")
|
||||
(traces / "half.jsonl").write_text("".join(lines[:20]), encoding="utf-8")
|
||||
row = client.get("/api/replay/runs").json()[0]
|
||||
assert row["run_id"] == "half"
|
||||
assert row["live"] is True
|
||||
assert row["status"] == "running"
|
||||
assert "ended_at" not in row
|
||||
|
||||
|
||||
def test_ignores_non_jsonl_and_unsafe_names(traces, client):
|
||||
_copy_all(traces)
|
||||
(traces / "notes.txt").write_text("x", encoding="utf-8")
|
||||
(traces / "bad name.jsonl").write_text(_lines("legacy_v10")[0], encoding="utf-8")
|
||||
ids = {row["run_id"] for row in client.get("/api/replay/runs").json()}
|
||||
assert ids == set(NAMES)
|
||||
|
||||
|
||||
# ── /api/replay/runs/{id}/events ─────────────────────────────────────
|
||||
|
||||
|
||||
def test_events_and_graph(traces, client):
|
||||
_copy_all(traces)
|
||||
data = client.get("/api/replay/runs/team_handoff/events").json()
|
||||
assert len(data["events"]) == 36
|
||||
assert data["graph"]["schema"] == "praisonai.graph/1"
|
||||
assert data["graph"]["process"] == "hierarchical"
|
||||
assert data["run"]["run_id"] == "team_handoff"
|
||||
legacy = client.get("/api/replay/runs/legacy_v10/events").json()
|
||||
assert legacy["graph"] is None
|
||||
assert len(legacy["events"]) == 16
|
||||
|
||||
|
||||
def test_events_after_seq(traces, client):
|
||||
_copy_all(traces)
|
||||
data = client.get("/api/replay/runs/team_handoff/events?after_seq=30").json()
|
||||
assert [e["sequence_num"] for e in data["events"]] == [31, 32, 33, 34, 35]
|
||||
# graph still comes from the (earlier) workflow_start
|
||||
assert data["graph"] is not None
|
||||
assert client.get("/api/replay/runs/team_handoff/events?after_seq=-1").json()["events"][0]["sequence_num"] == 0
|
||||
assert client.get("/api/replay/runs/team_handoff/events?after_seq=abc").status_code == 400
|
||||
|
||||
|
||||
def test_unknown_event_types_and_fields_pass_through(traces, client):
|
||||
_copy_all(traces)
|
||||
events = client.get("/api/replay/runs/flow_parallel_loop/events").json()["events"]
|
||||
odd = [e for e in events if e["event_type"] == "context_rebalance"]
|
||||
assert len(odd) == 1
|
||||
assert odd[0]["future_field"] == {"x": 1}
|
||||
|
||||
|
||||
def test_partial_last_line_ignored_until_complete(traces, client):
|
||||
lines = _lines("legacy_v10")
|
||||
path = traces / "growing.jsonl"
|
||||
last = lines[-1]
|
||||
path.write_text("".join(lines[:-1]) + last[:25], encoding="utf-8")
|
||||
first = client.get("/api/replay/runs/growing/events").json()["events"]
|
||||
assert len(first) == 15 # half-written session_end is not surfaced
|
||||
assert first[-1]["event_type"] == "agent_end"
|
||||
with open(path, "a", encoding="utf-8") as fh:
|
||||
fh.write(last[25:])
|
||||
second = client.get("/api/replay/runs/growing/events").json()["events"]
|
||||
assert len(second) == 16
|
||||
assert second[-1]["event_type"] == "session_end"
|
||||
|
||||
|
||||
def test_complete_last_line_without_newline_is_read(traces, client):
|
||||
text = "".join(_lines("legacy_v10")).rstrip("\n")
|
||||
(traces / "nonl.jsonl").write_text(text, encoding="utf-8")
|
||||
events = client.get("/api/replay/runs/nonl/events").json()["events"]
|
||||
assert len(events) == 16
|
||||
|
||||
|
||||
def test_garbage_lines_are_skipped(traces, client):
|
||||
lines = _lines("legacy_v10")
|
||||
(traces / "dirty.jsonl").write_text(lines[0] + "not json\n[1,2]\n\n" + "".join(lines[1:]), encoding="utf-8")
|
||||
assert len(client.get("/api/replay/runs/dirty/events").json()["events"]) == 16
|
||||
|
||||
|
||||
# ── safety ───────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"run_id",
|
||||
["..", ".", "a b", "a;b", "%2e%2e", "..%2Fsecret", "x%00y", "ü"],
|
||||
)
|
||||
def test_path_traversal_ids_rejected(traces, client, run_id):
|
||||
secret = traces.parent / "secret.jsonl"
|
||||
secret.write_text(_lines("legacy_v10")[0], encoding="utf-8")
|
||||
for suffix in ("events", "stream"):
|
||||
r = client.get(f"/api/replay/runs/{run_id}/{suffix}")
|
||||
assert r.status_code in (400, 404), (run_id, suffix, r.status_code)
|
||||
assert "legacy-sess-10" not in r.text
|
||||
|
||||
|
||||
def test_run_path_validation_unit(tmp_path):
|
||||
from praisonaiui.features import _trace_jsonl as tj
|
||||
|
||||
assert tj.run_path("../etc/passwd", tmp_path) is None
|
||||
assert tj.run_path("..", tmp_path) is None
|
||||
assert tj.run_path("a/b", tmp_path) is None
|
||||
assert tj.run_path("ok-run_1.2", tmp_path) == tmp_path / "ok-run_1.2.jsonl"
|
||||
|
||||
|
||||
def test_symlink_escaping_dir_is_rejected(traces, client, tmp_path):
|
||||
outside = tmp_path / "outside"
|
||||
outside.mkdir()
|
||||
target = outside / "x.jsonl"
|
||||
target.write_text(_lines("legacy_v10")[0], encoding="utf-8")
|
||||
link = traces / "sneaky.jsonl"
|
||||
try:
|
||||
link.symlink_to(target)
|
||||
except (OSError, NotImplementedError):
|
||||
pytest.skip("symlinks unavailable")
|
||||
assert client.get("/api/replay/runs/sneaky/events").status_code == 400
|
||||
|
||||
|
||||
def test_unknown_run_is_404(traces, client):
|
||||
assert client.get("/api/replay/runs/nope/events").status_code == 404
|
||||
assert client.get("/api/replay/runs/nope/stream").status_code == 404
|
||||
|
||||
|
||||
# ── SSE stream ───────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def _parse_sse(text: str) -> list[tuple[str, str, str | None]]:
|
||||
out = []
|
||||
for block in text.split("\n\n"):
|
||||
if not block.strip():
|
||||
continue
|
||||
name, data, ident = "message", [], None
|
||||
for line in block.split("\n"):
|
||||
if line.startswith("event:"):
|
||||
name = line[6:].strip()
|
||||
elif line.startswith("data:"):
|
||||
data.append(line[5:].strip())
|
||||
elif line.startswith("id:"):
|
||||
ident = line[3:].strip()
|
||||
if data or name != "message":
|
||||
out.append((name, "\n".join(data), ident))
|
||||
return out
|
||||
|
||||
|
||||
def test_stream_tails_appended_lines(traces, client, monkeypatch):
|
||||
"""Lines written while the stream is open (including one written in two
|
||||
halves) arrive exactly once, in order; the stream ends after session_end."""
|
||||
from praisonaiui.features import _trace_jsonl as tj
|
||||
|
||||
monkeypatch.setattr(tj, "LIVE_WINDOW_S", 8) # safety net: never hang the suite
|
||||
lines = _lines("team_handoff")
|
||||
path = traces / "live-run.jsonl"
|
||||
path.write_text("".join(lines[:10]), encoding="utf-8")
|
||||
|
||||
def writer():
|
||||
time.sleep(0.15)
|
||||
for i, line in enumerate(lines[10:]):
|
||||
with open(path, "a", encoding="utf-8") as fh:
|
||||
if i == 5: # a line split across two writes
|
||||
fh.write(line[:30])
|
||||
fh.flush()
|
||||
time.sleep(0.08)
|
||||
fh.write(line[30:])
|
||||
else:
|
||||
fh.write(line)
|
||||
time.sleep(0.01)
|
||||
|
||||
t = threading.Thread(target=writer, daemon=True)
|
||||
t.start()
|
||||
started = time.monotonic()
|
||||
with client.stream("GET", "/api/replay/runs/live-run/stream") as r:
|
||||
assert r.status_code == 200
|
||||
assert r.headers["content-type"].startswith("text/event-stream")
|
||||
body = "".join(r.iter_text())
|
||||
elapsed = time.monotonic() - started
|
||||
t.join(timeout=5)
|
||||
|
||||
msgs = _parse_sse(body)
|
||||
ctx = [json.loads(d) for name, d, _ in msgs if name == "ctx"]
|
||||
assert [e["sequence_num"] for e in ctx] == list(range(36))
|
||||
assert [ident for name, _, ident in msgs if name == "ctx"] == [str(i) for i in range(36)]
|
||||
assert msgs[-1][0] == "end"
|
||||
assert any(name == "ping" for name, _, _ in msgs)
|
||||
assert elapsed < 7, "stream should end shortly after session_end + quiet period"
|
||||
|
||||
|
||||
def test_stream_after_seq_and_last_event_id(traces, client):
|
||||
shutil.copy(FIXTURES / "legacy_v10.jsonl", traces / "done.jsonl")
|
||||
with client.stream("GET", "/api/replay/runs/done/stream?after_seq=12") as r:
|
||||
body = "".join(r.iter_text())
|
||||
seqs = [json.loads(d)["sequence_num"] for n, d, _ in _parse_sse(body) if n == "ctx"]
|
||||
assert seqs == [13, 14, 15]
|
||||
# reconnect: Last-Event-ID beats an older after_seq in the URL
|
||||
with client.stream("GET", "/api/replay/runs/done/stream?after_seq=2", headers={"Last-Event-ID": "14"}) as r:
|
||||
body = "".join(r.iter_text())
|
||||
seqs = [json.loads(d)["sequence_num"] for n, d, _ in _parse_sse(body) if n == "ctx"]
|
||||
assert seqs == [15]
|
||||
|
||||
|
||||
def test_stream_ends_for_stale_unfinished_file(traces, client, monkeypatch):
|
||||
from praisonaiui.features import _trace_jsonl as tj
|
||||
|
||||
monkeypatch.setattr(tj, "LIVE_WINDOW_S", 0.4)
|
||||
(traces / "stale.jsonl").write_text("".join(_lines("team_handoff")[:5]), encoding="utf-8")
|
||||
with client.stream("GET", "/api/replay/runs/stale/stream") as r:
|
||||
body = "".join(r.iter_text())
|
||||
msgs = _parse_sse(body)
|
||||
assert len([m for m in msgs if m[0] == "ctx"]) == 5
|
||||
assert msgs[-1][0] == "end" and "stale" in msgs[-1][1]
|
||||
|
||||
|
||||
def test_stream_restarts_when_file_is_truncated(tmp_path):
|
||||
from praisonaiui.features import _trace_jsonl as tj
|
||||
|
||||
path = tmp_path / "r.jsonl"
|
||||
lines = _lines("legacy_v10")
|
||||
path.write_text("".join(lines), encoding="utf-8")
|
||||
tail = tj.TailReader(path)
|
||||
assert len(tail.poll()) == 16
|
||||
path.write_text(lines[0], encoding="utf-8") # writer restarted the file
|
||||
again = tail.poll()
|
||||
assert len(again) == 1 and again[0]["sequence_num"] == 0
|
||||
|
||||
|
||||
# ── registration / entry point ───────────────────────────────────────
|
||||
|
||||
|
||||
def test_feature_registered_by_default():
|
||||
from praisonaiui.features import auto_register_defaults, get_feature
|
||||
|
||||
auto_register_defaults()
|
||||
f = get_feature("replay")
|
||||
assert f is not None
|
||||
paths = {r.path for r in f.routes()}
|
||||
assert paths == {
|
||||
"/api/replay/runs",
|
||||
"/api/replay/runs/{run_id}/events",
|
||||
"/api/replay/runs/{run_id}/stream",
|
||||
}
|
||||
|
||||
|
||||
def test_replay_page_and_routes_mounted_in_server(traces, isolated_server):
|
||||
import praisonaiui as aiui
|
||||
import praisonaiui.server as server
|
||||
|
||||
_copy_all(traces)
|
||||
server.reset_state()
|
||||
aiui.set_style("dashboard")
|
||||
app = server.create_app()
|
||||
c = TestClient(app)
|
||||
pages = c.get("/api/pages").json()["pages"]
|
||||
page = next((p for p in pages if p["id"] == "replay"), None)
|
||||
assert page is not None
|
||||
assert page["title"] == "Replay"
|
||||
assert page["group"] == "Control"
|
||||
assert page["api_endpoint"] == "/api/replay/runs"
|
||||
assert len(c.get("/api/replay/runs").json()) == 3
|
||||
assert c.get("/plugins/views/replay.js").status_code == 200
|
||||
assert c.get("/plugins/views/replay-core.js").status_code == 200
|
||||
|
||||
|
||||
def test_replay_app_whitelists_replay_first(traces, isolated_server):
|
||||
import praisonaiui.server as server
|
||||
from praisonaiui.features.replay import REPLAY_PAGES, configure_replay_app
|
||||
|
||||
server.reset_state()
|
||||
configure_replay_app()
|
||||
try:
|
||||
app = server.create_app()
|
||||
pages = TestClient(app).get("/api/pages").json()["pages"]
|
||||
builtin = [p["id"] for p in pages if p["id"] in REPLAY_PAGES]
|
||||
assert builtin[0] == "replay"
|
||||
assert set(builtin) == set(REPLAY_PAGES)
|
||||
assert "chat" not in {p["id"] for p in pages}
|
||||
finally:
|
||||
server.reset_state()
|
||||
|
||||
|
||||
def test_replay_app_module_is_importable_and_documented():
|
||||
import importlib
|
||||
|
||||
src = (Path(__file__).resolve().parents[2] / "src" / "praisonaiui" / "replay_app.py").read_text(encoding="utf-8")
|
||||
assert "aiui run" in src and "/replay?run=" in src
|
||||
import praisonaiui.server as server
|
||||
|
||||
try:
|
||||
importlib.import_module("praisonaiui.replay_app")
|
||||
assert server.get_style() == "dashboard"
|
||||
finally:
|
||||
server.reset_state()
|
||||
|
||||
|
||||
def test_cli_list(traces, feature):
|
||||
_copy_all(traces)
|
||||
out = feature._cli_list()
|
||||
assert "team_handoff" in out and "Solar sail brief" in out
|
||||
|
||||
|
||||
# ── standalone export helper ─────────────────────────────────────────
|
||||
|
||||
|
||||
def test_standalone_html_is_self_contained():
|
||||
from praisonaiui.features.replay import standalone_html
|
||||
|
||||
events = [json.loads(line) for line in _lines("team_handoff")]
|
||||
events[3]["data"]["evil"] = "</script><script>alert(1)</script>"
|
||||
html = standalone_html(events, title="Run <1>")
|
||||
assert html.count("<script") == 3 # data + core + view, nothing injected
|
||||
assert "</script><script>alert" not in html
|
||||
assert "window.__REPLAY_DATA__" in html
|
||||
assert "export function buildModel" in html and "export async function render" in html
|
||||
assert "<title>Run <1></title>" in html
|
||||
for banned in ("http://", "https://", "cdn"):
|
||||
# SVG namespace URI is a name, not a fetch
|
||||
assert banned not in html.replace("http://www.w3.org/2000/svg", ""), banned
|
||||
# graph is picked up from workflow_start when not passed
|
||||
payload = html.split("window.__REPLAY_DATA__ = ", 1)[1].split(";</script>", 1)[0]
|
||||
data = json.loads(payload)
|
||||
assert data["graph"]["schema"] == "praisonai.graph/1"
|
||||
assert len(data["events"]) == 36
|
||||
@@ -0,0 +1,149 @@
|
||||
"""PraisonAI Replay dashboard view: registration and wiring (source-grep, like test_run_recorder)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
import shutil
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[2]
|
||||
FRONTEND = ROOT / "src" / "praisonaiui" / "templates" / "frontend" / "plugins"
|
||||
VIEWS = FRONTEND / "views"
|
||||
CORE = VIEWS / "replay-core.js"
|
||||
VIEW = VIEWS / "replay.js"
|
||||
|
||||
|
||||
def test_view_files_exist():
|
||||
assert CORE.exists()
|
||||
assert VIEW.exists()
|
||||
|
||||
|
||||
def test_replay_registered_in_builtin_views():
|
||||
source = (FRONTEND / "dashboard.js").read_text(encoding="utf-8")
|
||||
block = source.split("const BUILTIN_VIEWS = {", 1)[1].split("};", 1)[0]
|
||||
assert re.search(r"^\s*replay:\s*'/plugins/views/replay\.js',", block, re.M)
|
||||
|
||||
|
||||
def test_dashboard_accepts_hash_deep_links():
|
||||
source = (FRONTEND / "dashboard.js").read_text(encoding="utf-8")
|
||||
assert "location.hash.match(" in source
|
||||
assert "history.replaceState(null, '', `/${hashRoute[1]}" in source
|
||||
|
||||
|
||||
def test_core_exports_pure_api():
|
||||
source = CORE.read_text(encoding="utf-8")
|
||||
for fn in (
|
||||
"export function buildModel",
|
||||
"export function narrate",
|
||||
"export function layoutMap",
|
||||
"export function deriveGraph",
|
||||
):
|
||||
assert fn in source, fn
|
||||
assert "globalThis.PraisonReplayCore = api" in source
|
||||
# pure: no DOM and no network
|
||||
for banned in ("document.", "window.", "fetch(", "EventSource", "localStorage"):
|
||||
assert banned not in source, banned
|
||||
|
||||
|
||||
def test_view_exports_render_and_cleanup():
|
||||
source = VIEW.read_text(encoding="utf-8")
|
||||
assert "export async function render" in source
|
||||
assert "export function cleanup" in source
|
||||
assert "abort" in source.lower()
|
||||
assert "es.close()" in source
|
||||
|
||||
|
||||
def test_view_uses_contract_endpoints():
|
||||
source = VIEW.read_text(encoding="utf-8")
|
||||
for url in (
|
||||
"/api/replay/runs",
|
||||
"/api/replay/runs/${encodeURIComponent(id)}/events",
|
||||
"/api/replay/runs/${encodeURIComponent(id)}/stream",
|
||||
"/api/approvals/${encodeURIComponent(b.approvalId)}/${action}",
|
||||
"modified_args",
|
||||
):
|
||||
assert url in source, url
|
||||
|
||||
|
||||
def test_no_cdn_or_third_party_assets():
|
||||
for path in (CORE, VIEW):
|
||||
text = path.read_text(encoding="utf-8")
|
||||
stripped = text.replace("http://www.w3.org/2000/svg", "")
|
||||
assert "http://" not in stripped and "https://" not in stripped, path.name
|
||||
assert not re.search(r"cdn[\w-]*\.[a-z]{2,}", stripped.lower()), path.name
|
||||
assert "mermaid" not in stripped.lower(), path.name
|
||||
assert not re.search(r"^\s*import .* from ['\"](?!\./|/plugins/)", text, re.M), path.name
|
||||
|
||||
|
||||
def test_palette_and_shapes_follow_contract():
|
||||
core = CORE.read_text(encoding="utf-8")
|
||||
for colour in ("#8B0000", "#0F7287", "#8A5A00", "#3C4A5C"):
|
||||
assert colour in core
|
||||
assert "#189AB4" not in core + VIEW.read_text(encoding="utf-8")
|
||||
for pair in ("agent: 'circle'", "tool: 'rounded'", "human: 'diamond'", "manager: 'square'"):
|
||||
assert pair in core
|
||||
|
||||
|
||||
def test_accessibility_hooks_present():
|
||||
view = VIEW.read_text(encoding="utf-8")
|
||||
assert "prefers-reduced-motion" in view
|
||||
assert ":focus-visible" in view
|
||||
assert "aria-live" in view
|
||||
assert "role: 'tablist'" in view and "role: 'tab'" in view and "role: 'tabpanel'" in view
|
||||
assert "aria-current" in view
|
||||
assert "'ArrowLeft'" in view and "'ArrowRight'" in view
|
||||
assert "praisonai replay view agents.yaml" in view
|
||||
assert "with record(" in view
|
||||
assert "Studio" not in view and "__start__" not in view
|
||||
|
||||
|
||||
def test_standalone_boot_contract():
|
||||
view = VIEW.read_text(encoding="utf-8")
|
||||
assert "globalThis.__REPLAY_DATA__" in view
|
||||
assert "replay-root" in view
|
||||
assert "globalThis.PraisonReplayCore" in view
|
||||
|
||||
|
||||
def test_replay_core_node_suite():
|
||||
"""Runs tests/js/replay-core.test.mjs (buildModel, narrate, layoutMap) when Node is present."""
|
||||
node = shutil.which("node")
|
||||
if not node:
|
||||
pytest.skip("node not installed")
|
||||
proc = subprocess.run(
|
||||
[node, "--test", str(ROOT / "tests" / "js" / "replay-core.test.mjs")],
|
||||
cwd=ROOT,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=120,
|
||||
)
|
||||
assert proc.returncode == 0, proc.stdout[-4000:] + proc.stderr[-2000:]
|
||||
assert re.search(r"# fail 0", proc.stdout)
|
||||
|
||||
|
||||
def test_replay_page_uses_own_svg_mark_not_emoji_heading():
|
||||
from praisonaiui.features.replay import REPLAY_MARK_SVG
|
||||
|
||||
assert REPLAY_MARK_SVG.startswith('<svg xmlns="http://www.w3.org/2000/svg"')
|
||||
for banned in ("<script", "foreignObject", "onload", "javascript:"):
|
||||
assert banned not in REPLAY_MARK_SVG
|
||||
server_src = (ROOT / "src" / "praisonaiui" / "server.py").read_text(encoding="utf-8")
|
||||
assert '"icon_svg": REPLAY_MARK_SVG' in server_src
|
||||
dash = (FRONTEND / "dashboard.js").read_text(encoding="utf-8")
|
||||
assert "function safeSvgIcon" in dash and "parsererror, script, foreignObject" in dash
|
||||
assert "setPageIcon(iconSpan, page" in dash
|
||||
|
||||
|
||||
def test_dashboard_mobile_shell_gives_content_the_width():
|
||||
dash = (FRONTEND / "dashboard.js").read_text(encoding="utf-8")
|
||||
assert ".db-sidebar { position: fixed !important;" in dash
|
||||
assert "#db-shell-header-slot { flex: 0 0 100%;" in dash
|
||||
assert ".db-main { padding: 64px 16px 16px; }" in dash
|
||||
|
||||
|
||||
def test_theme_defaults_to_system():
|
||||
view = VIEW.read_text(encoding="utf-8")
|
||||
assert "@media (prefers-color-scheme:dark){.rp-root[data-theme=\"system\"]" in view
|
||||
assert "'Theme: system'" in view
|
||||
@@ -0,0 +1,153 @@
|
||||
"""Tracing feature reads recorded runs from the context-trace JSONL directory.
|
||||
|
||||
The old SDKTracingManager looked up ``praisonaiagents.trace.list_traces`` /
|
||||
``list_spans`` / ``get_trace``, which do not exist, so recorded runs never
|
||||
showed up and it silently fell back to an empty in-memory store.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import shutil
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
from starlette.applications import Starlette
|
||||
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()
|
||||
for name in ("team_handoff", "flow_parallel_loop", "legacy_v10"):
|
||||
shutil.copy(FIXTURES / f"{name}.jsonl", d / f"{name}.jsonl")
|
||||
monkeypatch.setenv("PRAISONAI_TRACES_DIR", str(d))
|
||||
from praisonaiui.features import tracing
|
||||
|
||||
monkeypatch.setattr(tracing, "_tracing_manager", None)
|
||||
return d
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def client(traces):
|
||||
from praisonaiui.features.tracing import TracingFeature
|
||||
|
||||
return TestClient(Starlette(routes=TracingFeature().routes()))
|
||||
|
||||
|
||||
def test_recorded_runs_are_listed_as_traces(client):
|
||||
data = client.get("/api/traces").json()
|
||||
ids = {t["id"] for t in data["traces"]}
|
||||
assert {"team_handoff", "flow_parallel_loop", "legacy_v10"} <= ids
|
||||
team = next(t for t in data["traces"] if t["id"] == "team_handoff")
|
||||
assert team["source"] == "jsonl"
|
||||
assert team["name"] == "Solar sail brief"
|
||||
assert team["status"] == "completed"
|
||||
assert team["duration_ms"] == pytest.approx(12250, abs=1)
|
||||
assert team["span_count"] > 0
|
||||
assert team["metadata"]["session_id"] == "sess-team-01"
|
||||
|
||||
|
||||
def test_agent_filter(client):
|
||||
data = client.get("/api/traces?agent_id=Checker").json()
|
||||
assert [t["id"] for t in data["traces"]] == ["legacy_v10"]
|
||||
|
||||
|
||||
def test_spans_from_start_end_pairs_via_parent_seq(client):
|
||||
spans = client.get("/api/traces/spans?trace_id=team_handoff&limit=500").json()["spans"]
|
||||
kinds = {}
|
||||
for s in spans:
|
||||
kinds[s["kind"]] = kinds.get(s["kind"], 0) + 1
|
||||
assert kinds == {"session": 1, "workflow": 1, "llm": 5, "agent": 2, "task": 2, "tool": 4, "approval": 1}
|
||||
by_id = {s["id"]: s for s in spans}
|
||||
# tool span nests under the agent span that was open (parent_seq on the start)
|
||||
tool = next(s for s in spans if s["kind"] == "tool" and s["name"] == "write_file")
|
||||
parent = by_id[tool["parent_span_id"]]
|
||||
assert parent["kind"] == "agent" and parent["agent_id"] == "Writer"
|
||||
assert tool["status"] == "ok"
|
||||
assert tool["duration_ms"] == pytest.approx(50.0)
|
||||
assert tool["attributes"]["input"] == {"path": "docs/draft.md", "content": "[REDACTED]"}
|
||||
# approval pairs requested -> decided by parent_seq even though the decision has no agent
|
||||
appr = next(s for s in spans if s["kind"] == "approval")
|
||||
assert appr["status"] == "ok"
|
||||
assert appr["end_time"] == pytest.approx(1767225611.2)
|
||||
# every span closed
|
||||
assert all(s["status"] != "open" for s in spans)
|
||||
assert all(s["trace_id"] == "team_handoff" for s in spans)
|
||||
|
||||
|
||||
def test_spans_for_legacy_file_fall_back_to_sequence_order(client):
|
||||
spans = client.get("/api/traces/spans?trace_id=legacy_v10").json()["spans"]
|
||||
by_id = {s["id"]: s for s in spans}
|
||||
names = [(s["kind"], s["agent_id"]) for s in spans]
|
||||
assert names == [
|
||||
("session", ""),
|
||||
("agent", "Assistant"),
|
||||
("llm", "Assistant"),
|
||||
("tool", "Assistant"),
|
||||
("llm", "Assistant"),
|
||||
("agent", "Checker"),
|
||||
("llm", "Checker"),
|
||||
]
|
||||
calc = next(s for s in spans if s["kind"] == "tool")
|
||||
assert by_id[calc["parent_span_id"]]["kind"] == "agent"
|
||||
assert by_id[calc["parent_span_id"]]["agent_id"] == "Assistant"
|
||||
checker_llm = spans[-1]
|
||||
assert by_id[checker_llm["parent_span_id"]]["agent_id"] == "Checker"
|
||||
assert all(s["status"] == "ok" for s in spans)
|
||||
|
||||
|
||||
def test_parallel_branch_spans_pair_within_branch(client):
|
||||
spans = client.get("/api/traces/spans?trace_id=flow_parallel_loop&limit=500").json()["spans"]
|
||||
tools = {s["name"]: s for s in spans if s["kind"] == "tool"}
|
||||
assert tools["fetch_rss"]["duration_ms"] == pytest.approx(1250.0)
|
||||
assert tools["search_papers"]["duration_ms"] == pytest.approx(1840.0)
|
||||
assert tools["search_papers"]["attributes"]["branch_id"] == "gather/1"
|
||||
assert tools["search_papers"]["attributes"]["cost_usd"] == pytest.approx(0.001)
|
||||
|
||||
|
||||
def test_get_trace_and_unknown(client):
|
||||
r = client.get("/api/traces/legacy_v10")
|
||||
assert r.status_code == 200
|
||||
body = r.json()
|
||||
assert body["trace"]["id"] == "legacy_v10"
|
||||
assert len(body["spans"]) == 7
|
||||
assert client.get("/api/traces/does-not-exist").status_code == 404
|
||||
assert client.get("/api/traces/../../etc/passwd").status_code == 404
|
||||
|
||||
|
||||
def test_health_reports_source(client, tmp_path, monkeypatch):
|
||||
status = client.get("/api/traces/status").json()
|
||||
assert status["source"] == "jsonl"
|
||||
assert status["jsonl_traces"] == 3
|
||||
from praisonaiui.features import tracing
|
||||
|
||||
monkeypatch.setenv("PRAISONAI_TRACES_DIR", str(tmp_path / "empty"))
|
||||
monkeypatch.setattr(tracing, "_tracing_manager", None)
|
||||
assert client.get("/api/traces/status").json()["source"] == "memory"
|
||||
|
||||
|
||||
def test_recorded_in_memory_traces_still_work(client):
|
||||
r = client.post("/api/traces/record", json={"type": "trace", "id": "mem-1", "name": "manual"})
|
||||
assert r.status_code == 200
|
||||
ids = [t["id"] for t in client.get("/api/traces?limit=100").json()["traces"]]
|
||||
assert "mem-1" in ids and "team_handoff" in ids
|
||||
assert client.get("/api/traces/mem-1").json()["trace"]["name"] == "manual"
|
||||
|
||||
|
||||
def test_no_dead_sdk_lookups_remain():
|
||||
src = (Path(__file__).resolve().parents[2] / "src" / "praisonaiui" / "features" / "tracing.py").read_text(encoding="utf-8")
|
||||
for dead in ('"list_traces", None', '"list_spans", None', '"get_trace", None', "trace_module"):
|
||||
assert dead not in src
|
||||
from praisonaiui.features.tracing import JsonlTracingManager, SDKTracingManager
|
||||
|
||||
assert SDKTracingManager is JsonlTracingManager # deprecated alias kept for imports
|
||||
|
||||
|
||||
def test_cli_status_mentions_source(traces):
|
||||
from praisonaiui.features.tracing import TracingFeature
|
||||
|
||||
out = TracingFeature()._cli_status()
|
||||
assert "Source: jsonl" in out
|
||||
@@ -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.144"
|
||||
version = "0.3.151"
|
||||
source = { editable = "." }
|
||||
dependencies = [
|
||||
{ name = "pydantic" },
|
||||
@@ -1241,7 +1241,8 @@ dependencies = [
|
||||
{ name = "mmh3" },
|
||||
{ name = "numpy", version = "2.2.6", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" },
|
||||
{ name = "numpy", version = "2.4.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" },
|
||||
{ name = "onnxruntime" },
|
||||
{ name = "onnxruntime", version = "1.23.2", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" },
|
||||
{ name = "onnxruntime", version = "1.24.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" },
|
||||
{ name = "opentelemetry-api" },
|
||||
{ name = "opentelemetry-exporter-otlp-proto-grpc" },
|
||||
{ name = "opentelemetry-sdk" },
|
||||
@@ -1311,6 +1312,18 @@ wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "coloredlogs"
|
||||
version = "15.0.1"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "humanfriendly" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/cc/c7/eed8f27100517e8c0e6b923d5f0845d0cb99763da6fdee00478f91db7325/coloredlogs-15.0.1.tar.gz", hash = "sha256:7c991aa71a4577af2f82600d8f8f3a89f936baeaf9b50a9c197da014e5bf16b0", size = 278520, upload-time = "2021-06-11T10:22:45.202Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/a7/06/3d6badcf13db419e25b07041d9c7b4a2c331d3f4e7134445ec5df57714cd/coloredlogs-15.0.1-py2.py3-none-any.whl", hash = "sha256:612ee75c546f53e92e70049c9dbfcc18c935a2b9a53b66085ce9ef6a6e5c0934", size = 46018, upload-time = "2021-06-11T10:22:42.561Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "coverage"
|
||||
version = "7.13.2"
|
||||
@@ -2450,6 +2463,18 @@ wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/92/e3/e3a44f54c8e2f28983fcf07f13d4260b37bd6a0d3a081041bc60b91d230e/huggingface_hub-1.6.0-py3-none-any.whl", hash = "sha256:ef40e2d5cb85e48b2c067020fa5142168342d5108a1b267478ed384ecbf18961", size = 612874, upload-time = "2026-03-06T14:19:16.844Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "humanfriendly"
|
||||
version = "10.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "pyreadline3", marker = "sys_platform == 'win32'" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/cc/3f/2c29224acb2e2df4d2046e4c73ee2662023c58ff5b113c4c1adac0886c43/humanfriendly-10.0.tar.gz", hash = "sha256:6b0b831ce8f15f7300721aa49829fc4e83921a9a301cc7f606be6686a2288ddc", size = 360702, upload-time = "2021-09-17T21:40:43.31Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/f0/0f/310fb31e39e2d734ccaa2c0fb981ee41f7bd5056ce9bc29b2248bd569169/humanfriendly-10.0-py2.py3-none-any.whl", hash = "sha256:1697e1a8a8f550fd43c2865cd84542fc175a61dcb779b6fee18cf6b6ccba1477", size = 86794, upload-time = "2021-09-17T21:40:39.897Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "humanize"
|
||||
version = "4.15.0"
|
||||
@@ -3355,7 +3380,8 @@ dependencies = [
|
||||
{ name = "click" },
|
||||
{ name = "numpy", version = "2.2.6", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" },
|
||||
{ name = "numpy", version = "2.4.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" },
|
||||
{ name = "onnxruntime" },
|
||||
{ name = "onnxruntime", version = "1.23.2", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" },
|
||||
{ name = "onnxruntime", version = "1.24.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" },
|
||||
{ name = "python-dotenv" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/fe/b6/8fdd991142ad3e037179a494b153f463024e5a211ef3ad948b955c26b4de/magika-0.6.2.tar.gz", hash = "sha256:37eb6ae8020f6e68f231bc06052c0a0cbe8e6fa27492db345e8dc867dbceb067", size = 3036634, upload-time = "2025-05-02T14:54:18.88Z" }
|
||||
@@ -3379,7 +3405,8 @@ dependencies = [
|
||||
{ name = "click" },
|
||||
{ name = "numpy", version = "2.2.6", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" },
|
||||
{ name = "numpy", version = "2.4.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" },
|
||||
{ name = "onnxruntime" },
|
||||
{ name = "onnxruntime", version = "1.23.2", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" },
|
||||
{ name = "onnxruntime", version = "1.24.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" },
|
||||
{ name = "python-dotenv" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/a3/f3/3d1dcdd7b9c41d589f5cff252d32ed91cdf86ba84391cfc81d9d8773571d/magika-0.6.3.tar.gz", hash = "sha256:7cc52aa7359af861957043e2bf7265ed4741067251c104532765cd668c0c0cb1", size = 3042784, upload-time = "2025-10-30T15:22:34.499Z" }
|
||||
@@ -4292,14 +4319,64 @@ wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/17/d3/b64c356a907242d719fc668b71befd73324e47ab46c8ebbbede252c154b2/olefile-0.47-py2.py3-none-any.whl", hash = "sha256:543c7da2a7adadf21214938bb79c83ea12b473a4b6ee4ad4bf854e7715e13d1f", size = 114565, upload-time = "2023-12-01T16:22:51.518Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "onnxruntime"
|
||||
version = "1.23.2"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
resolution-markers = [
|
||||
"python_full_version < '3.11' and sys_platform == 'win32'",
|
||||
"python_full_version < '3.11' and sys_platform != 'win32'",
|
||||
]
|
||||
dependencies = [
|
||||
{ name = "coloredlogs" },
|
||||
{ name = "flatbuffers" },
|
||||
{ name = "numpy", version = "2.2.6", source = { registry = "https://pypi.org/simple" } },
|
||||
{ name = "packaging" },
|
||||
{ name = "protobuf" },
|
||||
{ name = "sympy" },
|
||||
]
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/35/d6/311b1afea060015b56c742f3531168c1644650767f27ef40062569960587/onnxruntime-1.23.2-cp310-cp310-macosx_13_0_arm64.whl", hash = "sha256:a7730122afe186a784660f6ec5807138bf9d792fa1df76556b27307ea9ebcbe3", size = 17195934, upload-time = "2025-10-27T23:06:14.143Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/db/db/81bf3d7cecfbfed9092b6b4052e857a769d62ed90561b410014e0aae18db/onnxruntime-1.23.2-cp310-cp310-macosx_13_0_x86_64.whl", hash = "sha256:b28740f4ecef1738ea8f807461dd541b8287d5650b5be33bca7b474e3cbd1f36", size = 19153079, upload-time = "2025-10-27T23:05:57.686Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/2e/4d/a382452b17cf70a2313153c520ea4c96ab670c996cb3a95cc5d5ac7bfdac/onnxruntime-1.23.2-cp310-cp310-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:8f7d1fe034090a1e371b7f3ca9d3ccae2fabae8c1d8844fb7371d1ea38e8e8d2", size = 15219883, upload-time = "2025-10-22T03:46:21.66Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/fb/56/179bf90679984c85b417664c26aae4f427cba7514bd2d65c43b181b7b08b/onnxruntime-1.23.2-cp310-cp310-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4ca88747e708e5c67337b0f65eed4b7d0dd70d22ac332038c9fc4635760018f7", size = 17370357, upload-time = "2025-10-22T03:46:57.968Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/cd/6d/738e50c47c2fd285b1e6c8083f15dac1a5f6199213378a5f14092497296d/onnxruntime-1.23.2-cp310-cp310-win_amd64.whl", hash = "sha256:0be6a37a45e6719db5120e9986fcd30ea205ac8103fd1fb74b6c33348327a0cc", size = 13467651, upload-time = "2025-10-27T23:06:11.904Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/44/be/467b00f09061572f022ffd17e49e49e5a7a789056bad95b54dfd3bee73ff/onnxruntime-1.23.2-cp311-cp311-macosx_13_0_arm64.whl", hash = "sha256:6f91d2c9b0965e86827a5ba01531d5b669770b01775b23199565d6c1f136616c", size = 17196113, upload-time = "2025-10-22T03:47:33.526Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/9f/a8/3c23a8f75f93122d2b3410bfb74d06d0f8da4ac663185f91866b03f7da1b/onnxruntime-1.23.2-cp311-cp311-macosx_13_0_x86_64.whl", hash = "sha256:87d8b6eaf0fbeb6835a60a4265fde7a3b60157cf1b2764773ac47237b4d48612", size = 19153857, upload-time = "2025-10-22T03:46:37.578Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/3f/d8/506eed9af03d86f8db4880a4c47cd0dffee973ef7e4f4cff9f1d4bcf7d22/onnxruntime-1.23.2-cp311-cp311-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:bbfd2fca76c855317568c1b36a885ddea2272c13cb0e395002c402f2360429a6", size = 15220095, upload-time = "2025-10-22T03:46:24.769Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e9/80/113381ba832d5e777accedc6cb41d10f9eca82321ae31ebb6bcede530cea/onnxruntime-1.23.2-cp311-cp311-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:da44b99206e77734c5819aa2142c69e64f3b46edc3bd314f6a45a932defc0b3e", size = 17372080, upload-time = "2025-10-22T03:47:00.265Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/3a/db/1b4a62e23183a0c3fe441782462c0ede9a2a65c6bbffb9582fab7c7a0d38/onnxruntime-1.23.2-cp311-cp311-win_amd64.whl", hash = "sha256:902c756d8b633ce0dedd889b7c08459433fbcf35e9c38d1c03ddc020f0648c6e", size = 13468349, upload-time = "2025-10-22T03:47:25.783Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/1b/9e/f748cd64161213adeef83d0cb16cb8ace1e62fa501033acdd9f9341fff57/onnxruntime-1.23.2-cp312-cp312-macosx_13_0_arm64.whl", hash = "sha256:b8f029a6b98d3cf5be564d52802bb50a8489ab73409fa9db0bf583eabb7c2321", size = 17195929, upload-time = "2025-10-22T03:47:36.24Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/91/9d/a81aafd899b900101988ead7fb14974c8a58695338ab6a0f3d6b0100f30b/onnxruntime-1.23.2-cp312-cp312-macosx_13_0_x86_64.whl", hash = "sha256:218295a8acae83905f6f1aed8cacb8e3eb3bd7513a13fe4ba3b2664a19fc4a6b", size = 19157705, upload-time = "2025-10-22T03:46:40.415Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/3c/35/4e40f2fba272a6698d62be2cd21ddc3675edfc1a4b9ddefcc4648f115315/onnxruntime-1.23.2-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:76ff670550dc23e58ea9bc53b5149b99a44e63b34b524f7b8547469aaa0dcb8c", size = 15226915, upload-time = "2025-10-22T03:46:27.773Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ef/88/9cc25d2bafe6bc0d4d3c1db3ade98196d5b355c0b273e6a5dc09c5d5d0d5/onnxruntime-1.23.2-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0f9b4ae77f8e3c9bee50c27bc1beede83f786fe1d52e99ac85aa8d65a01e9b77", size = 17382649, upload-time = "2025-10-22T03:47:02.782Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c0/b4/569d298f9fc4d286c11c45e85d9ffa9e877af12ace98af8cab52396e8f46/onnxruntime-1.23.2-cp312-cp312-win_amd64.whl", hash = "sha256:25de5214923ce941a3523739d34a520aac30f21e631de53bba9174dc9c004435", size = 13470528, upload-time = "2025-10-22T03:47:28.106Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/3d/41/fba0cabccecefe4a1b5fc8020c44febb334637f133acefc7ec492029dd2c/onnxruntime-1.23.2-cp313-cp313-macosx_13_0_arm64.whl", hash = "sha256:2ff531ad8496281b4297f32b83b01cdd719617e2351ffe0dba5684fb283afa1f", size = 17196337, upload-time = "2025-10-22T03:46:35.168Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/fe/f9/2d49ca491c6a986acce9f1d1d5fc2099108958cc1710c28e89a032c9cfe9/onnxruntime-1.23.2-cp313-cp313-macosx_13_0_x86_64.whl", hash = "sha256:162f4ca894ec3de1a6fd53589e511e06ecdc3ff646849b62a9da7489dee9ce95", size = 19157691, upload-time = "2025-10-22T03:46:43.518Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/1c/a1/428ee29c6eaf09a6f6be56f836213f104618fb35ac6cc586ff0f477263eb/onnxruntime-1.23.2-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:45d127d6e1e9b99d1ebeae9bcd8f98617a812f53f46699eafeb976275744826b", size = 15226898, upload-time = "2025-10-22T03:46:30.039Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f2/2b/b57c8a2466a3126dbe0a792f56ad7290949b02f47b86216cd47d857e4b77/onnxruntime-1.23.2-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8bace4e0d46480fbeeb7bbe1ffe1f080e6663a42d1086ff95c1551f2d39e7872", size = 17382518, upload-time = "2025-10-22T03:47:05.407Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/4a/93/aba75358133b3a941d736816dd392f687e7eab77215a6e429879080b76b6/onnxruntime-1.23.2-cp313-cp313-win_amd64.whl", hash = "sha256:1f9cc0a55349c584f083c1c076e611a7c35d5b867d5d6e6d6c823bf821978088", size = 13470276, upload-time = "2025-10-22T03:47:31.193Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/7c/3d/6830fa61c69ca8e905f237001dbfc01689a4e4ab06147020a4518318881f/onnxruntime-1.23.2-cp313-cp313t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9d2385e774f46ac38f02b3a91a91e30263d41b2f1f4f26ae34805b2a9ddef466", size = 15229610, upload-time = "2025-10-22T03:46:32.239Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b6/ca/862b1e7a639460f0ca25fd5b6135fb42cf9deea86d398a92e44dfda2279d/onnxruntime-1.23.2-cp313-cp313t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e2b9233c4947907fd1818d0e581c049c41ccc39b2856cc942ff6d26317cee145", size = 17394184, upload-time = "2025-10-22T03:47:08.127Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "onnxruntime"
|
||||
version = "1.24.3"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
resolution-markers = [
|
||||
"python_full_version >= '3.14' and sys_platform == 'win32'",
|
||||
"python_full_version >= '3.14' and sys_platform != 'win32'",
|
||||
"python_full_version == '3.13.*' and sys_platform == 'win32'",
|
||||
"python_full_version == '3.13.*' and sys_platform != 'win32'",
|
||||
"python_full_version == '3.12.*' and sys_platform == 'win32'",
|
||||
"python_full_version == '3.12.*' and sys_platform != 'win32'",
|
||||
"python_full_version == '3.11.*' and sys_platform == 'win32'",
|
||||
"python_full_version == '3.11.*' and sys_platform != 'win32'",
|
||||
]
|
||||
dependencies = [
|
||||
{ name = "flatbuffers" },
|
||||
{ name = "numpy", version = "2.2.6", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" },
|
||||
{ name = "numpy", version = "2.4.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" },
|
||||
{ name = "numpy", version = "2.4.3", source = { registry = "https://pypi.org/simple" } },
|
||||
{ name = "packaging" },
|
||||
{ name = "protobuf" },
|
||||
{ name = "sympy" },
|
||||
@@ -5779,6 +5856,15 @@ wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/bd/24/12818598c362d7f300f18e74db45963dbcb85150324092410c8b49405e42/pyproject_hooks-1.2.0-py3-none-any.whl", hash = "sha256:9e5c6bfa8dcc30091c74b0cf803c81fdd29d94f01992a7707bc97babb1141913", size = 10216, upload-time = "2024-09-29T09:24:11.978Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pyreadline3"
|
||||
version = "3.5.6"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/b6/6d/f94028646d7bbe6d9d873c47ee7c246f2d29129d253f0d96cb6fcab70733/pyreadline3-3.5.6.tar.gz", hash = "sha256:61e53218b99656091ddb077df9e71f25850e72e030b6183b39c9b7e6e4f4a9bf", size = 100368, upload-time = "2026-05-14T17:55:04.471Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/f7/5e/35c856e186b74678c24927847ad9895a51f1bc02a0c6126477a6c6040064/pyreadline3-3.5.6-py3-none-any.whl", hash = "sha256:8449b734232e42a5dcd74048e39b60db2839a4c38cf3ae2bf7707d58b5389c0d", size = 85243, upload-time = "2026-05-14T17:55:03.262Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pytest"
|
||||
version = "9.0.2"
|
||||
|
||||
Reference in new issue
Block a user