mirror of
https://github.com/MervinPraison/PraisonAIUI.git
synced 2026-11-12 11:51:35 -10:00
Compare 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.
|
||||
+54
-1
@@ -73,6 +73,47 @@ aiui.table(
|
||||
|-----------|------|----------|-------------|
|
||||
| `headers` | `Sequence[str]` | ✅ | Column header labels |
|
||||
| `rows` | `Sequence[Sequence[Any]]` | ✅ | Row data arrays |
|
||||
| `row_ids` | `Sequence[str]` | — | Stable unique ID per row. Makes rows selectable by click, Enter or Space |
|
||||
| `actions` | `Sequence[str]` | — | Row action button labels (requires `row_ids`) |
|
||||
|
||||
With `row_ids`, the dashboard renderer emits bubbling DOM events from the table, so
|
||||
a custom view can own query state (search, filters, page, sort) and react to rows:
|
||||
|
||||
| Event | `detail` |
|
||||
|-------|----------|
|
||||
| `aiui:table-select` | `{rowId, index}` |
|
||||
| `aiui:table-action` | `{action, rowId}` |
|
||||
|
||||
Because events carry the stable `rowId`, selection stays correct after the server
|
||||
re-sorts or filters rows. Loading, empty and error states compose from existing
|
||||
components (`aiui.skeleton()`, `aiui.empty()`, `aiui.alert()`):
|
||||
|
||||
```js
|
||||
// Custom view: server-paged, searchable table using only documented APIs.
|
||||
window.aiui.registerView('companies', (container) => {
|
||||
const state = { q: '', page: 1 };
|
||||
const show = (comps) => { container.replaceChildren(...comps.map(window.aiui.renderComponent)); };
|
||||
async function load() {
|
||||
show([{ type: 'skeleton', variant: 'card' }]);
|
||||
try {
|
||||
const res = await fetch(`/api/companies?q=${encodeURIComponent(state.q)}&page=${state.page}`);
|
||||
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
||||
const { rows, total } = await res.json(); // [{id, name, status}], count
|
||||
if (!rows.length) return show([{ type: 'empty', text: 'No results' }]);
|
||||
show([{ type: 'table', headers: ['Name', 'Status'],
|
||||
rows: rows.map(r => [r.name, r.status]),
|
||||
row_ids: rows.map(r => r.id), actions: ['Edit'] },
|
||||
{ type: 'caption', text: `${total} companies` }]);
|
||||
} catch (e) {
|
||||
show([{ type: 'alert', variant: 'error', message: 'Could not load companies. Try again.' }]);
|
||||
}
|
||||
}
|
||||
container.addEventListener('aiui:table-select', (e) => openDetail(e.detail.rowId));
|
||||
container.addEventListener('aiui:table-action', (e) => editRow(e.detail.rowId));
|
||||
// On search/filter change: state.q = value; state.page = 1; load();
|
||||
load();
|
||||
});
|
||||
```
|
||||
|
||||
### `aiui.text(content)`
|
||||
|
||||
@@ -705,7 +746,7 @@ aiui.breadcrumb([
|
||||
|-----------|------|----------|-------------|
|
||||
| `items` | `Sequence[dict]` | ✅ | List of `{"label": str, "href": str\|None}` |
|
||||
|
||||
### `aiui.pagination(*, total, page, per_page)`
|
||||
### `aiui.pagination(*, total, page, per_page, id)`
|
||||
|
||||
Pagination controls.
|
||||
|
||||
@@ -718,6 +759,18 @@ aiui.pagination(total=250, page=3, per_page=25)
|
||||
| `total` | `int` | ✅ | — | Total items |
|
||||
| `page` | `int` | ❌ | `1` | Current page (1-based) |
|
||||
| `per_page` | `int` | ❌ | `10` | Items per page |
|
||||
| `id` | `str` | ❌ | `None` | Identifier echoed in the page-change event |
|
||||
|
||||
The control is optimistic: clicking ←, →, or a page number updates the active
|
||||
page and dispatches a bubbling `aiui:pagination` DOM event. Buttons that would
|
||||
go out of bounds are disabled and emit nothing. `page` is clamped to the valid range.
|
||||
|
||||
```javascript
|
||||
container.addEventListener('aiui:pagination', (e) => {
|
||||
const { id, page, per_page, total } = e.detail;
|
||||
renderRows(rows.slice((page - 1) * per_page, page * per_page)); // or fetch()
|
||||
});
|
||||
```
|
||||
|
||||
### `aiui.key_value_list(items, *, title)`
|
||||
|
||||
|
||||
+9
-1
@@ -208,7 +208,7 @@ aiui.set_chat_features(
|
||||
|
||||
---
|
||||
|
||||
### `aiui.set_dashboard(*, sidebar, page_header)`
|
||||
### `aiui.set_dashboard(*, sidebar, page_header, modules, plugin_dirs, plugins)`
|
||||
|
||||
Configure dashboard layout options.
|
||||
|
||||
@@ -216,6 +216,9 @@ Configure dashboard layout options.
|
||||
|-----------|------|---------|-------------|
|
||||
| `sidebar` | `bool` | `True` | Show the left sidebar navigation |
|
||||
| `page_header` | `bool` | `True` | Show the page title/description header |
|
||||
| `modules` | `list[str] \| None` | `None` | Optional dashboard JS modules (`jobs`, `auth`, `api`) |
|
||||
| `plugin_dirs` | `list[str] \| None` | `None` | Extra directories to scan for dashboard plugins |
|
||||
| `plugins` | `list[str] \| None` | `None` | Allowlist of dashboard plugin names. `None` = all discovered (bundled, `~/.praisonai/dashboard-plugins`, `plugin_dirs`); `[]` = none |
|
||||
|
||||
```python
|
||||
import praisonaiui as aiui
|
||||
@@ -223,8 +226,13 @@ import praisonaiui as aiui
|
||||
aiui.set_style("dashboard")
|
||||
aiui.set_dashboard(sidebar=False) # Chat-only dashboard, no sidebar
|
||||
aiui.set_dashboard(page_header=False) # Hide page titles
|
||||
aiui.set_dashboard(plugins=[]) # No auto-discovered dashboard plugins
|
||||
```
|
||||
|
||||
`plugins` filters both the sidebar pages (`/api/pages`) and the browser plugin
|
||||
manifest (`/api/dashboard/plugins`), so disabled plugin scripts are never loaded.
|
||||
It scopes navigation only — it is **not** an authorization boundary for API routes.
|
||||
|
||||
---
|
||||
|
||||
### `aiui.set_chat_preview(enabled, *, surface_id, width)`
|
||||
|
||||
@@ -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.
|
||||
@@ -259,19 +259,31 @@ from starlette.responses import JSONResponse
|
||||
|
||||
class MyFeature(BaseFeatureProtocol):
|
||||
name = "my-feature"
|
||||
feature_name = "my-feature"
|
||||
description = "Example feature with GET and PATCH routes"
|
||||
|
||||
def routes(self):
|
||||
return [
|
||||
Route("/api/my-feature", self._handler, methods=["GET"]),
|
||||
Route("/api/my-feature", self._get, methods=["GET"]),
|
||||
Route("/api/my-feature/{item_id}", self._patch, methods=["PATCH"]),
|
||||
]
|
||||
|
||||
async def _handler(self, request):
|
||||
return JSONResponse({"data": "hello"})
|
||||
async def _get(self, request):
|
||||
# Query parameters: GET /api/my-feature?q=hello
|
||||
return JSONResponse({"data": request.query_params.get("q", "hello")})
|
||||
|
||||
async def _patch(self, request):
|
||||
# Path parameter + JSON body: PATCH /api/my-feature/42
|
||||
body = await request.json()
|
||||
return JSONResponse({"ok": True, "id": request.path_params["item_id"], **body})
|
||||
|
||||
# Register before the app is created — create_app() mounts feature
|
||||
# routes ahead of the SPA fallback, so no route reordering is needed.
|
||||
register_feature(MyFeature())
|
||||
```
|
||||
|
||||
`name` and `description` are abstract on `BaseFeatureProtocol`; omitting
|
||||
either raises `TypeError: Can't instantiate abstract class`.
|
||||
|
||||
See [Protocol Architecture](protocol-architecture.md).
|
||||
|
||||
---
|
||||
|
||||
@@ -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.
|
||||
@@ -36,7 +36,7 @@ curl http://localhost:8082/health/live
|
||||
|
||||
### Readiness Check: `/health/ready`
|
||||
|
||||
Verifies the server is ready to handle requests. Performs parallel health checks on all features with timeout protection.
|
||||
Verifies the server is ready to handle requests. Performs parallel health checks on all features with timeout protection. The provider check runs concurrently and is bounded by the same 0.5s timeout, so a slow provider cannot hang the response (or a cold `/api/health`).
|
||||
|
||||
**Use for:**
|
||||
- Kubernetes readiness probes
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
# Example 28 — Full Extensibility Showcase
|
||||
# Example 29 — Full Extensibility Showcase
|
||||
|
||||
Demonstrates **every** extension point in PraisonAIUI.
|
||||
|
||||
@@ -45,6 +45,9 @@ The `timeline` component and the `custom-view` page are client-side extensions.
|
||||
> **Note:** `aiui.set_custom_js(path)` injects client-side JS from a file path at page load.
|
||||
> See `docs/features/how-to-add-a-feature.md` for the extension API contract.
|
||||
|
||||
> **Tip:** if a custom view renders its own heading, call
|
||||
> `aiui.set_dashboard(page_header=False)` to hide the default page title/description header.
|
||||
|
||||
## Testing the form action
|
||||
|
||||
1. Navigate to **Contact Form**
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
"""Example 28 — Full Extensibility Showcase.
|
||||
"""Example 29 — Full Extensibility Showcase.
|
||||
|
||||
Demonstrates EVERY extension point in PraisonAIUI:
|
||||
|
||||
@@ -203,10 +203,9 @@ async def custom_view_fallback():
|
||||
aiui.callout(
|
||||
title="Plugin not loaded",
|
||||
content=(
|
||||
"This page is meant to be rendered client-side. "
|
||||
"Open DevTools → Console and paste the contents of "
|
||||
"plugin.js (in this example folder) to "
|
||||
"activate the custom view."
|
||||
"This page is meant to be rendered client-side by "
|
||||
"plugin.js, which app.py loads via aiui.set_custom_js(). "
|
||||
"If you see this, check that /custom.js loaded."
|
||||
),
|
||||
variant="warning",
|
||||
),
|
||||
|
||||
@@ -5,9 +5,11 @@
|
||||
* 1. window.aiui.registerComponent(type, renderFn) — adds a new component type
|
||||
* 2. window.aiui.registerView(pageId, render, cleanup) — adds a custom page view
|
||||
*
|
||||
* To load this file, paste its contents into the browser DevTools Console
|
||||
* after the page loads. (A Python-side API to inject custom JS is a planned
|
||||
* enhancement — see 28-full-extensibility/README.md.)
|
||||
* Loaded automatically by app.py, which serves it at /custom.js:
|
||||
*
|
||||
* aiui.set_custom_js(Path(__file__).with_name("plugin.js"))
|
||||
*
|
||||
* See 29-full-extensibility/README.md.
|
||||
*/
|
||||
|
||||
(function () {
|
||||
|
||||
@@ -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.146"
|
||||
version = "0.3.153"
|
||||
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.146"
|
||||
__version__ = "0.3.153"
|
||||
@@ -32,8 +32,18 @@ def _plugin_search_dirs(extra_dirs: list[str] | None = None) -> list[Path]:
|
||||
return dirs
|
||||
|
||||
|
||||
def discover_dashboard_plugins(extra_dirs: list[str] | None = None) -> list[dict[str, Any]]:
|
||||
"""Scan dashboard-plugins folders for manifest.json files."""
|
||||
def discover_dashboard_plugins(
|
||||
extra_dirs: list[str] | None = None,
|
||||
allowed: list[str] | None = None,
|
||||
) -> list[dict[str, Any]]:
|
||||
"""Scan dashboard-plugins folders for manifest.json files.
|
||||
|
||||
``allowed`` restricts results to the given plugin names; ``None`` keeps
|
||||
every discovered plugin and ``[]`` disables all of them.
|
||||
"""
|
||||
if allowed is not None and not allowed:
|
||||
return []
|
||||
allow = set(allowed) if allowed is not None else None
|
||||
manifests: list[dict[str, Any]] = []
|
||||
seen: set[str] = set()
|
||||
|
||||
@@ -53,6 +63,8 @@ def discover_dashboard_plugins(extra_dirs: list[str] | None = None) -> list[dict
|
||||
if name in seen:
|
||||
continue
|
||||
seen.add(name)
|
||||
if allow is not None and name not in allow:
|
||||
continue
|
||||
tab = data.get("tab") or {}
|
||||
path = tab.get("path") or f"/{name}"
|
||||
page_id = path.strip("/").split("/")[0] or name
|
||||
@@ -80,8 +92,13 @@ def discover_dashboard_plugins(extra_dirs: list[str] | None = None) -> list[dict
|
||||
return manifests
|
||||
|
||||
|
||||
def _resolve_plugin_file(name: str, subpath: str, extra_dirs: list[str] | None) -> Path | None:
|
||||
for manifest in discover_dashboard_plugins(extra_dirs):
|
||||
def _resolve_plugin_file(
|
||||
name: str,
|
||||
subpath: str,
|
||||
extra_dirs: list[str] | None,
|
||||
allowed: list[str] | None = None,
|
||||
) -> Path | None:
|
||||
for manifest in discover_dashboard_plugins(extra_dirs, allowed):
|
||||
if manifest["name"] != name:
|
||||
continue
|
||||
root = Path(manifest["root"])
|
||||
@@ -95,18 +112,25 @@ def _resolve_plugin_file(name: str, subpath: str, extra_dirs: list[str] | None)
|
||||
|
||||
def make_dashboard_plugin_routes(
|
||||
get_extra_dirs,
|
||||
get_allowed=None,
|
||||
) -> list[Route]:
|
||||
"""Factory: ``get_extra_dirs`` returns plugin dir list from server config."""
|
||||
"""Factory: ``get_extra_dirs`` returns plugin dir list from server config.
|
||||
|
||||
``get_allowed`` optionally returns the plugin-name allowlist (``None`` = all).
|
||||
"""
|
||||
|
||||
def _allowed() -> list[str] | None:
|
||||
return get_allowed() if get_allowed else None
|
||||
|
||||
async def list_plugins(request: Request) -> JSONResponse:
|
||||
extra = get_extra_dirs()
|
||||
return JSONResponse({"plugins": discover_dashboard_plugins(extra)})
|
||||
return JSONResponse({"plugins": discover_dashboard_plugins(extra, _allowed())})
|
||||
|
||||
async def serve_plugin_asset(request: Request) -> Response:
|
||||
name = request.path_params["name"]
|
||||
subpath = request.path_params["path"]
|
||||
extra = get_extra_dirs()
|
||||
file_path = _resolve_plugin_file(name, subpath, extra)
|
||||
file_path = _resolve_plugin_file(name, subpath, extra, _allowed())
|
||||
if not file_path:
|
||||
return Response(status_code=404)
|
||||
media = "text/javascript"
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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()
|
||||
+65
-13
@@ -276,9 +276,11 @@ def set_custom_js(path: str | Path) -> None:
|
||||
|
||||
Reads the file at ``path`` and serves it at ``/custom.js``. A
|
||||
``<script src="/custom.js">`` tag is injected into the host HTML
|
||||
*after* the plugin loader, so ``window.aiui`` and all registry APIs
|
||||
(``registerView``, ``registerComponent``) are ready when your code
|
||||
runs.
|
||||
*after* the plugin loader. ``window.aiui`` registry APIs
|
||||
(``registerView``, ``registerComponent``, ``registerSlot``,
|
||||
``registerSurfaceRenderer``) can be called directly at load time:
|
||||
calls made before the dashboard module is ready are queued and
|
||||
replayed, and a view registered for the active page re-renders it.
|
||||
|
||||
Use this to ship client-side extensions from your Python app without
|
||||
modifying the package bundle.
|
||||
@@ -373,6 +375,7 @@ def set_dashboard(
|
||||
page_header: bool = True,
|
||||
modules: list[str] | None = None,
|
||||
plugin_dirs: list[str] | None = None,
|
||||
plugins: list[str] | None = None,
|
||||
) -> None:
|
||||
"""Configure dashboard layout options.
|
||||
|
||||
@@ -384,6 +387,11 @@ def set_dashboard(
|
||||
page_header: Show the page title/description header (default True).
|
||||
modules: Optional JS modules to load (``jobs``, ``auth``, ``api``).
|
||||
plugin_dirs: Extra paths to scan for ``dashboard-plugins`` manifests.
|
||||
plugins: Allowlist of dashboard plugin names to enable. ``None``
|
||||
(default) enables every discovered plugin (bundled, home and
|
||||
``plugin_dirs``); ``[]`` disables all. Applies to both the sidebar
|
||||
pages and the browser plugin manifest. This scopes navigation only;
|
||||
it is not an authorization boundary for API routes.
|
||||
|
||||
Example::
|
||||
|
||||
@@ -399,6 +407,14 @@ def set_dashboard(
|
||||
"modules": list(modules) if modules else [],
|
||||
"pluginDirs": list(plugin_dirs) if plugin_dirs else [],
|
||||
}
|
||||
if plugins is not None:
|
||||
_dashboard_config["plugins"] = list(plugins)
|
||||
|
||||
|
||||
def _dashboard_plugin_allowlist() -> list[str] | None:
|
||||
"""Plugin-name allowlist from ``set_dashboard(plugins=...)`` (``None`` = all)."""
|
||||
allowed = (_dashboard_config or {}).get("plugins")
|
||||
return list(allowed) if allowed is not None else None
|
||||
|
||||
|
||||
_jobs_api_config: dict[str, str] = {"apiBase": "/api/jobs", "backend": "aiui"}
|
||||
@@ -792,7 +808,18 @@ def _custom_js_tag(cache_bust: int) -> str:
|
||||
"""Return a <script src="/custom.js"> tag if set_custom_js() was called."""
|
||||
if _custom_js is None:
|
||||
return ""
|
||||
return f'<script src="/custom.js?v={cache_bust}"></script>'
|
||||
return _CUSTOM_JS_QUEUE + f'<script src="/custom.js?v={cache_bust}"></script>'
|
||||
|
||||
|
||||
# Registration stubs for /custom.js: the dashboard module loads
|
||||
# asynchronously, so until it defines the real registry APIs these stubs
|
||||
# queue calls on ``window.aiui.__queue``; dashboard.js replays them.
|
||||
_CUSTOM_JS_QUEUE = (
|
||||
"<script>(function(a){a.__queue=a.__queue||[];"
|
||||
"['registerView','registerComponent','registerSlot','registerSurfaceRenderer']"
|
||||
".forEach(function(n){if(!a[n])a[n]=function(){a.__queue.push([n,arguments]);};});"
|
||||
"})(window.aiui=window.aiui||{});</script>"
|
||||
)
|
||||
|
||||
|
||||
async def _serve_custom_js(request: Request) -> Response:
|
||||
@@ -1081,11 +1108,18 @@ async def health_ready(request: Request) -> JSONResponse:
|
||||
|
||||
provider = get_provider()
|
||||
provider_info = {"name": type(provider).__name__}
|
||||
try:
|
||||
provider_health = await provider.health()
|
||||
provider_info.update(provider_health)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
async def _safe_provider() -> Dict[str, Any]:
|
||||
# Bounded like feature checks so a slow provider cannot hang readiness
|
||||
# (or the cold /api/health dashboard request that delegates here).
|
||||
try:
|
||||
result = await asyncio.wait_for(provider.health(), timeout=0.5)
|
||||
except Exception:
|
||||
return {}
|
||||
return result if isinstance(result, dict) else {}
|
||||
|
||||
# Start the provider check now so it runs concurrently with feature checks.
|
||||
provider_task = asyncio.ensure_future(_safe_provider())
|
||||
|
||||
features_health = {}
|
||||
sdk_gaps: list = []
|
||||
@@ -1124,6 +1158,8 @@ async def health_ready(request: Request) -> JSONResponse:
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
provider_info.update(await provider_task)
|
||||
|
||||
payload: Dict[str, Any] = {
|
||||
"status": "ok",
|
||||
"timestamp": datetime.utcnow().isoformat(),
|
||||
@@ -1303,9 +1339,11 @@ async def patch_session(request: Request) -> JSONResponse:
|
||||
body = await request.json()
|
||||
except Exception:
|
||||
return JSONResponse({"error": "Invalid JSON"}, status_code=400)
|
||||
title = body.get("title")
|
||||
title = body.get("title") if isinstance(body, dict) else None
|
||||
if title is not None:
|
||||
await _datastore.update_session(session_id, title=title)
|
||||
if not isinstance(title, str) or not title.strip():
|
||||
return JSONResponse({"error": "title must be a non-empty string"}, status_code=400)
|
||||
await _datastore.update_session(session_id, title=title.strip()[:200])
|
||||
return JSONResponse({"status": "updated", "session_id": session_id})
|
||||
|
||||
|
||||
@@ -1723,7 +1761,7 @@ async def api_pages(request: Request) -> JSONResponse:
|
||||
pages = list(_pages.values())
|
||||
extra_dirs = (_dashboard_config or {}).get("pluginDirs") or []
|
||||
known_ids = {p["id"] for p in pages}
|
||||
for manifest in discover_dashboard_plugins(extra_dirs):
|
||||
for manifest in discover_dashboard_plugins(extra_dirs, _dashboard_plugin_allowlist()):
|
||||
page = manifest.get("page")
|
||||
if page and page["id"] not in known_ids:
|
||||
pages.append(page)
|
||||
@@ -3191,7 +3229,7 @@ def create_app(
|
||||
cfg = _dashboard_config or {}
|
||||
return list(cfg.get("pluginDirs") or cfg.get("plugin_dirs") or [])
|
||||
|
||||
routes.extend(make_dashboard_plugin_routes(_dashboard_plugin_dirs))
|
||||
routes.extend(make_dashboard_plugin_routes(_dashboard_plugin_dirs, _dashboard_plugin_allowlist))
|
||||
|
||||
# ── Gap S1: Auto-initialize gateway in standalone mode ──────────
|
||||
# When running standalone (not via AIUIGateway.start()), we need to
|
||||
@@ -3280,6 +3318,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 +3336,7 @@ def create_app(
|
||||
"group": "Agent",
|
||||
"description": "AI agent chat",
|
||||
"order": 1,
|
||||
"layout": "full-bleed",
|
||||
},
|
||||
{
|
||||
"id": "chat-canvas",
|
||||
@@ -3304,6 +3345,7 @@ def create_app(
|
||||
"group": "Agent",
|
||||
"description": "Chat with live A2UI preview panel",
|
||||
"order": 2,
|
||||
"layout": "full-bleed",
|
||||
},
|
||||
{
|
||||
"id": "canvas",
|
||||
@@ -3345,6 +3387,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 +3633,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',
|
||||
@@ -240,6 +241,10 @@ const aiuiSdk = {
|
||||
window.aiui = window.aiui || {};
|
||||
window.aiui.registerView = function(pageId, renderFn, cleanupFn) {
|
||||
VIEW_REGISTRY[pageId] = { render: renderFn, cleanup: cleanupFn || null };
|
||||
// Late registration for the page already on screen: re-render it.
|
||||
if (pageId === activePageId && document.getElementById('db-main-content')) {
|
||||
selectPage(pageId);
|
||||
}
|
||||
};
|
||||
window.aiui.views = VIEW_REGISTRY;
|
||||
window.aiui.registerComponent = function(type, renderFn) {
|
||||
@@ -290,6 +295,14 @@ const DASHBOARD_STYLE = `
|
||||
|
||||
/* Root becomes the flex container */
|
||||
#root { display: flex; min-height: 100vh; }
|
||||
/* With a shell:header slot: sidebar | (header row sized to content, page panel below). */
|
||||
#root.db-active:has(> #db-shell-header-slot) {
|
||||
display: grid; grid-template-columns: auto minmax(0, 1fr); grid-template-rows: auto minmax(0, 1fr);
|
||||
height: 100vh; min-height: 0;
|
||||
}
|
||||
#root.db-active:has(> #db-shell-header-slot) > .db-sidebar { grid-column: 1; grid-row: 1 / -1; }
|
||||
#db-shell-header-slot { grid-column: 2; grid-row: 1; min-width: 0; padding: 8px 40px 0; }
|
||||
#root.db-active:has(> #db-shell-header-slot) > .db-main { grid-column: 2; grid-row: 2; height: auto; min-height: 0; }
|
||||
|
||||
/* Sidebar */
|
||||
.db-sidebar {
|
||||
@@ -366,7 +379,9 @@ const DASHBOARD_STYLE = `
|
||||
border-left: 3px solid transparent;
|
||||
text-decoration: none;
|
||||
}
|
||||
button.db-nav-item { width: 100%; background: none; border-top: 0; border-right: 0; border-bottom: 0; font: inherit; text-align: left; }
|
||||
.db-nav-item:hover { background: var(--db-hover); color: var(--db-text); }
|
||||
.db-nav-item:focus-visible { outline: 2px solid var(--db-accent); outline-offset: -2px; }
|
||||
.db-nav-item.active {
|
||||
color: var(--db-accent); background: var(--db-accent-glow);
|
||||
border-left-color: var(--db-accent); font-weight: 500;
|
||||
@@ -377,8 +392,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 +476,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; }
|
||||
.db-sidebar.db-sidebar-open { transform: translateX(0); }
|
||||
/* !important: buildSidebar() sets an inline position:relative for its toggle,
|
||||
which otherwise keeps the hidden sidebar's 260px in the flex row. */
|
||||
/* visibility keeps links in the closed drawer out of the tab order. */
|
||||
.db-sidebar { position: fixed !important; top: 0; left: 0; z-index: 45; transform: translateX(-100%); visibility: hidden; transition: transform 0.2s ease, visibility 0s linear 0.2s; }
|
||||
.db-sidebar.db-sidebar-open { transform: translateX(0); visibility: visible; transition: transform 0.2s ease; }
|
||||
.db-mobile-overlay.db-visible { display: block; }
|
||||
.db-main { padding-top: 48px; }
|
||||
/* Sidebar is fixed here; the header row sits below the fixed mobile bar. */
|
||||
#db-shell-header-slot { margin-top: 48px; padding: 8px 16px 0; }
|
||||
#db-shell-header-slot + .db-main { padding-top: 16px; }
|
||||
}
|
||||
@media (max-width: 600px) {
|
||||
.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 +1781,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 +1804,55 @@ 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;
|
||||
}
|
||||
|
||||
// Sidebar entry: a real link for page routes (keyboard reachable, open-in-new-tab
|
||||
// works) or a button for actions. Plain left clicks stay in the SPA.
|
||||
function navEntry(page, fallbackIcon, onAction) {
|
||||
const el = document.createElement(onAction ? 'button' : 'a');
|
||||
el.className = 'db-nav-item';
|
||||
if (onAction) {
|
||||
el.type = 'button';
|
||||
el.addEventListener('click', onAction);
|
||||
} else {
|
||||
el.dataset.navId = page.id;
|
||||
el.href = '/' + encodeURIComponent(page.id);
|
||||
el.addEventListener('click', (e) => {
|
||||
if (e.button !== 0 || e.metaKey || e.ctrlKey || e.shiftKey || e.altKey) return;
|
||||
e.preventDefault();
|
||||
selectPage(page.id);
|
||||
});
|
||||
}
|
||||
const iconSpan = document.createElement('span');
|
||||
iconSpan.className = 'db-nav-icon';
|
||||
iconSpan.setAttribute('aria-hidden', 'true');
|
||||
setPageIcon(iconSpan, page, fallbackIcon);
|
||||
el.appendChild(iconSpan);
|
||||
el.appendChild(document.createTextNode(' ' + page.title));
|
||||
return el;
|
||||
}
|
||||
|
||||
function buildSidebar(pages) {
|
||||
const sidebar = document.createElement('nav');
|
||||
sidebar.className = 'db-sidebar';
|
||||
@@ -1860,18 +1945,7 @@ function buildSidebar(pages) {
|
||||
label.textContent = groupName;
|
||||
sidebar.appendChild(label);
|
||||
|
||||
groupPages.forEach(page => {
|
||||
const item = document.createElement('div');
|
||||
item.className = 'db-nav-item';
|
||||
item.dataset.navId = page.id;
|
||||
const iconSpan = document.createElement('span');
|
||||
iconSpan.className = 'db-nav-icon';
|
||||
iconSpan.textContent = page.icon || '📄';
|
||||
item.appendChild(iconSpan);
|
||||
item.appendChild(document.createTextNode(' ' + page.title));
|
||||
item.addEventListener('click', () => selectPage(page.id));
|
||||
sidebar.appendChild(item);
|
||||
});
|
||||
groupPages.forEach(page => sidebar.appendChild(navEntry(page, '📄')));
|
||||
}
|
||||
|
||||
// Footer pages — render any page with position='footer' at the sidebar bottom
|
||||
@@ -1884,21 +1958,8 @@ function buildSidebar(pages) {
|
||||
sidebar.appendChild(spacer);
|
||||
|
||||
for (const page of footerPages) {
|
||||
const btn = document.createElement('div');
|
||||
btn.className = 'db-nav-item';
|
||||
const iconSpan = document.createElement('span');
|
||||
iconSpan.className = 'db-nav-icon';
|
||||
iconSpan.textContent = page.icon || '⚙';
|
||||
btn.appendChild(iconSpan);
|
||||
btn.appendChild(document.createTextNode(' ' + page.title));
|
||||
btn.addEventListener('click', () => {
|
||||
// Use built-in handler if available, otherwise navigate to page
|
||||
if (page.id === 'inspector') {
|
||||
showTestPanel();
|
||||
} else {
|
||||
selectPage(page.id);
|
||||
}
|
||||
});
|
||||
// Inspector opens a built-in panel (an action); others are page links
|
||||
const btn = navEntry(page, '⚙', page.id === 'inspector' ? showTestPanel : null);
|
||||
btn.style.borderTop = '1px solid var(--db-border)';
|
||||
btn.style.marginTop = '8px';
|
||||
sidebar.appendChild(btn);
|
||||
@@ -1922,6 +1983,12 @@ function showDashboardInitError(message) {
|
||||
banner.textContent = message;
|
||||
}
|
||||
|
||||
// A render superseded by a newer selectPage never became active, so run its
|
||||
// own cleanup to release intervals/listeners it set up.
|
||||
function _supersededCleanup(fn) {
|
||||
if (fn) { try { fn(); } catch (e) {} }
|
||||
}
|
||||
|
||||
async function selectPage(pageId) {
|
||||
// Cleanup previous view if it has a cleanup function
|
||||
if (_activeCleanup) { try { _activeCleanup(); } catch(e) {} _activeCleanup = null; }
|
||||
@@ -1934,9 +2001,11 @@ async function selectPage(pageId) {
|
||||
}
|
||||
|
||||
// Update nav active state
|
||||
document.querySelectorAll('.db-nav-item').forEach(el => el.classList.remove('active'));
|
||||
const active = document.querySelector(`[data-nav-id="${pageId}"]`);
|
||||
if (active) active.classList.add('active');
|
||||
document.querySelectorAll('.db-nav-item').forEach(el => {
|
||||
const on = el.dataset.navId === pageId;
|
||||
el.classList.toggle('active', on);
|
||||
if (on) el.setAttribute('aria-current', 'page'); else el.removeAttribute('aria-current');
|
||||
});
|
||||
|
||||
const page = pagesData.find(p => p.id === pageId);
|
||||
const main = document.getElementById('db-main-content');
|
||||
@@ -1950,14 +2019,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 +2038,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]) {
|
||||
@@ -1984,9 +2059,11 @@ async function selectPage(pageId) {
|
||||
|
||||
// Priority 1: Custom registered view
|
||||
if (VIEW_REGISTRY[pageId]) {
|
||||
const view = VIEW_REGISTRY[pageId];
|
||||
try {
|
||||
await VIEW_REGISTRY[pageId].render(container);
|
||||
_activeCleanup = VIEW_REGISTRY[pageId].cleanup || null;
|
||||
await view.render(container);
|
||||
if (!container.isConnected) { _supersededCleanup(view.cleanup); return; } // superseded by a newer selectPage
|
||||
_activeCleanup = view.cleanup || null;
|
||||
rendered = true;
|
||||
} catch (e) { console.warn(`View '${pageId}' render error:`, e); }
|
||||
}
|
||||
@@ -1997,17 +2074,21 @@ async function selectPage(pageId) {
|
||||
const viewUrl = BUILTIN_VIEWS[pageId] + '?v=' + Date.now();
|
||||
const mod = await import(viewUrl);
|
||||
if (mod.render) {
|
||||
if (!container.isConnected) return; // superseded by a newer selectPage
|
||||
await mod.render(container);
|
||||
if (!container.isConnected) { _supersededCleanup(mod.cleanup); return; }
|
||||
_activeCleanup = mod.cleanup || null;
|
||||
rendered = true;
|
||||
// Cache for future use
|
||||
VIEW_REGISTRY[pageId] = { render: mod.render, cleanup: mod.cleanup || null };
|
||||
// Cache for future use (never overwrite a view registered meanwhile)
|
||||
if (!VIEW_REGISTRY[pageId]) {
|
||||
VIEW_REGISTRY[pageId] = { render: mod.render, cleanup: mod.cleanup || null };
|
||||
}
|
||||
}
|
||||
} catch (e) { console.warn(`Built-in view '${pageId}' import error:`, e); }
|
||||
}
|
||||
|
||||
// Priority 3: Generic JSON viewer fallback
|
||||
if (!rendered) {
|
||||
if (!rendered && container.isConnected) {
|
||||
loadGenericViewer(page, container);
|
||||
}
|
||||
|
||||
@@ -2266,7 +2347,17 @@ function renderTable(comp) {
|
||||
table.appendChild(thead);
|
||||
}
|
||||
|
||||
rows.forEach(row => {
|
||||
// Opt-in row selection/actions (#336): only when stable row_ids are given.
|
||||
const rowIds = Array.isArray(comp.row_ids) && comp.row_ids.length === rows.length ? comp.row_ids : null;
|
||||
const actions = rowIds && Array.isArray(comp.actions) ? comp.actions : [];
|
||||
const emit = (name, detail) => wrapper.dispatchEvent(new CustomEvent(name, { detail, bubbles: true }));
|
||||
if (actions.length && headers.length) {
|
||||
const th = document.createElement('th');
|
||||
th.style.cssText = 'padding:8px 12px;border-bottom:1px solid var(--db-border);';
|
||||
table.firstChild.appendChild(th);
|
||||
}
|
||||
|
||||
rows.forEach((row, index) => {
|
||||
const tr = document.createElement('tr');
|
||||
row.forEach(cell => {
|
||||
const td = document.createElement('td');
|
||||
@@ -2274,6 +2365,43 @@ function renderTable(comp) {
|
||||
td.textContent = cell !== null && cell !== undefined ? cell : '';
|
||||
tr.appendChild(td);
|
||||
});
|
||||
if (rowIds) {
|
||||
const rowId = String(rowIds[index]);
|
||||
tr.dataset.rowId = rowId;
|
||||
tr.tabIndex = 0;
|
||||
tr.style.cursor = 'pointer';
|
||||
tr.setAttribute('aria-selected', 'false');
|
||||
const select = () => {
|
||||
table.querySelectorAll('tr[aria-selected="true"]').forEach(r => {
|
||||
r.setAttribute('aria-selected', 'false');
|
||||
r.style.background = '';
|
||||
});
|
||||
tr.setAttribute('aria-selected', 'true');
|
||||
tr.style.background = 'var(--db-hover)';
|
||||
emit('aiui:table-select', { rowId, index });
|
||||
};
|
||||
tr.addEventListener('click', select);
|
||||
tr.addEventListener('keydown', (e) => {
|
||||
if (e.target === tr && (e.key === 'Enter' || e.key === ' ')) { e.preventDefault(); select(); }
|
||||
});
|
||||
if (actions.length) {
|
||||
const td = document.createElement('td');
|
||||
td.style.cssText = 'padding:4px 12px;border-bottom:1px solid var(--db-border);text-align:right;white-space:nowrap;';
|
||||
actions.forEach(action => {
|
||||
const btn = document.createElement('button');
|
||||
btn.type = 'button';
|
||||
btn.className = 'db-btn';
|
||||
btn.textContent = action;
|
||||
btn.setAttribute('aria-label', `${action} ${row[0] ?? rowId}`);
|
||||
btn.addEventListener('click', (e) => {
|
||||
e.stopPropagation();
|
||||
emit('aiui:table-action', { action, rowId });
|
||||
});
|
||||
td.appendChild(btn);
|
||||
});
|
||||
tr.appendChild(td);
|
||||
}
|
||||
}
|
||||
table.appendChild(tr);
|
||||
});
|
||||
|
||||
@@ -2939,24 +3067,44 @@ function renderBreadcrumb(comp) {
|
||||
}
|
||||
|
||||
function renderPagination(comp) {
|
||||
const el = document.createElement('div');
|
||||
// Optimistic control: the active page updates locally, then a bubbling
|
||||
// `aiui:pagination` CustomEvent ({id, page, per_page, total}) notifies the
|
||||
// host so it can re-slice local rows or fetch the requested page.
|
||||
const el = document.createElement('nav');
|
||||
el.className = 'db-pagination';
|
||||
const total = comp.total || 0;
|
||||
const perPage = comp.per_page || 10;
|
||||
const totalPages = Math.ceil(total / perPage);
|
||||
let currentPage = comp.page || 1;
|
||||
el.setAttribute('aria-label', 'Pagination');
|
||||
if (comp.id) el.dataset.id = comp.id;
|
||||
const total = Math.max(0, comp.total || 0);
|
||||
const perPage = Math.max(1, comp.per_page || 10);
|
||||
const totalPages = Math.max(1, Math.ceil(total / perPage));
|
||||
let currentPage = Math.min(Math.max(1, comp.page || 1), totalPages);
|
||||
function go(page) {
|
||||
if (page < 1 || page > totalPages || page === currentPage) return;
|
||||
currentPage = page;
|
||||
render();
|
||||
el.dispatchEvent(new CustomEvent('aiui:pagination', {
|
||||
bubbles: true,
|
||||
detail: { id: comp.id || null, page, per_page: perPage, total },
|
||||
}));
|
||||
}
|
||||
function button(label, ariaLabel, page, disabled) {
|
||||
const btn = document.createElement('button');
|
||||
btn.type = 'button';
|
||||
btn.textContent = label;
|
||||
btn.setAttribute('aria-label', ariaLabel);
|
||||
btn.disabled = disabled;
|
||||
btn.addEventListener('click', () => go(page));
|
||||
return btn;
|
||||
}
|
||||
function render() {
|
||||
el.innerHTML = '';
|
||||
const prev = document.createElement('button');
|
||||
prev.textContent = '←';
|
||||
prev.disabled = currentPage <= 1;
|
||||
prev.addEventListener('click', () => { if (currentPage > 1) { currentPage--; render(); } });
|
||||
el.appendChild(prev);
|
||||
el.appendChild(button('←', 'Previous page', currentPage - 1, currentPage <= 1));
|
||||
for (let i = 1; i <= Math.min(totalPages, 7); i++) {
|
||||
const btn = document.createElement('button');
|
||||
btn.textContent = i;
|
||||
if (i === currentPage) btn.classList.add('active');
|
||||
btn.addEventListener('click', () => { currentPage = i; render(); });
|
||||
const btn = button(String(i), `Page ${i}`, i, false);
|
||||
if (i === currentPage) {
|
||||
btn.classList.add('active');
|
||||
btn.setAttribute('aria-current', 'page');
|
||||
}
|
||||
el.appendChild(btn);
|
||||
}
|
||||
if (totalPages > 7) {
|
||||
@@ -2965,11 +3113,7 @@ function renderPagination(comp) {
|
||||
dots.style.cssText = 'padding:0 4px;color:var(--db-text-dim)';
|
||||
el.appendChild(dots);
|
||||
}
|
||||
const next = document.createElement('button');
|
||||
next.textContent = '→';
|
||||
next.disabled = currentPage >= totalPages;
|
||||
next.addEventListener('click', () => { if (currentPage < totalPages) { currentPage++; render(); } });
|
||||
el.appendChild(next);
|
||||
el.appendChild(button('→', 'Next page', currentPage + 1, currentPage >= totalPages));
|
||||
}
|
||||
render();
|
||||
return el;
|
||||
@@ -3072,7 +3216,10 @@ async function showTestPanel() {
|
||||
if (location.pathname !== '/inspector') {
|
||||
history.pushState({ pageId: '__test__' }, '', '/inspector');
|
||||
}
|
||||
document.querySelectorAll('.db-nav-item').forEach(el => el.classList.remove('active'));
|
||||
document.querySelectorAll('.db-nav-item').forEach(el => {
|
||||
el.classList.remove('active');
|
||||
el.removeAttribute('aria-current');
|
||||
});
|
||||
const main = document.getElementById('db-main-content');
|
||||
if (!main) return;
|
||||
|
||||
@@ -3393,23 +3540,21 @@ window.aiui.registerSlot('shell:header', () => {
|
||||
window.aiui.registerSlot('shell:sidebar:footer', () => {
|
||||
const el = document.createElement('div');
|
||||
el.innerHTML = '<span class="db-health-dot" style="color:var(--db-text-dim)">●</span> <span class="db-health-label">Checking…</span>';
|
||||
fetch('/api/health').then((r) => r.json()).then((d) => {
|
||||
// Connection indicator uses side-effect-free liveness (#341). Deep
|
||||
// feature/SDK-gap diagnostics remain at /api/health (Debug view).
|
||||
fetch('/api/health/live').then((r) => r.json()).then((d) => {
|
||||
const ok = d && (d.status === 'ok' || d.status === 'healthy');
|
||||
const dot = el.querySelector('.db-health-dot');
|
||||
const label = el.querySelector('.db-health-label');
|
||||
const gaps = (d && d.sdk_gaps) || [];
|
||||
if (dot) dot.style.color = ok && !gaps.length ? '#22c55e' : (gaps.length ? '#f59e0b' : '#ef4444');
|
||||
if (label) {
|
||||
if (gaps.length) {
|
||||
label.textContent = `SDK gaps (${gaps.length})`;
|
||||
label.title = gaps.map((g) => `${g.feature}: ${g.message}`).join('\n');
|
||||
} else {
|
||||
label.textContent = ok ? (d.integrated ? 'Integrated' : 'Connected') : 'Degraded';
|
||||
label.title = '';
|
||||
}
|
||||
}
|
||||
if (dot) dot.style.color = ok ? '#22c55e' : '#ef4444';
|
||||
if (label) label.textContent = ok ? 'Connected' : 'Degraded';
|
||||
}).catch(() => { const label = el.querySelector('.db-health-label'); if (label) label.textContent = 'Offline'; });
|
||||
return el;
|
||||
});
|
||||
|
||||
// Replay registrations queued by the /custom.js stubs (see server.py).
|
||||
(window.aiui.__queue || []).splice(0).forEach(([name, args]) => {
|
||||
try { window.aiui[name](...args); } catch (e) { console.warn(`[AIUI] queued ${name} failed:`, e); }
|
||||
});
|
||||
|
||||
export { init, onContentChange };
|
||||
@@ -164,6 +164,20 @@ function renderProgressBar(percentage) {
|
||||
`;
|
||||
}
|
||||
|
||||
function escapeAttr(value) {
|
||||
return String(value).replace(/&/g, '&').replace(/"/g, '"').replace(/</g, '<').replace(/>/g, '>');
|
||||
}
|
||||
|
||||
// Navigate to Chat and load the job's linked session (same event the
|
||||
// session-search palette and run recorder use).
|
||||
function openJobSession(sessionId) {
|
||||
if (!sessionId) return;
|
||||
const nav = (typeof window.aiui?.selectPage === 'function') ? window.aiui.selectPage('chat') : null;
|
||||
Promise.resolve(nav).finally(() => {
|
||||
window.dispatchEvent(new CustomEvent('aiui:session-select', { detail: { sessionId } }));
|
||||
});
|
||||
}
|
||||
|
||||
function renderJobRow(job) {
|
||||
const status = job.status || 'queued';
|
||||
const isTerminal = ['succeeded', 'failed', 'cancelled'].includes(status);
|
||||
@@ -199,6 +213,7 @@ function renderJobRow(job) {
|
||||
` : `
|
||||
${_jobsCfg.backend !== 'praisonai' ? `<button class="aiui-btn aiui-btn-sm aiui-btn-delete" onclick="window.aiuiDeleteJob('${job.id}')" title="Delete">🗑️</button>` : ''}
|
||||
`}
|
||||
${job.session_id ? `<button class="aiui-btn aiui-btn-sm aiui-btn-open-session" data-session-id="${escapeAttr(job.session_id)}" onclick="window.aiuiOpenJobSession(this.dataset.sessionId)" title="Open session in Chat">💬</button>` : ''}
|
||||
<button class="aiui-btn aiui-btn-sm aiui-btn-view" onclick="window.aiuiViewJob('${job.id}')" title="View Details">
|
||||
👁️
|
||||
</button>
|
||||
@@ -599,6 +614,7 @@ function checkForJobsPage(root) {
|
||||
window.aiuiCancelJob = cancelJob;
|
||||
window.aiuiDeleteJob = deleteJob;
|
||||
window.aiuiViewJob = viewJob;
|
||||
window.aiuiOpenJobSession = openJobSession;
|
||||
|
||||
export default {
|
||||
name: 'jobs',
|
||||
|
||||
@@ -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
+33
-3
@@ -110,18 +110,38 @@ def table(
|
||||
*,
|
||||
headers: Sequence[str],
|
||||
rows: Sequence[Sequence[Any]],
|
||||
row_ids: Sequence[str] | None = None,
|
||||
actions: Sequence[str] | None = None,
|
||||
) -> dict:
|
||||
"""A data table.
|
||||
|
||||
Args:
|
||||
headers: Column header labels
|
||||
rows: List of row arrays
|
||||
row_ids: Optional stable ID per row. When given, rows are selectable
|
||||
(click, Enter or Space) and emit a bubbling ``aiui:table-select``
|
||||
DOM event with ``detail = {rowId, index}``.
|
||||
actions: Optional row action labels (requires ``row_ids``). Each row
|
||||
gets one button per action, emitting a bubbling
|
||||
``aiui:table-action`` DOM event with ``detail = {action, rowId}``.
|
||||
"""
|
||||
return {
|
||||
comp: dict[str, Any] = {
|
||||
"type": "table",
|
||||
"headers": list(headers),
|
||||
"rows": [list(r) for r in rows],
|
||||
}
|
||||
if row_ids is not None:
|
||||
ids = [str(i) for i in row_ids]
|
||||
if len(ids) != len(comp["rows"]):
|
||||
raise ValueError("row_ids must have one entry per row")
|
||||
if len(set(ids)) != len(ids):
|
||||
raise ValueError("row_ids must be unique")
|
||||
comp["row_ids"] = ids
|
||||
if actions:
|
||||
if row_ids is None:
|
||||
raise ValueError("actions require row_ids")
|
||||
comp["actions"] = [str(a) for a in actions]
|
||||
return comp
|
||||
|
||||
|
||||
def text(content: str) -> dict:
|
||||
@@ -829,15 +849,25 @@ def breadcrumb(items: Sequence[dict]) -> dict:
|
||||
return {"type": "breadcrumb", "items": list(items)}
|
||||
|
||||
|
||||
def pagination(*, total: int, page: int = 1, per_page: int = 10) -> dict:
|
||||
def pagination(
|
||||
*, total: int, page: int = 1, per_page: int = 10, id: str | None = None
|
||||
) -> dict:
|
||||
"""Pagination controls.
|
||||
|
||||
Clicking a page updates the control and dispatches a bubbling
|
||||
``aiui:pagination`` DOM event with ``{id, page, per_page, total}`` so
|
||||
the host can re-slice or fetch rows.
|
||||
|
||||
Args:
|
||||
total: Total number of items
|
||||
page: Current page number (1-based)
|
||||
per_page: Items per page
|
||||
id: Optional identifier echoed in the event to tell controls apart
|
||||
"""
|
||||
return {"type": "pagination", "total": total, "page": page, "per_page": per_page}
|
||||
comp: dict[str, Any] = {"type": "pagination", "total": total, "page": page, "per_page": per_page}
|
||||
if id is not None:
|
||||
comp["id"] = id
|
||||
return comp
|
||||
|
||||
|
||||
def definition_list(items: Sequence[dict], *, title: str | None = None) -> dict:
|
||||
|
||||
@@ -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,54 @@
|
||||
"""Regression tests for #322 — Chat must fill the dashboard main column.
|
||||
|
||||
Chat pages declare ``layout: "full-bleed"``; ``selectPage()`` then adds
|
||||
``db-fill`` to ``#db-main-content`` and skips the duplicate page header, and
|
||||
``.chat-root`` sizes from flex allocation instead of ``calc(100vh - 140px)``.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
from pathlib import Path
|
||||
|
||||
PLUGINS = (
|
||||
Path(__file__).resolve().parents[2]
|
||||
/ "src" / "praisonaiui" / "templates" / "frontend" / "plugins"
|
||||
)
|
||||
|
||||
|
||||
def _select_page_body() -> str:
|
||||
src = (PLUGINS / "dashboard.js").read_text(encoding="utf-8")
|
||||
m = re.search(r"async function selectPage\(pageId\)\s*\{(.*?)^}", src, re.DOTALL | re.MULTILINE)
|
||||
assert m
|
||||
return m.group(1)
|
||||
|
||||
|
||||
def test_chat_pages_declare_full_bleed():
|
||||
from starlette.testclient import TestClient
|
||||
|
||||
from praisonaiui.server import create_app, reset_state
|
||||
|
||||
reset_state() # isolate from page whitelists set by other tests
|
||||
pages = {p["id"]: p for p in TestClient(create_app()).get("/api/pages").json()["pages"]}
|
||||
for pid in ("chat", "chat-canvas"):
|
||||
assert pages[pid].get("layout") == "full-bleed"
|
||||
assert pages["overview"].get("layout") is None
|
||||
|
||||
|
||||
def test_select_page_toggles_fill_and_skips_header():
|
||||
body = _select_page_body()
|
||||
assert "page.layout === 'full-bleed'" in body
|
||||
assert "classList.toggle('db-fill'" in body
|
||||
assert re.search(r"showPageHeader = .*&& !fullBleed", body)
|
||||
|
||||
|
||||
def test_fill_css_defined():
|
||||
src = (PLUGINS / "dashboard.js").read_text(encoding="utf-8")
|
||||
assert ".db-main.db-fill > #db-page-content" in src
|
||||
|
||||
|
||||
def test_chat_root_fills_column_in_fill_mode():
|
||||
chat = (PLUGINS / "views" / "chat.js").read_text(encoding="utf-8")
|
||||
assert re.search(r"\.db-fill \.chat-root \{[^}]*flex:1;[^}]*min-height:0", chat)
|
||||
canvas = (PLUGINS / "views" / "chat-canvas.js").read_text(encoding="utf-8")
|
||||
assert ".db-fill .chat-canvas-root" in canvas
|
||||
@@ -0,0 +1,31 @@
|
||||
"""Tests for the in-rail session search in the chat view (issue #324).
|
||||
|
||||
The chat sidebar must expose a search input that filters the already
|
||||
fetched session list client-side, without refetching from the API.
|
||||
"""
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
CHAT = Path("src/praisonaiui/templates/frontend/plugins/views/chat.js")
|
||||
|
||||
|
||||
class TestChatSessionRailSearch:
|
||||
def test_search_input_rendered_in_rail(self):
|
||||
src = CHAT.read_text()
|
||||
assert 'id="chat-session-search"' in src
|
||||
assert src.index('id="chat-session-search"') < src.index('id="chat-sessions-list"')
|
||||
|
||||
def test_input_wired_to_client_side_render(self):
|
||||
src = CHAT.read_text()
|
||||
assert "sessionSearch.addEventListener('input', renderSessionList)" in src
|
||||
|
||||
def test_load_sessions_caches_and_delegates_render(self):
|
||||
src = CHAT.read_text()
|
||||
assert "allSessions = Array.isArray(fetched) ? fetched : [];" in src
|
||||
body = src.split("async function loadSessions()", 1)[1].split("\n}\n", 1)[0]
|
||||
assert "renderSessionList();" in body
|
||||
|
||||
def test_filter_matches_title_and_id_fields(self):
|
||||
src = CHAT.read_text()
|
||||
assert "function _sessionMatches(s, q)" in src
|
||||
assert "No matching sessions" in src
|
||||
@@ -0,0 +1,49 @@
|
||||
"""Issue #331 — rename a session from the chat rail via the existing PATCH route."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
from pathlib import Path
|
||||
|
||||
from starlette.applications import Starlette
|
||||
from starlette.routing import Route
|
||||
from starlette.testclient import TestClient
|
||||
|
||||
import praisonaiui.server as server
|
||||
from praisonaiui.datastore import MemoryDataStore
|
||||
|
||||
CHAT = Path("src/praisonaiui/templates/frontend/plugins/views/chat.js")
|
||||
|
||||
|
||||
def _client(monkeypatch):
|
||||
ds = MemoryDataStore()
|
||||
monkeypatch.setattr(server, "_datastore", ds)
|
||||
app = Starlette(routes=[Route("/sessions/{session_id}", server.patch_session, methods=["PATCH"])])
|
||||
sid = asyncio.run(ds.create_session())["id"]
|
||||
return TestClient(app), ds, sid
|
||||
|
||||
|
||||
def test_patch_renames_and_trims(monkeypatch):
|
||||
client, ds, sid = _client(monkeypatch)
|
||||
r = client.patch(f"/sessions/{sid}", json={"title": " My chat "})
|
||||
assert r.status_code == 200
|
||||
assert asyncio.run(ds.get_session(sid))["title"] == "My chat"
|
||||
|
||||
|
||||
def test_patch_rejects_blank_or_non_string_title(monkeypatch):
|
||||
client, _, sid = _client(monkeypatch)
|
||||
assert client.patch(f"/sessions/{sid}", json={"title": " "}).status_code == 400
|
||||
assert client.patch(f"/sessions/{sid}", json={"title": 123}).status_code == 400
|
||||
|
||||
|
||||
def test_patch_unknown_session_404(monkeypatch):
|
||||
client, _, _ = _client(monkeypatch)
|
||||
assert client.patch("/sessions/nope", json={"title": "x"}).status_code == 404
|
||||
|
||||
|
||||
def test_rail_has_rename_button_wired_to_patch():
|
||||
src = CHAT.read_text()
|
||||
assert "async function renameSession(sessionId, currentTitle)" in src
|
||||
assert "method: 'PATCH'" in src
|
||||
assert "renameSession(id, title)" in src
|
||||
assert "e.stopPropagation()" in src
|
||||
@@ -122,3 +122,46 @@ class TestCustomJsServing:
|
||||
"custom.js must be injected AFTER plugin-loader.js so window.aiui "
|
||||
"APIs are available when custom.js runs"
|
||||
)
|
||||
|
||||
|
||||
# ── Registration lifecycle (issue #338) ───────────────────────────
|
||||
|
||||
_DASHBOARD_JS = (
|
||||
Path(srv.__file__).parent / "templates" / "frontend" / "plugins" / "dashboard.js"
|
||||
)
|
||||
|
||||
|
||||
class TestCustomJsRegistrationLifecycle:
|
||||
def test_queue_stub_injected_before_custom_js(self, tmp_js):
|
||||
"""Registry stubs must exist before /custom.js runs (dashboard loads async)."""
|
||||
srv.set_custom_js(tmp_js)
|
||||
srv.set_style("dashboard")
|
||||
html = TestClient(srv.create_app()).get("/").text
|
||||
stub_idx = html.find("__queue")
|
||||
assert stub_idx != -1, "registration queue stub must be injected"
|
||||
assert stub_idx < html.find("/custom.js")
|
||||
for name in ("registerView", "registerComponent", "registerSlot",
|
||||
"registerSurfaceRenderer"):
|
||||
assert name in html[stub_idx:html.find("/custom.js")]
|
||||
|
||||
def test_no_queue_stub_when_unset(self, client):
|
||||
assert "__queue" not in client.get("/").text
|
||||
|
||||
def test_dashboard_replays_queued_registrations(self):
|
||||
src = _DASHBOARD_JS.read_text(encoding="utf-8")
|
||||
replay_idx = src.find("window.aiui.__queue")
|
||||
assert replay_idx != -1, "dashboard.js must replay queued registrations"
|
||||
# Replay must run after `let activePageId` is initialised (no TDZ error).
|
||||
assert replay_idx > src.find("let activePageId")
|
||||
|
||||
def test_late_register_view_rerenders_active_page(self):
|
||||
src = _DASHBOARD_JS.read_text(encoding="utf-8")
|
||||
start = src.find("window.aiui.registerView = function")
|
||||
body = src[start:src.find("\n};", start)]
|
||||
assert "activePageId" in body and "selectPage(pageId)" in body
|
||||
|
||||
def test_superseded_render_runs_its_cleanup(self):
|
||||
"""A render replaced by a newer selectPage must still be cleaned up."""
|
||||
src = _DASHBOARD_JS.read_text(encoding="utf-8")
|
||||
assert "_supersededCleanup(view.cleanup)" in src
|
||||
assert "_supersededCleanup(mod.cleanup)" in src
|
||||
@@ -0,0 +1,68 @@
|
||||
"""Regression tests for #342 — dashboard sidebar navigation keyboard semantics.
|
||||
|
||||
Sidebar entries were clickable ``div``s: not focusable, no role, and the
|
||||
current page was only a CSS class. They are now native links (pages) or
|
||||
buttons (actions such as the Inspector) with ``aria-current`` kept in sync.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
from pathlib import Path
|
||||
|
||||
DASHBOARD_JS = (
|
||||
Path(__file__).resolve().parents[2]
|
||||
/ "src" / "praisonaiui" / "templates" / "frontend" / "plugins" / "dashboard.js"
|
||||
)
|
||||
|
||||
|
||||
def _src() -> str:
|
||||
return DASHBOARD_JS.read_text(encoding="utf-8")
|
||||
|
||||
|
||||
def _fn(name: str) -> str:
|
||||
m = re.search(rf"function {name}\([^)]*\)\s*\{{(.*?)^}}", _src(), re.DOTALL | re.MULTILINE)
|
||||
assert m, f"{name} not found"
|
||||
return m.group(1)
|
||||
|
||||
|
||||
def test_nav_items_are_not_divs():
|
||||
src = _src()
|
||||
assert not re.search(
|
||||
r"createElement\('div'\);\s*\n\s*\w+\.className = 'db-nav-item'", src
|
||||
), "db-nav-item must not be a clickable div"
|
||||
|
||||
|
||||
def test_nav_entry_uses_link_or_button():
|
||||
body = _fn("navEntry")
|
||||
assert "onAction ? 'button' : 'a'" in body
|
||||
assert "el.href = '/' + encodeURIComponent(page.id)" in body
|
||||
assert "el.type = 'button'" in body
|
||||
|
||||
|
||||
def test_nav_link_keeps_modified_clicks_native():
|
||||
body = _fn("navEntry")
|
||||
assert "e.preventDefault()" in body
|
||||
for mod in ("metaKey", "ctrlKey", "shiftKey", "altKey", "e.button !== 0"):
|
||||
assert mod in body
|
||||
|
||||
|
||||
def test_sidebar_and_footer_use_nav_entry():
|
||||
body = _fn("buildSidebar")
|
||||
assert "navEntry(page, '📄')" in body
|
||||
assert "navEntry(page, '⚙', page.id === 'inspector' ? showTestPanel : null)" in body
|
||||
|
||||
|
||||
def test_select_page_syncs_aria_current():
|
||||
m = re.search(r"async function selectPage\(pageId\)\s*\{(.*?)^}", _src(), re.DOTALL | re.MULTILINE)
|
||||
assert m
|
||||
body = m.group(1)
|
||||
assert "setAttribute('aria-current', 'page')" in body
|
||||
assert "removeAttribute('aria-current')" in body
|
||||
|
||||
|
||||
def test_focus_visible_and_closed_drawer_hidden():
|
||||
src = _src()
|
||||
assert ".db-nav-item:focus-visible" in src
|
||||
assert re.search(r"\.db-sidebar \{ position: fixed !important;[^}]*visibility: hidden", src)
|
||||
assert re.search(r"\.db-sidebar\.db-sidebar-open \{[^}]*visibility: visible", src)
|
||||
@@ -0,0 +1,71 @@
|
||||
"""Dashboard plugin scope: ``set_dashboard(plugins=...)`` applies to pages and manifests."""
|
||||
|
||||
import json
|
||||
|
||||
import pytest
|
||||
from starlette.testclient import TestClient
|
||||
|
||||
import praisonaiui.dashboard_plugins as dp
|
||||
import praisonaiui.server as srv
|
||||
|
||||
|
||||
def _make_plugin(root, name, label=None):
|
||||
d = root / name
|
||||
d.mkdir(parents=True)
|
||||
(d / "manifest.json").write_text(json.dumps({"name": name, "label": label or name}))
|
||||
(d / "index.js").write_text("// plugin")
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def env(tmp_path, monkeypatch):
|
||||
home = tmp_path / "home"
|
||||
bundled = tmp_path / "bundled"
|
||||
extra = tmp_path / "extra"
|
||||
_make_plugin(home / ".praisonai" / "dashboard-plugins", "home-plug")
|
||||
_make_plugin(home / ".praisonai" / "dashboard-plugins", "dup", label="from-home")
|
||||
_make_plugin(bundled, "bundled-plug")
|
||||
_make_plugin(bundled, "dup", label="from-bundled")
|
||||
bad = bundled / "broken"
|
||||
bad.mkdir()
|
||||
(bad / "manifest.json").write_text("{not json")
|
||||
_make_plugin(extra, "extra-plug")
|
||||
monkeypatch.setattr(dp.Path, "home", classmethod(lambda cls: home))
|
||||
monkeypatch.setattr(dp, "_BUILTIN_PLUGIN_ROOT", bundled)
|
||||
srv.reset_state()
|
||||
srv.set_style("dashboard")
|
||||
srv.set_pages([])
|
||||
srv.register_page("synthetic", title="Synthetic", handler=lambda: {"_components": []})
|
||||
yield str(extra)
|
||||
srv.reset_state()
|
||||
|
||||
|
||||
def _state():
|
||||
client = TestClient(srv.create_app())
|
||||
pages = [p["id"] for p in client.get("/api/pages").json()["pages"]]
|
||||
plugins = {p["name"]: p for p in client.get("/api/dashboard/plugins").json()["plugins"]}
|
||||
return client, pages, plugins
|
||||
|
||||
|
||||
def test_default_keeps_all_discovered(env):
|
||||
srv.set_dashboard(plugin_dirs=[env])
|
||||
_, pages, plugins = _state()
|
||||
assert set(plugins) == {"home-plug", "dup", "bundled-plug", "extra-plug"}
|
||||
assert plugins["dup"]["label"] == "from-home" # first root wins on duplicates
|
||||
assert {"synthetic", "home-plug", "bundled-plug", "extra-plug", "dup"} <= set(pages)
|
||||
|
||||
|
||||
def test_empty_disables_all_plugins(env):
|
||||
srv.set_dashboard(plugin_dirs=[env], plugins=[])
|
||||
client, pages, plugins = _state()
|
||||
assert plugins == {}
|
||||
assert pages == ["synthetic"]
|
||||
assert client.get("/dashboard-plugins/bundled-plug/index.js").status_code == 404
|
||||
|
||||
|
||||
def test_allowlist_filters_pages_and_manifest(env):
|
||||
srv.set_dashboard(plugin_dirs=[env], plugins=["extra-plug", "missing"])
|
||||
client, pages, plugins = _state()
|
||||
assert set(plugins) == {"extra-plug"}
|
||||
assert sorted(pages) == ["extra-plug", "synthetic"]
|
||||
assert client.get("/dashboard-plugins/extra-plug/index.js").status_code == 200
|
||||
assert client.get("/dashboard-plugins/home-plug/index.js").status_code == 404
|
||||
@@ -0,0 +1,45 @@
|
||||
"""Smoke test for the feature-module example in docs/features/how-to-add-a-feature.md (#335)."""
|
||||
|
||||
from starlette.responses import JSONResponse
|
||||
from starlette.routing import Route
|
||||
from starlette.testclient import TestClient
|
||||
|
||||
from praisonaiui.features import BaseFeatureProtocol, _features, register_feature
|
||||
from praisonaiui.server import create_app
|
||||
|
||||
|
||||
# Mirrors section 7 of docs/features/how-to-add-a-feature.md — keep in sync.
|
||||
class MyFeature(BaseFeatureProtocol):
|
||||
name = "my-feature"
|
||||
description = "Example feature with GET and PATCH routes"
|
||||
|
||||
def routes(self):
|
||||
return [
|
||||
Route("/api/my-feature", self._get, methods=["GET"]),
|
||||
Route("/api/my-feature/{item_id}", self._patch, methods=["PATCH"]),
|
||||
]
|
||||
|
||||
async def _get(self, request):
|
||||
return JSONResponse({"data": request.query_params.get("q", "hello")})
|
||||
|
||||
async def _patch(self, request):
|
||||
body = await request.json()
|
||||
return JSONResponse({"ok": True, "id": request.path_params["item_id"], **body})
|
||||
|
||||
|
||||
def test_doc_feature_example_routes():
|
||||
saved = dict(_features)
|
||||
try:
|
||||
register_feature(MyFeature())
|
||||
client = TestClient(create_app())
|
||||
|
||||
r = client.get("/api/my-feature", params={"q": "hi"})
|
||||
assert r.status_code == 200
|
||||
assert r.json() == {"data": "hi"}
|
||||
|
||||
r = client.patch("/api/my-feature/42", json={"title": "x"})
|
||||
assert r.status_code == 200
|
||||
assert r.json() == {"ok": True, "id": "42", "title": "x"}
|
||||
finally:
|
||||
_features.clear()
|
||||
_features.update(saved)
|
||||
@@ -0,0 +1,31 @@
|
||||
"""Issue #323 — Jobs rows link to their chat session when ``session_id`` is set.
|
||||
|
||||
Source-parsing test, mirroring ``test_session_search_dashboard.py``.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
JOBS_JS = (
|
||||
Path(__file__).resolve().parents[2]
|
||||
/ "src" / "praisonaiui" / "templates" / "frontend" / "plugins" / "jobs.js"
|
||||
)
|
||||
|
||||
|
||||
def _src() -> str:
|
||||
return JOBS_JS.read_text(encoding="utf-8")
|
||||
|
||||
|
||||
def test_open_session_button_only_when_session_id():
|
||||
src = _src()
|
||||
assert "job.session_id ? `<button" in src
|
||||
assert 'data-session-id="${escapeAttr(job.session_id)}"' in src
|
||||
|
||||
|
||||
def test_open_session_reuses_session_select_event():
|
||||
src = _src()
|
||||
assert "function openJobSession(sessionId)" in src
|
||||
assert "selectPage('chat')" in src
|
||||
assert "'aiui:session-select'" in src
|
||||
assert "window.aiuiOpenJobSession = openJobSession;" in src
|
||||
@@ -0,0 +1,91 @@
|
||||
"""Pagination exposes a page-change event to the host (issue #337)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import re
|
||||
import shutil
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from praisonaiui.ui import pagination
|
||||
|
||||
DASHBOARD_JS = (
|
||||
Path(__file__).resolve().parents[2]
|
||||
/ "src/praisonaiui/templates/frontend/plugins/dashboard.js"
|
||||
)
|
||||
|
||||
|
||||
def test_helper_id_optional():
|
||||
assert "id" not in pagination(total=10)
|
||||
assert pagination(total=10, id="users")["id"] == "users"
|
||||
|
||||
|
||||
def _render_pagination_src() -> str:
|
||||
src = DASHBOARD_JS.read_text()
|
||||
m = re.search(r"function renderPagination\(comp\) \{.*?\n\}\n", src, re.S)
|
||||
assert m
|
||||
return m.group(0)
|
||||
|
||||
|
||||
# Minimal DOM shim so the renderer runs under plain Node.
|
||||
_HARNESS = r"""
|
||||
class CustomEvent { constructor(t, o) { this.type = t; this.detail = o.detail; this.bubbles = o.bubbles; } }
|
||||
class El {
|
||||
constructor(tag) { this.tag = tag; this.children = []; this.attrs = {}; this.dataset = {};
|
||||
this.listeners = {}; this.disabled = false; this.textContent = '';
|
||||
const cls = new Set(); this.classList = { add: c => cls.add(c), has: c => cls.has(c) }; this.style = {}; }
|
||||
set innerHTML(v) { this.children = []; }
|
||||
appendChild(c) { this.children.push(c); return c; }
|
||||
setAttribute(k, v) { this.attrs[k] = v; }
|
||||
addEventListener(t, f) { (this.listeners[t] ||= []).push(f); }
|
||||
dispatchEvent(e) { (this.listeners[e.type] || []).forEach(f => f(e)); }
|
||||
click() { if (!this.disabled) (this.listeners.click || []).forEach(f => f()); }
|
||||
}
|
||||
const document = { createElement: t => new El(t) };
|
||||
__SRC__
|
||||
const out = {};
|
||||
function mk(comp) {
|
||||
const el = renderPagination(comp); const events = [];
|
||||
el.addEventListener('aiui:pagination', e => events.push(e.detail));
|
||||
const btn = l => el.children.find(c => c.attrs && c.attrs['aria-label'] === l);
|
||||
const active = () => el.children.find(c => c.attrs && c.attrs['aria-current'] === 'page').textContent;
|
||||
return { el, events, btn, active };
|
||||
}
|
||||
// next / prev / numbered, exactly once each
|
||||
let a = mk({ total: 30, page: 1, per_page: 10, id: 'a' });
|
||||
let b = mk({ total: 30, page: 1, per_page: 10, id: 'b' });
|
||||
a.btn('Previous page').click(); // disabled at bound
|
||||
a.btn('Next page').click();
|
||||
a.btn('Page 3').click();
|
||||
a.btn('Next page').click(); // disabled at bound
|
||||
a.btn('Page 3').click(); // same page: no event
|
||||
a.btn('Previous page').click();
|
||||
out.a = a.events; out.aActive = a.active(); out.b = b.events; out.bActive = b.active();
|
||||
out.tag = a.el.tag; out.label = a.el.attrs['aria-label'];
|
||||
// clamping
|
||||
out.clampHigh = mk({ total: 25, page: 99, per_page: 10 }).active();
|
||||
out.clampLow = mk({ total: 0, page: 0, per_page: 0 }).active();
|
||||
console.log(JSON.stringify(out));
|
||||
"""
|
||||
|
||||
|
||||
@pytest.mark.skipif(shutil.which("node") is None, reason="node not installed")
|
||||
def test_pagination_dispatches_page_change(tmp_path):
|
||||
script = tmp_path / "h.js"
|
||||
script.write_text(_HARNESS.replace("__SRC__", _render_pagination_src()))
|
||||
res = subprocess.run(["node", str(script)], capture_output=True, text=True, check=True)
|
||||
out = json.loads(res.stdout)
|
||||
d = {"per_page": 10, "total": 30}
|
||||
assert out["a"] == [
|
||||
{"id": "a", "page": 2, **d},
|
||||
{"id": "a", "page": 3, **d},
|
||||
{"id": "a", "page": 2, **d},
|
||||
]
|
||||
assert out["aActive"] == "2"
|
||||
assert out["b"] == [] and out["bActive"] == "1"
|
||||
assert out["tag"] == "nav" and out["label"] == "Pagination"
|
||||
assert out["clampHigh"] == "3"
|
||||
assert out["clampLow"] == "1"
|
||||
@@ -0,0 +1,166 @@
|
||||
"""create_replay_app(): the Replay view mounted inside another app, with an optional Run box."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import shutil
|
||||
import threading
|
||||
import time
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
from starlette.applications import Starlette
|
||||
from starlette.routing import Mount
|
||||
from starlette.testclient import TestClient
|
||||
|
||||
FIXTURES = Path(__file__).resolve().parents[1] / "fixtures" / "replay"
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def traces(tmp_path, monkeypatch):
|
||||
d = tmp_path / "traces"
|
||||
d.mkdir()
|
||||
monkeypatch.setenv("PRAISONAI_TRACES_DIR", str(d))
|
||||
monkeypatch.delenv("PRAISON_HOME", raising=False)
|
||||
return d
|
||||
|
||||
|
||||
def _host(sub) -> TestClient:
|
||||
"""A host app with the replay app mounted at /replay, like app.mount() in FastAPI."""
|
||||
return TestClient(Starlette(routes=[Mount("/replay", app=sub)]))
|
||||
|
||||
|
||||
def _cfg(page: str) -> dict:
|
||||
line = next(l for l in page.splitlines() if l.startswith("render("))
|
||||
return json.loads(line[line.index(",") + 1 : line.rindex(")")].strip())
|
||||
|
||||
|
||||
def test_page_mounts_the_view_relative_to_the_mount(traces):
|
||||
from praisonaiui.features.replay import create_replay_app
|
||||
|
||||
c = _host(create_replay_app())
|
||||
r = c.get("/replay/")
|
||||
assert r.status_code == 200
|
||||
assert 'from "/replay/replay.js"' in r.text
|
||||
cfg = _cfg(r.text)
|
||||
assert cfg == {"base": "/replay", "approve": False} # no run list pin, no Run box
|
||||
assert "aiui" not in r.text.lower() # no dashboard around it
|
||||
for name in ("replay.js", "replay-core.js"):
|
||||
js = c.get(f"/replay/{name}")
|
||||
assert js.status_code == 200 and js.headers["content-type"].startswith("text/javascript")
|
||||
|
||||
|
||||
def test_page_pins_a_valid_run_and_ignores_a_bad_one(traces):
|
||||
from praisonaiui.features.replay import create_replay_app
|
||||
|
||||
c = _host(create_replay_app())
|
||||
assert _cfg(c.get("/replay/?run=run-abc").text)["run"] == "run-abc"
|
||||
page = c.get("/replay/?run=../../etc/passwd").text
|
||||
assert "run" not in _cfg(page) and "passwd" not in page
|
||||
|
||||
|
||||
def test_api_routes_answer_under_the_mount(traces):
|
||||
from praisonaiui.features.replay import create_replay_app
|
||||
|
||||
shutil.copy(FIXTURES / "team_handoff.jsonl", traces / "team_handoff.jsonl")
|
||||
c = _host(create_replay_app())
|
||||
rows = c.get("/replay/api/replay/runs").json()
|
||||
assert [r["run_id"] for r in rows] == ["team_handoff"]
|
||||
assert c.get("/replay/api/replay/runs/team_handoff/events").json()["events"]
|
||||
|
||||
|
||||
def test_events_wait_param_answers_a_missing_run_without_404(traces):
|
||||
from praisonaiui.features.replay import create_replay_app
|
||||
|
||||
c = _host(create_replay_app())
|
||||
assert c.get("/replay/api/replay/runs/run-later/events").status_code == 404
|
||||
r = c.get("/replay/api/replay/runs/run-later/events?wait=1")
|
||||
assert r.status_code == 200 and r.json() == {"events": [], "graph": None, "run": None}
|
||||
|
||||
|
||||
def test_no_run_route_without_a_runner(traces):
|
||||
from praisonaiui.features.replay import create_replay_app
|
||||
|
||||
c = _host(create_replay_app())
|
||||
assert c.post("/replay/api/replay/run", json={"input": "x"}).status_code in (404, 405)
|
||||
|
||||
|
||||
def test_run_box_calls_the_runner_with_a_new_run_id(traces):
|
||||
from praisonaiui.features.replay import create_replay_app
|
||||
|
||||
calls = []
|
||||
|
||||
def runner(text, run_id):
|
||||
(traces / f"{run_id}.jsonl").write_text(json.dumps({"event_type": "session_start", "sequence_num": 0, "timestamp": time.time()}) + "\n")
|
||||
calls.append((text, run_id))
|
||||
|
||||
c = _host(create_replay_app(runner, input="solar sails", placeholder="Topic"))
|
||||
cfg = _cfg(c.get("/replay/").text)
|
||||
assert cfg["runner"] == {"input": "solar sails", "placeholder": "Topic", "ask": True}
|
||||
r = c.post("/replay/api/replay/run", json={"input": "comets"})
|
||||
assert r.status_code == 200
|
||||
run_id = r.json()["run_id"]
|
||||
assert (traces / f"{run_id}.jsonl").is_file(), "answers once the trace exists"
|
||||
for _ in range(100):
|
||||
if calls:
|
||||
break
|
||||
time.sleep(0.01)
|
||||
assert calls == [("comets", run_id)]
|
||||
assert run_id.startswith("run-") and len(run_id) == 16
|
||||
|
||||
|
||||
def test_run_box_without_input_box(traces):
|
||||
from praisonaiui.features.replay import create_replay_app
|
||||
|
||||
cfg = _cfg(_host(create_replay_app(lambda t, r: None, input=None)).get("/replay/").text)
|
||||
assert cfg["runner"]["ask"] is False
|
||||
|
||||
|
||||
def test_run_rejects_non_json_bad_input_and_a_second_run(traces):
|
||||
from praisonaiui.features.replay import MAX_RUN_INPUT, create_replay_app
|
||||
|
||||
gate = threading.Event()
|
||||
c = _host(create_replay_app(lambda text, run_id: gate.wait(5)))
|
||||
assert c.post("/replay/api/replay/run", data="input=x", headers={"content-type": "application/x-www-form-urlencoded"}).status_code == 415
|
||||
assert c.post("/replay/api/replay/run", content="input=x", headers={"content-type": "text/plain"}).status_code == 415
|
||||
assert c.post("/replay/api/replay/run", json={"input": 3}).status_code == 400
|
||||
assert c.post("/replay/api/replay/run", json={"input": "x" * (MAX_RUN_INPUT + 1)}).status_code == 400
|
||||
assert c.post("/replay/api/replay/run", json={"input": "a"}).status_code == 200
|
||||
busy = c.post("/replay/api/replay/run", json={"input": "b"})
|
||||
assert busy.status_code == 409 and "already" in busy.json()["error"]
|
||||
gate.set()
|
||||
for _ in range(100): # the lock is released when the runner returns
|
||||
r = c.post("/replay/api/replay/run", json={"input": "c"})
|
||||
if r.status_code == 200:
|
||||
break
|
||||
time.sleep(0.02)
|
||||
assert r.status_code == 200
|
||||
|
||||
|
||||
def test_a_failing_runner_frees_the_run_box(traces, caplog):
|
||||
from praisonaiui.features.replay import create_replay_app
|
||||
|
||||
def boom(text, run_id):
|
||||
raise RuntimeError("no key")
|
||||
|
||||
c = _host(create_replay_app(boom))
|
||||
assert c.post("/replay/api/replay/run", json={"input": ""}).status_code == 200
|
||||
for _ in range(100):
|
||||
r = c.post("/replay/api/replay/run", json={"input": ""})
|
||||
if r.status_code == 200:
|
||||
break
|
||||
time.sleep(0.02)
|
||||
assert r.status_code == 200
|
||||
|
||||
|
||||
def test_async_runner_is_awaited(traces):
|
||||
from praisonaiui.features.replay import create_replay_app
|
||||
|
||||
done = threading.Event()
|
||||
|
||||
async def runner(text, run_id):
|
||||
done.set()
|
||||
|
||||
c = _host(create_replay_app(runner))
|
||||
assert c.post("/replay/api/replay/run", json={"input": ""}).status_code == 200
|
||||
assert done.wait(3)
|
||||
@@ -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,158 @@
|
||||
"""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 { margin-top: 48px;" in dash
|
||||
assert ".db-main { padding: 64px 16px 16px; }" in dash
|
||||
|
||||
|
||||
def test_shell_header_is_a_content_sized_row_not_a_column():
|
||||
"""#339: the header slot must not take a flex column or stretch on mobile."""
|
||||
dash = (FRONTEND / "dashboard.js").read_text(encoding="utf-8")
|
||||
assert "#root.db-active:has(> #db-shell-header-slot) {" in dash
|
||||
assert "grid-template-rows: auto minmax(0, 1fr);" in dash
|
||||
assert "#db-shell-header-slot { grid-column: 2; grid-row: 1;" in dash
|
||||
assert "#root.db-active { flex-wrap: wrap; }" not in dash
|
||||
|
||||
|
||||
def test_theme_defaults_to_system():
|
||||
view = VIEW.read_text(encoding="utf-8")
|
||||
assert "@media (prefers-color-scheme:dark){.rp-root[data-theme=\"system\"]" in view
|
||||
assert "'Theme: system'" in view
|
||||
@@ -25,6 +25,8 @@ def client():
|
||||
|
||||
_srv._DEEP_HEALTH_CACHE = {}
|
||||
_srv._DEEP_HEALTH_CACHE_TIME = 0.0
|
||||
_srv._health_cache = {}
|
||||
_srv._health_cache_time = 0
|
||||
# Snapshot and clear the global feature registry so a feature leaked by an
|
||||
# earlier test (e.g. one that crashed before its cleanup) cannot slow down
|
||||
# /health and flake the deep-health timing/cache assertions under the full
|
||||
@@ -203,6 +205,97 @@ class TestHealthEndpoint:
|
||||
assert second.json().get("cached") is True
|
||||
assert duration < 0.1
|
||||
|
||||
def test_slow_provider_does_not_block_readiness(self, client):
|
||||
"""Cold /api/health and /health/ready bound a slow provider (#341)."""
|
||||
import asyncio
|
||||
import time
|
||||
|
||||
import praisonaiui.server as _srv
|
||||
|
||||
class _SlowProvider:
|
||||
async def health(self):
|
||||
await asyncio.sleep(5)
|
||||
return {"status": "ok"}
|
||||
|
||||
prev_provider = _srv._provider
|
||||
_srv._provider = _SlowProvider()
|
||||
_srv._health_cache = {}
|
||||
_srv._health_cache_time = 0
|
||||
try:
|
||||
for path in ("/api/health", "/health/ready"):
|
||||
start = time.perf_counter()
|
||||
response = client.get(path)
|
||||
duration = time.perf_counter() - start
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert data["status"] == "ok"
|
||||
assert data["provider"]["name"] == "_SlowProvider"
|
||||
assert duration < 2.0
|
||||
finally:
|
||||
_srv._provider = prev_provider
|
||||
_srv._health_cache = {}
|
||||
_srv._health_cache_time = 0
|
||||
|
||||
def test_non_dict_provider_health_does_not_500_readiness(self, client):
|
||||
"""A provider returning a non-dict from health() must not break /health/ready."""
|
||||
import praisonaiui.server as _srv
|
||||
|
||||
class _NoneProvider:
|
||||
async def health(self):
|
||||
return None
|
||||
|
||||
prev_provider = _srv._provider
|
||||
_srv._provider = _NoneProvider()
|
||||
try:
|
||||
response = client.get("/health/ready")
|
||||
assert response.status_code == 200
|
||||
assert response.json()["provider"] == {"name": "_NoneProvider"}
|
||||
finally:
|
||||
_srv._provider = prev_provider
|
||||
|
||||
|
||||
class TestFooterLiveness:
|
||||
"""Dashboard footer uses side-effect-free liveness (#341)."""
|
||||
|
||||
def test_live_makes_no_provider_or_feature_calls(self, client):
|
||||
import praisonaiui.server as _srv
|
||||
from praisonaiui.features import _features
|
||||
|
||||
calls = {"provider": 0, "feature": 0}
|
||||
|
||||
class _Provider:
|
||||
async def health(self):
|
||||
calls["provider"] += 1
|
||||
return {}
|
||||
|
||||
class _Feature:
|
||||
async def health(self):
|
||||
calls["feature"] += 1
|
||||
return {"healthy": True}
|
||||
|
||||
original = _srv._provider
|
||||
_srv._provider = _Provider()
|
||||
_features["__count__"] = _Feature()
|
||||
try:
|
||||
assert client.get("/api/health/live").status_code == 200
|
||||
assert calls == {"provider": 0, "feature": 0}
|
||||
finally:
|
||||
_srv._provider = original
|
||||
_features.pop("__count__", None)
|
||||
|
||||
def test_sidebar_footer_uses_liveness_endpoint(self):
|
||||
from pathlib import Path
|
||||
|
||||
import praisonaiui
|
||||
|
||||
js = (
|
||||
Path(praisonaiui.__file__).parent
|
||||
/ "templates/frontend/plugins/dashboard.js"
|
||||
).read_text(encoding="utf-8")
|
||||
footer = js[js.index("shell:sidebar:footer"):]
|
||||
assert "fetch('/api/health/live')" in footer
|
||||
assert "fetch('/api/health')" not in footer
|
||||
|
||||
|
||||
class TestProviderHealthEndpoint:
|
||||
"""Tests for the provider health JSON route (#151)."""
|
||||
|
||||
@@ -0,0 +1,71 @@
|
||||
"""Tests for #336 — opt-in stable row IDs, selection and row actions on table()."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from praisonaiui import table
|
||||
|
||||
DASHBOARD_JS = (
|
||||
Path(__file__).resolve().parents[2]
|
||||
/ "src" / "praisonaiui" / "templates" / "frontend" / "plugins" / "dashboard.js"
|
||||
)
|
||||
|
||||
|
||||
class TestTableHelper:
|
||||
def test_static_payload_unchanged(self):
|
||||
assert table(headers=["A"], rows=[["x"]]) == {
|
||||
"type": "table", "headers": ["A"], "rows": [["x"]],
|
||||
}
|
||||
|
||||
def test_row_ids_and_actions(self):
|
||||
t = table(headers=["Name"], rows=[["Acme"], ["Beta"]],
|
||||
row_ids=["c1", "c2"], actions=["Edit"])
|
||||
assert t["row_ids"] == ["c1", "c2"]
|
||||
assert t["actions"] == ["Edit"]
|
||||
|
||||
def test_row_ids_length_mismatch(self):
|
||||
with pytest.raises(ValueError):
|
||||
table(headers=["A"], rows=[["x"], ["y"]], row_ids=["1"])
|
||||
|
||||
def test_row_ids_unique(self):
|
||||
with pytest.raises(ValueError):
|
||||
table(headers=["A"], rows=[["x"], ["y"]], row_ids=["1", "1"])
|
||||
|
||||
def test_actions_require_row_ids(self):
|
||||
with pytest.raises(ValueError):
|
||||
table(headers=["A"], rows=[["x"]], actions=["Edit"])
|
||||
|
||||
|
||||
class TestRenderTable:
|
||||
def _body(self) -> str:
|
||||
src = DASHBOARD_JS.read_text(encoding="utf-8")
|
||||
m = re.search(r"function renderTable\(comp\)\s*\{(.*?)^}", src,
|
||||
re.DOTALL | re.MULTILINE)
|
||||
assert m, "renderTable not found"
|
||||
return m.group(1)
|
||||
|
||||
def test_selection_is_opt_in_and_uses_row_id(self):
|
||||
body = self._body()
|
||||
assert "comp.row_ids" in body
|
||||
assert "aiui:table-select" in body
|
||||
assert "rowId" in body
|
||||
|
||||
def test_keyboard_and_pointer_selection(self):
|
||||
body = self._body()
|
||||
assert "tabIndex = 0" in body
|
||||
assert "'click', select" in body
|
||||
assert "'Enter'" in body and "' '" in body
|
||||
assert "aria-selected" in body
|
||||
|
||||
def test_row_actions_are_buttons(self):
|
||||
body = self._body()
|
||||
assert "aiui:table-action" in body
|
||||
assert "btn.type = 'button'" in body
|
||||
assert "stopPropagation" in body
|
||||
|
||||
def test_events_bubble(self):
|
||||
assert "bubbles: true" in self._body()
|
||||
@@ -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.146"
|
||||
version = "0.3.153"
|
||||
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