mirror of
https://github.com/MervinPraison/PraisonAIUI.git
synced 2026-11-13 05:01:37 -10:00
Compare 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.11",
|
||||
"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].20
|
||||
secrets: inherit
|
||||
with:
|
||||
hub_ref: v1.1.6
|
||||
hub_ref: v1.1.20
|
||||
@@ -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].20
|
||||
secrets: inherit
|
||||
with:
|
||||
hub_ref: v1.1.6
|
||||
hub_ref: v1.1.20
|
||||
pr_number: ${{ github.event.inputs.pr_number || '' }}
|
||||
@@ -25,8 +25,8 @@ permissions:
|
||||
|
||||
jobs:
|
||||
detect-and-trigger:
|
||||
uses: MervinPraison/github-automation-template/.github/workflows/[email protected].6
|
||||
uses: MervinPraison/github-automation-template/.github/workflows/[email protected].20
|
||||
secrets: inherit
|
||||
with:
|
||||
hub_ref: v1.1.6
|
||||
hub_ref: v1.1.20
|
||||
pr_number: ${{ github.event.inputs.pr_number || '' }}
|
||||
@@ -36,6 +36,30 @@ jobs:
|
||||
- name: Integration smoke tests
|
||||
run: pytest tests/integration/test_agentic_roundtrip.py tests/test_feature_sdk_backends.py -q --no-cov --timeout=120
|
||||
|
||||
frontend:
|
||||
name: frontend
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Set up Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: "22"
|
||||
cache: npm
|
||||
cache-dependency-path: src/frontend/package-lock.json
|
||||
|
||||
- name: Install dependencies
|
||||
run: npm ci
|
||||
working-directory: src/frontend
|
||||
|
||||
# `npm run build` is the dashboard ship path: dist/ is synced to
|
||||
# src/praisonaiui/templates/frontend/ (see src/frontend/README.md).
|
||||
- name: Build frontend
|
||||
run: npm run build
|
||||
working-directory: src/frontend
|
||||
|
||||
docs:
|
||||
runs-on: ubuntu-latest
|
||||
if: github.ref == 'refs/heads/main'
|
||||
|
||||
@@ -43,9 +43,9 @@ jobs:
|
||||
id-token: write
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: MervinPraison/github-automation-template/.github/actions/[email protected].6
|
||||
- uses: MervinPraison/github-automation-template/.github/actions/[email protected].20
|
||||
with:
|
||||
hub_ref: v1.1.6
|
||||
hub_ref: v1.1.20
|
||||
- name: Generate GitHub App token
|
||||
id: app-token
|
||||
uses: actions/create-github-app-token@v1
|
||||
@@ -87,9 +87,10 @@ jobs:
|
||||
issues: write
|
||||
actions: read
|
||||
id-token: write
|
||||
uses: MervinPraison/github-automation-template/.github/workflows/[email protected]
|
||||
statuses: write # the assess job records security/outsider-review
|
||||
uses: MervinPraison/github-automation-template/.github/workflows/[email protected]
|
||||
secrets: inherit
|
||||
with:
|
||||
hub_ref: v1.1.6
|
||||
hub_ref: v1.1.20
|
||||
pr_number: ${{ github.event.inputs.pr_number || github.event.client_payload.pr_number || '' }}
|
||||
merge_method: ${{ github.event.inputs.merge_method || 'merge' }}
|
||||
@@ -18,7 +18,7 @@ jobs:
|
||||
issues: write
|
||||
actions: read
|
||||
id-token: write
|
||||
uses: MervinPraison/github-automation-template/.github/workflows/[email protected].6
|
||||
uses: MervinPraison/github-automation-template/.github/workflows/[email protected].20
|
||||
secrets: inherit
|
||||
with:
|
||||
hub_ref: v1.1.6
|
||||
hub_ref: v1.1.20
|
||||
@@ -25,9 +25,9 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: MervinPraison/github-automation-template/.github/actions/[email protected].6
|
||||
- uses: MervinPraison/github-automation-template/.github/actions/[email protected].20
|
||||
with:
|
||||
hub_ref: v1.1.6
|
||||
hub_ref: v1.1.20
|
||||
- name: Detect conflicting PRs and trigger Claude
|
||||
uses: actions/github-script@v7
|
||||
with:
|
||||
|
||||
@@ -13,7 +13,7 @@ permissions:
|
||||
|
||||
jobs:
|
||||
sync:
|
||||
uses: MervinPraison/github-automation-template/.github/workflows/[email protected].6
|
||||
uses: MervinPraison/github-automation-template/.github/workflows/[email protected].20
|
||||
secrets: inherit
|
||||
with:
|
||||
hub_ref: v1.1.6
|
||||
hub_ref: v1.1.20
|
||||
@@ -366,3 +366,7 @@ pytest tests -v
|
||||
## License
|
||||
|
||||
MIT © [Praison Limited](https://praison.ai)
|
||||
|
||||
Third-party vendored source is documented in [THIRD_PARTY_NOTICES.md](THIRD_PARTY_NOTICES.md).
|
||||
See the [BeautifulUI upgrade playbook](docs/frontend/beautifului.md) for how BeautifulUI
|
||||
primitives are vendored and pinned.
|
||||
+37
@@ -0,0 +1,37 @@
|
||||
# Security Policy
|
||||
|
||||
We take the security of PraisonAIUI seriously and appreciate responsible
|
||||
disclosure of vulnerabilities.
|
||||
|
||||
## Supported Versions
|
||||
|
||||
Security fixes are applied to the latest released version of PraisonAIUI.
|
||||
Please make sure you are on the most recent release before reporting.
|
||||
|
||||
## Reporting a Vulnerability
|
||||
|
||||
**Please do not open a public issue for security vulnerabilities.**
|
||||
|
||||
Report privately through GitHub's **Private Vulnerability Reporting**:
|
||||
|
||||
1. Go to the [Security tab](https://github.com/MervinPraison/PraisonAIUI/security)
|
||||
of this repository.
|
||||
2. Click **"Report a vulnerability"** to open a private advisory.
|
||||
3. Include as much detail as you can:
|
||||
- Affected component(s) and version.
|
||||
- Steps to reproduce.
|
||||
- Impact assessment.
|
||||
- Any suggested fix or mitigation.
|
||||
|
||||
If you are unable to use private reporting, you may open a public issue that
|
||||
contains **no technical specifics** and requests a private channel, and a
|
||||
maintainer will follow up.
|
||||
|
||||
## What to Expect
|
||||
|
||||
- We aim to acknowledge new reports within a few business days.
|
||||
- We will keep you informed as we investigate and work on a fix.
|
||||
- Once a fix is released, we are happy to credit you in the advisory (unless
|
||||
you prefer to remain anonymous).
|
||||
|
||||
Thank you for helping keep PraisonAIUI and its users safe.
|
||||
@@ -0,0 +1,43 @@
|
||||
# Third-Party Notices
|
||||
|
||||
PraisonAIUI incorporates or vendors source from the third-party projects listed below.
|
||||
Their licences and copyright notices are retained here as required.
|
||||
|
||||
---
|
||||
|
||||
## BeautifulUI
|
||||
|
||||
- Project: BeautifulUI — <https://www.beautifului.dev/>
|
||||
- Source: <https://github.com/slev12397/beautiful-ui>
|
||||
- Licence: MIT
|
||||
|
||||
BeautifulUI is distributed as a shadcn registry / copy-paste library (no npm package).
|
||||
When its React primitives are vendored into `src/frontend`, the following MIT licence
|
||||
applies to that copied source.
|
||||
|
||||
```
|
||||
MIT License
|
||||
|
||||
Copyright (c) Shane Levine
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
```
|
||||
|
||||
See the upgrade playbook at [`docs/frontend/beautifului.md`](docs/frontend/beautifului.md)
|
||||
for how BeautifulUI is vendored and pinned.
|
||||
@@ -59,6 +59,11 @@ navigation:
|
||||
- typescript/runtime
|
||||
- typescript/nextjs
|
||||
- typescript/components
|
||||
- group: "Frontend"
|
||||
icon: "layout"
|
||||
prefix: "frontend"
|
||||
pages:
|
||||
- frontend/beautifului
|
||||
- group: "Examples"
|
||||
icon: "folder-open"
|
||||
prefix: "examples"
|
||||
|
||||
@@ -0,0 +1,94 @@
|
||||
# ADR 0001: PraisonAI Replay view, a clean-room design
|
||||
|
||||
- **Status:** accepted
|
||||
- **Date:** 2026-10-26
|
||||
- **Scope:** `src/praisonaiui/features/replay.py`, `features/_trace_jsonl.py`,
|
||||
`templates/frontend/plugins/views/replay-core.js`, `templates/frontend/plugins/views/replay.js`
|
||||
|
||||
## Context
|
||||
|
||||
PraisonAI records agent runs as context-trace JSONL (one `ContextEvent.to_dict()` per
|
||||
line). People need to see what a team or flow did: who did what, when, why, and at what
|
||||
cost, both after the fact and live. Other tools have shown that a graph plus a trace is a
|
||||
useful way to look at agent runs. We want that value without copying anyone's product,
|
||||
code or look, and without adding dependencies.
|
||||
|
||||
## Decision
|
||||
|
||||
Build our own view, in plain DOM and SVG, with our own layout code, palette and words.
|
||||
|
||||
### Ideas we took (ideas only)
|
||||
|
||||
| Idea | Where it came from | What we did with it |
|
||||
|---|---|---|
|
||||
| Show the graph of a multi-agent program next to the trace of a run | The general concept behind LangGraph Studio's graph-plus-trace view, known from public talks and articles | A **Map** view drawn by our own layered layout (`layoutMap`); the default view is a **Timeline**, which Studio does not have |
|
||||
| Parallel voices on shared time, read left to right | Musical scores and swimlane diagrams | One lane per agent plus Manager and You; parallel branches as sub-lanes; tasks and steps as "chapters" across the top |
|
||||
| Vocabulary for LLM and tool spans, tokens and cost | OpenTelemetry GenAI semantic conventions (public specification) | Block kinds (LLM, tool), token and cost totals; the wrapper's OTLP export uses the conventions, the UI only borrows the concepts |
|
||||
|
||||
### What we did not use
|
||||
|
||||
- **No third-party UI code, CSS, icons, layouts or screenshots** were used to build this
|
||||
view. Shapes, palette, spacing, wording ("Brief", "Delivered", "Waiting for you") and
|
||||
the layout algorithm are our own.
|
||||
- **LangGraph's server (Elastic License 2.0) and the proprietary LangGraph Studio were
|
||||
not consulted for code**, and none of their assets, markup or styles were copied. We
|
||||
do not use the name "Studio", any "Lang…" naming, or `__start__`/`__end__` pills.
|
||||
- No Mermaid, D3, React Flow, dagre, ELK or other graph libraries. The dashboard's
|
||||
existing Mermaid CDN plugin is not used by Replay.
|
||||
|
||||
### How it is built
|
||||
|
||||
- `replay-core.js`: pure functions (`buildModel`, `narrate`, `layoutMap`,
|
||||
`deriveGraph`). No DOM, no network. Tested with `node --test`.
|
||||
- `replay.js`: DOM/SVG rendering, keyboard handling, SSE follow and approvals. It also
|
||||
boots by itself when `window.__REPLAY_DATA__ = {events, graph}` is set, which is how
|
||||
the wrapper's single-file HTML export works (`standalone_html()` inlines both files).
|
||||
- `layoutMap` is a textbook layered layout written from scratch: depth-first search to
|
||||
find back edges (loops), longest-path layering, one virtual waypoint per crossed layer
|
||||
for long edges, and a few barycentre sweeps to order each layer. It is deterministic.
|
||||
- Narration is template text, chosen per event type. No LLM, no randomness.
|
||||
|
||||
## Palette and accessibility rationale
|
||||
|
||||
| Role | Fill | Shape | Why |
|
||||
|---|---|---|---|
|
||||
| Agent | `#8B0000` | circle | PraisonAI's brand red; white text on it is 10.0:1 |
|
||||
| Tool | `#0F7287` | rounded rectangle | Teal kept from the brand but darkened from `#189AB4` (white text on `#189AB4` fails AA); white on `#0F7287` is 5.6:1 |
|
||||
| You (human) | `#8A5A00` | diamond | Amber distinct from red and teal for common colour-vision deficiencies; white on it is 5.9:1 |
|
||||
| Manager | `#3C4A5C` | square | Neutral slate, so the manager reads as coordination, not work; white on it is 9.0:1 |
|
||||
|
||||
- **Never colour alone.** Every lane, block, node and step has a shape and a text label;
|
||||
arrows differ by dash pattern and carry words ("hands off", "delegates", "routes").
|
||||
Heat in the cost/latency overlay is a bar whose height and shade both grow, plus the
|
||||
value in text.
|
||||
- **Theme.** Follows the system setting (`prefers-color-scheme`) by default, with a
|
||||
Light/Dark override; the light theme is the design baseline.
|
||||
- **Light theme first.** Text tokens are at least 6.9:1 on their backgrounds in light mode
|
||||
and 7.7:1 in dark mode. In dark mode the palette fills keep white text at AA, and get a
|
||||
light 2 px outline so each shape's boundary stays above 3:1 against the dark page.
|
||||
- **Keyboard throughout.** ← → (and ↑ ↓ in the step list) move the replay cursor, Home
|
||||
and End jump to the ends, tabs follow the ARIA tabs pattern, map nodes use a roving
|
||||
tab stop, and every action has a real button.
|
||||
- **Screen readers.** The narration column is a real ordered list and is the main way
|
||||
through a run. Timeline blocks and map nodes are named buttons whose names start with
|
||||
their visible text (WCAG 2.5.3). Live updates are announced through a polite live
|
||||
region, throttled.
|
||||
- **Motion.** The only animation (the live dot) runs only under
|
||||
`prefers-reduced-motion: no-preference`; scrolling is instant when motion is reduced.
|
||||
- Checked with axe-core against WCAG 2.0, 2.1 and 2.2 A/AA in both themes (dev-only test
|
||||
in `tests/browser/`).
|
||||
|
||||
## Dependency licences
|
||||
|
||||
No new runtime or build dependencies. The Python side uses only the standard library and
|
||||
Starlette, which aiui already depends on. The browser side is plain ES modules with no
|
||||
imports beyond our own file. Dev-only test tools that are not shipped: Playwright
|
||||
(Apache-2.0, already used in this environment) and axe-core (MPL-2.0, loaded from a path
|
||||
given by `AXE_CORE_JS`; not vendored).
|
||||
|
||||
## Consequences
|
||||
|
||||
- We own the look and the layout code, and can change them without licence questions.
|
||||
- The layout is simpler than a dedicated graph engine. It is fine for the team and flow
|
||||
sizes we see (tens of nodes). Very large graphs may need a better crossing-reduction
|
||||
step later.
|
||||
+54
-1
@@ -73,6 +73,47 @@ aiui.table(
|
||||
|-----------|------|----------|-------------|
|
||||
| `headers` | `Sequence[str]` | ✅ | Column header labels |
|
||||
| `rows` | `Sequence[Sequence[Any]]` | ✅ | Row data arrays |
|
||||
| `row_ids` | `Sequence[str]` | — | Stable unique ID per row. Makes rows selectable by click, Enter or Space |
|
||||
| `actions` | `Sequence[str]` | — | Row action button labels (requires `row_ids`) |
|
||||
|
||||
With `row_ids`, the dashboard renderer emits bubbling DOM events from the table, so
|
||||
a custom view can own query state (search, filters, page, sort) and react to rows:
|
||||
|
||||
| Event | `detail` |
|
||||
|-------|----------|
|
||||
| `aiui:table-select` | `{rowId, index}` |
|
||||
| `aiui:table-action` | `{action, rowId}` |
|
||||
|
||||
Because events carry the stable `rowId`, selection stays correct after the server
|
||||
re-sorts or filters rows. Loading, empty and error states compose from existing
|
||||
components (`aiui.skeleton()`, `aiui.empty()`, `aiui.alert()`):
|
||||
|
||||
```js
|
||||
// Custom view: server-paged, searchable table using only documented APIs.
|
||||
window.aiui.registerView('companies', (container) => {
|
||||
const state = { q: '', page: 1 };
|
||||
const show = (comps) => { container.replaceChildren(...comps.map(window.aiui.renderComponent)); };
|
||||
async function load() {
|
||||
show([{ type: 'skeleton', variant: 'card' }]);
|
||||
try {
|
||||
const res = await fetch(`/api/companies?q=${encodeURIComponent(state.q)}&page=${state.page}`);
|
||||
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
||||
const { rows, total } = await res.json(); // [{id, name, status}], count
|
||||
if (!rows.length) return show([{ type: 'empty', text: 'No results' }]);
|
||||
show([{ type: 'table', headers: ['Name', 'Status'],
|
||||
rows: rows.map(r => [r.name, r.status]),
|
||||
row_ids: rows.map(r => r.id), actions: ['Edit'] },
|
||||
{ type: 'caption', text: `${total} companies` }]);
|
||||
} catch (e) {
|
||||
show([{ type: 'alert', variant: 'error', message: 'Could not load companies. Try again.' }]);
|
||||
}
|
||||
}
|
||||
container.addEventListener('aiui:table-select', (e) => openDetail(e.detail.rowId));
|
||||
container.addEventListener('aiui:table-action', (e) => editRow(e.detail.rowId));
|
||||
// On search/filter change: state.q = value; state.page = 1; load();
|
||||
load();
|
||||
});
|
||||
```
|
||||
|
||||
### `aiui.text(content)`
|
||||
|
||||
@@ -705,7 +746,7 @@ aiui.breadcrumb([
|
||||
|-----------|------|----------|-------------|
|
||||
| `items` | `Sequence[dict]` | ✅ | List of `{"label": str, "href": str\|None}` |
|
||||
|
||||
### `aiui.pagination(*, total, page, per_page)`
|
||||
### `aiui.pagination(*, total, page, per_page, id)`
|
||||
|
||||
Pagination controls.
|
||||
|
||||
@@ -718,6 +759,18 @@ aiui.pagination(total=250, page=3, per_page=25)
|
||||
| `total` | `int` | ✅ | — | Total items |
|
||||
| `page` | `int` | ❌ | `1` | Current page (1-based) |
|
||||
| `per_page` | `int` | ❌ | `10` | Items per page |
|
||||
| `id` | `str` | ❌ | `None` | Identifier echoed in the page-change event |
|
||||
|
||||
The control is optimistic: clicking ←, →, or a page number updates the active
|
||||
page and dispatches a bubbling `aiui:pagination` DOM event. Buttons that would
|
||||
go out of bounds are disabled and emit nothing. `page` is clamped to the valid range.
|
||||
|
||||
```javascript
|
||||
container.addEventListener('aiui:pagination', (e) => {
|
||||
const { id, page, per_page, total } = e.detail;
|
||||
renderRows(rows.slice((page - 1) * per_page, page * per_page)); // or fetch()
|
||||
});
|
||||
```
|
||||
|
||||
### `aiui.key_value_list(items, *, title)`
|
||||
|
||||
|
||||
+9
-1
@@ -208,7 +208,7 @@ aiui.set_chat_features(
|
||||
|
||||
---
|
||||
|
||||
### `aiui.set_dashboard(*, sidebar, page_header)`
|
||||
### `aiui.set_dashboard(*, sidebar, page_header, modules, plugin_dirs, plugins)`
|
||||
|
||||
Configure dashboard layout options.
|
||||
|
||||
@@ -216,6 +216,9 @@ Configure dashboard layout options.
|
||||
|-----------|------|---------|-------------|
|
||||
| `sidebar` | `bool` | `True` | Show the left sidebar navigation |
|
||||
| `page_header` | `bool` | `True` | Show the page title/description header |
|
||||
| `modules` | `list[str] \| None` | `None` | Optional dashboard JS modules (`jobs`, `auth`, `api`) |
|
||||
| `plugin_dirs` | `list[str] \| None` | `None` | Extra directories to scan for dashboard plugins |
|
||||
| `plugins` | `list[str] \| None` | `None` | Allowlist of dashboard plugin names. `None` = all discovered (bundled, `~/.praisonai/dashboard-plugins`, `plugin_dirs`); `[]` = none |
|
||||
|
||||
```python
|
||||
import praisonaiui as aiui
|
||||
@@ -223,8 +226,13 @@ import praisonaiui as aiui
|
||||
aiui.set_style("dashboard")
|
||||
aiui.set_dashboard(sidebar=False) # Chat-only dashboard, no sidebar
|
||||
aiui.set_dashboard(page_header=False) # Hide page titles
|
||||
aiui.set_dashboard(plugins=[]) # No auto-discovered dashboard plugins
|
||||
```
|
||||
|
||||
`plugins` filters both the sidebar pages (`/api/pages`) and the browser plugin
|
||||
manifest (`/api/dashboard/plugins`), so disabled plugin scripts are never loaded.
|
||||
It scopes navigation only — it is **not** an authorization boundary for API routes.
|
||||
|
||||
---
|
||||
|
||||
### `aiui.set_chat_preview(enabled, *, surface_id, width)`
|
||||
|
||||
@@ -0,0 +1,56 @@
|
||||
# BeautifulUI Integration Strategy
|
||||
|
||||
> **Epic:** [#292](https://github.com/MervinPraison/PraisonAIUI/issues/292) — BeautifulUI integration strategy for AI-native dashboard UX.
|
||||
> This is the durable, agreed strategy that the linked child issues execute against.
|
||||
|
||||
[BeautifulUI](https://www.beautifului.dev/) (MIT, Shane Levine / Turbo) is a **copy-paste / shadcn-registry** library of React primitives for AI-native interfaces: thinking traces, streaming answers with inline sources, HITL approval cards, tool chips, task rows, context chunks, diff/records tables, prompt bars, and a reference chat harness.
|
||||
|
||||
There is **no published npm package** named `beautiful-ui`. It ships only as source consumed via the shadcn registry or manual copy.
|
||||
|
||||
## 1. Strategic decision
|
||||
|
||||
BeautifulUI is adopted as **vendored source**, not a runtime dependency, and is exposed to dashboard plugins **only through the existing `@praisonaiui/react` package** — never by importing upstream file paths directly. Rollout is **phased behind a feature flag**, keeping the current vanilla dashboard as the fallback until parity is proven.
|
||||
|
||||
Upstream stack (Next.js 15 · React 19 · Tailwind v4 · TypeScript) is closely aligned with PraisonAIUI's `src/frontend` (Vite · React 19 · Tailwind v4 · shadcn/Radix), so primitives port with adapter work rather than a runtime bridge.
|
||||
|
||||
## 2. Pillars
|
||||
|
||||
| # | Pillar | Decision |
|
||||
|---|--------|----------|
|
||||
| 1 | **Vendoring model** | Add primitives into `src/frontend` via `npx shadcn add https://www.beautifului.dev/r/<component>.json`, or manual copy from [github.com/slev12397/beautiful-ui](https://github.com/slev12397/beautiful-ui). Pin via lockfile plus a recorded registry URL / git SHA. No `beautiful-ui` npm dependency in production builds. |
|
||||
| 2 | **Adapter layer** | Map PraisonAIUI chat / approval / trace **protocol events** to stable internal props. Primitives sit behind `@praisonaiui/react` exports; dashboard plugins consume those exports, not upstream paths. See [#294](https://github.com/MervinPraison/PraisonAIUI/issues/294). |
|
||||
| 3 | **Hybrid rollout** | Mount React **islands** (or expand `@praisonaiui/react`) for high-value surfaces first — chat streaming/thinking/tools, then approvals. Vanilla chat (`templates/frontend/plugins/views/chat.js`) stays as the flag-gated fallback until parity is proven. |
|
||||
| 4 | **Token strategy** | Merge BeautifulUI foundation CSS **selectively** into the existing shadcn theme generation (`praisonaiui/themes.py`). One design system per page — avoid two competing token sets. |
|
||||
| 5 | **Icon licence** | Upstream `SidebarNav` uses the paid `@central-icons-react`. Port nav patterns with **lucide-react** (already in PraisonAIUI) instead. |
|
||||
|
||||
## 3. Phasing
|
||||
|
||||
1. **Spike** — evaluate primitives in the dev environment, confirm React 19 / Tailwind v4 alignment. [#293](https://github.com/MervinPraison/PraisonAIUI/issues/293)
|
||||
2. **Architecture** — adapter layer + upstream pin policy. [#294](https://github.com/MervinPraison/PraisonAIUI/issues/294)
|
||||
3. **CI gate** — frontend build + BeautifulUI compatibility check (no frontend build gate exists today). [#295](https://github.com/MervinPraison/PraisonAIUI/issues/295)
|
||||
4. **Migrations** — chat streaming / thinking / tool chips ([#296](https://github.com/MervinPraison/PraisonAIUI/issues/296)), then the approval card ([#297](https://github.com/MervinPraison/PraisonAIUI/issues/297)).
|
||||
5. **Docs** — upgrade playbook + MIT attribution. [#298](https://github.com/MervinPraison/PraisonAIUI/issues/298)
|
||||
|
||||
## 4. Risks
|
||||
|
||||
| Risk | Mitigation |
|
||||
|------|------------|
|
||||
| No semver npm package | Vendor the source; pin to a recorded registry URL / git SHA in the lockfile. |
|
||||
| Upstream breaking registry JSON | Vendoring decouples builds from live registry; re-sync is a deliberate, reviewed step. |
|
||||
| Icon licence (`@central-icons-react` is paid) | Use lucide-react for ported nav patterns; never vendor the paid package. |
|
||||
| Two competing design systems on one page | Selective CSS token merge into `themes.py`; adapter-only prop surface. |
|
||||
| React / Tailwind drift | Upstream (React 19 / Tailwind v4) already matches `src/frontend`; the CI gate catches drift. |
|
||||
|
||||
## 5. Out of scope
|
||||
|
||||
- Replacing the entire dashboard shell in one PR.
|
||||
- PyPI packaging of BeautifulUI.
|
||||
- iframe embedding of beautifului.dev.
|
||||
- Depending on any unpublished npm package for production builds.
|
||||
|
||||
## 6. Acceptance criteria status
|
||||
|
||||
- [x] Written integration strategy agreed (vendoring + adapter + phased rollout) — this document.
|
||||
- [x] Risks documented — section 4.
|
||||
- [x] Child issues filed and prioritised — [#293](https://github.com/MervinPraison/PraisonAIUI/issues/293)–[#298](https://github.com/MervinPraison/PraisonAIUI/issues/298).
|
||||
- [x] No requirement to depend on unpublished npm packages for production builds — pillar 1, out of scope.
|
||||
@@ -259,19 +259,31 @@ from starlette.responses import JSONResponse
|
||||
|
||||
class MyFeature(BaseFeatureProtocol):
|
||||
name = "my-feature"
|
||||
feature_name = "my-feature"
|
||||
description = "Example feature with GET and PATCH routes"
|
||||
|
||||
def routes(self):
|
||||
return [
|
||||
Route("/api/my-feature", self._handler, methods=["GET"]),
|
||||
Route("/api/my-feature", self._get, methods=["GET"]),
|
||||
Route("/api/my-feature/{item_id}", self._patch, methods=["PATCH"]),
|
||||
]
|
||||
|
||||
async def _handler(self, request):
|
||||
return JSONResponse({"data": "hello"})
|
||||
async def _get(self, request):
|
||||
# Query parameters: GET /api/my-feature?q=hello
|
||||
return JSONResponse({"data": request.query_params.get("q", "hello")})
|
||||
|
||||
async def _patch(self, request):
|
||||
# Path parameter + JSON body: PATCH /api/my-feature/42
|
||||
body = await request.json()
|
||||
return JSONResponse({"ok": True, "id": request.path_params["item_id"], **body})
|
||||
|
||||
# Register before the app is created — create_app() mounts feature
|
||||
# routes ahead of the SPA fallback, so no route reordering is needed.
|
||||
register_feature(MyFeature())
|
||||
```
|
||||
|
||||
`name` and `description` are abstract on `BaseFeatureProtocol`; omitting
|
||||
either raises `TypeError: Can't instantiate abstract class`.
|
||||
|
||||
See [Protocol Architecture](protocol-architecture.md).
|
||||
|
||||
---
|
||||
|
||||
@@ -0,0 +1,176 @@
|
||||
# Replay
|
||||
|
||||
**PraisonAI Replay** shows a recorded agent run as a story: who did what, when, why, and
|
||||
at what cost. It works for finished runs and for runs that are still going.
|
||||
|
||||
## Record a run, then open it
|
||||
|
||||
```bash
|
||||
praisonai replay view agents.yaml # records the run and opens Replay on it
|
||||
```
|
||||
|
||||
or from Python:
|
||||
|
||||
```python
|
||||
from praisonai.replay import record
|
||||
|
||||
with record(live=True):
|
||||
team.start()
|
||||
```
|
||||
|
||||
Every recorded run is a file `<id>.jsonl` in the traces folder. Replay reads it from
|
||||
`$PRAISONAI_TRACES_DIR`, else `$PRAISON_HOME/traces`, else `~/.praison/traces`.
|
||||
|
||||
To open aiui on Replay yourself:
|
||||
|
||||
```bash
|
||||
aiui run "$(python -c 'import praisonaiui.replay_app as m; print(m.__file__)')" \
|
||||
--style dashboard --host 127.0.0.1 --port 8090
|
||||
# then open http://127.0.0.1:8090/replay?run=<id>
|
||||
```
|
||||
|
||||
`/#/replay?run=<id>` works too. In a full dashboard, Replay is in the sidebar under
|
||||
**Control**.
|
||||
|
||||
## Watch a run live: the Run button
|
||||
|
||||
For learning, demos and debugging, open a page with a **Run** button. Each click records
|
||||
a new run, and the page draws it as it happens:
|
||||
|
||||
```python
|
||||
from praisonai.replay import watch
|
||||
|
||||
watch(flow, input="solar sails") # an Agent, AgentTeam, AgentFlow or agents.yaml
|
||||
```
|
||||
|
||||
```bash
|
||||
praisonai replay watch agents.yaml
|
||||
```
|
||||
|
||||
On the **Map**, each step and agent shows where the run is: dashed means not reached,
|
||||
blue (pulsing) means running now, green means done and red means failed. After a run,
|
||||
**▶ Play** moves through it again one step at a time, and the map rewinds with it. One
|
||||
run goes at a time. Each run of a team starts its tasks afresh; an Agent keeps its chat
|
||||
history between runs.
|
||||
|
||||
## Show progress inside your own app
|
||||
|
||||
`create_replay_app()` is the same view as a small ASGI app, without the dashboard. Mount
|
||||
it in any FastAPI or Starlette app:
|
||||
|
||||
```python
|
||||
from praisonai.replay import record, replay_app
|
||||
|
||||
app.mount("/replay", replay_app()) # read-only: shows recorded runs
|
||||
|
||||
# wherever your app starts work:
|
||||
with record(run_id, live=True):
|
||||
team.start()
|
||||
```
|
||||
|
||||
Then show one run, live, in your page, for example in an `<iframe>`:
|
||||
|
||||
```html
|
||||
<iframe src="/replay/?run=RUN_ID" style="width:100%;height:640px"></iframe>
|
||||
```
|
||||
|
||||
| URL | Shows |
|
||||
|---|---|
|
||||
| `/replay/` | The run list and the selected run |
|
||||
| `/replay/?run=<id>` | Only that run, no list. A run that has not started yet is waited for. |
|
||||
|
||||
`replay_app(team)` adds the Run box to the mounted page. Without the PraisonAI wrapper,
|
||||
pass any `run(input, run_id)` callable to `praisonaiui.features.replay.create_replay_app`;
|
||||
it must record the run under `run_id`. For a page you build yourself, the view is one
|
||||
module:
|
||||
|
||||
```html
|
||||
<div id="progress"></div>
|
||||
<script type="module">
|
||||
import { render } from "/replay/replay.js";
|
||||
render(document.getElementById("progress"), { base: "/replay", run: "RUN_ID", approve: false });
|
||||
</script>
|
||||
```
|
||||
|
||||
`render()` options: `base` (where the API is mounted), `run` (show only this run),
|
||||
`runner` (`{input, placeholder, ask}` to show the Run box) and `approve` (`false` hides
|
||||
the approve and deny buttons; the embedded app has no approvals API, so answer approvals
|
||||
where your app asks for them).
|
||||
|
||||
Anyone who can reach the mounted app can read the runs, and with a runner can start one.
|
||||
Keep it on localhost or behind your app's login. `POST /api/replay/run` only accepts
|
||||
`Content-Type: application/json`, so another website cannot start a run from a visitor's
|
||||
browser.
|
||||
|
||||
## What you see
|
||||
|
||||
| Part | What it shows |
|
||||
|---|---|
|
||||
| **Runs** | Recorded runs, newest first. A run that is still being written has a **Live** badge. |
|
||||
| **Timeline** (default) | One lane per agent, plus **Manager** and **You**. Time runs left to right. LLM calls and tool calls are blocks in their agent's lane. Arrows show hand-offs, delegation, routing and approval requests. Parallel branches get their own sub-lanes. A call or step repeated back to back is one block marked ×N. Tasks and steps run across the top. |
|
||||
| **Map** | The team or flow as a graph, drawn from the graph recorded at the start of the run and fitted to the page (−, Fit and + zoom; drag to pan). Agents that took part but are not in the graph, for example after a hand-off to an agent outside the team, appear in an **Outside the team** group with their hand-offs. Older recordings without a graph get a "who talked to whom" map built from the events. Each node shows its state at the cursor: dashed means not reached, blue means running, green means done (×N when it ran more than once) and red means failed; the node the cursor is on has a bold outline. |
|
||||
| **What happened** | One plain sentence per step, for example *"Researcher asked web_search for 'solar sail facts' — 1.2 s, $0.002."* Hovering or focusing a sentence highlights its block, and the other way round. |
|
||||
| **Details** | Three tabs for the selected step. **What happened**: when, who, how long, cost, tokens, output. **Why**: only what the recording holds, such as the input given to a tool, the context passed in a hand-off and its reason, the manager's task snapshot, the route a step chose, and an approval's decision and where an automatic approval came from. **Raw JSON**: the events themselves. |
|
||||
| **Cost & latency** | Adds a bar under each block (taller and darker means more cost, or more time) and per-agent totals in the lane labels. |
|
||||
|
||||
Shapes always carry meaning as well as colour: agents are circles, tools are rounded
|
||||
rectangles, you are a diamond and the manager is a square. In each lane, LLM calls sit on
|
||||
the top row and that agent's tool calls on a thinner row under them, so a long LLM call
|
||||
never hides its tools. Labels that do not fit end in "…"; hover or focus a block to see the
|
||||
whole label.
|
||||
|
||||
The **Theme** menu follows your system's light or dark setting by default; choose Light or
|
||||
Dark to override it. On a phone the run list, timeline and steps stack in one column.
|
||||
|
||||
## Moving through a run
|
||||
|
||||
Replay opens at the end of the run, so you see everything. Move the cursor to replay it:
|
||||
anything after the cursor is drawn as an outline.
|
||||
|
||||
| Key | Action |
|
||||
|---|---|
|
||||
| ← → | Previous / next step |
|
||||
| ↑ ↓ | Previous / next step in the step list; previous / next node in the map |
|
||||
| Home / End | First / last step |
|
||||
| Enter or Space | Open the focused block or node |
|
||||
| ← → in the tabs | Switch between What happened, Why and Raw JSON |
|
||||
|
||||
The scrubber under the timeline does the same with a mouse or touch, and **▶ Play** steps
|
||||
through on its own. On a live run the
|
||||
cursor follows new events; if you step back, **Jump to live** takes you to the latest.
|
||||
|
||||
## Approving from Replay
|
||||
|
||||
When an agent asks for approval and the run uses aiui's approval store, a **Waiting for
|
||||
you** card appears above the timeline:
|
||||
|
||||
- **Approve** or **Deny**, with an optional reason.
|
||||
- **Edit arguments…** opens the arguments as JSON (taken from the server, not the redacted
|
||||
recording when the server has them). The JSON must be an object; mistakes are shown
|
||||
as you type. **Approve with these arguments** sends them as `modified_args`.
|
||||
|
||||
These buttons call `POST /api/approvals/{id}/approve` and `/deny`. The agent waits until
|
||||
you answer, and is denied if nobody answers before the approval timeout.
|
||||
|
||||
## Sharing a run as one file
|
||||
|
||||
`praisonai replay export RUN --format html` writes a single HTML file with the run and
|
||||
this viewer inside. It needs no server and no network. Approvals cannot be answered from
|
||||
an exported file.
|
||||
|
||||
## HTTP API
|
||||
|
||||
| Endpoint | Returns |
|
||||
|---|---|
|
||||
| `GET /api/replay/runs` | `[{run_id, session_id, path, started_at, ended_at?, status, kind, name, agents, events, cost_usd, live}]`, newest first |
|
||||
| `GET /api/replay/runs/{id}/events?after_seq=N` | `{events, graph, run}`; `after_seq` keeps only events with a larger `sequence_num`; `wait=1` answers `{events: [], graph: null, run: null}` instead of 404 for a run that has not started yet |
|
||||
| `GET /api/replay/runs/{id}/stream` | Server-sent events: `event: ctx` per trace event (with `id:` = `sequence_num`), `ping` every 15 s, `end` once the run has finished and gone quiet for 2 s. Honours `after_seq` and `Last-Event-ID`. |
|
||||
|
||||
With a runner (`create_replay_app(run=...)`), `POST /api/replay/run` with
|
||||
`{"input": "..."}` starts a run and returns `{run_id}` (409 while one is going).
|
||||
|
||||
Run ids must match `^[A-Za-z0-9._-]+$`. Replay never writes trace files itself. Half-written last lines, unknown fields and unknown event types are skipped
|
||||
safely, and schema 1.0 recordings still open.
|
||||
|
||||
The **Traces** page reads the same files: each run is a trace, and its spans come from
|
||||
matching start and end events.
|
||||
@@ -0,0 +1,35 @@
|
||||
# BeautifulUI upstream pin record
|
||||
|
||||
BeautifulUI is vendored (shadcn-style registry copy), **not** an npm dependency.
|
||||
This file is the single source of truth for what upstream revision is vendored.
|
||||
Update it in the **same PR** that adds or bumps any vendored component, and make
|
||||
sure the frontend CI gate (issue #295) passes.
|
||||
|
||||
See [beautifului-adapter-architecture.md](./beautifului-adapter-architecture.md)
|
||||
for the adapter boundary and folder layout.
|
||||
|
||||
## Pin
|
||||
|
||||
| Field | Value |
|
||||
|-------|-------|
|
||||
| Upstream repo | _TBD — record on first vendor_ |
|
||||
| Git SHA | _TBD_ |
|
||||
| Vendored on (date) | _TBD_ |
|
||||
| Registry base URL | _TBD_ |
|
||||
| License | MIT (kept in-tree) |
|
||||
|
||||
## Vendored components
|
||||
|
||||
Record one row per component the first time it is copied in.
|
||||
|
||||
| Component | Registry URL | SHA | Vendored path | Date |
|
||||
|-----------|--------------|-----|---------------|------|
|
||||
| _(none yet)_ | | | | |
|
||||
|
||||
## Upgrade checklist
|
||||
|
||||
1. Update the SHA / date / registry rows above.
|
||||
2. Re-copy changed components into `src/frontend/src/agent-ui/vendors/beautifului/`.
|
||||
3. Keep all upstream imports inside `src/agent-ui/` (adapter boundary rule).
|
||||
4. Run the frontend build + adapter unit tests (frontend CI gate, #295).
|
||||
5. Do **not** add a `beautiful-ui` entry to `package.json` dependencies.
|
||||
@@ -0,0 +1,102 @@
|
||||
# ADR: BeautifulUI adapter layer & upstream pin policy
|
||||
|
||||
**Status:** Accepted · **Issue:** [#294](https://github.com/MervinPraison/PraisonAIUI/issues/294)
|
||||
|
||||
## Context
|
||||
|
||||
BeautifulUI is **not** a semver npm library — it is distributed as shadcn-style
|
||||
registry components that are copied into the consuming repo. PraisonAIUI must
|
||||
integrate it without leaking upstream types or import paths across the codebase,
|
||||
and without breaking when either side updates.
|
||||
|
||||
## Decision
|
||||
|
||||
Introduce a single **adapter boundary** between transport payloads and the UI,
|
||||
and vendor BeautifulUI components behind that boundary.
|
||||
|
||||
```
|
||||
Agent APIs (Python) ──► event bus / WS messages
|
||||
│
|
||||
▼
|
||||
Agent-UI adapter (src/agent-ui/adapter.ts)
|
||||
(stable PraisonAIUI contracts)
|
||||
│
|
||||
▼
|
||||
@praisonaiui/react re-exports
|
||||
(BeautifulUI vendored components)
|
||||
│
|
||||
▼
|
||||
Dashboard / chat mount points (React islands, then full shell)
|
||||
```
|
||||
|
||||
### 1. Folder layout
|
||||
|
||||
```
|
||||
src/frontend/src/agent-ui/
|
||||
├── contracts.ts # Stable PraisonAIUI view models (the ONLY types consumers import)
|
||||
├── adapter.ts # Pure mappers: wire payload -> contracts
|
||||
├── adapter.test.ts # Unit tests (mock events -> props), node:test
|
||||
├── index.ts # Barrel: contracts + adapters + (later) vendored re-exports
|
||||
└── vendors/
|
||||
└── beautifului/ # Vendored BeautifulUI components (added during migration)
|
||||
```
|
||||
|
||||
**Rule:** BeautifulUI / upstream prop types must **never** be imported outside
|
||||
`src/agent-ui/`. Consumers depend on `contracts.ts` only, so the vendored
|
||||
implementation can be swapped later without touching call sites.
|
||||
|
||||
### 2. Adapter contracts
|
||||
|
||||
Internal, transport-agnostic types live in `contracts.ts`:
|
||||
|
||||
- `AgentStreamEvent` — normalized form of the WebSocket frames handled in
|
||||
`chat.js` (`run_started`, `run_content`, `tool_call_started`, …). Unknown
|
||||
frame `type`s degrade to `kind: 'unknown'` with `rawType` preserved instead
|
||||
of throwing, so new upstream events never break the UI.
|
||||
- `ToolCallChipModel` — tool-call chip view model.
|
||||
- `ApprovalPromptModel` — approval card view model, mapped from the
|
||||
`/api/approvals/pending` REST payload.
|
||||
|
||||
Mappers (`adapter.ts`): `mapChatEventToUi(frame)` and
|
||||
`mapApprovalToCardProps(item)`. Both are **pure** and unit tested.
|
||||
|
||||
### 3. Vendoring & pin policy
|
||||
|
||||
| Mechanism | Purpose |
|
||||
|-----------|---------|
|
||||
| [`BEAUTIFULUI_UPSTREAM.md`](./BEAUTIFULUI_UPSTREAM.md) | Record git SHA, date, registry base URL for every vendored component |
|
||||
| shadcn registry URLs | Per-component install, pinned in the upstream doc |
|
||||
| **No** `dependencies.beautiful-ui` in `package.json` | Avoid a phantom npm package |
|
||||
|
||||
**Upgrade rule:** bump upstream **only** via a PR that updates
|
||||
`BEAUTIFULUI_UPSTREAM.md` and passes the frontend CI gate (issue #295). Icons
|
||||
stay on `lucide-react`; do not vendor paid Central icons.
|
||||
|
||||
### 4. Feature flag (default **off**)
|
||||
|
||||
Integration is gated so the existing vanilla-JS chat/approvals plugins remain
|
||||
the fallback until migration milestones (#296, #297) complete.
|
||||
|
||||
- **Flag:** `AIUI_BEAUTIFULUI` (build-time env / dashboard config JSON).
|
||||
- **Default:** off — vanilla JS renders; the adapter is imported but not mounted.
|
||||
- **On:** React islands mount using `src/agent-ui` contracts.
|
||||
- **Fallback:** if a vendored component fails to load, the flag is treated as
|
||||
off and the vanilla path renders. No user-facing regression when off.
|
||||
|
||||
### 5. Design tokens
|
||||
|
||||
Scope agent-UI primitives under an `.aiui-agent-ui { … }` CSS layer (or extend
|
||||
the existing shadcn presets in `themes.py`) so vendored component tokens cannot
|
||||
collide with the global dashboard theme.
|
||||
|
||||
### 6. Deprecation / upstream abandonment
|
||||
|
||||
Vendored code stays MIT-licensed in-tree. Because consumers depend only on the
|
||||
`contracts.ts` view models, a future first-party implementation can satisfy the
|
||||
same contracts and replace the vendored components with no call-site churn.
|
||||
|
||||
## Consequences
|
||||
|
||||
- One place (`src/agent-ui/`) owns the upstream coupling.
|
||||
- Adapters are testable without a browser or the transport layer.
|
||||
- The default-off flag keeps the migration reversible at every step.
|
||||
@@ -0,0 +1,107 @@
|
||||
# BeautifulUI Upgrade Playbook
|
||||
|
||||
How PraisonAIUI vendors [BeautifulUI](https://www.beautifului.dev/) React primitives so
|
||||
upgrades are **repeatable** and licence obligations are **explicit**.
|
||||
|
||||
!!! info "Related planning"
|
||||
Epic [#292](https://github.com/MervinPraison/PraisonAIUI/issues/292) (integration
|
||||
strategy) and the adapter/pin policy in
|
||||
[#294](https://github.com/MervinPraison/PraisonAIUI/issues/294).
|
||||
|
||||
## 1. What BeautifulUI is
|
||||
|
||||
BeautifulUI (MIT, © Shane Levine / Turbo) is a library of React primitives aimed at
|
||||
**AI-native interfaces** — thinking traces, streaming answers with inline sources,
|
||||
human-in-the-loop approval cards, tool chips, task rows, context chunks, and a reference
|
||||
chat harness.
|
||||
|
||||
**It is a shadcn registry + copy-paste library — not an npm package.** There is no
|
||||
published `beautiful-ui` package. You install components by resolving a registry URL, and
|
||||
the source is copied into your tree (like any other shadcn/ui primitive).
|
||||
|
||||
- Website & registry: <https://www.beautifului.dev/>
|
||||
- Upstream source: <https://github.com/slev12397/beautiful-ui>
|
||||
|
||||
Upstream stack (Next.js 15 · React 19 · Tailwind CSS v4 · TypeScript) is closely aligned
|
||||
with PraisonAIUI's `src/frontend` (Vite · React 19 · Tailwind v4 · shadcn/Radix), which is
|
||||
why copy-paste vendoring works without a framework shim.
|
||||
|
||||
## 2. One-time foundation setup
|
||||
|
||||
PraisonAIUI already carries the shadcn/ui foundation that BeautifulUI primitives depend on:
|
||||
|
||||
- **shadcn config** — `src/frontend/components.json` (`style: new-york`,
|
||||
`baseColor: neutral`, `iconLibrary: lucide`, `cssVariables: true`). Its `registries`
|
||||
map is where pinned BeautifulUI registry URLs are added (see §3).
|
||||
- **Theme tokens** — `src/praisonaiui/themes.py` generates the shadcn CSS variables
|
||||
(`--background`, `--foreground`, `--primary`, `--radius`, …) at build time for all 22
|
||||
official presets, offline. BeautifulUI's foundation CSS (upstream `app/globals.css`)
|
||||
must only contribute **new** tokens/utilities; it must **not** redefine variables that
|
||||
`themes.py` already owns, or theme switching and dark mode break.
|
||||
|
||||
**Rule:** when merging upstream `app/globals.css`, diff it against the variables emitted by
|
||||
`themes.py` and keep only the additive slices (new keyframes, component-scoped utilities).
|
||||
Record what was merged in the pin doc (§3).
|
||||
|
||||
## 3. Adding or updating a primitive
|
||||
|
||||
1. **Install with a pinned registry URL** from `src/frontend/`:
|
||||
```bash
|
||||
npx shadcn add https://www.beautifului.dev/r/<component>.json
|
||||
```
|
||||
The registry resolves dependencies and CSS slices. Commit the resulting
|
||||
`package-lock.json` change in the same PR.
|
||||
2. **Re-export through the adapter** — add the component to
|
||||
`src/frontend/src/components/index.ts` (`@praisonaiui/react`). Consumers never import
|
||||
the raw vendored path (see §4).
|
||||
3. **Update the pin record** — create/update `docs/frontend/BEAUTIFULUI_UPSTREAM.md` with:
|
||||
the upstream **git SHA**, the **date**, the **registry base URL**, and the **component
|
||||
list** vendored so far. This file is the single source of truth for "what version are
|
||||
we on". (It is created the first time a real primitive is vendored — do not add it
|
||||
empty.)
|
||||
4. **Rebuild & sync the bundle** — the committed frontend bundle must be regenerated:
|
||||
```bash
|
||||
cd src/frontend && npm run build
|
||||
# then sync dist/{index.html,assets/,icon.svg} → src/praisonaiui/templates/frontend/
|
||||
```
|
||||
(See `AGENTS.md` → "Frontend Build".)
|
||||
5. **Run frontend CI** — `.github/workflows/ci.yml` (ruff + pytest) must pass, plus the
|
||||
frontend build step.
|
||||
|
||||
## 4. Adapter boundary
|
||||
|
||||
Consumers import from **`@praisonaiui/react` only** — never from raw vendored paths such as
|
||||
`@/components/ui/...` or any upstream module.
|
||||
|
||||
```tsx
|
||||
// ✅ stable PraisonAIUI contract
|
||||
import { Button, ThemeToggle } from '@praisonaiui/react'
|
||||
|
||||
// ❌ leaks upstream/shadcn paths across the codebase
|
||||
import { Button } from '@/components/ui/button'
|
||||
```
|
||||
|
||||
This keeps the upstream surface behind one file (`components/index.ts`), so a BeautifulUI
|
||||
bump changes vendored source but not consumer imports.
|
||||
|
||||
## 5. Rollback
|
||||
|
||||
- BeautifulUI-backed views mount behind a **feature flag**; the **vanilla dashboard views**
|
||||
(`src/praisonaiui/templates/frontend/plugins/*`) remain the fallback and are kept working.
|
||||
- To roll back a bad bump: revert the vendoring PR (source + `BEAUTIFULUI_UPSTREAM.md` +
|
||||
rebuilt bundle move together in one commit), and disable the flag. No consumer import
|
||||
changes are needed because everything routes through the adapter (§4).
|
||||
|
||||
## 6. Licence
|
||||
|
||||
BeautifulUI is **MIT, © Shane Levine**. The full licence text is retained in
|
||||
[`THIRD_PARTY_NOTICES.md`](https://github.com/MervinPraison/PraisonAIUI/blob/main/THIRD_PARTY_NOTICES.md)
|
||||
at the repository root. Any PR that vendors or bumps BeautifulUI source must keep that
|
||||
notice present and current.
|
||||
|
||||
## 7. Commercial icons — do not vendor
|
||||
|
||||
Do **not** use `@central-icons-react` without a licence — it is commercial. PraisonAIUI
|
||||
standardises on **`lucide-react`** (`iconLibrary: lucide` in `components.json`). If an
|
||||
upstream BeautifulUI primitive imports commercial icons, swap them for lucide equivalents
|
||||
during vendoring.
|
||||
@@ -36,7 +36,7 @@ curl http://localhost:8082/health/live
|
||||
|
||||
### Readiness Check: `/health/ready`
|
||||
|
||||
Verifies the server is ready to handle requests. Performs parallel health checks on all features with timeout protection.
|
||||
Verifies the server is ready to handle requests. Performs parallel health checks on all features with timeout protection. The provider check runs concurrently and is bounded by the same 0.5s timeout, so a slow provider cannot hang the response (or a cold `/api/health`).
|
||||
|
||||
**Use for:**
|
||||
- Kubernetes readiness probes
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
# Example 28 — Full Extensibility Showcase
|
||||
# Example 29 — Full Extensibility Showcase
|
||||
|
||||
Demonstrates **every** extension point in PraisonAIUI.
|
||||
|
||||
@@ -45,6 +45,9 @@ The `timeline` component and the `custom-view` page are client-side extensions.
|
||||
> **Note:** `aiui.set_custom_js(path)` injects client-side JS from a file path at page load.
|
||||
> See `docs/features/how-to-add-a-feature.md` for the extension API contract.
|
||||
|
||||
> **Tip:** if a custom view renders its own heading, call
|
||||
> `aiui.set_dashboard(page_header=False)` to hide the default page title/description header.
|
||||
|
||||
## Testing the form action
|
||||
|
||||
1. Navigate to **Contact Form**
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
"""Example 28 — Full Extensibility Showcase.
|
||||
"""Example 29 — Full Extensibility Showcase.
|
||||
|
||||
Demonstrates EVERY extension point in PraisonAIUI:
|
||||
|
||||
@@ -203,10 +203,9 @@ async def custom_view_fallback():
|
||||
aiui.callout(
|
||||
title="Plugin not loaded",
|
||||
content=(
|
||||
"This page is meant to be rendered client-side. "
|
||||
"Open DevTools → Console and paste the contents of "
|
||||
"plugin.js (in this example folder) to "
|
||||
"activate the custom view."
|
||||
"This page is meant to be rendered client-side by "
|
||||
"plugin.js, which app.py loads via aiui.set_custom_js(). "
|
||||
"If you see this, check that /custom.js loaded."
|
||||
),
|
||||
variant="warning",
|
||||
),
|
||||
|
||||
@@ -5,9 +5,11 @@
|
||||
* 1. window.aiui.registerComponent(type, renderFn) — adds a new component type
|
||||
* 2. window.aiui.registerView(pageId, render, cleanup) — adds a custom page view
|
||||
*
|
||||
* To load this file, paste its contents into the browser DevTools Console
|
||||
* after the page loads. (A Python-side API to inject custom JS is a planned
|
||||
* enhancement — see 28-full-extensibility/README.md.)
|
||||
* Loaded automatically by app.py, which serves it at /custom.js:
|
||||
*
|
||||
* aiui.set_custom_js(Path(__file__).with_name("plugin.js"))
|
||||
*
|
||||
* See 29-full-extensibility/README.md.
|
||||
*/
|
||||
|
||||
(function () {
|
||||
|
||||
@@ -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,8 +95,12 @@ nav:
|
||||
- Templates & Layouts: concepts/templates.md
|
||||
- Slots & Components: concepts/slots.md
|
||||
- Providers: concepts/providers.md
|
||||
- Frontend:
|
||||
- BeautifulUI Adapter: frontend/beautifului-adapter-architecture.md
|
||||
- BeautifulUI Upstream Pin: frontend/BEAUTIFULUI_UPSTREAM.md
|
||||
- Features:
|
||||
- Dashboard: features/dashboard.md
|
||||
- Replay: features/replay.md
|
||||
- Gateway Chat: features/gateway-chat.md
|
||||
- Attachments: features/attachments.md
|
||||
- Theme System: features/theme-system.md
|
||||
@@ -129,12 +133,15 @@ nav:
|
||||
- Feature Inventory: features/feature-inventory.md
|
||||
- Gap Analysis: features/gap-analysis.md
|
||||
- Integration Analysis: features/integration-analysis.md
|
||||
- BeautifulUI Integration: features/beautifului-integration.md
|
||||
- Standardization: features/standardization-verification.md
|
||||
- Comparison (OpenClaw): features/comparison-openclaw.md
|
||||
- What Makes It Different: features/differentiation.md
|
||||
- How to Add a Feature: features/how-to-add-a-feature.md
|
||||
- Message Elements: features/message-elements.md
|
||||
- Steps: features/steps.md
|
||||
- Frontend:
|
||||
- BeautifulUI Playbook: frontend/beautifului.md
|
||||
- API Reference:
|
||||
- Python CLI: api/cli.md
|
||||
- Python API: api/python.md
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
[project]
|
||||
name = "aiui"
|
||||
version = "0.3.143"
|
||||
version = "0.3.154"
|
||||
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:
|
||||
|
||||
|
||||
@@ -6,7 +6,7 @@ import tseslint from 'typescript-eslint'
|
||||
import { defineConfig, globalIgnores } from 'eslint/config'
|
||||
|
||||
export default defineConfig([
|
||||
globalIgnores(['dist']),
|
||||
globalIgnores(['dist', 'src/components/ui', 'src/spike', 'dev']),
|
||||
{
|
||||
files: ['**/*.{ts,tsx}'],
|
||||
extends: [
|
||||
|
||||
Generated
+1892
-637
File diff suppressed because it is too large.
Load diff
@@ -27,9 +27,16 @@
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "tsc -b && vite build",
|
||||
"postbuild": "node scripts/gen-externals.mjs",
|
||||
"build:lib": "tsc -b && vite build --mode lib",
|
||||
"lint": "eslint .",
|
||||
"preview": "vite preview"
|
||||
"preview": "vite preview",
|
||||
"test": "node scripts/run-tests.mjs",
|
||||
"test:e2e": "playwright test -c e2e/playwright.config.ts",
|
||||
"size": "size-limit",
|
||||
"sync": "node scripts/sync-templates.mjs",
|
||||
"check:bundle": "node scripts/check-bundle-sync.mjs",
|
||||
"check:closure": "node scripts/check-entry-closure.mjs"
|
||||
},
|
||||
"keywords": [
|
||||
"react",
|
||||
@@ -62,6 +69,7 @@
|
||||
}
|
||||
},
|
||||
"dependencies": {
|
||||
"@assistant-ui/react": "0.15.36",
|
||||
"@radix-ui/react-accordion": "^1.2.12",
|
||||
"@radix-ui/react-avatar": "^1.1.11",
|
||||
"@radix-ui/react-checkbox": "^1.3.3",
|
||||
@@ -81,6 +89,7 @@
|
||||
"@radix-ui/react-switch": "^1.2.6",
|
||||
"@radix-ui/react-tabs": "^1.1.13",
|
||||
"@radix-ui/react-tooltip": "^1.2.8",
|
||||
"@streamdown/math": "1.0.3",
|
||||
"@tailwindcss/typography": "^0.5.19",
|
||||
"@types/react-syntax-highlighter": "^15.5.13",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
@@ -88,31 +97,41 @@
|
||||
"cmdk": "^1.1.1",
|
||||
"date-fns": "^4.1.0",
|
||||
"embla-carousel-react": "^8.6.0",
|
||||
"katex": "0.16.47",
|
||||
"lucide-react": "^0.563.0",
|
||||
"mermaid": "^11.17.2",
|
||||
"next-themes": "0.4.6",
|
||||
"radix-ui": "1.8.1",
|
||||
"react-day-picker": "^9.13.0",
|
||||
"react-markdown": "^10.1.0",
|
||||
"react-resizable-panels": "^4.5.4",
|
||||
"react-syntax-highlighter": "^16.1.1",
|
||||
"recharts": "2.15.4",
|
||||
"remark-gfm": "^4.0.1",
|
||||
"shiki": "4.6.0",
|
||||
"sonner": "2.0.8",
|
||||
"streamdown": "2.7.1",
|
||||
"tailwind-merge": "^3.4.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@a2ui/react": "^0.10.0",
|
||||
"@a2ui/web_core": "^0.10.0",
|
||||
"@eslint/js": "^9.39.1",
|
||||
"@playwright/test": "1.65.0",
|
||||
"@size-limit/file": "14.3.0",
|
||||
"@tailwindcss/vite": "^4.1.18",
|
||||
"@types/node": "^24.10.1",
|
||||
"@types/react": "^19.2.5",
|
||||
"@types/react-dom": "^19.2.3",
|
||||
"@vitejs/plugin-react": "^5.1.1",
|
||||
"esbuild": "0.27.3",
|
||||
"eslint": "^9.39.1",
|
||||
"eslint-plugin-react-hooks": "^7.0.1",
|
||||
"eslint-plugin-react-refresh": "^0.4.24",
|
||||
"globals": "^16.5.0",
|
||||
"react": "^19.2.0",
|
||||
"react-dom": "^19.2.0",
|
||||
"size-limit": "14.3.0",
|
||||
"tailwindcss": "^4.1.18",
|
||||
"tw-animate-css": "^1.4.0",
|
||||
"typescript": "~5.9.3",
|
||||
|
||||
@@ -0,0 +1,120 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* Post-build step (`npm run build` runs it as `postbuild`).
|
||||
*
|
||||
* Writes dist/aiui-externals.json: the named exports of every bare specifier a
|
||||
* plugin may import (ADR-0001 §8). The server builds its
|
||||
* /_aiui/externals/*.js ESM shims from this file, so the shims always match
|
||||
* the React version actually bundled instead of a hand-written list.
|
||||
*
|
||||
* {
|
||||
* "schema": 1,
|
||||
* "sdkVersion": 1,
|
||||
* "react": "19.2.4",
|
||||
* "modules": {
|
||||
* "react": ["Children", ...],
|
||||
* "react/jsx-runtime": [...],
|
||||
* "react-dom": [...],
|
||||
* "react-dom/client": [...],
|
||||
* "@aiui/sdk": [...] // SDK_EXPORTS from src/sdk/manifest.ts
|
||||
* }
|
||||
* }
|
||||
*
|
||||
* Export lists are the union of React's production and development builds,
|
||||
* restricted to valid JS identifiers, sorted, without `default`.
|
||||
*
|
||||
* Usage: node scripts/gen-externals.mjs [--out <file>]
|
||||
*/
|
||||
import { mkdirSync, readFileSync, writeFileSync } from 'node:fs'
|
||||
import { createRequire } from 'node:module'
|
||||
import path from 'node:path'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
import * as esbuild from 'esbuild'
|
||||
|
||||
const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..')
|
||||
const require = createRequire(path.join(ROOT, 'package.json'))
|
||||
const IDENT = /^[A-Za-z_$][\w$]*$/
|
||||
|
||||
function argValue(flag) {
|
||||
const i = process.argv.indexOf(flag)
|
||||
return i >= 0 ? process.argv[i + 1] : undefined
|
||||
}
|
||||
|
||||
const outFile = path.resolve(ROOT, argValue('--out') ?? 'dist/aiui-externals.json')
|
||||
|
||||
/** Load src/sdk/manifest.ts on its own (it has no runtime imports). */
|
||||
async function loadManifestModule() {
|
||||
const file = path.join(ROOT, 'src', 'sdk', 'manifest.ts')
|
||||
const { code } = await esbuild.transform(readFileSync(file, 'utf8'), {
|
||||
loader: 'ts',
|
||||
format: 'esm',
|
||||
target: 'node22',
|
||||
sourcefile: file,
|
||||
})
|
||||
if (/^\s*import\s/m.test(code)) {
|
||||
throw new Error('src/sdk/manifest.ts must not have runtime imports (gen-externals loads it standalone)')
|
||||
}
|
||||
return import(`data:text/javascript;base64,${Buffer.from(code).toString('base64')}`)
|
||||
}
|
||||
|
||||
/** Object.keys of a CommonJS package entry, loaded under a given NODE_ENV. */
|
||||
function exportsUnder(specifier, nodeEnv) {
|
||||
const prev = process.env.NODE_ENV
|
||||
process.env.NODE_ENV = nodeEnv
|
||||
try {
|
||||
const resolved = require.resolve(specifier)
|
||||
// The package entry picks ./cjs/*.{production,development}.js at require
|
||||
// time; drop it from the cache so the other build is loaded next time.
|
||||
delete require.cache[resolved]
|
||||
return Object.keys(require(specifier))
|
||||
} finally {
|
||||
if (prev === undefined) delete process.env.NODE_ENV
|
||||
else process.env.NODE_ENV = prev
|
||||
}
|
||||
}
|
||||
|
||||
function exportNames(specifier) {
|
||||
const names = new Set([...exportsUnder(specifier, 'production'), ...exportsUnder(specifier, 'development')])
|
||||
names.delete('default')
|
||||
names.delete('__esModule')
|
||||
return [...names].filter((n) => IDENT.test(n)).sort()
|
||||
}
|
||||
|
||||
const manifest = await loadManifestModule()
|
||||
const { SHELL_EXTERNALS, SDK_EXPORTS, SDK_VERSION } = manifest
|
||||
|
||||
if (!Array.isArray(SHELL_EXTERNALS) || !Array.isArray(SDK_EXPORTS)) {
|
||||
throw new Error('src/sdk/manifest.ts must export SHELL_EXTERNALS and SDK_EXPORTS arrays')
|
||||
}
|
||||
const badSdk = SDK_EXPORTS.filter((n) => typeof n !== 'string' || !IDENT.test(n))
|
||||
if (badSdk.length) throw new Error(`SDK_EXPORTS has invalid identifiers: ${badSdk.join(', ')}`)
|
||||
if (new Set(SDK_EXPORTS).size !== SDK_EXPORTS.length) throw new Error('SDK_EXPORTS has duplicates')
|
||||
|
||||
const modules = {}
|
||||
for (const spec of SHELL_EXTERNALS) {
|
||||
if (spec === '@aiui/sdk') {
|
||||
modules[spec] = [...SDK_EXPORTS]
|
||||
continue
|
||||
}
|
||||
const names = exportNames(spec)
|
||||
if (names.length === 0) throw new Error(`no exports found for ${spec}`)
|
||||
modules[spec] = names
|
||||
}
|
||||
|
||||
for (const required of ['react', 'react/jsx-runtime', 'react-dom', 'react-dom/client', '@aiui/sdk']) {
|
||||
if (!modules[required]) throw new Error(`SHELL_EXTERNALS is missing ${required}`)
|
||||
}
|
||||
|
||||
const out = {
|
||||
schema: 1,
|
||||
sdkVersion: SDK_VERSION,
|
||||
react: require('react/package.json').version,
|
||||
modules,
|
||||
}
|
||||
|
||||
mkdirSync(path.dirname(outFile), { recursive: true })
|
||||
writeFileSync(outFile, `${JSON.stringify(out, null, 2)}\n`)
|
||||
const counts = Object.entries(modules)
|
||||
.map(([k, v]) => `${k}=${v.length}`)
|
||||
.join(' ')
|
||||
console.log(`gen-externals: wrote ${path.relative(ROOT, outFile)} (React ${out.react}; ${counts})`)
|
||||
@@ -0,0 +1,117 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* Frontend unit test runner: `npm test [-- <path-substring> ...]`.
|
||||
*
|
||||
* Finds every src/**\/*.test.{ts,tsx}, bundles each one with esbuild into
|
||||
* node_modules/.cache/aiui-tests/ and runs the bundles with `node --test`.
|
||||
* Bundling is required: Node's type stripping cannot resolve extensionless
|
||||
* imports (`./protocol`), the `@/` alias, TSX or CSS imports.
|
||||
*
|
||||
* - npm packages stay external (Node loads them from node_modules, so React
|
||||
* has one copy and react-dom/server works).
|
||||
* - CSS imports (relative or from packages) become empty modules.
|
||||
* - `import.meta.env` is defined the way Vite would in a test build.
|
||||
* - Positional arguments filter test files by path substring.
|
||||
*
|
||||
* Exits 0 when no test file matches.
|
||||
*/
|
||||
import { spawnSync } from 'node:child_process'
|
||||
import { existsSync, readdirSync, rmSync, statSync } from 'node:fs'
|
||||
import path from 'node:path'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
import * as esbuild from 'esbuild'
|
||||
|
||||
const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..')
|
||||
const SRC = path.join(ROOT, 'src')
|
||||
const OUT = path.join(ROOT, 'node_modules', '.cache', 'aiui-tests')
|
||||
const TEST_FILE = /\.test\.tsx?$/
|
||||
const SKIP_DIRS = new Set(['node_modules', 'dist'])
|
||||
|
||||
function findTests(dir) {
|
||||
const found = []
|
||||
for (const name of readdirSync(dir)) {
|
||||
if (SKIP_DIRS.has(name)) continue
|
||||
const full = path.join(dir, name)
|
||||
const st = statSync(full)
|
||||
if (st.isDirectory()) found.push(...findTests(full))
|
||||
else if (TEST_FILE.test(name)) found.push(full)
|
||||
}
|
||||
return found
|
||||
}
|
||||
|
||||
/** Any `*.css` import (including bare package paths) becomes an empty module. */
|
||||
const ignoreCss = {
|
||||
name: 'aiui-ignore-css',
|
||||
setup(build) {
|
||||
build.onResolve({ filter: /\.css(\?.*)?$/ }, (args) => ({ path: args.path, namespace: 'aiui-empty-css' }))
|
||||
build.onLoad({ filter: /.*/, namespace: 'aiui-empty-css' }, () => ({ contents: '', loader: 'js' }))
|
||||
},
|
||||
}
|
||||
|
||||
const filters = process.argv.slice(2).filter((a) => !a.startsWith('-'))
|
||||
const tests = findTests(SRC)
|
||||
.filter((f) => filters.length === 0 || filters.some((s) => path.relative(ROOT, f).includes(s)))
|
||||
.sort()
|
||||
|
||||
if (tests.length === 0) {
|
||||
console.log(filters.length ? `No test files match: ${filters.join(', ')}` : 'No test files found under src/.')
|
||||
process.exit(0)
|
||||
}
|
||||
|
||||
rmSync(OUT, { recursive: true, force: true })
|
||||
|
||||
try {
|
||||
await esbuild.build({
|
||||
entryPoints: tests,
|
||||
outdir: OUT,
|
||||
outbase: SRC,
|
||||
outExtension: { '.js': '.mjs' },
|
||||
bundle: true,
|
||||
splitting: false,
|
||||
platform: 'node',
|
||||
format: 'esm',
|
||||
target: 'node22',
|
||||
packages: 'external',
|
||||
tsconfig: path.join(ROOT, 'tsconfig.app.json'),
|
||||
alias: { '@': SRC },
|
||||
loader: {
|
||||
'.css': 'empty',
|
||||
'.svg': 'dataurl',
|
||||
'.png': 'dataurl',
|
||||
'.jpg': 'dataurl',
|
||||
'.gif': 'dataurl',
|
||||
'.webp': 'dataurl',
|
||||
},
|
||||
plugins: [ignoreCss],
|
||||
define: {
|
||||
'import.meta.env': JSON.stringify({ MODE: 'test', DEV: false, PROD: true, SSR: true, BASE_URL: '/' }),
|
||||
},
|
||||
jsx: 'automatic',
|
||||
sourcemap: 'inline',
|
||||
logLevel: 'warning',
|
||||
})
|
||||
} catch {
|
||||
// esbuild already printed the diagnostics.
|
||||
process.exit(1)
|
||||
}
|
||||
|
||||
const bundles = tests.map((t) => {
|
||||
const rel = path.relative(SRC, t).replace(/\.tsx?$/, '.mjs')
|
||||
const out = path.join(OUT, rel)
|
||||
if (!existsSync(out)) {
|
||||
console.error(`run-tests: esbuild did not emit ${out}`)
|
||||
process.exit(1)
|
||||
}
|
||||
return out
|
||||
})
|
||||
|
||||
console.log(`run-tests: ${tests.length} test file(s): ${tests.map((t) => path.relative(ROOT, t)).join(', ')}`)
|
||||
const res = spawnSync(process.execPath, ['--enable-source-maps', '--test', ...bundles], {
|
||||
cwd: ROOT,
|
||||
stdio: 'inherit',
|
||||
})
|
||||
if (res.error) {
|
||||
console.error(res.error)
|
||||
process.exit(1)
|
||||
}
|
||||
process.exit(res.status ?? 1)
|
||||
@@ -0,0 +1,80 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* Copy the Vite build (dist/) into the Python package's frontend templates.
|
||||
*
|
||||
* node scripts/sync-templates.mjs # -> src/praisonaiui/templates/frontend (tracked tree)
|
||||
* node scripts/sync-templates.mjs --dest <dir> # -> any directory, e.g. a temp dir
|
||||
*
|
||||
* Serve a --dest copy without touching the tracked tree:
|
||||
* AIUI_TEMPLATES_DIR=<dir> aiui run ...
|
||||
*
|
||||
* Steps:
|
||||
* 1. delete <dest>/assets (removes stale hashed chunks from earlier builds)
|
||||
* 2. copy dist/assets/**, dist/index.html, dist/icon.svg,
|
||||
* dist/aiui-manifest.json and dist/aiui-externals.json into <dest>
|
||||
* Anything else in <dest> (plugins/, dashboard-plugins/, chat.css, ...) is
|
||||
* left untouched.
|
||||
*/
|
||||
import { copyFileSync, cpSync, existsSync, mkdirSync, rmSync, statSync } from 'node:fs'
|
||||
import os from 'node:os'
|
||||
import path from 'node:path'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
|
||||
const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..')
|
||||
const DIST = path.join(ROOT, 'dist')
|
||||
const DEFAULT_DEST = path.resolve(ROOT, '..', 'praisonaiui', 'templates', 'frontend')
|
||||
const ROOT_FILES = ['index.html', 'icon.svg', 'aiui-manifest.json', 'aiui-externals.json']
|
||||
|
||||
function fail(msg) {
|
||||
console.error(`sync-templates: ${msg}`)
|
||||
process.exit(1)
|
||||
}
|
||||
|
||||
function parseDest(argv) {
|
||||
for (let i = 0; i < argv.length; i++) {
|
||||
const a = argv[i]
|
||||
if (a === '--dest') {
|
||||
if (!argv[i + 1] || argv[i + 1].startsWith('--')) fail('--dest needs a directory')
|
||||
return path.resolve(argv[i + 1])
|
||||
}
|
||||
if (a.startsWith('--dest=')) return path.resolve(a.slice('--dest='.length))
|
||||
if (a === '-h' || a === '--help') {
|
||||
console.log('usage: node scripts/sync-templates.mjs [--dest <dir>]')
|
||||
process.exit(0)
|
||||
}
|
||||
fail(`unknown argument: ${a}`)
|
||||
}
|
||||
return DEFAULT_DEST
|
||||
}
|
||||
|
||||
const dest = parseDest(process.argv.slice(2))
|
||||
|
||||
// Guard against wiping something important with a typo'd --dest.
|
||||
const forbidden = new Set([path.parse(dest).root, os.homedir(), ROOT, path.resolve(ROOT, '..'), DIST])
|
||||
if (forbidden.has(dest)) fail(`refusing to sync into ${dest}`)
|
||||
|
||||
for (const required of ['index.html', 'assets/index.js', 'aiui-manifest.json']) {
|
||||
if (!existsSync(path.join(DIST, required))) fail(`dist/${required} is missing; run \`npm run build\` first`)
|
||||
}
|
||||
if (!existsSync(path.join(DIST, 'aiui-externals.json'))) {
|
||||
fail('dist/aiui-externals.json is missing; run `npm run build` (postbuild runs scripts/gen-externals.mjs)')
|
||||
}
|
||||
if (existsSync(dest) && !statSync(dest).isDirectory()) fail(`${dest} is not a directory`)
|
||||
|
||||
mkdirSync(dest, { recursive: true })
|
||||
const destAssets = path.join(dest, 'assets')
|
||||
rmSync(destAssets, { recursive: true, force: true })
|
||||
cpSync(path.join(DIST, 'assets'), destAssets, { recursive: true })
|
||||
|
||||
const copied = []
|
||||
for (const f of ROOT_FILES) {
|
||||
const src = path.join(DIST, f)
|
||||
if (!existsSync(src)) {
|
||||
console.warn(`sync-templates: dist/${f} not found, skipped`)
|
||||
continue
|
||||
}
|
||||
copyFileSync(src, path.join(dest, f))
|
||||
copied.push(f)
|
||||
}
|
||||
|
||||
console.log(`sync-templates: dist/assets + ${copied.join(', ')} -> ${dest}`)
|
||||
@@ -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,15 @@
|
||||
/**
|
||||
* CONTRACT STUB (WP0a). WP7 replaces this file with the `kind=blocks` page
|
||||
* renderer. Contract (ADR-0001 §5):
|
||||
* export default function BlockPage(p: { page: ShellPage }): JSX.Element
|
||||
*/
|
||||
import type { JSX } from 'react'
|
||||
import type { ShellPage } from '../sdk/manifest'
|
||||
|
||||
export default function BlockPage({ page }: { page: ShellPage }): JSX.Element {
|
||||
return (
|
||||
<div data-aiui-stub="block-page" data-page-id={page.id}>
|
||||
{page.title}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
/**
|
||||
* CONTRACT STUB (WP0a). WP2 replaces this file with the assistant-ui chat page.
|
||||
* Contract (ADR-0001 §5):
|
||||
* export default function ChatPage(p: { page: ShellPage }): JSX.Element
|
||||
*/
|
||||
import type { JSX } from 'react'
|
||||
import type { ShellPage } from '../sdk/manifest'
|
||||
|
||||
export default function ChatPage({ page }: { page: ShellPage }): JSX.Element {
|
||||
return (
|
||||
<div data-aiui-stub="chat-page" data-page-id={page.id}>
|
||||
{page.title}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -38,7 +38,7 @@ interface ActionButtonsProps {
|
||||
*/
|
||||
export function ActionButtons({ actions, messageId, sessionId }: ActionButtonsProps) {
|
||||
const [pendingActions, setPendingActions] = useState<Set<string>>(new Set())
|
||||
const [hiddenActions, setHiddenActions] = useState<Set<string>>(new Set())
|
||||
const [hiddenActions] = useState<Set<string>>(new Set())
|
||||
|
||||
const handleActionClick = useCallback(async (action: ActionButton) => {
|
||||
if (pendingActions.has(action.id) || hiddenActions.has(action.id)) {
|
||||
|
||||
@@ -73,7 +73,7 @@ export function AskActionPrompt({
|
||||
|
||||
{/* Action buttons */}
|
||||
<div className="space-y-3">
|
||||
{actions.map((action, index) => (
|
||||
{actions.map((action) => (
|
||||
<button
|
||||
key={action.name}
|
||||
type="button"
|
||||
|
||||
@@ -15,7 +15,7 @@ import { Textarea } from '../components/ui/textarea';
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '../components/ui/select';
|
||||
import { Slider } from '../components/ui/slider';
|
||||
import { Switch } from '../components/ui/switch';
|
||||
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from '../components/ui/card';
|
||||
import { Card, CardContent } from '../components/ui/card';
|
||||
import { Sheet, SheetContent, SheetHeader, SheetTitle, SheetDescription } from '../components/ui/sheet';
|
||||
|
||||
interface SettingsWidget {
|
||||
@@ -74,7 +74,7 @@ const WidgetRenderer: React.FC<{
|
||||
id={name}
|
||||
placeholder={placeholder}
|
||||
value={value || ''}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
onChange={(e: React.ChangeEvent<HTMLTextAreaElement>) => onChange(e.target.value)}
|
||||
maxLength={widget.maxLength}
|
||||
rows={4}
|
||||
/>
|
||||
|
||||
@@ -20,43 +20,16 @@ interface CustomElementProps {
|
||||
onError?: (error: Error) => void;
|
||||
}
|
||||
|
||||
interface CustomComponentModule {
|
||||
default: React.ComponentType<any>;
|
||||
}
|
||||
|
||||
// Registry of loaded components (cache)
|
||||
const componentCache = new Map<string, Promise<React.ComponentType<any>>>();
|
||||
|
||||
// List of available custom components (in practice, this would be populated from backend)
|
||||
const availableComponents = new Set([
|
||||
'ExampleWidget',
|
||||
'UserCard',
|
||||
'DataChart',
|
||||
'FormBuilder',
|
||||
'CodeEditor',
|
||||
'ImageGallery',
|
||||
'ChatEmbed'
|
||||
]);
|
||||
|
||||
// Static imports for build-tool compatibility
|
||||
const ExampleWidget = React.lazy(() => import('../components/custom/ExampleWidget'));
|
||||
const UserCard = React.lazy(() => import('../components/custom/UserCard'));
|
||||
const DataChart = React.lazy(() => import('../components/custom/DataChart'));
|
||||
const FormBuilder = React.lazy(() => import('../components/custom/FormBuilder'));
|
||||
const CodeEditor = React.lazy(() => import('../components/custom/CodeEditor'));
|
||||
const ImageGallery = React.lazy(() => import('../components/custom/ImageGallery'));
|
||||
const ChatEmbed = React.lazy(() => import('../components/custom/ChatEmbed'));
|
||||
|
||||
// Static mapping of component names to imports
|
||||
const componentMap: Record<string, React.ComponentType<any>> = {
|
||||
ExampleWidget,
|
||||
UserCard,
|
||||
DataChart,
|
||||
FormBuilder,
|
||||
CodeEditor,
|
||||
ImageGallery,
|
||||
ChatEmbed,
|
||||
};
|
||||
// Custom components available to <CustomElementHost name="..." />. The example
|
||||
// widgets this once listed (ExampleWidget, UserCard, ...) were never added, and
|
||||
// importing them broke the build. Register real components here as they land:
|
||||
// const MyWidget = React.lazy(() => import('../components/custom/MyWidget'));
|
||||
// componentMap.MyWidget = MyWidget;
|
||||
const componentMap: Record<string, React.ComponentType<any>> = {};
|
||||
const availableComponents = new Set(Object.keys(componentMap));
|
||||
|
||||
/**
|
||||
* Load a custom component with static mapping for build-tool compatibility
|
||||
|
||||
@@ -4,7 +4,6 @@
|
||||
* Shows live-updating task lists with states, progress bars, and click-to-jump functionality.
|
||||
*/
|
||||
|
||||
import React from 'react'
|
||||
import { ChevronRight, ChevronDown, CheckCircle, Clock, XCircle, Circle, ExternalLink } from 'lucide-react'
|
||||
import { useTaskState, setTasksCollapsed, type Task, type TaskList, type TaskStatus } from '../state/tasks'
|
||||
import { cn } from '../lib/utils'
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
import * as React from "react"
|
||||
import { cva, type VariantProps } from "class-variance-authority"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const alertVariants = cva(
|
||||
"relative w-full rounded-lg border px-4 py-3 text-sm [&>svg+div]:translate-y-[-3px] [&>svg]:absolute [&>svg]:left-4 [&>svg]:top-4 [&>svg]:text-foreground [&>svg~*]:pl-7",
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default: "bg-background text-foreground",
|
||||
destructive:
|
||||
"border-destructive/50 text-destructive dark:border-destructive [&>svg]:text-destructive",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: "default",
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
const Alert = React.forwardRef<
|
||||
HTMLDivElement,
|
||||
React.HTMLAttributes<HTMLDivElement> & VariantProps<typeof alertVariants>
|
||||
>(({ className, variant, ...props }, ref) => (
|
||||
<div ref={ref} role="alert" className={cn(alertVariants({ variant }), className)} {...props} />
|
||||
))
|
||||
Alert.displayName = "Alert"
|
||||
|
||||
const AlertTitle = React.forwardRef<HTMLParagraphElement, React.HTMLAttributes<HTMLHeadingElement>>(
|
||||
({ className, ...props }, ref) => (
|
||||
<h5 ref={ref} className={cn("mb-1 font-medium leading-none tracking-tight", className)} {...props} />
|
||||
)
|
||||
)
|
||||
AlertTitle.displayName = "AlertTitle"
|
||||
|
||||
const AlertDescription = React.forwardRef<HTMLParagraphElement, React.HTMLAttributes<HTMLParagraphElement>>(
|
||||
({ className, ...props }, ref) => (
|
||||
<div ref={ref} className={cn("text-sm [&_p]:leading-relaxed", className)} {...props} />
|
||||
)
|
||||
)
|
||||
AlertDescription.displayName = "AlertDescription"
|
||||
|
||||
export { Alert, AlertTitle, AlertDescription }
|
||||
@@ -0,0 +1,19 @@
|
||||
import * as React from "react"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const Textarea = React.forwardRef<HTMLTextAreaElement, React.ComponentProps<"textarea">>(
|
||||
({ className, ...props }, ref) => (
|
||||
<textarea
|
||||
className={cn(
|
||||
"flex min-h-[60px] w-full rounded-md border border-input bg-transparent px-3 py-2 text-base shadow-sm placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50 md:text-sm",
|
||||
className
|
||||
)}
|
||||
ref={ref}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
)
|
||||
Textarea.displayName = "Textarea"
|
||||
|
||||
export { Textarea }
|
||||
@@ -183,7 +183,7 @@ export function useMicStream(options: UseMicStreamOptions = {}): UseMicStreamRet
|
||||
onConnectionChangeRef.current?.(false);
|
||||
};
|
||||
|
||||
ws.onerror = (error) => {
|
||||
ws.onerror = () => {
|
||||
onErrorRef.current?.(new Error('WebSocket error'));
|
||||
setConnected(false);
|
||||
onConnectionChangeRef.current?.(false);
|
||||
|
||||
@@ -5,8 +5,33 @@
|
||||
* incoming messages from parent.
|
||||
*/
|
||||
|
||||
import { useEffect, useCallback, useRef } from 'react';
|
||||
import { useSSE } from './useSSE';
|
||||
import { useEffect, useCallback, useRef, useState } from 'react';
|
||||
|
||||
interface EventSourceOptions {
|
||||
enabled?: boolean;
|
||||
onMessage?: (event: MessageEvent) => void;
|
||||
onOpen?: () => void;
|
||||
onError?: () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Minimal EventSource subscription. (useSSE in this package is the chat
|
||||
* streaming store, not a generic SSE hook, so it cannot open this stream.)
|
||||
*/
|
||||
function useEventSource(url: string, { enabled = true, onMessage, onOpen, onError }: EventSourceOptions) {
|
||||
const [connected, setConnected] = useState(false);
|
||||
const handlers = useRef({ onMessage, onOpen, onError });
|
||||
handlers.current = { onMessage, onOpen, onError };
|
||||
useEffect(() => {
|
||||
if (!enabled || typeof EventSource === 'undefined') return;
|
||||
const source = new EventSource(url);
|
||||
source.onopen = () => { setConnected(true); handlers.current.onOpen?.(); };
|
||||
source.onmessage = (event) => handlers.current.onMessage?.(event);
|
||||
source.onerror = () => { setConnected(false); handlers.current.onError?.(); };
|
||||
return () => { source.close(); setConnected(false); };
|
||||
}, [url, enabled]);
|
||||
return { connected };
|
||||
}
|
||||
|
||||
export interface WindowMessageData {
|
||||
type: string;
|
||||
@@ -85,7 +110,7 @@ export function useWindowMessage(options: UseWindowMessageOptions = {}): UseWind
|
||||
const isIframe = window !== window.parent;
|
||||
|
||||
// SSE connection for outbound messages
|
||||
const { connected } = useSSE('/sse/window-message', {
|
||||
const { connected } = useEventSource('/sse/window-message', {
|
||||
enabled: enabled && isIframe,
|
||||
onMessage: (event) => {
|
||||
try {
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
import { StrictMode } from 'react'
|
||||
import { createRoot } from 'react-dom/client'
|
||||
import App from './App.tsx'
|
||||
|
||||
/**
|
||||
* The pre-shell React UI (styles chat, docs, agents, playground, custom).
|
||||
* main.tsx lazy-loads this chunk unless <html data-aiui-shell="react"> is set.
|
||||
* Rendering is exactly what main.tsx did before the boot split.
|
||||
*/
|
||||
export function mountApp(el: HTMLElement) {
|
||||
createRoot(el).render(
|
||||
<StrictMode>
|
||||
<App />
|
||||
</StrictMode>,
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
/**
|
||||
* CONTRACT STUB (WP0a). WP4 replaces this file with the host that mounts a
|
||||
* legacy dashboard view through legacy/runtime.ts mountView().
|
||||
* Contract (ADR-0001 §5):
|
||||
* export function LegacyViewHost(p: { pageId: string; page?: ShellPage }): JSX.Element
|
||||
*/
|
||||
import type { JSX } from 'react'
|
||||
import type { ShellPage } from '../sdk/manifest'
|
||||
|
||||
export interface LegacyViewHostProps {
|
||||
pageId: string
|
||||
page?: ShellPage
|
||||
}
|
||||
|
||||
export function LegacyViewHost({ pageId, page }: LegacyViewHostProps): JSX.Element {
|
||||
return (
|
||||
<div className="aiui-legacy-host" data-legacy-page={pageId} data-status="stub" data-aiui-stub="legacy-view-host">
|
||||
{page?.title ?? pageId}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
Loaded 100 of 183 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user