Compare commits

...
Author SHA1 Message Date
MervinPraisonandClaude Opus 5.5 a0464bc347 fix: only accept code-execution approvals issued by /api/code/execute
Entries created via the unauthenticated POST /api/approvals carry a
caller-chosen risk_level/agent_name and could be auto-approved under a
non-default policy, then redeemed to run arbitrary code.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-24 01:50:28 +00:00
MervinPraisonandClaude Opus 5.5 c8a3af4f3e fix: gate code execution on approval decision and require auth to approve (fixes #311)
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-24 01:16:30 +00:00
Mervin PraisonandClaude Opus 5.5 129adb9285 fix(ci): github-automation-template v1.1.17 (permission ceiling) (#310)
Co-authored-by: Claude Opus 5.5 (1M context) <[email protected]>
2026-10-17 09:34:51 +01:00
Mervin PraisonandClaude Opus 5.5 78deb698b6 chore(ci): github-automation-template v1.1.16 (#309)
Co-authored-by: Claude Opus 5.5 (1M context) <[email protected]>
2026-10-17 09:27:31 +01:00
b04e0e4c2c chore(ci): bump github-automation-template to v1.1.15 (#308)
Writers-only auto-Claude, least-privilege tokens for Claude on fork PRs and
in the merge-gate assessor, merge-gate held-out BLOCKs (no repeat verdicts on
an unchanged HEAD), portable prompts. auto-pr-comment sweep moves to hourly so
stale FINAL reviews are re-posted once a push settles.

Co-authored-by: MervinPraison <[email protected]>
Co-authored-by: Claude Opus 5.5 (1M context) <[email protected]>
2026-10-16 16:58:34 +01:00
Mervin PraisonandClaude Opus 5.5 8bfcab2570 fix(ci): github-automation-template v1.1.14 (fork PR isolation) (#307)
Co-authored-by: Claude Opus 5.5 (1M context) <[email protected]>
2026-10-15 08:58:59 +01:00
Mervin PraisonandClaude Opus 5.5 83a1e10c4a fix(ci): require write access to invoke @claude (template v1.1.13) (#306)
Co-authored-by: Claude Opus 5.5 (1M context) <[email protected]>
2026-10-15 08:30:36 +01:00
f7d4eb8a13 chore: bump github-automation-template hub_ref to v1.1.11 (#305)
Picks up the 403 label fix in the hub callable workflows.

Co-authored-by: MervinPraison <[email protected]>
Co-authored-by: Cursor <[email protected]>
2026-09-30 08:38:05 +01:00
Mervin Praison f254fefe95 Merge pull request #304 from MervinPraison/claude/issue-293-20260923
spike: evaluate BeautifulUI primitives in dev environment (#293)
2026-09-24 04:27:06 +01:00
Mervin Praison e22537fa38 Merge pull request #302 from MervinPraison/claude/issue-295-20260923
ci: add frontend build gate for BeautifulUI dashboard
2026-09-24 04:26:45 +01:00
MervinPraison b151143eb4 Release v0.3.147 2026-09-23 15:42:18 +00:00
praisonai-triage-agent[bot] 5949866103 Merge pull request #303 from MervinPraison/claude/issue-298-20260923
docs: BeautifulUI upgrade playbook and MIT attribution
2026-09-23 15:39:27 +00:00
praisonai-triage-agent[bot] b56bd87903 Merge pull request #300 from MervinPraison/claude/issue-294-20260923
fix: BeautifulUI adapter boundary + upstream pin policy (#294)
2026-09-23 15:39:07 +00:00
praisonai-triage-agent[bot] 0976d1caa3 Merge pull request #301 from MervinPraison/claude/issue-297-20260923
fix: render approvals view as accessible Approval Card (#297)
2026-09-23 15:38:53 +00:00
praisonai-triage-agent[bot] ef4a16ee68 Merge pull request #299 from MervinPraison/claude/issue-292-20260923
docs: BeautifulUI integration strategy (Epic #292)
2026-09-23 15:38:42 +00:00
MervinPraisonandClaude Opus 4.8 a3568bdb31 fix: normalize approval created_at epoch to ISO in agent-UI adapter
The /api/approvals payloads emit created_at as epoch seconds (time.time()),
but mapApprovalToCardProps used asString(), silently dropping the value for
real payloads so ApprovalPromptModel.createdAt was always undefined. Coerce
numeric epochs to an ISO-8601 string (ISO strings still pass through) and
align the unit test with the real wire shape.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
2026-09-23 14:23:52 +00:00
MervinPraisonandClaude Opus 4.8 ffcb9da9df docs: fix vanilla chat plugin path in BeautifulUI strategy
Correct the fallback reference to templates/frontend/plugins/views/chat.js
(the file lives under views/, not directly under plugins/).

Co-Authored-By: Claude Opus 4.8 <[email protected]>
2026-09-23 14:23:22 +00:00
MervinPraisonandClaude Opus 4.8 afce1e542d docs: wire BeautifulUI playbook into aiui.template.yaml navigation
The shipped docs site is built by `aiui build` from aiui.template.yaml
(see ci.yml / deploy-pages.yml), not mkdocs.yml. Add a Frontend nav group
so the playbook is discoverable in the built site navigation.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
2026-09-23 14:11:45 +00:00
MervinPraisonandClaude Opus 4.8 4d8f9b0322 fix: persist approval custom answer/option via reason field (#297)
The approve/deny endpoints only store `reason`; map the selected option or
typed custom answer into it so the human's decision note is recorded, while
keeping `option`/`custom_answer` for a future richer backend.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
2026-09-23 14:10:13 +00:00
MervinPraisonandClaude Opus 4.8 a3a5e06225 test: make agent-UI adapter tests runnable via npm test (#294)
The adapter unit tests use Node's built-in runner, but the documented
command (`node --test src/agent-ui/`) fails on Node 22 — it neither
strips TypeScript types nor discovers the files, so the suite never ran.

- Add a `test` script to src/frontend that runs the suite with
  `--experimental-strip-types` (8/8 passing on Node 22).
- Fix the misleading run instructions in the test header.

Closes the "unit tests" acceptance criterion of #294 with an executable
suite. Purely additive; no runtime code changed.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
2026-09-23 14:07:50 +00:00
MervinPraisonandClaude Opus 4.8 ec31577a38 spike: evaluate BeautifulUI primitives in dev environment (fixes #293)
Isolated, dev-only spike proving StreamingText, ThinkingState and
ApprovalCard build in src/frontend (React 19 + Vite + Tailwind v4) and
render against mock, PraisonAIUI-shaped agent event streams.

- Vendored 3 primitives + deps (button, glide-menu, foundation.css) under
  src/frontend/src/spike/beautifui/, imports rewritten to @/spike/* so
  nothing collides with the shipped components or the templates/frontend
  bundle (per issue non-goals).
- mockEvents.ts uses the real SSEEvent contract (run_content deltas,
  reasoning_step/tool_call_*, approval_request → /api/approvals).
- spike.html + SpikePage.tsx: `npm run dev` → /spike.html.
- Findings in src/frontend/src/spike/README.md: zero new runtime npm deps
  (liveline/glimm not pulled; @central-icons-react avoided — inline SVG),
  fixed an upstream unbalanced-brace bug in foundation.css, dropped the
  shadow-plugin dep, documented bundle size / reduced-motion / dark-mode.

Upstream pin: slev12397/beautiful-ui@44a274e598

Co-Authored-By: Claude Opus 4.8 <[email protected]>
2026-09-23 13:52:48 +00:00
MervinPraisonandClaude Opus 4.8 1807595cd0 docs: add BeautifulUI upgrade playbook and MIT attribution (fixes #298)
Co-Authored-By: Claude Opus 4.8 <[email protected]>
2026-09-23 13:52:35 +00:00
MervinPraisonandClaude Opus 4.8 166091bc8e ci: add frontend build gate for BeautifulUI dashboard (fixes #295)
Adds a `frontend` job to CI that runs `npm ci && npm run build` for
src/frontend on every push/PR to main, failing on TypeScript/Vite build
errors. Uses Node 22 with npm caching keyed on the frontend lockfile.

Also documents the Node version, lockfile, and build/ship path in
src/frontend/README.md.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
2026-09-23 13:50:39 +00:00
MervinPraisonandClaude Opus 4.8 aeefceeaa2 fix: render approvals view as accessible Approval Card (fixes #297)
- Add self-contained toApprovalModel() normalizer (ApprovalPromptModel shape)
- Render pending items as an Approval Card: risk accent, question, options,
  custom answer and confidence copy — surfaced only when the API supplies them
- Preserve /api/approvals approve|deny endpoints and skill-approval deep links
- Escape all interpolated API values via esc() (fixes prior XSS gap)
- Improve a11y: role=group/radiogroup, aria-labels on buttons/inputs

Co-Authored-By: Claude Opus 4.8 <[email protected]>
2026-09-23 13:49:18 +00:00
MervinPraisonandClaude Opus 4.8 5b191a439b fix: add BeautifulUI adapter boundary + upstream pin policy (fixes #294)
Introduces the agent-UI adapter layer so BeautifulUI can be vendored
without leaking upstream types/paths across the frontend.

- src/frontend/src/agent-ui/contracts.ts: stable PraisonAIUI view models
  (AgentStreamEvent, ToolCallChipModel, ApprovalPromptModel)
- src/frontend/src/agent-ui/adapter.ts: pure mappers mapChatEventToUi()
  and mapApprovalToCardProps() from WS frames / approvals REST payloads
- src/frontend/src/agent-ui/adapter.test.ts: dependency-free unit tests
  (node:test, mock events -> props); 8 tests pass via `node --test`
- docs/frontend/: ADR (vendoring, adapter boundaries, folder layout,
  default-off feature flag + fallback) and upstream SHA/pin record
- mkdocs.yml: nav entries for the new frontend docs

Boundary rule: BeautifulUI prop types are imported only inside
src/agent-ui/; consumers depend on contracts.ts, keeping the vendored
implementation swappable. No new runtime deps; adapter is not yet
mounted (migration is #296/#297, CI gate is #295).

Co-Authored-By: Claude Opus 4.8 <[email protected]>
2026-09-23 13:47:39 +00:00
MervinPraisonandClaude Opus 4.8 0746ef5e6c docs: add BeautifulUI integration strategy (fixes #292)
Adds the durable integration strategy for the BeautifulUI epic: vendored
source (no runtime npm dep), adapter layer behind @praisonaiui/react,
phased flag-gated rollout, selective token merge, and lucide-react for
nav icons. Documents risks and links the child issues (#293-#298).

Co-Authored-By: Claude Opus 4.8 <[email protected]>
2026-09-23 13:41:43 +00:00
MervinPraisonandCursor 3058886312 chore: bump automation hub to v1.1.10 for merge gate fix
Co-authored-by: Cursor <[email protected]>
2026-09-22 15:30:07 +01:00
Mervin Praison 70d8610733 Merge pull request #291 from MervinPraison/claude/issue-290-20260916
docs: add SECURITY.md with private disclosure policy
2026-09-17 10:37:03 +01:00
MervinPraisonandClaude Opus 4.8 2797667e04 docs: add SECURITY.md with private disclosure policy (fixes #290)
Co-Authored-By: Claude Opus 4.8 <[email protected]>
2026-09-16 16:28:01 +00:00
43 changed files with 3278 additions and 63 deletions

No files matched your search

+1 -1
View File
@@ -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"
}
+3 -3
View File
@@ -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].17
secrets: inherit
with:
hub_ref: v1.1.8
hub_ref: v1.1.17
+2 -2
View File
@@ -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].17
secrets: inherit
with:
hub_ref: v1.1.8
hub_ref: v1.1.17
pr_number: ${{ github.event.inputs.pr_number || '' }}
+2 -2
View File
@@ -25,8 +25,8 @@ permissions:
jobs:
detect-and-trigger:
uses: MervinPraison/github-automation-template/.github/workflows/[email protected].8
uses: MervinPraison/github-automation-template/.github/workflows/[email protected].17
secrets: inherit
with:
hub_ref: v1.1.8
hub_ref: v1.1.17
pr_number: ${{ github.event.inputs.pr_number || '' }}
+24
View File
@@ -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'
+5 -4
View File
@@ -43,9 +43,9 @@ jobs:
id-token: write
steps:
- uses: actions/checkout@v4
- uses: MervinPraison/github-automation-template/.github/actions/[email protected].8
- uses: MervinPraison/github-automation-template/.github/actions/[email protected].17
with:
hub_ref: v1.1.8
hub_ref: v1.1.17
- 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.17
pr_number: ${{ github.event.inputs.pr_number || github.event.client_payload.pr_number || '' }}
merge_method: ${{ github.event.inputs.merge_method || 'merge' }}
+2 -2
View File
@@ -18,7 +18,7 @@ jobs:
issues: write
actions: read
id-token: write
uses: MervinPraison/github-automation-template/.github/workflows/[email protected].8
uses: MervinPraison/github-automation-template/.github/workflows/[email protected].17
secrets: inherit
with:
hub_ref: v1.1.8
hub_ref: v1.1.17
+2 -2
View File
@@ -25,9 +25,9 @@ jobs:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: MervinPraison/github-automation-template/.github/actions/[email protected].8
- uses: MervinPraison/github-automation-template/.github/actions/[email protected].17
with:
hub_ref: v1.1.8
hub_ref: v1.1.17
- name: Detect conflicting PRs and trigger Claude
uses: actions/github-script@v7
with:
+2 -2
View File
@@ -13,7 +13,7 @@ permissions:
jobs:
sync:
uses: MervinPraison/github-automation-template/.github/workflows/[email protected].8
uses: MervinPraison/github-automation-template/.github/workflows/[email protected].17
secrets: inherit
with:
hub_ref: v1.1.8
hub_ref: v1.1.17
+4
View File
@@ -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
View File
@@ -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.
+43
View File
@@ -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.
+5
View File
@@ -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"
+56
View File
@@ -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.
+10
View File
@@ -31,6 +31,16 @@ graph TD
3. If `praisonaiagents` is installed, delegates to `execute_code` for real execution
4. Otherwise returns a simulation response with the code size
## Approval & Auth
Every `POST /api/code/execute` records an approval decision (`tool_name: "execute_code"`, `risk_level: "medium"`) through the [approvals](../api/features-api.md) manager:
- **Default policy** (`risk_threshold: "high"`) auto-approves and records the decision; the response includes `approval_id`.
- `always_deny_tools: ["execute_code"]` → `403 {"status": "denied"}`.
- A stricter policy (e.g. `risk_threshold: "low"`) → `202 {"status": "pending_approval", "approval_id": "..."}`. After approval via `POST /api/approvals/{id}/approve`, resubmit the **same** code with `"approval_id"`. Each approval works once and only for that exact code.
When auth is enabled (`/api/auth/config` mode other than `none`), code execution, approve/deny, and `PUT /api/approvals/policies` return `401` to unauthenticated callers. The recorded `approver` is the authenticated identity, not a value from the request body.
## Configuration
```python
+35
View File
@@ -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.
+107
View File
@@ -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.
+6
View File
@@ -95,6 +95,9 @@ 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
- Gateway Chat: features/gateway-chat.md
@@ -129,12 +132,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
View File
@@ -1,6 +1,6 @@
[project]
name = "aiui"
version = "0.3.146"
version = "0.3.147"
description = "YAML-driven website generator - CLI and compiler"
readme = "README.md"
license = {text = "MIT"}
+23 -2
View File
@@ -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:
+2 -1
View File
@@ -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",
+13
View File
@@ -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>
+104
View File
@@ -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, '{}')
})
+177
View File
@@ -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),
}
}
+69
View File
@@ -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
}
+19
View File
@@ -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'
+112
View File
@@ -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`.
+90
View File
@@ -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
View File
@@ -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>
);
}
+11
View File
@@ -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>,
);
+91
View File
@@ -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 -1
View File
@@ -1,3 +1,3 @@
"""Version information."""
__version__ = "0.3.146"
__version__ = "0.3.147"
+24 -2
View File
@@ -46,6 +46,20 @@ def _list_backend_approvals() -> List[Dict[str, Any]] | None:
return items if isinstance(items, list) else None
def _authenticated_user(request: Request) -> Optional[str]:
"""Return the caller identity, or ``None`` when auth is enabled and the caller is
unauthenticated. In auth mode ``none`` every caller is accepted (backward compat)."""
from .auth import check_auth
result = check_auth(request)
if not result:
return None
return result.get("user") or "user"
_UNAUTHORIZED = {"error": "Authentication required"}
# ── Approval Protocol ────────────────────────────────────────────────
@@ -446,13 +460,16 @@ class ApprovalsFeature(BaseFeatureProtocol):
return JSONResponse(result, status_code=201)
async def _approve(self, request: Request) -> JSONResponse:
approver = _authenticated_user(request)
if approver is None:
return JSONResponse(_UNAUTHORIZED, status_code=401)
mgr = get_approval_manager()
approval_id = request.path_params["approval_id"]
body = await request.json() if request.headers.get("content-length") else {}
entry = mgr.approve(
approval_id,
reason=body.get("reason", ""),
approver=body.get("approver", "user"),
approver=approver,
always=body.get("always", False),
)
if entry is None:
@@ -461,13 +478,16 @@ class ApprovalsFeature(BaseFeatureProtocol):
return JSONResponse(entry)
async def _deny(self, request: Request) -> JSONResponse:
approver = _authenticated_user(request)
if approver is None:
return JSONResponse(_UNAUTHORIZED, status_code=401)
mgr = get_approval_manager()
approval_id = request.path_params["approval_id"]
body = await request.json() if request.headers.get("content-length") else {}
entry = mgr.deny(
approval_id,
reason=body.get("reason", ""),
approver=body.get("approver", "user"),
approver=approver,
always=body.get("always", False),
)
if entry is None:
@@ -496,6 +516,8 @@ class ApprovalsFeature(BaseFeatureProtocol):
)
async def _update_policies(self, request: Request) -> JSONResponse:
if _authenticated_user(request) is None:
return JSONResponse(_UNAUTHORIZED, status_code=401)
mgr = get_approval_manager()
body = await request.json()
policies = mgr.update_policies(body)
+54 -3
View File
@@ -141,6 +141,9 @@ def set_execution_manager(manager: CodeExecutionProtocol) -> None:
_execution_manager = manager
EXECUTE_TOOL_NAME = "execute_code"
# ── Feature class ────────────────────────────────────────────────────
@@ -171,13 +174,61 @@ class CodeExecutionFeature(BaseFeatureProtocol):
return h
async def _execute(self, request: Request) -> JSONResponse:
mgr = get_execution_manager()
from .approvals import _authenticated_user, _notify_subscribers, get_approval_manager
if _authenticated_user(request) is None:
return JSONResponse({"error": "Authentication required"}, status_code=401)
body = await request.json()
code = body.get("code", "")
language = body.get("language", "python")
# Every execution goes through the approval manager so a decision is
# recorded. Default policy auto-approves (risk below threshold); operators
# can require manual approval via policies (e.g. lower ``risk_threshold``).
approvals = get_approval_manager()
args = {"code": code, "language": language}
approval_id = body.get("approval_id")
if approval_id:
decision = approvals.get(approval_id)
if (
not decision
or decision.get("status") != "approved"
or decision.get("source") != EXECUTE_TOOL_NAME
or decision.get("arguments") != args
or decision.get("consumed")
):
return JSONResponse({"error": "Approval not valid for this code"}, status_code=403)
else:
decision = approvals.request_approval(
{
"tool_name": EXECUTE_TOOL_NAME,
"arguments": args,
"risk_level": "medium",
# Only approvals issued here are redeemable; POST /api/approvals
# entries (caller-chosen risk/agent) cannot authorise execution.
"source": EXECUTE_TOOL_NAME,
}
)
if decision["status"] == "denied":
return JSONResponse(
{"status": "denied", "approval_id": decision["id"], "error": "Execution denied"},
status_code=403,
)
if decision["status"] != "approved":
await _notify_subscribers("new", decision)
return JSONResponse(
{"status": "pending_approval", "approval_id": decision["id"]},
status_code=202,
)
decision["consumed"] = True # single-use: no replay after policy changes
mgr = get_execution_manager()
result = mgr.execute(
code=body.get("code", ""),
language=body.get("language", "python"),
code=code,
language=language,
timeout=body.get("timeout", 30),
)
result = {**result, "approval_id": decision["id"]}
status = 200 if result.get("status") != "error" else 400
return JSONResponse(result, status_code=status)
@@ -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>';
+87
View File
@@ -0,0 +1,87 @@
"""Code execution is gated by approvals; approval endpoints honour auth (#311)."""
import pytest
from starlette.applications import Starlette
from starlette.testclient import TestClient
from praisonaiui.features import approvals as approvals_mod
from praisonaiui.features import auth as auth_mod
from praisonaiui.features.approvals import ApprovalsFeature, SimpleApprovalManager
from praisonaiui.features.code_execution import CodeExecutionFeature
@pytest.fixture
def client(monkeypatch):
mgr = SimpleApprovalManager()
monkeypatch.setattr(approvals_mod, "_approval_manager", mgr)
monkeypatch.setitem(auth_mod._auth_config, "mode", "none")
app = Starlette(routes=ApprovalsFeature().routes() + CodeExecutionFeature().routes())
c = TestClient(app)
c.mgr = mgr
return c
CODE = {"code": "print(1)", "language": "python"}
def test_default_policy_auto_approves_and_records(client):
r = client.post("/api/code/execute", json=CODE)
assert r.status_code == 200
aid = r.json()["approval_id"]
entry = client.mgr.get(aid)
assert entry["status"] == "approved" and entry["tool_name"] == "execute_code"
def test_denied_policy_blocks_execution(client):
client.mgr.update_policies({"always_deny_tools": ["execute_code"]})
r = client.post("/api/code/execute", json=CODE)
assert r.status_code == 403
assert r.json()["status"] == "denied"
def test_pending_then_approved_executes_once(client):
client.mgr.update_policies({"risk_threshold": "low"})
r = client.post("/api/code/execute", json=CODE)
assert r.status_code == 202
aid = r.json()["approval_id"]
# Not approved yet → rejected
assert client.post("/api/code/execute", json={**CODE, "approval_id": aid}).status_code == 403
assert client.post(f"/api/approvals/{aid}/approve", json={}).status_code == 200
# Approval is bound to the exact code
other = {"code": "print(2)", "language": "python", "approval_id": aid}
assert client.post("/api/code/execute", json=other).status_code == 403
assert client.post("/api/code/execute", json={**CODE, "approval_id": aid}).status_code == 200
# Single use
assert client.post("/api/code/execute", json={**CODE, "approval_id": aid}).status_code == 403
def test_auth_required_when_enabled(client, monkeypatch):
client.mgr.update_policies({"risk_threshold": "low"})
aid = client.post("/api/code/execute", json=CODE).json()["approval_id"]
monkeypatch.setitem(auth_mod._auth_config, "mode", "api_key")
assert client.post(f"/api/approvals/{aid}/approve", json={}).status_code == 401
assert client.post(f"/api/approvals/{aid}/deny", json={}).status_code == 401
assert client.put("/api/approvals/policies", json={"auto_approve_tools": ["x"]}).status_code == 401
assert client.post("/api/code/execute", json=CODE).status_code == 401
assert client.mgr.get(aid)["status"] == "pending"
monkeypatch.setitem(auth_mod._auth_config, "api_keys", {"pk_test": {"name": "alice"}})
r = client.post(f"/api/approvals/{aid}/approve", json={}, headers={"X-API-Key": "pk_test"})
assert r.status_code == 200
assert r.json()["approver"] == "alice"
def test_externally_created_approval_cannot_authorise_execution(client):
# Policy that would auto-approve a caller-chosen low-risk entry
client.mgr.update_policies({"risk_threshold": "medium"})
r = client.post(
"/api/approvals",
json={"tool_name": "execute_code", "arguments": CODE, "risk_level": "low"},
)
assert r.json()["status"] == "approved"
aid = r.json()["id"]
assert client.post("/api/code/execute", json={**CODE, "approval_id": aid}).status_code == 403
Generated
+92 -6
View File
@@ -207,7 +207,7 @@ wheels = [
[[package]]
name = "aiui"
version = "0.3.146"
version = "0.3.147"
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"