mirror of
https://github.com/MervinPraison/PraisonAIUI.git
synced 2026-11-07 05:56:17 -10:00
Compare commits
62
Commits
No files matched your search
@@ -3,7 +3,7 @@
|
||||
"stack": "claude",
|
||||
"profile": "sdk",
|
||||
"mode": "hub",
|
||||
"hubRef": "v1.1.6",
|
||||
"templateVersion": "1.1.6",
|
||||
"hubRef": "v1.1.15",
|
||||
"templateVersion": "1.1.7",
|
||||
"installedAt": "2026-09-07T10:02:11Z"
|
||||
}
|
||||
@@ -4,6 +4,10 @@
|
||||
|
||||
module.exports = {
|
||||
repoFullName: 'MervinPraison/PraisonAIUI',
|
||||
gitUser: 'MervinPraison',
|
||||
gitEmail: '[email protected]',
|
||||
triggerLogins: ['MervinPraison', 'github-actions[bot]'],
|
||||
allowedTriageBots: ['praisonai-triage-agent[bot]'],
|
||||
productPathPrefixes: ['src/praisonaiui/', 'src/frontend/', 'tests/'],
|
||||
sensitivePathPatterns: [
|
||||
/^\.github\/workflows\//,
|
||||
|
||||
@@ -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].6
|
||||
uses: MervinPraison/github-automation-template/.github/workflows/[email protected].15
|
||||
secrets: inherit
|
||||
with:
|
||||
hub_ref: v1.1.6
|
||||
hub_ref: v1.1.15
|
||||
@@ -16,8 +16,8 @@ permissions:
|
||||
|
||||
jobs:
|
||||
recover:
|
||||
uses: MervinPraison/github-automation-template/.github/workflows/[email protected].6
|
||||
uses: MervinPraison/github-automation-template/.github/workflows/[email protected].15
|
||||
secrets: inherit
|
||||
with:
|
||||
hub_ref: v1.1.6
|
||||
hub_ref: v1.1.15
|
||||
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].6
|
||||
uses: MervinPraison/github-automation-template/.github/workflows/[email protected].15
|
||||
secrets: inherit
|
||||
with:
|
||||
hub_ref: v1.1.6
|
||||
hub_ref: v1.1.15
|
||||
pr_number: ${{ github.event.inputs.pr_number || '' }}
|
||||
@@ -36,6 +36,30 @@ jobs:
|
||||
- name: Integration smoke tests
|
||||
run: pytest tests/integration/test_agentic_roundtrip.py tests/test_feature_sdk_backends.py -q --no-cov --timeout=120
|
||||
|
||||
frontend:
|
||||
name: frontend
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Set up Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: "22"
|
||||
cache: npm
|
||||
cache-dependency-path: src/frontend/package-lock.json
|
||||
|
||||
- name: Install dependencies
|
||||
run: npm ci
|
||||
working-directory: src/frontend
|
||||
|
||||
# `npm run build` is the dashboard ship path: dist/ is synced to
|
||||
# src/praisonaiui/templates/frontend/ (see src/frontend/README.md).
|
||||
- name: Build frontend
|
||||
run: npm run build
|
||||
working-directory: src/frontend
|
||||
|
||||
docs:
|
||||
runs-on: ubuntu-latest
|
||||
if: github.ref == 'refs/heads/main'
|
||||
|
||||
@@ -43,9 +43,9 @@ jobs:
|
||||
id-token: write
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: MervinPraison/github-automation-template/.github/actions/[email protected].6
|
||||
- uses: MervinPraison/github-automation-template/.github/actions/[email protected].15
|
||||
with:
|
||||
hub_ref: v1.1.6
|
||||
hub_ref: v1.1.15
|
||||
- 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].6
|
||||
uses: MervinPraison/github-automation-template/.github/workflows/[email protected].15
|
||||
secrets: inherit
|
||||
with:
|
||||
hub_ref: v1.1.6
|
||||
hub_ref: v1.1.15
|
||||
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].6
|
||||
uses: MervinPraison/github-automation-template/.github/workflows/[email protected].15
|
||||
secrets: inherit
|
||||
with:
|
||||
hub_ref: v1.1.6
|
||||
hub_ref: v1.1.15
|
||||
@@ -25,9 +25,9 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: MervinPraison/github-automation-template/.github/actions/[email protected].6
|
||||
- uses: MervinPraison/github-automation-template/.github/actions/[email protected].15
|
||||
with:
|
||||
hub_ref: v1.1.6
|
||||
hub_ref: v1.1.15
|
||||
- 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].6
|
||||
uses: MervinPraison/github-automation-template/.github/workflows/[email protected].15
|
||||
secrets: inherit
|
||||
with:
|
||||
hub_ref: v1.1.6
|
||||
hub_ref: v1.1.15
|
||||
@@ -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.
|
||||
@@ -13,7 +13,9 @@ MEETING_AGENT_PORT=8000
|
||||
# ── Recall.ai (EU workspace: Praison) ───────────────────────────────────────
|
||||
# Region must match dashboard: eu-central-1 (Frankfurt).
|
||||
RECALL_REGION=eu-central-1
|
||||
RECALL_WORKSPACE_ID=33a035c7-03c1-4ec9-b752-18cdfbbefc42
|
||||
# Optional: your Recall workspace ID (shown in the config-status API only; the
|
||||
# API key already scopes requests to a workspace).
|
||||
RECALL_WORKSPACE_ID=
|
||||
|
||||
# REST API key from Recall dashboard (Developers → API Keys). Server-side only.
|
||||
RECALL_API_KEY=
|
||||
|
||||
@@ -17,9 +17,10 @@ Meeting Bots for live Zoom / Meet / Teams capture.
|
||||
|
||||
## Recall.ai workspace
|
||||
|
||||
This integration targets the **Praison** workspace in **EU (Frankfurt)**:
|
||||
This integration targets your Recall workspace in **EU (Frankfurt)** by default:
|
||||
|
||||
- Workspace ID: `33a035c7-03c1-4ec9-b752-18cdfbbefc42`
|
||||
- Workspace ID: set `RECALL_WORKSPACE_ID` to your own (optional — the API key
|
||||
already scopes requests to a workspace)
|
||||
- API region: `eu-central-1`
|
||||
|
||||
Follow the [Recall onboarding guide](https://docs.recall.ai/docs/agent-quickstarts) via Recall MCP (`recall://guides/onboarding`) for credential and webhook setup.
|
||||
|
||||
@@ -102,7 +102,7 @@ _agent: Any | None = None
|
||||
def _recall_schedule_tool():
|
||||
from praisonai_tools.tools.decorator import tool
|
||||
|
||||
from integrations.recall.service import schedule_recall_bot as _schedule
|
||||
from meeting_integrations.recall.service import schedule_recall_bot as _schedule
|
||||
|
||||
@tool
|
||||
def schedule_recall_bot(meeting_url: str, title: str = "") -> dict:
|
||||
@@ -115,7 +115,7 @@ def _recall_schedule_tool():
|
||||
def _upcoming_meetings_tool():
|
||||
from praisonai_tools.tools.decorator import tool
|
||||
|
||||
from integrations.recall.calendar_service import list_upcoming_calendar_meetings
|
||||
from meeting_integrations.recall.calendar_service import list_upcoming_calendar_meetings
|
||||
|
||||
@tool
|
||||
def get_upcoming_meetings(hours: int = 24) -> list[dict]:
|
||||
@@ -194,8 +194,8 @@ def _sync_stale_recall_status(record: dict[str, Any]) -> dict[str, Any]:
|
||||
if not os.getenv("RECALL_API_KEY"):
|
||||
return record
|
||||
try:
|
||||
from integrations.recall.client import RecallClient
|
||||
from integrations.recall.config import load_recall_settings
|
||||
from meeting_integrations.recall.client import RecallClient
|
||||
from meeting_integrations.recall.config import load_recall_settings
|
||||
from pipeline import merge_meeting_metadata
|
||||
|
||||
bot = RecallClient(load_recall_settings()).get_bot(str(bot_id))
|
||||
@@ -464,7 +464,7 @@ async def calendar_page():
|
||||
status_note = "Set RECALL_API_KEY to load calendar status."
|
||||
if os.getenv("RECALL_API_KEY"):
|
||||
try:
|
||||
from integrations.recall.calendar_service import list_upcoming_calendar_meetings
|
||||
from meeting_integrations.recall.calendar_service import list_upcoming_calendar_meetings
|
||||
|
||||
upcoming = list_upcoming_calendar_meetings(hours=48)
|
||||
status_note = f"{len(upcoming)} upcoming event(s) with video links (48h window)."
|
||||
|
||||
@@ -1,13 +0,0 @@
|
||||
"""Recall.ai Meeting Bot integration for the meeting-agent example."""
|
||||
|
||||
from integrations.recall.client import RecallClient
|
||||
from integrations.recall.config import RecallSettings, load_recall_settings
|
||||
from integrations.recall.verify import VerificationError, verify_request_from_recall
|
||||
|
||||
__all__ = [
|
||||
"RecallClient",
|
||||
"RecallSettings",
|
||||
"VerificationError",
|
||||
"load_recall_settings",
|
||||
"verify_request_from_recall",
|
||||
]
|
||||
File renamed without changes.
@@ -0,0 +1,13 @@
|
||||
"""Recall.ai Meeting Bot integration for the meeting-agent example."""
|
||||
|
||||
from meeting_integrations.recall.client import RecallClient
|
||||
from meeting_integrations.recall.config import RecallSettings, load_recall_settings
|
||||
from meeting_integrations.recall.verify import VerificationError, verify_request_from_recall
|
||||
|
||||
__all__ = [
|
||||
"RecallClient",
|
||||
"RecallSettings",
|
||||
"VerificationError",
|
||||
"load_recall_settings",
|
||||
"verify_request_from_recall",
|
||||
]
|
||||
File renamed without changes.
+4
-4
@@ -6,9 +6,9 @@ import logging
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from typing import Any
|
||||
|
||||
from integrations.recall.client import RecallAPIError, RecallClient
|
||||
from integrations.recall.config import RecallSettings
|
||||
from integrations.recall.store import RecallStore
|
||||
from meeting_integrations.recall.client import RecallAPIError, RecallClient
|
||||
from meeting_integrations.recall.config import RecallSettings
|
||||
from meeting_integrations.recall.store import RecallStore
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -64,7 +64,7 @@ def list_upcoming_calendar_meetings(
|
||||
client: RecallClient | None = None,
|
||||
) -> list[dict[str, Any]]:
|
||||
"""List upcoming Recall calendar events with video links (for UI + agent tool)."""
|
||||
from integrations.recall.config import load_recall_settings
|
||||
from meeting_integrations.recall.config import load_recall_settings
|
||||
|
||||
settings = settings or load_recall_settings()
|
||||
client = client or RecallClient(settings)
|
||||
+1
-1
@@ -10,7 +10,7 @@ from typing import Any
|
||||
|
||||
import httpx
|
||||
|
||||
from integrations.recall.config import RecallSettings
|
||||
from meeting_integrations.recall.config import RecallSettings
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
+1
-3
@@ -93,9 +93,7 @@ def load_recall_settings(*, require_public_url: bool = False) -> RecallSettings:
|
||||
api_key=_require("RECALL_API_KEY"),
|
||||
webhook_verification_secret=_require("RECALL_WEBHOOK_VERIFICATION_SECRET"),
|
||||
public_api_base_url=public_url,
|
||||
workspace_id=os.getenv(
|
||||
"RECALL_WORKSPACE_ID", "33a035c7-03c1-4ec9-b752-18cdfbbefc42"
|
||||
).strip(),
|
||||
workspace_id=os.getenv("RECALL_WORKSPACE_ID", "").strip(),
|
||||
bot_name=os.getenv("RECALL_BOT_NAME", "Praison Meeting Agent").strip()
|
||||
or "Praison Meeting Agent",
|
||||
calendar_auto_record=auto_record not in {"0", "false", "no", "off"},
|
||||
File renamed without changes.
+7
-7
@@ -5,10 +5,10 @@ from __future__ import annotations
|
||||
import logging
|
||||
from typing import Any
|
||||
|
||||
from integrations.recall.client import RecallAPIError, RecallClient
|
||||
from integrations.recall.config import RecallSettings
|
||||
from integrations.recall.store import RecallStore, webhook_event_key
|
||||
from integrations.recall.transcript import transcript_data_to_line, transcript_download_to_text
|
||||
from meeting_integrations.recall.client import RecallAPIError, RecallClient
|
||||
from meeting_integrations.recall.config import RecallSettings
|
||||
from meeting_integrations.recall.store import RecallStore, webhook_event_key
|
||||
from meeting_integrations.recall.transcript import transcript_data_to_line, transcript_download_to_text
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -22,7 +22,7 @@ def _speaker_prefix(line: str) -> str:
|
||||
def _upsert_live_transcript(meeting_id: str, line: str, *, partial: bool = False) -> str:
|
||||
"""Merge one live caption line; partial updates replace the last same-speaker line."""
|
||||
from praisonai_tools.tools.meeting_tools import get_meeting
|
||||
from integrations.recall.live import publish_live_chunk
|
||||
from meeting_integrations.recall.live import publish_live_chunk
|
||||
from pipeline import _metadata_lock
|
||||
|
||||
# Hold the merge lock across the read too: streaming transcript events are
|
||||
@@ -120,7 +120,7 @@ def process_recall_webhook(
|
||||
|
||||
if event_type == "bot.in_call_recording" and meeting_id:
|
||||
_merge_metadata(meeting_id, {"live_status": "live", "status": "live", "error": ""})
|
||||
from integrations.recall.live import set_live_status
|
||||
from meeting_integrations.recall.live import set_live_status
|
||||
|
||||
set_live_status(meeting_id, "live")
|
||||
return
|
||||
@@ -139,7 +139,7 @@ def process_recall_webhook(
|
||||
|
||||
if event_type == "bot.call_ended" and meeting_id:
|
||||
_merge_metadata(meeting_id, {"live_status": "ended"})
|
||||
from integrations.recall.live import set_live_status
|
||||
from meeting_integrations.recall.live import set_live_status
|
||||
|
||||
set_live_status(meeting_id, "ended")
|
||||
return
|
||||
+2
-2
@@ -6,8 +6,8 @@ import logging
|
||||
import re
|
||||
from typing import Any
|
||||
|
||||
from integrations.recall.client import RecallAPIError, RecallClient
|
||||
from integrations.recall.config import RecallConfigError, RecallSettings, load_recall_settings
|
||||
from meeting_integrations.recall.client import RecallAPIError, RecallClient
|
||||
from meeting_integrations.recall.config import RecallConfigError, RecallSettings, load_recall_settings
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
+14
-14
@@ -9,7 +9,7 @@ from contextlib import closing
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from integrations.recall.client import RecallClient
|
||||
from meeting_integrations.recall.client import RecallClient
|
||||
|
||||
|
||||
def _state_db_path() -> str:
|
||||
@@ -100,42 +100,42 @@ class RecallStore:
|
||||
|
||||
@staticmethod
|
||||
def extract_bot_id(payload: dict[str, Any]) -> str | None:
|
||||
from integrations.recall.client import RecallClient
|
||||
from meeting_integrations.recall.client import RecallClient
|
||||
|
||||
return RecallClient.extract_bot_id(payload)
|
||||
|
||||
@staticmethod
|
||||
def extract_recording_id(payload: dict[str, Any]) -> str | None:
|
||||
from integrations.recall.client import RecallClient
|
||||
from meeting_integrations.recall.client import RecallClient
|
||||
|
||||
return RecallClient.extract_recording_id(payload)
|
||||
|
||||
@staticmethod
|
||||
def extract_transcript_id(payload: dict[str, Any]) -> str | None:
|
||||
from integrations.recall.client import RecallClient
|
||||
from meeting_integrations.recall.client import RecallClient
|
||||
|
||||
return RecallClient.extract_transcript_id(payload)
|
||||
|
||||
@staticmethod
|
||||
def mark_event_processed(event_key: str, event_type: str) -> bool:
|
||||
"""Return False if this event was already processed."""
|
||||
"""Claim an event for processing; return False if already processed.
|
||||
|
||||
Uses an atomic ``INSERT OR IGNORE`` so concurrent duplicate webhook
|
||||
deliveries (Svix retries/fan-out) cannot both pass the check — the
|
||||
second insert is ignored and reports zero changed rows.
|
||||
"""
|
||||
from datetime import datetime, timezone
|
||||
|
||||
with closing(_connect()) as conn, conn:
|
||||
row = conn.execute(
|
||||
"SELECT 1 FROM recall_webhook_events WHERE event_key = ?",
|
||||
(event_key,),
|
||||
).fetchone()
|
||||
if row:
|
||||
return False
|
||||
conn.execute(
|
||||
cursor = conn.execute(
|
||||
"""
|
||||
INSERT INTO recall_webhook_events (event_key, event_type, processed_at)
|
||||
INSERT OR IGNORE INTO recall_webhook_events
|
||||
(event_key, event_type, processed_at)
|
||||
VALUES (?, ?, ?)
|
||||
""",
|
||||
(event_key, event_type, datetime.now(timezone.utc).isoformat()),
|
||||
)
|
||||
return True
|
||||
return cursor.rowcount > 0
|
||||
|
||||
@staticmethod
|
||||
def save_calendar_setup(setup_id: str, platform: str, state: dict[str, Any]) -> None:
|
||||
File renamed without changes.
File renamed without changes.
@@ -11,18 +11,18 @@ from typing import Any
|
||||
from starlette.requests import Request
|
||||
from starlette.responses import JSONResponse, PlainTextResponse, Response, StreamingResponse
|
||||
|
||||
from integrations.recall.calendar import forward_calendar_callback
|
||||
from integrations.recall.calendar_service import (
|
||||
from meeting_integrations.recall.calendar import forward_calendar_callback
|
||||
from meeting_integrations.recall.calendar_service import (
|
||||
list_upcoming_calendar_meetings,
|
||||
process_calendar_webhook,
|
||||
sync_calendar_events,
|
||||
)
|
||||
from integrations.recall.config import RecallConfigError, load_recall_settings
|
||||
from integrations.recall.live import snapshot_for_api, wait_for_update
|
||||
from integrations.recall.processor import process_recall_webhook
|
||||
from integrations.recall.service import cancel_recall_bot, schedule_recall_bot
|
||||
from integrations.recall.store import RecallStore
|
||||
from integrations.recall.verify import VerificationError, verify_request_from_recall
|
||||
from meeting_integrations.recall.config import RecallConfigError, load_recall_settings
|
||||
from meeting_integrations.recall.live import snapshot_for_api, wait_for_update
|
||||
from meeting_integrations.recall.processor import process_recall_webhook
|
||||
from meeting_integrations.recall.service import cancel_recall_bot, schedule_recall_bot
|
||||
from meeting_integrations.recall.store import RecallStore
|
||||
from meeting_integrations.recall.verify import VerificationError, verify_request_from_recall
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -208,7 +208,7 @@ async def api_recall_calendar_status(_request: Request) -> JSONResponse:
|
||||
public_url = os.getenv("PUBLIC_API_BASE_URL", "").strip()
|
||||
calendars: list[dict[str, Any]] = []
|
||||
try:
|
||||
from integrations.recall.client import RecallClient
|
||||
from meeting_integrations.recall.client import RecallClient
|
||||
|
||||
settings = load_recall_settings()
|
||||
calendars = RecallClient(settings).list_calendars(status="connected")
|
||||
|
||||
@@ -45,9 +45,9 @@ def _duration_seconds(started_at: str | None, completed_at: str | None) -> int |
|
||||
|
||||
|
||||
def recover(meeting_id: str, bot_id: str | None = None) -> dict:
|
||||
from integrations.recall.client import RecallClient
|
||||
from integrations.recall.config import load_recall_settings
|
||||
from integrations.recall.transcript import transcript_download_to_text
|
||||
from meeting_integrations.recall.client import RecallClient
|
||||
from meeting_integrations.recall.config import load_recall_settings
|
||||
from meeting_integrations.recall.transcript import transcript_download_to_text
|
||||
from pipeline import merge_meeting_metadata, run_post_transcript_pipeline
|
||||
from praisonai_tools.tools.meeting_tools import get_meeting
|
||||
|
||||
|
||||
@@ -39,12 +39,12 @@ def run_smoke() -> None:
|
||||
os.environ.setdefault("RECALL_REGION", "eu-central-1")
|
||||
os.environ.setdefault("RECALL_API_KEY", "smoke-test-key")
|
||||
os.environ.setdefault("RECALL_WEBHOOK_VERIFICATION_SECRET", "whsec_" + base64.b64encode(b"smoke-secret-key-32-bytes-long!!").decode())
|
||||
os.environ.setdefault("RECALL_WORKSPACE_ID", "33a035c7-03c1-4ec9-b752-18cdfbbefc42")
|
||||
os.environ.setdefault("RECALL_WORKSPACE_ID", "smoke-workspace")
|
||||
os.environ.setdefault("PUBLIC_API_BASE_URL", "https://smoke.example.test")
|
||||
|
||||
import app as meeting_app # noqa: WPS433
|
||||
from integrations.recall.config import load_recall_settings
|
||||
from integrations.recall.verify import verify_request_from_recall
|
||||
from meeting_integrations.recall.config import load_recall_settings
|
||||
from meeting_integrations.recall.verify import verify_request_from_recall
|
||||
|
||||
settings = load_recall_settings()
|
||||
assert settings.api_key == "smoke-test-key"
|
||||
|
||||
@@ -4,13 +4,48 @@ OPENAI_API_KEY=
|
||||
|
||||
VOICE_AGENT_PORT=8001
|
||||
|
||||
# LLM for voice assistant (synced to provider on setup / start_dev)
|
||||
VOICE_AGENT_MODEL=gpt-4o-mini
|
||||
|
||||
# Voice telephony provider
|
||||
VOICE_API_BASE=
|
||||
VOICE_PRIVATE_API_KEY=
|
||||
VOICE_PUBLIC_API_KEY=
|
||||
VOICE_SERVER_URL_SECRET=
|
||||
VOICE_ASSISTANT_ID=
|
||||
VOICE_PHONE_NUMBER_ID=
|
||||
|
||||
# Phase 2 — browser web talk (provider web SDK)
|
||||
# VOICE_WEB_SDK_URL=https://esm.sh/@vapi-ai/[email protected]
|
||||
# VOICE_WEB_SDK_GLOBAL=VoiceWebClient
|
||||
|
||||
# Dynamic assistant (assistant-request returns inline config + agent tools)
|
||||
# VOICE_DYNAMIC_ASSISTANT=true
|
||||
# VOICE_AGENT_EXTRA_TOOLS=meeting_search
|
||||
|
||||
# ElevenLabs Speech Engine — OpenAI tool calling on the sidecar LLM path
|
||||
# VOICE_SPEECH_USE_TOOLS=true
|
||||
|
||||
# Cross-call memory — stable browser user id → PraisonAI session reload
|
||||
# VOICE_MEMORY_ENABLED=true
|
||||
# VOICE_MEMORY_HISTORY_LIMIT=20
|
||||
# VOICE_MEMORY_USE_KNOWLEDGE=true
|
||||
|
||||
# OpenAI Realtime (browser speech-to-speech — higher audio token cost; keep demos short)
|
||||
# REALTIME_MODEL=gpt-realtime-2.1-mini
|
||||
# REALTIME_VOICE=marin
|
||||
# REALTIME_REASONING_EFFORT=low
|
||||
# REALTIME_INSTRUCTIONS=You are a helpful voice assistant. Keep replies to one or two short sentences.
|
||||
# REALTIME_FIRST_MESSAGE=
|
||||
|
||||
# ElevenLabs Speech Engine (Phase 3 — browser voice, BYO LLM)
|
||||
# ELEVENLABS_API_KEY=
|
||||
# SPEECH_ENGINE_ID= # auto-written by setup_speech_engine.py
|
||||
# SPEECH_ENGINE_PUBLIC_URL= # auto-written — dedicated tunnel for sidecar :8002
|
||||
# SPEECH_ENGINE_PORT=8002
|
||||
# SPEECH_ENGINE_FIRST_MESSAGE=Hello! How can I help you today?
|
||||
# ELEVENLABS_VOICE_ID= # Praison/custom voice from ElevenLabs dashboard
|
||||
|
||||
# Public HTTPS URL for provider webhook (Cloudflare tunnel)
|
||||
# cloudflared tunnel --url http://127.0.0.1:8001
|
||||
PUBLIC_API_BASE_URL=
|
||||
@@ -34,16 +34,80 @@ Or run `py -3.13 setup_voice_provider.py` after `start_dev.ps1`.
|
||||
| `/api/voice/calls` | POST | Outbound call `{ "customer_number": "+1..." }` |
|
||||
| `/api/voice/calls` | GET | List stored calls |
|
||||
| `/api/voice/calls/{id}` | GET | Call detail + transcript |
|
||||
| `/api/voice/calls/{id}/live-transcript` | GET | SSE live transcript stream |
|
||||
| `/api/voice/web-config` | GET | Browser talk widget config |
|
||||
| `/webhooks/voice` | POST | Provider webhooks (tool-calls, transcript, end-of-call) |
|
||||
| `/api/voice/config` | GET | Config status |
|
||||
| `/api/voice/doctor` | GET | Voice stack health checks (.env, ports, APIs, tunnel) |
|
||||
|
||||
## Phase 2 features
|
||||
|
||||
- **Live transcript** — `GET /api/voice/calls/{id}/live-transcript` (SSE); **Call detail** page uses `plugin.js` + EventSource
|
||||
- **Dynamic assistant** — set `VOICE_DYNAMIC_ASSISTANT=true` so `assistant-request` returns inline config with agent tool names
|
||||
- **Agent tools** — `tool-calls` run through `integrations/voice/agent_runner.py` (same registry as dashboard chat)
|
||||
- **Web talk** — **Web talk** page loads provider web SDK from `VOICE_WEB_SDK_URL` / `VOICE_WEB_SDK_GLOBAL`
|
||||
- **Chat bridge** — final transcript lines and end-of-call summaries appear in **Chat** sidebar as `📞 Call …` sessions
|
||||
|
||||
Optional: `VOICE_AGENT_EXTRA_TOOLS=meeting_search` adds meeting search when `praisonai_tools` is installed.
|
||||
|
||||
## Cross-call memory (PraisonAI sessions)
|
||||
|
||||
Stable browser `voice-user-id` (localStorage) maps to `voice-user-{id}` PraisonAI session folder. Prior turns reload on new calls/restarts when `VOICE_MEMORY_ENABLED=true` (default).
|
||||
|
||||
| Route | Purpose |
|
||||
|-------|---------|
|
||||
| `/api/voice/memory/attach-user` | ElevenLabs — attach user before conversation starts |
|
||||
| `/api/voice/memory/bind` | Bind `call_id` + `user_id` |
|
||||
| `/api/voice/memory/resolve?user_id=` | Resolve Praison session id |
|
||||
|
||||
Knowledge base entries are injected into voice prompts when available.
|
||||
|
||||
## Call detail UI (PraisonAIUI components)
|
||||
|
||||
Call detail uses server-built components (`code_block`, `badge`, `key_value_list`, `tabs`) via `GET /api/voice/calls/{id}/ui`. Live SSE updates the transcript code block. Mic/WebRTC talk pages still use minimal `plugin.js`.
|
||||
|
||||
## End-of-call summary & analytics
|
||||
|
||||
When a session ends (ElevenLabs, GPT Realtime, or telephony webhook), `integrations/voice/call_finalize.py`:
|
||||
|
||||
- Generates a one-sentence summary (OpenAI) when the provider did not send one
|
||||
- Stores **duration** and **turn count** in call metadata
|
||||
- Posts the summary to the **Chat** sidebar via the chat bridge
|
||||
|
||||
**Voice calls** table shows Duration and Turns. **Config** page runs the voice doctor (`GET /api/voice/doctor` or `py -3.13 doctor.py`).
|
||||
|
||||
## MCP manager
|
||||
|
||||
The dashboard **MCP** page lists configured Model Context Protocol servers (`GET /api/mcp/servers`) and supports connect/disconnect from the UI.
|
||||
|
||||
## Phase 3 — ElevenLabs Speech Engine
|
||||
|
||||
Bring-your-own-LLM browser voice ([Speech Engine docs](https://elevenlabs.io/docs/overview/capabilities/speech-engine)):
|
||||
|
||||
```powershell
|
||||
pip install -r requirements-voice.txt
|
||||
# Add ELEVENLABS_API_KEY to .env
|
||||
.\start_dev.ps1 -Restart
|
||||
```
|
||||
|
||||
- **ElevenLabs talk** page — WebRTC client, token from `/api/voice/speech-engine/token`
|
||||
- **Speech Engine sidecar** (`speech_engine_sidecar.py` on port **8002**) — official SDK WebSocket server
|
||||
- **Dedicated tunnel** — `SPEECH_ENGINE_PUBLIC_URL` → `wss://…/ws` (separate from dashboard tunnel)
|
||||
- **`setup_speech_engine.py`** — registers the public WebSocket URL with ElevenLabs
|
||||
|
||||
| Route | Purpose |
|
||||
|-------|---------|
|
||||
| `/api/voice/speech-engine/config` | Client config |
|
||||
| `/api/voice/speech-engine/token` | Browser conversation token |
|
||||
| `/ws` | Speech Engine WebSocket (ElevenLabs → your server) |
|
||||
|
||||
## Architecture
|
||||
|
||||
External platform handles voice pipeline (phone, STT, TTS, turn-taking). This app handles:
|
||||
|
||||
- **tool-calls** → PraisonAI-registered tools (`integrations/voice/tools.py`)
|
||||
- **transcript** / **end-of-call-report** → SQLite store + dashboard pages
|
||||
- **assistant-request** → returns `VOICE_ASSISTANT_ID`
|
||||
- **tool-calls** → PraisonAI agent tools (`integrations/voice/agent_runner.py`)
|
||||
- **transcript** / **end-of-call-report** → SQLite store, SSE hub, chat bridge
|
||||
- **assistant-request** → default `VOICE_ASSISTANT_ID` or dynamic inline assistant
|
||||
|
||||
Same pattern as `examples/python/meeting-agent` (Recall webhooks).
|
||||
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
- Outbound calls → POST /api/voice/calls
|
||||
- Provider webhooks → POST /webhooks/voice (tool-calls, transcript, end-of-call)
|
||||
- Dashboard → Calls list + call detail + chat
|
||||
- Dashboard → Calls list + live call detail + web talk + chat bridge
|
||||
|
||||
Run:
|
||||
cd examples/python/voice-agent
|
||||
@@ -20,17 +20,40 @@ import sys
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from starlette.routing import Route
|
||||
from voice_routes import (
|
||||
api_voice_call_detail,
|
||||
api_voice_config_status,
|
||||
api_voice_create_call,
|
||||
api_voice_list_calls,
|
||||
webhook_voice,
|
||||
)
|
||||
|
||||
import praisonaiui as aiui
|
||||
from praisonaiui.server import create_app
|
||||
from starlette.routing import Route, WebSocketRoute
|
||||
|
||||
from memory_routes import (
|
||||
api_voice_memory_attach_user,
|
||||
api_voice_memory_bind,
|
||||
api_voice_memory_config,
|
||||
api_voice_memory_for_call,
|
||||
api_voice_memory_resolve,
|
||||
)
|
||||
from realtime_routes import (
|
||||
api_realtime_config,
|
||||
api_realtime_end,
|
||||
api_realtime_new_call,
|
||||
api_realtime_session,
|
||||
api_realtime_transcript,
|
||||
)
|
||||
from speech_engine_routes import (
|
||||
api_speech_engine_config,
|
||||
api_speech_engine_token,
|
||||
speech_engine_websocket,
|
||||
)
|
||||
from voice_routes import (
|
||||
api_voice_call_detail,
|
||||
api_voice_call_detail_ui,
|
||||
api_voice_config_status,
|
||||
api_voice_create_call,
|
||||
api_voice_doctor,
|
||||
api_voice_list_calls,
|
||||
api_voice_live_transcript,
|
||||
api_voice_web_config,
|
||||
webhook_voice,
|
||||
)
|
||||
|
||||
_EXAMPLE_DIR = Path(__file__).resolve().parent
|
||||
if str(_EXAMPLE_DIR) not in sys.path:
|
||||
@@ -47,68 +70,54 @@ def _load_local_env() -> None:
|
||||
continue
|
||||
key, _, value = line.partition("=")
|
||||
key, value = key.strip(), value.strip()
|
||||
if key and key not in os.environ:
|
||||
if key:
|
||||
os.environ[key] = value
|
||||
|
||||
|
||||
_load_local_env()
|
||||
|
||||
aiui.set_pages(["chat", "voice-calls", "call-detail", "config"])
|
||||
aiui.set_pages(
|
||||
[
|
||||
"chat",
|
||||
"voice-calls",
|
||||
"call-detail",
|
||||
"web-talk",
|
||||
"eleven-talk",
|
||||
"realtime-talk",
|
||||
"mcp",
|
||||
"config",
|
||||
]
|
||||
)
|
||||
aiui.set_style("dashboard")
|
||||
|
||||
_agent: Any | None = None
|
||||
aiui.set_custom_js(_EXAMPLE_DIR / "plugin.js")
|
||||
|
||||
|
||||
def get_agent():
|
||||
global _agent
|
||||
if _agent is not None:
|
||||
return _agent
|
||||
from integrations.voice.tools import echo_message, get_current_time, register_tool
|
||||
from praisonaiagents import Agent
|
||||
from integrations.voice.agent_runner import get_voice_agent
|
||||
|
||||
register_tool("get_current_time", get_current_time)
|
||||
register_tool("echo_message", echo_message)
|
||||
|
||||
_agent = Agent(
|
||||
name="Voice Assistant",
|
||||
instructions=(
|
||||
"You help with voice and phone agent demos. "
|
||||
"Use tools when the caller asks for the time or wants a message echoed."
|
||||
),
|
||||
model="gpt-4o-mini",
|
||||
tools=[get_current_time, echo_message],
|
||||
)
|
||||
from praisonaiagents.escalation.loop_guard import LoopGuard, LoopGuardConfig
|
||||
|
||||
max_turn_sec = float(os.getenv("VOICE_AGENT_LOOP_GUARD_MAX_SEC", "600"))
|
||||
_agent._loop_guard = LoopGuard(
|
||||
LoopGuardConfig(enabled=True, max_time_per_turn=max_turn_sec)
|
||||
)
|
||||
return _agent
|
||||
return get_voice_agent()
|
||||
|
||||
|
||||
def _demo_tool_reply(text: str) -> str | None:
|
||||
"""Fast path for demo tool queries — no LLM required."""
|
||||
from integrations.voice.tools import echo_message, get_current_time
|
||||
aiui.register_agent("voice-assistant", get_agent())
|
||||
|
||||
lower = text.lower().strip()
|
||||
if any(p in lower for p in ("what time", "current time", "tell me the time", "time is it")):
|
||||
return str(get_current_time()["spoken"])
|
||||
if lower.startswith("echo "):
|
||||
return str(echo_message(text[5:])["spoken"])
|
||||
if lower.startswith("repeat "):
|
||||
return str(echo_message(text[7:])["spoken"])
|
||||
return None
|
||||
|
||||
@aiui.on_app_startup
|
||||
async def _start_voice_chat_bridge() -> None:
|
||||
from integrations.voice.chat_bridge import start_chat_bridge_worker
|
||||
|
||||
start_chat_bridge_worker()
|
||||
|
||||
|
||||
@aiui.reply
|
||||
async def on_message(message: str):
|
||||
"""Chat with the voice demo agent."""
|
||||
from integrations.voice.tools import demo_tool_reply
|
||||
|
||||
text = str(message).strip()
|
||||
if not text:
|
||||
return
|
||||
|
||||
demo = _demo_tool_reply(text)
|
||||
demo = demo_tool_reply(text)
|
||||
if demo is not None:
|
||||
await aiui.say(demo)
|
||||
return
|
||||
@@ -126,19 +135,25 @@ async def on_message(message: str):
|
||||
|
||||
|
||||
def _list_calls() -> list[dict[str, Any]]:
|
||||
from integrations.voice.call_finalize import analytics_for_record
|
||||
from integrations.voice.store import VoiceCallStore
|
||||
|
||||
rows = VoiceCallStore.list_calls()
|
||||
return [
|
||||
{
|
||||
"id": r["call_id"],
|
||||
"status": r["status"],
|
||||
"customer_number": r.get("customer_number") or "—",
|
||||
"transcript_preview": (r.get("transcript") or "")[:120],
|
||||
"updated_at": r.get("updated_at") or "",
|
||||
}
|
||||
for r in rows
|
||||
]
|
||||
out: list[dict[str, Any]] = []
|
||||
for r in rows:
|
||||
analytics = analytics_for_record(r)
|
||||
out.append(
|
||||
{
|
||||
"id": r["call_id"],
|
||||
"status": r["status"],
|
||||
"customer_number": r.get("customer_number") or "—",
|
||||
"transcript_preview": (r.get("transcript") or "")[:120],
|
||||
"duration": analytics.get("duration") or "—",
|
||||
"turn_count": analytics.get("turn_count") or 0,
|
||||
"updated_at": r.get("updated_at") or "",
|
||||
}
|
||||
)
|
||||
return out
|
||||
|
||||
|
||||
def _pick_call(calls: list[dict[str, Any]]) -> dict[str, Any]:
|
||||
@@ -149,19 +164,22 @@ def _pick_call(calls: list[dict[str, Any]]) -> dict[str, Any]:
|
||||
@aiui.page("voice-calls", title="Voice calls", icon="📞", group="Voice", order=1)
|
||||
async def voice_calls_page():
|
||||
calls = _list_calls()
|
||||
rows = [[c["id"], c["customer_number"], c["status"], c["updated_at"]] for c in calls]
|
||||
rows = [
|
||||
[c["id"], c["customer_number"], c["status"], c["duration"], str(c["turn_count"]), c["updated_at"]]
|
||||
for c in calls
|
||||
]
|
||||
return aiui.layout(
|
||||
[
|
||||
aiui.text("Outbound and inbound voice calls via telephony provider"),
|
||||
aiui.table(
|
||||
headers=["Call ID", "Customer", "Status", "Updated"],
|
||||
rows=rows or [["—", "—", "—", "—"]],
|
||||
headers=["Call ID", "Customer", "Status", "Duration", "Turns", "Updated"],
|
||||
rows=rows or [["—", "—", "—", "—", "—", "—"]],
|
||||
),
|
||||
aiui.alert(
|
||||
"Start a call: POST /api/voice/calls with {\"customer_number\": \"+1...\"}. "
|
||||
"Set provider webhook URL to {PUBLIC_API_BASE_URL}/webhooks/voice",
|
||||
"Open **Call detail** for live transcript (SSE). "
|
||||
"Voice sessions also appear in **Chat** sidebar as 📞 entries.",
|
||||
variant="info",
|
||||
title="Setup",
|
||||
title="Phase 2",
|
||||
),
|
||||
]
|
||||
)
|
||||
@@ -169,23 +187,65 @@ async def voice_calls_page():
|
||||
|
||||
@aiui.page("call-detail", title="Call detail", icon="🎙️", group="Voice", order=2)
|
||||
async def call_detail_page():
|
||||
"""Server fallback — plugin.js registerView overrides with live SSE + UI components."""
|
||||
from integrations.voice.call_detail_ui import build_call_detail_layout
|
||||
from integrations.voice.store import VoiceCallStore
|
||||
|
||||
calls = _list_calls()
|
||||
picked = _pick_call(calls)
|
||||
record = VoiceCallStore.get_call(picked["id"]) if picked.get("id") and picked["id"] != "—" else None
|
||||
transcript = (record or {}).get("transcript") or "(empty)"
|
||||
summary = (record or {}).get("summary") or "(not available yet)"
|
||||
status = (record or {}).get("status") or picked.get("status") or "unknown"
|
||||
if record:
|
||||
return build_call_detail_layout(record)
|
||||
return aiui.layout(
|
||||
[
|
||||
aiui.text(f"Call {picked.get('id', '—')}"),
|
||||
aiui.badge(status, variant="default"),
|
||||
aiui.tabs(
|
||||
[
|
||||
{"label": "Transcript", "children": [aiui.code_block(transcript, language="text")]},
|
||||
{"label": "Summary", "children": [aiui.text(summary)]},
|
||||
]
|
||||
aiui.alert(
|
||||
"No calls yet. Start from ElevenLabs talk, GPT Realtime, or Web talk.",
|
||||
variant="info",
|
||||
title="Call detail",
|
||||
),
|
||||
]
|
||||
)
|
||||
|
||||
|
||||
@aiui.page("web-talk", title="Web talk", icon="🎤", group="Voice", order=3)
|
||||
async def web_talk_page():
|
||||
return aiui.layout(
|
||||
[
|
||||
aiui.text("Browser voice widget — client view loads from plugin.js"),
|
||||
aiui.alert(
|
||||
"Set VOICE_PUBLIC_API_KEY, VOICE_ASSISTANT_ID, VOICE_WEB_SDK_URL, and "
|
||||
"VOICE_WEB_SDK_GLOBAL in .env.",
|
||||
variant="info",
|
||||
title="Web SDK",
|
||||
),
|
||||
]
|
||||
)
|
||||
|
||||
|
||||
@aiui.page("eleven-talk", title="ElevenLabs talk", icon="🗣️", group="Voice", order=4)
|
||||
async def eleven_talk_page():
|
||||
return aiui.layout(
|
||||
[
|
||||
aiui.text("Browser voice via ElevenLabs Speech Engine — loads from plugin.js"),
|
||||
aiui.alert(
|
||||
"Set ELEVENLABS_API_KEY, run setup_speech_engine.py after start_dev.ps1, "
|
||||
"then click Start conversation.",
|
||||
variant="info",
|
||||
title="Speech Engine",
|
||||
),
|
||||
]
|
||||
)
|
||||
|
||||
|
||||
@aiui.page("realtime-talk", title="GPT Realtime", icon="⚡", group="Voice", order=5)
|
||||
async def realtime_talk_page():
|
||||
return aiui.layout(
|
||||
[
|
||||
aiui.text("Browser voice via OpenAI gpt-realtime-2.1-mini — loads from plugin.js"),
|
||||
aiui.alert(
|
||||
"Set OPENAI_API_KEY and REALTIME_MODEL=gpt-realtime-2.1-mini.",
|
||||
variant="warning",
|
||||
title="OpenAI Realtime",
|
||||
),
|
||||
]
|
||||
)
|
||||
@@ -196,8 +256,25 @@ app.routes[0:0] = [
|
||||
Route("/api/voice/calls", api_voice_create_call, methods=["POST"]),
|
||||
Route("/api/voice/calls", api_voice_list_calls, methods=["GET"]),
|
||||
Route("/api/voice/calls/{call_id}", api_voice_call_detail, methods=["GET"]),
|
||||
Route("/api/voice/calls/{call_id}/ui", api_voice_call_detail_ui, methods=["GET"]),
|
||||
Route("/api/voice/calls/{call_id}/live-transcript", api_voice_live_transcript, methods=["GET"]),
|
||||
Route("/api/voice/config", api_voice_config_status, methods=["GET"]),
|
||||
Route("/api/voice/doctor", api_voice_doctor, methods=["GET"]),
|
||||
Route("/api/voice/web-config", api_voice_web_config, methods=["GET"]),
|
||||
Route("/api/voice/speech-engine/config", api_speech_engine_config, methods=["GET"]),
|
||||
Route("/api/voice/speech-engine/token", api_speech_engine_token, methods=["GET"]),
|
||||
Route("/api/voice/memory/config", api_voice_memory_config, methods=["GET"]),
|
||||
Route("/api/voice/memory/bind", api_voice_memory_bind, methods=["POST"]),
|
||||
Route("/api/voice/memory/attach-user", api_voice_memory_attach_user, methods=["POST"]),
|
||||
Route("/api/voice/memory/resolve", api_voice_memory_resolve, methods=["GET"]),
|
||||
Route("/api/voice/memory/for-call", api_voice_memory_for_call, methods=["GET"]),
|
||||
Route("/api/voice/realtime/config", api_realtime_config, methods=["GET"]),
|
||||
Route("/api/voice/realtime/new-call", api_realtime_new_call, methods=["POST"]),
|
||||
Route("/api/voice/realtime/session", api_realtime_session, methods=["POST"]),
|
||||
Route("/api/voice/realtime/transcript", api_realtime_transcript, methods=["POST"]),
|
||||
Route("/api/voice/realtime/end", api_realtime_end, methods=["POST"]),
|
||||
Route("/webhooks/voice", webhook_voice, methods=["POST"]),
|
||||
WebSocketRoute("/ws", speech_engine_websocket),
|
||||
]
|
||||
|
||||
if __name__ == "__main__":
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
"""CLI for voice-agent stack diagnostics."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
_EXAMPLE_DIR = Path(__file__).resolve().parent
|
||||
if str(_EXAMPLE_DIR) not in sys.path:
|
||||
sys.path.insert(0, str(_EXAMPLE_DIR))
|
||||
|
||||
|
||||
def _load_local_env() -> None:
|
||||
env_path = _EXAMPLE_DIR / ".env"
|
||||
if not env_path.is_file():
|
||||
return
|
||||
for raw in env_path.read_text(encoding="utf-8").splitlines():
|
||||
line = raw.strip()
|
||||
if not line or line.startswith("#") or "=" not in line:
|
||||
continue
|
||||
key, _, value = line.partition("=")
|
||||
key, value = key.strip(), value.strip()
|
||||
if key:
|
||||
os.environ[key] = value
|
||||
|
||||
|
||||
def main() -> int:
|
||||
_load_local_env()
|
||||
from integrations.voice.doctor import run_voice_doctor
|
||||
|
||||
app_port = int(os.getenv("VOICE_AGENT_PORT", "8001"))
|
||||
sidecar_port = int(os.getenv("SPEECH_ENGINE_SIDECAR_PORT", "8002"))
|
||||
report = run_voice_doctor(app_port=app_port, sidecar_port=sidecar_port)
|
||||
print(json.dumps(report, indent=2))
|
||||
return 1 if report["summary"]["failed"] else 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
@@ -0,0 +1,171 @@
|
||||
"""Run voice tool-calls through the PraisonAI agent tool registry."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
from typing import Any, Callable
|
||||
|
||||
from integrations.voice.tools import execute_tool, register_tool
|
||||
|
||||
_agent: Any | None = None
|
||||
_session_agents: dict[str, Any] = {}
|
||||
|
||||
|
||||
def _tool_name(tool: Any) -> str | None:
|
||||
for attr in ("name", "__name__"):
|
||||
value = getattr(tool, attr, None)
|
||||
if isinstance(value, str) and value.strip():
|
||||
return value.strip()
|
||||
fn = getattr(tool, "func", None) or getattr(tool, "function", None)
|
||||
if fn is not None:
|
||||
return _tool_name(fn)
|
||||
return None
|
||||
|
||||
|
||||
def _build_voice_agent(*, session_id: str | None = None):
|
||||
from praisonaiagents import Agent
|
||||
from praisonaiagents.config.feature_configs import MemoryConfig
|
||||
from praisonaiagents.escalation.loop_guard import LoopGuard, LoopGuardConfig
|
||||
|
||||
from integrations.voice.session_memory import memory_enabled
|
||||
from integrations.voice.tools import echo_message, get_current_time
|
||||
|
||||
register_tool("get_current_time", get_current_time)
|
||||
register_tool("echo_message", echo_message)
|
||||
|
||||
tools: list[Any] = [get_current_time, echo_message]
|
||||
extra = os.getenv("VOICE_AGENT_EXTRA_TOOLS", "").strip()
|
||||
if extra == "meeting_search":
|
||||
try:
|
||||
from praisonai_tools.tools.meeting_tools import search_meetings
|
||||
|
||||
tools.append(search_meetings)
|
||||
register_tool("search_meetings", search_meetings)
|
||||
except ImportError:
|
||||
pass
|
||||
|
||||
memory_cfg = None
|
||||
if memory_enabled() and session_id:
|
||||
memory_cfg = MemoryConfig(
|
||||
history=True,
|
||||
session_id=session_id,
|
||||
history_limit=int(os.getenv("VOICE_MEMORY_HISTORY_LIMIT", "20")),
|
||||
)
|
||||
|
||||
agent = Agent(
|
||||
name="Voice Assistant",
|
||||
instructions=(
|
||||
"You help with voice and phone agent demos. "
|
||||
"Use tools when the caller asks for the time, wants a message echoed, "
|
||||
"or asks about past meetings."
|
||||
),
|
||||
model=os.getenv("VOICE_AGENT_MODEL", "gpt-4o-mini"),
|
||||
tools=tools,
|
||||
memory=memory_cfg or False,
|
||||
)
|
||||
max_turn_sec = float(os.getenv("VOICE_AGENT_LOOP_GUARD_MAX_SEC", "600"))
|
||||
agent._loop_guard = LoopGuard(LoopGuardConfig(enabled=True, max_time_per_turn=max_turn_sec))
|
||||
|
||||
for tool in tools:
|
||||
name = _tool_name(tool)
|
||||
if name:
|
||||
register_tool(name, _callable_tool(tool))
|
||||
return agent
|
||||
|
||||
|
||||
def get_voice_agent(session_id: str | None = None):
|
||||
"""Lazy-load the voice assistant (optionally bound to a PraisonAI session)."""
|
||||
global _agent
|
||||
if session_id:
|
||||
cached = _session_agents.get(session_id)
|
||||
if cached is not None:
|
||||
return cached
|
||||
agent = _build_voice_agent(session_id=session_id)
|
||||
_session_agents[session_id] = agent
|
||||
return agent
|
||||
if _agent is not None:
|
||||
return _agent
|
||||
_agent = _build_voice_agent()
|
||||
return _agent
|
||||
|
||||
|
||||
def _callable_tool(tool: Any) -> Callable[..., Any]:
|
||||
fn = getattr(tool, "func", None) or getattr(tool, "run", None) or tool
|
||||
return fn # type: ignore[return-value]
|
||||
|
||||
|
||||
def execute_voice_tool(
|
||||
name: str,
|
||||
parameters: dict[str, Any] | None,
|
||||
*,
|
||||
session_id: str | None = None,
|
||||
) -> str:
|
||||
"""Execute via agent tool registry, then fall back to static registry."""
|
||||
params = parameters or {}
|
||||
agent = get_voice_agent(session_id)
|
||||
for tool in agent.tools or []:
|
||||
tname = _tool_name(tool)
|
||||
if tname != name:
|
||||
continue
|
||||
fn = _callable_tool(tool)
|
||||
try:
|
||||
result = fn(**params)
|
||||
except TypeError:
|
||||
try:
|
||||
result = fn(params)
|
||||
except Exception as exc: # noqa: BLE001
|
||||
return json.dumps({"error": str(exc)})
|
||||
except Exception as exc: # noqa: BLE001
|
||||
return json.dumps({"error": str(exc)})
|
||||
if isinstance(result, str):
|
||||
return result
|
||||
return json.dumps(result)
|
||||
return execute_tool(name, params)
|
||||
|
||||
|
||||
def list_registered_tool_names() -> list[str]:
|
||||
agent = get_voice_agent()
|
||||
names: list[str] = []
|
||||
for tool in agent.tools or []:
|
||||
tname = _tool_name(tool)
|
||||
if tname:
|
||||
names.append(tname)
|
||||
return names
|
||||
|
||||
|
||||
def build_assistant_request_response(settings: Any) -> dict[str, Any]:
|
||||
"""Return provider assistant config for dynamic assistant-request events."""
|
||||
if settings.default_assistant_id and not os.getenv("VOICE_DYNAMIC_ASSISTANT", "").lower() in (
|
||||
"1",
|
||||
"true",
|
||||
"yes",
|
||||
):
|
||||
return {"assistantId": settings.default_assistant_id}
|
||||
|
||||
tool_names = list_registered_tool_names()
|
||||
assistant: dict[str, Any] = {
|
||||
"name": "Praison Voice Assistant",
|
||||
"firstMessage": os.getenv("VOICE_FIRST_MESSAGE", "Hello! How can I help you today?"),
|
||||
"model": {
|
||||
"provider": os.getenv("VOICE_MODEL_PROVIDER", "openai"),
|
||||
"model": os.getenv("VOICE_AGENT_MODEL", "gpt-4o-mini"),
|
||||
"messages": [
|
||||
{
|
||||
"role": "system",
|
||||
"content": (
|
||||
"You are a helpful voice assistant. When asked for the time, call get_current_time. "
|
||||
"When asked to repeat or echo text, call echo_message with the message parameter. "
|
||||
"Available tools: "
|
||||
+ ", ".join(tool_names or ["get_current_time", "echo_message"])
|
||||
),
|
||||
}
|
||||
],
|
||||
},
|
||||
}
|
||||
if settings.webhook_url and settings.server_url_secret:
|
||||
assistant["server"] = {
|
||||
"url": settings.webhook_url,
|
||||
"headers": {"X-Voice-Webhook-Secret": settings.server_url_secret},
|
||||
}
|
||||
return {"assistant": assistant}
|
||||
@@ -0,0 +1,57 @@
|
||||
"""Build PraisonAIUI components for the call detail page."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
import praisonaiui as aiui
|
||||
|
||||
|
||||
def build_call_detail_layout(record: dict[str, Any]) -> dict[str, Any]:
|
||||
"""Return dashboard component tree for call detail (code_block + metadata)."""
|
||||
from integrations.voice.call_finalize import analytics_for_record
|
||||
|
||||
metadata = record.get("metadata") or {}
|
||||
analytics = analytics_for_record(record)
|
||||
transcript = (record.get("transcript") or "").strip() or "(empty — waiting for speech…)"
|
||||
summary = (record.get("summary") or "").strip() or "(not available yet)"
|
||||
status = str(record.get("status") or "unknown")
|
||||
|
||||
kv_items: list[dict[str, str]] = [
|
||||
{"label": "Call ID", "value": str(record.get("call_id") or "—")},
|
||||
{"label": "Status", "value": status},
|
||||
{"label": "Customer", "value": str(record.get("customer_number") or "—")},
|
||||
]
|
||||
if metadata.get("user_id"):
|
||||
kv_items.append({"label": "User ID", "value": str(metadata["user_id"])})
|
||||
if metadata.get("praison_session_id"):
|
||||
kv_items.append({"label": "Memory session", "value": str(metadata["praison_session_id"])})
|
||||
kv_items.append({"label": "Duration", "value": str(analytics.get("duration") or "—")})
|
||||
kv_items.append({"label": "Turns", "value": str(analytics.get("turn_count") or 0)})
|
||||
|
||||
return aiui.layout(
|
||||
[
|
||||
aiui.text("Live voice call transcript and metadata"),
|
||||
aiui.badge(status, variant="default"),
|
||||
aiui.definition_list(kv_items, title="Call metadata"),
|
||||
aiui.alert(
|
||||
"Transcript updates live via SSE. Final lines use PraisonAIUI code blocks.",
|
||||
variant="info",
|
||||
title="Live feed",
|
||||
),
|
||||
aiui.tabs(
|
||||
[
|
||||
{
|
||||
"label": "Transcript",
|
||||
"children": [
|
||||
aiui.code_block(transcript, language="text"),
|
||||
],
|
||||
},
|
||||
{
|
||||
"label": "Summary",
|
||||
"children": [aiui.text(summary)],
|
||||
},
|
||||
]
|
||||
),
|
||||
]
|
||||
)
|
||||
@@ -0,0 +1,126 @@
|
||||
"""End-of-call summary, analytics, and chat bridge on session close."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
import os
|
||||
from datetime import datetime, timezone
|
||||
from typing import Any
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def count_turns(transcript: str) -> int:
|
||||
"""Count user/agent lines in stored transcript."""
|
||||
turns = 0
|
||||
for line in (transcript or "").splitlines():
|
||||
text = line.strip()
|
||||
if text.startswith("user:") or text.startswith("agent:") or text.startswith("assistant:"):
|
||||
turns += 1
|
||||
return turns
|
||||
|
||||
|
||||
def duration_seconds(created_at: str | None, ended_at: str | None) -> int | None:
|
||||
if not created_at or not ended_at:
|
||||
return None
|
||||
try:
|
||||
start = datetime.fromisoformat(created_at.replace("Z", "+00:00"))
|
||||
end = datetime.fromisoformat(ended_at.replace("Z", "+00:00"))
|
||||
return max(0, int((end - start).total_seconds()))
|
||||
except ValueError:
|
||||
return None
|
||||
|
||||
|
||||
def format_duration(seconds: int | None) -> str:
|
||||
if seconds is None:
|
||||
return "—"
|
||||
if seconds < 60:
|
||||
return f"{seconds}s"
|
||||
minutes, rem = divmod(seconds, 60)
|
||||
return f"{minutes}m {rem}s"
|
||||
|
||||
|
||||
async def generate_call_summary(transcript: str) -> str:
|
||||
"""One-sentence LLM summary for call detail + chat sidebar."""
|
||||
text = (transcript or "").strip()
|
||||
if not text:
|
||||
return "Empty call — no conversation captured."
|
||||
api_key = os.getenv("OPENAI_API_KEY", "").strip()
|
||||
if not api_key:
|
||||
lines = [ln.strip() for ln in text.splitlines() if ln.strip()]
|
||||
preview = " · ".join(lines[:3])
|
||||
return f"Call covered: {preview[:240]}"
|
||||
|
||||
from openai import AsyncOpenAI
|
||||
|
||||
client = AsyncOpenAI(api_key=api_key)
|
||||
try:
|
||||
response = await asyncio.wait_for(
|
||||
client.chat.completions.create(
|
||||
model=os.getenv("VOICE_AGENT_MODEL", "gpt-4o-mini"),
|
||||
messages=[
|
||||
{
|
||||
"role": "system",
|
||||
"content": (
|
||||
"Summarize this voice call in one concise sentence for a dashboard. "
|
||||
"Mention main topic and outcome only."
|
||||
),
|
||||
},
|
||||
{"role": "user", "content": text[:6000]},
|
||||
],
|
||||
max_tokens=80,
|
||||
),
|
||||
timeout=15.0,
|
||||
)
|
||||
summary = (response.choices[0].message.content or "").strip()
|
||||
return summary or "Call completed."
|
||||
except Exception: # noqa: BLE001
|
||||
logger.exception("Call summary generation failed")
|
||||
return "Call completed — summary unavailable."
|
||||
|
||||
|
||||
async def finalize_call(call_id: str, *, status: str) -> None:
|
||||
"""Persist analytics, optional summary, and notify chat bridge."""
|
||||
from integrations.voice.chat_bridge import enqueue_call_summary
|
||||
from integrations.voice.store import VoiceCallStore
|
||||
|
||||
record = VoiceCallStore.get_call(call_id)
|
||||
if not record:
|
||||
return
|
||||
|
||||
transcript = (record.get("transcript") or "").strip()
|
||||
metadata: dict[str, Any] = dict(record.get("metadata") or {})
|
||||
ended_at = datetime.now(timezone.utc).isoformat()
|
||||
metadata["ended_at"] = ended_at
|
||||
metadata["turn_count"] = count_turns(transcript)
|
||||
metadata["duration_sec"] = duration_seconds(record.get("created_at"), ended_at)
|
||||
|
||||
summary = (record.get("summary") or "").strip()
|
||||
if not summary and transcript:
|
||||
summary = await generate_call_summary(transcript)
|
||||
|
||||
VoiceCallStore.upsert_call(
|
||||
call_id,
|
||||
status=status,
|
||||
summary=summary or None,
|
||||
metadata=metadata,
|
||||
)
|
||||
enqueue_call_summary(call_id, summary or "(no summary)", status)
|
||||
|
||||
|
||||
def analytics_for_record(record: dict[str, Any]) -> dict[str, Any]:
|
||||
"""Derived analytics for API + dashboard tables."""
|
||||
metadata = record.get("metadata") or {}
|
||||
transcript = record.get("transcript") or ""
|
||||
turns = metadata.get("turn_count")
|
||||
if turns is None:
|
||||
turns = count_turns(transcript)
|
||||
duration_sec = metadata.get("duration_sec")
|
||||
if duration_sec is None:
|
||||
duration_sec = duration_seconds(record.get("created_at"), metadata.get("ended_at"))
|
||||
return {
|
||||
"turn_count": turns,
|
||||
"duration_sec": duration_sec,
|
||||
"duration": format_duration(duration_sec if isinstance(duration_sec, int) else None),
|
||||
}
|
||||
@@ -0,0 +1,150 @@
|
||||
"""Bridge voice call events into Chat UI sessions (sidebar + history)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
import queue
|
||||
import threading
|
||||
from typing import Any
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
_bridge_queue: queue.Queue[dict[str, Any]] = queue.Queue(maxsize=512)
|
||||
_worker_lock = threading.Lock()
|
||||
_worker_task: asyncio.Task[None] | None = None
|
||||
|
||||
|
||||
def voice_session_id(call_id: str) -> str:
|
||||
return f"voice-{call_id}"
|
||||
|
||||
|
||||
def enqueue_transcript(call_id: str, role: str, text: str, *, final: bool) -> None:
|
||||
if not call_id or not text.strip():
|
||||
return
|
||||
_enqueue(
|
||||
{
|
||||
"kind": "transcript",
|
||||
"call_id": call_id,
|
||||
"role": role,
|
||||
"text": text.strip(),
|
||||
"final": final,
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
def enqueue_call_summary(call_id: str, summary: str, status: str) -> None:
|
||||
if not call_id:
|
||||
return
|
||||
_enqueue(
|
||||
{
|
||||
"kind": "summary",
|
||||
"call_id": call_id,
|
||||
"summary": summary.strip(),
|
||||
"status": status,
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
def _enqueue(item: dict[str, Any]) -> None:
|
||||
try:
|
||||
_bridge_queue.put_nowait(item)
|
||||
except queue.Full:
|
||||
logger.warning("Voice chat bridge queue full; dropping event")
|
||||
|
||||
|
||||
async def _ensure_session(call_id: str) -> str:
|
||||
from praisonaiui.server import _datastore
|
||||
|
||||
session_id = voice_session_id(call_id)
|
||||
existing = await _datastore.get_session(session_id)
|
||||
if existing is None:
|
||||
await _datastore.create_session(session_id)
|
||||
await _datastore.update_session(
|
||||
session_id,
|
||||
platform="voice",
|
||||
icon="📞",
|
||||
title=f"📞 Call {call_id[:8]}",
|
||||
)
|
||||
return session_id
|
||||
|
||||
|
||||
async def _process_item(item: dict[str, Any]) -> None:
|
||||
from praisonaiui.features.chat import get_chat_manager
|
||||
from praisonaiui.server import _datastore
|
||||
|
||||
call_id = str(item.get("call_id") or "")
|
||||
if not call_id:
|
||||
return
|
||||
session_id = await _ensure_session(call_id)
|
||||
mgr = get_chat_manager()
|
||||
|
||||
if item.get("kind") == "transcript":
|
||||
role = str(item.get("role") or "unknown")
|
||||
text = str(item.get("text") or "")
|
||||
final = bool(item.get("final"))
|
||||
if not final:
|
||||
await mgr.broadcast(
|
||||
session_id,
|
||||
{
|
||||
"type": "voice_transcript_partial",
|
||||
"session_id": session_id,
|
||||
"call_id": call_id,
|
||||
"role": role,
|
||||
"content": text,
|
||||
},
|
||||
)
|
||||
return
|
||||
content = f"**{role}:** {text}"
|
||||
await _datastore.add_message(
|
||||
session_id,
|
||||
{"role": "user" if role == "user" else "assistant", "content": content, "platform": "voice"},
|
||||
)
|
||||
await mgr.broadcast(
|
||||
session_id,
|
||||
{
|
||||
"type": "voice_transcript",
|
||||
"session_id": session_id,
|
||||
"call_id": call_id,
|
||||
"role": role,
|
||||
"content": text,
|
||||
},
|
||||
)
|
||||
return
|
||||
|
||||
if item.get("kind") == "summary":
|
||||
summary = str(item.get("summary") or "(call ended)")
|
||||
status = str(item.get("status") or "ended")
|
||||
content = f"**Call {status}**\n\n{summary}"
|
||||
await _datastore.add_message(session_id, {"role": "assistant", "content": content, "platform": "voice"})
|
||||
await mgr.broadcast(
|
||||
session_id,
|
||||
{
|
||||
"type": "voice_call_ended",
|
||||
"session_id": session_id,
|
||||
"call_id": call_id,
|
||||
"status": status,
|
||||
"summary": summary,
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
async def run_chat_bridge_worker() -> None:
|
||||
while True:
|
||||
try:
|
||||
item = await asyncio.to_thread(_bridge_queue.get, True, 1.0)
|
||||
except queue.Empty:
|
||||
continue
|
||||
try:
|
||||
await _process_item(item)
|
||||
except Exception as exc: # noqa: BLE001
|
||||
logger.debug("Voice chat bridge error: %s", exc)
|
||||
|
||||
|
||||
def start_chat_bridge_worker() -> None:
|
||||
global _worker_task
|
||||
with _worker_lock:
|
||||
if _worker_task is not None and not _worker_task.done():
|
||||
return
|
||||
loop = asyncio.get_running_loop()
|
||||
_worker_task = loop.create_task(run_chat_bridge_worker())
|
||||
@@ -18,6 +18,7 @@ class VoiceSettings:
|
||||
public_api_base_url: str | None
|
||||
default_assistant_id: str | None
|
||||
default_phone_number_id: str | None
|
||||
public_api_key: str | None
|
||||
api_base: str
|
||||
|
||||
@property
|
||||
@@ -61,5 +62,13 @@ def load_voice_settings(*, require_key: bool = True) -> VoiceSettings:
|
||||
public_api_base_url=_env("PUBLIC_API_BASE_URL") or None,
|
||||
default_assistant_id=_env("VOICE_ASSISTANT_ID") or None,
|
||||
default_phone_number_id=_env("VOICE_PHONE_NUMBER_ID") or None,
|
||||
public_api_key=_env("VOICE_PUBLIC_API_KEY") or None,
|
||||
api_base=api_base.rstrip("/"),
|
||||
)
|
||||
|
||||
|
||||
def get_web_sdk_settings() -> tuple[str, str]:
|
||||
"""Browser SDK URL + legacy global name (global unused for ESM imports)."""
|
||||
url = _env("VOICE_WEB_SDK_URL") or "https://esm.sh/@vapi-ai/[email protected]"
|
||||
global_name = _env("VOICE_WEB_SDK_GLOBAL") or "Vapi"
|
||||
return url, global_name
|
||||
@@ -0,0 +1,119 @@
|
||||
"""Voice agent stack diagnostics (OpenClaw-style doctor)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import socket
|
||||
from typing import Any
|
||||
from urllib.error import HTTPError, URLError
|
||||
from urllib.request import Request, urlopen
|
||||
|
||||
|
||||
def _tcp_open(host: str, port: int, timeout: float = 2.0) -> bool:
|
||||
try:
|
||||
with socket.create_connection((host, port), timeout=timeout):
|
||||
return True
|
||||
except OSError:
|
||||
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"})
|
||||
with urlopen(req, timeout=timeout) as resp:
|
||||
code = resp.getcode()
|
||||
if 200 <= code < 500:
|
||||
return "pass", f"HTTP {code}"
|
||||
return "warn", f"HTTP {code}"
|
||||
except HTTPError as exc:
|
||||
if 200 <= exc.code < 500:
|
||||
return "pass", f"HTTP {exc.code}"
|
||||
return "fail", f"HTTP {exc.code}"
|
||||
except URLError as exc:
|
||||
return "fail", str(exc.reason)
|
||||
except Exception as exc: # noqa: BLE001
|
||||
return "fail", str(exc)
|
||||
|
||||
|
||||
def _env_check(name: str, *, required: bool = False) -> dict[str, Any]:
|
||||
value = os.getenv(name, "").strip()
|
||||
if value:
|
||||
masked = value[:4] + "…" if len(value) > 8 else "set"
|
||||
return {"name": name, "status": "pass", "detail": masked}
|
||||
if required:
|
||||
return {"name": name, "status": "fail", "detail": "missing"}
|
||||
return {"name": name, "status": "warn", "detail": "not set"}
|
||||
|
||||
|
||||
def run_voice_doctor(*, app_port: int = 8001, sidecar_port: int = 8002) -> dict[str, Any]:
|
||||
"""Run local voice-agent health checks."""
|
||||
checks: list[dict[str, Any]] = []
|
||||
|
||||
checks.append(_env_check("OPENAI_API_KEY", required=True))
|
||||
checks.append(_env_check("ELEVENLABS_API_KEY"))
|
||||
checks.append(_env_check("SPEECH_ENGINE_ID"))
|
||||
checks.append(_env_check("SPEECH_ENGINE_PUBLIC_URL"))
|
||||
checks.append(_env_check("PUBLIC_API_BASE_URL"))
|
||||
checks.append(_env_check("VOICE_PRIVATE_API_KEY"))
|
||||
|
||||
if _tcp_open("127.0.0.1", app_port):
|
||||
checks.append({"name": f"App port {app_port}", "status": "pass", "detail": "listening"})
|
||||
status, detail = _http_status(f"http://127.0.0.1:{app_port}/api/voice/config")
|
||||
checks.append({"name": "Voice config API", "status": status, "detail": detail})
|
||||
status, detail = _http_status(f"http://127.0.0.1:{app_port}/api/voice/speech-engine/config")
|
||||
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 = _mcp_servers_probe(app_port)
|
||||
checks.append({"name": "MCP servers API", "status": status, "detail": detail})
|
||||
else:
|
||||
checks.append(
|
||||
{
|
||||
"name": f"App port {app_port}",
|
||||
"status": "fail",
|
||||
"detail": "not listening — run .\\start_dev.ps1",
|
||||
}
|
||||
)
|
||||
|
||||
if _tcp_open("127.0.0.1", sidecar_port):
|
||||
checks.append({"name": f"Speech sidecar {sidecar_port}", "status": "pass", "detail": "listening"})
|
||||
else:
|
||||
checks.append(
|
||||
{
|
||||
"name": f"Speech sidecar {sidecar_port}",
|
||||
"status": "warn",
|
||||
"detail": "not listening (ElevenLabs path needs sidecar)",
|
||||
}
|
||||
)
|
||||
|
||||
speech_url = os.getenv("SPEECH_ENGINE_PUBLIC_URL", "").strip().rstrip("/")
|
||||
if speech_url:
|
||||
status, detail = _http_status(speech_url)
|
||||
checks.append({"name": "Speech Engine tunnel", "status": status, "detail": detail})
|
||||
|
||||
passed = sum(1 for c in checks if c["status"] == "pass")
|
||||
warnings = sum(1 for c in checks if c["status"] == "warn")
|
||||
failed = sum(1 for c in checks if c["status"] == "fail")
|
||||
return {
|
||||
"checks": checks,
|
||||
"summary": {"passed": passed, "warnings": warnings, "failed": failed},
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
"""Live transcript pub/sub for voice calls (SSE)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import queue
|
||||
import threading
|
||||
from collections import defaultdict
|
||||
from typing import Any, AsyncIterator
|
||||
|
||||
|
||||
class TranscriptHub:
|
||||
"""Thread-safe hub broadcasting transcript events per call_id."""
|
||||
|
||||
def __init__(self) -> None:
|
||||
self._queues: dict[str, list[queue.Queue[dict[str, Any]]]] = defaultdict(list)
|
||||
self._lock = threading.Lock()
|
||||
|
||||
def publish(self, call_id: str, event: dict[str, Any]) -> None:
|
||||
if not call_id:
|
||||
return
|
||||
with self._lock:
|
||||
queues = list(self._queues.get(call_id, []))
|
||||
for sub in queues:
|
||||
try:
|
||||
sub.put_nowait(event)
|
||||
except queue.Full:
|
||||
pass
|
||||
|
||||
async def subscribe(self, call_id: str) -> AsyncIterator[dict[str, Any]]:
|
||||
sub: queue.Queue[dict[str, Any]] = queue.Queue(maxsize=256)
|
||||
with self._lock:
|
||||
self._queues[call_id].append(sub)
|
||||
try:
|
||||
while True:
|
||||
try:
|
||||
event = await asyncio.to_thread(sub.get, True, 1.0)
|
||||
except queue.Empty:
|
||||
continue
|
||||
yield event
|
||||
finally:
|
||||
with self._lock:
|
||||
if call_id in self._queues and sub in self._queues[call_id]:
|
||||
self._queues[call_id].remove(sub)
|
||||
|
||||
def snapshot_sse(self, event: dict[str, Any]) -> str:
|
||||
return f"data: {json.dumps(event, ensure_ascii=False)}\n\n"
|
||||
|
||||
|
||||
transcript_hub = TranscriptHub()
|
||||
@@ -2,13 +2,15 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import logging
|
||||
from typing import Any
|
||||
|
||||
from integrations.voice.agent_runner import build_assistant_request_response, execute_voice_tool
|
||||
from integrations.voice.config import VoiceSettings
|
||||
from integrations.voice.live import transcript_hub
|
||||
from integrations.voice.store import VoiceCallStore, webhook_event_key
|
||||
from integrations.voice.tools import execute_tool
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -35,6 +37,34 @@ def _customer_number(payload: dict[str, Any]) -> str | None:
|
||||
return None
|
||||
|
||||
|
||||
def _tool_parameters(item: dict[str, Any]) -> dict[str, Any]:
|
||||
"""Extract tool params from provider payloads (parameters or arguments)."""
|
||||
for key in ("parameters", "arguments"):
|
||||
value = item.get(key)
|
||||
if isinstance(value, dict):
|
||||
return value
|
||||
fn = item.get("function")
|
||||
if isinstance(fn, dict):
|
||||
for key in ("parameters", "arguments"):
|
||||
value = fn.get(key)
|
||||
if isinstance(value, dict):
|
||||
return value
|
||||
return {}
|
||||
|
||||
|
||||
def _spoken_tool_result(raw: str) -> str:
|
||||
"""Return plain spoken text for the voice pipeline (no newlines)."""
|
||||
try:
|
||||
data = json.loads(raw)
|
||||
if isinstance(data, dict):
|
||||
spoken = data.get("spoken") or data.get("message")
|
||||
if spoken:
|
||||
return str(spoken).replace("\n", " ").strip()
|
||||
except json.JSONDecodeError:
|
||||
pass
|
||||
return str(raw).replace("\n", " ").strip()
|
||||
|
||||
|
||||
def process_voice_webhook(
|
||||
event_type: str, payload: dict[str, Any], *, settings: VoiceSettings
|
||||
) -> dict[str, Any] | None:
|
||||
@@ -49,21 +79,36 @@ def process_voice_webhook(
|
||||
call_id = _call_id(payload)
|
||||
|
||||
if event_type == "assistant-request":
|
||||
assistant_id = settings.default_assistant_id
|
||||
if not assistant_id:
|
||||
return {"error": "No assistant configured on server"}
|
||||
return {"assistantId": assistant_id}
|
||||
return build_assistant_request_response(settings)
|
||||
|
||||
if event_type == "tool-calls" and call_id:
|
||||
tool_calls = msg.get("toolCallList") or []
|
||||
if not tool_calls and isinstance(msg.get("toolWithToolCallList"), list):
|
||||
for entry in msg["toolWithToolCallList"]:
|
||||
if not isinstance(entry, dict):
|
||||
continue
|
||||
tool_call = entry.get("toolCall")
|
||||
if isinstance(tool_call, dict):
|
||||
tool_calls.append(
|
||||
{
|
||||
"id": tool_call.get("id"),
|
||||
"name": entry.get("name") or (tool_call.get("function") or {}).get("name"),
|
||||
"parameters": _tool_parameters(tool_call),
|
||||
"arguments": _tool_parameters(tool_call),
|
||||
}
|
||||
)
|
||||
results = []
|
||||
for item in tool_calls:
|
||||
if not isinstance(item, dict):
|
||||
continue
|
||||
tool_call_id = str(item.get("id") or "")
|
||||
name = str(item.get("name") or "")
|
||||
params = item.get("parameters") if isinstance(item.get("parameters"), dict) else {}
|
||||
result = execute_tool(name, params)
|
||||
if not name:
|
||||
fn = item.get("function")
|
||||
if isinstance(fn, dict):
|
||||
name = str(fn.get("name") or "")
|
||||
params = _tool_parameters(item)
|
||||
result = _spoken_tool_result(execute_voice_tool(name, params))
|
||||
results.append({"toolCallId": tool_call_id, "name": name, "result": result})
|
||||
VoiceCallStore.upsert_call(call_id, status="in-progress", customer_number=_customer_number(payload))
|
||||
return {"results": results}
|
||||
@@ -76,8 +121,25 @@ def process_voice_webhook(
|
||||
if event_type == "transcript" and call_id:
|
||||
role = str(msg.get("role") or "unknown")
|
||||
text = str(msg.get("transcript") or "").strip()
|
||||
if text and str(msg.get("transcriptType") or "final") == "final":
|
||||
VoiceCallStore.append_transcript_line(call_id, f"{role}: {text}")
|
||||
kind = str(msg.get("transcriptType") or "final")
|
||||
if text:
|
||||
line = f"{role}: {text}"
|
||||
if kind == "final":
|
||||
VoiceCallStore.append_transcript_line(call_id, line)
|
||||
else:
|
||||
VoiceCallStore.upsert_call(
|
||||
call_id,
|
||||
status="in-progress",
|
||||
customer_number=_customer_number(payload),
|
||||
metadata={"partial_transcript": line},
|
||||
)
|
||||
transcript_hub.publish(
|
||||
call_id,
|
||||
{"type": "transcript", "role": role, "text": text, "transcriptType": kind},
|
||||
)
|
||||
from integrations.voice.chat_bridge import enqueue_transcript
|
||||
|
||||
enqueue_transcript(call_id, role, text, final=kind == "final")
|
||||
return None
|
||||
|
||||
if event_type == "end-of-call-report" and call_id:
|
||||
@@ -93,6 +155,9 @@ def process_voice_webhook(
|
||||
summary=summary or None,
|
||||
metadata={"artifact": artifact},
|
||||
)
|
||||
from integrations.voice.call_finalize import finalize_call
|
||||
|
||||
asyncio.run(finalize_call(call_id, status=f"ended ({ended})"))
|
||||
return None
|
||||
|
||||
if event_type == "conversation-update" and call_id:
|
||||
|
||||
@@ -0,0 +1,180 @@
|
||||
"""OpenAI Realtime API — browser WebRTC voice (gpt-realtime-2.1-mini)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import uuid
|
||||
from dataclasses import dataclass
|
||||
from typing import Any
|
||||
|
||||
import httpx
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
OPENAI_REALTIME_CALLS_URL = "https://api.openai.com/v1/realtime/calls"
|
||||
OPENAI_CLIENT_SECRETS_URL = "https://api.openai.com/v1/realtime/client_secrets"
|
||||
|
||||
|
||||
class RealtimeConfigError(Exception):
|
||||
pass
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class RealtimeSettings:
|
||||
api_key: str
|
||||
model: str
|
||||
voice: str
|
||||
reasoning_effort: str
|
||||
instructions: str
|
||||
first_message: str | None
|
||||
|
||||
|
||||
def load_realtime_settings() -> RealtimeSettings:
|
||||
api_key = os.getenv("OPENAI_API_KEY", "").strip()
|
||||
if not api_key:
|
||||
raise RealtimeConfigError("Set OPENAI_API_KEY in .env")
|
||||
first_message = os.getenv("REALTIME_FIRST_MESSAGE", "").strip() or None
|
||||
return RealtimeSettings(
|
||||
api_key=api_key,
|
||||
model=os.getenv("REALTIME_MODEL", "gpt-realtime-2.1-mini").strip(),
|
||||
voice=os.getenv("REALTIME_VOICE", "marin").strip(),
|
||||
reasoning_effort=os.getenv("REALTIME_REASONING_EFFORT", "low").strip(),
|
||||
instructions=os.getenv(
|
||||
"REALTIME_INSTRUCTIONS",
|
||||
"You are a helpful voice assistant for PraisonAI demos. "
|
||||
"Keep every reply to one or two short sentences.",
|
||||
).strip(),
|
||||
first_message=first_message,
|
||||
)
|
||||
|
||||
|
||||
def realtime_enabled() -> bool:
|
||||
try:
|
||||
load_realtime_settings()
|
||||
except RealtimeConfigError:
|
||||
return False
|
||||
return True
|
||||
|
||||
|
||||
def build_session_config(
|
||||
settings: RealtimeSettings | None = None,
|
||||
*,
|
||||
praison_session_id: str | None = None,
|
||||
) -> dict[str, Any]:
|
||||
from integrations.voice.session_memory import instructions_with_memory
|
||||
|
||||
cfg = settings or load_realtime_settings()
|
||||
session: dict[str, Any] = {
|
||||
"type": "realtime",
|
||||
"model": cfg.model,
|
||||
"instructions": instructions_with_memory(cfg.instructions, session_id=praison_session_id),
|
||||
"audio": {"output": {"voice": cfg.voice}},
|
||||
# Required for user transcript events in the browser data channel.
|
||||
"input_audio_transcription": {
|
||||
"model": os.getenv("REALTIME_TRANSCRIBE_MODEL", "gpt-4o-mini-transcribe"),
|
||||
},
|
||||
}
|
||||
if cfg.reasoning_effort:
|
||||
session["reasoning"] = {"effort": cfg.reasoning_effort}
|
||||
return session
|
||||
|
||||
|
||||
def new_call_id() -> str:
|
||||
return f"rt-{uuid.uuid4().hex[:12]}"
|
||||
|
||||
|
||||
async def create_webrtc_session(sdp_offer: str, *, call_id: str | None = None) -> str:
|
||||
"""Relay browser SDP to OpenAI unified Realtime calls endpoint."""
|
||||
from integrations.voice.session_memory import resolve_praison_session_id
|
||||
|
||||
settings = load_realtime_settings()
|
||||
praison_session_id = resolve_praison_session_id(call_id)
|
||||
session_json = json.dumps(build_session_config(settings, praison_session_id=praison_session_id))
|
||||
files = {
|
||||
"sdp": (None, sdp_offer, "application/sdp"),
|
||||
"session": (None, session_json, "application/json"),
|
||||
}
|
||||
async with httpx.AsyncClient(timeout=30.0) as client:
|
||||
response = await client.post(
|
||||
OPENAI_REALTIME_CALLS_URL,
|
||||
headers={"Authorization": f"Bearer {settings.api_key}"},
|
||||
files=files,
|
||||
)
|
||||
if response.status_code >= 400:
|
||||
detail = response.text[:500]
|
||||
logger.error("OpenAI Realtime session failed (%s): %s", response.status_code, detail)
|
||||
raise RealtimeConfigError(f"OpenAI Realtime session failed: HTTP {response.status_code}")
|
||||
return response.text
|
||||
|
||||
|
||||
async def mint_client_secret() -> str:
|
||||
"""Mint ephemeral key (alternative to unified SDP relay)."""
|
||||
settings = load_realtime_settings()
|
||||
payload = {"session": build_session_config(settings)}
|
||||
async with httpx.AsyncClient(timeout=30.0) as client:
|
||||
response = await client.post(
|
||||
OPENAI_CLIENT_SECRETS_URL,
|
||||
headers={
|
||||
"Authorization": f"Bearer {settings.api_key}",
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
json=payload,
|
||||
)
|
||||
if response.status_code >= 400:
|
||||
detail = response.text[:500]
|
||||
raise RealtimeConfigError(f"OpenAI client secret failed: HTTP {response.status_code} — {detail}")
|
||||
data = response.json()
|
||||
token = data.get("value") or data.get("client_secret", {}).get("value")
|
||||
if not token:
|
||||
raise RealtimeConfigError("OpenAI client secret response missing value")
|
||||
return str(token)
|
||||
|
||||
|
||||
def persist_transcript_line(call_id: str, role: str, text: str) -> None:
|
||||
from integrations.voice.live import transcript_hub
|
||||
from integrations.voice.store import VoiceCallStore
|
||||
|
||||
text = text.strip()
|
||||
if not call_id or not text:
|
||||
return
|
||||
record = VoiceCallStore.get_call(call_id) or {}
|
||||
prefix = f"{role}: {text}"
|
||||
existing = (record.get("transcript") or "").strip()
|
||||
merged = f"{existing}\n{prefix}".strip() if existing else prefix
|
||||
VoiceCallStore.upsert_call(
|
||||
call_id,
|
||||
status=record.get("status") or "in-progress",
|
||||
customer_number="web-openai-realtime",
|
||||
transcript=merged,
|
||||
)
|
||||
transcript_hub.publish(
|
||||
call_id,
|
||||
{
|
||||
"type": "transcript",
|
||||
"role": role,
|
||||
"transcriptType": "final",
|
||||
"text": text,
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
def mark_session_started(call_id: str) -> None:
|
||||
from integrations.voice.store import VoiceCallStore
|
||||
|
||||
VoiceCallStore.upsert_call(
|
||||
call_id,
|
||||
status="in-progress",
|
||||
customer_number="web-openai-realtime",
|
||||
)
|
||||
|
||||
|
||||
def mark_session_ended(call_id: str) -> None:
|
||||
from integrations.voice.store import VoiceCallStore
|
||||
|
||||
VoiceCallStore.upsert_call(
|
||||
call_id,
|
||||
status="ended (customer-ended-call)",
|
||||
customer_number="web-openai-realtime",
|
||||
)
|
||||
@@ -0,0 +1,251 @@
|
||||
"""Cross-call voice memory via PraisonAI session reload by stable user ID."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import os
|
||||
from typing import Any
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
_CALL_USER: dict[str, str] = {}
|
||||
|
||||
|
||||
def memory_enabled() -> bool:
|
||||
return os.getenv("VOICE_MEMORY_ENABLED", "true").lower() in ("1", "true", "yes")
|
||||
|
||||
|
||||
def voice_user_session_id(user_id: str) -> str:
|
||||
clean = str(user_id or "").strip()
|
||||
if not clean:
|
||||
raise ValueError("user_id required")
|
||||
return f"voice-user-{clean}"
|
||||
|
||||
|
||||
def attach_pending_user(user_id: str) -> None:
|
||||
from integrations.voice.store import VoiceCallStore
|
||||
|
||||
clean = str(user_id or "").strip()
|
||||
if clean:
|
||||
VoiceCallStore.set_pending_user(clean)
|
||||
|
||||
|
||||
def consume_pending_user() -> str | None:
|
||||
from integrations.voice.store import VoiceCallStore
|
||||
|
||||
return VoiceCallStore.consume_pending_user()
|
||||
|
||||
|
||||
def bind_call_user(call_id: str, user_id: str) -> str:
|
||||
"""Associate a provider call/conversation id with a stable browser user id."""
|
||||
from integrations.voice.store import VoiceCallStore
|
||||
|
||||
call_id = str(call_id or "").strip()
|
||||
user_id = str(user_id or "").strip()
|
||||
if not call_id or not user_id:
|
||||
raise ValueError("call_id and user_id required")
|
||||
session_id = voice_user_session_id(user_id)
|
||||
_CALL_USER[call_id] = user_id
|
||||
VoiceCallStore.upsert_call(
|
||||
call_id,
|
||||
metadata={"user_id": user_id, "praison_session_id": session_id},
|
||||
)
|
||||
return session_id
|
||||
|
||||
|
||||
def resolve_call_user(call_id: str | None) -> str | None:
|
||||
if not call_id:
|
||||
return None
|
||||
if call_id in _CALL_USER:
|
||||
return _CALL_USER[call_id]
|
||||
from integrations.voice.store import VoiceCallStore
|
||||
|
||||
record = VoiceCallStore.get_call(call_id)
|
||||
if not record:
|
||||
return None
|
||||
user_id = (record.get("metadata") or {}).get("user_id")
|
||||
if user_id:
|
||||
_CALL_USER[call_id] = str(user_id)
|
||||
return str(user_id) if user_id else None
|
||||
|
||||
|
||||
def resolve_praison_session_id(call_id: str | None) -> str | None:
|
||||
user_id = resolve_call_user(call_id)
|
||||
if not user_id:
|
||||
return None
|
||||
return voice_user_session_id(user_id)
|
||||
|
||||
|
||||
def _history_limit() -> int:
|
||||
try:
|
||||
return max(1, int(os.getenv("VOICE_MEMORY_HISTORY_LIMIT", "20")))
|
||||
except ValueError:
|
||||
return 20
|
||||
|
||||
|
||||
def load_session_history(session_id: str) -> list[dict[str, str]]:
|
||||
"""Load prior turns from PraisonAI DefaultSessionStore."""
|
||||
try:
|
||||
from praisonaiagents.session.store import DefaultSessionStore
|
||||
|
||||
store = DefaultSessionStore()
|
||||
return store.get_chat_history(session_id, max_messages=_history_limit())
|
||||
except Exception: # noqa: BLE001
|
||||
logger.debug("Failed to load session history for %s", session_id, exc_info=True)
|
||||
return []
|
||||
|
||||
|
||||
def append_session_turn(session_id: str, role: str, content: str) -> None:
|
||||
"""Persist one voice turn into the PraisonAI session folder."""
|
||||
text = str(content or "").strip()
|
||||
if not session_id or not text:
|
||||
return
|
||||
try:
|
||||
from praisonaiagents.session.store import DefaultSessionStore
|
||||
|
||||
store = DefaultSessionStore()
|
||||
if role == "user":
|
||||
store.add_user_message(session_id, text, metadata={"platform": "voice"})
|
||||
else:
|
||||
store.add_assistant_message(session_id, text, metadata={"platform": "voice"})
|
||||
except Exception: # noqa: BLE001
|
||||
logger.debug("Failed to append session turn for %s", session_id, exc_info=True)
|
||||
|
||||
|
||||
async def ensure_ui_session(session_id: str, *, user_id: str | None = None, call_id: str | None = None) -> None:
|
||||
"""Ensure PraisonAIUI datastore session exists for sidebar reload."""
|
||||
try:
|
||||
from praisonaiui.server import _datastore
|
||||
except Exception: # noqa: BLE001
|
||||
return
|
||||
existing = await _datastore.get_session(session_id)
|
||||
title = "Voice memory"
|
||||
if call_id:
|
||||
title = f"Voice user {user_id or call_id[:8]}"
|
||||
if existing is None:
|
||||
await _datastore.create_session(session_id)
|
||||
await _datastore.update_session(
|
||||
session_id,
|
||||
platform="voice",
|
||||
icon="🎙️",
|
||||
title=title,
|
||||
)
|
||||
|
||||
|
||||
async def append_ui_message(session_id: str, role: str, content: str) -> None:
|
||||
try:
|
||||
from praisonaiui.server import _datastore
|
||||
|
||||
await _datastore.add_message(
|
||||
session_id,
|
||||
{"role": "user" if role == "user" else "assistant", "content": content, "platform": "voice"},
|
||||
)
|
||||
except Exception: # noqa: BLE001
|
||||
logger.debug("Failed to append UI message for %s", session_id, exc_info=True)
|
||||
|
||||
|
||||
def _apply_knowledge_augmentation(prompt: str) -> str:
|
||||
from praisonaiui.features.knowledge import get_knowledge_manager
|
||||
|
||||
k_mgr = get_knowledge_manager()
|
||||
if not k_mgr.list_all():
|
||||
return prompt
|
||||
results = k_mgr.search(prompt, limit=5)
|
||||
lines = [r.get("text", "") for r in results if r.get("text")]
|
||||
if not lines:
|
||||
return prompt
|
||||
block = "\n".join(lines)
|
||||
return f"[Knowledge Context]\n{block}\n[/Knowledge Context]\n\n{prompt}"
|
||||
|
||||
|
||||
def build_knowledge_augmented_prompt(prompt: str, *, timeout_s: float | None = 2.0) -> str:
|
||||
"""Inject knowledge search results like dashboard chat run_agent().
|
||||
|
||||
Knowledge indexing can take tens of seconds on cold start; Speech Engine
|
||||
disconnects if the LLM path blocks too long, so callers use a short timeout.
|
||||
"""
|
||||
if not os.getenv("VOICE_MEMORY_USE_KNOWLEDGE", "true").lower() in ("1", "true", "yes"):
|
||||
return prompt
|
||||
try:
|
||||
if timeout_s is None:
|
||||
return _apply_knowledge_augmentation(prompt)
|
||||
from concurrent.futures import ThreadPoolExecutor, TimeoutError as FuturesTimeoutError
|
||||
|
||||
with ThreadPoolExecutor(max_workers=1) as pool:
|
||||
future = pool.submit(_apply_knowledge_augmentation, prompt)
|
||||
return future.result(timeout=timeout_s)
|
||||
except FuturesTimeoutError:
|
||||
logger.warning("Knowledge augmentation timed out after %.1fs; using plain prompt", timeout_s)
|
||||
return prompt
|
||||
except Exception: # noqa: BLE001
|
||||
logger.debug("Knowledge augmentation skipped", exc_info=True)
|
||||
return prompt
|
||||
|
||||
|
||||
def history_context_block(session_id: str | None) -> str:
|
||||
if not memory_enabled() or not session_id:
|
||||
return ""
|
||||
history = load_session_history(session_id)
|
||||
if not history:
|
||||
return ""
|
||||
lines = []
|
||||
for item in history[-_history_limit() :]:
|
||||
role = item.get("role", "user")
|
||||
content = str(item.get("content") or "").strip()
|
||||
if content:
|
||||
lines.append(f"{role}: {content}")
|
||||
if not lines:
|
||||
return ""
|
||||
return "Previous conversation with this user:\n" + "\n".join(lines)
|
||||
|
||||
|
||||
def openai_messages_with_memory(
|
||||
transcript_lines: list[str],
|
||||
*,
|
||||
session_id: str | None,
|
||||
base_instructions: str,
|
||||
) -> list[dict[str, str]]:
|
||||
messages: list[dict[str, str]] = [{"role": "system", "content": base_instructions}]
|
||||
prior = history_context_block(session_id)
|
||||
if prior:
|
||||
messages.append({"role": "system", "content": prior})
|
||||
for line in transcript_lines:
|
||||
if line.startswith("user:"):
|
||||
messages.append({"role": "user", "content": line[5:].strip()})
|
||||
elif line.startswith("agent:"):
|
||||
messages.append({"role": "assistant", "content": line[6:].strip()})
|
||||
return messages
|
||||
|
||||
|
||||
def bind_call_from_pending(call_id: str) -> str | None:
|
||||
user_id = consume_pending_user()
|
||||
if not user_id:
|
||||
return None
|
||||
return bind_call_user(call_id, user_id)
|
||||
|
||||
|
||||
async def record_voice_turn(
|
||||
call_id: str | None,
|
||||
*,
|
||||
user_text: str | None = None,
|
||||
agent_text: str | None = None,
|
||||
) -> None:
|
||||
"""Save latest user/agent lines to Praison session + chat UI."""
|
||||
session_id = resolve_praison_session_id(call_id)
|
||||
if not session_id:
|
||||
return
|
||||
user_id = resolve_call_user(call_id)
|
||||
await ensure_ui_session(session_id, user_id=user_id, call_id=call_id)
|
||||
if user_text:
|
||||
append_session_turn(session_id, "user", user_text)
|
||||
await append_ui_message(session_id, "user", user_text)
|
||||
if agent_text:
|
||||
append_session_turn(session_id, "assistant", agent_text)
|
||||
await append_ui_message(session_id, "assistant", agent_text)
|
||||
|
||||
|
||||
def instructions_with_memory(base: str, *, session_id: str | None) -> str:
|
||||
prior = history_context_block(session_id)
|
||||
if not prior:
|
||||
return base
|
||||
return f"{base}\n\n{prior}\nUse prior conversation when the user refers to earlier calls."
|
||||
@@ -0,0 +1,346 @@
|
||||
"""ElevenLabs Speech Engine — WebSocket LLM bridge for browser voice."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
import os
|
||||
from dataclasses import dataclass
|
||||
from typing import Any
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
_engine_resource: Any | None = None
|
||||
_engine_lock = asyncio.Lock()
|
||||
|
||||
|
||||
class SpeechEngineConfigError(Exception):
|
||||
pass
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class SpeechEngineSettings:
|
||||
api_key: str
|
||||
engine_id: str | None
|
||||
public_ws_url: str | None
|
||||
first_message: str
|
||||
|
||||
|
||||
def _public_ws_url() -> str | None:
|
||||
speech_base = os.getenv("SPEECH_ENGINE_PUBLIC_URL", "").strip().rstrip("/")
|
||||
base = speech_base or os.getenv("PUBLIC_API_BASE_URL", "").strip().rstrip("/")
|
||||
if not base:
|
||||
return None
|
||||
if base.startswith("https://"):
|
||||
return "wss://" + base[len("https://") :] + "/ws"
|
||||
if base.startswith("http://"):
|
||||
return "ws://" + base[len("http://") :] + "/ws"
|
||||
return None
|
||||
|
||||
|
||||
def load_speech_engine_settings(*, require_engine_id: bool = False) -> SpeechEngineSettings:
|
||||
api_key = os.getenv("ELEVENLABS_API_KEY", "").strip()
|
||||
if not api_key:
|
||||
raise SpeechEngineConfigError("Set ELEVENLABS_API_KEY in .env")
|
||||
engine_id = os.getenv("SPEECH_ENGINE_ID", "").strip() or None
|
||||
if require_engine_id and not engine_id:
|
||||
raise SpeechEngineConfigError("Set SPEECH_ENGINE_ID in .env (run setup_speech_engine.py)")
|
||||
return SpeechEngineSettings(
|
||||
api_key=api_key,
|
||||
engine_id=engine_id,
|
||||
public_ws_url=_public_ws_url(),
|
||||
first_message=os.getenv(
|
||||
"SPEECH_ENGINE_FIRST_MESSAGE", "Hello! How can I help you today?"
|
||||
).strip(),
|
||||
)
|
||||
|
||||
|
||||
def speech_engine_enabled() -> bool:
|
||||
try:
|
||||
settings = load_speech_engine_settings()
|
||||
except SpeechEngineConfigError:
|
||||
return False
|
||||
return bool(settings.engine_id and settings.public_ws_url)
|
||||
|
||||
|
||||
async def get_engine_resource():
|
||||
"""Lazy-load the ElevenLabs SpeechEngineResource."""
|
||||
global _engine_resource
|
||||
if _engine_resource is not None:
|
||||
return _engine_resource
|
||||
|
||||
async with _engine_lock:
|
||||
if _engine_resource is not None:
|
||||
return _engine_resource
|
||||
settings = load_speech_engine_settings(require_engine_id=True)
|
||||
try:
|
||||
from elevenlabs import AsyncElevenLabs
|
||||
except ImportError as exc:
|
||||
raise SpeechEngineConfigError(
|
||||
"Install Speech Engine SDK: pip install 'elevenlabs>=2.47.0'"
|
||||
) from exc
|
||||
|
||||
if not hasattr(AsyncElevenLabs(api_key=settings.api_key), "speech_engine"):
|
||||
raise SpeechEngineConfigError(
|
||||
"elevenlabs package is too old. Run: pip install -U 'elevenlabs>=2.47.0'"
|
||||
)
|
||||
|
||||
client = AsyncElevenLabs(api_key=settings.api_key)
|
||||
_engine_resource = await client.speech_engine.get(settings.engine_id)
|
||||
return _engine_resource
|
||||
|
||||
|
||||
def _conversation_id(session: Any) -> str | None:
|
||||
cid = getattr(session, "conversation_id", None)
|
||||
return str(cid) if cid else None
|
||||
|
||||
|
||||
def _transcript_display_text(transcript: list[Any], agent_reply: str | None = None) -> str:
|
||||
text = _transcript_to_prompt(transcript)
|
||||
if agent_reply:
|
||||
agent_line = f"agent: {agent_reply.strip()}"
|
||||
text = f"{text}\n{agent_line}".strip() if text else agent_line
|
||||
return text
|
||||
|
||||
|
||||
def _persist_conversation_turn(call_id: str, transcript: list[Any], agent_reply: str) -> None:
|
||||
from integrations.voice.live import transcript_hub
|
||||
from integrations.voice.store import VoiceCallStore
|
||||
|
||||
display = _transcript_display_text(transcript, agent_reply)
|
||||
VoiceCallStore.upsert_call(
|
||||
call_id,
|
||||
status="in-progress",
|
||||
customer_number="web-speech-engine",
|
||||
transcript=display,
|
||||
)
|
||||
prompt = _transcript_to_prompt(transcript)
|
||||
if prompt:
|
||||
last_line = prompt.splitlines()[-1]
|
||||
role, _, content = last_line.partition(": ")
|
||||
transcript_hub.publish(
|
||||
call_id,
|
||||
{
|
||||
"type": "transcript",
|
||||
"role": role or "user",
|
||||
"transcriptType": "final",
|
||||
"text": content,
|
||||
},
|
||||
)
|
||||
transcript_hub.publish(
|
||||
call_id,
|
||||
{
|
||||
"type": "transcript",
|
||||
"role": "assistant",
|
||||
"transcriptType": "final",
|
||||
"text": agent_reply,
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
def _transcript_to_prompt(transcript: list[Any]) -> str:
|
||||
lines: list[str] = []
|
||||
for message in transcript:
|
||||
role = getattr(message, "role", None) or (message.get("role") if isinstance(message, dict) else "user")
|
||||
content = getattr(message, "content", None) or (
|
||||
message.get("content") if isinstance(message, dict) else ""
|
||||
)
|
||||
text = str(content or "").strip()
|
||||
if text:
|
||||
lines.append(f"{role}: {text}")
|
||||
if not lines:
|
||||
return ""
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
def _try_demo_tool_reply(transcript: list[Any]) -> str | None:
|
||||
from integrations.voice.tools import demo_tool_reply
|
||||
|
||||
user_text = _latest_user_text(transcript)
|
||||
if not user_text:
|
||||
return None
|
||||
return demo_tool_reply(user_text)
|
||||
|
||||
|
||||
async def _run_openai_reply(transcript: list[Any], *, session_id: str | None = None) -> str:
|
||||
"""Voice LLM path with demo fast-path + OpenAI tool calling."""
|
||||
import json
|
||||
|
||||
from openai import AsyncOpenAI
|
||||
|
||||
from integrations.voice.agent_runner import execute_voice_tool
|
||||
from integrations.voice.session_memory import openai_messages_with_memory
|
||||
from integrations.voice.tools import openai_tool_schemas, tool_result_for_voice
|
||||
|
||||
prompt = _transcript_to_prompt(transcript)
|
||||
if not prompt:
|
||||
return "I did not catch that. Could you repeat?"
|
||||
|
||||
fast = _try_demo_tool_reply(transcript)
|
||||
if fast:
|
||||
return fast
|
||||
|
||||
client = AsyncOpenAI(api_key=os.getenv("OPENAI_API_KEY", "").strip())
|
||||
model = os.getenv("VOICE_AGENT_MODEL", "gpt-4o-mini")
|
||||
base_instructions = (
|
||||
"You are a helpful voice assistant for PraisonAI demos. "
|
||||
"Keep answers concise (1-3 sentences) and conversational. "
|
||||
"When the caller asks for the time, call get_current_time. "
|
||||
"When they ask you to repeat or echo text, call echo_message."
|
||||
)
|
||||
messages = openai_messages_with_memory(
|
||||
prompt.splitlines(),
|
||||
session_id=session_id,
|
||||
base_instructions=base_instructions,
|
||||
)
|
||||
use_tools = os.getenv("VOICE_SPEECH_USE_TOOLS", "true").lower() in ("1", "true", "yes")
|
||||
create_kwargs: dict[str, Any] = {
|
||||
"model": model,
|
||||
"messages": messages,
|
||||
"max_tokens": 200,
|
||||
}
|
||||
if use_tools:
|
||||
create_kwargs["tools"] = openai_tool_schemas()
|
||||
create_kwargs["tool_choice"] = "auto"
|
||||
|
||||
response = await client.chat.completions.create(**create_kwargs)
|
||||
choice = response.choices[0].message
|
||||
|
||||
if use_tools and choice.tool_calls:
|
||||
messages.append(choice.model_dump(exclude_none=True))
|
||||
fallback = "Done."
|
||||
for tool_call in choice.tool_calls:
|
||||
fn = tool_call.function
|
||||
name = fn.name if fn else ""
|
||||
try:
|
||||
args = json.loads(fn.arguments or "{}") if fn else {}
|
||||
except json.JSONDecodeError:
|
||||
args = {}
|
||||
raw = execute_voice_tool(name, args, session_id=session_id)
|
||||
fallback = tool_result_for_voice(raw)
|
||||
messages.append(
|
||||
{
|
||||
"role": "tool",
|
||||
"tool_call_id": tool_call.id,
|
||||
"content": raw,
|
||||
}
|
||||
)
|
||||
final = await client.chat.completions.create(
|
||||
model=model,
|
||||
messages=messages,
|
||||
max_tokens=200,
|
||||
)
|
||||
text = (final.choices[0].message.content or "").strip()
|
||||
return text or fallback
|
||||
|
||||
text = (choice.content or "").strip()
|
||||
return text or "Sorry, I could not generate a response."
|
||||
|
||||
|
||||
async def _run_agent_reply(transcript: list[Any], *, session_id: str | None = None) -> str:
|
||||
prompt = _transcript_to_prompt(transcript)
|
||||
if not prompt:
|
||||
return "I did not catch that. Could you repeat?"
|
||||
|
||||
# Prefer fast OpenAI streaming for Speech Engine turn latency.
|
||||
if os.getenv("OPENAI_API_KEY", "").strip():
|
||||
try:
|
||||
return await asyncio.wait_for(
|
||||
_run_openai_reply(transcript, session_id=session_id),
|
||||
timeout=20.0,
|
||||
)
|
||||
except TimeoutError:
|
||||
return "Sorry, that took too long. Could you ask again with a shorter question?"
|
||||
except Exception: # noqa: BLE001
|
||||
logger.exception("OpenAI Speech Engine reply failed; falling back to agent")
|
||||
|
||||
from integrations.voice.agent_runner import get_voice_agent
|
||||
|
||||
agent = get_voice_agent(session_id)
|
||||
reply = await asyncio.wait_for(asyncio.to_thread(agent.chat, prompt), timeout=25.0)
|
||||
return str(reply).strip() or "Sorry, I could not generate a response."
|
||||
|
||||
|
||||
def _latest_user_text(transcript: list[Any]) -> str:
|
||||
prompt = _transcript_to_prompt(transcript)
|
||||
if not prompt:
|
||||
return ""
|
||||
for line in reversed(prompt.splitlines()):
|
||||
if line.startswith("user:"):
|
||||
return line[5:].strip()
|
||||
return ""
|
||||
|
||||
|
||||
async def handle_user_transcript(transcript: list[Any], session: Any) -> None:
|
||||
"""Run voice LLM on transcript and send text back to Speech Engine."""
|
||||
from integrations.voice.session_memory import record_voice_turn, resolve_praison_session_id
|
||||
|
||||
call_id = _conversation_id(session)
|
||||
praison_session_id = resolve_praison_session_id(call_id)
|
||||
preview = _transcript_to_prompt(transcript)
|
||||
print(f"[speech sidecar] transcript: {preview[:200]!r}", flush=True)
|
||||
reply = ""
|
||||
try:
|
||||
reply = await _run_agent_reply(transcript, session_id=praison_session_id)
|
||||
print(f"[speech sidecar] reply: {reply[:200]!r}", flush=True)
|
||||
await session.send_response(reply)
|
||||
except TimeoutError:
|
||||
reply = "Sorry, that took too long. Please try a shorter question."
|
||||
await session.send_response(reply)
|
||||
except Exception: # noqa: BLE001
|
||||
logger.exception("Speech Engine agent reply failed")
|
||||
reply = "Sorry, something went wrong on my side."
|
||||
await session.send_response(reply)
|
||||
if call_id and reply:
|
||||
_persist_conversation_turn(call_id, transcript, reply)
|
||||
user_text = _latest_user_text(transcript)
|
||||
await record_voice_turn(call_id, user_text=user_text or None, agent_text=reply)
|
||||
|
||||
|
||||
async def handle_session_init(conversation_id: str, session: Any) -> None:
|
||||
from integrations.voice.session_memory import bind_call_from_pending, ensure_ui_session, resolve_praison_session_id
|
||||
from integrations.voice.store import VoiceCallStore
|
||||
|
||||
call_id = str(conversation_id)
|
||||
bind_call_from_pending(call_id)
|
||||
VoiceCallStore.upsert_call(
|
||||
call_id,
|
||||
status="in-progress",
|
||||
customer_number="web-speech-engine",
|
||||
)
|
||||
session_id = resolve_praison_session_id(call_id)
|
||||
if session_id:
|
||||
await ensure_ui_session(session_id, call_id=call_id)
|
||||
logger.info("Speech Engine session started: %s", conversation_id)
|
||||
|
||||
|
||||
async def handle_session_close(session: Any) -> None:
|
||||
from integrations.voice.call_finalize import finalize_call
|
||||
|
||||
conversation_id = _conversation_id(session)
|
||||
if conversation_id:
|
||||
await finalize_call(conversation_id, status="ended (customer-ended-call)")
|
||||
logger.info("Speech Engine session ended: %s", conversation_id)
|
||||
|
||||
|
||||
async def handle_session_disconnect(session: Any) -> None:
|
||||
"""Fallback when WebSocket drops without a formal close message."""
|
||||
await handle_session_close(session)
|
||||
|
||||
|
||||
def get_webrtc_token() -> str:
|
||||
"""Mint a browser conversation token (keeps API key server-side)."""
|
||||
settings = load_speech_engine_settings(require_engine_id=True)
|
||||
try:
|
||||
from elevenlabs import ElevenLabs
|
||||
except ImportError as exc:
|
||||
raise SpeechEngineConfigError("Install elevenlabs: pip install elevenlabs") from exc
|
||||
|
||||
client = ElevenLabs(api_key=settings.api_key)
|
||||
response = client.conversational_ai.conversations.get_webrtc_token(
|
||||
agent_id=settings.engine_id,
|
||||
)
|
||||
token = getattr(response, "token", None) or (response.get("token") if isinstance(response, dict) else None)
|
||||
if not token:
|
||||
raise SpeechEngineConfigError("Could not mint Speech Engine conversation token")
|
||||
return str(token)
|
||||
@@ -43,6 +43,15 @@ def _connect() -> sqlite3.Connection:
|
||||
)
|
||||
"""
|
||||
)
|
||||
conn.execute(
|
||||
"""
|
||||
CREATE TABLE IF NOT EXISTS voice_memory_pending (
|
||||
key TEXT PRIMARY KEY,
|
||||
user_id TEXT NOT NULL,
|
||||
updated_at TEXT NOT NULL
|
||||
)
|
||||
"""
|
||||
)
|
||||
return conn
|
||||
|
||||
|
||||
@@ -146,14 +155,25 @@ class VoiceCallStore:
|
||||
|
||||
@staticmethod
|
||||
def append_transcript_line(call_id: str, line: str) -> str:
|
||||
with closing(_connect()) as conn:
|
||||
now = datetime.now(timezone.utc).isoformat()
|
||||
with closing(_connect()) as conn, conn:
|
||||
row = conn.execute(
|
||||
"SELECT transcript FROM voice_calls WHERE call_id = ?",
|
||||
(call_id,),
|
||||
).fetchone()
|
||||
existing = row[0] if row else ""
|
||||
full = f"{existing}\n{line}".strip() if existing else line
|
||||
VoiceCallStore.upsert_call(call_id, transcript=full)
|
||||
existing = row[0] if row else ""
|
||||
full = f"{existing}\n{line}".strip() if existing else line
|
||||
conn.execute(
|
||||
"""
|
||||
INSERT INTO voice_calls (
|
||||
call_id, status, transcript, summary, metadata_json, created_at, updated_at
|
||||
) VALUES (?, 'unknown', ?, '', '{}', ?, ?)
|
||||
ON CONFLICT(call_id) DO UPDATE SET
|
||||
transcript = excluded.transcript,
|
||||
updated_at = excluded.updated_at
|
||||
""",
|
||||
(call_id, full, now, now),
|
||||
)
|
||||
return full
|
||||
|
||||
@staticmethod
|
||||
@@ -161,7 +181,8 @@ class VoiceCallStore:
|
||||
with closing(_connect()) as conn:
|
||||
rows = conn.execute(
|
||||
"""
|
||||
SELECT call_id, status, customer_number, transcript, summary, metadata_json, updated_at
|
||||
SELECT call_id, status, customer_number, transcript, summary, metadata_json,
|
||||
created_at, updated_at
|
||||
FROM voice_calls ORDER BY updated_at DESC LIMIT ?
|
||||
""",
|
||||
(max(1, limit),),
|
||||
@@ -176,17 +197,43 @@ class VoiceCallStore:
|
||||
"transcript": row[3],
|
||||
"summary": row[4],
|
||||
"metadata": json.loads(row[5] or "{}"),
|
||||
"updated_at": row[6],
|
||||
"created_at": row[6],
|
||||
"updated_at": row[7],
|
||||
}
|
||||
)
|
||||
return out
|
||||
|
||||
@staticmethod
|
||||
def set_pending_user(user_id: str) -> None:
|
||||
now = datetime.now(timezone.utc).isoformat()
|
||||
with closing(_connect()) as conn, conn:
|
||||
conn.execute(
|
||||
"""
|
||||
INSERT INTO voice_memory_pending (key, user_id, updated_at)
|
||||
VALUES ('browser', ?, ?)
|
||||
ON CONFLICT(key) DO UPDATE SET user_id = excluded.user_id, updated_at = excluded.updated_at
|
||||
""",
|
||||
(user_id, now),
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def consume_pending_user() -> str | None:
|
||||
with closing(_connect()) as conn, conn:
|
||||
row = conn.execute(
|
||||
"SELECT user_id FROM voice_memory_pending WHERE key = 'browser'",
|
||||
).fetchone()
|
||||
if not row:
|
||||
return None
|
||||
conn.execute("DELETE FROM voice_memory_pending WHERE key = 'browser'")
|
||||
return str(row[0])
|
||||
|
||||
@staticmethod
|
||||
def get_call(call_id: str) -> dict[str, Any] | None:
|
||||
with closing(_connect()) as conn:
|
||||
row = conn.execute(
|
||||
"""
|
||||
SELECT call_id, status, customer_number, transcript, summary, metadata_json, updated_at
|
||||
SELECT call_id, status, customer_number, transcript, summary, metadata_json,
|
||||
created_at, updated_at
|
||||
FROM voice_calls WHERE call_id = ?
|
||||
""",
|
||||
(call_id,),
|
||||
@@ -200,5 +247,6 @@ class VoiceCallStore:
|
||||
"transcript": row[3],
|
||||
"summary": row[4],
|
||||
"metadata": json.loads(row[5] or "{}"),
|
||||
"updated_at": row[6],
|
||||
"created_at": row[6],
|
||||
"updated_at": row[7],
|
||||
}
|
||||
@@ -19,6 +19,65 @@ def echo_message(message: str = "") -> dict[str, Any]:
|
||||
return {"message": text, "spoken": f"You said {text}"}
|
||||
|
||||
|
||||
def demo_tool_reply(text: str) -> str | None:
|
||||
"""Fast path for common voice tool queries — no LLM round-trip."""
|
||||
lower = text.lower().strip()
|
||||
if any(p in lower for p in ("what time", "current time", "tell me the time", "time is it")):
|
||||
return str(get_current_time()["spoken"])
|
||||
if lower.startswith("echo "):
|
||||
return str(echo_message(text[5:])["spoken"])
|
||||
if lower.startswith("repeat "):
|
||||
return str(echo_message(text[7:])["spoken"])
|
||||
return None
|
||||
|
||||
|
||||
def openai_tool_schemas() -> list[dict[str, Any]]:
|
||||
"""OpenAI function schemas for Speech Engine / voice LLM paths."""
|
||||
return [
|
||||
{
|
||||
"type": "function",
|
||||
"function": {
|
||||
"name": "get_current_time",
|
||||
"description": "Return the current UTC time when the caller asks for the time.",
|
||||
"parameters": {"type": "object", "properties": {}, "required": []},
|
||||
},
|
||||
},
|
||||
{
|
||||
"type": "function",
|
||||
"function": {
|
||||
"name": "echo_message",
|
||||
"description": "Repeat or echo back a phrase the caller wants repeated.",
|
||||
"parameters": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"message": {
|
||||
"type": "string",
|
||||
"description": "The text to echo back to the caller",
|
||||
}
|
||||
},
|
||||
"required": ["message"],
|
||||
},
|
||||
},
|
||||
},
|
||||
]
|
||||
|
||||
|
||||
def tool_result_for_voice(raw_result: str) -> str:
|
||||
"""Convert tool JSON output to speakable voice text."""
|
||||
try:
|
||||
data = json.loads(raw_result)
|
||||
except json.JSONDecodeError:
|
||||
return raw_result.strip() or "Done."
|
||||
if isinstance(data, dict):
|
||||
spoken = data.get("spoken")
|
||||
if spoken:
|
||||
return str(spoken)
|
||||
err = data.get("error")
|
||||
if err:
|
||||
return f"Sorry, {err}"
|
||||
return raw_result.strip() or "Done."
|
||||
|
||||
|
||||
_TOOL_REGISTRY: dict[str, Callable[..., Any]] = {
|
||||
"get_current_time": get_current_time,
|
||||
"echo_message": echo_message,
|
||||
|
||||
@@ -0,0 +1,76 @@
|
||||
"""HTTP routes for stable voice user sessions."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from starlette.requests import Request
|
||||
from starlette.responses import JSONResponse
|
||||
|
||||
from integrations.voice.session_memory import (
|
||||
attach_pending_user,
|
||||
bind_call_user,
|
||||
memory_enabled,
|
||||
resolve_praison_session_id,
|
||||
voice_user_session_id,
|
||||
)
|
||||
|
||||
|
||||
async def api_voice_memory_config(_request: Request) -> JSONResponse:
|
||||
return JSONResponse({"enabled": memory_enabled()})
|
||||
|
||||
|
||||
async def api_voice_memory_bind(request: Request) -> JSONResponse:
|
||||
try:
|
||||
payload = await request.json()
|
||||
except Exception: # noqa: BLE001
|
||||
return JSONResponse({"error": "Invalid JSON"}, status_code=400)
|
||||
call_id = str(payload.get("call_id") or "").strip()
|
||||
user_id = str(payload.get("user_id") or "").strip()
|
||||
if not call_id or not user_id:
|
||||
return JSONResponse({"error": "call_id and user_id required"}, status_code=400)
|
||||
try:
|
||||
session_id = bind_call_user(call_id, user_id)
|
||||
except ValueError as exc:
|
||||
return JSONResponse({"error": str(exc)}, status_code=400)
|
||||
return JSONResponse(
|
||||
{
|
||||
"ok": True,
|
||||
"call_id": call_id,
|
||||
"user_id": user_id,
|
||||
"praison_session_id": session_id,
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
async def api_voice_memory_resolve(request: Request) -> JSONResponse:
|
||||
user_id = request.query_params.get("user_id", "").strip()
|
||||
if not user_id:
|
||||
return JSONResponse({"error": "user_id required"}, status_code=400)
|
||||
session_id = voice_user_session_id(user_id)
|
||||
return JSONResponse({"user_id": user_id, "praison_session_id": session_id})
|
||||
|
||||
|
||||
async def api_voice_memory_attach_user(request: Request) -> JSONResponse:
|
||||
"""ElevenLabs: browser attaches user before conversation id exists."""
|
||||
try:
|
||||
payload = await request.json()
|
||||
except Exception: # noqa: BLE001
|
||||
return JSONResponse({"error": "Invalid JSON"}, status_code=400)
|
||||
user_id = str(payload.get("user_id") or "").strip()
|
||||
if not user_id:
|
||||
return JSONResponse({"error": "user_id required"}, status_code=400)
|
||||
attach_pending_user(user_id)
|
||||
return JSONResponse(
|
||||
{
|
||||
"ok": True,
|
||||
"user_id": user_id,
|
||||
"praison_session_id": voice_user_session_id(user_id),
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
async def api_voice_memory_for_call(request: Request) -> JSONResponse:
|
||||
call_id = request.query_params.get("call_id", "").strip()
|
||||
if not call_id:
|
||||
return JSONResponse({"error": "call_id required"}, status_code=400)
|
||||
session_id = resolve_praison_session_id(call_id)
|
||||
return JSONResponse({"call_id": call_id, "praison_session_id": session_id})
|
||||
@@ -0,0 +1,781 @@
|
||||
/**
|
||||
* Voice Agent Phase 2 — live transcript + browser talk widget.
|
||||
*
|
||||
* custom.js can run before dashboard.js defines window.aiui, so we wait.
|
||||
*/
|
||||
(function () {
|
||||
function whenReady(fn) {
|
||||
if (window.aiui && typeof window.aiui.registerView === 'function') {
|
||||
fn();
|
||||
return;
|
||||
}
|
||||
var n = 0;
|
||||
var t = setInterval(function () {
|
||||
n += 1;
|
||||
if (window.aiui && typeof window.aiui.registerView === 'function') {
|
||||
clearInterval(t);
|
||||
fn();
|
||||
} else if (n > 150) {
|
||||
clearInterval(t);
|
||||
console.warn('[voice plugin] window.aiui never became ready');
|
||||
}
|
||||
}, 100);
|
||||
}
|
||||
|
||||
function el(tag, style, text) {
|
||||
var node = document.createElement(tag);
|
||||
if (style) node.style.cssText = style;
|
||||
if (text != null) node.textContent = text;
|
||||
return node;
|
||||
}
|
||||
|
||||
function currentCallId() {
|
||||
return new URLSearchParams(window.location.search).get('call_id') || '';
|
||||
}
|
||||
|
||||
function voiceUserId() {
|
||||
var key = 'voice-user-id';
|
||||
var id = localStorage.getItem(key);
|
||||
if (!id) {
|
||||
id = 'u-' + Math.random().toString(36).slice(2, 12);
|
||||
localStorage.setItem(key, id);
|
||||
}
|
||||
return id;
|
||||
}
|
||||
|
||||
async function postJson(url, body) {
|
||||
var resp = await fetch(url, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(body || {}),
|
||||
});
|
||||
if (!resp.ok) throw new Error('HTTP ' + resp.status);
|
||||
return resp.json();
|
||||
}
|
||||
|
||||
async function attachVoiceUser() {
|
||||
return postJson('/api/voice/memory/attach-user', { user_id: voiceUserId() });
|
||||
}
|
||||
|
||||
function renderUiComponent(comp) {
|
||||
if (window.aiui && typeof window.aiui.renderComponent === 'function') {
|
||||
return window.aiui.renderComponent(comp);
|
||||
}
|
||||
var wrap = el('div');
|
||||
if (comp.type === 'code_block') {
|
||||
var pre = el(
|
||||
'pre',
|
||||
'margin:0;padding:16px;border-radius:10px;background:var(--db-card-bg,#1f2937);' +
|
||||
'border:1px solid var(--db-border,#334155);white-space:pre-wrap;font-family:ui-monospace,monospace;font-size:13px'
|
||||
);
|
||||
pre.textContent = comp.code || '';
|
||||
wrap.appendChild(pre);
|
||||
wrap.className = 'db-code-block';
|
||||
return wrap;
|
||||
}
|
||||
if (comp.type === 'text') {
|
||||
wrap.textContent = comp.content || '';
|
||||
return wrap;
|
||||
}
|
||||
wrap.textContent = JSON.stringify(comp);
|
||||
return wrap;
|
||||
}
|
||||
|
||||
function renderUiTree(data, container) {
|
||||
if (!data) return;
|
||||
if (data._components) {
|
||||
data._components.forEach(function (c) {
|
||||
container.appendChild(renderUiComponent(c));
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (data.type === 'layout' && data.children) {
|
||||
data.children.forEach(function (c) {
|
||||
container.appendChild(renderUiComponent(c));
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function findTranscriptCodeEl(root) {
|
||||
var blocks = root.querySelectorAll('.db-code-block code');
|
||||
if (!blocks.length) {
|
||||
blocks = root.querySelectorAll('.db-code-block pre');
|
||||
}
|
||||
return blocks.length ? blocks[blocks.length - 1] : null;
|
||||
}
|
||||
|
||||
async function fetchJson(url, retries) {
|
||||
var attempts = retries == null ? 1 : retries;
|
||||
var lastErr = null;
|
||||
for (var i = 0; i < attempts; i += 1) {
|
||||
try {
|
||||
var resp = await fetch(url);
|
||||
if (!resp.ok) throw new Error('HTTP ' + resp.status);
|
||||
return resp.json();
|
||||
} catch (err) {
|
||||
lastErr = err;
|
||||
if (i < attempts - 1) {
|
||||
await new Promise(function (r) {
|
||||
setTimeout(r, 1500);
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
throw lastErr || new Error('fetch failed');
|
||||
}
|
||||
|
||||
async function resolveCallId() {
|
||||
var fromQuery = currentCallId();
|
||||
if (fromQuery) return fromQuery;
|
||||
var data = await fetchJson('/api/voice/calls');
|
||||
var calls = data.calls || [];
|
||||
var live = calls.find(function (c) {
|
||||
return String(c.status || '').toLowerCase().indexOf('progress') >= 0;
|
||||
});
|
||||
return (live && live.call_id) || (calls[0] && calls[0].call_id) || '';
|
||||
}
|
||||
|
||||
async function loadWebSdk(cfg) {
|
||||
var candidates = [];
|
||||
var configured = cfg.sdk_url || '';
|
||||
if (configured && configured.indexOf('dist/vapi.js') < 0) {
|
||||
candidates.push(configured);
|
||||
}
|
||||
candidates.push('https://esm.sh/@vapi-ai/[email protected]');
|
||||
|
||||
var lastErr = null;
|
||||
for (var i = 0; i < candidates.length; i++) {
|
||||
try {
|
||||
var mod = await import(/* webpackIgnore: true */ candidates[i]);
|
||||
var Sdk = mod.default || mod.Vapi;
|
||||
if (typeof Sdk === 'function') return Sdk;
|
||||
lastErr = new Error('Module loaded but no Vapi class');
|
||||
} catch (err) {
|
||||
lastErr = err;
|
||||
console.warn('[voice plugin] SDK load failed for', candidates[i], err);
|
||||
}
|
||||
}
|
||||
throw new Error(
|
||||
'Could not load voice web SDK' + (lastErr && lastErr.message ? ': ' + lastErr.message : '')
|
||||
);
|
||||
}
|
||||
|
||||
function registerVoiceViews() {
|
||||
window.aiui.registerView(
|
||||
'call-detail',
|
||||
async function (container) {
|
||||
container.innerHTML = '';
|
||||
var wrap = el(
|
||||
'div',
|
||||
'padding:20px;display:flex;flex-direction:column;gap:16px;color:var(--db-text,#eee)'
|
||||
);
|
||||
var uiHost = el('div', 'display:flex;flex-direction:column;gap:12px');
|
||||
var statusLine = el(
|
||||
'div',
|
||||
'font-size:12px;color:var(--db-text-dim,#94a3b8)',
|
||||
'Loading PraisonAIUI components…'
|
||||
);
|
||||
wrap.appendChild(uiHost);
|
||||
wrap.appendChild(statusLine);
|
||||
container.appendChild(wrap);
|
||||
|
||||
var es = null;
|
||||
var lines = [];
|
||||
var transcriptEl = null;
|
||||
|
||||
function renderTranscript(fullText) {
|
||||
if (!transcriptEl) return;
|
||||
if (fullText) {
|
||||
transcriptEl.textContent = fullText;
|
||||
return;
|
||||
}
|
||||
transcriptEl.textContent = lines.length ? lines.join('\n') : '(empty)';
|
||||
}
|
||||
|
||||
function appendLine(role, text) {
|
||||
lines.push(role + ': ' + text);
|
||||
renderTranscript('');
|
||||
}
|
||||
|
||||
try {
|
||||
var callId = await resolveCallId();
|
||||
if (!callId) {
|
||||
renderUiTree(
|
||||
{
|
||||
type: 'layout',
|
||||
children: [
|
||||
{
|
||||
type: 'alert',
|
||||
variant: 'info',
|
||||
title: 'Call detail',
|
||||
message: 'No calls yet — start from ElevenLabs talk or GPT Realtime.',
|
||||
},
|
||||
],
|
||||
},
|
||||
uiHost
|
||||
);
|
||||
statusLine.textContent = 'Idle';
|
||||
return;
|
||||
}
|
||||
var uiData = await fetchJson('/api/voice/calls/' + encodeURIComponent(callId) + '/ui');
|
||||
renderUiTree(uiData.layout, uiHost);
|
||||
transcriptEl = findTranscriptCodeEl(uiHost);
|
||||
var detail = await fetchJson('/api/voice/calls/' + encodeURIComponent(callId));
|
||||
if (detail.transcript) {
|
||||
lines = detail.transcript.split('\n').filter(Boolean);
|
||||
renderTranscript(detail.transcript);
|
||||
}
|
||||
statusLine.textContent = 'Live feed connecting…';
|
||||
es = new EventSource(
|
||||
'/api/voice/calls/' + encodeURIComponent(callId) + '/live-transcript'
|
||||
);
|
||||
es.onmessage = function (ev) {
|
||||
try {
|
||||
var payload = JSON.parse(ev.data);
|
||||
if (payload.type === 'snapshot' && payload.transcript) {
|
||||
lines = payload.transcript.split('\n').filter(Boolean);
|
||||
renderTranscript(payload.transcript);
|
||||
statusLine.textContent = 'Live · snapshot loaded';
|
||||
return;
|
||||
}
|
||||
if (payload.type === 'transcript') {
|
||||
if (payload.transcriptType === 'final') {
|
||||
appendLine(payload.role || 'unknown', payload.text || '');
|
||||
} else if (transcriptEl) {
|
||||
transcriptEl.textContent =
|
||||
(lines.length ? lines.join('\n') + '\n' : '') +
|
||||
(payload.role || 'unknown') +
|
||||
': ' +
|
||||
(payload.text || '') +
|
||||
' …';
|
||||
}
|
||||
statusLine.textContent = 'Live · ' + (payload.transcriptType || 'update');
|
||||
}
|
||||
} catch (err) {
|
||||
console.warn('[voice plugin] SSE parse error', err);
|
||||
}
|
||||
};
|
||||
es.onerror = function () {
|
||||
statusLine.textContent = 'Live feed disconnected (call may have ended)';
|
||||
};
|
||||
} catch (err) {
|
||||
statusLine.textContent = 'Failed to load call: ' + err.message;
|
||||
}
|
||||
|
||||
return function cleanup() {
|
||||
if (es) es.close();
|
||||
};
|
||||
},
|
||||
function () {}
|
||||
);
|
||||
|
||||
window.aiui.registerView(
|
||||
'web-talk',
|
||||
async function (container) {
|
||||
container.innerHTML = '';
|
||||
var wrap = el('div', 'padding:24px;max-width:640px;color:var(--db-text,#eee)');
|
||||
wrap.appendChild(el('h2', 'margin:0 0 12px 0', 'Web voice'));
|
||||
wrap.appendChild(
|
||||
el(
|
||||
'p',
|
||||
'margin:0 0 20px 0;line-height:1.6;color:var(--db-text-dim,#94a3b8)',
|
||||
'Click Start talking, then allow the microphone when Chrome asks. The prompt does not appear until you click the button.'
|
||||
)
|
||||
);
|
||||
var status = el('div', 'margin-bottom:16px;font-size:13px', 'Loading config…');
|
||||
var btn = el(
|
||||
'button',
|
||||
'padding:12px 20px;border-radius:10px;border:none;background:var(--db-accent,#6366f1);' +
|
||||
'color:#fff;font-size:15px;font-weight:600;cursor:pointer',
|
||||
'Start talking'
|
||||
);
|
||||
btn.disabled = true;
|
||||
wrap.appendChild(status);
|
||||
wrap.appendChild(btn);
|
||||
container.appendChild(wrap);
|
||||
|
||||
var cfg = null;
|
||||
var client = null;
|
||||
|
||||
try {
|
||||
cfg = await fetchJson('/api/voice/web-config');
|
||||
if (!cfg.enabled) {
|
||||
status.textContent =
|
||||
cfg.error ||
|
||||
'Web talk is not configured. Set VOICE_PUBLIC_API_KEY, VOICE_ASSISTANT_ID, VOICE_WEB_SDK_URL, VOICE_WEB_SDK_GLOBAL.';
|
||||
btn.style.display = 'none';
|
||||
return;
|
||||
}
|
||||
status.textContent = 'Ready — click Start talking, then allow microphone.';
|
||||
btn.disabled = false;
|
||||
} catch (err) {
|
||||
status.textContent = 'Config error: ' + err.message;
|
||||
return;
|
||||
}
|
||||
|
||||
btn.addEventListener('click', async function () {
|
||||
if (client && typeof client.stop === 'function' && btn.textContent === 'End call') {
|
||||
client.stop();
|
||||
client = null;
|
||||
status.textContent = 'Call ended';
|
||||
btn.textContent = 'Start talking';
|
||||
return;
|
||||
}
|
||||
btn.disabled = true;
|
||||
status.textContent = 'Loading web SDK…';
|
||||
try {
|
||||
var Sdk = await loadWebSdk(cfg);
|
||||
client = new Sdk(cfg.public_key);
|
||||
if (typeof client.start === 'function') {
|
||||
await client.start(cfg.assistant_id);
|
||||
} else if (typeof client.startAssistant === 'function') {
|
||||
await client.startAssistant(cfg.assistant_id);
|
||||
} else {
|
||||
throw new Error('SDK has no start() method');
|
||||
}
|
||||
status.textContent = 'In call — allow microphone if Chrome asks. Speak now.';
|
||||
btn.textContent = 'End call';
|
||||
btn.disabled = false;
|
||||
} catch (err) {
|
||||
status.textContent = 'Start failed: ' + err.message;
|
||||
btn.disabled = false;
|
||||
}
|
||||
});
|
||||
}
|
||||
);
|
||||
|
||||
window.aiui.registerView(
|
||||
'eleven-talk',
|
||||
async function (container) {
|
||||
container.innerHTML = '';
|
||||
var wrap = el('div', 'padding:24px;max-width:640px;color:var(--db-text,#eee)');
|
||||
wrap.appendChild(el('h2', 'margin:0 0 12px 0', 'ElevenLabs voice'));
|
||||
wrap.appendChild(
|
||||
el(
|
||||
'p',
|
||||
'margin:0 0 20px 0;line-height:1.6;color:var(--db-text-dim,#94a3b8)',
|
||||
'Speech Engine runs STT/TTS in the browser. Your PraisonAI agent handles replies on the server.'
|
||||
)
|
||||
);
|
||||
var status = el('div', 'margin-bottom:16px;font-size:13px', 'Loading config…');
|
||||
var btn = el(
|
||||
'button',
|
||||
'padding:12px 20px;border-radius:10px;border:none;background:var(--db-accent,#6366f1);' +
|
||||
'color:#fff;font-size:15px;font-weight:600;cursor:pointer',
|
||||
'Start conversation'
|
||||
);
|
||||
btn.disabled = true;
|
||||
wrap.appendChild(status);
|
||||
wrap.appendChild(btn);
|
||||
container.appendChild(wrap);
|
||||
|
||||
var conversation = null;
|
||||
var cfg = null;
|
||||
|
||||
try {
|
||||
cfg = await fetchJson('/api/voice/speech-engine/config', 8);
|
||||
if (!cfg.enabled) {
|
||||
status.textContent =
|
||||
cfg.error ||
|
||||
'Speech Engine not ready. Run: pip install -U "elevenlabs>=2.47.0" then .\\start_dev.ps1 -Restart. ' +
|
||||
'API key needs convai_write permission in ElevenLabs dashboard.';
|
||||
btn.style.display = 'none';
|
||||
return;
|
||||
}
|
||||
var toolList = (cfg.tools && cfg.tools.length ? cfg.tools.join(', ') : 'get_current_time, echo_message');
|
||||
status.textContent =
|
||||
'Ready — LLM ' +
|
||||
(cfg.model || 'gpt-4o-mini') +
|
||||
' · tools: ' +
|
||||
toolList +
|
||||
'. Click Start conversation, then allow microphone.';
|
||||
btn.disabled = false;
|
||||
} catch (err) {
|
||||
status.textContent = 'Config error: ' + err.message;
|
||||
return;
|
||||
}
|
||||
|
||||
btn.addEventListener('click', async function () {
|
||||
if (conversation && btn.textContent === 'End conversation') {
|
||||
try {
|
||||
await conversation.endSession();
|
||||
} catch (e) {
|
||||
/* ignore */
|
||||
}
|
||||
conversation = null;
|
||||
status.textContent = 'Conversation ended';
|
||||
btn.textContent = 'Start conversation';
|
||||
return;
|
||||
}
|
||||
btn.disabled = true;
|
||||
status.textContent = 'Loading ElevenLabs client…';
|
||||
try {
|
||||
var sdkUrl = cfg.client_sdk_url || 'https://esm.sh/@elevenlabs/client';
|
||||
var mod = await import(/* webpackIgnore: true */ sdkUrl);
|
||||
var Conversation = mod.Conversation;
|
||||
if (!Conversation || typeof Conversation.startSession !== 'function') {
|
||||
throw new Error('ElevenLabs client SDK missing Conversation.startSession');
|
||||
}
|
||||
var tokenResp = await fetchJson('/api/voice/speech-engine/token');
|
||||
if (!tokenResp.token) throw new Error('No conversation token returned');
|
||||
await navigator.mediaDevices.getUserMedia({ audio: true });
|
||||
await attachVoiceUser();
|
||||
var startOpts = {
|
||||
conversationToken: tokenResp.token,
|
||||
onConnect: function () {
|
||||
status.textContent =
|
||||
'Connected — speak, then pause ~1s so the agent knows you finished.';
|
||||
btn.textContent = 'End conversation';
|
||||
btn.disabled = false;
|
||||
},
|
||||
onDisconnect: function (details) {
|
||||
var reason = details && details.reason ? details.reason : '';
|
||||
if (reason === 'agent' || reason === 'unknown') {
|
||||
reason =
|
||||
'Speech Engine server did not respond in time. Restart .\\start_dev.ps1 -Restart and try again.';
|
||||
} else if (!reason) {
|
||||
reason =
|
||||
'Connection closed (ElevenLabs could not reach your Speech Engine WebSocket).';
|
||||
}
|
||||
status.textContent = 'Disconnected — ' + reason;
|
||||
btn.textContent = 'Start conversation';
|
||||
btn.disabled = false;
|
||||
conversation = null;
|
||||
},
|
||||
onError: function (error) {
|
||||
status.textContent =
|
||||
'Error: ' + (error && error.message ? error.message : String(error));
|
||||
btn.disabled = false;
|
||||
},
|
||||
onStatusChange: function (state) {
|
||||
if (state && state.status) {
|
||||
status.textContent = 'Status: ' + state.status;
|
||||
}
|
||||
},
|
||||
onModeChange: function (mode) {
|
||||
if (!mode || !mode.mode) return;
|
||||
if (mode.mode === 'listening') {
|
||||
status.textContent = 'Listening… speak, then pause when done.';
|
||||
} else if (mode.mode === 'speaking') {
|
||||
status.textContent = 'Agent speaking…';
|
||||
} else {
|
||||
status.textContent = 'Mode: ' + mode.mode;
|
||||
}
|
||||
},
|
||||
onMessage: function (msg) {
|
||||
if (msg && msg.message) {
|
||||
console.log('[eleven-talk]', msg.source || 'unknown', msg.message);
|
||||
}
|
||||
},
|
||||
};
|
||||
if (cfg.first_message) {
|
||||
startOpts.overrides = {
|
||||
agent: { firstMessage: cfg.first_message },
|
||||
};
|
||||
}
|
||||
conversation = await Conversation.startSession(startOpts);
|
||||
} catch (err) {
|
||||
status.textContent = 'Start failed: ' + err.message;
|
||||
btn.disabled = false;
|
||||
}
|
||||
});
|
||||
}
|
||||
);
|
||||
|
||||
window.aiui.registerView(
|
||||
'realtime-talk',
|
||||
async function (container) {
|
||||
container.innerHTML = '';
|
||||
var wrap = el('div', 'padding:24px;max-width:640px;color:var(--db-text,#eee)');
|
||||
wrap.appendChild(el('h2', 'margin:0 0 12px 0', 'GPT Realtime voice'));
|
||||
wrap.appendChild(
|
||||
el(
|
||||
'p',
|
||||
'margin:0 0 20px 0;line-height:1.6;color:var(--db-text-dim,#94a3b8)',
|
||||
'Speech-to-speech via OpenAI gpt-realtime-2.1-mini (WebRTC).'
|
||||
)
|
||||
);
|
||||
var status = el('div', 'margin-bottom:16px;font-size:13px', 'Loading config…');
|
||||
var btn = el(
|
||||
'button',
|
||||
'padding:12px 20px;border-radius:10px;border:none;background:var(--db-accent,#6366f1);' +
|
||||
'color:#fff;font-size:15px;font-weight:600;cursor:pointer',
|
||||
'Start conversation'
|
||||
);
|
||||
btn.disabled = true;
|
||||
wrap.appendChild(status);
|
||||
wrap.appendChild(btn);
|
||||
container.appendChild(wrap);
|
||||
|
||||
var pc = null;
|
||||
var dc = null;
|
||||
var audioEl = null;
|
||||
var localStream = null;
|
||||
var callId = null;
|
||||
var cfg = null;
|
||||
|
||||
async function saveTranscript(role, text) {
|
||||
if (!callId || !text) return;
|
||||
try {
|
||||
await postJson('/api/voice/realtime/transcript', {
|
||||
call_id: callId,
|
||||
role: role,
|
||||
text: text,
|
||||
});
|
||||
} catch (e) {
|
||||
console.warn('[realtime-talk] transcript save failed', e);
|
||||
}
|
||||
}
|
||||
|
||||
function handleRealtimeEvent(event) {
|
||||
if (!event || !event.type) return;
|
||||
if (
|
||||
event.type === 'conversation.item.input_audio_transcription.completed' &&
|
||||
event.transcript
|
||||
) {
|
||||
saveTranscript('user', event.transcript);
|
||||
status.textContent = 'You: ' + event.transcript;
|
||||
}
|
||||
if (event.type === 'response.output_audio_transcript.done' && event.transcript) {
|
||||
saveTranscript('agent', event.transcript);
|
||||
status.textContent = 'Agent: ' + event.transcript;
|
||||
}
|
||||
if (event.type === 'response.output_audio_transcript.delta' && event.delta) {
|
||||
status.textContent = 'Agent speaking…';
|
||||
}
|
||||
if (event.type === 'input_audio_buffer.speech_started') {
|
||||
status.textContent = 'Listening…';
|
||||
}
|
||||
}
|
||||
|
||||
async function endSession() {
|
||||
if (dc) {
|
||||
try {
|
||||
dc.close();
|
||||
} catch (e) {
|
||||
/* ignore */
|
||||
}
|
||||
dc = null;
|
||||
}
|
||||
if (pc) {
|
||||
try {
|
||||
pc.close();
|
||||
} catch (e) {
|
||||
/* ignore */
|
||||
}
|
||||
pc = null;
|
||||
}
|
||||
if (localStream) {
|
||||
localStream.getTracks().forEach(function (t) {
|
||||
t.stop();
|
||||
});
|
||||
localStream = null;
|
||||
}
|
||||
if (audioEl && audioEl.parentNode) {
|
||||
audioEl.parentNode.removeChild(audioEl);
|
||||
audioEl = null;
|
||||
}
|
||||
if (callId) {
|
||||
try {
|
||||
await postJson('/api/voice/realtime/end', { call_id: callId });
|
||||
} catch (e) {
|
||||
/* ignore */
|
||||
}
|
||||
}
|
||||
callId = null;
|
||||
status.textContent = 'Conversation ended.';
|
||||
btn.textContent = 'Start conversation';
|
||||
btn.disabled = false;
|
||||
}
|
||||
|
||||
try {
|
||||
cfg = await fetchJson('/api/voice/realtime/config', 8);
|
||||
if (!cfg.enabled) {
|
||||
status.textContent =
|
||||
cfg.error || 'Set OPENAI_API_KEY in .env, then restart .\\start_dev.ps1';
|
||||
btn.style.display = 'none';
|
||||
return;
|
||||
}
|
||||
status.textContent = 'Ready (' + (cfg.model || 'gpt-realtime-2.1-mini') + ').';
|
||||
btn.disabled = false;
|
||||
} catch (err) {
|
||||
status.textContent = 'Config error: ' + err.message;
|
||||
return;
|
||||
}
|
||||
|
||||
btn.addEventListener('click', async function () {
|
||||
if (pc && btn.textContent === 'End conversation') {
|
||||
await endSession();
|
||||
return;
|
||||
}
|
||||
btn.disabled = true;
|
||||
status.textContent = 'Starting WebRTC session…';
|
||||
try {
|
||||
var callResp = await postJson('/api/voice/realtime/new-call', {
|
||||
user_id: voiceUserId(),
|
||||
});
|
||||
callId = callResp.call_id;
|
||||
localStream = await navigator.mediaDevices.getUserMedia({ audio: true });
|
||||
pc = new RTCPeerConnection();
|
||||
audioEl = document.createElement('audio');
|
||||
audioEl.autoplay = true;
|
||||
audioEl.style.display = 'none';
|
||||
wrap.appendChild(audioEl);
|
||||
pc.ontrack = function (e) {
|
||||
if (audioEl) audioEl.srcObject = e.streams[0];
|
||||
};
|
||||
localStream.getTracks().forEach(function (track) {
|
||||
pc.addTrack(track, localStream);
|
||||
});
|
||||
dc = pc.createDataChannel('oai-events');
|
||||
dc.addEventListener('open', function () {
|
||||
if (cfg.first_message) {
|
||||
dc.send(
|
||||
JSON.stringify({
|
||||
type: 'response.create',
|
||||
response: {
|
||||
instructions:
|
||||
'Greet the user by saying exactly: ' + cfg.first_message,
|
||||
},
|
||||
})
|
||||
);
|
||||
}
|
||||
});
|
||||
dc.addEventListener('message', function (e) {
|
||||
try {
|
||||
handleRealtimeEvent(JSON.parse(e.data));
|
||||
} catch (err) {
|
||||
console.warn('[realtime-talk] event parse failed', err);
|
||||
}
|
||||
});
|
||||
var offer = await pc.createOffer();
|
||||
await pc.setLocalDescription(offer);
|
||||
var sdpResp = await fetch(
|
||||
'/api/voice/realtime/session?call_id=' + encodeURIComponent(callId),
|
||||
{
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/sdp' },
|
||||
body: offer.sdp,
|
||||
}
|
||||
);
|
||||
if (!sdpResp.ok) {
|
||||
var errText = await sdpResp.text();
|
||||
throw new Error(errText || 'SDP exchange failed');
|
||||
}
|
||||
var answerSdp = await sdpResp.text();
|
||||
await pc.setRemoteDescription({ type: 'answer', sdp: answerSdp });
|
||||
status.textContent = 'Connected — speak when ready.';
|
||||
btn.textContent = 'End conversation';
|
||||
btn.disabled = false;
|
||||
} catch (err) {
|
||||
status.textContent = 'Start failed: ' + err.message;
|
||||
await endSession();
|
||||
btn.disabled = false;
|
||||
}
|
||||
});
|
||||
}
|
||||
);
|
||||
|
||||
window.aiui.registerView(
|
||||
'config',
|
||||
async function (container) {
|
||||
container.innerHTML = '';
|
||||
var wrap = el('div', 'padding:24px;max-width:900px;color:var(--db-text,#eee)');
|
||||
wrap.appendChild(el('h2', 'margin:0 0 8px 0', 'Voice stack doctor'));
|
||||
wrap.appendChild(
|
||||
el(
|
||||
'p',
|
||||
'margin:0 0 16px 0;font-size:13px;color:var(--db-text-dim,#94a3b8)',
|
||||
'Checks .env keys, local ports, APIs, and Speech Engine tunnel reachability.'
|
||||
)
|
||||
);
|
||||
var status = el('div', 'margin-bottom:16px;font-size:13px', 'Running checks…');
|
||||
var tableHost = el('div', '');
|
||||
wrap.appendChild(status);
|
||||
wrap.appendChild(tableHost);
|
||||
container.appendChild(wrap);
|
||||
|
||||
function statusColor(st) {
|
||||
if (st === 'pass') return '#22c55e';
|
||||
if (st === 'warn') return '#eab308';
|
||||
return '#ef4444';
|
||||
}
|
||||
|
||||
try {
|
||||
var data = await fetchJson('/api/voice/doctor', 12);
|
||||
var summary = data.summary || {};
|
||||
status.textContent =
|
||||
'Passed ' +
|
||||
(summary.passed || 0) +
|
||||
' · Warnings ' +
|
||||
(summary.warnings || 0) +
|
||||
' · Failed ' +
|
||||
(summary.failed || 0);
|
||||
var rows = (data.checks || []).map(function (c) {
|
||||
return [
|
||||
c.name || '—',
|
||||
c.status || '—',
|
||||
c.detail || '—',
|
||||
];
|
||||
});
|
||||
renderUiTree(
|
||||
{
|
||||
type: 'layout',
|
||||
children: [
|
||||
{
|
||||
type: 'table',
|
||||
headers: ['Check', 'Status', 'Detail'],
|
||||
rows: rows.length ? rows : [['—', '—', '—']],
|
||||
},
|
||||
],
|
||||
},
|
||||
tableHost
|
||||
);
|
||||
var badges = tableHost.querySelectorAll('td');
|
||||
badges.forEach(function (cell) {
|
||||
if (cell.textContent === 'pass' || cell.textContent === 'warn' || cell.textContent === 'fail') {
|
||||
cell.style.color = statusColor(cell.textContent);
|
||||
cell.style.fontWeight = '600';
|
||||
}
|
||||
});
|
||||
} catch (err) {
|
||||
status.textContent = 'Doctor failed: ' + err.message;
|
||||
}
|
||||
},
|
||||
function () {}
|
||||
);
|
||||
|
||||
console.log(
|
||||
'[voice plugin] Registered call-detail + web-talk + eleven-talk + realtime-talk + config views'
|
||||
);
|
||||
|
||||
var path = (window.location.pathname || '').replace(/^\//, '');
|
||||
if (
|
||||
(path === 'web-talk' ||
|
||||
path === 'call-detail' ||
|
||||
path === 'eleven-talk' ||
|
||||
path === 'realtime-talk') &&
|
||||
typeof window.aiui.selectPage === 'function'
|
||||
) {
|
||||
window.aiui.selectPage(path);
|
||||
} else if (
|
||||
path === 'web-talk' ||
|
||||
path === 'call-detail' ||
|
||||
path === 'eleven-talk' ||
|
||||
path === 'realtime-talk'
|
||||
) {
|
||||
var n = 0;
|
||||
var t = setInterval(function () {
|
||||
n += 1;
|
||||
if (typeof window.aiui.selectPage === 'function') {
|
||||
clearInterval(t);
|
||||
window.aiui.selectPage(path);
|
||||
} else if (n > 50) {
|
||||
clearInterval(t);
|
||||
}
|
||||
}, 100);
|
||||
}
|
||||
}
|
||||
|
||||
whenReady(registerVoiceViews);
|
||||
})();
|
||||
@@ -0,0 +1,115 @@
|
||||
"""HTTP routes for OpenAI Realtime WebRTC voice."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
|
||||
from starlette.requests import Request
|
||||
from starlette.responses import JSONResponse, PlainTextResponse
|
||||
|
||||
from integrations.voice.realtime import (
|
||||
RealtimeConfigError,
|
||||
load_realtime_settings,
|
||||
mark_session_started,
|
||||
new_call_id,
|
||||
persist_transcript_line,
|
||||
realtime_enabled,
|
||||
create_webrtc_session,
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
async def api_realtime_config(_request: Request) -> JSONResponse:
|
||||
try:
|
||||
settings = load_realtime_settings()
|
||||
enabled = realtime_enabled()
|
||||
error = None
|
||||
except RealtimeConfigError as exc:
|
||||
settings = None
|
||||
enabled = False
|
||||
error = str(exc)
|
||||
return JSONResponse(
|
||||
{
|
||||
"enabled": enabled,
|
||||
"error": error,
|
||||
"model": settings.model if settings else None,
|
||||
"voice": settings.voice if settings else None,
|
||||
"reasoning_effort": settings.reasoning_effort if settings else None,
|
||||
"first_message": settings.first_message if settings else None,
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
async def api_realtime_new_call(request: Request) -> JSONResponse:
|
||||
from integrations.voice.session_memory import bind_call_user, voice_user_session_id
|
||||
|
||||
try:
|
||||
payload = await request.json()
|
||||
except Exception: # noqa: BLE001
|
||||
payload = {}
|
||||
call_id = new_call_id()
|
||||
mark_session_started(call_id)
|
||||
user_id = str(payload.get("user_id") or "").strip()
|
||||
praison_session_id = None
|
||||
if user_id:
|
||||
praison_session_id = bind_call_user(call_id, user_id)
|
||||
return JSONResponse(
|
||||
{
|
||||
"call_id": call_id,
|
||||
"user_id": user_id or None,
|
||||
"praison_session_id": praison_session_id or (voice_user_session_id(user_id) if user_id else None),
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
async def api_realtime_session(request: Request) -> PlainTextResponse | JSONResponse:
|
||||
"""Unified WebRTC interface — browser SDP in, OpenAI SDP answer out."""
|
||||
call_id = request.query_params.get("call_id", "").strip()
|
||||
body = await request.body()
|
||||
sdp_offer = body.decode("utf-8").strip()
|
||||
if not sdp_offer:
|
||||
return JSONResponse({"error": "Missing SDP offer body"}, status_code=400)
|
||||
try:
|
||||
sdp_answer = await create_webrtc_session(sdp_offer, call_id=call_id or None)
|
||||
except RealtimeConfigError as exc:
|
||||
return JSONResponse({"error": str(exc)}, status_code=503)
|
||||
except Exception as exc: # noqa: BLE001
|
||||
logger.exception("Realtime WebRTC session failed")
|
||||
return JSONResponse({"error": str(exc)}, status_code=502)
|
||||
if call_id:
|
||||
mark_session_started(call_id)
|
||||
return PlainTextResponse(sdp_answer, media_type="application/sdp")
|
||||
|
||||
|
||||
async def api_realtime_transcript(request: Request) -> JSONResponse:
|
||||
try:
|
||||
payload = await request.json()
|
||||
except Exception: # noqa: BLE001
|
||||
return JSONResponse({"error": "Invalid JSON"}, status_code=400)
|
||||
call_id = str(payload.get("call_id") or "").strip()
|
||||
role = str(payload.get("role") or "user").strip()
|
||||
text = str(payload.get("text") or "").strip()
|
||||
if not call_id or not text:
|
||||
return JSONResponse({"error": "call_id and text required"}, status_code=400)
|
||||
from integrations.voice.session_memory import record_voice_turn
|
||||
|
||||
persist_transcript_line(call_id, role, text)
|
||||
if role == "user":
|
||||
await record_voice_turn(call_id, user_text=text)
|
||||
else:
|
||||
await record_voice_turn(call_id, agent_text=text)
|
||||
return JSONResponse({"ok": True})
|
||||
|
||||
|
||||
async def api_realtime_end(request: Request) -> JSONResponse:
|
||||
try:
|
||||
payload = await request.json()
|
||||
except Exception: # noqa: BLE001
|
||||
return JSONResponse({"error": "Invalid JSON"}, status_code=400)
|
||||
call_id = str(payload.get("call_id") or "").strip()
|
||||
if call_id:
|
||||
from integrations.voice.call_finalize import finalize_call
|
||||
|
||||
await finalize_call(call_id, status="ended (customer-ended-call)")
|
||||
return JSONResponse({"ok": True})
|
||||
@@ -0,0 +1,3 @@
|
||||
httpx
|
||||
elevenlabs>=2.47.0
|
||||
openai
|
||||
@@ -0,0 +1,172 @@
|
||||
"""Create or update ElevenLabs Speech Engine with the public WebSocket URL.
|
||||
|
||||
Usage:
|
||||
cd examples/python/voice-agent
|
||||
.\\start_dev.ps1 # sets PUBLIC_API_BASE_URL first
|
||||
py -3.13 setup_speech_engine.py
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import os
|
||||
from pathlib import Path
|
||||
|
||||
_ROOT = Path(__file__).resolve().parent
|
||||
ENV_PATH = _ROOT / ".env"
|
||||
|
||||
|
||||
def _load_env() -> None:
|
||||
if not ENV_PATH.is_file():
|
||||
return
|
||||
for raw in ENV_PATH.read_text(encoding="utf-8").splitlines():
|
||||
line = raw.strip()
|
||||
if not line or line.startswith("#") or "=" not in line:
|
||||
continue
|
||||
key, _, value = line.partition("=")
|
||||
key, value = key.strip(), value.strip()
|
||||
if key:
|
||||
os.environ[key] = value
|
||||
|
||||
|
||||
def _write_env_value(key: str, value: str) -> None:
|
||||
lines = ENV_PATH.read_text(encoding="utf-8").splitlines() if ENV_PATH.is_file() else []
|
||||
updated = False
|
||||
new_lines: list[str] = []
|
||||
for line in lines:
|
||||
if line.strip().startswith(f"{key}="):
|
||||
new_lines.append(f"{key}={value}")
|
||||
updated = True
|
||||
else:
|
||||
new_lines.append(line)
|
||||
if not updated:
|
||||
new_lines.append(f"{key}={value}")
|
||||
ENV_PATH.write_text("\n".join(new_lines) + "\n", encoding="utf-8")
|
||||
os.environ[key] = value
|
||||
|
||||
|
||||
def _http_to_ws(base: str) -> str:
|
||||
base = base.strip().rstrip("/")
|
||||
if base.startswith("https://"):
|
||||
return "wss://" + base[len("https://") :] + "/ws"
|
||||
if base.startswith("http://"):
|
||||
return "ws://" + base[len("http://") :] + "/ws"
|
||||
raise RuntimeError(f"Unsupported URL: {base}")
|
||||
|
||||
|
||||
def _public_ws_url() -> str:
|
||||
# Prefer dedicated Speech Engine tunnel (sidecar on 8002).
|
||||
speech_base = os.getenv("SPEECH_ENGINE_PUBLIC_URL", "").strip().rstrip("/")
|
||||
if speech_base:
|
||||
return _http_to_ws(speech_base)
|
||||
base = os.getenv("PUBLIC_API_BASE_URL", "").strip().rstrip("/")
|
||||
if not base:
|
||||
raise RuntimeError(
|
||||
"Set SPEECH_ENGINE_PUBLIC_URL or PUBLIC_API_BASE_URL in .env "
|
||||
"(run .\\start_dev.ps1 first)"
|
||||
)
|
||||
return _http_to_ws(base)
|
||||
|
||||
|
||||
async def _patch_overrides(client: object, engine_id: str) -> None:
|
||||
"""Enable client first-message override (not exposed on speech_engine.update yet)."""
|
||||
wrapper = getattr(client, "_client_wrapper", None)
|
||||
if wrapper is None:
|
||||
return
|
||||
try:
|
||||
await wrapper.httpx_client.request(
|
||||
f"v1/speech-engine/{engine_id}",
|
||||
method="PATCH",
|
||||
json={"overrides": {"first_message": True}},
|
||||
headers={"content-type": "application/json"},
|
||||
)
|
||||
print("Enabled first_message client override")
|
||||
except Exception as exc: # noqa: BLE001
|
||||
print(f"Note: could not enable first_message override ({exc})")
|
||||
|
||||
|
||||
async def _ensure_engine() -> str:
|
||||
api_key = os.getenv("ELEVENLABS_API_KEY", "").strip()
|
||||
if not api_key:
|
||||
raise RuntimeError("Set ELEVENLABS_API_KEY in .env")
|
||||
|
||||
try:
|
||||
from elevenlabs import AsyncElevenLabs
|
||||
except ImportError as exc:
|
||||
raise RuntimeError(
|
||||
"Install Speech Engine SDK: pip install 'elevenlabs>=2.47.0'"
|
||||
) from exc
|
||||
|
||||
if not hasattr(AsyncElevenLabs(api_key="check"), "speech_engine"):
|
||||
raise RuntimeError(
|
||||
"elevenlabs package is too old for Speech Engine. Run: "
|
||||
"pip install -U 'elevenlabs>=2.47.0'"
|
||||
)
|
||||
|
||||
ws_url = _public_ws_url()
|
||||
engine_id = os.getenv("SPEECH_ENGINE_ID", "").strip()
|
||||
client = AsyncElevenLabs(api_key=api_key)
|
||||
|
||||
turn_config = {"turn_eagerness": "eager", "turn_timeout": 5.0}
|
||||
voice_id = os.getenv("ELEVENLABS_VOICE_ID", "").strip() or "cjVigY5qzO86Huf0OWal"
|
||||
tts_config = {"model_id": "eleven_flash_v2", "voice_id": voice_id}
|
||||
|
||||
if engine_id:
|
||||
updated = await client.speech_engine.update(
|
||||
speech_engine_id=engine_id,
|
||||
speech_engine={"ws_url": ws_url},
|
||||
turn=turn_config,
|
||||
tts=tts_config,
|
||||
)
|
||||
resolved = getattr(updated, "speech_engine_id", None) or getattr(updated, "engine_id", None) or engine_id
|
||||
print(f"Updated Speech Engine: {resolved}")
|
||||
print(f"WebSocket URL: {ws_url}")
|
||||
print(f"TTS voice_id: {voice_id}")
|
||||
await _patch_overrides(client, str(resolved))
|
||||
return str(resolved)
|
||||
|
||||
created = await client.speech_engine.create(
|
||||
name="PraisonAI Voice",
|
||||
speech_engine={"ws_url": ws_url},
|
||||
turn=turn_config,
|
||||
tts=tts_config,
|
||||
overrides={"first_message": True},
|
||||
)
|
||||
resolved = getattr(created, "engine_id", None) or getattr(created, "id", None)
|
||||
if not resolved:
|
||||
raise RuntimeError("Speech Engine create did not return an engine_id")
|
||||
print(f"Created Speech Engine: {resolved}")
|
||||
print(f"WebSocket URL: {ws_url}")
|
||||
print(f"TTS voice_id: {voice_id}")
|
||||
return str(resolved)
|
||||
|
||||
|
||||
async def main_async() -> int:
|
||||
_load_env()
|
||||
try:
|
||||
engine_id = await _ensure_engine()
|
||||
except RuntimeError as exc:
|
||||
print(f"Setup failed: {exc}")
|
||||
return 1
|
||||
except Exception as exc: # noqa: BLE001
|
||||
message = str(exc)
|
||||
if "convai_write" in message or "missing_permissions" in message:
|
||||
print("Setup failed: ElevenLabs API key lacks convai_write permission.")
|
||||
print(
|
||||
"In ElevenLabs dashboard: API Keys -> edit key -> ElevenAgents: enable Write."
|
||||
)
|
||||
else:
|
||||
print(f"Setup failed: {exc}")
|
||||
return 1
|
||||
|
||||
_write_env_value("SPEECH_ENGINE_ID", engine_id)
|
||||
print("Speech Engine auto-setup complete.")
|
||||
return 0
|
||||
|
||||
|
||||
def main() -> int:
|
||||
return asyncio.run(main_async())
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
@@ -28,7 +28,7 @@ def _load_env() -> None:
|
||||
continue
|
||||
key, _, value = line.partition("=")
|
||||
key, value = key.strip(), value.strip()
|
||||
if key and key not in os.environ:
|
||||
if key:
|
||||
os.environ[key] = value
|
||||
|
||||
|
||||
@@ -117,6 +117,119 @@ def ensure_server_secret() -> str:
|
||||
return secret
|
||||
|
||||
|
||||
def ensure_assistant_model(api_key: str, api_base: str, assistant_id: str) -> dict[str, Any]:
|
||||
assistant = _request("GET", f"/assistant/{assistant_id}", api_key=api_key, api_base=api_base)
|
||||
if not isinstance(assistant, dict):
|
||||
raise RuntimeError("Could not load assistant")
|
||||
model = dict(assistant.get("model") or {})
|
||||
target = os.getenv("VOICE_AGENT_MODEL", "gpt-4o-mini")
|
||||
if model.get("provider") != "openai" or model.get("model") != target:
|
||||
model["provider"] = "openai"
|
||||
model["model"] = target
|
||||
patched = _request(
|
||||
"PATCH",
|
||||
f"/assistant/{assistant_id}",
|
||||
api_key=api_key,
|
||||
api_base=api_base,
|
||||
body={"model": model},
|
||||
)
|
||||
if patched is None:
|
||||
raise RuntimeError(f"Failed to set assistant model to {target}")
|
||||
print(f"Set assistant model to openai/{target}")
|
||||
return model
|
||||
|
||||
|
||||
def ensure_agent_model_env() -> str:
|
||||
"""Ensure VOICE_AGENT_MODEL is set in .env and return the target model."""
|
||||
target = os.getenv("VOICE_AGENT_MODEL", "").strip() or "gpt-4o-mini"
|
||||
if not os.getenv("VOICE_AGENT_MODEL", "").strip():
|
||||
_write_env_value("VOICE_AGENT_MODEL", target)
|
||||
print(f"Set VOICE_AGENT_MODEL={target} in .env")
|
||||
return target
|
||||
|
||||
|
||||
def ensure_openai_credential(api_key: str, api_base: str, openai_api_key: str) -> tuple[str, str]:
|
||||
"""Create or reuse an OpenAI-compatible credential in the provider account."""
|
||||
existing = _request("GET", "/credential", api_key=api_key, api_base=api_base)
|
||||
if isinstance(existing, list):
|
||||
preferred = sorted(
|
||||
[item for item in existing if isinstance(item, dict) and item.get("id")],
|
||||
key=lambda item: 0 if str(item.get("provider") or "") == "openai" else 1,
|
||||
)
|
||||
for item in preferred:
|
||||
provider = str(item.get("provider") or "")
|
||||
if provider in ("openai", "custom-llm"):
|
||||
cred_id = str(item["id"])
|
||||
print(f"Reusing credential ({provider}): {cred_id}")
|
||||
updated = _request(
|
||||
"PATCH",
|
||||
f"/credential/{cred_id}",
|
||||
api_key=api_key,
|
||||
api_base=api_base,
|
||||
body={
|
||||
"provider": provider,
|
||||
"apiKey": openai_api_key,
|
||||
"name": "Praison Voice OpenAI",
|
||||
},
|
||||
)
|
||||
if updated is None:
|
||||
raise RuntimeError("Failed to update OpenAI credential")
|
||||
return cred_id, provider
|
||||
|
||||
for provider in ("openai", "custom-llm"):
|
||||
created = _request(
|
||||
"POST",
|
||||
"/credential",
|
||||
api_key=api_key,
|
||||
api_base=api_base,
|
||||
body={
|
||||
"provider": provider,
|
||||
"apiKey": openai_api_key,
|
||||
"name": "Praison Voice OpenAI",
|
||||
},
|
||||
)
|
||||
if isinstance(created, dict) and created.get("id"):
|
||||
cred_id = str(created["id"])
|
||||
print(f"Created credential ({provider}): {cred_id}")
|
||||
return cred_id, provider
|
||||
|
||||
raise RuntimeError(
|
||||
"Failed to create OpenAI credential. Add the key manually in the provider dashboard "
|
||||
"under Provider Keys / OpenAI, or enable gpt-4o-mini on your OpenAI project."
|
||||
)
|
||||
|
||||
|
||||
def ensure_openai_on_assistant(
|
||||
api_key: str, api_base: str, assistant_id: str, openai_credential_id: str, *, provider: str
|
||||
) -> None:
|
||||
assistant = _request("GET", f"/assistant/{assistant_id}", api_key=api_key, api_base=api_base)
|
||||
if not isinstance(assistant, dict):
|
||||
raise RuntimeError("Could not load assistant for OpenAI setup")
|
||||
|
||||
target_model = os.getenv("VOICE_AGENT_MODEL", "gpt-4o-mini")
|
||||
if provider == "custom-llm":
|
||||
model = dict(assistant.get("model") or {})
|
||||
model["model"] = target_model
|
||||
model["provider"] = "custom-llm"
|
||||
model["url"] = "https://api.openai.com/v1"
|
||||
model["metadataSendMode"] = "off"
|
||||
model.pop("credentialId", None)
|
||||
else:
|
||||
# Native OpenAI — drop custom-llm-only fields from prior config.
|
||||
model = {"provider": "openai", "model": target_model}
|
||||
|
||||
patched = _request(
|
||||
"PATCH",
|
||||
f"/assistant/{assistant_id}",
|
||||
api_key=api_key,
|
||||
api_base=api_base,
|
||||
body={"model": model},
|
||||
)
|
||||
if patched is None:
|
||||
raise RuntimeError("Failed to attach OpenAI credential to assistant")
|
||||
print(f"Configured assistant model ({provider}): {target_model}")
|
||||
|
||||
|
||||
def ensure_tools(api_key: str, api_base: str, assistant_id: str) -> list[str]:
|
||||
existing = _request("GET", "/tool", api_key=api_key, api_base=api_base)
|
||||
by_name: dict[str, str] = {}
|
||||
@@ -230,8 +343,18 @@ def main() -> int:
|
||||
|
||||
secret = ensure_server_secret()
|
||||
webhook_url = f"{public_base}/webhooks/voice"
|
||||
target_model = ensure_agent_model_env()
|
||||
openai_api_key = os.getenv("OPENAI_API_KEY", "").strip()
|
||||
|
||||
try:
|
||||
if openai_api_key:
|
||||
cred_id, cred_provider = ensure_openai_credential(api_key, api_base, openai_api_key)
|
||||
ensure_openai_on_assistant(
|
||||
api_key, api_base, assistant_id, cred_id, provider=cred_provider
|
||||
)
|
||||
print(f"Assistant model target: {target_model}")
|
||||
else:
|
||||
print("OPENAI_API_KEY not set — skipping provider OpenAI credential setup")
|
||||
ensure_tools(api_key, api_base, assistant_id)
|
||||
ensure_webhook(api_key, api_base, assistant_id, webhook_url, secret)
|
||||
except RuntimeError as exc:
|
||||
|
||||
@@ -0,0 +1,110 @@
|
||||
"""HTTP + WebSocket routes for ElevenLabs Speech Engine."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import os
|
||||
|
||||
from starlette.requests import Request
|
||||
from starlette.responses import JSONResponse
|
||||
from starlette.websockets import WebSocket, WebSocketDisconnect
|
||||
|
||||
from integrations.voice.speech_engine import (
|
||||
SpeechEngineConfigError,
|
||||
get_engine_resource,
|
||||
get_webrtc_token,
|
||||
handle_session_close,
|
||||
handle_session_init,
|
||||
handle_user_transcript,
|
||||
load_speech_engine_settings,
|
||||
speech_engine_enabled,
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
async def api_speech_engine_config(_request: Request) -> JSONResponse:
|
||||
try:
|
||||
settings = load_speech_engine_settings()
|
||||
enabled = speech_engine_enabled()
|
||||
error = None
|
||||
except SpeechEngineConfigError as exc:
|
||||
settings = None
|
||||
enabled = False
|
||||
error = str(exc)
|
||||
tools: list[str] = []
|
||||
if enabled:
|
||||
try:
|
||||
from integrations.voice.agent_runner import list_registered_tool_names
|
||||
|
||||
tools = list_registered_tool_names()
|
||||
except Exception: # noqa: BLE001
|
||||
tools = ["get_current_time", "echo_message"]
|
||||
return JSONResponse(
|
||||
{
|
||||
"enabled": enabled,
|
||||
"error": error,
|
||||
"engine_id": settings.engine_id if settings else None,
|
||||
"ws_url": settings.public_ws_url if settings else None,
|
||||
"client_sdk_url": "https://esm.sh/@elevenlabs/client",
|
||||
"first_message": settings.first_message if settings else None,
|
||||
"model": os.getenv("VOICE_AGENT_MODEL", "gpt-4o-mini"),
|
||||
"tools": tools,
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
async def api_speech_engine_token(_request: Request) -> JSONResponse:
|
||||
try:
|
||||
token = get_webrtc_token()
|
||||
except SpeechEngineConfigError as exc:
|
||||
return JSONResponse({"error": str(exc)}, status_code=503)
|
||||
except Exception as exc: # noqa: BLE001
|
||||
logger.exception("Speech Engine token mint failed")
|
||||
return JSONResponse({"error": str(exc)}, status_code=502)
|
||||
return JSONResponse({"token": token})
|
||||
|
||||
|
||||
async def speech_engine_websocket(websocket: WebSocket) -> None:
|
||||
try:
|
||||
engine = await get_engine_resource()
|
||||
except SpeechEngineConfigError as exc:
|
||||
logger.warning("Speech Engine WS rejected: %s", exc)
|
||||
return
|
||||
|
||||
headers = dict(websocket.headers)
|
||||
if hasattr(engine, "verify_request") and not engine.verify_request(headers):
|
||||
logger.warning(
|
||||
"Speech Engine WS unauthorized (missing or invalid ElevenLabs JWT header)"
|
||||
)
|
||||
return
|
||||
|
||||
await websocket.accept()
|
||||
logger.info("Speech Engine WebSocket accepted from ElevenLabs")
|
||||
session = engine.create_session(websocket, debug=False)
|
||||
|
||||
async def on_transcript(transcript, sess): # noqa: ANN001
|
||||
await handle_user_transcript(transcript, sess)
|
||||
|
||||
async def on_init(conversation_id, sess): # noqa: ANN001
|
||||
await handle_session_init(conversation_id, sess)
|
||||
|
||||
async def on_close(sess): # noqa: ANN001
|
||||
await handle_session_close(sess)
|
||||
|
||||
def on_error(err, sess=None): # noqa: ANN001
|
||||
logger.error("Speech Engine session error: %s", err)
|
||||
|
||||
session.on("user_transcript", on_transcript)
|
||||
session.on("init", on_init)
|
||||
session.on("close", on_close)
|
||||
session.on("error", on_error)
|
||||
|
||||
try:
|
||||
await session.run()
|
||||
except WebSocketDisconnect:
|
||||
logger.info("Speech Engine WebSocket disconnected")
|
||||
await handle_session_close(session)
|
||||
except Exception: # noqa: BLE001
|
||||
logger.exception("Speech Engine WebSocket session failed")
|
||||
await handle_session_close(session)
|
||||
@@ -0,0 +1,89 @@
|
||||
"""Standalone ElevenLabs Speech Engine WebSocket server (port 8002).
|
||||
|
||||
ElevenLabs connects here for transcripts; the dashboard stays on 8001.
|
||||
Official SDK path: engine.serve() — more reliable than in-app Starlette /ws.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import os
|
||||
from pathlib import Path
|
||||
|
||||
_ROOT = Path(__file__).resolve().parent
|
||||
if str(_ROOT) not in __import__("sys").path:
|
||||
__import__("sys").path.insert(0, str(_ROOT))
|
||||
|
||||
|
||||
def _load_env() -> None:
|
||||
env_path = _ROOT / ".env"
|
||||
if not env_path.is_file():
|
||||
return
|
||||
for raw in env_path.read_text(encoding="utf-8").splitlines():
|
||||
line = raw.strip()
|
||||
if not line or line.startswith("#") or "=" not in line:
|
||||
continue
|
||||
key, _, value = line.partition("=")
|
||||
key, value = key.strip(), value.strip()
|
||||
if key:
|
||||
os.environ[key] = value
|
||||
|
||||
|
||||
async def _main() -> None:
|
||||
_load_env()
|
||||
api_key = os.getenv("ELEVENLABS_API_KEY", "").strip()
|
||||
engine_id = os.getenv("SPEECH_ENGINE_ID", "").strip()
|
||||
port = int(os.getenv("SPEECH_ENGINE_PORT", "8002"))
|
||||
disable_auth = os.getenv("SPEECH_ENGINE_DISABLE_AUTH", "").lower() in (
|
||||
"1",
|
||||
"true",
|
||||
"yes",
|
||||
)
|
||||
|
||||
if not api_key or not engine_id:
|
||||
raise SystemExit("Set ELEVENLABS_API_KEY and SPEECH_ENGINE_ID in .env")
|
||||
|
||||
from elevenlabs import AsyncElevenLabs
|
||||
|
||||
from integrations.voice.speech_engine import (
|
||||
handle_session_close,
|
||||
handle_session_disconnect,
|
||||
handle_session_init,
|
||||
handle_user_transcript,
|
||||
)
|
||||
|
||||
client = AsyncElevenLabs(api_key=api_key)
|
||||
engine = await client.speech_engine.get(engine_id)
|
||||
|
||||
async def on_transcript(transcript, session): # noqa: ANN001
|
||||
await handle_user_transcript(transcript, session)
|
||||
|
||||
async def on_init(conversation_id, session): # noqa: ANN001
|
||||
await handle_session_init(conversation_id, session)
|
||||
|
||||
async def on_close(session): # noqa: ANN001
|
||||
await handle_session_close(session)
|
||||
|
||||
async def on_disconnect(session): # noqa: ANN001
|
||||
await handle_session_disconnect(session)
|
||||
|
||||
async def on_error(err, session): # noqa: ANN001
|
||||
print(f"[speech sidecar] error: {err}", flush=True)
|
||||
|
||||
debug = os.getenv("SPEECH_ENGINE_DEBUG", "true").lower() in ("1", "true", "yes")
|
||||
print(f"[speech sidecar] listening on ws://127.0.0.1:{port}/ws (debug={debug})", flush=True)
|
||||
await engine.serve(
|
||||
port=port,
|
||||
path="/ws",
|
||||
debug=debug,
|
||||
disable_auth=disable_auth,
|
||||
on_init=on_init,
|
||||
on_transcript=on_transcript,
|
||||
on_close=on_close,
|
||||
on_disconnect=on_disconnect,
|
||||
on_error=on_error,
|
||||
)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
asyncio.run(_main())
|
||||
@@ -18,13 +18,14 @@ function Load-DotEnv {
|
||||
$line = $_.Trim()
|
||||
if (-not $line -or $line.StartsWith("#") -or $line -notmatch "=") { return }
|
||||
$key, $value = $line -split "=", 2
|
||||
if ($key -and -not [string]::IsNullOrWhiteSpace($key) -and -not (Get-Item "Env:$key" -ErrorAction SilentlyContinue)) {
|
||||
$key = $key.Trim()
|
||||
if ($key -and -not [string]::IsNullOrWhiteSpace($key)) {
|
||||
Set-Item -Path "Env:$key" -Value $value.Trim()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function Test-PortListening([int]$p) {
|
||||
function Test-AppHttpReady([int]$p) {
|
||||
foreach ($path in @("/health/live", "/health", "/api/voice/config")) {
|
||||
try {
|
||||
$r = Invoke-WebRequest -Uri "http://127.0.0.1:$p$path" -UseBasicParsing -TimeoutSec 15
|
||||
@@ -34,6 +35,33 @@ function Test-PortListening([int]$p) {
|
||||
return $false
|
||||
}
|
||||
|
||||
function Test-TcpPortListening([int]$p) {
|
||||
try {
|
||||
$conn = Get-NetTCPConnection -LocalPort $p -State Listen -ErrorAction SilentlyContinue | Select-Object -First 1
|
||||
if ($conn) { return $true }
|
||||
} catch { }
|
||||
try {
|
||||
$client = New-Object System.Net.Sockets.TcpClient
|
||||
$async = $client.BeginConnect("127.0.0.1", $p, $null, $null)
|
||||
$ok = $async.AsyncWaitHandle.WaitOne(500)
|
||||
if ($ok -and $client.Connected) {
|
||||
$client.Close()
|
||||
return $true
|
||||
}
|
||||
$client.Close()
|
||||
} catch { }
|
||||
return $false
|
||||
}
|
||||
|
||||
function Wait-PortReleased([int]$p, [int]$maxSec = 20) {
|
||||
$deadline = (Get-Date).AddSeconds($maxSec)
|
||||
while ((Get-Date) -lt $deadline) {
|
||||
if (-not (Test-TcpPortListening $p)) { return $true }
|
||||
Start-Sleep -Seconds 1
|
||||
}
|
||||
return -not (Test-TcpPortListening $p)
|
||||
}
|
||||
|
||||
function Stop-PortProcess([int]$p) {
|
||||
try {
|
||||
$conns = Get-NetTCPConnection -LocalPort $p -State Listen -ErrorAction SilentlyContinue
|
||||
@@ -69,10 +97,10 @@ function Start-VoiceApp([int]$p) {
|
||||
-RedirectStandardOutput $appLog -RedirectStandardError $appErr
|
||||
$deadline = (Get-Date).AddSeconds(120)
|
||||
while ((Get-Date) -lt $deadline) {
|
||||
if (Test-PortListening $p) { break }
|
||||
if (Test-AppHttpReady $p) { break }
|
||||
Start-Sleep -Seconds 3
|
||||
}
|
||||
if (-not (Test-PortListening $p)) {
|
||||
if (-not (Test-AppHttpReady $p)) {
|
||||
$errText = ""
|
||||
if (Test-Path $appErr) { $errText = Get-Content $appErr -Raw -ErrorAction SilentlyContinue }
|
||||
if ($errText -match "Uvicorn running") {
|
||||
@@ -80,7 +108,7 @@ function Start-VoiceApp([int]$p) {
|
||||
Start-Sleep -Seconds 10
|
||||
}
|
||||
}
|
||||
if (-not (Test-PortListening $p)) {
|
||||
if (-not (Test-AppHttpReady $p)) {
|
||||
if (Test-Path $appErr) {
|
||||
Write-Host "App startup log:" -ForegroundColor Red
|
||||
Get-Content $appErr -Tail 20 | ForEach-Object { Write-Host $_ }
|
||||
@@ -90,6 +118,164 @@ function Start-VoiceApp([int]$p) {
|
||||
Write-Host "App ready: http://127.0.0.1:$p"
|
||||
}
|
||||
|
||||
function Get-CloudflaredPidFile([string]$suffix) {
|
||||
return Join-Path $Root ".cloudflared$suffix.pid"
|
||||
}
|
||||
|
||||
function Stop-CloudflaredForSuffix([string]$suffix) {
|
||||
$pidFile = Get-CloudflaredPidFile $suffix
|
||||
try {
|
||||
if (Test-Path $pidFile) {
|
||||
$procId = [int](Get-Content $pidFile -Raw).Trim()
|
||||
if ($procId) {
|
||||
Stop-Process -Id $procId -Force -ErrorAction SilentlyContinue
|
||||
}
|
||||
Remove-Item $pidFile -Force -ErrorAction SilentlyContinue
|
||||
}
|
||||
Start-Sleep -Seconds 1
|
||||
} catch { }
|
||||
}
|
||||
|
||||
function Stop-CloudflaredTunnel {
|
||||
Stop-CloudflaredForSuffix ""
|
||||
}
|
||||
|
||||
function Test-TunnelUrl([string]$baseUrl, [int]$retries = 2) {
|
||||
if (-not $baseUrl) { return $false }
|
||||
for ($i = 0; $i -lt $retries; $i++) {
|
||||
try {
|
||||
$null = Invoke-WebRequest -Uri "$baseUrl/api/voice/config" -UseBasicParsing -TimeoutSec 8
|
||||
return $true
|
||||
} catch {
|
||||
if ($i -lt ($retries - 1)) { Start-Sleep -Seconds 2 }
|
||||
}
|
||||
}
|
||||
return $false
|
||||
}
|
||||
|
||||
function Test-SpeechTunnelUrl([string]$baseUrl, [int]$retries = 12) {
|
||||
if (-not $baseUrl) { return $false }
|
||||
$paths = @("", "/ws")
|
||||
for ($i = 0; $i -lt $retries; $i++) {
|
||||
foreach ($path in $paths) {
|
||||
$uri = if ($path) { "$baseUrl$path" } else { $baseUrl }
|
||||
try {
|
||||
$null = Invoke-WebRequest -Uri $uri -UseBasicParsing -TimeoutSec 10
|
||||
return $true
|
||||
} catch {
|
||||
$code = $null
|
||||
try {
|
||||
if ($_.Exception.Response) {
|
||||
$code = [int]$_.Exception.Response.StatusCode
|
||||
}
|
||||
} catch { }
|
||||
# Sidecar returns 404 on / — that still means the tunnel is up.
|
||||
if ($code -and $code -ge 200 -and $code -lt 500) {
|
||||
return $true
|
||||
}
|
||||
}
|
||||
}
|
||||
if ($i -lt ($retries - 1)) {
|
||||
Write-Host " Speech tunnel not ready yet (attempt $($i + 1)/$retries)..."
|
||||
Start-Sleep -Seconds 3
|
||||
}
|
||||
}
|
||||
return $false
|
||||
}
|
||||
|
||||
function Update-EnvValue([string]$key, [string]$value) {
|
||||
$envFile = Join-Path $Root ".env"
|
||||
if (-not (Test-Path $envFile)) { return }
|
||||
$lines = Get-Content $envFile
|
||||
$updated = $false
|
||||
$newLines = foreach ($line in $lines) {
|
||||
if ($line -match "^\s*$key=") {
|
||||
$updated = $true
|
||||
"$key=$value"
|
||||
} else { $line }
|
||||
}
|
||||
if (-not $updated) { $newLines += "$key=$value" }
|
||||
Set-Content -Path $envFile -Value ($newLines -join "`n")
|
||||
Set-Item -Path "Env:$key" -Value $value
|
||||
}
|
||||
|
||||
function Start-SpeechSidecar([int]$p = 8002, [switch]$Force) {
|
||||
if ((Test-TcpPortListening $p) -and -not $Force) {
|
||||
Write-Host "Speech Engine sidecar already listening on port $p"
|
||||
return
|
||||
}
|
||||
Write-Host "Starting Speech Engine sidecar on port $p..."
|
||||
Stop-PortProcess $p
|
||||
if (-not (Wait-PortReleased $p)) {
|
||||
Write-Host "Port $p still in use — forcing stop again..." -ForegroundColor Yellow
|
||||
Stop-PortProcess $p
|
||||
Start-Sleep -Seconds 3
|
||||
}
|
||||
$log = Join-Path $Root ".speech-sidecar.log"
|
||||
$logErr = "${log}.err"
|
||||
Clear-LogFile $log
|
||||
Clear-LogFile $logErr
|
||||
$env:PYTHONUNBUFFERED = "1"
|
||||
Start-Process -FilePath "py" -ArgumentList "-3.13", "-u", "speech_engine_sidecar.py" `
|
||||
-WorkingDirectory $Root -WindowStyle Minimized `
|
||||
-RedirectStandardOutput $log -RedirectStandardError $logErr
|
||||
$deadline = (Get-Date).AddSeconds(90)
|
||||
while ((Get-Date) -lt $deadline) {
|
||||
if (Test-TcpPortListening $p) {
|
||||
Write-Host "Speech Engine sidecar ready on port $p"
|
||||
return
|
||||
}
|
||||
if (Test-Path $log) {
|
||||
$tail = Get-Content $log -Tail 1 -ErrorAction SilentlyContinue
|
||||
if ($tail) { Write-Host " $tail" }
|
||||
}
|
||||
Start-Sleep -Seconds 2
|
||||
}
|
||||
Write-Host "Sidecar logs:" -ForegroundColor Red
|
||||
if (Test-Path $log) {
|
||||
Get-Content $log -Tail 15 -ErrorAction SilentlyContinue | ForEach-Object { Write-Host $_ }
|
||||
}
|
||||
if (Test-Path $logErr) {
|
||||
Get-Content $logErr -Tail 15 -ErrorAction SilentlyContinue | ForEach-Object { Write-Host $_ }
|
||||
}
|
||||
throw "Speech Engine sidecar did not start on port $p (ElevenLabs API fetch can take up to 90s — check .speech-sidecar.log)"
|
||||
}
|
||||
|
||||
function Start-CloudflaredTunnel([int]$p, [string]$logSuffix = "") {
|
||||
Write-Host "Starting Cloudflare tunnel for port $p..."
|
||||
$log = Join-Path $Root ".tunnel$logSuffix.log"
|
||||
$logErr = "${log}.err"
|
||||
Stop-CloudflaredForSuffix $logSuffix
|
||||
Clear-LogFile $log
|
||||
Clear-LogFile $logErr
|
||||
$proc = Start-Process -FilePath "cloudflared" -ArgumentList "tunnel", "--url", "http://127.0.0.1:$p" `
|
||||
-RedirectStandardOutput $log -RedirectStandardError $logErr -WindowStyle Minimized -PassThru
|
||||
if ($proc -and $proc.Id) {
|
||||
Set-Content -Path (Get-CloudflaredPidFile $logSuffix) -Value $proc.Id
|
||||
}
|
||||
$deadline = (Get-Date).AddSeconds(90)
|
||||
$newUrl = $null
|
||||
while ((Get-Date) -lt $deadline -and -not $newUrl) {
|
||||
Start-Sleep -Seconds 2
|
||||
foreach ($path in @($logErr, $log)) {
|
||||
if (-not (Test-Path $path)) { continue }
|
||||
$match = Select-String -Path $path -Pattern "https://[a-z0-9]+(-[a-z0-9]+)+\.trycloudflare\.com" | Select-Object -First 1
|
||||
if ($match) {
|
||||
$newUrl = $match.Matches[0].Value
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
if (-not $newUrl) { throw "Could not read tunnel URL from $logErr" }
|
||||
if ($logSuffix) {
|
||||
Write-Host "Tunnel ready ($logSuffix): $newUrl"
|
||||
} else {
|
||||
Update-PublicUrl $newUrl
|
||||
Write-Host "Tunnel ready: $newUrl"
|
||||
}
|
||||
return $newUrl
|
||||
}
|
||||
|
||||
function Update-PublicUrl([string]$baseUrl) {
|
||||
$envFile = Join-Path $Root ".env"
|
||||
$content = Get-Content $envFile -Raw
|
||||
@@ -147,7 +333,7 @@ Ensure-ServerSecret
|
||||
$env:VOICE_AGENT_PORT = "$Port"
|
||||
|
||||
$needsRestart = $Restart.IsPresent
|
||||
if (Test-PortListening $Port) {
|
||||
if (Test-TcpPortListening $Port) {
|
||||
if ($needsRestart) {
|
||||
Stop-PortProcess $Port
|
||||
} else {
|
||||
@@ -166,42 +352,20 @@ if (Test-PortListening $Port) {
|
||||
}
|
||||
}
|
||||
|
||||
if ($needsRestart -or -not (Test-PortListening $Port)) {
|
||||
Start-VoiceApp $Port
|
||||
}
|
||||
|
||||
$public = $env:PUBLIC_API_BASE_URL
|
||||
try {
|
||||
$null = Invoke-WebRequest -Uri "$public/api/voice/config" -UseBasicParsing -TimeoutSec 5
|
||||
$mainTunnelRecreated = $false
|
||||
if (Test-TunnelUrl $public) {
|
||||
Write-Host "Tunnel OK: $public"
|
||||
} catch {
|
||||
Write-Host "Starting Cloudflare tunnel..."
|
||||
$log = Join-Path $Root ".tunnel.log"
|
||||
$logErr = "${log}.err"
|
||||
if (Test-Path $log) { Remove-Item $log -Force }
|
||||
if (Test-Path $logErr) { Remove-Item $logErr -Force }
|
||||
Start-Process -FilePath "cloudflared" -ArgumentList "tunnel", "--url", "http://127.0.0.1:$Port" `
|
||||
-RedirectStandardOutput $log -RedirectStandardError $logErr -WindowStyle Minimized
|
||||
$deadline = (Get-Date).AddSeconds(90)
|
||||
$newUrl = $null
|
||||
while ((Get-Date) -lt $deadline -and -not $newUrl) {
|
||||
Start-Sleep -Seconds 2
|
||||
foreach ($path in @($logErr, $log)) {
|
||||
if (-not (Test-Path $path)) { continue }
|
||||
$match = Select-String -Path $path -Pattern "https://[a-z0-9-]+\.trycloudflare\.com" | Select-Object -First 1
|
||||
if ($match) {
|
||||
$newUrl = $match.Matches[0].Value
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
if (-not $newUrl) { throw "Could not read tunnel URL from $logErr" }
|
||||
Update-PublicUrl $newUrl
|
||||
Write-Host "Tunnel ready: $newUrl"
|
||||
if ($needsRestart -or -not (Test-PortListening $Port)) {
|
||||
Stop-PortProcess $Port
|
||||
Start-VoiceApp $Port
|
||||
}
|
||||
} else {
|
||||
$null = Start-CloudflaredTunnel $Port
|
||||
$mainTunnelRecreated = $true
|
||||
}
|
||||
if ($needsRestart -or -not (Test-TcpPortListening $Port)) {
|
||||
Start-VoiceApp $Port
|
||||
} elseif ($mainTunnelRecreated) {
|
||||
Write-Host "Main tunnel URL changed — restarting app to reload .env"
|
||||
Stop-PortProcess $Port
|
||||
Start-VoiceApp $Port
|
||||
}
|
||||
|
||||
$config = Invoke-RestMethod -Uri "http://127.0.0.1:$Port/api/voice/config"
|
||||
@@ -218,4 +382,47 @@ if ($LASTEXITCODE -eq 0) {
|
||||
Show-ProviderSetupSteps -webhookUrl $webhook -secret $env:VOICE_SERVER_URL_SECRET
|
||||
}
|
||||
|
||||
if ($env:ELEVENLABS_API_KEY) {
|
||||
Write-Host ""
|
||||
$speechSidecarOk = Test-TcpPortListening 8002
|
||||
$speechTunnelOk = Test-SpeechTunnelUrl $env:SPEECH_ENGINE_PUBLIC_URL
|
||||
if (-not $speechTunnelOk) {
|
||||
Write-Host "Speech tunnel down — starting sidecar + new Cloudflare tunnel..." -ForegroundColor Cyan
|
||||
Start-SpeechSidecar 8002 -Force
|
||||
$speechTunnel = Start-CloudflaredTunnel 8002 "-speech"
|
||||
Update-EnvValue "SPEECH_ENGINE_PUBLIC_URL" $speechTunnel
|
||||
Write-Host "Speech Engine tunnel: $speechTunnel"
|
||||
Write-Host "Waiting for speech tunnel to accept connections..."
|
||||
Start-Sleep -Seconds 5
|
||||
Write-Host "Configuring ElevenLabs Speech Engine..." -ForegroundColor Cyan
|
||||
py -3.13 setup_speech_engine.py
|
||||
if ($LASTEXITCODE -ne 0) {
|
||||
throw "Speech Engine setup failed — run: py -3.13 setup_speech_engine.py"
|
||||
}
|
||||
Write-Host "Speech Engine configured (WebSocket /ws + browser token)." -ForegroundColor Green
|
||||
Load-DotEnv
|
||||
Stop-PortProcess $Port
|
||||
Start-VoiceApp $Port
|
||||
} elseif ($Restart.IsPresent -or -not $speechSidecarOk) {
|
||||
Write-Host "Restarting speech sidecar to reload code (keeping existing tunnel)..." -ForegroundColor Yellow
|
||||
Start-SpeechSidecar 8002 -Force
|
||||
} else {
|
||||
Write-Host "Speech stack OK (sidecar :8002 + tunnel reachable)" -ForegroundColor Green
|
||||
}
|
||||
if (-not (Test-TcpPortListening 8002)) {
|
||||
throw "Speech Engine sidecar not listening on 8002"
|
||||
}
|
||||
Load-DotEnv
|
||||
if (-not (Test-SpeechTunnelUrl $env:SPEECH_ENGINE_PUBLIC_URL)) {
|
||||
Write-Host ""
|
||||
Write-Host "WARNING: Speech Engine tunnel not reachable yet: $($env:SPEECH_ENGINE_PUBLIC_URL)" -ForegroundColor Yellow
|
||||
Write-Host "Local dashboard is up at http://127.0.0.1:$Port — wait ~30s, then retry ElevenLabs talk." -ForegroundColor Yellow
|
||||
Write-Host "If it still fails, run: .\start_dev.ps1 -Restart" -ForegroundColor Yellow
|
||||
} else {
|
||||
Write-Host "Speech sidecar: port 8002 | ws_url wss://$($env:SPEECH_ENGINE_PUBLIC_URL -replace 'https://','')/ws"
|
||||
}
|
||||
}
|
||||
|
||||
Write-Host "Done. Dashboard: http://127.0.0.1:$Port"
|
||||
Write-Host "ElevenLabs talk: http://127.0.0.1:$Port/eleven-talk"
|
||||
Write-Host "Keep this window open — sidecar + tunnels run in the background."
|
||||
@@ -7,12 +7,13 @@ import json
|
||||
import logging
|
||||
|
||||
from integrations.voice.client import VoiceClient
|
||||
from integrations.voice.config import VoiceConfigError, load_voice_settings
|
||||
from integrations.voice.config import VoiceConfigError, get_web_sdk_settings, load_voice_settings
|
||||
from integrations.voice.live import transcript_hub
|
||||
from integrations.voice.processor import process_voice_webhook
|
||||
from integrations.voice.store import VoiceCallStore
|
||||
from integrations.voice.verify import VerificationError, verify_webhook_request
|
||||
from starlette.requests import Request
|
||||
from starlette.responses import JSONResponse, PlainTextResponse, Response
|
||||
from starlette.responses import JSONResponse, PlainTextResponse, Response, StreamingResponse
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -21,6 +22,14 @@ logger = logging.getLogger(__name__)
|
||||
_background_tasks: set[asyncio.Task] = set()
|
||||
|
||||
|
||||
def _schedule_webhook_processing(event_type: str, payload: dict, *, settings) -> None:
|
||||
task = asyncio.create_task(
|
||||
asyncio.to_thread(process_voice_webhook, event_type, payload, settings=settings)
|
||||
)
|
||||
_background_tasks.add(task)
|
||||
task.add_done_callback(_background_tasks.discard)
|
||||
|
||||
|
||||
async def api_voice_config_status(_request: Request) -> JSONResponse:
|
||||
try:
|
||||
settings = load_voice_settings()
|
||||
@@ -36,6 +45,24 @@ async def api_voice_config_status(_request: Request) -> JSONResponse:
|
||||
"error": error,
|
||||
"webhook_url": settings.webhook_url if settings else None,
|
||||
"default_assistant_id": settings.default_assistant_id if settings else None,
|
||||
"web_talk_enabled": bool(settings.public_api_key and settings.default_assistant_id),
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
async def api_voice_web_config(_request: Request) -> JSONResponse:
|
||||
try:
|
||||
settings = load_voice_settings(require_key=False)
|
||||
except VoiceConfigError as exc:
|
||||
return JSONResponse({"enabled": False, "error": str(exc)})
|
||||
sdk_url, sdk_global = get_web_sdk_settings()
|
||||
return JSONResponse(
|
||||
{
|
||||
"enabled": bool(settings.public_api_key and settings.default_assistant_id and sdk_url),
|
||||
"public_key": settings.public_api_key,
|
||||
"assistant_id": settings.default_assistant_id,
|
||||
"sdk_url": sdk_url or None,
|
||||
"sdk_global": sdk_global or "Vapi",
|
||||
}
|
||||
)
|
||||
|
||||
@@ -69,15 +96,62 @@ async def api_voice_create_call(request: Request) -> JSONResponse:
|
||||
|
||||
|
||||
async def api_voice_list_calls(_request: Request) -> JSONResponse:
|
||||
return JSONResponse({"calls": VoiceCallStore.list_calls()})
|
||||
from integrations.voice.call_finalize import analytics_for_record
|
||||
|
||||
calls = []
|
||||
for record in VoiceCallStore.list_calls():
|
||||
row = dict(record)
|
||||
row["analytics"] = analytics_for_record(record)
|
||||
calls.append(row)
|
||||
return JSONResponse({"calls": calls})
|
||||
|
||||
|
||||
async def api_voice_doctor(_request: Request) -> JSONResponse:
|
||||
import os
|
||||
|
||||
from integrations.voice.doctor import run_voice_doctor
|
||||
|
||||
app_port = int(os.getenv("VOICE_AGENT_PORT", "8001"))
|
||||
sidecar_port = int(os.getenv("SPEECH_ENGINE_SIDECAR_PORT", "8002"))
|
||||
return JSONResponse(run_voice_doctor(app_port=app_port, sidecar_port=sidecar_port))
|
||||
|
||||
|
||||
async def api_voice_call_detail(request: Request) -> JSONResponse:
|
||||
from integrations.voice.call_finalize import analytics_for_record
|
||||
|
||||
call_id = request.path_params["call_id"]
|
||||
record = VoiceCallStore.get_call(call_id)
|
||||
if not record:
|
||||
return JSONResponse({"error": "not found"}, status_code=404)
|
||||
return JSONResponse(record)
|
||||
out = dict(record)
|
||||
out["analytics"] = analytics_for_record(record)
|
||||
return JSONResponse(out)
|
||||
|
||||
|
||||
async def api_voice_call_detail_ui(request: Request) -> JSONResponse:
|
||||
from integrations.voice.call_detail_ui import build_call_detail_layout
|
||||
|
||||
call_id = request.path_params["call_id"]
|
||||
record = VoiceCallStore.get_call(call_id)
|
||||
if not record:
|
||||
return JSONResponse({"error": "not found"}, status_code=404)
|
||||
layout = build_call_detail_layout(record)
|
||||
return JSONResponse({"call_id": call_id, "layout": layout})
|
||||
|
||||
|
||||
async def api_voice_live_transcript(request: Request) -> StreamingResponse:
|
||||
call_id = request.path_params["call_id"]
|
||||
|
||||
async def event_stream():
|
||||
record = VoiceCallStore.get_call(call_id)
|
||||
if record and record.get("transcript"):
|
||||
yield transcript_hub.snapshot_sse(
|
||||
{"type": "snapshot", "transcript": record["transcript"]}
|
||||
)
|
||||
async for event in transcript_hub.subscribe(call_id):
|
||||
yield transcript_hub.snapshot_sse(event)
|
||||
|
||||
return StreamingResponse(event_stream(), media_type="text/event-stream")
|
||||
|
||||
|
||||
async def webhook_voice(request: Request) -> Response:
|
||||
@@ -105,9 +179,5 @@ async def webhook_voice(request: Request) -> Response:
|
||||
result = await asyncio.to_thread(process_voice_webhook, event_type, payload, settings=settings)
|
||||
return JSONResponse(result or {})
|
||||
|
||||
task = asyncio.create_task(
|
||||
asyncio.to_thread(process_voice_webhook, event_type, payload, settings=settings)
|
||||
)
|
||||
_background_tasks.add(task)
|
||||
task.add_done_callback(_background_tasks.discard)
|
||||
_schedule_webhook_processing(event_type, payload, settings=settings)
|
||||
return PlainTextResponse("ok", status_code=200)
|
||||
@@ -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.143"
|
||||
version = "0.3.147"
|
||||
description = "YAML-driven website generator - CLI and compiler"
|
||||
readme = "README.md"
|
||||
license = {text = "MIT"}
|
||||
|
||||
+23
-2
@@ -1,6 +1,27 @@
|
||||
# React + TypeScript + Vite
|
||||
# PraisonAIUI Frontend (React + TypeScript + Vite)
|
||||
|
||||
This template provides a minimal setup to get React working in Vite with HMR and some ESLint rules.
|
||||
The BeautifulUI dashboard source. Built with React 19 + Vite 7 + Tailwind v4.
|
||||
|
||||
## Build & CI
|
||||
|
||||
- **Node.js:** 22 (matches the `frontend` job in `.github/workflows/ci.yml`)
|
||||
- **Lockfile:** `src/frontend/package-lock.json` — install with `npm ci`
|
||||
|
||||
```bash
|
||||
cd src/frontend
|
||||
npm ci
|
||||
npm run build # SPA build → dist/ (dashboard ship path)
|
||||
npm run build:lib # library build for the @praisonaiui/react package
|
||||
npm run lint # eslint
|
||||
```
|
||||
|
||||
`npm run build` output in `dist/` is synced to `src/praisonaiui/templates/frontend/`
|
||||
(the committed bundle served by `aiui build`). The CI `frontend` job runs
|
||||
`npm ci && npm run build` on PRs to `main` and fails on TypeScript/Vite errors.
|
||||
|
||||
## Vite template notes
|
||||
|
||||
This project uses a minimal setup to get React working in Vite with HMR and some ESLint rules.
|
||||
|
||||
Currently, two official plugins are available:
|
||||
|
||||
|
||||
@@ -29,7 +29,8 @@
|
||||
"build": "tsc -b && vite build",
|
||||
"build:lib": "tsc -b && vite build --mode lib",
|
||||
"lint": "eslint .",
|
||||
"preview": "vite preview"
|
||||
"preview": "vite preview",
|
||||
"test": "node --experimental-strip-types --test \"src/agent-ui/**/*.test.ts\""
|
||||
},
|
||||
"keywords": [
|
||||
"react",
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
<!doctype html>
|
||||
<!-- Spike #293 dev harness. Open with `npm run dev` → http://localhost:5173/spike.html -->
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>BeautifulUI spike · PraisonAIUI #293</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/spike/main.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,104 @@
|
||||
/**
|
||||
* Unit tests for the Agent-UI adapter (mock events -> stable contracts).
|
||||
*
|
||||
* Dependency-free: uses the Node built-in test runner so it needs no extra
|
||||
* toolchain. Run with `npm test` (from src/frontend), which invokes the Node
|
||||
* test runner with `--experimental-strip-types` so the TypeScript types are
|
||||
* stripped on Node 22+. The frontend CI gate (see issue #295) wires this in.
|
||||
*/
|
||||
|
||||
import { test } from 'node:test'
|
||||
import assert from 'node:assert/strict'
|
||||
|
||||
import { mapApprovalToCardProps, mapChatEventToUi } from './adapter.ts'
|
||||
|
||||
test('mapChatEventToUi normalizes a streaming token frame', () => {
|
||||
const event = mapChatEventToUi({
|
||||
type: 'run_content',
|
||||
token: 'Hello',
|
||||
agent_name: 'Researcher',
|
||||
session_id: 's1',
|
||||
run_id: 'r1',
|
||||
})
|
||||
assert.equal(event.kind, 'run-token')
|
||||
assert.equal(event.text, 'Hello')
|
||||
assert.equal(event.agentName, 'Researcher')
|
||||
assert.equal(event.sessionId, 's1')
|
||||
assert.equal(event.runId, 'r1')
|
||||
})
|
||||
|
||||
test('mapChatEventToUi maps a started tool call to a running chip', () => {
|
||||
const event = mapChatEventToUi({
|
||||
type: 'tool_call_started',
|
||||
name: 'search',
|
||||
description: '🔎 Searching the web',
|
||||
icon: '🔎',
|
||||
step_number: 2,
|
||||
tool_call_id: 'tc-1',
|
||||
})
|
||||
assert.equal(event.kind, 'tool-call')
|
||||
assert.deepEqual(event.toolCall, {
|
||||
id: 'tc-1',
|
||||
label: '🔎 Searching the web',
|
||||
icon: '🔎',
|
||||
stepNumber: 2,
|
||||
status: 'running',
|
||||
error: undefined,
|
||||
})
|
||||
})
|
||||
|
||||
test('mapChatEventToUi marks completed tool calls as done', () => {
|
||||
const event = mapChatEventToUi({ type: 'tool_call_completed', name: 'search' })
|
||||
assert.equal(event.toolCall?.status, 'done')
|
||||
// Falls back to the tool name for id and label when unspecified.
|
||||
assert.equal(event.toolCall?.id, 'search')
|
||||
assert.equal(event.toolCall?.label, 'search')
|
||||
})
|
||||
|
||||
test('mapChatEventToUi treats team_* frames the same as their solo variant', () => {
|
||||
assert.equal(mapChatEventToUi({ type: 'team_run_content', token: 'x' }).kind, 'run-token')
|
||||
assert.equal(mapChatEventToUi({ type: 'team_run_error', error: 'boom' }).kind, 'run-error')
|
||||
})
|
||||
|
||||
test('mapChatEventToUi preserves unknown frame types without throwing', () => {
|
||||
const event = mapChatEventToUi({ type: 'future_event', foo: 'bar' })
|
||||
assert.equal(event.kind, 'unknown')
|
||||
assert.equal(event.rawType, 'future_event')
|
||||
})
|
||||
|
||||
test('mapChatEventToUi defaults run errors to a readable message', () => {
|
||||
const event = mapChatEventToUi({ type: 'run_error' })
|
||||
assert.equal(event.kind, 'run-error')
|
||||
assert.equal(event.error, 'Unknown error')
|
||||
})
|
||||
|
||||
test('mapApprovalToCardProps maps a pending approval item', () => {
|
||||
const card = mapApprovalToCardProps({
|
||||
id: 'a-1',
|
||||
tool_name: 'delete_file',
|
||||
agent_name: 'Ops',
|
||||
risk_level: 'high',
|
||||
risk_icon: '🔥',
|
||||
description: 'Deletes a file',
|
||||
arguments: { path: '/tmp/x' },
|
||||
// Approvals API emits epoch seconds (time.time()), not an ISO string.
|
||||
created_at: 1758585600,
|
||||
})
|
||||
assert.equal(card.id, 'a-1')
|
||||
assert.equal(card.toolName, 'delete_file')
|
||||
assert.equal(card.agentName, 'Ops')
|
||||
assert.equal(card.riskLevel, 'high')
|
||||
assert.equal(card.riskIcon, '🔥')
|
||||
assert.equal(card.argumentsJson, '{\n "path": "/tmp/x"\n}')
|
||||
// Epoch seconds are normalized to an ISO-8601 string for display.
|
||||
assert.equal(card.createdAt, '2025-09-23T00:00:00.000Z')
|
||||
})
|
||||
|
||||
test('mapApprovalToCardProps falls back for missing/invalid fields', () => {
|
||||
const card = mapApprovalToCardProps({ id: 'a-2', risk_level: 'nonsense' })
|
||||
assert.equal(card.toolName, 'unknown')
|
||||
assert.equal(card.agentName, 'Unknown')
|
||||
assert.equal(card.riskLevel, 'medium')
|
||||
assert.equal(card.riskIcon, '⚠️')
|
||||
assert.equal(card.argumentsJson, '{}')
|
||||
})
|
||||
@@ -0,0 +1,177 @@
|
||||
/**
|
||||
* PraisonAIUI Agent-UI adapter
|
||||
*
|
||||
* Pure mapping functions from wire payloads to stable PraisonAIUI contracts.
|
||||
* This is the single boundary between transport shapes (WebSocket frames from
|
||||
* chat.js, REST payloads from /api/approvals/*) and the UI layer. Vendored
|
||||
* BeautifulUI components are rendered from these contracts elsewhere in
|
||||
* `src/agent-ui/`; consumers never import upstream prop types directly.
|
||||
*
|
||||
* Functions are intentionally side-effect free so they can be unit tested
|
||||
* with mock events (see adapter.test.ts).
|
||||
*/
|
||||
|
||||
import type {
|
||||
AgentStreamEvent,
|
||||
ApprovalPromptModel,
|
||||
ApprovalRiskLevel,
|
||||
ToolCallChipModel,
|
||||
} from './contracts'
|
||||
|
||||
/** Raw WebSocket frame as parsed in chat.js (`JSON.parse(event.data)`). */
|
||||
export type RawChatFrame = Record<string, unknown>
|
||||
|
||||
/** Raw approval item as returned by `/api/approvals/pending`. */
|
||||
export type RawApprovalItem = Record<string, unknown>
|
||||
|
||||
const VALID_RISK_LEVELS: readonly ApprovalRiskLevel[] = [
|
||||
'low',
|
||||
'medium',
|
||||
'high',
|
||||
'critical',
|
||||
]
|
||||
|
||||
function asString(value: unknown): string | undefined {
|
||||
return typeof value === 'string' ? value : undefined
|
||||
}
|
||||
|
||||
function asNumber(value: unknown): number | undefined {
|
||||
return typeof value === 'number' ? value : undefined
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalize a `created_at` value to an ISO-8601 string. The approvals API
|
||||
* emits epoch seconds (`time.time()`), while some payloads already carry an
|
||||
* ISO string — accept both, drop anything else.
|
||||
*/
|
||||
function asTimestamp(value: unknown): string | undefined {
|
||||
if (typeof value === 'string') return value
|
||||
if (typeof value === 'number' && Number.isFinite(value)) {
|
||||
return new Date(value * 1000).toISOString()
|
||||
}
|
||||
return undefined
|
||||
}
|
||||
|
||||
/** Map a raw tool-call frame to a stable chip view model. */
|
||||
function mapToolCall(
|
||||
frame: RawChatFrame,
|
||||
status: ToolCallChipModel['status'],
|
||||
): ToolCallChipModel {
|
||||
const name = asString(frame.name) ?? 'tool'
|
||||
return {
|
||||
id: asString(frame.tool_call_id) ?? name,
|
||||
label: asString(frame.description) ?? name,
|
||||
icon: asString(frame.icon),
|
||||
stepNumber: asNumber(frame.step_number),
|
||||
status,
|
||||
error: asString(frame.error),
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Map a raw WebSocket chat frame to a normalized {@link AgentStreamEvent}.
|
||||
*
|
||||
* Unknown frame types are preserved as `kind: 'unknown'` with `rawType` set,
|
||||
* so new upstream event types degrade gracefully instead of throwing.
|
||||
*/
|
||||
export function mapChatEventToUi(frame: RawChatFrame): AgentStreamEvent {
|
||||
const rawType = asString(frame.type)
|
||||
const sessionId = asString(frame.session_id)
|
||||
const runId = asString(frame.run_id)
|
||||
const agentName = asString(frame.agent_name)
|
||||
|
||||
switch (rawType) {
|
||||
case 'run_started':
|
||||
return { kind: 'run-started', sessionId, runId, agentName }
|
||||
case 'run_content':
|
||||
case 'team_run_content':
|
||||
return {
|
||||
kind: 'run-token',
|
||||
sessionId,
|
||||
runId,
|
||||
agentName,
|
||||
text: asString(frame.token),
|
||||
}
|
||||
case 'run_completed':
|
||||
case 'team_run_completed':
|
||||
return {
|
||||
kind: 'run-completed',
|
||||
sessionId,
|
||||
runId,
|
||||
agentName,
|
||||
text: asString(frame.content),
|
||||
}
|
||||
case 'run_error':
|
||||
case 'team_run_error':
|
||||
return {
|
||||
kind: 'run-error',
|
||||
sessionId,
|
||||
runId,
|
||||
agentName,
|
||||
error: asString(frame.error) ?? 'Unknown error',
|
||||
}
|
||||
case 'tool_call_started':
|
||||
case 'team_tool_call_started':
|
||||
return {
|
||||
kind: 'tool-call',
|
||||
sessionId,
|
||||
runId,
|
||||
agentName,
|
||||
toolCall: mapToolCall(frame, 'running'),
|
||||
}
|
||||
case 'tool_call_completed':
|
||||
case 'team_tool_call_completed':
|
||||
return {
|
||||
kind: 'tool-call',
|
||||
sessionId,
|
||||
runId,
|
||||
agentName,
|
||||
toolCall: mapToolCall(frame, 'done'),
|
||||
}
|
||||
case 'reasoning_step':
|
||||
case 'team_reasoning_step':
|
||||
return {
|
||||
kind: 'reasoning-step',
|
||||
sessionId,
|
||||
runId,
|
||||
agentName,
|
||||
text: asString(frame.step),
|
||||
}
|
||||
default:
|
||||
return { kind: 'unknown', sessionId, runId, agentName, rawType }
|
||||
}
|
||||
}
|
||||
|
||||
function normalizeRisk(value: unknown): ApprovalRiskLevel {
|
||||
return VALID_RISK_LEVELS.includes(value as ApprovalRiskLevel)
|
||||
? (value as ApprovalRiskLevel)
|
||||
: 'medium'
|
||||
}
|
||||
|
||||
/**
|
||||
* Map a raw approval item from `/api/approvals/pending` to a stable
|
||||
* {@link ApprovalPromptModel}. Arguments are pretty-printed for display and
|
||||
* an unknown/missing risk level falls back to `medium`.
|
||||
*/
|
||||
export function mapApprovalToCardProps(
|
||||
item: RawApprovalItem,
|
||||
): ApprovalPromptModel {
|
||||
let argumentsJson = '{}'
|
||||
try {
|
||||
argumentsJson = JSON.stringify(item.arguments ?? {}, null, 2)
|
||||
} catch {
|
||||
// Circular or non-serializable args — degrade to empty object.
|
||||
argumentsJson = '{}'
|
||||
}
|
||||
|
||||
return {
|
||||
id: asString(item.id) ?? '',
|
||||
toolName: asString(item.tool_name) ?? 'unknown',
|
||||
agentName: asString(item.agent_name) ?? 'Unknown',
|
||||
riskLevel: normalizeRisk(item.risk_level),
|
||||
riskIcon: asString(item.risk_icon) ?? '⚠️',
|
||||
description: asString(item.description),
|
||||
argumentsJson,
|
||||
createdAt: asTimestamp(item.created_at),
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
/**
|
||||
* PraisonAIUI Agent-UI contracts
|
||||
*
|
||||
* Stable, framework-agnostic view models owned by PraisonAIUI. These are the
|
||||
* ONLY types that non-adapter code (chat views, dashboards, tests) should
|
||||
* depend on. BeautifulUI / vendored component prop types must never leak
|
||||
* outside `src/agent-ui/` — the adapter maps to these contracts instead so
|
||||
* the upstream implementation can be swapped without touching consumers.
|
||||
*
|
||||
* See docs/frontend/beautifului-adapter-architecture.md.
|
||||
*/
|
||||
|
||||
/** Discriminant for a normalized agent stream event. */
|
||||
export type AgentStreamEventKind =
|
||||
| 'run-started'
|
||||
| 'run-token'
|
||||
| 'run-completed'
|
||||
| 'run-error'
|
||||
| 'tool-call'
|
||||
| 'reasoning-step'
|
||||
| 'unknown'
|
||||
|
||||
/**
|
||||
* Normalized agent stream event — the stable shape UI code consumes,
|
||||
* decoupled from the raw WebSocket frame `type` strings handled in chat.js.
|
||||
*/
|
||||
export interface AgentStreamEvent {
|
||||
kind: AgentStreamEventKind
|
||||
/** Owning session, when the frame carried one. */
|
||||
sessionId?: string
|
||||
/** Run identifier, when the frame carried one. */
|
||||
runId?: string
|
||||
/** Human-facing agent name, when present. */
|
||||
agentName?: string
|
||||
/** Streaming token or final content, depending on `kind`. */
|
||||
text?: string
|
||||
/** Populated for `kind === 'tool-call'`. */
|
||||
toolCall?: ToolCallChipModel
|
||||
/** Populated for `kind === 'run-error'`. */
|
||||
error?: string
|
||||
/** Raw frame `type` for events we do not yet normalize (`kind === 'unknown'`). */
|
||||
rawType?: string
|
||||
}
|
||||
|
||||
/** View model for a tool-call chip, independent of any component library. */
|
||||
export interface ToolCallChipModel {
|
||||
id: string
|
||||
label: string
|
||||
icon?: string
|
||||
stepNumber?: number
|
||||
status: 'running' | 'done' | 'error'
|
||||
error?: string
|
||||
}
|
||||
|
||||
/** Risk levels surfaced by the approvals REST API (`/api/approvals/*`). */
|
||||
export type ApprovalRiskLevel = 'low' | 'medium' | 'high' | 'critical'
|
||||
|
||||
/** View model for an approval prompt/card, independent of any component library. */
|
||||
export interface ApprovalPromptModel {
|
||||
id: string
|
||||
toolName: string
|
||||
agentName: string
|
||||
riskLevel: ApprovalRiskLevel
|
||||
riskIcon: string
|
||||
description?: string
|
||||
/** Pretty-printed tool arguments, ready for display. */
|
||||
argumentsJson: string
|
||||
createdAt?: string
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
/**
|
||||
* Agent-UI boundary barrel.
|
||||
*
|
||||
* Consumers import PraisonAIUI's stable agent-UI contracts and adapters from
|
||||
* here. Vendored BeautifulUI components live under `src/agent-ui/vendors/` and
|
||||
* are re-exported from this folder only — no upstream prop types should be
|
||||
* imported outside `src/agent-ui/`.
|
||||
*/
|
||||
|
||||
export type {
|
||||
AgentStreamEvent,
|
||||
AgentStreamEventKind,
|
||||
ApprovalPromptModel,
|
||||
ApprovalRiskLevel,
|
||||
ToolCallChipModel,
|
||||
} from './contracts'
|
||||
|
||||
export type { RawApprovalItem, RawChatFrame } from './adapter'
|
||||
export { mapApprovalToCardProps, mapChatEventToUi } from './adapter'
|
||||
@@ -0,0 +1,112 @@
|
||||
# Spike #293 — BeautifulUI primitives in PraisonAIUI
|
||||
|
||||
Time-boxed evaluation proving BeautifulUI's AI-native primitives can build inside
|
||||
PraisonAIUI's React 19 + Vite + Tailwind v4 toolchain and render against **mock,
|
||||
PraisonAIUI-shaped** agent event streams.
|
||||
|
||||
**This folder is a spike. It is intentionally isolated from the shipped bundle:**
|
||||
nothing here is imported by `src/main.tsx`, and none of it is synced into
|
||||
`src/praisonaiui/templates/frontend`. Removing `src/frontend/src/spike/` and
|
||||
`src/frontend/spike.html` fully reverts the spike.
|
||||
|
||||
## How to run
|
||||
|
||||
```bash
|
||||
cd src/frontend
|
||||
npm install
|
||||
npm run dev # then open http://localhost:5173/spike.html
|
||||
```
|
||||
|
||||
`SpikePage.tsx` drives the three primitives from `mockEvents.ts`, which uses the
|
||||
real `SSEEvent` contract from `src/types.ts` (`run_content` deltas,
|
||||
`reasoning_step` / `tool_call_started` / `tool_call_completed`, and an
|
||||
`approval_request` payload shaped like a row from `GET /api/approvals`).
|
||||
|
||||
## What was vendored (upstream pin)
|
||||
|
||||
- Registry index: `https://www.beautifului.dev/r/registry.json`
|
||||
- Component JSON: `https://www.beautifului.dev/r/<name>.json`
|
||||
- Upstream repo: `slev12397/beautiful-ui` @ **`44a274e598395ab61e7c96c26fda2758780253b7`** (2026-09-22)
|
||||
- License: **MIT** (upstream repo)
|
||||
|
||||
| Primitive | Registry item | Source | Registry deps | npm deps |
|
||||
|---|---|---|---|---|
|
||||
| StreamingText | `streaming-text` | `beautifui/primitives/StreamingText.tsx` (10.3 KB) | foundation | **none** (`react` only) |
|
||||
| ThinkingState | `thinking-state` | `beautifui/primitives/ThinkingState.tsx` (11.3 KB) | foundation | **none** (`react` only) |
|
||||
| ApprovalCard | `approval-card` | `beautifui/primitives/ApprovalCard.tsx` (16.5 KB) | foundation, button, glide-menu | **none** (`react` only) |
|
||||
| Button (dep) | `button` | `beautifui/atoms/Button.tsx` (2.0 KB) | foundation | `class-variance-authority` *(already a PraisonAIUI dep)* |
|
||||
| GlideMenu (dep) | `glide-menu` | `beautifui/primitives/GlideMenu.tsx` (1.9 KB) | foundation | **none** (`react` only) |
|
||||
| foundation | `foundation` | `beautifui/foundation.css` (17.5 KB) | — | `shadow-plugin` *(see deviation)* |
|
||||
|
||||
### npm packages pulled by the registry & licence check
|
||||
|
||||
For the three chosen primitives (+ their two registry deps) the registry pulls
|
||||
**zero new runtime npm packages**. The only npm dependency any of them declares is
|
||||
`class-variance-authority` (via `button`), which PraisonAIUI already ships.
|
||||
|
||||
- `liveline`, `glimm` — **not pulled.** These are referenced only by chart-style
|
||||
components (InsightCards) that we did not adopt. No licence review needed for this spike.
|
||||
- `@central-icons-react` — **avoided.** All three primitives render icons as
|
||||
**inline SVG** (no icon package at all), so no `lucide-react` substitution was
|
||||
even required. This is the cleanest possible outcome for the icon concern.
|
||||
|
||||
## Deviations from a raw `shadcn add`
|
||||
|
||||
1. **`shadow-plugin` removed.** `foundation.css` imports `shadow-plugin/unprefixed`
|
||||
(an extra Tailwind plugin dependency). To keep the spike lightweight we replaced
|
||||
it with a small local `@theme` block defining the `--shadow-*` scale. Visual
|
||||
shadows are approximate but faithful.
|
||||
2. **Unbalanced-brace fix (upstream bug).** The flattened `foundation.css` emits a
|
||||
**stray extra `}`** around `.records-footer-hint` (a `@media` block that lost its
|
||||
opening line in the registry export). This makes Tailwind v4 fail with
|
||||
`Missing opening {`. We removed the stray brace. **This is a real upstream blocker
|
||||
worth reporting to `beautiful-ui`.**
|
||||
3. **Imports rewritten** from `@/components/*` to `@/spike/beautifui/*` so the spike
|
||||
stays isolated and cannot collide with the existing shadcn `components/ui/button.tsx`.
|
||||
4. **`"use client"` directives dropped** (Vite SPA, not RSC).
|
||||
|
||||
## Build result
|
||||
|
||||
`vite build` of the spike entry (`spike.html`) succeeds:
|
||||
|
||||
```
|
||||
✓ 39 modules transformed
|
||||
spike.css 97.82 kB │ gzip: 17.48 kB (Tailwind + foundation)
|
||||
spike.js 247.94 kB │ gzip: 78.52 kB (React runtime + 3 primitives + 2 deps)
|
||||
```
|
||||
|
||||
- **Bundle size:** the primitives are cheap — self-contained TSX with no extra npm
|
||||
deps. The JS weight is dominated by the React runtime; the incremental cost of the
|
||||
three primitives is on the order of ~40 KB raw / low-teens KB gzip. The CSS is
|
||||
full Tailwind + the ~17 KB foundation token/keyframe layer.
|
||||
- **Motion / `prefers-reduced-motion`:** foundation ships two
|
||||
`@media (prefers-reduced-motion: reduce)` guards (global animation kill-switch +
|
||||
a StreamText-specific one). Motion is handled responsibly out of the box.
|
||||
- **Dark mode parity:** foundation defines its **own** token namespace (`--page`,
|
||||
`--canvas`, `--ink`, `--accent`, …) with its own `.dark` block, *separate* from
|
||||
PraisonAIUI's shadcn tokens (`--background`/`--foreground` via `hsl(var(--…))`).
|
||||
Good news: **no token collision** with the dashboard. Trade-off: the primitives do
|
||||
**not** automatically inherit `chat.css`/YAML-driven theme colours — they'd need a
|
||||
token bridge (map `--ink`→`--foreground`, `--surface`→`--card`, `--accent`→`--primary`)
|
||||
before they visually match the shipped chat surface in both themes.
|
||||
|
||||
## Assessment
|
||||
|
||||
**Fit score: 7/10** for a spike, with caveats for production migration.
|
||||
|
||||
**Blockers / gaps:**
|
||||
- Upstream `foundation.css` brace bug (fixed here; report upstream).
|
||||
- No automatic theme inheritance — needs a token bridge to match `chat.css` dark/light.
|
||||
- Primitives embed **demo content** (ice-cream sales copy, fake sources). They accept
|
||||
props (`content`, `rows`, `questions`, `onSubmitted`, …) so they *can* be driven by
|
||||
real events — proven in `SpikePage` — but the built-in defaults must be replaced.
|
||||
- PraisonAIUI already has native equivalents: `chat/ThinkingSteps.tsx`,
|
||||
`chat/ToolCallDisplay.tsx`, streaming in `chat/ChatMessages.tsx`. Adopting
|
||||
BeautifulUI should be weighed against enhancing these rather than duplicating them.
|
||||
|
||||
**Recommended primitive order for migration (lowest risk → highest):**
|
||||
1. **StreamingText** — pure token-stream UX, `react`-only, maps 1:1 to `run_content`.
|
||||
2. **ThinkingState** — reasoning/tool trace, `react`-only, maps to
|
||||
`reasoning_step` + `tool_call_*`; overlaps existing `ThinkingSteps.tsx`.
|
||||
3. **ApprovalCard** — highest value (HITL) but most surface: pulls `button` +
|
||||
`glide-menu` and needs wiring to `POST /api/approvals/{id}/resolve`.
|
||||
@@ -0,0 +1,90 @@
|
||||
/* Spike #293 — BeautifulUI primitives driven by PraisonAIUI-shaped mock events.
|
||||
*
|
||||
* Dev-only harness. Run with `npm run dev` and open /spike.html. This page is
|
||||
* NOT imported by the shipped library entry (main.tsx) and is NOT synced into
|
||||
* src/praisonaiui/templates/frontend — per the issue's non-goals it must not
|
||||
* touch the production bundle.
|
||||
*/
|
||||
import { useEffect, useState } from "react";
|
||||
import StreamingText from "@/spike/beautifui/primitives/StreamingText";
|
||||
import ThinkingState from "@/spike/beautifui/primitives/ThinkingState";
|
||||
import ApprovalCard from "@/spike/beautifui/primitives/ApprovalCard";
|
||||
import {
|
||||
streamingTimeline,
|
||||
tokensFromDeltas,
|
||||
rowsFromReasoning,
|
||||
REASONING_STREAM,
|
||||
APPROVAL_REQUEST,
|
||||
} from "@/spike/mockEvents";
|
||||
|
||||
function Panel({ title, children }: { title: string; children: React.ReactNode }) {
|
||||
return (
|
||||
<section style={{ marginBottom: 40 }}>
|
||||
<h2 style={{ font: "600 13px/1.4 ui-monospace, monospace", opacity: 0.6, marginBottom: 12 }}>{title}</h2>
|
||||
{children}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
export default function SpikePage() {
|
||||
const [dark, setDark] = useState(false);
|
||||
const [log, setLog] = useState<string[]>([]);
|
||||
|
||||
useEffect(() => {
|
||||
document.documentElement.classList.toggle("dark", dark);
|
||||
}, [dark]);
|
||||
|
||||
// Emulate the SSE timeline so we exercise real delta pacing (logs only —
|
||||
// the primitives below are fed the fully-resolved token/row arrays).
|
||||
useEffect(() => {
|
||||
const frames = streamingTimeline();
|
||||
const timers = frames.map((f) =>
|
||||
setTimeout(() => setLog((l) => [...l.slice(-4), `${f.event.type}: ${f.event.token ?? ""}`]), f.at),
|
||||
);
|
||||
return () => timers.forEach(clearTimeout);
|
||||
}, []);
|
||||
|
||||
const tokens = tokensFromDeltas(streamingTimeline());
|
||||
const rows = rowsFromReasoning(REASONING_STREAM);
|
||||
|
||||
return (
|
||||
<div style={{ maxWidth: 720, margin: "0 auto", padding: 32, fontFamily: "system-ui, sans-serif" }}>
|
||||
<header style={{ display: "flex", justifyContent: "space-between", alignItems: "center", marginBottom: 32 }}>
|
||||
<div>
|
||||
<h1 style={{ font: "700 20px/1.2 system-ui" }}>BeautifulUI spike</h1>
|
||||
<p style={{ opacity: 0.6, fontSize: 13 }}>Issue #293 · mock PraisonAIUI event streams</p>
|
||||
</div>
|
||||
<button onClick={() => setDark((d) => !d)} style={{ padding: "6px 12px", borderRadius: 8 }}>
|
||||
{dark ? "☀ light" : "🌙 dark"}
|
||||
</button>
|
||||
</header>
|
||||
|
||||
<Panel title="StreamingText ← run_content deltas">
|
||||
<StreamingText content={tokens} loop={false} sources={[]} followUps={[]} />
|
||||
</Panel>
|
||||
|
||||
<Panel title="ThinkingState ← reasoning_step / tool_call_*">
|
||||
<ThinkingState rows={rows} active="Thinking" done={`Ran ${rows.length} steps`} />
|
||||
</Panel>
|
||||
|
||||
<Panel title="ApprovalCard ← approval_request (/api/approvals)">
|
||||
<ApprovalCard
|
||||
questions={APPROVAL_REQUEST.questions}
|
||||
onSubmitted={(answers) =>
|
||||
// In production this maps to POST /api/approvals/{id}/resolve.
|
||||
setLog((l) => [
|
||||
...l.slice(-4),
|
||||
`resolve ${APPROVAL_REQUEST.id}: ${JSON.stringify(answers)}`,
|
||||
])
|
||||
}
|
||||
/>
|
||||
</Panel>
|
||||
|
||||
<Panel title="event log (last 5)">
|
||||
<pre style={{ font: "12px/1.5 ui-monospace, monospace", opacity: 0.7, whiteSpace: "pre-wrap" }}>
|
||||
{log.join("\n") || "…"}
|
||||
</pre>
|
||||
</Panel>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
/* Vendored from BeautifulUI registry: https://www.beautifului.dev/r/button.json
|
||||
* Spike #293 — DO NOT ship in templates/frontend. Imports rewritten to @/spike/beautifui/*.
|
||||
* License: MIT (upstream https://github.com/slev12397/beautiful-ui). */
|
||||
import type { ButtonHTMLAttributes } from "react";
|
||||
import { cva, type VariantProps } from "class-variance-authority";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
const filledShadow = "shadow-[inset_0_1px_0_rgba(255,255,255,0.14)]";
|
||||
|
||||
/* Pill-shaped by default — the app's core button style. Explicit symmetric
|
||||
* padding (not a fixed height) so the top/bottom spacing is always equal. */
|
||||
export const buttonVariants = cva(
|
||||
`inline-flex items-center justify-center font-medium select-none
|
||||
transition-[transform,background-color,opacity] duration-150 ease-out
|
||||
active:scale-[0.96] disabled:opacity-50 disabled:pointer-events-none`,
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
primary: `bg-ink text-canvas hover:opacity-90 dark:bg-ink dark:text-canvas ${filledShadow}`,
|
||||
secondary: "bg-surface text-ink shadow-btn hover:bg-inset aria-expanded:bg-hover",
|
||||
ghost: "bg-hover-2 text-ink hover:bg-line-strong",
|
||||
accent: `bg-accent text-white hover:bg-accent-ink ${filledShadow}`,
|
||||
success: `bg-green text-white hover:brightness-95 ${filledShadow}`,
|
||||
/* transparent until hovered — for dense toolbars/action rows */
|
||||
quiet: "text-ink hover:bg-hover",
|
||||
},
|
||||
size: {
|
||||
/* compact toolbar pill — fixed height, lighter weight */
|
||||
xs: "h-7 rounded-full px-2.5 text-[12px] font-normal leading-none gap-1",
|
||||
/* canonical action pill — 27px tall, roomy sides */
|
||||
sm: "h-[27px] px-3 text-[13px] leading-none rounded-full gap-1.5",
|
||||
md: "px-4 py-[9px] text-sm leading-none rounded-full gap-2",
|
||||
},
|
||||
},
|
||||
defaultVariants: { variant: "secondary", size: "md" },
|
||||
},
|
||||
);
|
||||
|
||||
export type ButtonVariant = NonNullable<VariantProps<typeof buttonVariants>["variant"]>;
|
||||
|
||||
export function Button({
|
||||
variant,
|
||||
size,
|
||||
className,
|
||||
...props
|
||||
}: ButtonHTMLAttributes<HTMLButtonElement> & VariantProps<typeof buttonVariants>) {
|
||||
return <button className={cn(buttonVariants({ variant, size }), className)} {...props} />;
|
||||
}
|
||||
+659
@@ -0,0 +1,659 @@
|
||||
/* Vendored from BeautifulUI foundation: https://www.beautifului.dev/r/foundation.json
|
||||
* Spike #293. shadow-plugin dependency neutralized (see note below). License: MIT. */
|
||||
@import "tailwindcss";
|
||||
/* shadow-plugin (flornkm) — smooth, layered shadow stacks. The unprefixed
|
||||
* entry maps them onto Tailwind's --shadow-xs…2xl scale; our semantic tokens
|
||||
* below build on those values so the whole app picks up the smooth shadows. */
|
||||
/* @import "shadow-plugin/unprefixed"; -- removed for spike */
|
||||
/* SPIKE deviation (#293): upstream imports `shadow-plugin/unprefixed` (extra npm dep).
|
||||
* To keep the spike lightweight we define the --shadow-* scale locally instead. */
|
||||
@theme inline {
|
||||
--shadow-2xs: 0 1px 0 0 oklch(0 0 0 / 0.03);
|
||||
--shadow-xs: 0 1px 2px 0 oklch(0 0 0 / 0.05);
|
||||
--shadow-sm: 0 1px 2px 0 oklch(0 0 0 / 0.06), 0 1px 1px -1px oklch(0 0 0 / 0.04);
|
||||
--shadow-md: 0 2px 4px -1px oklch(0 0 0 / 0.07), 0 1px 2px -1px oklch(0 0 0 / 0.05);
|
||||
--shadow-lg: 0 8px 16px -4px oklch(0 0 0 / 0.10), 0 2px 4px -2px oklch(0 0 0 / 0.06);
|
||||
--shadow-xl: 0 16px 32px -8px oklch(0 0 0 / 0.12);
|
||||
--shadow-2xl: 0 24px 48px -12px oklch(0 0 0 / 0.18);
|
||||
}
|
||||
|
||||
|
||||
@custom-variant dark (&:where(.dark, .dark *));
|
||||
|
||||
/* GENERIC FOUNDATION — tokens, @theme, base rules, the primitive-*
|
||||
* spacing utilities, and shared keyframes below are needed by EVERY
|
||||
* component. Blocks tagged COMPONENT-SPECIFIC further down belong to
|
||||
* one primitive each. */
|
||||
/* ──────────────────────────────────────────────────────────
|
||||
* Beautiful UI — design tokens
|
||||
* Cool near-white canvas, white cards, hairline borders, layered
|
||||
* single-digit-opacity shadows, a neutral ink ramp, and semantic
|
||||
* color used sparingly as a condiment.
|
||||
* ────────────────────────────────────────────────────────── */
|
||||
|
||||
:root {
|
||||
/* surfaces — cool, blue-tinted neutrals */
|
||||
--page: oklch(0.985 0.001 286.376);
|
||||
--canvas: oklch(0.961 0.002 247.84);
|
||||
--surface: oklch(1 0 0);
|
||||
--inset: oklch(0.979 0.002 247.839);
|
||||
--hover: oklch(0.97 0.002 247.839);
|
||||
--hover-2: oklch(0.933 0.003 247.86);
|
||||
|
||||
/* ink ramp */
|
||||
--ink: oklch(0.247 0.006 258.361);
|
||||
--ink-2: oklch(0.506 0.01 264.477);
|
||||
--ink-3: oklch(0.695 0.009 264.505);
|
||||
|
||||
/* borders — solid and crisp, not alpha */
|
||||
--line: oklch(0.946 0.003 264.542);
|
||||
--line-strong: oklch(0.912 0.005 258.326);
|
||||
--line-soft: oklch(0.966 0.002 264.542);
|
||||
--grid-line: color-mix(in srgb, var(--line) 78%, transparent);
|
||||
--field: oklch(0.961 0.001 286.375);
|
||||
--stripe: oklch(0.405 0 0 / 0.075);
|
||||
--stripe-bg: oklch(0.97 0 0);
|
||||
|
||||
/* accent */
|
||||
--accent: oklch(0.626 0.205 254.947);
|
||||
--accent-ink: oklch(0.556 0.187 255.617);
|
||||
--accent-tint: oklch(0.96 0.019 252.878);
|
||||
|
||||
/* semantic */
|
||||
--green: oklch(0.603 0.155 150.883);
|
||||
--green-tint: oklch(0.958 0.017 159.118);
|
||||
--orange: oklch(0.689 0.179 49.902);
|
||||
--orange-tint: oklch(0.964 0.021 67.581);
|
||||
--red: oklch(0.621 0.192 23.042);
|
||||
--red-tint: oklch(0.956 0.017 17.462);
|
||||
|
||||
/* chart overlays — neutral system colors with enough contrast on either theme */
|
||||
--tooltip-bg: oklch(0.272 0.008 264.435);
|
||||
--tooltip-fg: oklch(0.976 0.002 247.839);
|
||||
--tooltip-muted: oklch(0.731 0.008 260.731);
|
||||
--tooltip-border: oklch(0.356 0.007 264.474);
|
||||
|
||||
/* shadows — our solid hairline ring + shadow-plugin's smooth layered stacks
|
||||
* (--shadow-xs…lg come from shadow-plugin/unprefixed) so every surface reads
|
||||
* consistently across the app. */
|
||||
--shadow-hairline: 0 0 0 1px var(--line);
|
||||
--shadow-btn: 0 0 0 1px var(--line-strong), var(--shadow-xs);
|
||||
--shadow-card: 0 0 0 1px var(--line), var(--shadow-sm);
|
||||
--shadow-raised: 0 0 0 1px var(--line), var(--shadow-md);
|
||||
--shadow-overlay: 0 0 0 1px var(--line), var(--shadow-lg);
|
||||
--shadow-inset-field: inset 0 1px 2px oklch(0 0 0 / 0.12);
|
||||
}
|
||||
|
||||
.dark {
|
||||
--page: oklch(0.209 0.004 264.477);
|
||||
--canvas: oklch(0.231 0.004 264.487);
|
||||
--surface: oklch(0.26 0.006 271.191);
|
||||
--inset: oklch(0.243 0.004 264.492);
|
||||
--hover: oklch(0.289 0.006 271.22);
|
||||
--hover-2: oklch(0.318 0.007 274.747);
|
||||
|
||||
--ink: oklch(0.964 0.002 247.839);
|
||||
--ink-2: oklch(0.731 0.008 260.731);
|
||||
--ink-3: oklch(0.541 0.01 264.484);
|
||||
|
||||
--line: oklch(0.308 0.006 258.354);
|
||||
--line-strong: oklch(0.356 0.007 264.474);
|
||||
--line-soft: oklch(0.278 0.006 258.354);
|
||||
--field: oklch(0.293 0.006 271.223);
|
||||
--stripe: oklch(1 0 0 / 0.055);
|
||||
--stripe-bg: oklch(0.226 0.004 264.485);
|
||||
|
||||
--accent: oklch(0.68 0.173 253.301);
|
||||
--accent-ink: oklch(0.788 0.113 248.33);
|
||||
--accent-tint: oklch(0.68 0.173 253.301 / 0.16);
|
||||
|
||||
--green: oklch(0.705 0.154 153.814);
|
||||
--green-tint: oklch(0.705 0.154 153.814 / 0.14);
|
||||
--orange: oklch(0.746 0.156 55.642);
|
||||
--orange-tint: oklch(0.746 0.156 55.642 / 0.14);
|
||||
--red: oklch(0.666 0.18 21.433);
|
||||
--red-tint: oklch(0.666 0.18 21.433 / 0.14);
|
||||
|
||||
--tooltip-bg: oklch(0.182 0.004 264.459);
|
||||
--tooltip-fg: oklch(0.964 0.002 247.839);
|
||||
--tooltip-muted: oklch(0.731 0.008 260.731);
|
||||
--tooltip-border: oklch(0.308 0.006 258.354);
|
||||
|
||||
--shadow-hairline: 0 0 0 1px var(--line);
|
||||
/* Elevation rings go to a subtle *light* hairline in dark mode: the ring
|
||||
* paints over the surface behind the element, so a low-alpha white edge
|
||||
* dissolves into the shadow as one continuous stroke (per smooth-shadow-ring),
|
||||
* graduated by how far the surface floats. */
|
||||
--shadow-btn: 0 0 0 1px oklch(1 0 0 / 0.1), 0 1px 2px oklch(0 0 0 / 0.3);
|
||||
--shadow-card:
|
||||
0 0 0 1px oklch(1 0 0 / 0.11), 0 1px 2px oklch(0 0 0 / 0.2),
|
||||
0 2px 6px oklch(0 0 0 / 0.2);
|
||||
--shadow-raised:
|
||||
0 0 0 1px oklch(1 0 0 / 0.13), 0 2px 10px oklch(0 0 0 / 0.22);
|
||||
--shadow-overlay:
|
||||
0 0 0 1px oklch(1 0 0 / 0.15), 0 8px 28px oklch(0 0 0 / 0.34);
|
||||
--shadow-inset-field: inset 0 1px 2px oklch(0 0 0 / 0.4);
|
||||
}
|
||||
|
||||
@theme inline {
|
||||
/* colors */
|
||||
--color-page: var(--page);
|
||||
--color-canvas: var(--canvas);
|
||||
--color-surface: var(--surface);
|
||||
--color-inset: var(--inset);
|
||||
--color-ink: var(--ink);
|
||||
--color-ink-2: var(--ink-2);
|
||||
--color-ink-3: var(--ink-3);
|
||||
--color-hover: var(--hover);
|
||||
--color-hover-2: var(--hover-2);
|
||||
--color-field: var(--field);
|
||||
--color-line: var(--line);
|
||||
--color-line-strong: var(--line-strong);
|
||||
--color-accent: var(--accent);
|
||||
--color-accent-ink: var(--accent-ink);
|
||||
--color-accent-tint: var(--accent-tint);
|
||||
--color-green: var(--green);
|
||||
--color-green-tint: var(--green-tint);
|
||||
--color-orange: var(--orange);
|
||||
--color-orange-tint: var(--orange-tint);
|
||||
--color-red: var(--red);
|
||||
--color-red-tint: var(--red-tint);
|
||||
|
||||
/* shadows */
|
||||
--shadow-hairline: var(--shadow-hairline);
|
||||
--shadow-btn: var(--shadow-btn);
|
||||
--shadow-inset-field: var(--shadow-inset-field);
|
||||
--shadow-card: var(--shadow-card);
|
||||
--shadow-raised: var(--shadow-raised);
|
||||
--shadow-overlay: var(--shadow-overlay);
|
||||
|
||||
/* type */
|
||||
--font-sans: var(--font-inter), ui-sans-serif, system-ui, sans-serif;
|
||||
--font-mono: var(--font-mono-face), ui-monospace, "SF Mono", monospace;
|
||||
|
||||
/* radii — chips 6, controls 8, cards 10, windows 14, pills 999 */
|
||||
--radius-chip: 6px;
|
||||
--radius-control: 8px;
|
||||
--radius-card: 10px;
|
||||
--radius-window: 14px;
|
||||
|
||||
/* easing — strong curves; built-ins are too weak */
|
||||
--ease-out-strong: cubic-bezier(0.23, 1, 0.32, 1);
|
||||
--ease-in-out-strong: cubic-bezier(0.77, 0, 0.175, 1);
|
||||
--ease-link: cubic-bezier(0.16, 1, 0.3, 1);
|
||||
}
|
||||
|
||||
/* ── base ─────────────────────────────────────────────── */
|
||||
|
||||
html {
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
text-rendering: optimizeLegibility;
|
||||
}
|
||||
|
||||
body {
|
||||
background-color: var(--stripe-bg);
|
||||
background-image: repeating-linear-gradient(
|
||||
-45deg,
|
||||
transparent 0,
|
||||
transparent 7px,
|
||||
var(--stripe) 7px,
|
||||
var(--stripe) 8px
|
||||
);
|
||||
/* Pin the stripe pattern to the viewport so it stays put on scroll. */
|
||||
background-attachment: fixed;
|
||||
color: var(--ink);
|
||||
font-feature-settings: "cv11", "ss01";
|
||||
font-size: 14px;
|
||||
letter-spacing: -0.01em;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
::selection {
|
||||
background: var(--accent-tint);
|
||||
color: var(--ink);
|
||||
}
|
||||
|
||||
/* ── interface hygiene (interfaces.dev cheat sheet) ────── */
|
||||
|
||||
/* headings balance; descriptions avoid orphans (skip long-form — we have none) */
|
||||
h1,
|
||||
h2,
|
||||
h3,
|
||||
h4 {
|
||||
text-wrap: balance;
|
||||
}
|
||||
|
||||
p {
|
||||
text-wrap: pretty;
|
||||
}
|
||||
|
||||
/* underlines clear descenders */
|
||||
a {
|
||||
text-underline-position: from-font;
|
||||
text-decoration-skip-ink: auto;
|
||||
}
|
||||
|
||||
/* theme switches swap every token at once — freeze transitions so the flip
|
||||
* is one clean repaint instead of hundreds of mismatched color fades */
|
||||
.theme-switching *,
|
||||
.theme-switching *::before,
|
||||
.theme-switching *::after {
|
||||
transition: none !important;
|
||||
}
|
||||
|
||||
/* touch: :hover sticks after a tap, so neutralize row/header hovers there */
|
||||
@media (hover: none) {
|
||||
.records-row:hover > .records-cell {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.records-row:hover > td.is-colsel {
|
||||
background: color-mix(in srgb, var(--accent) 4%, var(--surface));
|
||||
}
|
||||
|
||||
.records-header-button:hover {
|
||||
background: transparent;
|
||||
color: var(--ink-2);
|
||||
}
|
||||
}
|
||||
|
||||
/* Keep the component nav scrollable but hide its scrollbar. */
|
||||
.component-nav-scroll {
|
||||
scrollbar-width: none; /* Firefox */
|
||||
-ms-overflow-style: none; /* legacy Edge */
|
||||
}
|
||||
.component-nav-scroll::-webkit-scrollbar {
|
||||
display: none; /* WebKit */
|
||||
}
|
||||
|
||||
@media (min-width: 64rem) {
|
||||
.component-nav-scroll {
|
||||
-webkit-mask-image: linear-gradient(
|
||||
to bottom,
|
||||
oklch(0 0 0) 0,
|
||||
oklch(0 0 0) calc(100% - 6rem),
|
||||
transparent 100%
|
||||
);
|
||||
mask-image: linear-gradient(
|
||||
to bottom,
|
||||
oklch(0 0 0) 0,
|
||||
oklch(0 0 0) calc(100% - 6rem),
|
||||
transparent 100%
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/* Mobile demo gestures should operate controls and visualizations rather than
|
||||
* selecting surrounding labels. Text entry and code remain intentionally
|
||||
* selectable. */
|
||||
@media (hover: none) and (pointer: coarse) {
|
||||
.primitive-showcase {
|
||||
-webkit-user-select: none;
|
||||
user-select: none;
|
||||
-webkit-touch-callout: none;
|
||||
}
|
||||
|
||||
.primitive-showcase :is(input, textarea, [contenteditable="true"], pre, code) {
|
||||
-webkit-user-select: text;
|
||||
user-select: text;
|
||||
-webkit-touch-callout: default;
|
||||
}
|
||||
|
||||
.primitive-showcase :is(a, button, [role="button"]) {
|
||||
-webkit-tap-highlight-color: transparent;
|
||||
}
|
||||
|
||||
.primitive-showcase :is(a, button, [role="button"]):focus-visible {
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.primitive-showcase :is(img, svg, canvas) {
|
||||
-webkit-user-drag: none;
|
||||
}
|
||||
|
||||
.insight-chart-stage {
|
||||
touch-action: pan-y;
|
||||
}
|
||||
}
|
||||
|
||||
/* Tailwind v4 resets buttons to cursor:default — anything
|
||||
* clickable must invite the click */
|
||||
button:not(:disabled),
|
||||
[role="button"]:not([aria-disabled="true"]),
|
||||
label:has(input),
|
||||
a {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
|
||||
:focus-visible {
|
||||
outline: 2px solid var(--accent);
|
||||
outline-offset: 2px;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
input:focus-visible,
|
||||
textarea:focus-visible,
|
||||
select:focus-visible,
|
||||
[contenteditable]:focus-visible {
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.animated-underline {
|
||||
position: relative;
|
||||
display: inline-block;
|
||||
}
|
||||
|
||||
.animated-underline::after {
|
||||
position: absolute;
|
||||
right: 0;
|
||||
bottom: -1px;
|
||||
left: 0;
|
||||
height: 1px;
|
||||
content: "";
|
||||
background: currentColor;
|
||||
transform: scaleX(0);
|
||||
transform-origin: left center;
|
||||
transition: transform 280ms var(--ease-link);
|
||||
}
|
||||
|
||||
a:hover .animated-underline::after,
|
||||
a:focus-visible .animated-underline::after {
|
||||
transform: scaleX(1);
|
||||
}
|
||||
|
||||
/* ══════════════════════════════════════════════════════════
|
||||
* COMPONENT-SPECIFIC — StreamingText / ContextCards
|
||||
* Favicon source avatars.
|
||||
* Copy this block only alongside StreamingText / ContextCards; it is not part of the
|
||||
* generic foundation above.
|
||||
* ══════════════════════════════════════════════════════════ */
|
||||
.source-avatar {
|
||||
box-shadow: 0 0 0 1px oklch(0.21 0.034 263.436 / 0.1);
|
||||
}
|
||||
|
||||
.dark .source-avatar {
|
||||
box-shadow: 0 0 0 1px oklch(1 0 0 / 0.08);
|
||||
}
|
||||
|
||||
/* ── primitive spacing system ─────────────────────────────
|
||||
* Shared optical offsets for small demo cards. Use these
|
||||
* instead of hand-tuning 10/12/14px per component.
|
||||
*/
|
||||
|
||||
.primitive-card-pad {
|
||||
padding: 12px;
|
||||
}
|
||||
|
||||
.primitive-card-bar {
|
||||
padding: 10px 12px;
|
||||
}
|
||||
|
||||
.primitive-card-footer {
|
||||
padding: 10px;
|
||||
}
|
||||
|
||||
.primitive-table-cell {
|
||||
padding: 10px 12px;
|
||||
}
|
||||
|
||||
.primitive-icon-button {
|
||||
display: inline-flex;
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border-radius: var(--radius-control);
|
||||
}
|
||||
|
||||
/* ── shared keyframes ─────────────────────────────────── */
|
||||
|
||||
@keyframes shimmer-text {
|
||||
from {
|
||||
background-position: 150% center;
|
||||
}
|
||||
to {
|
||||
background-position: -50% center;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes fade-up {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(8px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
.home-reveal {
|
||||
will-change: opacity, transform, filter;
|
||||
}
|
||||
|
||||
/* The sidebar is one persistent 224px tree clipped by a 52px shell.
|
||||
* Every rail glyph remains centered at x=26 in both states. */
|
||||
.records-footer-hint {
|
||||
display: none;
|
||||
}
|
||||
/* SPIKE fix (#293): upstream foundation.css emits a stray extra `}` here — a
|
||||
* flattened @media block that lost its opening line. Removed so Tailwind v4
|
||||
* can parse the stylesheet. See spike report. */
|
||||
|
||||
/* ── filter table electric status colors ─────────────────── */
|
||||
|
||||
/* ══════════════════════════════════════════════════════════
|
||||
* COMPONENT-SPECIFIC — FilterTable
|
||||
* Electric status-pill colors.
|
||||
* Copy this block only alongside FilterTable; it is not part of the
|
||||
* generic foundation above.
|
||||
* ══════════════════════════════════════════════════════════ */
|
||||
.filter-status-todo,
|
||||
.filter-status-progress,
|
||||
.filter-status-done {
|
||||
border: 1px solid transparent;
|
||||
}
|
||||
|
||||
/* ══════════════════════════════════════════════════════════
|
||||
* COMPONENT-SPECIFIC — InsightCards
|
||||
* Liveline chart tooltip + cursor.
|
||||
* Copy this block only alongside InsightCards; it is not part of the
|
||||
* generic foundation above.
|
||||
* ══════════════════════════════════════════════════════════ */
|
||||
.insight-chart-stage {
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.insight-chart-cursor {
|
||||
position: absolute;
|
||||
z-index: 4;
|
||||
top: 0;
|
||||
bottom: 0;
|
||||
width: 1px;
|
||||
background: var(--ink);
|
||||
opacity: 0.26;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.insight-chart-tooltip-anchor {
|
||||
position: absolute;
|
||||
z-index: 5;
|
||||
top: 8px;
|
||||
transform: translateX(-50%);
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.insight-chart-tooltip {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
padding: 5px 9px;
|
||||
border: 1px solid var(--line-strong);
|
||||
border-radius: 8px;
|
||||
color: var(--tooltip-fg);
|
||||
background: var(--tooltip-bg);
|
||||
box-shadow: var(--shadow-overlay);
|
||||
font-size: 11.5px;
|
||||
font-variant-numeric: tabular-nums;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.insight-chart-tooltip-item {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.insight-chart-tooltip-dot {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
flex: 0 0 8px;
|
||||
border-radius: 50%;
|
||||
}
|
||||
|
||||
/* Status pills share the same base-hue → color-mix derivation as .records-tag,
|
||||
* so both components stay consistent across light and dark. */
|
||||
.filter-status-todo {
|
||||
--tag-base: oklch(0.757 0.153 66.401);
|
||||
}
|
||||
.filter-status-progress {
|
||||
--tag-base: oklch(0.671 0.118 219.351);
|
||||
}
|
||||
.filter-status-done {
|
||||
--tag-base: oklch(0.652 0.131 162.865);
|
||||
}
|
||||
|
||||
.filter-status-todo,
|
||||
.filter-status-progress,
|
||||
.filter-status-done {
|
||||
color: color-mix(in srgb, var(--tag-base) 92%, var(--ink));
|
||||
background: color-mix(in srgb, var(--tag-base) 20%, var(--surface));
|
||||
border-color: color-mix(in srgb, var(--tag-base) 34%, var(--surface));
|
||||
}
|
||||
|
||||
/* Dark mode — a richer, saturated same-hue fill
|
||||
* with bright same-hue text and no visible border (light mode keeps the soft
|
||||
* tinted pastels above). Applies to both the records grid and status pills. */
|
||||
.dark .records-tag,
|
||||
.dark .filter-status-todo,
|
||||
.dark .filter-status-progress,
|
||||
.dark .filter-status-done {
|
||||
color: color-mix(in srgb, var(--tag-base) 86%, white);
|
||||
background: color-mix(in srgb, var(--tag-base) 34%, var(--surface));
|
||||
border-color: color-mix(in srgb, var(--tag-base) 34%, var(--surface));
|
||||
}
|
||||
|
||||
@keyframes fade-in {
|
||||
from {
|
||||
opacity: 0;
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes eq-bounce {
|
||||
0%,
|
||||
100% {
|
||||
transform: scaleY(0.35);
|
||||
}
|
||||
50% {
|
||||
transform: scaleY(1);
|
||||
}
|
||||
}
|
||||
|
||||
/* ── StreamText primitive ─────────────────────────────────
|
||||
* Fast character reveal: the newest few characters carry a
|
||||
* soft, masked blur so the leading edge resolves smoothly as
|
||||
* text flows in. The caret holds solid while streaming, then
|
||||
* blinks once the string settles. */
|
||||
/* ══════════════════════════════════════════════════════════
|
||||
* COMPONENT-SPECIFIC — StreamText
|
||||
* The streaming caret + tail.
|
||||
* Copy this block only alongside StreamText; it is not part of the
|
||||
* generic foundation above.
|
||||
* ══════════════════════════════════════════════════════════ */
|
||||
.stream-tail {
|
||||
filter: blur(1.6px);
|
||||
-webkit-mask-image: linear-gradient(to right, oklch(0 0 0) 20%, oklch(0 0 0 / 0.2));
|
||||
mask-image: linear-gradient(to right, oklch(0 0 0) 20%, oklch(0 0 0 / 0.2));
|
||||
}
|
||||
|
||||
.stream-caret {
|
||||
display: inline-block;
|
||||
width: 2px;
|
||||
height: 1.05em;
|
||||
margin-left: 1.5px;
|
||||
translate: 0 -0.5px;
|
||||
border-radius: 1px;
|
||||
background: var(--ink);
|
||||
vertical-align: text-bottom;
|
||||
animation: caret-blink 1s step-end infinite;
|
||||
}
|
||||
|
||||
.stream-caret.is-streaming {
|
||||
animation: none;
|
||||
}
|
||||
|
||||
@keyframes caret-blink {
|
||||
0%,
|
||||
100% {
|
||||
opacity: 1;
|
||||
}
|
||||
50% {
|
||||
opacity: 0;
|
||||
}
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.stream-caret {
|
||||
animation: none;
|
||||
}
|
||||
.stream-tail {
|
||||
filter: none;
|
||||
-webkit-mask-image: none;
|
||||
mask-image: none;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes pop-in {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: scale(0.95);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: scale(1);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes spin {
|
||||
to {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
|
||||
/* pixel-grid loader — brighten plateau, staggered per cell */
|
||||
@keyframes pixel-on {
|
||||
0%, 100% {
|
||||
opacity: 0.15;
|
||||
}
|
||||
18%, 42% {
|
||||
opacity: 1;
|
||||
}
|
||||
62% {
|
||||
opacity: 0.15;
|
||||
}
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
*,
|
||||
*::before,
|
||||
*::after {
|
||||
animation-duration: 0.01ms !important;
|
||||
animation-iteration-count: 1 !important;
|
||||
transition-duration: 0.01ms !important;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,420 @@
|
||||
/* Vendored from BeautifulUI registry: https://www.beautifului.dev/r/approval-card.json
|
||||
* Spike #293 — DO NOT ship in templates/frontend. Imports rewritten to @/spike/beautifui/*.
|
||||
* License: MIT (upstream https://github.com/slev12397/beautiful-ui). */
|
||||
import { useEffect, useLayoutEffect, useRef, useState, type CSSProperties } from "react";
|
||||
import { Button } from "@/spike/beautifui/atoms/Button";
|
||||
import GlideMenu from "@/spike/beautifui/primitives/GlideMenu";
|
||||
|
||||
/* ─────────────────────────────────────────────────────────
|
||||
* APPROVAL CARD (human-in-the-loop)
|
||||
* One question at a time. The stack slides vertically as you
|
||||
* move between questions (the card's height animates to fit),
|
||||
* the step counter rolls like an odometer, and the footer uses
|
||||
* pill actions — a quiet Skip and a dark Continue with a ⏎.
|
||||
* Single-choice answers auto-advance; multi-select waits.
|
||||
* ───────────────────────────────────────────────────────── */
|
||||
|
||||
export type ApprovalQuestion = {
|
||||
q: string;
|
||||
type: "radio" | "check";
|
||||
options: string[];
|
||||
};
|
||||
|
||||
const QUESTIONS: ApprovalQuestion[] = [
|
||||
{
|
||||
q: "How many flavors should we launch?",
|
||||
type: "radio",
|
||||
options: ["Three (core line)", "Five (full case)", "Just one hero"],
|
||||
},
|
||||
{
|
||||
q: "Which mix-ins should we stock?",
|
||||
type: "check",
|
||||
options: ["Chocolate chips", "Waffle bits", "Sprinkles"],
|
||||
},
|
||||
{
|
||||
q: "Which market do we enter first?",
|
||||
type: "radio",
|
||||
options: ["Food trucks", "Grocery freezers", "Scoop shops"],
|
||||
},
|
||||
];
|
||||
|
||||
export type ApprovalLabels = {
|
||||
skip: string;
|
||||
continue: string;
|
||||
send: string;
|
||||
customPlaceholder: string;
|
||||
sentMessage: string;
|
||||
};
|
||||
|
||||
const DEFAULT_LABELS: ApprovalLabels = {
|
||||
skip: "Skip",
|
||||
continue: "Continue",
|
||||
send: "Send",
|
||||
customPlaceholder: "Something else…",
|
||||
sentMessage: "Answers sent",
|
||||
};
|
||||
|
||||
const ROLL_MS = 400;
|
||||
const SLIDE = "360ms cubic-bezier(0.22, 1, 0.36, 1)";
|
||||
|
||||
/* odometer digits — each character that changes rolls up (or down) */
|
||||
function RollingDigits({ value }: { value: string }) {
|
||||
const prevRef = useRef(value);
|
||||
const [oldVal, setOldVal] = useState(value);
|
||||
const [newVal, setNewVal] = useState(value);
|
||||
const [rolling, setRolling] = useState(false);
|
||||
const [shifted, setShifted] = useState(false);
|
||||
const [dir, setDir] = useState<"up" | "down">("up");
|
||||
|
||||
useEffect(() => {
|
||||
if (prevRef.current === value) return;
|
||||
const from = prevRef.current;
|
||||
prevRef.current = value;
|
||||
const fromN = parseInt(from, 10);
|
||||
const toN = parseInt(value, 10);
|
||||
setDir(Number.isFinite(fromN) && Number.isFinite(toN) && toN < fromN ? "down" : "up");
|
||||
setOldVal(from);
|
||||
setNewVal(value);
|
||||
setRolling(true);
|
||||
setShifted(false);
|
||||
|
||||
let raf2 = 0;
|
||||
const raf1 = requestAnimationFrame(() => {
|
||||
raf2 = requestAnimationFrame(() => setShifted(true));
|
||||
});
|
||||
const done = setTimeout(() => {
|
||||
setRolling(false);
|
||||
setOldVal(value);
|
||||
setShifted(false);
|
||||
}, ROLL_MS);
|
||||
|
||||
return () => {
|
||||
cancelAnimationFrame(raf1);
|
||||
cancelAnimationFrame(raf2);
|
||||
clearTimeout(done);
|
||||
};
|
||||
}, [value]);
|
||||
|
||||
const chars = rolling ? newVal : oldVal;
|
||||
|
||||
return (
|
||||
<>
|
||||
{Array.from({ length: chars.length }, (_, i) => {
|
||||
const o = oldVal[i] ?? "";
|
||||
const n = chars[i] ?? "";
|
||||
if (!rolling || o === n) {
|
||||
return <span key={`${i}-${n}`}>{n}</span>;
|
||||
}
|
||||
const top = dir === "down" ? n : o;
|
||||
const bottom = dir === "down" ? o : n;
|
||||
const restY = dir === "down" ? "0" : "-1em";
|
||||
const startY = dir === "down" ? "-1em" : "0";
|
||||
return (
|
||||
<span
|
||||
key={`${i}-${o}-${n}-${dir}`}
|
||||
style={{ display: "inline-block", position: "relative", overflow: "hidden", height: "1em", lineHeight: "1em", verticalAlign: "-0.05em" }}
|
||||
>
|
||||
<span
|
||||
style={{
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
transition: "transform 350ms cubic-bezier(0.4, 0, 0.2, 1)",
|
||||
transform: `translateY(${shifted ? restY : startY})`,
|
||||
}}
|
||||
>
|
||||
<span style={{ height: "1em", lineHeight: "1em" }}>{top}</span>
|
||||
<span style={{ height: "1em", lineHeight: "1em" }}>{bottom}</span>
|
||||
</span>
|
||||
</span>
|
||||
);
|
||||
})}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function Ico({ path, size = 14, sw = 2 }: { path: React.ReactNode; size?: number; sw?: number }) {
|
||||
return (
|
||||
<svg width={size} height={size} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={sw} strokeLinecap="round" strokeLinejoin="round" aria-hidden>
|
||||
{path}
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
export default function ApprovalCard({
|
||||
questions = QUESTIONS,
|
||||
labels,
|
||||
onSubmitted,
|
||||
onAnswerChange,
|
||||
resettable = true,
|
||||
}: {
|
||||
questions?: ApprovalQuestion[];
|
||||
labels?: Partial<ApprovalLabels>;
|
||||
onSubmitted?: (answers: Record<number, number[]>) => void;
|
||||
onAnswerChange?: (questionIndex: number, answer: number[]) => void;
|
||||
resettable?: boolean;
|
||||
variant?: string;
|
||||
} = {}) {
|
||||
const t = { ...DEFAULT_LABELS, ...labels };
|
||||
const [qi, setQi] = useState(0);
|
||||
const [answers, setAnswers] = useState<Record<number, number[]>>({});
|
||||
const [custom, setCustom] = useState<Record<number, string>>({});
|
||||
const [sent, setSent] = useState(false);
|
||||
const [open, setOpen] = useState(true);
|
||||
|
||||
const advanceTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
const questionRefs = useRef<(HTMLDivElement | null)[]>([]);
|
||||
const measured = useRef(false);
|
||||
const [viewportH, setViewportH] = useState<number | undefined>(undefined);
|
||||
const [trackY, setTrackY] = useState(0);
|
||||
const [animate, setAnimate] = useState(false);
|
||||
// Until the first question is measured, render only the active one so the
|
||||
// initial (and SSR) height is Q1's height — not all questions stacked, which
|
||||
// would flash to full height and then shrink on mount.
|
||||
const [ready, setReady] = useState(false);
|
||||
|
||||
const last = qi === questions.length - 1;
|
||||
const selected = answers[qi] ?? [];
|
||||
const hasAnswer = selected.length > 0 || Boolean(custom[qi]?.trim());
|
||||
|
||||
const sync = (withAnim: boolean) => {
|
||||
const item = questionRefs.current[qi];
|
||||
if (!item) return;
|
||||
const reduce = typeof window !== "undefined" && window.matchMedia("(prefers-reduced-motion: reduce)").matches;
|
||||
setViewportH(item.offsetHeight);
|
||||
setTrackY(item.offsetTop);
|
||||
setAnimate(withAnim && !reduce);
|
||||
};
|
||||
|
||||
useLayoutEffect(() => {
|
||||
const withAnim = measured.current;
|
||||
measured.current = true;
|
||||
sync(withAnim);
|
||||
setReady(true);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [qi, answers, custom, open, sent]);
|
||||
|
||||
useEffect(() => {
|
||||
const id = requestAnimationFrame(() => sync(measured.current));
|
||||
return () => cancelAnimationFrame(id);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [qi]);
|
||||
|
||||
useEffect(() => () => { if (advanceTimer.current) clearTimeout(advanceTimer.current); }, []);
|
||||
|
||||
const goTo = (next: number) => {
|
||||
if (advanceTimer.current) clearTimeout(advanceTimer.current);
|
||||
setQi(Math.min(Math.max(next, 0), questions.length - 1));
|
||||
};
|
||||
|
||||
const send = () => {
|
||||
if (advanceTimer.current) clearTimeout(advanceTimer.current);
|
||||
setSent(true);
|
||||
onSubmitted?.(answers);
|
||||
};
|
||||
|
||||
const advance = () => {
|
||||
if (last) send();
|
||||
else goTo(qi + 1);
|
||||
};
|
||||
|
||||
const toggle = (index: number) => {
|
||||
const type = questions[qi].type;
|
||||
setAnswers((current) => {
|
||||
const picked = current[qi] ?? [];
|
||||
const next = type === "radio"
|
||||
? [index]
|
||||
: picked.includes(index)
|
||||
? picked.filter((item) => item !== index)
|
||||
: [...picked, index];
|
||||
onAnswerChange?.(qi, next);
|
||||
return { ...current, [qi]: next };
|
||||
});
|
||||
if (type === "radio") {
|
||||
setCustom((current) => ({ ...current, [qi]: "" }));
|
||||
if (advanceTimer.current) clearTimeout(advanceTimer.current);
|
||||
advanceTimer.current = setTimeout(() => {
|
||||
if (last) send();
|
||||
else setQi((current) => Math.min(questions.length - 1, current + 1));
|
||||
}, 480);
|
||||
}
|
||||
};
|
||||
|
||||
const reset = () => {
|
||||
setQi(0);
|
||||
setAnswers({});
|
||||
setCustom({});
|
||||
setSent(false);
|
||||
setOpen(true);
|
||||
measured.current = false;
|
||||
};
|
||||
|
||||
if (!open) {
|
||||
return (
|
||||
<button type="button" onClick={() => setOpen(true)} className="rounded-control bg-surface px-3 py-2 text-[12.5px] font-medium text-ink shadow-btn transition-colors duration-150 hover:bg-hover">
|
||||
Open approval
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
if (sent) {
|
||||
return (
|
||||
<div className="flex w-full max-w-80 items-center gap-3" style={{ animation: "pop-in 260ms cubic-bezier(0.23,1,0.32,1) both" }}>
|
||||
<span className="inline-flex items-center gap-1.5 rounded-full bg-green-tint py-1 pr-2.5 pl-1 text-[12.5px] font-medium text-green">
|
||||
<span className="flex size-4.5 items-center justify-center rounded-full bg-green text-white">
|
||||
<svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="3" strokeLinecap="round" strokeLinejoin="round"><path d="M20 6L9 17l-5-5" /></svg>
|
||||
</span>
|
||||
{t.sentMessage}
|
||||
</span>
|
||||
{resettable && (
|
||||
<button type="button" onClick={reset} className="text-[12px] font-medium text-ink-3 transition-colors duration-150 hover:text-ink">
|
||||
Start over
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="w-full max-w-80">
|
||||
<div className="relative overflow-hidden rounded-card bg-surface shadow-card" style={{ animation: "fade-up 380ms cubic-bezier(0.23,1,0.32,1) both" }}>
|
||||
<button
|
||||
type="button"
|
||||
aria-label="Dismiss"
|
||||
onClick={() => setOpen(false)}
|
||||
className="primitive-icon-button absolute right-2.5 top-2.5 z-10 text-ink-3 transition-colors duration-100 hover:bg-hover hover:text-ink"
|
||||
>
|
||||
<Ico size={14} sw={2.2} path={<path d="M18 6L6 18M6 6l12 12" />} />
|
||||
</button>
|
||||
<div className="primitive-card-pad">
|
||||
{/* the question itself is the heading */}
|
||||
<div
|
||||
className="overflow-hidden"
|
||||
style={{ height: viewportH, transition: animate ? `height ${SLIDE}` : undefined }}
|
||||
aria-live="polite"
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
gap: 26,
|
||||
transform: `translate3d(0, ${-trackY}px, 0)`,
|
||||
transition: animate ? `transform ${SLIDE}` : undefined,
|
||||
willChange: "transform",
|
||||
}}
|
||||
>
|
||||
{questions.map((question, qIdx) => {
|
||||
const active = qIdx === qi;
|
||||
// Before the first measure, mount only the active question so the
|
||||
// card opens at its real height instead of flashing to full height.
|
||||
if (!ready && !active) return null;
|
||||
const picked = answers[qIdx] ?? [];
|
||||
const questionStyle: CSSProperties = {
|
||||
opacity: active ? 1 : 0,
|
||||
transition: animate ? `opacity ${SLIDE}` : undefined,
|
||||
pointerEvents: active ? undefined : "none",
|
||||
};
|
||||
return (
|
||||
<div
|
||||
key={qIdx}
|
||||
ref={(el) => { questionRefs.current[qIdx] = el; }}
|
||||
aria-hidden={active ? undefined : true}
|
||||
style={questionStyle}
|
||||
>
|
||||
<div className="pr-7 text-[14px] font-medium text-ink">{question.q}</div>
|
||||
<GlideMenu className="mt-2.5 flex flex-col gap-1" highlightClassName="inset-x-0 rounded-control bg-hover">
|
||||
{question.options.map((option, i) => {
|
||||
const on = picked.includes(i);
|
||||
return (
|
||||
<button
|
||||
key={option}
|
||||
type="button"
|
||||
data-menu-row
|
||||
aria-pressed={on}
|
||||
tabIndex={active ? 0 : -1}
|
||||
onClick={() => { if (active) toggle(i); }}
|
||||
className="relative z-10 flex items-center gap-1.5 rounded-control pl-1 pr-2 py-1 text-left transition-colors duration-100"
|
||||
>
|
||||
<span
|
||||
className={`flex size-4 shrink-0 items-center justify-center transition-colors duration-200
|
||||
${question.type === "radio" ? "rounded-full" : "rounded-[5px]"}
|
||||
${on ? "bg-ink text-canvas" : "shadow-[inset_0_0_0_1.5px_var(--line-strong)] text-transparent"}`}
|
||||
>
|
||||
{question.type === "radio" ? (
|
||||
<span className="size-1.5 rounded-full bg-canvas transition-transform duration-200" style={{ transform: on ? "scale(1)" : "scale(0)" }} />
|
||||
) : (
|
||||
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="3" strokeLinecap="round" strokeLinejoin="round"><path d="M20 6L9 17l-5-5" /></svg>
|
||||
)}
|
||||
</span>
|
||||
<span className={`text-[13px] leading-none transition-colors duration-200 ${on ? "text-ink" : "text-ink-2"}`}>
|
||||
{option}
|
||||
</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
<label data-menu-row className="relative z-10 flex items-center gap-1.5 rounded-control pl-1 pr-2 py-1 transition-colors duration-100">
|
||||
<input
|
||||
value={custom[qIdx] ?? ""}
|
||||
tabIndex={active ? 0 : -1}
|
||||
onChange={(event) => {
|
||||
if (!active) return;
|
||||
setCustom((current) => ({ ...current, [qIdx]: event.target.value }));
|
||||
if (question.type === "radio") setAnswers((current) => ({ ...current, [qIdx]: [] }));
|
||||
}}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === "Enter" && hasAnswer) {
|
||||
event.preventDefault();
|
||||
advance();
|
||||
}
|
||||
}}
|
||||
placeholder={t.customPlaceholder}
|
||||
aria-label="Custom answer"
|
||||
className="min-w-0 flex-1 bg-transparent pl-1.5 text-[13px] text-ink outline-none placeholder:text-ink-3"
|
||||
/>
|
||||
</label>
|
||||
</GlideMenu>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* footer — step nav (rolling counter) + pill actions */}
|
||||
<div className="primitive-card-footer flex items-center justify-between gap-3">
|
||||
<div className="flex items-center gap-1 text-ink-3">
|
||||
<button
|
||||
type="button"
|
||||
aria-label="Previous question"
|
||||
disabled={qi <= 0}
|
||||
onClick={() => goTo(qi - 1)}
|
||||
className="flex size-[18px] items-center justify-center rounded-[5px] transition-colors duration-100 enabled:hover:text-ink disabled:opacity-30"
|
||||
>
|
||||
<Ico size={14} path={<path d="M18 15l-6-6-6 6" />} />
|
||||
</button>
|
||||
<span className="inline-flex items-center text-[12px] font-medium tabular-nums text-ink-3" style={{ letterSpacing: "-0.1px", lineHeight: 1 }}>
|
||||
<RollingDigits value={`${qi + 1} / ${questions.length}`} />
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
aria-label="Next question"
|
||||
disabled={last}
|
||||
onClick={() => goTo(qi + 1)}
|
||||
className="flex size-[18px] items-center justify-center rounded-[5px] transition-colors duration-100 enabled:hover:text-ink disabled:opacity-30"
|
||||
>
|
||||
<Ico size={14} path={<path d="M6 9l6 6 6-6" />} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="-mr-0.5 flex items-center gap-1.5">
|
||||
<Button variant="ghost" size="sm" onClick={() => (last ? setOpen(false) : goTo(qi + 1))}>
|
||||
{t.skip}
|
||||
</Button>
|
||||
<Button variant="accent" size="sm" disabled={!hasAnswer} onClick={advance}>
|
||||
{last ? t.send : t.continue}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
/* Vendored from BeautifulUI registry: https://www.beautifului.dev/r/glide-menu.json
|
||||
* Spike #293 — DO NOT ship in templates/frontend. Imports rewritten to @/spike/beautifui/*.
|
||||
* License: MIT (upstream https://github.com/slev12397/beautiful-ui). */
|
||||
import { useRef, useState, type ReactNode } from "react";
|
||||
|
||||
type GlideMenuProps = {
|
||||
children: ReactNode;
|
||||
className?: string;
|
||||
highlightClassName?: string;
|
||||
rowSelector?: string;
|
||||
};
|
||||
|
||||
/** A single hover layer that glides between interactive menu rows. */
|
||||
export default function GlideMenu({
|
||||
children,
|
||||
className = "",
|
||||
highlightClassName = "inset-x-0 rounded-[8px] bg-hover",
|
||||
rowSelector = "[data-menu-row]",
|
||||
}: GlideMenuProps) {
|
||||
const ref = useRef<HTMLDivElement>(null);
|
||||
const [box, setBox] = useState<{ top: number; height: number } | null>(null);
|
||||
const [visible, setVisible] = useState(false);
|
||||
|
||||
const moveTo = (target: EventTarget | null) => {
|
||||
const container = ref.current;
|
||||
if (!(target instanceof Element) || !container) return;
|
||||
const row = target.closest(rowSelector);
|
||||
if (!(row instanceof HTMLElement) || !container.contains(row)) return;
|
||||
const containerRect = container.getBoundingClientRect();
|
||||
const rowRect = row.getBoundingClientRect();
|
||||
setBox({ top: rowRect.top - containerRect.top, height: rowRect.height });
|
||||
setVisible(true);
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={ref}
|
||||
onMouseOver={(event) => moveTo(event.target)}
|
||||
onMouseLeave={() => setVisible(false)}
|
||||
onFocusCapture={(event) => moveTo(event.target)}
|
||||
onBlurCapture={(event) => {
|
||||
if (!ref.current?.contains(event.relatedTarget as Node | null)) setVisible(false);
|
||||
}}
|
||||
className={`group/glide-menu relative ${className}`}
|
||||
>
|
||||
<span
|
||||
aria-hidden
|
||||
className={`pointer-events-none absolute ${highlightClassName}`}
|
||||
style={{
|
||||
top: box?.top ?? 0,
|
||||
height: box?.height ?? 0,
|
||||
opacity: box && visible ? 1 : 0,
|
||||
transition:
|
||||
"top 220ms cubic-bezier(0.23,1,0.32,1), height 220ms cubic-bezier(0.23,1,0.32,1), opacity 150ms ease",
|
||||
}}
|
||||
/>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,253 @@
|
||||
/* Vendored from BeautifulUI registry: https://www.beautifului.dev/r/streaming-text.json
|
||||
* Spike #293 — DO NOT ship in templates/frontend. Imports rewritten to @/spike/beautifui/*.
|
||||
* License: MIT (upstream https://github.com/slev12397/beautiful-ui). */
|
||||
import { useEffect, useState } from "react";
|
||||
|
||||
/* ─────────────────────────────────────────────────────────
|
||||
* STREAMING TEXT
|
||||
* Words resolve out of blur, inline citations appear in
|
||||
* context, then actions and follow-up prompts become usable.
|
||||
* ───────────────────────────────────────────────────────── */
|
||||
|
||||
const WORD_MS = 55;
|
||||
const HOLD_MS = 3400;
|
||||
|
||||
/* one streamed word, or a `cite` placeholder that renders an inline source chip */
|
||||
export type StreamingToken = { text: string; cite?: boolean };
|
||||
|
||||
const TOKENS: StreamingToken[] = [
|
||||
..."Pistachio is your fastest-growing flavor — sales are up 23% this month and margins beat vanilla by 8 points."
|
||||
.split(" ")
|
||||
.map((text) => ({ text })),
|
||||
{ text: "", cite: true },
|
||||
..."Stone-fruit flavors are trending in the same range."
|
||||
.split(" ")
|
||||
.map((text) => ({ text })),
|
||||
];
|
||||
|
||||
const FOLLOW_UPS = [
|
||||
"Which flavors sell best in winter",
|
||||
"Compare gelato and soft serve margins",
|
||||
];
|
||||
|
||||
const SOURCE_IMAGES = {
|
||||
scoop:
|
||||
"data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 64 64'%3E%3Crect width='64' height='64' rx='16' fill='%231f7a5f'/%3E%3Cpath d='M20 36c0 7 5.4 12 12 12s12-5 12-12H20Z' fill='%23fff'/%3E%3Ccircle cx='32' cy='25' r='11' fill='%23bff3dd'/%3E%3Cpath d='M24 24c4-7 13-7 17 0' fill='none' stroke='%231f7a5f' stroke-width='4' stroke-linecap='round'/%3E%3C/svg%3E",
|
||||
trends:
|
||||
"data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 64 64'%3E%3Crect width='64' height='64' rx='16' fill='%232f6fec'/%3E%3Cpath d='M15 43 27 31l8 7 14-18' fill='none' stroke='%23fff' stroke-width='7' stroke-linecap='round' stroke-linejoin='round'/%3E%3Ccircle cx='49' cy='20' r='5' fill='%23bfe0ff'/%3E%3C/svg%3E",
|
||||
market:
|
||||
"data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 64 64'%3E%3Crect width='64' height='64' rx='16' fill='%23e56d24'/%3E%3Cpath d='M17 45V25h8v20h-8Zm11 0V16h8v29h-8Zm11 0V30h8v15h-8Z' fill='%23fff'/%3E%3Cpath d='M16 49h32' stroke='%23ffd6b8' stroke-width='4' stroke-linecap='round'/%3E%3C/svg%3E",
|
||||
};
|
||||
|
||||
/* one cited source rendered as an inline chip and in the sources list */
|
||||
export type StreamingSource = { name: string; domain: string; href: string; image: string };
|
||||
|
||||
const SOURCES: StreamingSource[] = [
|
||||
{ name: "Scoop Data", domain: "scoopdata.io", href: "https://scoopdata.io/", image: SOURCE_IMAGES.scoop },
|
||||
{ name: "Trends Index", domain: "trends.google.com", href: "https://trends.google.com/trends/", image: SOURCE_IMAGES.trends },
|
||||
{ name: "Market Basket", domain: "marketbasket.io", href: "https://marketbasket.io/", image: SOURCE_IMAGES.market },
|
||||
];
|
||||
|
||||
function sourceImage(source: StreamingSource) {
|
||||
return source.image;
|
||||
}
|
||||
|
||||
function SourceChip({ source }: { source?: StreamingSource }) {
|
||||
if (!source) return null;
|
||||
return (
|
||||
<a
|
||||
href={source.href}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className="ml-0 mr-1 inline-flex h-4.5 translate-y-[-1px] items-center gap-1 rounded-[5px]
|
||||
bg-inset pr-[3px] pl-[3px] align-middle font-mono text-[10.5px] text-ink-2 shadow-hairline
|
||||
transition-colors duration-150 hover:bg-hover hover:text-ink"
|
||||
style={{ animation: "pop-in 250ms cubic-bezier(0.23,1,0.32,1) both" }}
|
||||
>
|
||||
<img src={sourceImage(source)} alt="" className="source-avatar size-3 rounded-[3px]" />
|
||||
<span>{source.domain}</span>
|
||||
</a>
|
||||
);
|
||||
}
|
||||
|
||||
const ACTION_ICONS: React.ReactNode[] = [
|
||||
<g key="copy"><rect x="9" y="9" width="12" height="12" rx="2.5" /><path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1" /></g>,
|
||||
<path key="retry" d="M21 12a9 9 0 1 1-2.64-6.36M21 3v6h-6" />,
|
||||
<path key="up" d="M7 10v12M15 5.88L14 10h5.83a2 2 0 0 1 1.92 2.56l-2.33 8A2 2 0 0 1 17.5 22H4a2 2 0 0 1-2-2v-8a2 2 0 0 1 2-2h2.76a2 2 0 0 0 1.79-1.11L12 2a3.13 3.13 0 0 1 3 3.88z" />,
|
||||
<path key="down" d="M17 14V2M9 18.12L10 14H4.17a2 2 0 0 1-1.92-2.56l2.33-8A2 2 0 0 1 6.5 2H20a2 2 0 0 1 2 2v8a2 2 0 0 1-2 2h-2.76a2 2 0 0 0-1.79 1.11L12 22a3.13 3.13 0 0 1-3-3.88z" />,
|
||||
];
|
||||
|
||||
export type StreamingLabels = {
|
||||
/** label on the collapsed sources toggle */
|
||||
sources: string;
|
||||
/** heading above the follow-up prompts */
|
||||
followUps: string;
|
||||
};
|
||||
|
||||
const DEFAULT_LABELS: StreamingLabels = {
|
||||
sources: "10 sources",
|
||||
followUps: "Follow-ups",
|
||||
};
|
||||
|
||||
export default function StreamingText({
|
||||
content = TOKENS,
|
||||
sources = SOURCES,
|
||||
followUps = FOLLOW_UPS,
|
||||
labels,
|
||||
loop = true,
|
||||
fill = false,
|
||||
onDone,
|
||||
onFollowUp,
|
||||
}: {
|
||||
variant?: string;
|
||||
/** the streamed tokens; `cite` tokens render an inline source chip */
|
||||
content?: StreamingToken[];
|
||||
/** cited sources shown in the chip, avatar stack, and expanded list */
|
||||
sources?: StreamingSource[];
|
||||
/** follow-up prompt suggestions shown once the stream completes */
|
||||
followUps?: string[];
|
||||
/** prominent copy strings */
|
||||
labels?: Partial<StreamingLabels>;
|
||||
/** restart the stream after a hold; turn off when embedding in a real thread */
|
||||
loop?: boolean;
|
||||
/** fill the parent width instead of the gallery's fixed measure */
|
||||
fill?: boolean;
|
||||
onDone?: () => void;
|
||||
/** fired when a follow-up prompt is chosen */
|
||||
onFollowUp?: (text: string, index: number) => void;
|
||||
} = {}) {
|
||||
const l = { ...DEFAULT_LABELS, ...labels };
|
||||
const [count, setCount] = useState(0);
|
||||
const [sourcesOpen, setSourcesOpen] = useState(false);
|
||||
const done = count >= content.length;
|
||||
|
||||
useEffect(() => {
|
||||
if (done && !loop) {
|
||||
onDone?.();
|
||||
return;
|
||||
}
|
||||
const t = setTimeout(
|
||||
() => setCount((c) => (c >= content.length ? 0 : c + 1)),
|
||||
done ? HOLD_MS : WORD_MS,
|
||||
);
|
||||
return () => clearTimeout(t);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [count, done, loop]);
|
||||
|
||||
return (
|
||||
<div className={fill ? "w-full" : "min-h-[15.5rem] w-full max-w-95"}>
|
||||
<p className="text-[13px] leading-relaxed text-ink">
|
||||
{content.slice(0, count).map((token, i) =>
|
||||
token.cite ? (
|
||||
<SourceChip key={i} source={sources[0]} />
|
||||
) : (
|
||||
<span key={i} className="inline">
|
||||
{token.text}{" "}
|
||||
</span>
|
||||
),
|
||||
)}
|
||||
{!done && (
|
||||
<span
|
||||
className="ml-0.5 inline-block h-3 w-0.5 translate-y-0.5 rounded-full bg-ink"
|
||||
style={{ animation: "fade-in 150ms ease-out both" }}
|
||||
/>
|
||||
)}
|
||||
</p>
|
||||
|
||||
{/* action icons row */}
|
||||
<div
|
||||
className="mt-2 flex items-center gap-0.5 transition-opacity duration-400"
|
||||
style={{ opacity: done ? 1 : 0, pointerEvents: done ? "auto" : "none" }}
|
||||
>
|
||||
{ACTION_ICONS.map((icon, i) => (
|
||||
<button
|
||||
key={i}
|
||||
type="button"
|
||||
aria-label="Action"
|
||||
className="flex size-6 items-center justify-center rounded-[6px] text-ink-3
|
||||
transition-colors duration-100 hover:bg-hover-2 hover:text-ink-2"
|
||||
>
|
||||
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round">
|
||||
{icon}
|
||||
</svg>
|
||||
</button>
|
||||
))}
|
||||
<button
|
||||
type="button"
|
||||
aria-expanded={sourcesOpen}
|
||||
onClick={() => setSourcesOpen((current) => !current)}
|
||||
className="ml-1.5 flex items-center gap-1.5 rounded-[6px] px-1 py-0.5 text-left transition-colors duration-150 hover:bg-hover"
|
||||
>
|
||||
<span className="flex -space-x-1">
|
||||
{sources.map((source) => (
|
||||
<img
|
||||
key={source.domain}
|
||||
src={sourceImage(source)}
|
||||
alt=""
|
||||
className="source-avatar size-3.5 rounded-full bg-surface shadow-[0_0_0_1.5px_var(--canvas)]"
|
||||
/>
|
||||
))}
|
||||
</span>
|
||||
<span className="text-[12px] text-ink-2">{l.sources}</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div
|
||||
className="grid transition-[grid-template-rows,opacity] duration-300"
|
||||
style={{
|
||||
gridTemplateRows: done && sourcesOpen ? "1fr" : "0fr",
|
||||
opacity: done && sourcesOpen ? 1 : 0,
|
||||
transitionTimingFunction: "cubic-bezier(0.23, 1, 0.32, 1)",
|
||||
}}
|
||||
>
|
||||
<div className="overflow-hidden">
|
||||
<div className="mt-1.5 flex flex-col rounded-[10px] bg-inset p-1 shadow-hairline">
|
||||
{sources.map((source) => (
|
||||
<a
|
||||
key={source.domain}
|
||||
href={source.href}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className="flex items-center gap-2 rounded-[6px] px-1.5 py-1 text-[12px] text-ink-2 transition-colors duration-150 hover:bg-hover hover:text-ink"
|
||||
>
|
||||
<img src={sourceImage(source)} alt="" className="source-avatar size-4 rounded-[4px]" />
|
||||
<span className="animated-underline">{source.name}</span>
|
||||
<span className="ml-auto font-mono text-[10.5px] text-ink-3">{source.domain}</span>
|
||||
</a>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* follow-ups */}
|
||||
<div
|
||||
className="mt-2.5 transition-opacity duration-400"
|
||||
style={{ opacity: done ? 1 : 0, pointerEvents: done ? "auto" : "none" }}
|
||||
>
|
||||
<p className="text-[12px] font-medium text-ink-2">{l.followUps}</p>
|
||||
<div className="mt-0.5 flex flex-col">
|
||||
{followUps.map((text, i) => (
|
||||
<button
|
||||
key={text}
|
||||
onClick={() => onFollowUp?.(text, i)}
|
||||
className="-mx-1.5 flex items-center gap-2 rounded-[7px] border-b border-line
|
||||
px-1.5 py-1.5 text-left text-[12.5px] text-ink transition-colors
|
||||
duration-100 hover:bg-hover-2"
|
||||
style={
|
||||
done
|
||||
? { animation: `fade-up 350ms cubic-bezier(0.23,1,0.32,1) ${i * 90}ms both` }
|
||||
: { opacity: 0 }
|
||||
}
|
||||
>
|
||||
<svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="var(--ink-3)" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" className="shrink-0">
|
||||
<path d="M9 10l-5 5 5 5" />
|
||||
<path d="M20 4v7a4 4 0 0 1-4 4H4" />
|
||||
</svg>
|
||||
{text}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,302 @@
|
||||
/* Vendored from BeautifulUI registry: https://www.beautifului.dev/r/thinking-state.json
|
||||
* Spike #293 — DO NOT ship in templates/frontend. Imports rewritten to @/spike/beautifui/*.
|
||||
* License: MIT (upstream https://github.com/slev12397/beautiful-ui). */
|
||||
import { useEffect, useLayoutEffect, useRef, useState, type ReactNode } from "react";
|
||||
|
||||
/* ─────────────────────────────────────────────────────────
|
||||
* THINKING — expandable agent trace, four variants
|
||||
*
|
||||
* Steps step list with spinner → muted checks
|
||||
* Reasoning prose reasoning that expands, then settles
|
||||
* Search web-search trace: query + sources read
|
||||
* Coding tool trace: files read, edits, commands
|
||||
*
|
||||
* The trace runs once, settles, and remains expandable.
|
||||
* ───────────────────────────────────────────────────────── */
|
||||
|
||||
const STAGES = [800, 600, 1800, 2600, 1600];
|
||||
|
||||
function useSequence(steps: number[]) {
|
||||
const [stage, setStage] = useState(0);
|
||||
useEffect(() => {
|
||||
if (stage >= steps.length - 1) return;
|
||||
const t = setTimeout(() => setStage((s) => s + 1), steps[stage]);
|
||||
return () => clearTimeout(t);
|
||||
}, [stage, steps]);
|
||||
return stage;
|
||||
}
|
||||
|
||||
type Row = {
|
||||
primary: string;
|
||||
secondary?: string;
|
||||
mono?: boolean;
|
||||
add?: number;
|
||||
del?: number;
|
||||
href?: string;
|
||||
};
|
||||
|
||||
const VARIANTS: Record<
|
||||
string,
|
||||
{ active: string; done: string; rows: Row[]; query?: string }
|
||||
> = {
|
||||
Steps: {
|
||||
active: "Thinking",
|
||||
done: "Thought for 4 seconds",
|
||||
rows: [
|
||||
{ primary: "Reading flavor briefs" },
|
||||
{ primary: "Scanning supplier lists" },
|
||||
{ primary: "Comparing tasting notes", secondary: "6 flavors" },
|
||||
{ primary: "Writing the scoop report" },
|
||||
],
|
||||
},
|
||||
Reasoning: {
|
||||
active: "Thinking",
|
||||
done: "Thought for 4 seconds",
|
||||
rows: [
|
||||
{ primary: "Summer demand spikes for stone-fruit flavors — peach and apricot lead." },
|
||||
{ primary: "I should check cone inventory before promoting a waffle-bowl special." },
|
||||
],
|
||||
},
|
||||
Search: {
|
||||
active: "Searching the web",
|
||||
done: "Searched the web",
|
||||
query: "best waffle cone supplier",
|
||||
rows: [
|
||||
{ primary: "Joy Cone", secondary: "joycone.com", href: "https://joycone.com/fs_products/waffle-cones/" },
|
||||
{ primary: "WebstaurantStore", secondary: "webstaurantstore.com", href: "https://www.webstaurantstore.com/ice-cream-shop-supplies.html" },
|
||||
{ primary: "The Konery", secondary: "thekonery.com", href: "https://www.thekonery.com/" },
|
||||
],
|
||||
},
|
||||
Coding: {
|
||||
active: "Running tools",
|
||||
done: "Ran 3 tools",
|
||||
rows: [
|
||||
{ primary: "Read", secondary: "flavors.ts", mono: true },
|
||||
{ primary: "Edit", secondary: "ChurnSchedule.tsx", mono: true, add: 74, del: 41 },
|
||||
{ primary: "Run", secondary: "npm run freeze", mono: true },
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
function Dot({ tone }: { tone: string }) {
|
||||
return (
|
||||
<span className={`flex size-3.5 shrink-0 items-center justify-center rounded-full text-white ${tone}`}>
|
||||
<svg width="9" height="9" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5">
|
||||
<circle cx="12" cy="12" r="9" />
|
||||
<path d="M3.5 12h17M12 3a14 14 0 0 1 0 18M12 3a14 14 0 0 0 0 18" />
|
||||
</svg>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
const TONES = ["bg-accent", "bg-orange", "bg-green"];
|
||||
|
||||
export default function ThinkingState({
|
||||
variant = "Steps",
|
||||
onSettled,
|
||||
rows,
|
||||
active,
|
||||
done,
|
||||
icon,
|
||||
}: {
|
||||
variant?: string;
|
||||
onSettled?: () => void;
|
||||
/** override the built-in trace content (keeps the primitive reusable) */
|
||||
rows?: Row[];
|
||||
active?: string;
|
||||
done?: string;
|
||||
/** override the header glyph (defaults to the sparkle) */
|
||||
icon?: ReactNode;
|
||||
}) {
|
||||
const stage = useSequence(STAGES);
|
||||
const [manualExpanded, setManualExpanded] = useState<boolean | null>(null);
|
||||
const [selectedTool, setSelectedTool] = useState<string | null>(null);
|
||||
const base = VARIANTS[variant] ?? VARIANTS.Steps;
|
||||
const v = {
|
||||
...base,
|
||||
rows: rows ?? base.rows,
|
||||
active: active ?? base.active,
|
||||
done: done ?? base.done,
|
||||
};
|
||||
const autoExpanded = stage >= 1 && stage < 4;
|
||||
const expanded = manualExpanded ?? autoExpanded;
|
||||
const working = stage < 3;
|
||||
const visible = stage < 2 ? 0 : stage === 2 ? Math.min(2, v.rows.length) : v.rows.length;
|
||||
const traceRef = useRef<HTMLDivElement>(null);
|
||||
const [lineHeight, setLineHeight] = useState(0);
|
||||
useLayoutEffect(() => {
|
||||
if (traceRef.current) setLineHeight(traceRef.current.offsetHeight);
|
||||
}, [visible, expanded, variant, stage]);
|
||||
|
||||
/* let embedders sequence content after the trace settles */
|
||||
const settledRef = useRef(false);
|
||||
useEffect(() => {
|
||||
if (working || settledRef.current) return;
|
||||
settledRef.current = true;
|
||||
onSettled?.();
|
||||
}, [working, onSettled]);
|
||||
|
||||
return (
|
||||
<div
|
||||
key={variant}
|
||||
className="flex w-full max-w-95 flex-col"
|
||||
style={{
|
||||
minHeight: working || expanded ? 176 : undefined,
|
||||
transition: "min-height 400ms cubic-bezier(0.23,1,0.32,1)",
|
||||
}}
|
||||
>
|
||||
{/* header — shared across variants */}
|
||||
<button
|
||||
type="button"
|
||||
aria-expanded={expanded}
|
||||
onClick={() => setManualExpanded((current) => !(current ?? autoExpanded))}
|
||||
className="-mx-1.5 flex w-fit items-center gap-2 rounded-control px-1.5 py-1
|
||||
transition-colors duration-100 hover:bg-hover-2"
|
||||
>
|
||||
{icon ? (
|
||||
<span className="flex shrink-0 transition-colors duration-200" style={{ color: working ? "var(--ink-2)" : "var(--ink-3)" }}>
|
||||
{icon}
|
||||
</span>
|
||||
) : (
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill={working ? "var(--ink-2)" : "var(--ink-3)"}>
|
||||
<path d="M12 2l2.4 7.2L22 12l-7.6 2.8L12 22l-2.4-7.2L2 12l7.6-2.8z" />
|
||||
</svg>
|
||||
)}
|
||||
<span role="status" className="contents">
|
||||
{working ? (
|
||||
<span
|
||||
className="bg-clip-text text-[13px] font-medium whitespace-nowrap text-transparent"
|
||||
style={{
|
||||
backgroundImage:
|
||||
"linear-gradient(90deg, var(--ink-3) 35%, var(--ink) 50%, var(--ink-3) 65%)",
|
||||
backgroundSize: "200% 100%",
|
||||
animation: "shimmer-text 1.4s linear infinite",
|
||||
}}
|
||||
>
|
||||
{v.active}
|
||||
</span>
|
||||
) : (
|
||||
<span
|
||||
className="text-[13px] font-medium whitespace-nowrap text-ink-2"
|
||||
style={{ animation: "fade-in 350ms ease-out both" }}
|
||||
>
|
||||
{v.done}
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
<svg
|
||||
width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="var(--ink-3)" strokeWidth="2.2" strokeLinecap="round" strokeLinejoin="round"
|
||||
className="transition-transform duration-300"
|
||||
style={{ transform: expanded ? "rotate(180deg)" : "rotate(0)" }}
|
||||
>
|
||||
<path d="M6 9l6 6 6-6" />
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
{/* expandable trace */}
|
||||
<div
|
||||
className="grid transition-[grid-template-rows,opacity] duration-400"
|
||||
style={{
|
||||
gridTemplateRows: expanded ? "1fr" : "0fr",
|
||||
opacity: expanded ? 1 : 0,
|
||||
transitionTimingFunction: "cubic-bezier(0.23, 1, 0.32, 1)",
|
||||
}}
|
||||
>
|
||||
<div className="overflow-hidden">
|
||||
<div className="relative mt-1 ml-[5px] pl-4">
|
||||
<span
|
||||
aria-hidden
|
||||
className="absolute left-[3px] w-px bg-line"
|
||||
style={{ top: -8, height: lineHeight ? lineHeight - 2 : 0, transition: "height 500ms cubic-bezier(0.23,1,0.32,1)" }}
|
||||
/>
|
||||
<div ref={traceRef} className="flex flex-col gap-1 py-1">
|
||||
{v.query && (
|
||||
<div className="flex h-6 items-center gap-2 px-1.5" style={{ animation: expanded ? "fade-up 300ms cubic-bezier(0.23,1,0.32,1) both" : undefined }}>
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="var(--ink-3)" strokeWidth="2" strokeLinecap="round" className="shrink-0">
|
||||
<circle cx="11" cy="11" r="7" />
|
||||
<path d="M21 21l-4.3-4.3" />
|
||||
</svg>
|
||||
<span className="text-[12.5px] text-ink-2">{v.query}</span>
|
||||
</div>
|
||||
)}
|
||||
{v.rows.slice(0, visible).map((row, i) => {
|
||||
const content = (
|
||||
<>
|
||||
{variant === "Search" && <Dot tone={TONES[i % 3]} />}
|
||||
{variant === "Steps" && (
|
||||
i < visible - 1 || !working ? (
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="var(--ink-3)" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round" className="shrink-0">
|
||||
<path d="M20 6L9 17l-5-5" />
|
||||
</svg>
|
||||
) : (
|
||||
<span className="size-3 shrink-0 rounded-full border-[1.5px] border-line-strong border-t-ink-2" style={{ animation: "spin 700ms linear infinite" }} />
|
||||
)
|
||||
)}
|
||||
<span className={`min-w-0 truncate text-[12.5px] ${variant === "Reasoning" ? "whitespace-normal leading-relaxed text-ink-2" : "font-medium text-ink"} ${variant === "Search" ? "animated-underline" : ""}`}>
|
||||
{row.primary}
|
||||
</span>
|
||||
{row.secondary && (
|
||||
<span className={`shrink-0 text-[11.5px] text-ink-3 ${row.mono ? "font-mono" : ""}`}>
|
||||
{row.secondary}
|
||||
</span>
|
||||
)}
|
||||
{row.add !== undefined && (
|
||||
<span className="shrink-0 font-mono text-[11px] tabular-nums">
|
||||
<span className="text-green">+{row.add}</span>{" "}
|
||||
<span className="text-red">−{row.del}</span>
|
||||
</span>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
const rowClass = "flex min-h-7 w-full items-center gap-2 rounded-[6px] px-1.5 py-0.5 text-left";
|
||||
const animation = { animation: `fade-up 320ms cubic-bezier(0.23,1,0.32,1) ${i * 120}ms both` };
|
||||
|
||||
if (variant === "Search") {
|
||||
return (
|
||||
<a
|
||||
key={row.primary}
|
||||
href={row.href}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className={`${rowClass} transition-colors duration-150 hover:bg-hover`}
|
||||
style={animation}
|
||||
>
|
||||
{content}
|
||||
</a>
|
||||
);
|
||||
}
|
||||
|
||||
if (variant === "Coding") {
|
||||
const selected = selectedTool === row.primary;
|
||||
return (
|
||||
<button
|
||||
key={row.primary}
|
||||
type="button"
|
||||
aria-pressed={selected}
|
||||
onClick={() => setSelectedTool(selected ? null : row.primary)}
|
||||
className={`${rowClass} transition-colors duration-150 ${selected ? "bg-inset" : "hover:bg-hover"}`}
|
||||
style={animation}
|
||||
>
|
||||
{content}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div key={row.primary} className={rowClass} style={animation}>
|
||||
{content}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
{variant === "Search" && stage >= 3 && (
|
||||
<span className="text-[12px] text-ink-3" style={{ animation: "fade-in 300ms ease-out both" }}>
|
||||
+7 more
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
/* Spike #293 — dev-only entry. Not part of the production bundle. */
|
||||
import { StrictMode } from "react";
|
||||
import { createRoot } from "react-dom/client";
|
||||
import "@/spike/beautifui/foundation.css";
|
||||
import SpikePage from "@/spike/SpikePage";
|
||||
|
||||
createRoot(document.getElementById("root")!).render(
|
||||
<StrictMode>
|
||||
<SpikePage />
|
||||
</StrictMode>,
|
||||
);
|
||||
@@ -0,0 +1,91 @@
|
||||
/* Spike #293 — PraisonAIUI-shaped mock event stream.
|
||||
*
|
||||
* These events use the real `SSEEvent` contract from `@/types` so the spike
|
||||
* proves the BeautifulUI primitives can render against the *actual* event
|
||||
* vocabulary the chat surface emits (see src/praisonaiui/templates/frontend/
|
||||
* plugins/views/chat.js and RunEventType in src/frontend/src/types.ts).
|
||||
*
|
||||
* Nothing here talks to a live socket — that is an explicit non-goal of the
|
||||
* spike. The timeline below is replayed by SpikePage with setTimeout to
|
||||
* emulate SSE deltas / tool lifecycle / a HITL approval request.
|
||||
*/
|
||||
import type { SSEEvent } from "@/types";
|
||||
|
||||
/** A scripted SSE frame plus the delay (ms) before it fires. */
|
||||
export type MockFrame = { at: number; event: SSEEvent };
|
||||
|
||||
/** approval_request payload shaped like a row from GET /api/approvals. */
|
||||
export type ApprovalRequest = {
|
||||
id: string;
|
||||
tool: string;
|
||||
args: Record<string, unknown>;
|
||||
questions: { q: string; type: "radio" | "check"; options: string[] }[];
|
||||
};
|
||||
|
||||
/* SSE-style text deltas — streamed word-by-word as `run_content`. */
|
||||
const ANSWER =
|
||||
"Pistachio is your fastest-growing flavor — sales are up 23% this month " +
|
||||
"and margins beat vanilla by 8 points. Stone-fruit flavors trend in the same range.";
|
||||
|
||||
function contentDeltas(startAt: number, stepMs = 55): MockFrame[] {
|
||||
const words = ANSWER.split(" ");
|
||||
return words.map((w, i) => ({
|
||||
at: startAt + i * stepMs,
|
||||
event: { type: "run_content", token: w + " ", content: w + " " } as SSEEvent,
|
||||
}));
|
||||
}
|
||||
|
||||
const LAST_DELTA_AT = 300 + ANSWER.split(" ").length * 55;
|
||||
|
||||
/* Reasoning + tool lifecycle, mapped to ThinkingState rows. */
|
||||
export const REASONING_STREAM: SSEEvent[] = [
|
||||
{ type: "reasoning_started" },
|
||||
{ type: "reasoning_step", step: "Reading flavor briefs", step_number: 1 },
|
||||
{ type: "tool_call_started", name: "sql.query", tool_call_id: "t1", args: { table: "sales" } },
|
||||
{ type: "tool_call_completed", name: "sql.query", tool_call_id: "t1", formatted_result: "6 flavors" },
|
||||
{ type: "reasoning_step", step: "Comparing tasting notes", step_number: 2 },
|
||||
{ type: "tool_call_started", name: "trends.lookup", tool_call_id: "t2", args: { window: "30d" } },
|
||||
{ type: "tool_call_completed", name: "trends.lookup", tool_call_id: "t2", formatted_result: "+23%" },
|
||||
{ type: "reasoning_completed" },
|
||||
];
|
||||
|
||||
/* approval_request payload — the /api/approvals shape a HITL gate would push. */
|
||||
export const APPROVAL_REQUEST: ApprovalRequest = {
|
||||
id: "appr_9f2",
|
||||
tool: "menu.publish",
|
||||
args: { flavors: 3, season: "winter" },
|
||||
questions: [
|
||||
{ q: "How many flavors should we launch?", type: "radio", options: ["Three (core line)", "Five (full case)", "Just one hero"] },
|
||||
{ q: "Which mix-ins should we stock?", type: "check", options: ["Chocolate chips", "Waffle bits", "Sprinkles"] },
|
||||
],
|
||||
};
|
||||
|
||||
/** Full scripted timeline for the streaming panel. */
|
||||
export function streamingTimeline(): MockFrame[] {
|
||||
return [
|
||||
{ at: 0, event: { type: "run_started" } as SSEEvent },
|
||||
...contentDeltas(300),
|
||||
{ at: LAST_DELTA_AT + 100, event: { type: "run_completed" } as SSEEvent },
|
||||
];
|
||||
}
|
||||
|
||||
/* ── Adapters: PraisonAIUI events → BeautifulUI primitive props ── */
|
||||
|
||||
/** run_content deltas → StreamingText tokens (`cite` marks a source chip slot). */
|
||||
export function tokensFromDeltas(frames: MockFrame[]): { text: string; cite?: boolean }[] {
|
||||
return frames
|
||||
.filter((f) => f.event.type === "run_content")
|
||||
.map((f) => ({ text: (f.event.token ?? f.event.content ?? "").trim() }));
|
||||
}
|
||||
|
||||
/** reasoning_step / tool_call_* → ThinkingState rows. */
|
||||
export function rowsFromReasoning(events: SSEEvent[]): { primary: string; secondary?: string; mono?: boolean }[] {
|
||||
const rows: { primary: string; secondary?: string; mono?: boolean }[] = [];
|
||||
for (const e of events) {
|
||||
if (e.type === "reasoning_step" && e.step) rows.push({ primary: e.step });
|
||||
else if (e.type === "tool_call_started" && e.name) rows.push({ primary: e.name, mono: true });
|
||||
else if (e.type === "tool_call_completed" && e.name)
|
||||
rows.push({ primary: e.name, secondary: e.formatted_result, mono: true });
|
||||
}
|
||||
return rows;
|
||||
}
|
||||
@@ -1,3 +1,3 @@
|
||||
"""Version information."""
|
||||
|
||||
__version__ = "0.3.143"
|
||||
__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:
|
||||
|
||||
@@ -155,12 +155,14 @@ class SDKFileDataStore(BaseDataStore):
|
||||
role = message.get("role", "user")
|
||||
content = message.get("content", "")
|
||||
|
||||
# UI-specific fields go into metadata
|
||||
metadata: dict[str, Any] = {}
|
||||
if "toolCalls" in message:
|
||||
metadata["toolCalls"] = message["toolCalls"]
|
||||
if "elements" in message:
|
||||
metadata["elements"] = message["elements"]
|
||||
# All non-core fields (toolCalls, elements, platform, sender, icon,
|
||||
# channel_id, …) are preserved in metadata so channel and voice
|
||||
# context survives reload — matching the SQLAlchemy backend.
|
||||
metadata: dict[str, Any] = {
|
||||
k: v
|
||||
for k, v in message.items()
|
||||
if k not in ("role", "content") and v is not None
|
||||
}
|
||||
|
||||
await asyncio.to_thread(
|
||||
self._store.add_message,
|
||||
@@ -290,10 +292,13 @@ class SDKFileDataStore(BaseDataStore):
|
||||
"role": msg.role,
|
||||
"content": msg.content,
|
||||
}
|
||||
# Restore UI-specific fields from metadata
|
||||
if "toolCalls" in meta:
|
||||
d["toolCalls"] = meta["toolCalls"]
|
||||
if "elements" in meta:
|
||||
d["elements"] = meta["elements"]
|
||||
# Restore all persisted metadata (toolCalls, elements, platform,
|
||||
# sender, icon, channel_id, …). Internal sentinel keys never
|
||||
# appear here (the sentinel message is skipped above), but guard
|
||||
# against them defensively without clobbering role/content.
|
||||
for k, v in meta.items():
|
||||
if k in (_META_KEY, _TITLE_KEY, _CUSTOM_META_KEY):
|
||||
continue
|
||||
d.setdefault(k, v)
|
||||
messages.append(d)
|
||||
return messages
|
||||
@@ -315,6 +315,13 @@ class ChannelsFeature(BaseFeatureProtocol):
|
||||
|
||||
Returns None on success, or an error string on failure.
|
||||
"""
|
||||
# Idempotent start (#277): stop any bot already running for this channel
|
||||
# before creating a new one. Without this, a toggle off/on, a re-add, or
|
||||
# an autostart+manual race leaves a duplicate bot with duplicate event
|
||||
# handlers attached while _live_bots only tracks the latest task.
|
||||
if channel_id in _live_bots:
|
||||
await self._stop_channel_bot(channel_id)
|
||||
|
||||
platform = entry["platform"]
|
||||
config = entry.get("config", {})
|
||||
fallback_errors: List[str] = []
|
||||
@@ -645,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
|
||||
|
||||
@@ -824,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:
|
||||
@@ -905,9 +923,15 @@ class ChannelsFeature(BaseFeatureProtocol):
|
||||
try:
|
||||
if hasattr(bot, "stop"):
|
||||
await bot.stop()
|
||||
# Cancel the task if still running
|
||||
# Cancel the task and await it so the old bot is fully torn down
|
||||
# before this returns — otherwise a restart (#277) can spawn a new
|
||||
# bot while the previous task is still cancelling (a zombie).
|
||||
if info and info.get("task") and not info["task"].done():
|
||||
info["task"].cancel()
|
||||
try:
|
||||
await info["task"]
|
||||
except asyncio.CancelledError:
|
||||
pass
|
||||
logger.info(f"Stopped bot for channel '{channel_id}'")
|
||||
except Exception as e:
|
||||
logger.error(f"Error stopping bot for '{channel_id}': {e}")
|
||||
@@ -918,6 +942,19 @@ class ChannelsFeature(BaseFeatureProtocol):
|
||||
gw = self._get_gateway()
|
||||
if gw is not None:
|
||||
getattr(gw, "_channel_bots", {}).pop(channel_id, None)
|
||||
# Mirror start-time registration: drop the task from the gateway
|
||||
# too, so a restart (#277) leaves no stale/cancelled task behind.
|
||||
# The dict form is overwritten on restart, but the list form would
|
||||
# otherwise accumulate a cancelled task on every restart.
|
||||
channel_tasks = getattr(gw, "_channel_tasks", None)
|
||||
task = info.get("task") if info else None
|
||||
if isinstance(channel_tasks, dict):
|
||||
channel_tasks.pop(channel_id, None)
|
||||
elif isinstance(channel_tasks, list) and task is not None:
|
||||
try:
|
||||
channel_tasks.remove(task)
|
||||
except ValueError:
|
||||
pass
|
||||
|
||||
# Update local state
|
||||
ch = _channels.get(channel_id)
|
||||
|
||||
@@ -462,6 +462,24 @@ async def _run_and_broadcast(
|
||||
collected_tool_calls: Dict[str, Dict] = {} # Merge STARTED+COMPLETED per tool_call_id
|
||||
collected_elements: List[Dict[str, Any]] = []
|
||||
|
||||
# Register this run so ``abort_run(run_id)`` can cancel it, and announce the
|
||||
# run to WS clients up-front. Without this the ``_active_runs`` registry was
|
||||
# never populated, so the Stop button and ``POST /api/chat/abort`` were
|
||||
# silent no-ops that always returned ``no_active_run`` (issue #274). The
|
||||
# ``run_started`` event also lets the frontend capture ``run_id`` and enable
|
||||
# the Stop button immediately, before the first token arrives.
|
||||
self_task = asyncio.current_task()
|
||||
if self_task is not None:
|
||||
mgr._active_runs[run_id] = self_task
|
||||
started_event: Dict[str, Any] = {
|
||||
"type": "run_started",
|
||||
"session_id": session_id,
|
||||
"run_id": run_id,
|
||||
}
|
||||
if agent_name:
|
||||
started_event["agent_name"] = agent_name
|
||||
await mgr.broadcast(session_id, started_event)
|
||||
|
||||
try:
|
||||
# Pass image attachments to provider for SDK native multimodal handling
|
||||
run_kwargs = {}
|
||||
@@ -644,6 +662,9 @@ async def _run_and_broadcast(
|
||||
"error": str(e),
|
||||
},
|
||||
)
|
||||
finally:
|
||||
# Always deregister the run, on normal completion, error, or abort.
|
||||
mgr._active_runs.pop(run_id, None)
|
||||
|
||||
# Save assistant response
|
||||
if full_response or collected_elements:
|
||||
|
||||
@@ -168,7 +168,9 @@ async def run_startup_hooks() -> None:
|
||||
"""Execute all registered startup hooks.
|
||||
|
||||
Called by the server during application startup.
|
||||
Blocks until all hooks complete.
|
||||
Has a timeout from AIUI_STARTUP_TIMEOUT (default 60s) so a hung hook
|
||||
cannot block the server from reaching the lifespan ``yield`` — startup is
|
||||
marked completed on timeout so ``/health/live`` still responds.
|
||||
"""
|
||||
if _lifecycle_state["startup_completed"]:
|
||||
_log.warning("Startup hooks already executed")
|
||||
@@ -177,15 +179,24 @@ async def run_startup_hooks() -> None:
|
||||
_log.info(f"Running {len(_startup_hooks)} startup hooks")
|
||||
start_time = time.perf_counter()
|
||||
|
||||
for hook in _startup_hooks:
|
||||
try:
|
||||
_log.debug(f"Executing startup hook: {getattr(hook, '__name__', str(hook))}")
|
||||
result = hook()
|
||||
if asyncio.iscoroutine(result):
|
||||
await result
|
||||
except Exception as e:
|
||||
_log.error(f"Startup hook failed: {getattr(hook, '__name__', str(hook))}: {e}")
|
||||
# Continue with other hooks - individual failures shouldn't break startup
|
||||
# Get startup timeout from environment (mirrors AIUI_SHUTDOWN_TIMEOUT)
|
||||
timeout = float(os.environ.get("AIUI_STARTUP_TIMEOUT", "60.0"))
|
||||
|
||||
async def _execute_hooks():
|
||||
for hook in _startup_hooks:
|
||||
try:
|
||||
_log.debug(f"Executing startup hook: {getattr(hook, '__name__', str(hook))}")
|
||||
result = hook()
|
||||
if asyncio.iscoroutine(result):
|
||||
await result
|
||||
except Exception as e:
|
||||
_log.error(f"Startup hook failed: {getattr(hook, '__name__', str(hook))}: {e}")
|
||||
# Continue with other hooks - individual failures shouldn't break startup
|
||||
|
||||
try:
|
||||
await asyncio.wait_for(_execute_hooks(), timeout=timeout)
|
||||
except asyncio.TimeoutError:
|
||||
_log.error(f"Startup hooks timed out after {timeout}s")
|
||||
|
||||
execution_time = time.perf_counter() - start_time
|
||||
_lifecycle_state.update(
|
||||
|
||||
@@ -9,6 +9,8 @@ Provides first-class MCP client functionality with:
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import contextlib
|
||||
import json
|
||||
import logging
|
||||
import subprocess
|
||||
@@ -109,50 +111,52 @@ class StdioMCPClient:
|
||||
self.args = args
|
||||
self.session: Optional[ClientSession] = None
|
||||
self._connected = False
|
||||
self._session_manager = None
|
||||
self._exit_stack: contextlib.AsyncExitStack | None = None
|
||||
# For test backward compatibility
|
||||
self.process: Optional[subprocess.Popen] = None
|
||||
|
||||
async def connect(self) -> bool:
|
||||
"""Connect via stdio subprocess using official MCP SDK."""
|
||||
# Store the stack on self before entering contexts so that a caller
|
||||
# cancellation (e.g. asyncio.wait_for timeout, which raises the
|
||||
# BaseException CancelledError past the except below) can still find
|
||||
# and close the partially-entered stack via disconnect().
|
||||
exit_stack = contextlib.AsyncExitStack()
|
||||
self._exit_stack = exit_stack
|
||||
try:
|
||||
# Use official MCP stdio_client instead of raw subprocess
|
||||
server_params = StdioServerParameters(command=self.command, args=self.args)
|
||||
|
||||
# Create a new session using stdio_client context manager
|
||||
session_manager = stdio_client(server_params)
|
||||
self.session = await session_manager.__aenter__()
|
||||
|
||||
# Initialize the MCP session
|
||||
read_stream, write_stream = await exit_stack.enter_async_context(
|
||||
stdio_client(server_params)
|
||||
)
|
||||
self.session = await exit_stack.enter_async_context(
|
||||
ClientSession(read_stream, write_stream)
|
||||
)
|
||||
await self.session.initialize()
|
||||
|
||||
# Store the context manager for cleanup
|
||||
self._session_manager = session_manager
|
||||
|
||||
self._connected = True
|
||||
logger.info(f"Connected to MCP stdio server: {self.command}")
|
||||
return True
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to connect to MCP stdio server: {e}")
|
||||
await exit_stack.aclose()
|
||||
self._exit_stack = None
|
||||
self._connected = False
|
||||
self.session = None
|
||||
return False
|
||||
|
||||
async def disconnect(self) -> None:
|
||||
"""Properly disconnect the MCP session."""
|
||||
if self.session and self._connected:
|
||||
if self._exit_stack:
|
||||
try:
|
||||
# Properly exit the context manager
|
||||
if hasattr(self, "_session_manager"):
|
||||
await self._session_manager.__aexit__(None, None, None)
|
||||
else:
|
||||
await self.session.close()
|
||||
await self._exit_stack.aclose()
|
||||
except Exception as e:
|
||||
logger.warning(f"Error during MCP session close: {e}")
|
||||
finally:
|
||||
self._exit_stack = None
|
||||
self.session = None
|
||||
self._connected = False
|
||||
self.process = None # Reset for backward compatibility
|
||||
self.process = None
|
||||
|
||||
async def list_tools(self) -> List[ToolInfo]:
|
||||
"""List tools via proper MCP protocol."""
|
||||
@@ -203,42 +207,45 @@ class SSEMCPClient:
|
||||
self.headers = headers or {}
|
||||
self.session: Optional[ClientSession] = None
|
||||
self._connected = False
|
||||
self._session_manager = None
|
||||
self._exit_stack: contextlib.AsyncExitStack | None = None
|
||||
|
||||
async def connect(self) -> bool:
|
||||
"""Connect via SSE using official MCP SDK."""
|
||||
# Store the stack on self before entering contexts so a caller
|
||||
# cancellation (e.g. asyncio.wait_for timeout) can still close the
|
||||
# partially-entered stack via disconnect(). See StdioMCPClient.connect.
|
||||
exit_stack = contextlib.AsyncExitStack()
|
||||
self._exit_stack = exit_stack
|
||||
try:
|
||||
# Use official MCP sse_client context manager
|
||||
session_manager = sse_client(self.url, headers=self.headers)
|
||||
self.session = await session_manager.__aenter__()
|
||||
|
||||
# Initialize the MCP session
|
||||
read_stream, write_stream = await exit_stack.enter_async_context(
|
||||
sse_client(self.url, headers=self.headers)
|
||||
)
|
||||
self.session = await exit_stack.enter_async_context(
|
||||
ClientSession(read_stream, write_stream)
|
||||
)
|
||||
await self.session.initialize()
|
||||
|
||||
# Store the context manager for cleanup
|
||||
self._session_manager = session_manager
|
||||
|
||||
self._connected = True
|
||||
logger.info(f"Connected to MCP SSE server at {self.url}")
|
||||
return True
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to connect to MCP SSE server: {e}")
|
||||
await exit_stack.aclose()
|
||||
self._exit_stack = None
|
||||
self._connected = False
|
||||
self.session = None
|
||||
return False
|
||||
|
||||
async def disconnect(self) -> None:
|
||||
"""Properly disconnect SSE."""
|
||||
if self.session and self._connected:
|
||||
if self._exit_stack:
|
||||
try:
|
||||
# Properly exit the context manager
|
||||
if hasattr(self, "_session_manager") and self._session_manager:
|
||||
await self._session_manager.__aexit__(None, None, None)
|
||||
else:
|
||||
await self.session.close()
|
||||
await self._exit_stack.aclose()
|
||||
except Exception as e:
|
||||
logger.warning(f"Error during MCP SSE session close: {e}")
|
||||
finally:
|
||||
self._exit_stack = None
|
||||
self.session = None
|
||||
self._connected = False
|
||||
|
||||
@@ -354,6 +361,13 @@ class MCPClientManager:
|
||||
connection_data=server_config,
|
||||
)
|
||||
|
||||
# Disconnect any existing client for this name (reconnect) before
|
||||
# replacing it, otherwise the previous stdio subprocess/streams are
|
||||
# orphaned when the reference is overwritten.
|
||||
previous_client = self._clients.pop(name, None)
|
||||
if previous_client is not None:
|
||||
await self._safe_disconnect(name, previous_client)
|
||||
|
||||
# Register server
|
||||
_mcp_servers[name] = server
|
||||
self._clients[name] = client
|
||||
@@ -362,8 +376,7 @@ class MCPClientManager:
|
||||
await self._notify_status_change(server)
|
||||
|
||||
try:
|
||||
# Attempt connection
|
||||
connected = await client.connect()
|
||||
connected = await asyncio.wait_for(client.connect(), timeout=120.0)
|
||||
if connected:
|
||||
server.status = MCPStatus.CONNECTED
|
||||
server.tools = await client.list_tools()
|
||||
@@ -374,16 +387,38 @@ class MCPClientManager:
|
||||
else:
|
||||
server.status = MCPStatus.ERROR
|
||||
server.last_error = "Connection failed"
|
||||
await self._cleanup_failed_client(name, client)
|
||||
|
||||
except asyncio.TimeoutError:
|
||||
logger.error("Timed out connecting MCP server %s", name)
|
||||
server.status = MCPStatus.ERROR
|
||||
server.last_error = "Connection timed out after 120s (check npx/Node and server args)"
|
||||
await client.disconnect()
|
||||
except Exception as e:
|
||||
logger.exception(f"Failed to connect MCP server {name}")
|
||||
server.status = MCPStatus.ERROR
|
||||
server.last_error = str(e)
|
||||
await self._cleanup_failed_client(name, client)
|
||||
|
||||
# Notify final status
|
||||
await self._notify_status_change(server)
|
||||
return server
|
||||
|
||||
async def _safe_disconnect(self, name: str, client: MCPClientProtocol) -> None:
|
||||
"""Disconnect a client, logging (not raising) on failure."""
|
||||
try:
|
||||
await client.disconnect()
|
||||
except Exception:
|
||||
logger.exception(f"Error disconnecting MCP client {name}")
|
||||
|
||||
async def _cleanup_failed_client(self, name: str, client: MCPClientProtocol) -> None:
|
||||
"""Release a client whose connection failed and drop it from the registry."""
|
||||
await self._safe_disconnect(name, client)
|
||||
# Only remove if this client is still the registered one (avoid evicting
|
||||
# a newer client from a concurrent reconnect).
|
||||
if self._clients.get(name) is client:
|
||||
del self._clients[name]
|
||||
|
||||
async def disconnect_server(
|
||||
self, name: str, session_context: Optional[Dict[str, Any]] = None
|
||||
) -> bool:
|
||||
|
||||
@@ -252,6 +252,7 @@ window.aiui.registerSurfaceRenderer = function(surfaceId, renderFn) {
|
||||
window.aiui.surfaces = SURFACE_REGISTRY;
|
||||
window.aiui.version = '1';
|
||||
window.aiui.components = COMPONENT_REGISTRY;
|
||||
window.aiui.renderComponent = renderComponent;
|
||||
window.aiui.registerSlot = function (name, renderFn) { SLOT_REGISTRY[name] = renderFn; };
|
||||
window.aiui.slots = SLOT_REGISTRY;
|
||||
window.aiui.sdk = aiuiSdk;
|
||||
|
||||
@@ -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>';
|
||||
|
||||
@@ -116,7 +116,7 @@ function openConnectForm(host, container) {
|
||||
<h3 style="margin:0 0 16px;font-size:14px;font-weight:600">Connect MCP Server</h3>
|
||||
<div class="db-form-group">
|
||||
<label class="db-form-label">Name</label>
|
||||
<input id="mcp-f-name" class="db-form-input" placeholder="my-server">
|
||||
<input id="mcp-f-name" class="db-form-input" value="everything-demo" placeholder="my-server">
|
||||
</div>
|
||||
<div class="db-form-group">
|
||||
<label class="db-form-label">Transport</label>
|
||||
@@ -128,11 +128,11 @@ function openConnectForm(host, container) {
|
||||
<div id="mcp-f-stdio">
|
||||
<div class="db-form-group">
|
||||
<label class="db-form-label">Command</label>
|
||||
<input id="mcp-f-command" class="db-form-input" placeholder="npx">
|
||||
<input id="mcp-f-command" class="db-form-input" value="npx" placeholder="npx" required>
|
||||
</div>
|
||||
<div class="db-form-group">
|
||||
<label class="db-form-label">Arguments (space-separated)</label>
|
||||
<input id="mcp-f-args" class="db-form-input" placeholder="-y @modelcontextprotocol/server-filesystem /tmp">
|
||||
<input id="mcp-f-args" class="db-form-input" value="-y @modelcontextprotocol/server-everything" placeholder="-y @modelcontextprotocol/server-everything">
|
||||
</div>
|
||||
</div>
|
||||
<div id="mcp-f-url" style="display:none">
|
||||
|
||||
@@ -144,6 +144,74 @@ async def test_auto_start_skips_disabled_channels():
|
||||
assert mock_start.call_count == 0
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_start_channel_bot_is_idempotent_stops_existing_first():
|
||||
"""Regression for #277: starting a bot for a channel that already has a
|
||||
live bot must stop the existing one first so no duplicate bot/task lingers.
|
||||
"""
|
||||
feature = ChannelsFeature()
|
||||
gw = _GatewayDictTasks()
|
||||
entry = {"id": "ch6", "platform": "discord", "config": {"bot_token": "x" * 30}}
|
||||
|
||||
with patch.object(feature, "_get_gateway", return_value=gw), patch.object(
|
||||
feature, "_create_bot_direct", side_effect=lambda *a, **k: _FakeBot()
|
||||
), patch.object(feature, "_attach_chat_bridge"):
|
||||
await feature._start_channel_bot("ch6", entry)
|
||||
first = channels_mod._live_bots["ch6"]
|
||||
|
||||
await feature._start_channel_bot("ch6", entry)
|
||||
second = channels_mod._live_bots["ch6"]
|
||||
|
||||
# A new bot/task replaced the first, and only one entry is tracked.
|
||||
assert second is not first
|
||||
assert first["bot"] is not second["bot"]
|
||||
assert list(channels_mod._live_bots.keys()).count("ch6") == 1
|
||||
# The first task was cancelled by the pre-start _stop_channel_bot guard.
|
||||
assert first["task"].cancelled() or first["task"].done()
|
||||
# Gateway has exactly one (fresh) bot reference — no stale entry.
|
||||
assert gw._channel_bots["ch6"] is second["bot"]
|
||||
# ...and exactly one (fresh) task reference in the dict registry.
|
||||
assert gw._channel_tasks["ch6"] is second["task"]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_restart_leaves_no_stale_task_in_list_gateway():
|
||||
"""Regression for #277: with a list-based gateway _channel_tasks, a restart
|
||||
must not accumulate the old cancelled task alongside the fresh one.
|
||||
"""
|
||||
feature = ChannelsFeature()
|
||||
gw = _GatewayListTasks()
|
||||
entry = {"id": "ch8", "platform": "discord", "config": {"bot_token": "x" * 30}}
|
||||
|
||||
with patch.object(feature, "_get_gateway", return_value=gw), patch.object(
|
||||
feature, "_create_bot_direct", side_effect=lambda *a, **k: _FakeBot()
|
||||
), patch.object(feature, "_attach_chat_bridge"):
|
||||
await feature._start_channel_bot("ch8", entry)
|
||||
await feature._start_channel_bot("ch8", entry)
|
||||
current = channels_mod._live_bots["ch8"]
|
||||
|
||||
# Only the fresh task remains in the list — the cancelled one was removed.
|
||||
assert gw._channel_tasks == [current["task"]]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_start_channel_bot_no_existing_does_not_stop():
|
||||
"""Guard must be a no-op on first start (nothing in _live_bots)."""
|
||||
feature = ChannelsFeature()
|
||||
gw = _GatewayDictTasks()
|
||||
entry = {"id": "ch7", "platform": "discord", "config": {"bot_token": "x" * 30}}
|
||||
|
||||
with patch.object(feature, "_get_gateway", return_value=gw), patch.object(
|
||||
feature, "_create_bot_direct", return_value=_FakeBot()
|
||||
), patch.object(feature, "_attach_chat_bridge"), patch.object(
|
||||
feature, "_stop_channel_bot"
|
||||
) as mock_stop:
|
||||
error = await feature._start_channel_bot("ch7", entry)
|
||||
|
||||
assert error is None
|
||||
mock_stop.assert_not_called()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_auto_start_failure_sets_not_running_and_error():
|
||||
feature = ChannelsFeature()
|
||||
|
||||
@@ -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,145 @@
|
||||
"""Tests for chat run abort / cancellation (issue #274).
|
||||
|
||||
Regression coverage for the wiring bug where ``ChatManager._active_runs`` was
|
||||
never populated, so ``abort_run`` (Stop button / ``POST /api/chat/abort`` /
|
||||
``chat_abort`` WS) always returned ``no_active_run`` even while a run was
|
||||
streaming.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
|
||||
import pytest
|
||||
|
||||
from praisonaiui.provider import RunEvent, RunEventType
|
||||
|
||||
# ── Unit: abort_run against the _active_runs registry ────────────────
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_abort_cancels_active_run():
|
||||
from praisonaiui.features.chat import ChatManager
|
||||
|
||||
mgr = ChatManager()
|
||||
run_id = "test-run"
|
||||
|
||||
async def slow_run():
|
||||
await asyncio.sleep(60)
|
||||
|
||||
task = asyncio.create_task(slow_run())
|
||||
mgr._active_runs[run_id] = task
|
||||
|
||||
result = await mgr.abort_run(run_id)
|
||||
|
||||
assert result["status"] == "aborted"
|
||||
assert result["run_id"] == run_id
|
||||
# Give the event loop a tick to process the cancellation.
|
||||
await asyncio.sleep(0)
|
||||
assert task.cancelled() or task.done()
|
||||
# Registry entry removed on abort.
|
||||
assert run_id not in mgr._active_runs
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_abort_unknown_run_returns_no_active_run():
|
||||
from praisonaiui.features.chat import ChatManager
|
||||
|
||||
mgr = ChatManager()
|
||||
result = await mgr.abort_run("does-not-exist")
|
||||
assert result["status"] == "no_active_run"
|
||||
assert result["run_id"] == "does-not-exist"
|
||||
|
||||
|
||||
# ── Integration: _run_and_broadcast registers + aborts cleanly ───────
|
||||
|
||||
|
||||
class _FakeWS:
|
||||
"""Collects broadcast frames pushed by ChatManager.broadcast."""
|
||||
|
||||
def __init__(self) -> None:
|
||||
self.messages: list[dict] = []
|
||||
|
||||
async def send_text(self, data: str) -> None:
|
||||
import json
|
||||
|
||||
self.messages.append(json.loads(data))
|
||||
|
||||
|
||||
class _SlowProvider:
|
||||
"""Streams a couple of tokens then hangs, keeping the run active."""
|
||||
|
||||
async def run(self, content, *, session_id=None, agent_name=None, **kwargs):
|
||||
yield RunEvent(type=RunEventType.RUN_CONTENT, token="hello ")
|
||||
yield RunEvent(type=RunEventType.RUN_CONTENT, token="world")
|
||||
# Simulate a long generation so the run stays registered.
|
||||
await asyncio.sleep(60)
|
||||
yield RunEvent(type=RunEventType.RUN_COMPLETED, content="hello world")
|
||||
|
||||
|
||||
class _FakeDatastore:
|
||||
async def add_message(self, *args, **kwargs):
|
||||
return None
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_run_and_broadcast_registers_and_aborts(monkeypatch):
|
||||
import praisonaiui.server as server
|
||||
from praisonaiui.features.chat import ChatManager, _run_and_broadcast, set_chat_manager
|
||||
|
||||
mgr = ChatManager()
|
||||
set_chat_manager(mgr)
|
||||
ws = _FakeWS()
|
||||
mgr.add_ws_client("c1", ws)
|
||||
|
||||
monkeypatch.setattr(server, "get_provider", lambda: _SlowProvider())
|
||||
monkeypatch.setattr(server, "_datastore", _FakeDatastore())
|
||||
|
||||
task = asyncio.create_task(_run_and_broadcast("hi", "sess-1", "Agent", None))
|
||||
|
||||
# Wait until the run is registered (populated by _run_and_broadcast).
|
||||
for _ in range(100):
|
||||
if mgr._active_runs:
|
||||
break
|
||||
await asyncio.sleep(0.01)
|
||||
assert mgr._active_runs, "run was never registered in _active_runs"
|
||||
|
||||
run_id = next(iter(mgr._active_runs))
|
||||
|
||||
# Frontend contract: a run_started frame is broadcast with the run_id.
|
||||
started = [m for m in ws.messages if m.get("type") == "run_started"]
|
||||
assert started and started[0]["run_id"] == run_id
|
||||
|
||||
# Abort the active run.
|
||||
result = await mgr.abort_run(run_id)
|
||||
assert result["status"] == "aborted"
|
||||
|
||||
# Let the cancellation propagate and _run_and_broadcast finish.
|
||||
await asyncio.sleep(0.05)
|
||||
|
||||
# Clients receive a run_cancelled frame, and the registry is cleaned up.
|
||||
cancelled = [m for m in ws.messages if m.get("type") == "run_cancelled"]
|
||||
assert cancelled and cancelled[0]["run_id"] == run_id
|
||||
assert run_id not in mgr._active_runs
|
||||
assert task.done()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_run_and_broadcast_cleans_up_on_normal_completion(monkeypatch):
|
||||
import praisonaiui.server as server
|
||||
from praisonaiui.features.chat import ChatManager, _run_and_broadcast, set_chat_manager
|
||||
|
||||
class _QuickProvider:
|
||||
async def run(self, content, *, session_id=None, agent_name=None, **kwargs):
|
||||
yield RunEvent(type=RunEventType.RUN_CONTENT, token="done")
|
||||
yield RunEvent(type=RunEventType.RUN_COMPLETED, content="done")
|
||||
|
||||
mgr = ChatManager()
|
||||
set_chat_manager(mgr)
|
||||
monkeypatch.setattr(server, "get_provider", lambda: _QuickProvider())
|
||||
monkeypatch.setattr(server, "_datastore", _FakeDatastore())
|
||||
|
||||
await _run_and_broadcast("hi", "sess-2", None, None)
|
||||
|
||||
# No leftover registry entries after a normal run.
|
||||
assert mgr._active_runs == {}
|
||||
@@ -117,6 +117,51 @@ async def test_elements_persisted_in_metadata(adapter):
|
||||
assert messages[0]["elements"] == elements
|
||||
|
||||
|
||||
# ── Channel metadata round-trip ──────────────────────────────────────
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_channel_metadata_round_trip(adapter):
|
||||
"""platform/sender/icon/channel_id survive a write→read cycle."""
|
||||
sid = str(uuid.uuid4())
|
||||
await adapter.create_session(sid)
|
||||
await adapter.add_message(sid, {
|
||||
"role": "user",
|
||||
"content": "[Bob] hi",
|
||||
"platform": "discord",
|
||||
"sender": "Bob",
|
||||
"icon": "game",
|
||||
"channel_id": "ch-99",
|
||||
})
|
||||
|
||||
messages = await adapter.get_messages(sid)
|
||||
assert len(messages) == 1
|
||||
assert messages[0]["platform"] == "discord"
|
||||
assert messages[0]["sender"] == "Bob"
|
||||
assert messages[0]["icon"] == "game"
|
||||
assert messages[0]["channel_id"] == "ch-99"
|
||||
# Core fields still intact
|
||||
assert messages[0]["role"] == "user"
|
||||
assert messages[0]["content"] == "[Bob] hi"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_none_metadata_values_dropped(adapter):
|
||||
"""None-valued fields are not persisted (kept out of the payload)."""
|
||||
sid = str(uuid.uuid4())
|
||||
await adapter.create_session(sid)
|
||||
await adapter.add_message(sid, {
|
||||
"role": "assistant",
|
||||
"content": "reply",
|
||||
"platform": "voice",
|
||||
"sender": None,
|
||||
})
|
||||
|
||||
messages = await adapter.get_messages(sid)
|
||||
assert messages[0]["platform"] == "voice"
|
||||
assert "sender" not in messages[0]
|
||||
|
||||
|
||||
# ── Title auto-generation ────────────────────────────────────────────
|
||||
|
||||
|
||||
|
||||
@@ -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
|
||||
@@ -246,6 +246,25 @@ class TestLifecycleHooks:
|
||||
assert elapsed < 0.5
|
||||
assert _lifecycle_state["shutdown_initiated"] is True
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_startup_timeout(self):
|
||||
"""Test startup timeout mechanism — a hung hook must not block forever."""
|
||||
|
||||
@on_app_startup
|
||||
async def slow_hook():
|
||||
await asyncio.sleep(2) # Simulate hung startup
|
||||
|
||||
# Set short timeout
|
||||
with patch.dict(os.environ, {"AIUI_STARTUP_TIMEOUT": "0.1"}):
|
||||
start_time = time.time()
|
||||
await run_startup_hooks()
|
||||
elapsed = time.time() - start_time
|
||||
|
||||
# Should timeout quickly and still mark startup completed so the
|
||||
# server reaches lifespan yield and /health/live responds
|
||||
assert elapsed < 0.5
|
||||
assert _lifecycle_state["startup_completed"] is True
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_startup_idempotent(self):
|
||||
"""Test that startup hooks are only run once."""
|
||||
|
||||
+77
-20
@@ -110,18 +110,27 @@ class TestStdioMCPClient:
|
||||
"""Test successful stdio connection."""
|
||||
client = StdioMCPClient("echo", ["test"])
|
||||
|
||||
with patch("praisonaiui.features.mcp.stdio_client") as mock_stdio_client:
|
||||
# Mock the context manager
|
||||
mock_session_manager = AsyncMock()
|
||||
with (
|
||||
patch("praisonaiui.features.mcp.stdio_client") as mock_stdio_client,
|
||||
patch("praisonaiui.features.mcp.ClientSession") as mock_client_session,
|
||||
patch("praisonaiui.features.mcp.contextlib.AsyncExitStack") as mock_stack_cls,
|
||||
):
|
||||
mock_read = MagicMock()
|
||||
mock_write = MagicMock()
|
||||
mock_session = AsyncMock()
|
||||
mock_session_manager.__aenter__.return_value = mock_session
|
||||
mock_stdio_client.return_value = mock_session_manager
|
||||
mock_stack = AsyncMock()
|
||||
mock_stack.enter_async_context = AsyncMock(
|
||||
side_effect=[(mock_read, mock_write), mock_session]
|
||||
)
|
||||
mock_stack_cls.return_value = mock_stack
|
||||
mock_stdio_client.return_value = MagicMock()
|
||||
mock_client_session.return_value = mock_session
|
||||
|
||||
result = await client.connect()
|
||||
|
||||
assert result is True
|
||||
assert client.session == mock_session
|
||||
mock_stdio_client.assert_called_once()
|
||||
assert mock_stack.enter_async_context.await_count == 2
|
||||
mock_session.initialize.assert_called_once()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@@ -138,16 +147,13 @@ class TestStdioMCPClient:
|
||||
"""Test stdio disconnect."""
|
||||
client = StdioMCPClient("echo", ["test"])
|
||||
|
||||
# Mock session and session manager
|
||||
mock_session = AsyncMock()
|
||||
mock_session_manager = AsyncMock()
|
||||
client.session = mock_session
|
||||
client._session_manager = mock_session_manager
|
||||
mock_stack = AsyncMock()
|
||||
client._exit_stack = mock_stack
|
||||
client._connected = True
|
||||
|
||||
await client.disconnect()
|
||||
|
||||
mock_session_manager.__aexit__.assert_called_once_with(None, None, None)
|
||||
mock_stack.aclose.assert_called_once()
|
||||
assert client.session is None
|
||||
assert client.process is None
|
||||
|
||||
@@ -156,18 +162,14 @@ class TestStdioMCPClient:
|
||||
"""Test stdio disconnect with exception handling."""
|
||||
client = StdioMCPClient("echo", ["test"])
|
||||
|
||||
# Mock session that throws exception during disconnect
|
||||
mock_session = AsyncMock()
|
||||
mock_session_manager = AsyncMock()
|
||||
mock_session_manager.__aexit__.side_effect = Exception("Disconnect failed")
|
||||
client.session = mock_session
|
||||
client._session_manager = mock_session_manager
|
||||
mock_stack = AsyncMock()
|
||||
mock_stack.aclose.side_effect = Exception("Disconnect failed")
|
||||
client._exit_stack = mock_stack
|
||||
client._connected = True
|
||||
|
||||
# Should not raise exception
|
||||
await client.disconnect()
|
||||
|
||||
mock_session_manager.__aexit__.assert_called_once()
|
||||
mock_stack.aclose.assert_called_once()
|
||||
assert client.session is None
|
||||
assert client.process is None
|
||||
|
||||
@@ -347,6 +349,61 @@ class TestMCPClientManager:
|
||||
with pytest.raises(ValueError, match="Invalid MCP server config"):
|
||||
await manager.connect_server(config)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_reconnect_same_name_disconnects_old(self):
|
||||
"""Reconnecting the same name disconnects the previous client (no leak)."""
|
||||
manager = MCPClientManager()
|
||||
|
||||
old_client = AsyncMock()
|
||||
old_client.disconnect = AsyncMock()
|
||||
manager._clients["srv"] = old_client
|
||||
|
||||
config = {"name": "srv", "command": "echo", "args": []}
|
||||
with patch("praisonaiui.features.mcp.StdioMCPClient") as mock_client_class:
|
||||
new_client = AsyncMock()
|
||||
new_client.connect.return_value = True
|
||||
new_client.list_tools.return_value = []
|
||||
mock_client_class.return_value = new_client
|
||||
|
||||
await manager.connect_server(config)
|
||||
|
||||
old_client.disconnect.assert_awaited_once()
|
||||
assert manager._clients["srv"] is new_client
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_connect_failure_removes_client(self):
|
||||
"""A failed connect (returns False) removes the client from the registry."""
|
||||
manager = MCPClientManager()
|
||||
config = {"name": "srv", "command": "echo", "args": []}
|
||||
|
||||
with patch("praisonaiui.features.mcp.StdioMCPClient") as mock_client_class:
|
||||
client = AsyncMock()
|
||||
client.connect.return_value = False
|
||||
mock_client_class.return_value = client
|
||||
|
||||
server = await manager.connect_server(config)
|
||||
|
||||
assert server.status == MCPStatus.ERROR
|
||||
client.disconnect.assert_awaited_once()
|
||||
assert "srv" not in manager._clients
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_connect_exception_removes_client(self):
|
||||
"""A connect that raises removes the client from the registry."""
|
||||
manager = MCPClientManager()
|
||||
config = {"name": "srv", "command": "echo", "args": []}
|
||||
|
||||
with patch("praisonaiui.features.mcp.StdioMCPClient") as mock_client_class:
|
||||
client = AsyncMock()
|
||||
client.connect.side_effect = Exception("boom")
|
||||
mock_client_class.return_value = client
|
||||
|
||||
server = await manager.connect_server(config)
|
||||
|
||||
assert server.status == MCPStatus.ERROR
|
||||
client.disconnect.assert_awaited_once()
|
||||
assert "srv" not in manager._clients
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_disconnect_server_success(self):
|
||||
"""Test successful server disconnect."""
|
||||
|
||||
Loaded 100 of 105 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user