mirror of
https://github.com/MervinPraison/PraisonAIUI.git
synced 2026-11-07 05:56:17 -10:00
Compare commits
27
Commits
No files matched your search
@@ -3,7 +3,7 @@
|
||||
"stack": "claude",
|
||||
"profile": "sdk",
|
||||
"mode": "hub",
|
||||
"hubRef": "v1.1.7",
|
||||
"hubRef": "v1.1.8",
|
||||
"templateVersion": "1.1.7",
|
||||
"installedAt": "2026-09-07T10:02:11Z"
|
||||
}
|
||||
@@ -27,7 +27,7 @@ jobs:
|
||||
permissions:
|
||||
issues: write
|
||||
pull-requests: write
|
||||
uses: MervinPraison/github-automation-template/.github/workflows/[email protected].7
|
||||
uses: MervinPraison/github-automation-template/.github/workflows/[email protected].10
|
||||
secrets: inherit
|
||||
with:
|
||||
hub_ref: v1.1.7
|
||||
hub_ref: v1.1.10
|
||||
@@ -16,8 +16,8 @@ permissions:
|
||||
|
||||
jobs:
|
||||
recover:
|
||||
uses: MervinPraison/github-automation-template/.github/workflows/[email protected].7
|
||||
uses: MervinPraison/github-automation-template/.github/workflows/[email protected].10
|
||||
secrets: inherit
|
||||
with:
|
||||
hub_ref: v1.1.7
|
||||
hub_ref: v1.1.10
|
||||
pr_number: ${{ github.event.inputs.pr_number || '' }}
|
||||
@@ -25,8 +25,8 @@ permissions:
|
||||
|
||||
jobs:
|
||||
detect-and-trigger:
|
||||
uses: MervinPraison/github-automation-template/.github/workflows/[email protected].7
|
||||
uses: MervinPraison/github-automation-template/.github/workflows/[email protected].10
|
||||
secrets: inherit
|
||||
with:
|
||||
hub_ref: v1.1.7
|
||||
hub_ref: v1.1.10
|
||||
pr_number: ${{ github.event.inputs.pr_number || '' }}
|
||||
@@ -43,9 +43,9 @@ jobs:
|
||||
id-token: write
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: MervinPraison/github-automation-template/.github/actions/[email protected].7
|
||||
- uses: MervinPraison/github-automation-template/.github/actions/[email protected].10
|
||||
with:
|
||||
hub_ref: v1.1.7
|
||||
hub_ref: v1.1.10
|
||||
- name: Generate GitHub App token
|
||||
id: app-token
|
||||
uses: actions/create-github-app-token@v1
|
||||
@@ -87,9 +87,9 @@ jobs:
|
||||
issues: write
|
||||
actions: read
|
||||
id-token: write
|
||||
uses: MervinPraison/github-automation-template/.github/workflows/[email protected].7
|
||||
uses: MervinPraison/github-automation-template/.github/workflows/[email protected].10
|
||||
secrets: inherit
|
||||
with:
|
||||
hub_ref: v1.1.7
|
||||
hub_ref: v1.1.10
|
||||
pr_number: ${{ github.event.inputs.pr_number || github.event.client_payload.pr_number || '' }}
|
||||
merge_method: ${{ github.event.inputs.merge_method || 'merge' }}
|
||||
@@ -18,7 +18,7 @@ jobs:
|
||||
issues: write
|
||||
actions: read
|
||||
id-token: write
|
||||
uses: MervinPraison/github-automation-template/.github/workflows/[email protected].7
|
||||
uses: MervinPraison/github-automation-template/.github/workflows/[email protected].10
|
||||
secrets: inherit
|
||||
with:
|
||||
hub_ref: v1.1.7
|
||||
hub_ref: v1.1.10
|
||||
@@ -25,9 +25,9 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: MervinPraison/github-automation-template/.github/actions/[email protected].7
|
||||
- uses: MervinPraison/github-automation-template/.github/actions/[email protected].10
|
||||
with:
|
||||
hub_ref: v1.1.7
|
||||
hub_ref: v1.1.10
|
||||
- name: Detect conflicting PRs and trigger Claude
|
||||
uses: actions/github-script@v7
|
||||
with:
|
||||
|
||||
@@ -13,7 +13,7 @@ permissions:
|
||||
|
||||
jobs:
|
||||
sync:
|
||||
uses: MervinPraison/github-automation-template/.github/workflows/[email protected].7
|
||||
uses: MervinPraison/github-automation-template/.github/workflows/[email protected].10
|
||||
secrets: inherit
|
||||
with:
|
||||
hub_ref: v1.1.7
|
||||
hub_ref: v1.1.10
|
||||
@@ -366,3 +366,7 @@ pytest tests -v
|
||||
## License
|
||||
|
||||
MIT © [Praison Limited](https://praison.ai)
|
||||
|
||||
Third-party vendored source is documented in [THIRD_PARTY_NOTICES.md](THIRD_PARTY_NOTICES.md).
|
||||
See the [BeautifulUI upgrade playbook](docs/frontend/beautifului.md) for how BeautifulUI
|
||||
primitives are vendored and pinned.
|
||||
+37
@@ -0,0 +1,37 @@
|
||||
# Security Policy
|
||||
|
||||
We take the security of PraisonAIUI seriously and appreciate responsible
|
||||
disclosure of vulnerabilities.
|
||||
|
||||
## Supported Versions
|
||||
|
||||
Security fixes are applied to the latest released version of PraisonAIUI.
|
||||
Please make sure you are on the most recent release before reporting.
|
||||
|
||||
## Reporting a Vulnerability
|
||||
|
||||
**Please do not open a public issue for security vulnerabilities.**
|
||||
|
||||
Report privately through GitHub's **Private Vulnerability Reporting**:
|
||||
|
||||
1. Go to the [Security tab](https://github.com/MervinPraison/PraisonAIUI/security)
|
||||
of this repository.
|
||||
2. Click **"Report a vulnerability"** to open a private advisory.
|
||||
3. Include as much detail as you can:
|
||||
- Affected component(s) and version.
|
||||
- Steps to reproduce.
|
||||
- Impact assessment.
|
||||
- Any suggested fix or mitigation.
|
||||
|
||||
If you are unable to use private reporting, you may open a public issue that
|
||||
contains **no technical specifics** and requests a private channel, and a
|
||||
maintainer will follow up.
|
||||
|
||||
## What to Expect
|
||||
|
||||
- We aim to acknowledge new reports within a few business days.
|
||||
- We will keep you informed as we investigate and work on a fix.
|
||||
- Once a fix is released, we are happy to credit you in the advisory (unless
|
||||
you prefer to remain anonymous).
|
||||
|
||||
Thank you for helping keep PraisonAIUI and its users safe.
|
||||
@@ -0,0 +1,43 @@
|
||||
# Third-Party Notices
|
||||
|
||||
PraisonAIUI incorporates or vendors source from the third-party projects listed below.
|
||||
Their licences and copyright notices are retained here as required.
|
||||
|
||||
---
|
||||
|
||||
## BeautifulUI
|
||||
|
||||
- Project: BeautifulUI — <https://www.beautifului.dev/>
|
||||
- Source: <https://github.com/slev12397/beautiful-ui>
|
||||
- Licence: MIT
|
||||
|
||||
BeautifulUI is distributed as a shadcn registry / copy-paste library (no npm package).
|
||||
When its React primitives are vendored into `src/frontend`, the following MIT licence
|
||||
applies to that copied source.
|
||||
|
||||
```
|
||||
MIT License
|
||||
|
||||
Copyright (c) Shane Levine
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
```
|
||||
|
||||
See the upgrade playbook at [`docs/frontend/beautifului.md`](docs/frontend/beautifului.md)
|
||||
for how BeautifulUI is vendored and pinned.
|
||||
@@ -59,6 +59,11 @@ navigation:
|
||||
- typescript/runtime
|
||||
- typescript/nextjs
|
||||
- typescript/components
|
||||
- group: "Frontend"
|
||||
icon: "layout"
|
||||
prefix: "frontend"
|
||||
pages:
|
||||
- frontend/beautifului
|
||||
- group: "Examples"
|
||||
icon: "folder-open"
|
||||
prefix: "examples"
|
||||
|
||||
@@ -0,0 +1,56 @@
|
||||
# BeautifulUI Integration Strategy
|
||||
|
||||
> **Epic:** [#292](https://github.com/MervinPraison/PraisonAIUI/issues/292) — BeautifulUI integration strategy for AI-native dashboard UX.
|
||||
> This is the durable, agreed strategy that the linked child issues execute against.
|
||||
|
||||
[BeautifulUI](https://www.beautifului.dev/) (MIT, Shane Levine / Turbo) is a **copy-paste / shadcn-registry** library of React primitives for AI-native interfaces: thinking traces, streaming answers with inline sources, HITL approval cards, tool chips, task rows, context chunks, diff/records tables, prompt bars, and a reference chat harness.
|
||||
|
||||
There is **no published npm package** named `beautiful-ui`. It ships only as source consumed via the shadcn registry or manual copy.
|
||||
|
||||
## 1. Strategic decision
|
||||
|
||||
BeautifulUI is adopted as **vendored source**, not a runtime dependency, and is exposed to dashboard plugins **only through the existing `@praisonaiui/react` package** — never by importing upstream file paths directly. Rollout is **phased behind a feature flag**, keeping the current vanilla dashboard as the fallback until parity is proven.
|
||||
|
||||
Upstream stack (Next.js 15 · React 19 · Tailwind v4 · TypeScript) is closely aligned with PraisonAIUI's `src/frontend` (Vite · React 19 · Tailwind v4 · shadcn/Radix), so primitives port with adapter work rather than a runtime bridge.
|
||||
|
||||
## 2. Pillars
|
||||
|
||||
| # | Pillar | Decision |
|
||||
|---|--------|----------|
|
||||
| 1 | **Vendoring model** | Add primitives into `src/frontend` via `npx shadcn add https://www.beautifului.dev/r/<component>.json`, or manual copy from [github.com/slev12397/beautiful-ui](https://github.com/slev12397/beautiful-ui). Pin via lockfile plus a recorded registry URL / git SHA. No `beautiful-ui` npm dependency in production builds. |
|
||||
| 2 | **Adapter layer** | Map PraisonAIUI chat / approval / trace **protocol events** to stable internal props. Primitives sit behind `@praisonaiui/react` exports; dashboard plugins consume those exports, not upstream paths. See [#294](https://github.com/MervinPraison/PraisonAIUI/issues/294). |
|
||||
| 3 | **Hybrid rollout** | Mount React **islands** (or expand `@praisonaiui/react`) for high-value surfaces first — chat streaming/thinking/tools, then approvals. Vanilla chat (`templates/frontend/plugins/views/chat.js`) stays as the flag-gated fallback until parity is proven. |
|
||||
| 4 | **Token strategy** | Merge BeautifulUI foundation CSS **selectively** into the existing shadcn theme generation (`praisonaiui/themes.py`). One design system per page — avoid two competing token sets. |
|
||||
| 5 | **Icon licence** | Upstream `SidebarNav` uses the paid `@central-icons-react`. Port nav patterns with **lucide-react** (already in PraisonAIUI) instead. |
|
||||
|
||||
## 3. Phasing
|
||||
|
||||
1. **Spike** — evaluate primitives in the dev environment, confirm React 19 / Tailwind v4 alignment. [#293](https://github.com/MervinPraison/PraisonAIUI/issues/293)
|
||||
2. **Architecture** — adapter layer + upstream pin policy. [#294](https://github.com/MervinPraison/PraisonAIUI/issues/294)
|
||||
3. **CI gate** — frontend build + BeautifulUI compatibility check (no frontend build gate exists today). [#295](https://github.com/MervinPraison/PraisonAIUI/issues/295)
|
||||
4. **Migrations** — chat streaming / thinking / tool chips ([#296](https://github.com/MervinPraison/PraisonAIUI/issues/296)), then the approval card ([#297](https://github.com/MervinPraison/PraisonAIUI/issues/297)).
|
||||
5. **Docs** — upgrade playbook + MIT attribution. [#298](https://github.com/MervinPraison/PraisonAIUI/issues/298)
|
||||
|
||||
## 4. Risks
|
||||
|
||||
| Risk | Mitigation |
|
||||
|------|------------|
|
||||
| No semver npm package | Vendor the source; pin to a recorded registry URL / git SHA in the lockfile. |
|
||||
| Upstream breaking registry JSON | Vendoring decouples builds from live registry; re-sync is a deliberate, reviewed step. |
|
||||
| Icon licence (`@central-icons-react` is paid) | Use lucide-react for ported nav patterns; never vendor the paid package. |
|
||||
| Two competing design systems on one page | Selective CSS token merge into `themes.py`; adapter-only prop surface. |
|
||||
| React / Tailwind drift | Upstream (React 19 / Tailwind v4) already matches `src/frontend`; the CI gate catches drift. |
|
||||
|
||||
## 5. Out of scope
|
||||
|
||||
- Replacing the entire dashboard shell in one PR.
|
||||
- PyPI packaging of BeautifulUI.
|
||||
- iframe embedding of beautifului.dev.
|
||||
- Depending on any unpublished npm package for production builds.
|
||||
|
||||
## 6. Acceptance criteria status
|
||||
|
||||
- [x] Written integration strategy agreed (vendoring + adapter + phased rollout) — this document.
|
||||
- [x] Risks documented — section 4.
|
||||
- [x] Child issues filed and prioritised — [#293](https://github.com/MervinPraison/PraisonAIUI/issues/293)–[#298](https://github.com/MervinPraison/PraisonAIUI/issues/298).
|
||||
- [x] No requirement to depend on unpublished npm packages for production builds — pillar 1, out of scope.
|
||||
@@ -0,0 +1,35 @@
|
||||
# BeautifulUI upstream pin record
|
||||
|
||||
BeautifulUI is vendored (shadcn-style registry copy), **not** an npm dependency.
|
||||
This file is the single source of truth for what upstream revision is vendored.
|
||||
Update it in the **same PR** that adds or bumps any vendored component, and make
|
||||
sure the frontend CI gate (issue #295) passes.
|
||||
|
||||
See [beautifului-adapter-architecture.md](./beautifului-adapter-architecture.md)
|
||||
for the adapter boundary and folder layout.
|
||||
|
||||
## Pin
|
||||
|
||||
| Field | Value |
|
||||
|-------|-------|
|
||||
| Upstream repo | _TBD — record on first vendor_ |
|
||||
| Git SHA | _TBD_ |
|
||||
| Vendored on (date) | _TBD_ |
|
||||
| Registry base URL | _TBD_ |
|
||||
| License | MIT (kept in-tree) |
|
||||
|
||||
## Vendored components
|
||||
|
||||
Record one row per component the first time it is copied in.
|
||||
|
||||
| Component | Registry URL | SHA | Vendored path | Date |
|
||||
|-----------|--------------|-----|---------------|------|
|
||||
| _(none yet)_ | | | | |
|
||||
|
||||
## Upgrade checklist
|
||||
|
||||
1. Update the SHA / date / registry rows above.
|
||||
2. Re-copy changed components into `src/frontend/src/agent-ui/vendors/beautifului/`.
|
||||
3. Keep all upstream imports inside `src/agent-ui/` (adapter boundary rule).
|
||||
4. Run the frontend build + adapter unit tests (frontend CI gate, #295).
|
||||
5. Do **not** add a `beautiful-ui` entry to `package.json` dependencies.
|
||||
@@ -0,0 +1,102 @@
|
||||
# ADR: BeautifulUI adapter layer & upstream pin policy
|
||||
|
||||
**Status:** Accepted · **Issue:** [#294](https://github.com/MervinPraison/PraisonAIUI/issues/294)
|
||||
|
||||
## Context
|
||||
|
||||
BeautifulUI is **not** a semver npm library — it is distributed as shadcn-style
|
||||
registry components that are copied into the consuming repo. PraisonAIUI must
|
||||
integrate it without leaking upstream types or import paths across the codebase,
|
||||
and without breaking when either side updates.
|
||||
|
||||
## Decision
|
||||
|
||||
Introduce a single **adapter boundary** between transport payloads and the UI,
|
||||
and vendor BeautifulUI components behind that boundary.
|
||||
|
||||
```
|
||||
Agent APIs (Python) ──► event bus / WS messages
|
||||
│
|
||||
▼
|
||||
Agent-UI adapter (src/agent-ui/adapter.ts)
|
||||
(stable PraisonAIUI contracts)
|
||||
│
|
||||
▼
|
||||
@praisonaiui/react re-exports
|
||||
(BeautifulUI vendored components)
|
||||
│
|
||||
▼
|
||||
Dashboard / chat mount points (React islands, then full shell)
|
||||
```
|
||||
|
||||
### 1. Folder layout
|
||||
|
||||
```
|
||||
src/frontend/src/agent-ui/
|
||||
├── contracts.ts # Stable PraisonAIUI view models (the ONLY types consumers import)
|
||||
├── adapter.ts # Pure mappers: wire payload -> contracts
|
||||
├── adapter.test.ts # Unit tests (mock events -> props), node:test
|
||||
├── index.ts # Barrel: contracts + adapters + (later) vendored re-exports
|
||||
└── vendors/
|
||||
└── beautifului/ # Vendored BeautifulUI components (added during migration)
|
||||
```
|
||||
|
||||
**Rule:** BeautifulUI / upstream prop types must **never** be imported outside
|
||||
`src/agent-ui/`. Consumers depend on `contracts.ts` only, so the vendored
|
||||
implementation can be swapped later without touching call sites.
|
||||
|
||||
### 2. Adapter contracts
|
||||
|
||||
Internal, transport-agnostic types live in `contracts.ts`:
|
||||
|
||||
- `AgentStreamEvent` — normalized form of the WebSocket frames handled in
|
||||
`chat.js` (`run_started`, `run_content`, `tool_call_started`, …). Unknown
|
||||
frame `type`s degrade to `kind: 'unknown'` with `rawType` preserved instead
|
||||
of throwing, so new upstream events never break the UI.
|
||||
- `ToolCallChipModel` — tool-call chip view model.
|
||||
- `ApprovalPromptModel` — approval card view model, mapped from the
|
||||
`/api/approvals/pending` REST payload.
|
||||
|
||||
Mappers (`adapter.ts`): `mapChatEventToUi(frame)` and
|
||||
`mapApprovalToCardProps(item)`. Both are **pure** and unit tested.
|
||||
|
||||
### 3. Vendoring & pin policy
|
||||
|
||||
| Mechanism | Purpose |
|
||||
|-----------|---------|
|
||||
| [`BEAUTIFULUI_UPSTREAM.md`](./BEAUTIFULUI_UPSTREAM.md) | Record git SHA, date, registry base URL for every vendored component |
|
||||
| shadcn registry URLs | Per-component install, pinned in the upstream doc |
|
||||
| **No** `dependencies.beautiful-ui` in `package.json` | Avoid a phantom npm package |
|
||||
|
||||
**Upgrade rule:** bump upstream **only** via a PR that updates
|
||||
`BEAUTIFULUI_UPSTREAM.md` and passes the frontend CI gate (issue #295). Icons
|
||||
stay on `lucide-react`; do not vendor paid Central icons.
|
||||
|
||||
### 4. Feature flag (default **off**)
|
||||
|
||||
Integration is gated so the existing vanilla-JS chat/approvals plugins remain
|
||||
the fallback until migration milestones (#296, #297) complete.
|
||||
|
||||
- **Flag:** `AIUI_BEAUTIFULUI` (build-time env / dashboard config JSON).
|
||||
- **Default:** off — vanilla JS renders; the adapter is imported but not mounted.
|
||||
- **On:** React islands mount using `src/agent-ui` contracts.
|
||||
- **Fallback:** if a vendored component fails to load, the flag is treated as
|
||||
off and the vanilla path renders. No user-facing regression when off.
|
||||
|
||||
### 5. Design tokens
|
||||
|
||||
Scope agent-UI primitives under an `.aiui-agent-ui { … }` CSS layer (or extend
|
||||
the existing shadcn presets in `themes.py`) so vendored component tokens cannot
|
||||
collide with the global dashboard theme.
|
||||
|
||||
### 6. Deprecation / upstream abandonment
|
||||
|
||||
Vendored code stays MIT-licensed in-tree. Because consumers depend only on the
|
||||
`contracts.ts` view models, a future first-party implementation can satisfy the
|
||||
same contracts and replace the vendored components with no call-site churn.
|
||||
|
||||
## Consequences
|
||||
|
||||
- One place (`src/agent-ui/`) owns the upstream coupling.
|
||||
- Adapters are testable without a browser or the transport layer.
|
||||
- The default-off flag keeps the migration reversible at every step.
|
||||
@@ -0,0 +1,107 @@
|
||||
# BeautifulUI Upgrade Playbook
|
||||
|
||||
How PraisonAIUI vendors [BeautifulUI](https://www.beautifului.dev/) React primitives so
|
||||
upgrades are **repeatable** and licence obligations are **explicit**.
|
||||
|
||||
!!! info "Related planning"
|
||||
Epic [#292](https://github.com/MervinPraison/PraisonAIUI/issues/292) (integration
|
||||
strategy) and the adapter/pin policy in
|
||||
[#294](https://github.com/MervinPraison/PraisonAIUI/issues/294).
|
||||
|
||||
## 1. What BeautifulUI is
|
||||
|
||||
BeautifulUI (MIT, © Shane Levine / Turbo) is a library of React primitives aimed at
|
||||
**AI-native interfaces** — thinking traces, streaming answers with inline sources,
|
||||
human-in-the-loop approval cards, tool chips, task rows, context chunks, and a reference
|
||||
chat harness.
|
||||
|
||||
**It is a shadcn registry + copy-paste library — not an npm package.** There is no
|
||||
published `beautiful-ui` package. You install components by resolving a registry URL, and
|
||||
the source is copied into your tree (like any other shadcn/ui primitive).
|
||||
|
||||
- Website & registry: <https://www.beautifului.dev/>
|
||||
- Upstream source: <https://github.com/slev12397/beautiful-ui>
|
||||
|
||||
Upstream stack (Next.js 15 · React 19 · Tailwind CSS v4 · TypeScript) is closely aligned
|
||||
with PraisonAIUI's `src/frontend` (Vite · React 19 · Tailwind v4 · shadcn/Radix), which is
|
||||
why copy-paste vendoring works without a framework shim.
|
||||
|
||||
## 2. One-time foundation setup
|
||||
|
||||
PraisonAIUI already carries the shadcn/ui foundation that BeautifulUI primitives depend on:
|
||||
|
||||
- **shadcn config** — `src/frontend/components.json` (`style: new-york`,
|
||||
`baseColor: neutral`, `iconLibrary: lucide`, `cssVariables: true`). Its `registries`
|
||||
map is where pinned BeautifulUI registry URLs are added (see §3).
|
||||
- **Theme tokens** — `src/praisonaiui/themes.py` generates the shadcn CSS variables
|
||||
(`--background`, `--foreground`, `--primary`, `--radius`, …) at build time for all 22
|
||||
official presets, offline. BeautifulUI's foundation CSS (upstream `app/globals.css`)
|
||||
must only contribute **new** tokens/utilities; it must **not** redefine variables that
|
||||
`themes.py` already owns, or theme switching and dark mode break.
|
||||
|
||||
**Rule:** when merging upstream `app/globals.css`, diff it against the variables emitted by
|
||||
`themes.py` and keep only the additive slices (new keyframes, component-scoped utilities).
|
||||
Record what was merged in the pin doc (§3).
|
||||
|
||||
## 3. Adding or updating a primitive
|
||||
|
||||
1. **Install with a pinned registry URL** from `src/frontend/`:
|
||||
```bash
|
||||
npx shadcn add https://www.beautifului.dev/r/<component>.json
|
||||
```
|
||||
The registry resolves dependencies and CSS slices. Commit the resulting
|
||||
`package-lock.json` change in the same PR.
|
||||
2. **Re-export through the adapter** — add the component to
|
||||
`src/frontend/src/components/index.ts` (`@praisonaiui/react`). Consumers never import
|
||||
the raw vendored path (see §4).
|
||||
3. **Update the pin record** — create/update `docs/frontend/BEAUTIFULUI_UPSTREAM.md` with:
|
||||
the upstream **git SHA**, the **date**, the **registry base URL**, and the **component
|
||||
list** vendored so far. This file is the single source of truth for "what version are
|
||||
we on". (It is created the first time a real primitive is vendored — do not add it
|
||||
empty.)
|
||||
4. **Rebuild & sync the bundle** — the committed frontend bundle must be regenerated:
|
||||
```bash
|
||||
cd src/frontend && npm run build
|
||||
# then sync dist/{index.html,assets/,icon.svg} → src/praisonaiui/templates/frontend/
|
||||
```
|
||||
(See `AGENTS.md` → "Frontend Build".)
|
||||
5. **Run frontend CI** — `.github/workflows/ci.yml` (ruff + pytest) must pass, plus the
|
||||
frontend build step.
|
||||
|
||||
## 4. Adapter boundary
|
||||
|
||||
Consumers import from **`@praisonaiui/react` only** — never from raw vendored paths such as
|
||||
`@/components/ui/...` or any upstream module.
|
||||
|
||||
```tsx
|
||||
// ✅ stable PraisonAIUI contract
|
||||
import { Button, ThemeToggle } from '@praisonaiui/react'
|
||||
|
||||
// ❌ leaks upstream/shadcn paths across the codebase
|
||||
import { Button } from '@/components/ui/button'
|
||||
```
|
||||
|
||||
This keeps the upstream surface behind one file (`components/index.ts`), so a BeautifulUI
|
||||
bump changes vendored source but not consumer imports.
|
||||
|
||||
## 5. Rollback
|
||||
|
||||
- BeautifulUI-backed views mount behind a **feature flag**; the **vanilla dashboard views**
|
||||
(`src/praisonaiui/templates/frontend/plugins/*`) remain the fallback and are kept working.
|
||||
- To roll back a bad bump: revert the vendoring PR (source + `BEAUTIFULUI_UPSTREAM.md` +
|
||||
rebuilt bundle move together in one commit), and disable the flag. No consumer import
|
||||
changes are needed because everything routes through the adapter (§4).
|
||||
|
||||
## 6. Licence
|
||||
|
||||
BeautifulUI is **MIT, © Shane Levine**. The full licence text is retained in
|
||||
[`THIRD_PARTY_NOTICES.md`](https://github.com/MervinPraison/PraisonAIUI/blob/main/THIRD_PARTY_NOTICES.md)
|
||||
at the repository root. Any PR that vendors or bumps BeautifulUI source must keep that
|
||||
notice present and current.
|
||||
|
||||
## 7. Commercial icons — do not vendor
|
||||
|
||||
Do **not** use `@central-icons-react` without a licence — it is commercial. PraisonAIUI
|
||||
standardises on **`lucide-react`** (`iconLibrary: lucide` in `components.json`). If an
|
||||
upstream BeautifulUI primitive imports commercial icons, swap them for lucide equivalents
|
||||
during vendoring.
|
||||
@@ -17,6 +17,25 @@ def _tcp_open(host: str, port: int, timeout: float = 2.0) -> bool:
|
||||
return False
|
||||
|
||||
|
||||
_MCP_PROBE_TIMEOUT_SEC = 2.0
|
||||
|
||||
|
||||
def _is_slow_mcp_probe(detail: str) -> bool:
|
||||
lowered = detail.lower()
|
||||
return any(token in lowered for token in ("timed out", "timeout", "temporarily unavailable"))
|
||||
|
||||
|
||||
def _mcp_servers_probe(app_port: int) -> tuple[str, str]:
|
||||
"""Probe MCP list endpoint; slow stdio connect must not fail the whole doctor run."""
|
||||
status, detail = _http_status(
|
||||
f"http://127.0.0.1:{app_port}/api/mcp/servers",
|
||||
timeout=_MCP_PROBE_TIMEOUT_SEC,
|
||||
)
|
||||
if status == "fail" and _is_slow_mcp_probe(detail):
|
||||
return "warn", detail
|
||||
return status, detail
|
||||
|
||||
|
||||
def _http_status(url: str, timeout: float = 5.0) -> tuple[str, str]:
|
||||
try:
|
||||
req = Request(url, headers={"User-Agent": "praisonai-voice-doctor"})
|
||||
@@ -64,7 +83,7 @@ def run_voice_doctor(*, app_port: int = 8001, sidecar_port: int = 8002) -> dict[
|
||||
checks.append({"name": "Speech Engine config", "status": status, "detail": detail})
|
||||
status, detail = _http_status(f"http://127.0.0.1:{app_port}/api/voice/realtime/config")
|
||||
checks.append({"name": "GPT Realtime config", "status": status, "detail": detail})
|
||||
status, detail = _http_status(f"http://127.0.0.1:{app_port}/api/mcp/servers")
|
||||
status, detail = _mcp_servers_probe(app_port)
|
||||
checks.append({"name": "MCP servers API", "status": status, "detail": detail})
|
||||
else:
|
||||
checks.append(
|
||||
|
||||
@@ -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
@@ -1,6 +1,6 @@
|
||||
[project]
|
||||
name = "aiui"
|
||||
version = "0.3.144"
|
||||
version = "0.3.147"
|
||||
description = "YAML-driven website generator - CLI and compiler"
|
||||
readme = "README.md"
|
||||
license = {text = "MIT"}
|
||||
|
||||
@@ -29,7 +29,8 @@
|
||||
"build": "tsc -b && vite build",
|
||||
"build:lib": "tsc -b && vite build --mode lib",
|
||||
"lint": "eslint .",
|
||||
"preview": "vite preview"
|
||||
"preview": "vite preview",
|
||||
"test": "node --experimental-strip-types --test \"src/agent-ui/**/*.test.ts\""
|
||||
},
|
||||
"keywords": [
|
||||
"react",
|
||||
|
||||
@@ -0,0 +1,104 @@
|
||||
/**
|
||||
* Unit tests for the Agent-UI adapter (mock events -> stable contracts).
|
||||
*
|
||||
* Dependency-free: uses the Node built-in test runner so it needs no extra
|
||||
* toolchain. Run with `npm test` (from src/frontend), which invokes the Node
|
||||
* test runner with `--experimental-strip-types` so the TypeScript types are
|
||||
* stripped on Node 22+. The frontend CI gate (see issue #295) wires this in.
|
||||
*/
|
||||
|
||||
import { test } from 'node:test'
|
||||
import assert from 'node:assert/strict'
|
||||
|
||||
import { mapApprovalToCardProps, mapChatEventToUi } from './adapter.ts'
|
||||
|
||||
test('mapChatEventToUi normalizes a streaming token frame', () => {
|
||||
const event = mapChatEventToUi({
|
||||
type: 'run_content',
|
||||
token: 'Hello',
|
||||
agent_name: 'Researcher',
|
||||
session_id: 's1',
|
||||
run_id: 'r1',
|
||||
})
|
||||
assert.equal(event.kind, 'run-token')
|
||||
assert.equal(event.text, 'Hello')
|
||||
assert.equal(event.agentName, 'Researcher')
|
||||
assert.equal(event.sessionId, 's1')
|
||||
assert.equal(event.runId, 'r1')
|
||||
})
|
||||
|
||||
test('mapChatEventToUi maps a started tool call to a running chip', () => {
|
||||
const event = mapChatEventToUi({
|
||||
type: 'tool_call_started',
|
||||
name: 'search',
|
||||
description: '🔎 Searching the web',
|
||||
icon: '🔎',
|
||||
step_number: 2,
|
||||
tool_call_id: 'tc-1',
|
||||
})
|
||||
assert.equal(event.kind, 'tool-call')
|
||||
assert.deepEqual(event.toolCall, {
|
||||
id: 'tc-1',
|
||||
label: '🔎 Searching the web',
|
||||
icon: '🔎',
|
||||
stepNumber: 2,
|
||||
status: 'running',
|
||||
error: undefined,
|
||||
})
|
||||
})
|
||||
|
||||
test('mapChatEventToUi marks completed tool calls as done', () => {
|
||||
const event = mapChatEventToUi({ type: 'tool_call_completed', name: 'search' })
|
||||
assert.equal(event.toolCall?.status, 'done')
|
||||
// Falls back to the tool name for id and label when unspecified.
|
||||
assert.equal(event.toolCall?.id, 'search')
|
||||
assert.equal(event.toolCall?.label, 'search')
|
||||
})
|
||||
|
||||
test('mapChatEventToUi treats team_* frames the same as their solo variant', () => {
|
||||
assert.equal(mapChatEventToUi({ type: 'team_run_content', token: 'x' }).kind, 'run-token')
|
||||
assert.equal(mapChatEventToUi({ type: 'team_run_error', error: 'boom' }).kind, 'run-error')
|
||||
})
|
||||
|
||||
test('mapChatEventToUi preserves unknown frame types without throwing', () => {
|
||||
const event = mapChatEventToUi({ type: 'future_event', foo: 'bar' })
|
||||
assert.equal(event.kind, 'unknown')
|
||||
assert.equal(event.rawType, 'future_event')
|
||||
})
|
||||
|
||||
test('mapChatEventToUi defaults run errors to a readable message', () => {
|
||||
const event = mapChatEventToUi({ type: 'run_error' })
|
||||
assert.equal(event.kind, 'run-error')
|
||||
assert.equal(event.error, 'Unknown error')
|
||||
})
|
||||
|
||||
test('mapApprovalToCardProps maps a pending approval item', () => {
|
||||
const card = mapApprovalToCardProps({
|
||||
id: 'a-1',
|
||||
tool_name: 'delete_file',
|
||||
agent_name: 'Ops',
|
||||
risk_level: 'high',
|
||||
risk_icon: '🔥',
|
||||
description: 'Deletes a file',
|
||||
arguments: { path: '/tmp/x' },
|
||||
// Approvals API emits epoch seconds (time.time()), not an ISO string.
|
||||
created_at: 1758585600,
|
||||
})
|
||||
assert.equal(card.id, 'a-1')
|
||||
assert.equal(card.toolName, 'delete_file')
|
||||
assert.equal(card.agentName, 'Ops')
|
||||
assert.equal(card.riskLevel, 'high')
|
||||
assert.equal(card.riskIcon, '🔥')
|
||||
assert.equal(card.argumentsJson, '{\n "path": "/tmp/x"\n}')
|
||||
// Epoch seconds are normalized to an ISO-8601 string for display.
|
||||
assert.equal(card.createdAt, '2025-09-23T00:00:00.000Z')
|
||||
})
|
||||
|
||||
test('mapApprovalToCardProps falls back for missing/invalid fields', () => {
|
||||
const card = mapApprovalToCardProps({ id: 'a-2', risk_level: 'nonsense' })
|
||||
assert.equal(card.toolName, 'unknown')
|
||||
assert.equal(card.agentName, 'Unknown')
|
||||
assert.equal(card.riskLevel, 'medium')
|
||||
assert.equal(card.riskIcon, '⚠️')
|
||||
assert.equal(card.argumentsJson, '{}')
|
||||
})
|
||||
@@ -0,0 +1,177 @@
|
||||
/**
|
||||
* PraisonAIUI Agent-UI adapter
|
||||
*
|
||||
* Pure mapping functions from wire payloads to stable PraisonAIUI contracts.
|
||||
* This is the single boundary between transport shapes (WebSocket frames from
|
||||
* chat.js, REST payloads from /api/approvals/*) and the UI layer. Vendored
|
||||
* BeautifulUI components are rendered from these contracts elsewhere in
|
||||
* `src/agent-ui/`; consumers never import upstream prop types directly.
|
||||
*
|
||||
* Functions are intentionally side-effect free so they can be unit tested
|
||||
* with mock events (see adapter.test.ts).
|
||||
*/
|
||||
|
||||
import type {
|
||||
AgentStreamEvent,
|
||||
ApprovalPromptModel,
|
||||
ApprovalRiskLevel,
|
||||
ToolCallChipModel,
|
||||
} from './contracts'
|
||||
|
||||
/** Raw WebSocket frame as parsed in chat.js (`JSON.parse(event.data)`). */
|
||||
export type RawChatFrame = Record<string, unknown>
|
||||
|
||||
/** Raw approval item as returned by `/api/approvals/pending`. */
|
||||
export type RawApprovalItem = Record<string, unknown>
|
||||
|
||||
const VALID_RISK_LEVELS: readonly ApprovalRiskLevel[] = [
|
||||
'low',
|
||||
'medium',
|
||||
'high',
|
||||
'critical',
|
||||
]
|
||||
|
||||
function asString(value: unknown): string | undefined {
|
||||
return typeof value === 'string' ? value : undefined
|
||||
}
|
||||
|
||||
function asNumber(value: unknown): number | undefined {
|
||||
return typeof value === 'number' ? value : undefined
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalize a `created_at` value to an ISO-8601 string. The approvals API
|
||||
* emits epoch seconds (`time.time()`), while some payloads already carry an
|
||||
* ISO string — accept both, drop anything else.
|
||||
*/
|
||||
function asTimestamp(value: unknown): string | undefined {
|
||||
if (typeof value === 'string') return value
|
||||
if (typeof value === 'number' && Number.isFinite(value)) {
|
||||
return new Date(value * 1000).toISOString()
|
||||
}
|
||||
return undefined
|
||||
}
|
||||
|
||||
/** Map a raw tool-call frame to a stable chip view model. */
|
||||
function mapToolCall(
|
||||
frame: RawChatFrame,
|
||||
status: ToolCallChipModel['status'],
|
||||
): ToolCallChipModel {
|
||||
const name = asString(frame.name) ?? 'tool'
|
||||
return {
|
||||
id: asString(frame.tool_call_id) ?? name,
|
||||
label: asString(frame.description) ?? name,
|
||||
icon: asString(frame.icon),
|
||||
stepNumber: asNumber(frame.step_number),
|
||||
status,
|
||||
error: asString(frame.error),
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Map a raw WebSocket chat frame to a normalized {@link AgentStreamEvent}.
|
||||
*
|
||||
* Unknown frame types are preserved as `kind: 'unknown'` with `rawType` set,
|
||||
* so new upstream event types degrade gracefully instead of throwing.
|
||||
*/
|
||||
export function mapChatEventToUi(frame: RawChatFrame): AgentStreamEvent {
|
||||
const rawType = asString(frame.type)
|
||||
const sessionId = asString(frame.session_id)
|
||||
const runId = asString(frame.run_id)
|
||||
const agentName = asString(frame.agent_name)
|
||||
|
||||
switch (rawType) {
|
||||
case 'run_started':
|
||||
return { kind: 'run-started', sessionId, runId, agentName }
|
||||
case 'run_content':
|
||||
case 'team_run_content':
|
||||
return {
|
||||
kind: 'run-token',
|
||||
sessionId,
|
||||
runId,
|
||||
agentName,
|
||||
text: asString(frame.token),
|
||||
}
|
||||
case 'run_completed':
|
||||
case 'team_run_completed':
|
||||
return {
|
||||
kind: 'run-completed',
|
||||
sessionId,
|
||||
runId,
|
||||
agentName,
|
||||
text: asString(frame.content),
|
||||
}
|
||||
case 'run_error':
|
||||
case 'team_run_error':
|
||||
return {
|
||||
kind: 'run-error',
|
||||
sessionId,
|
||||
runId,
|
||||
agentName,
|
||||
error: asString(frame.error) ?? 'Unknown error',
|
||||
}
|
||||
case 'tool_call_started':
|
||||
case 'team_tool_call_started':
|
||||
return {
|
||||
kind: 'tool-call',
|
||||
sessionId,
|
||||
runId,
|
||||
agentName,
|
||||
toolCall: mapToolCall(frame, 'running'),
|
||||
}
|
||||
case 'tool_call_completed':
|
||||
case 'team_tool_call_completed':
|
||||
return {
|
||||
kind: 'tool-call',
|
||||
sessionId,
|
||||
runId,
|
||||
agentName,
|
||||
toolCall: mapToolCall(frame, 'done'),
|
||||
}
|
||||
case 'reasoning_step':
|
||||
case 'team_reasoning_step':
|
||||
return {
|
||||
kind: 'reasoning-step',
|
||||
sessionId,
|
||||
runId,
|
||||
agentName,
|
||||
text: asString(frame.step),
|
||||
}
|
||||
default:
|
||||
return { kind: 'unknown', sessionId, runId, agentName, rawType }
|
||||
}
|
||||
}
|
||||
|
||||
function normalizeRisk(value: unknown): ApprovalRiskLevel {
|
||||
return VALID_RISK_LEVELS.includes(value as ApprovalRiskLevel)
|
||||
? (value as ApprovalRiskLevel)
|
||||
: 'medium'
|
||||
}
|
||||
|
||||
/**
|
||||
* Map a raw approval item from `/api/approvals/pending` to a stable
|
||||
* {@link ApprovalPromptModel}. Arguments are pretty-printed for display and
|
||||
* an unknown/missing risk level falls back to `medium`.
|
||||
*/
|
||||
export function mapApprovalToCardProps(
|
||||
item: RawApprovalItem,
|
||||
): ApprovalPromptModel {
|
||||
let argumentsJson = '{}'
|
||||
try {
|
||||
argumentsJson = JSON.stringify(item.arguments ?? {}, null, 2)
|
||||
} catch {
|
||||
// Circular or non-serializable args — degrade to empty object.
|
||||
argumentsJson = '{}'
|
||||
}
|
||||
|
||||
return {
|
||||
id: asString(item.id) ?? '',
|
||||
toolName: asString(item.tool_name) ?? 'unknown',
|
||||
agentName: asString(item.agent_name) ?? 'Unknown',
|
||||
riskLevel: normalizeRisk(item.risk_level),
|
||||
riskIcon: asString(item.risk_icon) ?? '⚠️',
|
||||
description: asString(item.description),
|
||||
argumentsJson,
|
||||
createdAt: asTimestamp(item.created_at),
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
/**
|
||||
* PraisonAIUI Agent-UI contracts
|
||||
*
|
||||
* Stable, framework-agnostic view models owned by PraisonAIUI. These are the
|
||||
* ONLY types that non-adapter code (chat views, dashboards, tests) should
|
||||
* depend on. BeautifulUI / vendored component prop types must never leak
|
||||
* outside `src/agent-ui/` — the adapter maps to these contracts instead so
|
||||
* the upstream implementation can be swapped without touching consumers.
|
||||
*
|
||||
* See docs/frontend/beautifului-adapter-architecture.md.
|
||||
*/
|
||||
|
||||
/** Discriminant for a normalized agent stream event. */
|
||||
export type AgentStreamEventKind =
|
||||
| 'run-started'
|
||||
| 'run-token'
|
||||
| 'run-completed'
|
||||
| 'run-error'
|
||||
| 'tool-call'
|
||||
| 'reasoning-step'
|
||||
| 'unknown'
|
||||
|
||||
/**
|
||||
* Normalized agent stream event — the stable shape UI code consumes,
|
||||
* decoupled from the raw WebSocket frame `type` strings handled in chat.js.
|
||||
*/
|
||||
export interface AgentStreamEvent {
|
||||
kind: AgentStreamEventKind
|
||||
/** Owning session, when the frame carried one. */
|
||||
sessionId?: string
|
||||
/** Run identifier, when the frame carried one. */
|
||||
runId?: string
|
||||
/** Human-facing agent name, when present. */
|
||||
agentName?: string
|
||||
/** Streaming token or final content, depending on `kind`. */
|
||||
text?: string
|
||||
/** Populated for `kind === 'tool-call'`. */
|
||||
toolCall?: ToolCallChipModel
|
||||
/** Populated for `kind === 'run-error'`. */
|
||||
error?: string
|
||||
/** Raw frame `type` for events we do not yet normalize (`kind === 'unknown'`). */
|
||||
rawType?: string
|
||||
}
|
||||
|
||||
/** View model for a tool-call chip, independent of any component library. */
|
||||
export interface ToolCallChipModel {
|
||||
id: string
|
||||
label: string
|
||||
icon?: string
|
||||
stepNumber?: number
|
||||
status: 'running' | 'done' | 'error'
|
||||
error?: string
|
||||
}
|
||||
|
||||
/** Risk levels surfaced by the approvals REST API (`/api/approvals/*`). */
|
||||
export type ApprovalRiskLevel = 'low' | 'medium' | 'high' | 'critical'
|
||||
|
||||
/** View model for an approval prompt/card, independent of any component library. */
|
||||
export interface ApprovalPromptModel {
|
||||
id: string
|
||||
toolName: string
|
||||
agentName: string
|
||||
riskLevel: ApprovalRiskLevel
|
||||
riskIcon: string
|
||||
description?: string
|
||||
/** Pretty-printed tool arguments, ready for display. */
|
||||
argumentsJson: string
|
||||
createdAt?: string
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
/**
|
||||
* Agent-UI boundary barrel.
|
||||
*
|
||||
* Consumers import PraisonAIUI's stable agent-UI contracts and adapters from
|
||||
* here. Vendored BeautifulUI components live under `src/agent-ui/vendors/` and
|
||||
* are re-exported from this folder only — no upstream prop types should be
|
||||
* imported outside `src/agent-ui/`.
|
||||
*/
|
||||
|
||||
export type {
|
||||
AgentStreamEvent,
|
||||
AgentStreamEventKind,
|
||||
ApprovalPromptModel,
|
||||
ApprovalRiskLevel,
|
||||
ToolCallChipModel,
|
||||
} from './contracts'
|
||||
|
||||
export type { RawApprovalItem, RawChatFrame } from './adapter'
|
||||
export { mapApprovalToCardProps, mapChatEventToUi } from './adapter'
|
||||
@@ -1,3 +1,3 @@
|
||||
"""Version information."""
|
||||
|
||||
__version__ = "0.3.144"
|
||||
__version__ = "0.3.147"
|
||||
@@ -44,6 +44,43 @@ def _icon(symbol: str, fallback: str) -> str:
|
||||
return symbol if _supports_unicode() else fallback
|
||||
|
||||
|
||||
def _load_env_files() -> int:
|
||||
"""Load environment variables from the standard PraisonAI ``.env`` files.
|
||||
|
||||
Mirrors the PraisonAI CLI convention so ``env:VAR`` references (e.g. in
|
||||
Channels config) resolve when the server was started without the shell
|
||||
exporting the tokens. Reads ``~/.praisonai/.env`` first, then a ``.env`` in
|
||||
the current working directory. Existing process env vars are never
|
||||
overridden, and parsing is intentionally dependency-free (no python-dotenv).
|
||||
|
||||
Returns the number of variables newly set into ``os.environ``.
|
||||
"""
|
||||
import os
|
||||
|
||||
loaded = 0
|
||||
for env_path in (Path.home() / ".praisonai" / ".env", Path.cwd() / ".env"):
|
||||
try:
|
||||
if not env_path.is_file():
|
||||
continue
|
||||
for line in env_path.read_text(encoding="utf-8").splitlines():
|
||||
line = line.strip()
|
||||
if not line or line.startswith("#") or "=" not in line:
|
||||
continue
|
||||
if line.startswith("export "):
|
||||
line = line[len("export "):]
|
||||
key, value = line.split("=", 1)
|
||||
key = key.strip()
|
||||
value = value.strip().strip('"').strip("'")
|
||||
if key and key not in os.environ:
|
||||
os.environ[key] = value
|
||||
loaded += 1
|
||||
except Exception:
|
||||
# Never let env loading break startup — resolution falls back to
|
||||
# the existing process environment.
|
||||
continue
|
||||
return loaded
|
||||
|
||||
|
||||
app = typer.Typer(
|
||||
name="aiui",
|
||||
help="PraisonAIUI - YAML-driven website generator",
|
||||
@@ -1380,6 +1417,17 @@ def run(
|
||||
console.print(f"[red]Error:[/red] App file not found: {app_file}")
|
||||
raise typer.Exit(code=1)
|
||||
|
||||
# Auto-load standard PraisonAI env files so `env:VAR` references (e.g. in
|
||||
# Channels config) resolve even when the shell did not export the tokens.
|
||||
# This matches the PraisonAI CLI convention and fixes the "Connected but
|
||||
# bot never replies" class of issues caused by empty env-resolved tokens.
|
||||
_loaded = _load_env_files()
|
||||
if _loaded:
|
||||
console.print(
|
||||
f"[green]{_icon('✓', 'OK')}[/green] Loaded {_loaded} environment "
|
||||
f"variable(s) from PraisonAI config"
|
||||
)
|
||||
|
||||
is_yaml = app_file.suffix in (".yaml", ".yml")
|
||||
|
||||
if is_yaml:
|
||||
|
||||
@@ -652,13 +652,24 @@ class ChannelsFeature(BaseFeatureProtocol):
|
||||
if hasattr(bot, "_session") and hasattr(bot._session, "chat"):
|
||||
_original_chat = bot._session.chat
|
||||
|
||||
async def _wrapped_chat(agent: Any, user_id: str, text: str) -> str:
|
||||
async def _wrapped_chat(
|
||||
agent: Any,
|
||||
user_id: str,
|
||||
text: str,
|
||||
*args: Any,
|
||||
**kwargs: Any,
|
||||
) -> str:
|
||||
"""Wrap agent chat to broadcast responses AND intermediate
|
||||
tool/step events to the Chat UI.
|
||||
|
||||
Hooks into the agent's StreamEventEmitter (same pattern as
|
||||
PraisonAIProvider._run_direct_mode) to capture tool_call,
|
||||
reasoning, and content events during execution.
|
||||
|
||||
Any extra positional/keyword arguments (e.g. ``chat_id``,
|
||||
``platform``, ``thread_id``) are forwarded untouched to the
|
||||
wrapped session ``chat`` so per-conversation memory/session
|
||||
continuity is preserved instead of raising ``TypeError``.
|
||||
"""
|
||||
from .chat import _enrich_tool_payload, get_chat_manager
|
||||
|
||||
@@ -831,7 +842,7 @@ class ChannelsFeature(BaseFeatureProtocol):
|
||||
drain_task = asyncio.create_task(_drain_events())
|
||||
|
||||
try:
|
||||
response = await _original_chat(agent, user_id, text)
|
||||
response = await _original_chat(agent, user_id, text, *args, **kwargs)
|
||||
finally:
|
||||
# Signal drain to stop and clean up callback
|
||||
try:
|
||||
|
||||
@@ -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>';
|
||||
|
||||
@@ -0,0 +1,88 @@
|
||||
"""Regression coverage for issue #287: the channels chat bridge dropped
|
||||
extra keyword arguments (e.g. ``chat_id``, ``platform``) because the
|
||||
``_wrapped_chat`` wrapper installed over ``bot._session.chat`` had a fixed
|
||||
``(agent, user_id, text)`` signature.
|
||||
|
||||
When the bot internals invoked the session with per-conversation kwargs the
|
||||
wrapper raised ``TypeError: _wrapped_chat() got an unexpected keyword
|
||||
argument 'chat_id'`` and the bot silently failed to reply. The wrapper must
|
||||
forward any extra positional/keyword arguments untouched to the original
|
||||
session ``chat`` so memory/session continuity is preserved.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
import pytest
|
||||
|
||||
from praisonaiui.features.channels import ChannelsFeature
|
||||
|
||||
|
||||
class _FakeSession:
|
||||
"""Minimal session whose ``chat`` records forwarded arguments."""
|
||||
|
||||
def __init__(self) -> None:
|
||||
self.received_args: tuple = ()
|
||||
self.received_kwargs: dict = {}
|
||||
|
||||
async def chat(self, agent: Any, user_id: str, text: str, *args: Any, **kwargs: Any) -> str:
|
||||
self.received_args = args
|
||||
self.received_kwargs = dict(kwargs)
|
||||
return f"reply to {text}"
|
||||
|
||||
|
||||
class _FakeBot:
|
||||
def __init__(self) -> None:
|
||||
self._session = _FakeSession()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_wrapped_chat_forwards_kwargs() -> None:
|
||||
feature = ChannelsFeature()
|
||||
bot = _FakeBot()
|
||||
|
||||
feature._attach_chat_bridge("chan-1", bot, "telegram")
|
||||
|
||||
# The bridge replaced the session chat with the wrapper.
|
||||
assert bot._session.chat.__name__ == "_wrapped_chat"
|
||||
|
||||
reply = await bot._session.chat(
|
||||
object(),
|
||||
"user-42",
|
||||
"Hi",
|
||||
chat_id="987654321",
|
||||
platform="telegram",
|
||||
user_id_meta="987654321",
|
||||
)
|
||||
|
||||
assert reply == "reply to Hi"
|
||||
assert bot._session.received_kwargs["chat_id"] == "987654321"
|
||||
assert bot._session.received_kwargs["platform"] == "telegram"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_wrapped_chat_forwards_extra_positional_args() -> None:
|
||||
feature = ChannelsFeature()
|
||||
bot = _FakeBot()
|
||||
|
||||
feature._attach_chat_bridge("chan-2", bot, "slack")
|
||||
|
||||
reply = await bot._session.chat(object(), "user-7", "Hello", "extra")
|
||||
|
||||
assert reply == "reply to Hello"
|
||||
assert bot._session.received_args == ("extra",)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_wrapped_chat_still_works_without_kwargs() -> None:
|
||||
"""No-kwargs path (web UI / existing callers) must keep working."""
|
||||
feature = ChannelsFeature()
|
||||
bot = _FakeBot()
|
||||
|
||||
feature._attach_chat_bridge("chan-3", bot, "discord")
|
||||
|
||||
reply = await bot._session.chat(object(), "user-9", "Ping")
|
||||
|
||||
assert reply == "reply to Ping"
|
||||
assert bot._session.received_kwargs == {}
|
||||
@@ -0,0 +1,68 @@
|
||||
"""Unit tests for `_load_env_files` — auto-loading PraisonAI `.env` files.
|
||||
|
||||
Regression coverage for issue #286: `env:VAR` channel token references
|
||||
resolved to empty strings because `aiui run` never loaded the standard
|
||||
`~/.praisonai/.env` file, so bots showed "Connected" but never replied.
|
||||
"""
|
||||
|
||||
import os
|
||||
|
||||
from praisonaiui.cli import _load_env_files
|
||||
|
||||
|
||||
def _write_env(path, body):
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
path.write_text(body, encoding="utf-8")
|
||||
|
||||
|
||||
def test_loads_home_praisonai_env(tmp_path, monkeypatch):
|
||||
"""Variables in ~/.praisonai/.env are loaded into the process env."""
|
||||
monkeypatch.setattr("pathlib.Path.home", lambda: tmp_path)
|
||||
monkeypatch.chdir(tmp_path)
|
||||
monkeypatch.delenv("TELEGRAM_BOT_TOKEN", raising=False)
|
||||
|
||||
_write_env(tmp_path / ".praisonai" / ".env", 'TELEGRAM_BOT_TOKEN="123:AAtoken"\n')
|
||||
|
||||
loaded = _load_env_files()
|
||||
|
||||
assert loaded >= 1
|
||||
assert os.environ["TELEGRAM_BOT_TOKEN"] == "123:AAtoken"
|
||||
|
||||
|
||||
def test_does_not_override_existing_env(tmp_path, monkeypatch):
|
||||
"""An already-set process variable is never clobbered by the file."""
|
||||
monkeypatch.setattr("pathlib.Path.home", lambda: tmp_path)
|
||||
monkeypatch.chdir(tmp_path)
|
||||
monkeypatch.setenv("SLACK_BOT_TOKEN", "xoxb-from-shell")
|
||||
|
||||
_write_env(tmp_path / ".praisonai" / ".env", "SLACK_BOT_TOKEN=xoxb-from-file\n")
|
||||
|
||||
_load_env_files()
|
||||
|
||||
assert os.environ["SLACK_BOT_TOKEN"] == "xoxb-from-shell"
|
||||
|
||||
|
||||
def test_ignores_comments_blanks_and_export_prefix(tmp_path, monkeypatch):
|
||||
"""Comments/blank lines are skipped and `export ` prefixes are stripped."""
|
||||
monkeypatch.setattr("pathlib.Path.home", lambda: tmp_path)
|
||||
monkeypatch.chdir(tmp_path)
|
||||
monkeypatch.delenv("FOO", raising=False)
|
||||
monkeypatch.delenv("BAR", raising=False)
|
||||
|
||||
_write_env(
|
||||
tmp_path / ".praisonai" / ".env",
|
||||
"# a comment\n\nexport FOO=foo\nBAR='bar'\n",
|
||||
)
|
||||
|
||||
_load_env_files()
|
||||
|
||||
assert os.environ["FOO"] == "foo"
|
||||
assert os.environ["BAR"] == "bar"
|
||||
|
||||
|
||||
def test_missing_files_are_harmless(tmp_path, monkeypatch):
|
||||
"""No env files present → returns 0 and does not raise."""
|
||||
monkeypatch.setattr("pathlib.Path.home", lambda: tmp_path)
|
||||
monkeypatch.chdir(tmp_path)
|
||||
|
||||
assert _load_env_files() == 0
|
||||
@@ -478,6 +478,26 @@ def test_voice_doctor_report(monkeypatch):
|
||||
assert report["summary"]["failed"] >= 1
|
||||
|
||||
|
||||
def test_voice_doctor_mcp_probe_timeout_is_warn(monkeypatch):
|
||||
monkeypatch.setenv("OPENAI_API_KEY", "sk-test-key-12345678")
|
||||
|
||||
def fake_http_status(url: str, timeout: float = 5.0):
|
||||
if "/api/mcp/servers" in url:
|
||||
assert timeout == 2.0
|
||||
return "fail", "timed out"
|
||||
return "pass", "HTTP 200"
|
||||
|
||||
with _voice_modules() as imp:
|
||||
doctor = imp("integrations.voice.doctor")
|
||||
monkeypatch.setattr(doctor, "_tcp_open", lambda *args, **kwargs: True)
|
||||
monkeypatch.setattr(doctor, "_http_status", fake_http_status)
|
||||
report = doctor.run_voice_doctor(app_port=8001, sidecar_port=59998)
|
||||
|
||||
mcp = next(c for c in report["checks"] if c["name"] == "MCP servers API")
|
||||
assert mcp["status"] == "warn"
|
||||
assert mcp["detail"] == "timed out"
|
||||
|
||||
|
||||
def test_call_detail_ui_includes_analytics():
|
||||
with _voice_modules() as imp:
|
||||
ui = imp("integrations.voice.call_detail_ui")
|
||||
|
||||
@@ -207,7 +207,7 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "aiui"
|
||||
version = "0.3.144"
|
||||
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"
|
||||
|
||||
Reference in new issue
Block a user