Compare commits

...
Author SHA1 Message Date
MervinPraison 78493e0e17 Release v0.3.151 2026-11-05 16:51:31 +00:00
praisonai-triage-agent[bot] bf5415555b Merge pull request #332 from MervinPraison/claude/issue-331-20261105
fix: rename sessions from the chat rail
2026-11-05 16:48:12 +00:00
MervinPraisonandClaude Opus 5.5 e69a06c2d5 fix: rename sessions from the chat rail (fixes #331)
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-05 10:41:19 +00:00
MervinPraison 085bdd3793 Release v0.3.150 2026-11-04 12:47:04 +00:00
Superbot Helper 93abaac212 Bump @a2ui/web_core from 0.10.0 to 0.10.2 in the frontend [SECURITY] (#321)
GHSA-72qq-p3r5-f7wq: the basic catalog openUrl action accepted any URL
scheme and passed it to window.open, so an agent-supplied A2UI Button
carrying a javascript: URI would execute script in the app origin on click.
0.10.2 resolves the URL and refuses anything that is not http or https.
2026-11-04 12:45:00 +00:00
f2ecc8e39e fix: link job rows to their chat session (fixes #323) (#325)
Co-authored-by: MervinPraison <[email protected]>
Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-11-04 12:44:48 +00:00
e86b187246 fix: add in-rail session search to chat sidebar (#327)
* fix: add in-rail session search to chat sidebar (fixes #324)

Co-Authored-By: Claude Opus 5.5 <[email protected]>

* fix: guard session rail against non-array session payloads

Co-Authored-By: Claude Opus 5.5 <[email protected]>

---------

Co-authored-by: MervinPraison <[email protected]>
Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-11-04 12:44:26 +00:00
1255f44105 fix: make dashboard chat fill the main column (#326)
* fix: make dashboard chat fill the main column (fixes #322)

Chat and Chat+Canvas built-in pages declare layout: full-bleed. selectPage()
toggles a db-fill class on #db-main-content and skips the duplicate page
header, so .chat-root sizes from flex allocation instead of
calc(100vh - 140px). Session rail stacks above the thread below 900px.

Co-Authored-By: Claude Opus 5.5 <[email protected]>

* fix: make full-bleed page metadata test non-vacuous

Co-Authored-By: Claude Opus 5.5 <[email protected]>

---------

Co-authored-by: MervinPraison <[email protected]>
Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-11-04 12:44:10 +00:00
Mervin PraisonandClaude Opus 5.5 7002e956fb ci: bump github-automation-template to v1.1.20 (#320)
Co-authored-by: Claude Opus 5.5 (1M context) <[email protected]>
2026-10-29 02:38:22 +01:00
MervinPraison ebbb5f863e Release v0.3.149 2026-10-27 10:17:57 +00:00
Mervin Praison 34a01c1738 Merge pull request #318 from MervinPraison/fix/replay-unnamed-agents
fix(replay): map and narrate unnamed agents (Agent(instructions=...))
2026-10-27 11:14:51 +01:00
Mervin Praison 0d2b7b1d09 Merge branch 'main' into fix/replay-unnamed-agents 2026-10-27 11:13:04 +01:00
Mervin Praison 4ab1eee24f Merge pull request #319 from MervinPraison/fix/frontend-typecheck
fix(frontend): make the dashboard build pass again (26 type errors since April)
2026-10-27 11:12:54 +01:00
MervinPraisonandClaude Opus 5.5 1dad99b2c8 fix(frontend): make the dashboard build pass again (26 type errors since April)
The frontend build gate added on 2026-09-23 (166091b) has failed on every
main run since: five April PRs left code that never compiled — imports of
shadcn components that were never added (ui/alert, ui/textarea), seven
example widgets that never existed (CustomElementHost), react-router-dom
(not a dependency), a useSSE call written for a different hook, a wrong
ChatMessages prop set, and unused locals under noUnusedLocals.

- add the standard shadcn alert and textarea components
- CustomElementHost: empty registry instead of imports of missing widgets
- useWindowMessage: small local EventSource hook for /sse/window-message
- SharedThread: read the token from /shared/{token}; pass the props
  ChatMessages takes; type messages as ChatMessage[]
- drop unused imports/locals

None of these files are imported by the app today, so nothing users see
changes; the gate now builds and catches real breakage again.

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-10-27 10:08:44 +00:00
MervinPraisonandClaude Opus 5.5 a89b00da5b fix(replay): map and narrate unnamed agents (Agent(instructions=...))
An agent created without name= records agent_name null. The Replay map
skipped every such event, so watch(Agent(instructions=...)) showed
"Nothing to map yet" and its steps were narrated as "The run".

- agentOf(ev): agent_name, else the package's id (agent:Agent from the
  companion core fix), else tool:None/<tool> or nothing in 4.7.19 traces,
  falling back to "Agent" for an agent's own run events.
- deriveGraph / nodeIdsForEvent / narrate use it, so the agent and its tools
  are drawn and lit, and steps read "Agent started.".
- Only when no graph was recorded: a team graph leaves unnamed agents out on
  purpose, and they must not reappear as "Outside the team".

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-10-27 09:46:43 +00:00
MervinPraison 706c934629 Release v0.3.148 2026-10-26 19:36:13 +00:00
Mervin Praison 7a22011892 Merge pull request #317 from MervinPraison/feat/replay-embed
feat(replay): embeddable Replay app, Run box, Play, and a map that follows the cursor
2026-10-26 17:35:07 +01:00
MervinPraisonandClaude Opus 5.5 7037cf7bd3 feat(replay): embeddable Replay app, Run box, Play, and a map that follows the cursor
- create_replay_app(run=None): the Replay view as a small ASGI app (no dashboard)
  to mount in any FastAPI/Starlette app; /?run=<id> pins one run (iframe-friendly).
  With run(input, run_id) it shows a Run box: POST /api/replay/run (JSON only,
  one run at a time) starts a run and the page follows it live.
- render(el, {base, run, runner, approve}) so the view works under a mount prefix.
- Map nodes show their state at the cursor: not reached / running / done / failed /
  skipped, and "×N" counts real runs instead of events (a step that ran once
  no longer reads "×2"). Scrubbing or Play rewinds the map with the cursor.
- ▶ Play steps through a run on its own.
- events?wait=1 answers a not-yet-started run without a 404.
- Node names stay whole (squeezed or clipped) so accessible names contain the
  visible text (WCAG 2.5.3; axe flagged truncated tool names).

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-10-26 15:46:54 +00:00
Mervin Praison bf98d29a38 Merge pull request #316 from MervinPraison/feat/replay-view
feat(replay): PraisonAI Replay page (timeline, map, live, why, approvals) + trace-reader and UI-approval fixes
2026-10-26 14:44:22 +01:00
MervinPraisonandClaude Opus 5.5 fec821a306 docs(replay): rows, theme, map grouping and phone layout
Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-10-26 12:24:25 +00:00
MervinPraisonandClaude Opus 5.5 e4a13c6dde fix(dashboard): phone layout and SVG page icons; Replay gets its own mark
- At <=1023 px the hidden sidebar still took 260 px of the flex row,
  because buildSidebar() sets an inline position:relative that beat the
  mobile position:fixed; and the shell header slot took another 53 px.
  Every page's content was squeezed to a few pixels at phone width. The
  sidebar is now fixed (!important), the header slot gets its own row,
  and phones get a 16 px gutter.
- Pages may set icon_svg next to the text icon. The sidebar and page
  header parse it as SVG and drop it if it holds scripts, foreignObject,
  on* handlers or javascript: URLs; other UIs keep using `icon`.
- Replay's heading uses its own inline mark (three lanes and a cursor)
  instead of the emoji, and its description no longer says "why" (it
  made Playwright's text=Why match the page header, not the tab).

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-10-26 12:24:25 +00:00
MervinPraisonandClaude Opus 5.5 f1261958c8 fix(replay): real-run review — rows, system theme, fitted map, phone, export boot
From a review against real recorded runs:

- Timeline: an agent's LLM span encloses its tool calls in real runs, so
  tool blocks covered the LLM label. Each lane now has an LLM row with a
  thinner tool row under it, blocks are packed into tracks in pixel space
  so nothing overlaps, and labels are ellipsized; the full label is in
  the title, the accessible name and a hover/focus tip. Visible labels
  sit in a decorative layer so names never contain a cut-off word.
- Theme: defaults to the system setting (prefers-color-scheme, tracked
  live in CSS) with a System / Light / Dark menu as an override.
- Map: fits the whole graph on open (shares -/Fit/+), drag to pan, never
  below a readable scale, and gets the full width with the step list
  underneath. Agents seen in events but not in the graph (e.g. a
  hand-off to an agent outside the team) are added by observedGraph()
  and laid out by layoutRunMap() as an "Outside the team" group.
- Phone: 16 px gutter, stacked layout, no horizontal page scroll.
- Standalone boot waits for DOMContentLoaded so the export works with
  either script order, takes data.run_id as the title, and keeps the
  documented #replay-root / window.__REPLAY_DATA__ contract.
- New fixture team_outside.jsonl with the real-run shape; node tests
  for observedGraph/layoutRunMap; browser tests for no-overlap, the Why
  tab by role and keyboard, system theme, map fit, phone width and the
  wrapper-style export in both script orders.

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-10-26 12:24:25 +00:00
MervinPraisonandClaude Opus 5.5 0e26b0ec8d fix(replay): fit narrow canvases and say "still waiting" for open items
The fit-to-width minimum (480 px of plot) made the timeline overflow in
the dashboard's narrower content column; lower it to 260 px. Pending
approvals and running blocks showed "Duration 0 ms"; show "still waiting"
or "still running" instead.

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-10-26 11:38:42 +00:00
MervinPraisonandClaude Opus 5.5 bc3692ee69 docs(replay): user guide and clean-room ADR
docs/features/replay.md describes the page, keys, approvals, export and
HTTP API. docs/adr/0001-replay-view-clean-room.md records the ideas we
took (graph plus trace, score-like swimlanes, OTel GenAI vocabulary), that
no third-party UI code, CSS, icons, layouts or screenshots were used, and
the palette, accessibility and licence reasoning.

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-10-26 11:36:14 +00:00
MervinPraisonandClaude Opus 5.5 6a64157d00 fix(approvals): UI approval backend waits for the human decision
_UIStoreBackend returned "Awaiting UI approval" (a denial) immediately, so
any tool needing approval was always denied and the UI's Approve button
never reached the agent. It also dropped request.approval_id and any
edited arguments.

- each request is stored under request.approval_id and the caller blocks
  until /api/approvals/{id}/approve|deny resolves it: sync callers on a
  threading.Event, async callers on a future of their own loop
- no answer before the timeout (registry timeout, default 300 s) is a
  denial and the entry is resolved as denied (fail-closed)
- a sync call made on an event-loop thread is denied at once rather than
  freezing that loop
- POST /approve accepts modified_args (object) and the decision carries
  it as ApprovalDecision(modified_args=...); the handler change is two
  lines so the auth checks in #312 rebase cleanly

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-10-26 11:36:14 +00:00
MervinPraisonandClaude Opus 5.5 3da3d3d547 fix(tracing): read recorded runs from the trace JSONL directory
SDKTracingManager looked up praisonaiagents.trace.list_traces, list_spans
and get_trace, which do not exist, so /api/traces never showed a recorded
run and silently fell back to an empty in-memory store.

JsonlTracingManager reads the same directory as Replay: one file is one
trace, spans come from start/end pairs (parent_seq on schema 1.1, sequence
order on 1.0), and records posted to /api/traces/record still work.
health() reports source: jsonl|memory. SDKTracingManager stays as a
deprecated alias.

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-10-26 11:36:14 +00:00
MervinPraisonandClaude Opus 5.5 e46761e8a9 feat(replay): PraisonAI Replay feature and dashboard view
New `replay` feature (contract v1 §5), read-only:
- GET /api/replay/runs: run-picker rows, newest first, with a live flag
- GET /api/replay/runs/{id}/events?after_seq=N: {events, graph, run}
- GET /api/replay/runs/{id}/stream: SSE tail of the JSONL (stdlib seeks),
  `ctx` events with id=sequence_num, ping every 15 s, `end` after the run
  finishes plus 2 s quiet; honours after_seq and Last-Event-ID
- ids must match ^[A-Za-z0-9._-]+$ and resolve inside the traces dir
- traces dir: PRAISONAI_TRACES_DIR > PRAISON_HOME/traces > ~/.praison/traces
- half-written last lines, unknown fields/types and schema 1.0 are tolerated

Plain-JS view `replay` (no React, no CDN): replay-core.js holds pure
buildModel/narrate/layoutMap/deriveGraph (node --test); replay.js draws the
timeline (agent, Manager and You lanes, LLM/tool blocks, hand-off arrows,
parallel sub-lanes, xN repeats, task/step chapters, scrubber, live follow),
the narration list, What happened/Why/Raw JSON drawer, cost/latency
overlay, map view, and Approve/Deny/Edit-args for pending approvals.
Registered in BUILTIN_VIEWS and as a Control page; /#/replay?run=<id> deep
links resolve to /replay?run=<id>. It also boots standalone from
window.__REPLAY_DATA__; standalone_html() inlines both files for exports.

praisonaiui/replay_app.py + configure_replay_app() open aiui straight on
Replay for `praisonai replay view`.

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-10-26 11:36:14 +00:00
Mervin PraisonandClaude Opus 5.5 6c156990f4 ci: bump github-automation-template to v1.1.19 (#315)
Co-authored-by: Claude Opus 5.5 (1M context) <[email protected]>
2026-10-26 11:42:31 +01:00
Mervin PraisonandClaude Opus 5.5 116425519e chore(ci): github-automation-template v1.1.18 (#314)
Co-authored-by: Claude Opus 5.5 (1M context) <[email protected]>
2026-10-26 09:46:39 +01:00
Mervin PraisonandClaude Opus 5.5 129adb9285 fix(ci): github-automation-template v1.1.17 (permission ceiling) (#310)
Co-authored-by: Claude Opus 5.5 (1M context) <[email protected]>
2026-10-17 09:34:51 +01:00
Mervin PraisonandClaude Opus 5.5 78deb698b6 chore(ci): github-automation-template v1.1.16 (#309)
Co-authored-by: Claude Opus 5.5 (1M context) <[email protected]>
2026-10-17 09:27:31 +01:00
b04e0e4c2c chore(ci): bump github-automation-template to v1.1.15 (#308)
Writers-only auto-Claude, least-privilege tokens for Claude on fork PRs and
in the merge-gate assessor, merge-gate held-out BLOCKs (no repeat verdicts on
an unchanged HEAD), portable prompts. auto-pr-comment sweep moves to hourly so
stale FINAL reviews are re-posted once a push settles.

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

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

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

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

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

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

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

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

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

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

Upstream pin: slev12397/beautiful-ui@44a274e598

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

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

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

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

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

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

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

Co-Authored-By: Claude Opus 4.8 <[email protected]>
2026-09-23 13:41:43 +00:00
MervinPraisonandCursor 3058886312 chore: bump automation hub to v1.1.10 for merge gate fix
Co-authored-by: Cursor <[email protected]>
2026-09-22 15:30:07 +01:00
Mervin Praison 70d8610733 Merge pull request #291 from MervinPraison/claude/issue-290-20260916
docs: add SECURITY.md with private disclosure policy
2026-09-17 10:37:03 +01:00
MervinPraison 0f60ec41f1 Release v0.3.146 2026-09-17 03:02:10 +00:00
MervinPraisonandClaude Opus 4.8 2797667e04 docs: add SECURITY.md with private disclosure policy (fixes #290)
Co-Authored-By: Claude Opus 4.8 <[email protected]>
2026-09-16 16:28:01 +00:00
Mervin Praison f23aacdec9 Merge pull request #289 from MervinPraison/claude/issue-286-20260915
fix: auto-load ~/.praisonai/.env on aiui run so env: channel tokens resolve
2026-09-16 14:43:31 +01:00
MervinPraison 17cd4d9f86 Release v0.3.145 2026-09-16 13:00:52 +00:00
Mervin Praison 43935e4e41 fix: forward extra kwargs through channels chat bridge (fixes #287)
fix: forward extra kwargs through channels chat bridge (fixes #287)
2026-09-16 13:58:08 +01:00
MervinPraisonandClaude Opus 4.8 8dc8b9f49f fix: auto-load ~/.praisonai/.env on aiui run so env: tokens resolve (fixes #286)
Channels configured with env:VAR token references silently resolved to
empty strings when the dashboard was started without the shell exporting
the tokens, so bots showed 'Connected' but never replied. aiui run now
loads the standard PraisonAI .env files (~/.praisonai/.env, then ./.env)
into the process environment at startup, matching the PraisonAI CLI
convention. Existing process vars are never overridden and parsing is
dependency-free (no python-dotenv).

Co-Authored-By: Claude Opus 4.8 <[email protected]>
2026-09-15 13:16:37 +00:00
MervinPraisonandClaude Opus 4.8 135b05435c fix: forward extra kwargs through channels chat bridge (fixes #287)
The chat bridge installed over bot._session.chat used a fixed
(agent, user_id, text) signature. When the bot internals invoked the
session with per-conversation keyword arguments (chat_id, platform,
thread_id, ...) the wrapper raised
`TypeError: _wrapped_chat() got an unexpected keyword argument 'chat_id'`
and the bot silently failed to reply across all platforms (Telegram,
Slack, Discord).

Add `*args, **kwargs` to `_wrapped_chat` and forward them untouched to
the original session chat so memory/session continuity is preserved.
Minimal, backward-compatible change; no new params or dependencies.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
2026-09-15 13:11:51 +00:00
MervinPraisonandCursor be502d823f chore(automation): bump hub to v1.1.8 for dual OAuth failover
Uses pick-oauth-token pre-flight probe and TOKEN_B backup.

Co-authored-by: Cursor <[email protected]>
2026-09-15 07:49:29 +01:00
Mervin Praison 9be1266747 Merge pull request #285 from MervinPraison/fix/voice-doctor-mcp-probe-timeout
fix(voice-agent): warn on slow MCP doctor probe instead of failing
2026-09-14 17:45:00 +01:00
MervinPraisonandCursor 8ce13a9fec fix(voice-agent): warn on slow MCP doctor probe instead of failing
Use a 2s timeout for GET /api/mcp/servers and downgrade timeout-style
errors to warn so stdio MCP connect does not fail the whole doctor run.

Follow-up to #273 (example-only; no core SDK changes).

Co-authored-by: Cursor <[email protected]>
2026-09-14 14:51:08 +01:00
Mervin Praison e6a346aa22 Merge pull request #284 from MervinPraison/chore/automation-hub-v1.1.7
chore(automation): bump github-automation-template hub to v1.1.7
2026-09-14 14:22:51 +01:00
MervinPraisonandCursor b7b0cc2109 chore(automation): bump github-automation-template hub to v1.1.7
Fixes FINAL detection and triggerLogins fallback for merge gate auto-merge.

Co-authored-by: Cursor <[email protected]>
2026-09-14 14:22:39 +01:00
MervinPraison dc0d81fb55 Release v0.3.144 2026-09-14 13:19:10 +00:00
Mervin Praison 032f3832a7 Merge pull request #283 from MervinPraison/claude/issue-274-20260914
fix: wire chat run abort (populate _active_runs) — fixes #274
2026-09-14 14:17:05 +01:00
Mervin Praison 0c282f5268 Merge pull request #282 from MervinPraison/claude/issue-276-20260914
fix: prevent MCP subprocess leak on reconnect and failed connect
2026-09-14 14:17:02 +01:00
Mervin Praison 85f50ff9b8 Merge pull request #281 from MervinPraison/claude/issue-277-20260914
fix: make _start_channel_bot idempotent to prevent duplicate bots
2026-09-14 14:16:59 +01:00
Mervin Praison 6b79a346a7 Merge pull request #280 from MervinPraison/claude/issue-275-20260914
fix: preserve channel message metadata in SDKFileDataStore
2026-09-14 14:16:56 +01:00
Mervin Praison f2dec3bc81 Merge pull request #279 from MervinPraison/claude/issue-278-20260914
fix: bound @aiui.on_startup hooks with AIUI_STARTUP_TIMEOUT
2026-09-14 14:16:53 +01:00
Mervin Praison 169c0c2eb0 feat(voice-agent): Phases 1–4 — telephony, call analytics, doctor, and MCP manager (#272)
feat(voice-agent): Phases 1–4 — telephony, call analytics, doctor, and MCP manager
2026-09-14 14:15:23 +01:00
MervinPraisonandClaude Opus 4.8 1de8d054a8 fix: remove stale gateway task on channel bot restart (#277)
_start_channel_bot registers the task in gateway _channel_tasks, but
_stop_channel_bot only cleaned up _channel_bots. For the list-based
gateway registry this left the old cancelled task appended on every
restart, accumulating orphaned task references. Mirror start-time
registration by removing the task from _channel_tasks (dict pop / list
remove) on stop. Adds regression tests for both dict and list forms.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
2026-09-14 10:21:01 +00:00
MervinPraisonandClaude Opus 4.8 9ac4414901 fix: register chat runs in _active_runs so abort works (fixes #274)
_run_and_broadcast never inserted its task into ChatManager._active_runs
and never emitted run_started, so the Stop button, POST /api/chat/abort,
and the chat_abort WS message always returned no_active_run while tokens
were still streaming.

- Register the running task under run_id at run start
- Broadcast run_started up-front so the frontend captures run_id and can
  enable the Stop button before the first token
- Deregister the run in a finally on completion, error, or abort

Existing CancelledError handler already broadcasts run_cancelled once the
task is actually cancelled.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
2026-09-14 09:51:06 +00:00
MervinPraisonandClaude Opus 4.8 5168ea253a fix: disconnect MCP client on reconnect and failed connect to prevent subprocess leak (fixes #276)
Co-Authored-By: Claude Opus 4.8 <[email protected]>
2026-09-14 09:48:12 +00:00
MervinPraisonandClaude Opus 4.8 a739ba8a6c fix: make _start_channel_bot idempotent to prevent duplicate bots (fixes #277)
Add a guard at the top of ChannelsFeature._start_channel_bot that stops any
bot already running for the same channel_id before creating a new one. This
prevents duplicate bots (with duplicate event handlers) when a channel is
toggled off/on, re-added, or when autostart races with a manual start.

Also make _stop_channel_bot await the cancelled task so the previous bot is
fully torn down before a restart proceeds, eliminating zombie asyncio tasks.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
2026-09-14 09:47:54 +00:00
MervinPraisonandClaude Opus 4.8 112253013a fix: add AIUI_STARTUP_TIMEOUT to bound startup hooks (fixes #278)
Startup hooks ran with an unbounded await while shutdown hooks were
already protected by AIUI_SHUTDOWN_TIMEOUT. A single hung startup hook
blocked the Starlette lifespan from reaching yield, so /health/live never
responded and the dashboard never loaded.

Mirror the shutdown pattern: wrap startup hooks in asyncio.wait_for with
AIUI_STARTUP_TIMEOUT (default 60s). On timeout, log the error and mark
startup completed so the server still reaches lifespan yield. Shutdown
behaviour is unchanged.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
2026-09-14 09:47:16 +00:00
MervinPraisonandClaude Opus 4.8 ac88e1cab7 fix(mcp): close partially-entered exit stack on connect cancellation/timeout
Store the AsyncExitStack on self._exit_stack before entering the stdio/SSE
contexts. On an asyncio.wait_for timeout the inner connect() is cancelled with
CancelledError (a BaseException), which bypasses the except-Exception cleanup,
so self._exit_stack was never set and MCPClientManager.connect()'s timeout
branch called client.disconnect() as a no-op — leaking the stdio subprocess /
SSE streams. Assigning the stack up front lets disconnect() always close it.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
2026-09-10 18:55:52 +00:00
MervinPraisonandClaude Opus 4.8 14ca101086 fix(mcp): catch asyncio.TimeoutError so connect timeout works on Python 3.10
asyncio.wait_for raises asyncio.TimeoutError, which is only aliased to
the builtin TimeoutError on Python 3.11+. On the 3.10 CI leg the bare
'except TimeoutError' missed it, so the timeout fell through to the
generic handler with an empty last_error and no client.disconnect()
cleanup. Catch asyncio.TimeoutError to restore the helpful message and
subprocess/stream cleanup across all supported Pythons.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
2026-09-10 18:40:48 +00:00
Dhivya-Bharathy 30f046d0fb feat(voice-agent): Phases 2-4, call analytics, doctor, and MCP connect fix 2026-09-10 16:24:20 +05:30
123 changed files with 15862 additions and 554 deletions

No files matched your search

+2 -2
View File
@@ -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
View File
@@ -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\//,
+3 -3
View File
@@ -10,7 +10,7 @@ on:
pull_request_target:
types: [opened, synchronize]
schedule:
- cron: '30 3,9,15,21 * * *'
- cron: '30 * * * *'
workflow_dispatch:
env:
@@ -27,7 +27,7 @@ jobs:
permissions:
issues: write
pull-requests: write
uses: MervinPraison/github-automation-template/.github/workflows/[email protected].6
uses: MervinPraison/github-automation-template/.github/workflows/[email protected].20
secrets: inherit
with:
hub_ref: v1.1.6
hub_ref: v1.1.20
+2 -2
View File
@@ -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 || '' }}
+2 -2
View File
@@ -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 || '' }}
+24
View File
@@ -36,6 +36,30 @@ jobs:
- name: Integration smoke tests
run: pytest tests/integration/test_agentic_roundtrip.py tests/test_feature_sdk_backends.py -q --no-cov --timeout=120
frontend:
name: frontend
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Set up Node.js
uses: actions/setup-node@v4
with:
node-version: "22"
cache: npm
cache-dependency-path: src/frontend/package-lock.json
- name: Install dependencies
run: npm ci
working-directory: src/frontend
# `npm run build` is the dashboard ship path: dist/ is synced to
# src/praisonaiui/templates/frontend/ (see src/frontend/README.md).
- name: Build frontend
run: npm run build
working-directory: src/frontend
docs:
runs-on: ubuntu-latest
if: github.ref == 'refs/heads/main'
+5 -4
View File
@@ -43,9 +43,9 @@ jobs:
id-token: write
steps:
- uses: actions/checkout@v4
- uses: MervinPraison/github-automation-template/.github/actions/[email protected].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' }}
+2 -2
View File
@@ -18,7 +18,7 @@ jobs:
issues: write
actions: read
id-token: write
uses: MervinPraison/github-automation-template/.github/workflows/[email protected].6
uses: MervinPraison/github-automation-template/.github/workflows/[email protected].20
secrets: inherit
with:
hub_ref: v1.1.6
hub_ref: v1.1.20
+2 -2
View File
@@ -25,9 +25,9 @@ jobs:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: MervinPraison/github-automation-template/.github/actions/[email protected].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:
+2 -2
View File
@@ -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
+4
View File
@@ -366,3 +366,7 @@ pytest tests -v
## License
MIT © [Praison Limited](https://praison.ai)
Third-party vendored source is documented in [THIRD_PARTY_NOTICES.md](THIRD_PARTY_NOTICES.md).
See the [BeautifulUI upgrade playbook](docs/frontend/beautifului.md) for how BeautifulUI
primitives are vendored and pinned.
+37
View File
@@ -0,0 +1,37 @@
# Security Policy
We take the security of PraisonAIUI seriously and appreciate responsible
disclosure of vulnerabilities.
## Supported Versions
Security fixes are applied to the latest released version of PraisonAIUI.
Please make sure you are on the most recent release before reporting.
## Reporting a Vulnerability
**Please do not open a public issue for security vulnerabilities.**
Report privately through GitHub's **Private Vulnerability Reporting**:
1. Go to the [Security tab](https://github.com/MervinPraison/PraisonAIUI/security)
of this repository.
2. Click **"Report a vulnerability"** to open a private advisory.
3. Include as much detail as you can:
- Affected component(s) and version.
- Steps to reproduce.
- Impact assessment.
- Any suggested fix or mitigation.
If you are unable to use private reporting, you may open a public issue that
contains **no technical specifics** and requests a private channel, and a
maintainer will follow up.
## What to Expect
- We aim to acknowledge new reports within a few business days.
- We will keep you informed as we investigate and work on a fix.
- Once a fix is released, we are happy to credit you in the advisory (unless
you prefer to remain anonymous).
Thank you for helping keep PraisonAIUI and its users safe.
+43
View File
@@ -0,0 +1,43 @@
# Third-Party Notices
PraisonAIUI incorporates or vendors source from the third-party projects listed below.
Their licences and copyright notices are retained here as required.
---
## BeautifulUI
- Project: BeautifulUI — <https://www.beautifului.dev/>
- Source: <https://github.com/slev12397/beautiful-ui>
- Licence: MIT
BeautifulUI is distributed as a shadcn registry / copy-paste library (no npm package).
When its React primitives are vendored into `src/frontend`, the following MIT licence
applies to that copied source.
```
MIT License
Copyright (c) Shane Levine
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
```
See the upgrade playbook at [`docs/frontend/beautifului.md`](docs/frontend/beautifului.md)
for how BeautifulUI is vendored and pinned.
+5
View File
@@ -59,6 +59,11 @@ navigation:
- typescript/runtime
- typescript/nextjs
- typescript/components
- group: "Frontend"
icon: "layout"
prefix: "frontend"
pages:
- frontend/beautifului
- group: "Examples"
icon: "folder-open"
prefix: "examples"
+94
View File
@@ -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.
+56
View File
@@ -0,0 +1,56 @@
# BeautifulUI Integration Strategy
> **Epic:** [#292](https://github.com/MervinPraison/PraisonAIUI/issues/292) — BeautifulUI integration strategy for AI-native dashboard UX.
> This is the durable, agreed strategy that the linked child issues execute against.
[BeautifulUI](https://www.beautifului.dev/) (MIT, Shane Levine / Turbo) is a **copy-paste / shadcn-registry** library of React primitives for AI-native interfaces: thinking traces, streaming answers with inline sources, HITL approval cards, tool chips, task rows, context chunks, diff/records tables, prompt bars, and a reference chat harness.
There is **no published npm package** named `beautiful-ui`. It ships only as source consumed via the shadcn registry or manual copy.
## 1. Strategic decision
BeautifulUI is adopted as **vendored source**, not a runtime dependency, and is exposed to dashboard plugins **only through the existing `@praisonaiui/react` package** — never by importing upstream file paths directly. Rollout is **phased behind a feature flag**, keeping the current vanilla dashboard as the fallback until parity is proven.
Upstream stack (Next.js 15 · React 19 · Tailwind v4 · TypeScript) is closely aligned with PraisonAIUI's `src/frontend` (Vite · React 19 · Tailwind v4 · shadcn/Radix), so primitives port with adapter work rather than a runtime bridge.
## 2. Pillars
| # | Pillar | Decision |
|---|--------|----------|
| 1 | **Vendoring model** | Add primitives into `src/frontend` via `npx shadcn add https://www.beautifului.dev/r/<component>.json`, or manual copy from [github.com/slev12397/beautiful-ui](https://github.com/slev12397/beautiful-ui). Pin via lockfile plus a recorded registry URL / git SHA. No `beautiful-ui` npm dependency in production builds. |
| 2 | **Adapter layer** | Map PraisonAIUI chat / approval / trace **protocol events** to stable internal props. Primitives sit behind `@praisonaiui/react` exports; dashboard plugins consume those exports, not upstream paths. See [#294](https://github.com/MervinPraison/PraisonAIUI/issues/294). |
| 3 | **Hybrid rollout** | Mount React **islands** (or expand `@praisonaiui/react`) for high-value surfaces first — chat streaming/thinking/tools, then approvals. Vanilla chat (`templates/frontend/plugins/views/chat.js`) stays as the flag-gated fallback until parity is proven. |
| 4 | **Token strategy** | Merge BeautifulUI foundation CSS **selectively** into the existing shadcn theme generation (`praisonaiui/themes.py`). One design system per page — avoid two competing token sets. |
| 5 | **Icon licence** | Upstream `SidebarNav` uses the paid `@central-icons-react`. Port nav patterns with **lucide-react** (already in PraisonAIUI) instead. |
## 3. Phasing
1. **Spike** — evaluate primitives in the dev environment, confirm React 19 / Tailwind v4 alignment. [#293](https://github.com/MervinPraison/PraisonAIUI/issues/293)
2. **Architecture** — adapter layer + upstream pin policy. [#294](https://github.com/MervinPraison/PraisonAIUI/issues/294)
3. **CI gate** — frontend build + BeautifulUI compatibility check (no frontend build gate exists today). [#295](https://github.com/MervinPraison/PraisonAIUI/issues/295)
4. **Migrations** — chat streaming / thinking / tool chips ([#296](https://github.com/MervinPraison/PraisonAIUI/issues/296)), then the approval card ([#297](https://github.com/MervinPraison/PraisonAIUI/issues/297)).
5. **Docs** — upgrade playbook + MIT attribution. [#298](https://github.com/MervinPraison/PraisonAIUI/issues/298)
## 4. Risks
| Risk | Mitigation |
|------|------------|
| No semver npm package | Vendor the source; pin to a recorded registry URL / git SHA in the lockfile. |
| Upstream breaking registry JSON | Vendoring decouples builds from live registry; re-sync is a deliberate, reviewed step. |
| Icon licence (`@central-icons-react` is paid) | Use lucide-react for ported nav patterns; never vendor the paid package. |
| Two competing design systems on one page | Selective CSS token merge into `themes.py`; adapter-only prop surface. |
| React / Tailwind drift | Upstream (React 19 / Tailwind v4) already matches `src/frontend`; the CI gate catches drift. |
## 5. Out of scope
- Replacing the entire dashboard shell in one PR.
- PyPI packaging of BeautifulUI.
- iframe embedding of beautifului.dev.
- Depending on any unpublished npm package for production builds.
## 6. Acceptance criteria status
- [x] Written integration strategy agreed (vendoring + adapter + phased rollout) — this document.
- [x] Risks documented — section 4.
- [x] Child issues filed and prioritised — [#293](https://github.com/MervinPraison/PraisonAIUI/issues/293)–[#298](https://github.com/MervinPraison/PraisonAIUI/issues/298).
- [x] No requirement to depend on unpublished npm packages for production builds — pillar 1, out of scope.
+176
View File
@@ -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.
+35
View File
@@ -0,0 +1,35 @@
# BeautifulUI upstream pin record
BeautifulUI is vendored (shadcn-style registry copy), **not** an npm dependency.
This file is the single source of truth for what upstream revision is vendored.
Update it in the **same PR** that adds or bumps any vendored component, and make
sure the frontend CI gate (issue #295) passes.
See [beautifului-adapter-architecture.md](./beautifului-adapter-architecture.md)
for the adapter boundary and folder layout.
## Pin
| Field | Value |
|-------|-------|
| Upstream repo | _TBD — record on first vendor_ |
| Git SHA | _TBD_ |
| Vendored on (date) | _TBD_ |
| Registry base URL | _TBD_ |
| License | MIT (kept in-tree) |
## Vendored components
Record one row per component the first time it is copied in.
| Component | Registry URL | SHA | Vendored path | Date |
|-----------|--------------|-----|---------------|------|
| _(none yet)_ | | | | |
## Upgrade checklist
1. Update the SHA / date / registry rows above.
2. Re-copy changed components into `src/frontend/src/agent-ui/vendors/beautifului/`.
3. Keep all upstream imports inside `src/agent-ui/` (adapter boundary rule).
4. Run the frontend build + adapter unit tests (frontend CI gate, #295).
5. Do **not** add a `beautiful-ui` entry to `package.json` dependencies.
@@ -0,0 +1,102 @@
# ADR: BeautifulUI adapter layer & upstream pin policy
**Status:** Accepted · **Issue:** [#294](https://github.com/MervinPraison/PraisonAIUI/issues/294)
## Context
BeautifulUI is **not** a semver npm library — it is distributed as shadcn-style
registry components that are copied into the consuming repo. PraisonAIUI must
integrate it without leaking upstream types or import paths across the codebase,
and without breaking when either side updates.
## Decision
Introduce a single **adapter boundary** between transport payloads and the UI,
and vendor BeautifulUI components behind that boundary.
```
Agent APIs (Python) ──► event bus / WS messages
│
▼
Agent-UI adapter (src/agent-ui/adapter.ts)
(stable PraisonAIUI contracts)
│
▼
@praisonaiui/react re-exports
(BeautifulUI vendored components)
│
▼
Dashboard / chat mount points (React islands, then full shell)
```
### 1. Folder layout
```
src/frontend/src/agent-ui/
├── contracts.ts # Stable PraisonAIUI view models (the ONLY types consumers import)
├── adapter.ts # Pure mappers: wire payload -> contracts
├── adapter.test.ts # Unit tests (mock events -> props), node:test
├── index.ts # Barrel: contracts + adapters + (later) vendored re-exports
└── vendors/
└── beautifului/ # Vendored BeautifulUI components (added during migration)
```
**Rule:** BeautifulUI / upstream prop types must **never** be imported outside
`src/agent-ui/`. Consumers depend on `contracts.ts` only, so the vendored
implementation can be swapped later without touching call sites.
### 2. Adapter contracts
Internal, transport-agnostic types live in `contracts.ts`:
- `AgentStreamEvent` — normalized form of the WebSocket frames handled in
`chat.js` (`run_started`, `run_content`, `tool_call_started`, …). Unknown
frame `type`s degrade to `kind: 'unknown'` with `rawType` preserved instead
of throwing, so new upstream events never break the UI.
- `ToolCallChipModel` — tool-call chip view model.
- `ApprovalPromptModel` — approval card view model, mapped from the
`/api/approvals/pending` REST payload.
Mappers (`adapter.ts`): `mapChatEventToUi(frame)` and
`mapApprovalToCardProps(item)`. Both are **pure** and unit tested.
### 3. Vendoring & pin policy
| Mechanism | Purpose |
|-----------|---------|
| [`BEAUTIFULUI_UPSTREAM.md`](./BEAUTIFULUI_UPSTREAM.md) | Record git SHA, date, registry base URL for every vendored component |
| shadcn registry URLs | Per-component install, pinned in the upstream doc |
| **No** `dependencies.beautiful-ui` in `package.json` | Avoid a phantom npm package |
**Upgrade rule:** bump upstream **only** via a PR that updates
`BEAUTIFULUI_UPSTREAM.md` and passes the frontend CI gate (issue #295). Icons
stay on `lucide-react`; do not vendor paid Central icons.
### 4. Feature flag (default **off**)
Integration is gated so the existing vanilla-JS chat/approvals plugins remain
the fallback until migration milestones (#296, #297) complete.
- **Flag:** `AIUI_BEAUTIFULUI` (build-time env / dashboard config JSON).
- **Default:** off — vanilla JS renders; the adapter is imported but not mounted.
- **On:** React islands mount using `src/agent-ui` contracts.
- **Fallback:** if a vendored component fails to load, the flag is treated as
off and the vanilla path renders. No user-facing regression when off.
### 5. Design tokens
Scope agent-UI primitives under an `.aiui-agent-ui { … }` CSS layer (or extend
the existing shadcn presets in `themes.py`) so vendored component tokens cannot
collide with the global dashboard theme.
### 6. Deprecation / upstream abandonment
Vendored code stays MIT-licensed in-tree. Because consumers depend only on the
`contracts.ts` view models, a future first-party implementation can satisfy the
same contracts and replace the vendored components with no call-site churn.
## Consequences
- One place (`src/agent-ui/`) owns the upstream coupling.
- Adapters are testable without a browser or the transport layer.
- The default-off flag keeps the migration reversible at every step.
+107
View File
@@ -0,0 +1,107 @@
# BeautifulUI Upgrade Playbook
How PraisonAIUI vendors [BeautifulUI](https://www.beautifului.dev/) React primitives so
upgrades are **repeatable** and licence obligations are **explicit**.
!!! info "Related planning"
Epic [#292](https://github.com/MervinPraison/PraisonAIUI/issues/292) (integration
strategy) and the adapter/pin policy in
[#294](https://github.com/MervinPraison/PraisonAIUI/issues/294).
## 1. What BeautifulUI is
BeautifulUI (MIT, © Shane Levine / Turbo) is a library of React primitives aimed at
**AI-native interfaces** — thinking traces, streaming answers with inline sources,
human-in-the-loop approval cards, tool chips, task rows, context chunks, and a reference
chat harness.
**It is a shadcn registry + copy-paste library — not an npm package.** There is no
published `beautiful-ui` package. You install components by resolving a registry URL, and
the source is copied into your tree (like any other shadcn/ui primitive).
- Website & registry: <https://www.beautifului.dev/>
- Upstream source: <https://github.com/slev12397/beautiful-ui>
Upstream stack (Next.js 15 · React 19 · Tailwind CSS v4 · TypeScript) is closely aligned
with PraisonAIUI's `src/frontend` (Vite · React 19 · Tailwind v4 · shadcn/Radix), which is
why copy-paste vendoring works without a framework shim.
## 2. One-time foundation setup
PraisonAIUI already carries the shadcn/ui foundation that BeautifulUI primitives depend on:
- **shadcn config** — `src/frontend/components.json` (`style: new-york`,
`baseColor: neutral`, `iconLibrary: lucide`, `cssVariables: true`). Its `registries`
map is where pinned BeautifulUI registry URLs are added (see §3).
- **Theme tokens** — `src/praisonaiui/themes.py` generates the shadcn CSS variables
(`--background`, `--foreground`, `--primary`, `--radius`, …) at build time for all 22
official presets, offline. BeautifulUI's foundation CSS (upstream `app/globals.css`)
must only contribute **new** tokens/utilities; it must **not** redefine variables that
`themes.py` already owns, or theme switching and dark mode break.
**Rule:** when merging upstream `app/globals.css`, diff it against the variables emitted by
`themes.py` and keep only the additive slices (new keyframes, component-scoped utilities).
Record what was merged in the pin doc (§3).
## 3. Adding or updating a primitive
1. **Install with a pinned registry URL** from `src/frontend/`:
```bash
npx shadcn add https://www.beautifului.dev/r/<component>.json
```
The registry resolves dependencies and CSS slices. Commit the resulting
`package-lock.json` change in the same PR.
2. **Re-export through the adapter** — add the component to
`src/frontend/src/components/index.ts` (`@praisonaiui/react`). Consumers never import
the raw vendored path (see §4).
3. **Update the pin record** — create/update `docs/frontend/BEAUTIFULUI_UPSTREAM.md` with:
the upstream **git SHA**, the **date**, the **registry base URL**, and the **component
list** vendored so far. This file is the single source of truth for "what version are
we on". (It is created the first time a real primitive is vendored — do not add it
empty.)
4. **Rebuild & sync the bundle** — the committed frontend bundle must be regenerated:
```bash
cd src/frontend && npm run build
# then sync dist/{index.html,assets/,icon.svg} → src/praisonaiui/templates/frontend/
```
(See `AGENTS.md` → "Frontend Build".)
5. **Run frontend CI** — `.github/workflows/ci.yml` (ruff + pytest) must pass, plus the
frontend build step.
## 4. Adapter boundary
Consumers import from **`@praisonaiui/react` only** — never from raw vendored paths such as
`@/components/ui/...` or any upstream module.
```tsx
// ✅ stable PraisonAIUI contract
import { Button, ThemeToggle } from '@praisonaiui/react'
// ❌ leaks upstream/shadcn paths across the codebase
import { Button } from '@/components/ui/button'
```
This keeps the upstream surface behind one file (`components/index.ts`), so a BeautifulUI
bump changes vendored source but not consumer imports.
## 5. Rollback
- BeautifulUI-backed views mount behind a **feature flag**; the **vanilla dashboard views**
(`src/praisonaiui/templates/frontend/plugins/*`) remain the fallback and are kept working.
- To roll back a bad bump: revert the vendoring PR (source + `BEAUTIFULUI_UPSTREAM.md` +
rebuilt bundle move together in one commit), and disable the flag. No consumer import
changes are needed because everything routes through the adapter (§4).
## 6. Licence
BeautifulUI is **MIT, © Shane Levine**. The full licence text is retained in
[`THIRD_PARTY_NOTICES.md`](https://github.com/MervinPraison/PraisonAIUI/blob/main/THIRD_PARTY_NOTICES.md)
at the repository root. Any PR that vendors or bumps BeautifulUI source must keep that
notice present and current.
## 7. Commercial icons — do not vendor
Do **not** use `@central-icons-react` without a licence — it is commercial. PraisonAIUI
standardises on **`lucide-react`** (`iconLibrary: lucide` in `components.json`). If an
upstream BeautifulUI primitive imports commercial icons, swap them for lucide equivalents
during vendoring.
+35
View File
@@ -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=
+67 -3
View File
@@ -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).
+153 -76
View File
@@ -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__":
+41
View File
@@ -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})
+781
View File
@@ -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())
+247 -40
View File
@@ -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."
+79 -9
View File
@@ -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)
+7
View File
@@ -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
View File
@@ -1,6 +1,6 @@
[project]
name = "aiui"
version = "0.3.143"
version = "0.3.151"
description = "YAML-driven website generator - CLI and compiler"
readme = "README.md"
license = {text = "MIT"}
+23 -2
View File
@@ -1,6 +1,27 @@
# React + TypeScript + Vite
# PraisonAIUI Frontend (React + TypeScript + Vite)
This template provides a minimal setup to get React working in Vite with HMR and some ESLint rules.
The BeautifulUI dashboard source. Built with React 19 + Vite 7 + Tailwind v4.
## Build & CI
- **Node.js:** 22 (matches the `frontend` job in `.github/workflows/ci.yml`)
- **Lockfile:** `src/frontend/package-lock.json` — install with `npm ci`
```bash
cd src/frontend
npm ci
npm run build # SPA build → dist/ (dashboard ship path)
npm run build:lib # library build for the @praisonaiui/react package
npm run lint # eslint
```
`npm run build` output in `dist/` is synced to `src/praisonaiui/templates/frontend/`
(the committed bundle served by `aiui build`). The CI `frontend` job runs
`npm ci && npm run build` on PRs to `main` and fails on TypeScript/Vite errors.
## Vite template notes
This project uses a minimal setup to get React working in Vite with HMR and some ESLint rules.
Currently, two official plugins are available:
+87 -23
View File
@@ -82,20 +82,6 @@
}
}
},
"node_modules/@a2ui/markdown-it": {
"version": "0.0.4",
"resolved": "https://registry.npmjs.org/@a2ui/markdown-it/-/markdown-it-0.0.4.tgz",
"integrity": "sha512-uaYNywdiHDH4i//pHpyckFnApvOshiUNT2Xl8awceNjL1XAX/gqtki9GRUJQ26RE7id2+aPNgOy1W6mXNBxriQ==",
"dev": true,
"license": "Apache-2.0",
"dependencies": {
"dompurify": "^3.3.1",
"markdown-it": "^14.1.0"
},
"peerDependencies": {
"@a2ui/web_core": "^0.9.2"
}
},
"node_modules/@a2ui/react": {
"version": "0.10.0",
"resolved": "https://registry.npmjs.org/@a2ui/react/-/react-0.10.0.tgz",
@@ -129,16 +115,16 @@
}
},
"node_modules/@a2ui/web_core": {
"version": "0.10.0",
"resolved": "https://registry.npmjs.org/@a2ui/web_core/-/web_core-0.10.0.tgz",
"integrity": "sha512-pSieKxaB6aZWBL2LjZfKo4/1v4xMWylczpzte1ehuGH0kfNGf/iTd73PKsIKYx3b0FNWTbD8gAloOM4JCVqUig==",
"version": "0.10.2",
"resolved": "https://registry.npmjs.org/@a2ui/web_core/-/web_core-0.10.2.tgz",
"integrity": "sha512-IfS4K7baTL9P9AzUOzKRWzL9A9/+Z7TCgMY2GPwsZ0H7LgY/9/uJnlbReNhpHsAfN12KZtW9iERWkw5HOf71GA==",
"dev": true,
"license": "Apache-2.0",
"dependencies": {
"@preact/signals-core": "^1.13.0",
"date-fns": "^4.1.0",
"@preact/signals-core": "^1.14.2",
"date-fns": "^4.4.0",
"zod": "^3.25.76",
"zod-to-json-schema": "^3.25.1"
"zod-to-json-schema": "^3.25.2"
}
},
"node_modules/@a2ui/web_core/node_modules/zod": {
@@ -5502,9 +5488,9 @@
}
},
"node_modules/date-fns": {
"version": "4.1.0",
"resolved": "https://registry.npmjs.org/date-fns/-/date-fns-4.1.0.tgz",
"integrity": "sha512-Ukq0owbQXxa/U3EGtsdVBkR1w7KOQ5gIBqdH2hkvknzZPYvBxb/aa6E8L7tmjFtkwZBu3UXBbjIgPo/Ez4xaNg==",
"version": "4.4.0",
"resolved": "https://registry.npmjs.org/date-fns/-/date-fns-4.4.0.tgz",
"integrity": "sha512-+1UMbeh68lH1SegH83CGWwpb6OHHbpSgr3+s5Eww5M4CAgswBpoWS0AjTOfEJ33HiYKz1hdj/KTFprzXHmq/6w==",
"license": "MIT",
"funding": {
"type": "github",
@@ -9747,6 +9733,84 @@
"type": "github",
"url": "https://github.com/sponsors/wooorm"
}
},
"node_modules/@a2ui/react/node_modules/@a2ui/markdown-it": {
"version": "0.0.4",
"resolved": "https://registry.npmjs.org/@a2ui/markdown-it/-/markdown-it-0.0.4.tgz",
"integrity": "sha512-uaYNywdiHDH4i//pHpyckFnApvOshiUNT2Xl8awceNjL1XAX/gqtki9GRUJQ26RE7id2+aPNgOy1W6mXNBxriQ==",
"dev": true,
"license": "Apache-2.0",
"dependencies": {
"dompurify": "^3.3.1",
"markdown-it": "^14.1.0"
},
"peerDependencies": {
"@a2ui/web_core": "^0.9.2"
}
},
"node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@emnapi/core": {
"version": "1.8.1",
"dev": true,
"inBundle": true,
"license": "MIT",
"optional": true,
"dependencies": {
"@emnapi/wasi-threads": "1.1.0",
"tslib": "^2.4.0"
}
},
"node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@emnapi/runtime": {
"version": "1.8.1",
"dev": true,
"inBundle": true,
"license": "MIT",
"optional": true,
"dependencies": {
"tslib": "^2.4.0"
}
},
"node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@emnapi/wasi-threads": {
"version": "1.1.0",
"dev": true,
"inBundle": true,
"license": "MIT",
"optional": true,
"dependencies": {
"tslib": "^2.4.0"
}
},
"node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@napi-rs/wasm-runtime": {
"version": "1.1.1",
"dev": true,
"inBundle": true,
"license": "MIT",
"optional": true,
"dependencies": {
"@emnapi/core": "^1.7.1",
"@emnapi/runtime": "^1.7.1",
"@tybys/wasm-util": "^0.10.1"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/Brooooooklyn"
}
},
"node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@tybys/wasm-util": {
"version": "0.10.1",
"dev": true,
"inBundle": true,
"license": "MIT",
"optional": true,
"dependencies": {
"tslib": "^2.4.0"
}
},
"node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/tslib": {
"version": "2.8.1",
"dev": true,
"inBundle": true,
"license": "0BSD",
"optional": true
}
}
}
+2 -1
View File
@@ -29,7 +29,8 @@
"build": "tsc -b && vite build",
"build:lib": "tsc -b && vite build --mode lib",
"lint": "eslint .",
"preview": "vite preview"
"preview": "vite preview",
"test": "node --experimental-strip-types --test \"src/agent-ui/**/*.test.ts\""
},
"keywords": [
"react",
+13
View File
@@ -0,0 +1,13 @@
<!doctype html>
<!-- Spike #293 dev harness. Open with `npm run dev` → http://localhost:5173/spike.html -->
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>BeautifulUI spike · PraisonAIUI #293</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/spike/main.tsx"></script>
</body>
</html>
+104
View File
@@ -0,0 +1,104 @@
/**
* Unit tests for the Agent-UI adapter (mock events -> stable contracts).
*
* Dependency-free: uses the Node built-in test runner so it needs no extra
* toolchain. Run with `npm test` (from src/frontend), which invokes the Node
* test runner with `--experimental-strip-types` so the TypeScript types are
* stripped on Node 22+. The frontend CI gate (see issue #295) wires this in.
*/
import { test } from 'node:test'
import assert from 'node:assert/strict'
import { mapApprovalToCardProps, mapChatEventToUi } from './adapter.ts'
test('mapChatEventToUi normalizes a streaming token frame', () => {
const event = mapChatEventToUi({
type: 'run_content',
token: 'Hello',
agent_name: 'Researcher',
session_id: 's1',
run_id: 'r1',
})
assert.equal(event.kind, 'run-token')
assert.equal(event.text, 'Hello')
assert.equal(event.agentName, 'Researcher')
assert.equal(event.sessionId, 's1')
assert.equal(event.runId, 'r1')
})
test('mapChatEventToUi maps a started tool call to a running chip', () => {
const event = mapChatEventToUi({
type: 'tool_call_started',
name: 'search',
description: '🔎 Searching the web',
icon: '🔎',
step_number: 2,
tool_call_id: 'tc-1',
})
assert.equal(event.kind, 'tool-call')
assert.deepEqual(event.toolCall, {
id: 'tc-1',
label: '🔎 Searching the web',
icon: '🔎',
stepNumber: 2,
status: 'running',
error: undefined,
})
})
test('mapChatEventToUi marks completed tool calls as done', () => {
const event = mapChatEventToUi({ type: 'tool_call_completed', name: 'search' })
assert.equal(event.toolCall?.status, 'done')
// Falls back to the tool name for id and label when unspecified.
assert.equal(event.toolCall?.id, 'search')
assert.equal(event.toolCall?.label, 'search')
})
test('mapChatEventToUi treats team_* frames the same as their solo variant', () => {
assert.equal(mapChatEventToUi({ type: 'team_run_content', token: 'x' }).kind, 'run-token')
assert.equal(mapChatEventToUi({ type: 'team_run_error', error: 'boom' }).kind, 'run-error')
})
test('mapChatEventToUi preserves unknown frame types without throwing', () => {
const event = mapChatEventToUi({ type: 'future_event', foo: 'bar' })
assert.equal(event.kind, 'unknown')
assert.equal(event.rawType, 'future_event')
})
test('mapChatEventToUi defaults run errors to a readable message', () => {
const event = mapChatEventToUi({ type: 'run_error' })
assert.equal(event.kind, 'run-error')
assert.equal(event.error, 'Unknown error')
})
test('mapApprovalToCardProps maps a pending approval item', () => {
const card = mapApprovalToCardProps({
id: 'a-1',
tool_name: 'delete_file',
agent_name: 'Ops',
risk_level: 'high',
risk_icon: '🔥',
description: 'Deletes a file',
arguments: { path: '/tmp/x' },
// Approvals API emits epoch seconds (time.time()), not an ISO string.
created_at: 1758585600,
})
assert.equal(card.id, 'a-1')
assert.equal(card.toolName, 'delete_file')
assert.equal(card.agentName, 'Ops')
assert.equal(card.riskLevel, 'high')
assert.equal(card.riskIcon, '🔥')
assert.equal(card.argumentsJson, '{\n "path": "/tmp/x"\n}')
// Epoch seconds are normalized to an ISO-8601 string for display.
assert.equal(card.createdAt, '2025-09-23T00:00:00.000Z')
})
test('mapApprovalToCardProps falls back for missing/invalid fields', () => {
const card = mapApprovalToCardProps({ id: 'a-2', risk_level: 'nonsense' })
assert.equal(card.toolName, 'unknown')
assert.equal(card.agentName, 'Unknown')
assert.equal(card.riskLevel, 'medium')
assert.equal(card.riskIcon, '⚠️')
assert.equal(card.argumentsJson, '{}')
})
+177
View File
@@ -0,0 +1,177 @@
/**
* PraisonAIUI Agent-UI adapter
*
* Pure mapping functions from wire payloads to stable PraisonAIUI contracts.
* This is the single boundary between transport shapes (WebSocket frames from
* chat.js, REST payloads from /api/approvals/*) and the UI layer. Vendored
* BeautifulUI components are rendered from these contracts elsewhere in
* `src/agent-ui/`; consumers never import upstream prop types directly.
*
* Functions are intentionally side-effect free so they can be unit tested
* with mock events (see adapter.test.ts).
*/
import type {
AgentStreamEvent,
ApprovalPromptModel,
ApprovalRiskLevel,
ToolCallChipModel,
} from './contracts'
/** Raw WebSocket frame as parsed in chat.js (`JSON.parse(event.data)`). */
export type RawChatFrame = Record<string, unknown>
/** Raw approval item as returned by `/api/approvals/pending`. */
export type RawApprovalItem = Record<string, unknown>
const VALID_RISK_LEVELS: readonly ApprovalRiskLevel[] = [
'low',
'medium',
'high',
'critical',
]
function asString(value: unknown): string | undefined {
return typeof value === 'string' ? value : undefined
}
function asNumber(value: unknown): number | undefined {
return typeof value === 'number' ? value : undefined
}
/**
* Normalize a `created_at` value to an ISO-8601 string. The approvals API
* emits epoch seconds (`time.time()`), while some payloads already carry an
* ISO string — accept both, drop anything else.
*/
function asTimestamp(value: unknown): string | undefined {
if (typeof value === 'string') return value
if (typeof value === 'number' && Number.isFinite(value)) {
return new Date(value * 1000).toISOString()
}
return undefined
}
/** Map a raw tool-call frame to a stable chip view model. */
function mapToolCall(
frame: RawChatFrame,
status: ToolCallChipModel['status'],
): ToolCallChipModel {
const name = asString(frame.name) ?? 'tool'
return {
id: asString(frame.tool_call_id) ?? name,
label: asString(frame.description) ?? name,
icon: asString(frame.icon),
stepNumber: asNumber(frame.step_number),
status,
error: asString(frame.error),
}
}
/**
* Map a raw WebSocket chat frame to a normalized {@link AgentStreamEvent}.
*
* Unknown frame types are preserved as `kind: 'unknown'` with `rawType` set,
* so new upstream event types degrade gracefully instead of throwing.
*/
export function mapChatEventToUi(frame: RawChatFrame): AgentStreamEvent {
const rawType = asString(frame.type)
const sessionId = asString(frame.session_id)
const runId = asString(frame.run_id)
const agentName = asString(frame.agent_name)
switch (rawType) {
case 'run_started':
return { kind: 'run-started', sessionId, runId, agentName }
case 'run_content':
case 'team_run_content':
return {
kind: 'run-token',
sessionId,
runId,
agentName,
text: asString(frame.token),
}
case 'run_completed':
case 'team_run_completed':
return {
kind: 'run-completed',
sessionId,
runId,
agentName,
text: asString(frame.content),
}
case 'run_error':
case 'team_run_error':
return {
kind: 'run-error',
sessionId,
runId,
agentName,
error: asString(frame.error) ?? 'Unknown error',
}
case 'tool_call_started':
case 'team_tool_call_started':
return {
kind: 'tool-call',
sessionId,
runId,
agentName,
toolCall: mapToolCall(frame, 'running'),
}
case 'tool_call_completed':
case 'team_tool_call_completed':
return {
kind: 'tool-call',
sessionId,
runId,
agentName,
toolCall: mapToolCall(frame, 'done'),
}
case 'reasoning_step':
case 'team_reasoning_step':
return {
kind: 'reasoning-step',
sessionId,
runId,
agentName,
text: asString(frame.step),
}
default:
return { kind: 'unknown', sessionId, runId, agentName, rawType }
}
}
function normalizeRisk(value: unknown): ApprovalRiskLevel {
return VALID_RISK_LEVELS.includes(value as ApprovalRiskLevel)
? (value as ApprovalRiskLevel)
: 'medium'
}
/**
* Map a raw approval item from `/api/approvals/pending` to a stable
* {@link ApprovalPromptModel}. Arguments are pretty-printed for display and
* an unknown/missing risk level falls back to `medium`.
*/
export function mapApprovalToCardProps(
item: RawApprovalItem,
): ApprovalPromptModel {
let argumentsJson = '{}'
try {
argumentsJson = JSON.stringify(item.arguments ?? {}, null, 2)
} catch {
// Circular or non-serializable args — degrade to empty object.
argumentsJson = '{}'
}
return {
id: asString(item.id) ?? '',
toolName: asString(item.tool_name) ?? 'unknown',
agentName: asString(item.agent_name) ?? 'Unknown',
riskLevel: normalizeRisk(item.risk_level),
riskIcon: asString(item.risk_icon) ?? '⚠️',
description: asString(item.description),
argumentsJson,
createdAt: asTimestamp(item.created_at),
}
}
+69
View File
@@ -0,0 +1,69 @@
/**
* PraisonAIUI Agent-UI contracts
*
* Stable, framework-agnostic view models owned by PraisonAIUI. These are the
* ONLY types that non-adapter code (chat views, dashboards, tests) should
* depend on. BeautifulUI / vendored component prop types must never leak
* outside `src/agent-ui/` — the adapter maps to these contracts instead so
* the upstream implementation can be swapped without touching consumers.
*
* See docs/frontend/beautifului-adapter-architecture.md.
*/
/** Discriminant for a normalized agent stream event. */
export type AgentStreamEventKind =
| 'run-started'
| 'run-token'
| 'run-completed'
| 'run-error'
| 'tool-call'
| 'reasoning-step'
| 'unknown'
/**
* Normalized agent stream event — the stable shape UI code consumes,
* decoupled from the raw WebSocket frame `type` strings handled in chat.js.
*/
export interface AgentStreamEvent {
kind: AgentStreamEventKind
/** Owning session, when the frame carried one. */
sessionId?: string
/** Run identifier, when the frame carried one. */
runId?: string
/** Human-facing agent name, when present. */
agentName?: string
/** Streaming token or final content, depending on `kind`. */
text?: string
/** Populated for `kind === 'tool-call'`. */
toolCall?: ToolCallChipModel
/** Populated for `kind === 'run-error'`. */
error?: string
/** Raw frame `type` for events we do not yet normalize (`kind === 'unknown'`). */
rawType?: string
}
/** View model for a tool-call chip, independent of any component library. */
export interface ToolCallChipModel {
id: string
label: string
icon?: string
stepNumber?: number
status: 'running' | 'done' | 'error'
error?: string
}
/** Risk levels surfaced by the approvals REST API (`/api/approvals/*`). */
export type ApprovalRiskLevel = 'low' | 'medium' | 'high' | 'critical'
/** View model for an approval prompt/card, independent of any component library. */
export interface ApprovalPromptModel {
id: string
toolName: string
agentName: string
riskLevel: ApprovalRiskLevel
riskIcon: string
description?: string
/** Pretty-printed tool arguments, ready for display. */
argumentsJson: string
createdAt?: string
}
+19
View File
@@ -0,0 +1,19 @@
/**
* Agent-UI boundary barrel.
*
* Consumers import PraisonAIUI's stable agent-UI contracts and adapters from
* here. Vendored BeautifulUI components live under `src/agent-ui/vendors/` and
* are re-exported from this folder only — no upstream prop types should be
* imported outside `src/agent-ui/`.
*/
export type {
AgentStreamEvent,
AgentStreamEventKind,
ApprovalPromptModel,
ApprovalRiskLevel,
ToolCallChipModel,
} from './contracts'
export type { RawApprovalItem, RawChatFrame } from './adapter'
export { mapApprovalToCardProps, mapChatEventToUi } from './adapter'
+1 -1
View File
@@ -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)) {
+1 -1
View File
@@ -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"
+2 -2
View File
@@ -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}
/>
+7 -34
View File
@@ -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
-1
View File
@@ -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'
+44
View File
@@ -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 }
+1 -1
View File
@@ -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);
+28 -3
View File
@@ -5,8 +5,33 @@
* incoming messages from parent.
*/
import { useEffect, useCallback, useRef } from 'react';
import { useSSE } from './useSSE';
import { useEffect, useCallback, useRef, useState } from 'react';
interface EventSourceOptions {
enabled?: boolean;
onMessage?: (event: MessageEvent) => void;
onOpen?: () => void;
onError?: () => void;
}
/**
* Minimal EventSource subscription. (useSSE in this package is the chat
* streaming store, not a generic SSE hook, so it cannot open this stream.)
*/
function useEventSource(url: string, { enabled = true, onMessage, onOpen, onError }: EventSourceOptions) {
const [connected, setConnected] = useState(false);
const handlers = useRef({ onMessage, onOpen, onError });
handlers.current = { onMessage, onOpen, onError };
useEffect(() => {
if (!enabled || typeof EventSource === 'undefined') return;
const source = new EventSource(url);
source.onopen = () => { setConnected(true); handlers.current.onOpen?.(); };
source.onmessage = (event) => handlers.current.onMessage?.(event);
source.onerror = () => { setConnected(false); handlers.current.onError?.(); };
return () => { source.close(); setConnected(false); };
}, [url, enabled]);
return { connected };
}
export interface WindowMessageData {
type: string;
@@ -85,7 +110,7 @@ export function useWindowMessage(options: UseWindowMessageOptions = {}): UseWind
const isIframe = window !== window.parent;
// SSE connection for outbound messages
const { connected } = useSSE('/sse/window-message', {
const { connected } = useEventSource('/sse/window-message', {
enabled: enabled && isIframe,
onMessage: (event) => {
try {
@@ -7,9 +7,7 @@ import React, { useState } from 'react';
import { Button } from '../components/ui/button';
import { Input } from '../components/ui/input';
import { Label } from '../components/ui/label';
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '../components/ui/select';
import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle, DialogFooter } from '../components/ui/dialog';
import { Separator } from '../components/ui/separator';
import { Tabs, TabsContent, TabsList, TabsTrigger } from '../components/ui/tabs';
interface AddMCPServerModalProps {
+9 -11
View File
@@ -1,7 +1,7 @@
import { useEffect, useState } from 'react';
import { useParams } from 'react-router-dom';
import { Eye, AlertCircle } from 'lucide-react';
import { ChatMessages } from '../chat/ChatMessages';
import type { ChatMessage } from '../types';
import { Button } from '../components/ui/button';
interface SharedThreadData {
@@ -11,18 +11,13 @@ interface SharedThreadData {
title: string;
created_at: string;
};
messages: Array<{
id: string;
role: string;
content: string;
timestamp: string;
toolCalls?: any[];
}>;
messages: ChatMessage[];
read_only: boolean;
}
export function SharedThread() {
const { token } = useParams<{ token: string }>();
// Served at /shared/{token} (server.py); this app has no client-side router.
const token = decodeURIComponent(window.location.pathname.split('/shared/')[1]?.split('/')[0] ?? '');
const [threadData, setThreadData] = useState<SharedThreadData | null>(null);
const [isLoading, setIsLoading] = useState(true);
const [error, setError] = useState<string>('');
@@ -128,10 +123,13 @@ export function SharedThread() {
<div className="bg-white rounded-lg shadow-sm border border-gray-200 overflow-hidden">
{threadData.messages.length > 0 ? (
<div className="divide-y divide-gray-100">
<ChatMessages
<ChatMessages
messages={threadData.messages}
sessionId={threadData.thread_id}
isSharedView={true}
currentResponse=""
thinkingSteps={[]}
toolCalls={[]}
isStreaming={false}
/>
</div>
) : (
+112
View File
@@ -0,0 +1,112 @@
# Spike #293 — BeautifulUI primitives in PraisonAIUI
Time-boxed evaluation proving BeautifulUI's AI-native primitives can build inside
PraisonAIUI's React 19 + Vite + Tailwind v4 toolchain and render against **mock,
PraisonAIUI-shaped** agent event streams.
**This folder is a spike. It is intentionally isolated from the shipped bundle:**
nothing here is imported by `src/main.tsx`, and none of it is synced into
`src/praisonaiui/templates/frontend`. Removing `src/frontend/src/spike/` and
`src/frontend/spike.html` fully reverts the spike.
## How to run
```bash
cd src/frontend
npm install
npm run dev # then open http://localhost:5173/spike.html
```
`SpikePage.tsx` drives the three primitives from `mockEvents.ts`, which uses the
real `SSEEvent` contract from `src/types.ts` (`run_content` deltas,
`reasoning_step` / `tool_call_started` / `tool_call_completed`, and an
`approval_request` payload shaped like a row from `GET /api/approvals`).
## What was vendored (upstream pin)
- Registry index: `https://www.beautifului.dev/r/registry.json`
- Component JSON: `https://www.beautifului.dev/r/<name>.json`
- Upstream repo: `slev12397/beautiful-ui` @ **`44a274e598395ab61e7c96c26fda2758780253b7`** (2026-09-22)
- License: **MIT** (upstream repo)
| Primitive | Registry item | Source | Registry deps | npm deps |
|---|---|---|---|---|
| StreamingText | `streaming-text` | `beautifui/primitives/StreamingText.tsx` (10.3 KB) | foundation | **none** (`react` only) |
| ThinkingState | `thinking-state` | `beautifui/primitives/ThinkingState.tsx` (11.3 KB) | foundation | **none** (`react` only) |
| ApprovalCard | `approval-card` | `beautifui/primitives/ApprovalCard.tsx` (16.5 KB) | foundation, button, glide-menu | **none** (`react` only) |
| Button (dep) | `button` | `beautifui/atoms/Button.tsx` (2.0 KB) | foundation | `class-variance-authority` *(already a PraisonAIUI dep)* |
| GlideMenu (dep) | `glide-menu` | `beautifui/primitives/GlideMenu.tsx` (1.9 KB) | foundation | **none** (`react` only) |
| foundation | `foundation` | `beautifui/foundation.css` (17.5 KB) | — | `shadow-plugin` *(see deviation)* |
### npm packages pulled by the registry & licence check
For the three chosen primitives (+ their two registry deps) the registry pulls
**zero new runtime npm packages**. The only npm dependency any of them declares is
`class-variance-authority` (via `button`), which PraisonAIUI already ships.
- `liveline`, `glimm` — **not pulled.** These are referenced only by chart-style
components (InsightCards) that we did not adopt. No licence review needed for this spike.
- `@central-icons-react` — **avoided.** All three primitives render icons as
**inline SVG** (no icon package at all), so no `lucide-react` substitution was
even required. This is the cleanest possible outcome for the icon concern.
## Deviations from a raw `shadcn add`
1. **`shadow-plugin` removed.** `foundation.css` imports `shadow-plugin/unprefixed`
(an extra Tailwind plugin dependency). To keep the spike lightweight we replaced
it with a small local `@theme` block defining the `--shadow-*` scale. Visual
shadows are approximate but faithful.
2. **Unbalanced-brace fix (upstream bug).** The flattened `foundation.css` emits a
**stray extra `}`** around `.records-footer-hint` (a `@media` block that lost its
opening line in the registry export). This makes Tailwind v4 fail with
`Missing opening {`. We removed the stray brace. **This is a real upstream blocker
worth reporting to `beautiful-ui`.**
3. **Imports rewritten** from `@/components/*` to `@/spike/beautifui/*` so the spike
stays isolated and cannot collide with the existing shadcn `components/ui/button.tsx`.
4. **`"use client"` directives dropped** (Vite SPA, not RSC).
## Build result
`vite build` of the spike entry (`spike.html`) succeeds:
```
✓ 39 modules transformed
spike.css 97.82 kB │ gzip: 17.48 kB (Tailwind + foundation)
spike.js 247.94 kB │ gzip: 78.52 kB (React runtime + 3 primitives + 2 deps)
```
- **Bundle size:** the primitives are cheap — self-contained TSX with no extra npm
deps. The JS weight is dominated by the React runtime; the incremental cost of the
three primitives is on the order of ~40 KB raw / low-teens KB gzip. The CSS is
full Tailwind + the ~17 KB foundation token/keyframe layer.
- **Motion / `prefers-reduced-motion`:** foundation ships two
`@media (prefers-reduced-motion: reduce)` guards (global animation kill-switch +
a StreamText-specific one). Motion is handled responsibly out of the box.
- **Dark mode parity:** foundation defines its **own** token namespace (`--page`,
`--canvas`, `--ink`, `--accent`, …) with its own `.dark` block, *separate* from
PraisonAIUI's shadcn tokens (`--background`/`--foreground` via `hsl(var(--…))`).
Good news: **no token collision** with the dashboard. Trade-off: the primitives do
**not** automatically inherit `chat.css`/YAML-driven theme colours — they'd need a
token bridge (map `--ink`→`--foreground`, `--surface`→`--card`, `--accent`→`--primary`)
before they visually match the shipped chat surface in both themes.
## Assessment
**Fit score: 7/10** for a spike, with caveats for production migration.
**Blockers / gaps:**
- Upstream `foundation.css` brace bug (fixed here; report upstream).
- No automatic theme inheritance — needs a token bridge to match `chat.css` dark/light.
- Primitives embed **demo content** (ice-cream sales copy, fake sources). They accept
props (`content`, `rows`, `questions`, `onSubmitted`, …) so they *can* be driven by
real events — proven in `SpikePage` — but the built-in defaults must be replaced.
- PraisonAIUI already has native equivalents: `chat/ThinkingSteps.tsx`,
`chat/ToolCallDisplay.tsx`, streaming in `chat/ChatMessages.tsx`. Adopting
BeautifulUI should be weighed against enhancing these rather than duplicating them.
**Recommended primitive order for migration (lowest risk → highest):**
1. **StreamingText** — pure token-stream UX, `react`-only, maps 1:1 to `run_content`.
2. **ThinkingState** — reasoning/tool trace, `react`-only, maps to
`reasoning_step` + `tool_call_*`; overlaps existing `ThinkingSteps.tsx`.
3. **ApprovalCard** — highest value (HITL) but most surface: pulls `button` +
`glide-menu` and needs wiring to `POST /api/approvals/{id}/resolve`.
+90
View File
@@ -0,0 +1,90 @@
/* Spike #293 — BeautifulUI primitives driven by PraisonAIUI-shaped mock events.
*
* Dev-only harness. Run with `npm run dev` and open /spike.html. This page is
* NOT imported by the shipped library entry (main.tsx) and is NOT synced into
* src/praisonaiui/templates/frontend — per the issue's non-goals it must not
* touch the production bundle.
*/
import { useEffect, useState } from "react";
import StreamingText from "@/spike/beautifui/primitives/StreamingText";
import ThinkingState from "@/spike/beautifui/primitives/ThinkingState";
import ApprovalCard from "@/spike/beautifui/primitives/ApprovalCard";
import {
streamingTimeline,
tokensFromDeltas,
rowsFromReasoning,
REASONING_STREAM,
APPROVAL_REQUEST,
} from "@/spike/mockEvents";
function Panel({ title, children }: { title: string; children: React.ReactNode }) {
return (
<section style={{ marginBottom: 40 }}>
<h2 style={{ font: "600 13px/1.4 ui-monospace, monospace", opacity: 0.6, marginBottom: 12 }}>{title}</h2>
{children}
</section>
);
}
export default function SpikePage() {
const [dark, setDark] = useState(false);
const [log, setLog] = useState<string[]>([]);
useEffect(() => {
document.documentElement.classList.toggle("dark", dark);
}, [dark]);
// Emulate the SSE timeline so we exercise real delta pacing (logs only —
// the primitives below are fed the fully-resolved token/row arrays).
useEffect(() => {
const frames = streamingTimeline();
const timers = frames.map((f) =>
setTimeout(() => setLog((l) => [...l.slice(-4), `${f.event.type}: ${f.event.token ?? ""}`]), f.at),
);
return () => timers.forEach(clearTimeout);
}, []);
const tokens = tokensFromDeltas(streamingTimeline());
const rows = rowsFromReasoning(REASONING_STREAM);
return (
<div style={{ maxWidth: 720, margin: "0 auto", padding: 32, fontFamily: "system-ui, sans-serif" }}>
<header style={{ display: "flex", justifyContent: "space-between", alignItems: "center", marginBottom: 32 }}>
<div>
<h1 style={{ font: "700 20px/1.2 system-ui" }}>BeautifulUI spike</h1>
<p style={{ opacity: 0.6, fontSize: 13 }}>Issue #293 · mock PraisonAIUI event streams</p>
</div>
<button onClick={() => setDark((d) => !d)} style={{ padding: "6px 12px", borderRadius: 8 }}>
{dark ? "☀ light" : "🌙 dark"}
</button>
</header>
<Panel title="StreamingText ← run_content deltas">
<StreamingText content={tokens} loop={false} sources={[]} followUps={[]} />
</Panel>
<Panel title="ThinkingState ← reasoning_step / tool_call_*">
<ThinkingState rows={rows} active="Thinking" done={`Ran ${rows.length} steps`} />
</Panel>
<Panel title="ApprovalCard ← approval_request (/api/approvals)">
<ApprovalCard
questions={APPROVAL_REQUEST.questions}
onSubmitted={(answers) =>
// In production this maps to POST /api/approvals/{id}/resolve.
setLog((l) => [
...l.slice(-4),
`resolve ${APPROVAL_REQUEST.id}: ${JSON.stringify(answers)}`,
])
}
/>
</Panel>
<Panel title="event log (last 5)">
<pre style={{ font: "12px/1.5 ui-monospace, monospace", opacity: 0.7, whiteSpace: "pre-wrap" }}>
{log.join("\n") || "…"}
</pre>
</Panel>
</div>
);
}
@@ -0,0 +1,48 @@
/* Vendored from BeautifulUI registry: https://www.beautifului.dev/r/button.json
* Spike #293 — DO NOT ship in templates/frontend. Imports rewritten to @/spike/beautifui/*.
* License: MIT (upstream https://github.com/slev12397/beautiful-ui). */
import type { ButtonHTMLAttributes } from "react";
import { cva, type VariantProps } from "class-variance-authority";
import { cn } from "@/lib/utils";
const filledShadow = "shadow-[inset_0_1px_0_rgba(255,255,255,0.14)]";
/* Pill-shaped by default — the app's core button style. Explicit symmetric
* padding (not a fixed height) so the top/bottom spacing is always equal. */
export const buttonVariants = cva(
`inline-flex items-center justify-center font-medium select-none
transition-[transform,background-color,opacity] duration-150 ease-out
active:scale-[0.96] disabled:opacity-50 disabled:pointer-events-none`,
{
variants: {
variant: {
primary: `bg-ink text-canvas hover:opacity-90 dark:bg-ink dark:text-canvas ${filledShadow}`,
secondary: "bg-surface text-ink shadow-btn hover:bg-inset aria-expanded:bg-hover",
ghost: "bg-hover-2 text-ink hover:bg-line-strong",
accent: `bg-accent text-white hover:bg-accent-ink ${filledShadow}`,
success: `bg-green text-white hover:brightness-95 ${filledShadow}`,
/* transparent until hovered — for dense toolbars/action rows */
quiet: "text-ink hover:bg-hover",
},
size: {
/* compact toolbar pill — fixed height, lighter weight */
xs: "h-7 rounded-full px-2.5 text-[12px] font-normal leading-none gap-1",
/* canonical action pill — 27px tall, roomy sides */
sm: "h-[27px] px-3 text-[13px] leading-none rounded-full gap-1.5",
md: "px-4 py-[9px] text-sm leading-none rounded-full gap-2",
},
},
defaultVariants: { variant: "secondary", size: "md" },
},
);
export type ButtonVariant = NonNullable<VariantProps<typeof buttonVariants>["variant"]>;
export function Button({
variant,
size,
className,
...props
}: ButtonHTMLAttributes<HTMLButtonElement> & VariantProps<typeof buttonVariants>) {
return <button className={cn(buttonVariants({ variant, size }), className)} {...props} />;
}
+659
View File
@@ -0,0 +1,659 @@
/* Vendored from BeautifulUI foundation: https://www.beautifului.dev/r/foundation.json
* Spike #293. shadow-plugin dependency neutralized (see note below). License: MIT. */
@import "tailwindcss";
/* shadow-plugin (flornkm) — smooth, layered shadow stacks. The unprefixed
* entry maps them onto Tailwind's --shadow-xs…2xl scale; our semantic tokens
* below build on those values so the whole app picks up the smooth shadows. */
/* @import "shadow-plugin/unprefixed"; -- removed for spike */
/* SPIKE deviation (#293): upstream imports `shadow-plugin/unprefixed` (extra npm dep).
* To keep the spike lightweight we define the --shadow-* scale locally instead. */
@theme inline {
--shadow-2xs: 0 1px 0 0 oklch(0 0 0 / 0.03);
--shadow-xs: 0 1px 2px 0 oklch(0 0 0 / 0.05);
--shadow-sm: 0 1px 2px 0 oklch(0 0 0 / 0.06), 0 1px 1px -1px oklch(0 0 0 / 0.04);
--shadow-md: 0 2px 4px -1px oklch(0 0 0 / 0.07), 0 1px 2px -1px oklch(0 0 0 / 0.05);
--shadow-lg: 0 8px 16px -4px oklch(0 0 0 / 0.10), 0 2px 4px -2px oklch(0 0 0 / 0.06);
--shadow-xl: 0 16px 32px -8px oklch(0 0 0 / 0.12);
--shadow-2xl: 0 24px 48px -12px oklch(0 0 0 / 0.18);
}
@custom-variant dark (&:where(.dark, .dark *));
/* GENERIC FOUNDATION — tokens, @theme, base rules, the primitive-*
* spacing utilities, and shared keyframes below are needed by EVERY
* component. Blocks tagged COMPONENT-SPECIFIC further down belong to
* one primitive each. */
/* ──────────────────────────────────────────────────────────
* Beautiful UI — design tokens
* Cool near-white canvas, white cards, hairline borders, layered
* single-digit-opacity shadows, a neutral ink ramp, and semantic
* color used sparingly as a condiment.
* ────────────────────────────────────────────────────────── */
:root {
/* surfaces — cool, blue-tinted neutrals */
--page: oklch(0.985 0.001 286.376);
--canvas: oklch(0.961 0.002 247.84);
--surface: oklch(1 0 0);
--inset: oklch(0.979 0.002 247.839);
--hover: oklch(0.97 0.002 247.839);
--hover-2: oklch(0.933 0.003 247.86);
/* ink ramp */
--ink: oklch(0.247 0.006 258.361);
--ink-2: oklch(0.506 0.01 264.477);
--ink-3: oklch(0.695 0.009 264.505);
/* borders — solid and crisp, not alpha */
--line: oklch(0.946 0.003 264.542);
--line-strong: oklch(0.912 0.005 258.326);
--line-soft: oklch(0.966 0.002 264.542);
--grid-line: color-mix(in srgb, var(--line) 78%, transparent);
--field: oklch(0.961 0.001 286.375);
--stripe: oklch(0.405 0 0 / 0.075);
--stripe-bg: oklch(0.97 0 0);
/* accent */
--accent: oklch(0.626 0.205 254.947);
--accent-ink: oklch(0.556 0.187 255.617);
--accent-tint: oklch(0.96 0.019 252.878);
/* semantic */
--green: oklch(0.603 0.155 150.883);
--green-tint: oklch(0.958 0.017 159.118);
--orange: oklch(0.689 0.179 49.902);
--orange-tint: oklch(0.964 0.021 67.581);
--red: oklch(0.621 0.192 23.042);
--red-tint: oklch(0.956 0.017 17.462);
/* chart overlays — neutral system colors with enough contrast on either theme */
--tooltip-bg: oklch(0.272 0.008 264.435);
--tooltip-fg: oklch(0.976 0.002 247.839);
--tooltip-muted: oklch(0.731 0.008 260.731);
--tooltip-border: oklch(0.356 0.007 264.474);
/* shadows — our solid hairline ring + shadow-plugin's smooth layered stacks
* (--shadow-xs…lg come from shadow-plugin/unprefixed) so every surface reads
* consistently across the app. */
--shadow-hairline: 0 0 0 1px var(--line);
--shadow-btn: 0 0 0 1px var(--line-strong), var(--shadow-xs);
--shadow-card: 0 0 0 1px var(--line), var(--shadow-sm);
--shadow-raised: 0 0 0 1px var(--line), var(--shadow-md);
--shadow-overlay: 0 0 0 1px var(--line), var(--shadow-lg);
--shadow-inset-field: inset 0 1px 2px oklch(0 0 0 / 0.12);
}
.dark {
--page: oklch(0.209 0.004 264.477);
--canvas: oklch(0.231 0.004 264.487);
--surface: oklch(0.26 0.006 271.191);
--inset: oklch(0.243 0.004 264.492);
--hover: oklch(0.289 0.006 271.22);
--hover-2: oklch(0.318 0.007 274.747);
--ink: oklch(0.964 0.002 247.839);
--ink-2: oklch(0.731 0.008 260.731);
--ink-3: oklch(0.541 0.01 264.484);
--line: oklch(0.308 0.006 258.354);
--line-strong: oklch(0.356 0.007 264.474);
--line-soft: oklch(0.278 0.006 258.354);
--field: oklch(0.293 0.006 271.223);
--stripe: oklch(1 0 0 / 0.055);
--stripe-bg: oklch(0.226 0.004 264.485);
--accent: oklch(0.68 0.173 253.301);
--accent-ink: oklch(0.788 0.113 248.33);
--accent-tint: oklch(0.68 0.173 253.301 / 0.16);
--green: oklch(0.705 0.154 153.814);
--green-tint: oklch(0.705 0.154 153.814 / 0.14);
--orange: oklch(0.746 0.156 55.642);
--orange-tint: oklch(0.746 0.156 55.642 / 0.14);
--red: oklch(0.666 0.18 21.433);
--red-tint: oklch(0.666 0.18 21.433 / 0.14);
--tooltip-bg: oklch(0.182 0.004 264.459);
--tooltip-fg: oklch(0.964 0.002 247.839);
--tooltip-muted: oklch(0.731 0.008 260.731);
--tooltip-border: oklch(0.308 0.006 258.354);
--shadow-hairline: 0 0 0 1px var(--line);
/* Elevation rings go to a subtle *light* hairline in dark mode: the ring
* paints over the surface behind the element, so a low-alpha white edge
* dissolves into the shadow as one continuous stroke (per smooth-shadow-ring),
* graduated by how far the surface floats. */
--shadow-btn: 0 0 0 1px oklch(1 0 0 / 0.1), 0 1px 2px oklch(0 0 0 / 0.3);
--shadow-card:
0 0 0 1px oklch(1 0 0 / 0.11), 0 1px 2px oklch(0 0 0 / 0.2),
0 2px 6px oklch(0 0 0 / 0.2);
--shadow-raised:
0 0 0 1px oklch(1 0 0 / 0.13), 0 2px 10px oklch(0 0 0 / 0.22);
--shadow-overlay:
0 0 0 1px oklch(1 0 0 / 0.15), 0 8px 28px oklch(0 0 0 / 0.34);
--shadow-inset-field: inset 0 1px 2px oklch(0 0 0 / 0.4);
}
@theme inline {
/* colors */
--color-page: var(--page);
--color-canvas: var(--canvas);
--color-surface: var(--surface);
--color-inset: var(--inset);
--color-ink: var(--ink);
--color-ink-2: var(--ink-2);
--color-ink-3: var(--ink-3);
--color-hover: var(--hover);
--color-hover-2: var(--hover-2);
--color-field: var(--field);
--color-line: var(--line);
--color-line-strong: var(--line-strong);
--color-accent: var(--accent);
--color-accent-ink: var(--accent-ink);
--color-accent-tint: var(--accent-tint);
--color-green: var(--green);
--color-green-tint: var(--green-tint);
--color-orange: var(--orange);
--color-orange-tint: var(--orange-tint);
--color-red: var(--red);
--color-red-tint: var(--red-tint);
/* shadows */
--shadow-hairline: var(--shadow-hairline);
--shadow-btn: var(--shadow-btn);
--shadow-inset-field: var(--shadow-inset-field);
--shadow-card: var(--shadow-card);
--shadow-raised: var(--shadow-raised);
--shadow-overlay: var(--shadow-overlay);
/* type */
--font-sans: var(--font-inter), ui-sans-serif, system-ui, sans-serif;
--font-mono: var(--font-mono-face), ui-monospace, "SF Mono", monospace;
/* radii — chips 6, controls 8, cards 10, windows 14, pills 999 */
--radius-chip: 6px;
--radius-control: 8px;
--radius-card: 10px;
--radius-window: 14px;
/* easing — strong curves; built-ins are too weak */
--ease-out-strong: cubic-bezier(0.23, 1, 0.32, 1);
--ease-in-out-strong: cubic-bezier(0.77, 0, 0.175, 1);
--ease-link: cubic-bezier(0.16, 1, 0.3, 1);
}
/* ── base ─────────────────────────────────────────────── */
html {
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
text-rendering: optimizeLegibility;
}
body {
background-color: var(--stripe-bg);
background-image: repeating-linear-gradient(
-45deg,
transparent 0,
transparent 7px,
var(--stripe) 7px,
var(--stripe) 8px
);
/* Pin the stripe pattern to the viewport so it stays put on scroll. */
background-attachment: fixed;
color: var(--ink);
font-feature-settings: "cv11", "ss01";
font-size: 14px;
letter-spacing: -0.01em;
line-height: 1.5;
}
::selection {
background: var(--accent-tint);
color: var(--ink);
}
/* ── interface hygiene (interfaces.dev cheat sheet) ────── */
/* headings balance; descriptions avoid orphans (skip long-form — we have none) */
h1,
h2,
h3,
h4 {
text-wrap: balance;
}
p {
text-wrap: pretty;
}
/* underlines clear descenders */
a {
text-underline-position: from-font;
text-decoration-skip-ink: auto;
}
/* theme switches swap every token at once — freeze transitions so the flip
* is one clean repaint instead of hundreds of mismatched color fades */
.theme-switching *,
.theme-switching *::before,
.theme-switching *::after {
transition: none !important;
}
/* touch: :hover sticks after a tap, so neutralize row/header hovers there */
@media (hover: none) {
.records-row:hover > .records-cell {
background: transparent;
}
.records-row:hover > td.is-colsel {
background: color-mix(in srgb, var(--accent) 4%, var(--surface));
}
.records-header-button:hover {
background: transparent;
color: var(--ink-2);
}
}
/* Keep the component nav scrollable but hide its scrollbar. */
.component-nav-scroll {
scrollbar-width: none; /* Firefox */
-ms-overflow-style: none; /* legacy Edge */
}
.component-nav-scroll::-webkit-scrollbar {
display: none; /* WebKit */
}
@media (min-width: 64rem) {
.component-nav-scroll {
-webkit-mask-image: linear-gradient(
to bottom,
oklch(0 0 0) 0,
oklch(0 0 0) calc(100% - 6rem),
transparent 100%
);
mask-image: linear-gradient(
to bottom,
oklch(0 0 0) 0,
oklch(0 0 0) calc(100% - 6rem),
transparent 100%
);
}
}
/* Mobile demo gestures should operate controls and visualizations rather than
* selecting surrounding labels. Text entry and code remain intentionally
* selectable. */
@media (hover: none) and (pointer: coarse) {
.primitive-showcase {
-webkit-user-select: none;
user-select: none;
-webkit-touch-callout: none;
}
.primitive-showcase :is(input, textarea, [contenteditable="true"], pre, code) {
-webkit-user-select: text;
user-select: text;
-webkit-touch-callout: default;
}
.primitive-showcase :is(a, button, [role="button"]) {
-webkit-tap-highlight-color: transparent;
}
.primitive-showcase :is(a, button, [role="button"]):focus-visible {
outline: none;
}
.primitive-showcase :is(img, svg, canvas) {
-webkit-user-drag: none;
}
.insight-chart-stage {
touch-action: pan-y;
}
}
/* Tailwind v4 resets buttons to cursor:default — anything
* clickable must invite the click */
button:not(:disabled),
[role="button"]:not([aria-disabled="true"]),
label:has(input),
a {
cursor: pointer;
}
:focus-visible {
outline: 2px solid var(--accent);
outline-offset: 2px;
border-radius: 4px;
}
input:focus-visible,
textarea:focus-visible,
select:focus-visible,
[contenteditable]:focus-visible {
outline: none;
}
.animated-underline {
position: relative;
display: inline-block;
}
.animated-underline::after {
position: absolute;
right: 0;
bottom: -1px;
left: 0;
height: 1px;
content: "";
background: currentColor;
transform: scaleX(0);
transform-origin: left center;
transition: transform 280ms var(--ease-link);
}
a:hover .animated-underline::after,
a:focus-visible .animated-underline::after {
transform: scaleX(1);
}
/* ══════════════════════════════════════════════════════════
* COMPONENT-SPECIFIC — StreamingText / ContextCards
* Favicon source avatars.
* Copy this block only alongside StreamingText / ContextCards; it is not part of the
* generic foundation above.
* ══════════════════════════════════════════════════════════ */
.source-avatar {
box-shadow: 0 0 0 1px oklch(0.21 0.034 263.436 / 0.1);
}
.dark .source-avatar {
box-shadow: 0 0 0 1px oklch(1 0 0 / 0.08);
}
/* ── primitive spacing system ─────────────────────────────
* Shared optical offsets for small demo cards. Use these
* instead of hand-tuning 10/12/14px per component.
*/
.primitive-card-pad {
padding: 12px;
}
.primitive-card-bar {
padding: 10px 12px;
}
.primitive-card-footer {
padding: 10px;
}
.primitive-table-cell {
padding: 10px 12px;
}
.primitive-icon-button {
display: inline-flex;
width: 28px;
height: 28px;
align-items: center;
justify-content: center;
border-radius: var(--radius-control);
}
/* ── shared keyframes ─────────────────────────────────── */
@keyframes shimmer-text {
from {
background-position: 150% center;
}
to {
background-position: -50% center;
}
}
@keyframes fade-up {
from {
opacity: 0;
transform: translateY(8px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
.home-reveal {
will-change: opacity, transform, filter;
}
/* The sidebar is one persistent 224px tree clipped by a 52px shell.
* Every rail glyph remains centered at x=26 in both states. */
.records-footer-hint {
display: none;
}
/* SPIKE fix (#293): upstream foundation.css emits a stray extra `}` here — a
* flattened @media block that lost its opening line. Removed so Tailwind v4
* can parse the stylesheet. See spike report. */
/* ── filter table electric status colors ─────────────────── */
/* ══════════════════════════════════════════════════════════
* COMPONENT-SPECIFIC — FilterTable
* Electric status-pill colors.
* Copy this block only alongside FilterTable; it is not part of the
* generic foundation above.
* ══════════════════════════════════════════════════════════ */
.filter-status-todo,
.filter-status-progress,
.filter-status-done {
border: 1px solid transparent;
}
/* ══════════════════════════════════════════════════════════
* COMPONENT-SPECIFIC — InsightCards
* Liveline chart tooltip + cursor.
* Copy this block only alongside InsightCards; it is not part of the
* generic foundation above.
* ══════════════════════════════════════════════════════════ */
.insight-chart-stage {
overflow: hidden;
}
.insight-chart-cursor {
position: absolute;
z-index: 4;
top: 0;
bottom: 0;
width: 1px;
background: var(--ink);
opacity: 0.26;
pointer-events: none;
}
.insight-chart-tooltip-anchor {
position: absolute;
z-index: 5;
top: 8px;
transform: translateX(-50%);
pointer-events: none;
}
.insight-chart-tooltip {
display: flex;
align-items: center;
gap: 12px;
padding: 5px 9px;
border: 1px solid var(--line-strong);
border-radius: 8px;
color: var(--tooltip-fg);
background: var(--tooltip-bg);
box-shadow: var(--shadow-overlay);
font-size: 11.5px;
font-variant-numeric: tabular-nums;
white-space: nowrap;
}
.insight-chart-tooltip-item {
display: inline-flex;
align-items: center;
gap: 6px;
}
.insight-chart-tooltip-dot {
width: 8px;
height: 8px;
flex: 0 0 8px;
border-radius: 50%;
}
/* Status pills share the same base-hue → color-mix derivation as .records-tag,
* so both components stay consistent across light and dark. */
.filter-status-todo {
--tag-base: oklch(0.757 0.153 66.401);
}
.filter-status-progress {
--tag-base: oklch(0.671 0.118 219.351);
}
.filter-status-done {
--tag-base: oklch(0.652 0.131 162.865);
}
.filter-status-todo,
.filter-status-progress,
.filter-status-done {
color: color-mix(in srgb, var(--tag-base) 92%, var(--ink));
background: color-mix(in srgb, var(--tag-base) 20%, var(--surface));
border-color: color-mix(in srgb, var(--tag-base) 34%, var(--surface));
}
/* Dark mode — a richer, saturated same-hue fill
* with bright same-hue text and no visible border (light mode keeps the soft
* tinted pastels above). Applies to both the records grid and status pills. */
.dark .records-tag,
.dark .filter-status-todo,
.dark .filter-status-progress,
.dark .filter-status-done {
color: color-mix(in srgb, var(--tag-base) 86%, white);
background: color-mix(in srgb, var(--tag-base) 34%, var(--surface));
border-color: color-mix(in srgb, var(--tag-base) 34%, var(--surface));
}
@keyframes fade-in {
from {
opacity: 0;
}
to {
opacity: 1;
}
}
@keyframes eq-bounce {
0%,
100% {
transform: scaleY(0.35);
}
50% {
transform: scaleY(1);
}
}
/* ── StreamText primitive ─────────────────────────────────
* Fast character reveal: the newest few characters carry a
* soft, masked blur so the leading edge resolves smoothly as
* text flows in. The caret holds solid while streaming, then
* blinks once the string settles. */
/* ══════════════════════════════════════════════════════════
* COMPONENT-SPECIFIC — StreamText
* The streaming caret + tail.
* Copy this block only alongside StreamText; it is not part of the
* generic foundation above.
* ══════════════════════════════════════════════════════════ */
.stream-tail {
filter: blur(1.6px);
-webkit-mask-image: linear-gradient(to right, oklch(0 0 0) 20%, oklch(0 0 0 / 0.2));
mask-image: linear-gradient(to right, oklch(0 0 0) 20%, oklch(0 0 0 / 0.2));
}
.stream-caret {
display: inline-block;
width: 2px;
height: 1.05em;
margin-left: 1.5px;
translate: 0 -0.5px;
border-radius: 1px;
background: var(--ink);
vertical-align: text-bottom;
animation: caret-blink 1s step-end infinite;
}
.stream-caret.is-streaming {
animation: none;
}
@keyframes caret-blink {
0%,
100% {
opacity: 1;
}
50% {
opacity: 0;
}
}
@media (prefers-reduced-motion: reduce) {
.stream-caret {
animation: none;
}
.stream-tail {
filter: none;
-webkit-mask-image: none;
mask-image: none;
}
}
@keyframes pop-in {
from {
opacity: 0;
transform: scale(0.95);
}
to {
opacity: 1;
transform: scale(1);
}
}
@keyframes spin {
to {
transform: rotate(360deg);
}
}
/* pixel-grid loader — brighten plateau, staggered per cell */
@keyframes pixel-on {
0%, 100% {
opacity: 0.15;
}
18%, 42% {
opacity: 1;
}
62% {
opacity: 0.15;
}
}
@media (prefers-reduced-motion: reduce) {
*,
*::before,
*::after {
animation-duration: 0.01ms !important;
animation-iteration-count: 1 !important;
transition-duration: 0.01ms !important;
}
}
@@ -0,0 +1,420 @@
/* Vendored from BeautifulUI registry: https://www.beautifului.dev/r/approval-card.json
* Spike #293 — DO NOT ship in templates/frontend. Imports rewritten to @/spike/beautifui/*.
* License: MIT (upstream https://github.com/slev12397/beautiful-ui). */
import { useEffect, useLayoutEffect, useRef, useState, type CSSProperties } from "react";
import { Button } from "@/spike/beautifui/atoms/Button";
import GlideMenu from "@/spike/beautifui/primitives/GlideMenu";
/* ─────────────────────────────────────────────────────────
* APPROVAL CARD (human-in-the-loop)
* One question at a time. The stack slides vertically as you
* move between questions (the card's height animates to fit),
* the step counter rolls like an odometer, and the footer uses
* pill actions — a quiet Skip and a dark Continue with a ⏎.
* Single-choice answers auto-advance; multi-select waits.
* ───────────────────────────────────────────────────────── */
export type ApprovalQuestion = {
q: string;
type: "radio" | "check";
options: string[];
};
const QUESTIONS: ApprovalQuestion[] = [
{
q: "How many flavors should we launch?",
type: "radio",
options: ["Three (core line)", "Five (full case)", "Just one hero"],
},
{
q: "Which mix-ins should we stock?",
type: "check",
options: ["Chocolate chips", "Waffle bits", "Sprinkles"],
},
{
q: "Which market do we enter first?",
type: "radio",
options: ["Food trucks", "Grocery freezers", "Scoop shops"],
},
];
export type ApprovalLabels = {
skip: string;
continue: string;
send: string;
customPlaceholder: string;
sentMessage: string;
};
const DEFAULT_LABELS: ApprovalLabels = {
skip: "Skip",
continue: "Continue",
send: "Send",
customPlaceholder: "Something else…",
sentMessage: "Answers sent",
};
const ROLL_MS = 400;
const SLIDE = "360ms cubic-bezier(0.22, 1, 0.36, 1)";
/* odometer digits — each character that changes rolls up (or down) */
function RollingDigits({ value }: { value: string }) {
const prevRef = useRef(value);
const [oldVal, setOldVal] = useState(value);
const [newVal, setNewVal] = useState(value);
const [rolling, setRolling] = useState(false);
const [shifted, setShifted] = useState(false);
const [dir, setDir] = useState<"up" | "down">("up");
useEffect(() => {
if (prevRef.current === value) return;
const from = prevRef.current;
prevRef.current = value;
const fromN = parseInt(from, 10);
const toN = parseInt(value, 10);
setDir(Number.isFinite(fromN) && Number.isFinite(toN) && toN < fromN ? "down" : "up");
setOldVal(from);
setNewVal(value);
setRolling(true);
setShifted(false);
let raf2 = 0;
const raf1 = requestAnimationFrame(() => {
raf2 = requestAnimationFrame(() => setShifted(true));
});
const done = setTimeout(() => {
setRolling(false);
setOldVal(value);
setShifted(false);
}, ROLL_MS);
return () => {
cancelAnimationFrame(raf1);
cancelAnimationFrame(raf2);
clearTimeout(done);
};
}, [value]);
const chars = rolling ? newVal : oldVal;
return (
<>
{Array.from({ length: chars.length }, (_, i) => {
const o = oldVal[i] ?? "";
const n = chars[i] ?? "";
if (!rolling || o === n) {
return <span key={`${i}-${n}`}>{n}</span>;
}
const top = dir === "down" ? n : o;
const bottom = dir === "down" ? o : n;
const restY = dir === "down" ? "0" : "-1em";
const startY = dir === "down" ? "-1em" : "0";
return (
<span
key={`${i}-${o}-${n}-${dir}`}
style={{ display: "inline-block", position: "relative", overflow: "hidden", height: "1em", lineHeight: "1em", verticalAlign: "-0.05em" }}
>
<span
style={{
display: "flex",
flexDirection: "column",
transition: "transform 350ms cubic-bezier(0.4, 0, 0.2, 1)",
transform: `translateY(${shifted ? restY : startY})`,
}}
>
<span style={{ height: "1em", lineHeight: "1em" }}>{top}</span>
<span style={{ height: "1em", lineHeight: "1em" }}>{bottom}</span>
</span>
</span>
);
})}
</>
);
}
function Ico({ path, size = 14, sw = 2 }: { path: React.ReactNode; size?: number; sw?: number }) {
return (
<svg width={size} height={size} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={sw} strokeLinecap="round" strokeLinejoin="round" aria-hidden>
{path}
</svg>
);
}
export default function ApprovalCard({
questions = QUESTIONS,
labels,
onSubmitted,
onAnswerChange,
resettable = true,
}: {
questions?: ApprovalQuestion[];
labels?: Partial<ApprovalLabels>;
onSubmitted?: (answers: Record<number, number[]>) => void;
onAnswerChange?: (questionIndex: number, answer: number[]) => void;
resettable?: boolean;
variant?: string;
} = {}) {
const t = { ...DEFAULT_LABELS, ...labels };
const [qi, setQi] = useState(0);
const [answers, setAnswers] = useState<Record<number, number[]>>({});
const [custom, setCustom] = useState<Record<number, string>>({});
const [sent, setSent] = useState(false);
const [open, setOpen] = useState(true);
const advanceTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
const questionRefs = useRef<(HTMLDivElement | null)[]>([]);
const measured = useRef(false);
const [viewportH, setViewportH] = useState<number | undefined>(undefined);
const [trackY, setTrackY] = useState(0);
const [animate, setAnimate] = useState(false);
// Until the first question is measured, render only the active one so the
// initial (and SSR) height is Q1's height — not all questions stacked, which
// would flash to full height and then shrink on mount.
const [ready, setReady] = useState(false);
const last = qi === questions.length - 1;
const selected = answers[qi] ?? [];
const hasAnswer = selected.length > 0 || Boolean(custom[qi]?.trim());
const sync = (withAnim: boolean) => {
const item = questionRefs.current[qi];
if (!item) return;
const reduce = typeof window !== "undefined" && window.matchMedia("(prefers-reduced-motion: reduce)").matches;
setViewportH(item.offsetHeight);
setTrackY(item.offsetTop);
setAnimate(withAnim && !reduce);
};
useLayoutEffect(() => {
const withAnim = measured.current;
measured.current = true;
sync(withAnim);
setReady(true);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [qi, answers, custom, open, sent]);
useEffect(() => {
const id = requestAnimationFrame(() => sync(measured.current));
return () => cancelAnimationFrame(id);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [qi]);
useEffect(() => () => { if (advanceTimer.current) clearTimeout(advanceTimer.current); }, []);
const goTo = (next: number) => {
if (advanceTimer.current) clearTimeout(advanceTimer.current);
setQi(Math.min(Math.max(next, 0), questions.length - 1));
};
const send = () => {
if (advanceTimer.current) clearTimeout(advanceTimer.current);
setSent(true);
onSubmitted?.(answers);
};
const advance = () => {
if (last) send();
else goTo(qi + 1);
};
const toggle = (index: number) => {
const type = questions[qi].type;
setAnswers((current) => {
const picked = current[qi] ?? [];
const next = type === "radio"
? [index]
: picked.includes(index)
? picked.filter((item) => item !== index)
: [...picked, index];
onAnswerChange?.(qi, next);
return { ...current, [qi]: next };
});
if (type === "radio") {
setCustom((current) => ({ ...current, [qi]: "" }));
if (advanceTimer.current) clearTimeout(advanceTimer.current);
advanceTimer.current = setTimeout(() => {
if (last) send();
else setQi((current) => Math.min(questions.length - 1, current + 1));
}, 480);
}
};
const reset = () => {
setQi(0);
setAnswers({});
setCustom({});
setSent(false);
setOpen(true);
measured.current = false;
};
if (!open) {
return (
<button type="button" onClick={() => setOpen(true)} className="rounded-control bg-surface px-3 py-2 text-[12.5px] font-medium text-ink shadow-btn transition-colors duration-150 hover:bg-hover">
Open approval
</button>
);
}
if (sent) {
return (
<div className="flex w-full max-w-80 items-center gap-3" style={{ animation: "pop-in 260ms cubic-bezier(0.23,1,0.32,1) both" }}>
<span className="inline-flex items-center gap-1.5 rounded-full bg-green-tint py-1 pr-2.5 pl-1 text-[12.5px] font-medium text-green">
<span className="flex size-4.5 items-center justify-center rounded-full bg-green text-white">
<svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="3" strokeLinecap="round" strokeLinejoin="round"><path d="M20 6L9 17l-5-5" /></svg>
</span>
{t.sentMessage}
</span>
{resettable && (
<button type="button" onClick={reset} className="text-[12px] font-medium text-ink-3 transition-colors duration-150 hover:text-ink">
Start over
</button>
)}
</div>
);
}
return (
<div className="w-full max-w-80">
<div className="relative overflow-hidden rounded-card bg-surface shadow-card" style={{ animation: "fade-up 380ms cubic-bezier(0.23,1,0.32,1) both" }}>
<button
type="button"
aria-label="Dismiss"
onClick={() => setOpen(false)}
className="primitive-icon-button absolute right-2.5 top-2.5 z-10 text-ink-3 transition-colors duration-100 hover:bg-hover hover:text-ink"
>
<Ico size={14} sw={2.2} path={<path d="M18 6L6 18M6 6l12 12" />} />
</button>
<div className="primitive-card-pad">
{/* the question itself is the heading */}
<div
className="overflow-hidden"
style={{ height: viewportH, transition: animate ? `height ${SLIDE}` : undefined }}
aria-live="polite"
>
<div
style={{
display: "flex",
flexDirection: "column",
gap: 26,
transform: `translate3d(0, ${-trackY}px, 0)`,
transition: animate ? `transform ${SLIDE}` : undefined,
willChange: "transform",
}}
>
{questions.map((question, qIdx) => {
const active = qIdx === qi;
// Before the first measure, mount only the active question so the
// card opens at its real height instead of flashing to full height.
if (!ready && !active) return null;
const picked = answers[qIdx] ?? [];
const questionStyle: CSSProperties = {
opacity: active ? 1 : 0,
transition: animate ? `opacity ${SLIDE}` : undefined,
pointerEvents: active ? undefined : "none",
};
return (
<div
key={qIdx}
ref={(el) => { questionRefs.current[qIdx] = el; }}
aria-hidden={active ? undefined : true}
style={questionStyle}
>
<div className="pr-7 text-[14px] font-medium text-ink">{question.q}</div>
<GlideMenu className="mt-2.5 flex flex-col gap-1" highlightClassName="inset-x-0 rounded-control bg-hover">
{question.options.map((option, i) => {
const on = picked.includes(i);
return (
<button
key={option}
type="button"
data-menu-row
aria-pressed={on}
tabIndex={active ? 0 : -1}
onClick={() => { if (active) toggle(i); }}
className="relative z-10 flex items-center gap-1.5 rounded-control pl-1 pr-2 py-1 text-left transition-colors duration-100"
>
<span
className={`flex size-4 shrink-0 items-center justify-center transition-colors duration-200
${question.type === "radio" ? "rounded-full" : "rounded-[5px]"}
${on ? "bg-ink text-canvas" : "shadow-[inset_0_0_0_1.5px_var(--line-strong)] text-transparent"}`}
>
{question.type === "radio" ? (
<span className="size-1.5 rounded-full bg-canvas transition-transform duration-200" style={{ transform: on ? "scale(1)" : "scale(0)" }} />
) : (
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="3" strokeLinecap="round" strokeLinejoin="round"><path d="M20 6L9 17l-5-5" /></svg>
)}
</span>
<span className={`text-[13px] leading-none transition-colors duration-200 ${on ? "text-ink" : "text-ink-2"}`}>
{option}
</span>
</button>
);
})}
<label data-menu-row className="relative z-10 flex items-center gap-1.5 rounded-control pl-1 pr-2 py-1 transition-colors duration-100">
<input
value={custom[qIdx] ?? ""}
tabIndex={active ? 0 : -1}
onChange={(event) => {
if (!active) return;
setCustom((current) => ({ ...current, [qIdx]: event.target.value }));
if (question.type === "radio") setAnswers((current) => ({ ...current, [qIdx]: [] }));
}}
onKeyDown={(event) => {
if (event.key === "Enter" && hasAnswer) {
event.preventDefault();
advance();
}
}}
placeholder={t.customPlaceholder}
aria-label="Custom answer"
className="min-w-0 flex-1 bg-transparent pl-1.5 text-[13px] text-ink outline-none placeholder:text-ink-3"
/>
</label>
</GlideMenu>
</div>
);
})}
</div>
</div>
</div>
{/* footer — step nav (rolling counter) + pill actions */}
<div className="primitive-card-footer flex items-center justify-between gap-3">
<div className="flex items-center gap-1 text-ink-3">
<button
type="button"
aria-label="Previous question"
disabled={qi <= 0}
onClick={() => goTo(qi - 1)}
className="flex size-[18px] items-center justify-center rounded-[5px] transition-colors duration-100 enabled:hover:text-ink disabled:opacity-30"
>
<Ico size={14} path={<path d="M18 15l-6-6-6 6" />} />
</button>
<span className="inline-flex items-center text-[12px] font-medium tabular-nums text-ink-3" style={{ letterSpacing: "-0.1px", lineHeight: 1 }}>
<RollingDigits value={`${qi + 1} / ${questions.length}`} />
</span>
<button
type="button"
aria-label="Next question"
disabled={last}
onClick={() => goTo(qi + 1)}
className="flex size-[18px] items-center justify-center rounded-[5px] transition-colors duration-100 enabled:hover:text-ink disabled:opacity-30"
>
<Ico size={14} path={<path d="M6 9l6 6 6-6" />} />
</button>
</div>
<div className="-mr-0.5 flex items-center gap-1.5">
<Button variant="ghost" size="sm" onClick={() => (last ? setOpen(false) : goTo(qi + 1))}>
{t.skip}
</Button>
<Button variant="accent" size="sm" disabled={!hasAnswer} onClick={advance}>
{last ? t.send : t.continue}
</Button>
</div>
</div>
</div>
</div>
);
}
@@ -0,0 +1,60 @@
/* Vendored from BeautifulUI registry: https://www.beautifului.dev/r/glide-menu.json
* Spike #293 — DO NOT ship in templates/frontend. Imports rewritten to @/spike/beautifui/*.
* License: MIT (upstream https://github.com/slev12397/beautiful-ui). */
import { useRef, useState, type ReactNode } from "react";
type GlideMenuProps = {
children: ReactNode;
className?: string;
highlightClassName?: string;
rowSelector?: string;
};
/** A single hover layer that glides between interactive menu rows. */
export default function GlideMenu({
children,
className = "",
highlightClassName = "inset-x-0 rounded-[8px] bg-hover",
rowSelector = "[data-menu-row]",
}: GlideMenuProps) {
const ref = useRef<HTMLDivElement>(null);
const [box, setBox] = useState<{ top: number; height: number } | null>(null);
const [visible, setVisible] = useState(false);
const moveTo = (target: EventTarget | null) => {
const container = ref.current;
if (!(target instanceof Element) || !container) return;
const row = target.closest(rowSelector);
if (!(row instanceof HTMLElement) || !container.contains(row)) return;
const containerRect = container.getBoundingClientRect();
const rowRect = row.getBoundingClientRect();
setBox({ top: rowRect.top - containerRect.top, height: rowRect.height });
setVisible(true);
};
return (
<div
ref={ref}
onMouseOver={(event) => moveTo(event.target)}
onMouseLeave={() => setVisible(false)}
onFocusCapture={(event) => moveTo(event.target)}
onBlurCapture={(event) => {
if (!ref.current?.contains(event.relatedTarget as Node | null)) setVisible(false);
}}
className={`group/glide-menu relative ${className}`}
>
<span
aria-hidden
className={`pointer-events-none absolute ${highlightClassName}`}
style={{
top: box?.top ?? 0,
height: box?.height ?? 0,
opacity: box && visible ? 1 : 0,
transition:
"top 220ms cubic-bezier(0.23,1,0.32,1), height 220ms cubic-bezier(0.23,1,0.32,1), opacity 150ms ease",
}}
/>
{children}
</div>
);
}
@@ -0,0 +1,253 @@
/* Vendored from BeautifulUI registry: https://www.beautifului.dev/r/streaming-text.json
* Spike #293 — DO NOT ship in templates/frontend. Imports rewritten to @/spike/beautifui/*.
* License: MIT (upstream https://github.com/slev12397/beautiful-ui). */
import { useEffect, useState } from "react";
/* ─────────────────────────────────────────────────────────
* STREAMING TEXT
* Words resolve out of blur, inline citations appear in
* context, then actions and follow-up prompts become usable.
* ───────────────────────────────────────────────────────── */
const WORD_MS = 55;
const HOLD_MS = 3400;
/* one streamed word, or a `cite` placeholder that renders an inline source chip */
export type StreamingToken = { text: string; cite?: boolean };
const TOKENS: StreamingToken[] = [
..."Pistachio is your fastest-growing flavor — sales are up 23% this month and margins beat vanilla by 8 points."
.split(" ")
.map((text) => ({ text })),
{ text: "", cite: true },
..."Stone-fruit flavors are trending in the same range."
.split(" ")
.map((text) => ({ text })),
];
const FOLLOW_UPS = [
"Which flavors sell best in winter",
"Compare gelato and soft serve margins",
];
const SOURCE_IMAGES = {
scoop:
"data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 64 64'%3E%3Crect width='64' height='64' rx='16' fill='%231f7a5f'/%3E%3Cpath d='M20 36c0 7 5.4 12 12 12s12-5 12-12H20Z' fill='%23fff'/%3E%3Ccircle cx='32' cy='25' r='11' fill='%23bff3dd'/%3E%3Cpath d='M24 24c4-7 13-7 17 0' fill='none' stroke='%231f7a5f' stroke-width='4' stroke-linecap='round'/%3E%3C/svg%3E",
trends:
"data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 64 64'%3E%3Crect width='64' height='64' rx='16' fill='%232f6fec'/%3E%3Cpath d='M15 43 27 31l8 7 14-18' fill='none' stroke='%23fff' stroke-width='7' stroke-linecap='round' stroke-linejoin='round'/%3E%3Ccircle cx='49' cy='20' r='5' fill='%23bfe0ff'/%3E%3C/svg%3E",
market:
"data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 64 64'%3E%3Crect width='64' height='64' rx='16' fill='%23e56d24'/%3E%3Cpath d='M17 45V25h8v20h-8Zm11 0V16h8v29h-8Zm11 0V30h8v15h-8Z' fill='%23fff'/%3E%3Cpath d='M16 49h32' stroke='%23ffd6b8' stroke-width='4' stroke-linecap='round'/%3E%3C/svg%3E",
};
/* one cited source rendered as an inline chip and in the sources list */
export type StreamingSource = { name: string; domain: string; href: string; image: string };
const SOURCES: StreamingSource[] = [
{ name: "Scoop Data", domain: "scoopdata.io", href: "https://scoopdata.io/", image: SOURCE_IMAGES.scoop },
{ name: "Trends Index", domain: "trends.google.com", href: "https://trends.google.com/trends/", image: SOURCE_IMAGES.trends },
{ name: "Market Basket", domain: "marketbasket.io", href: "https://marketbasket.io/", image: SOURCE_IMAGES.market },
];
function sourceImage(source: StreamingSource) {
return source.image;
}
function SourceChip({ source }: { source?: StreamingSource }) {
if (!source) return null;
return (
<a
href={source.href}
target="_blank"
rel="noreferrer"
className="ml-0 mr-1 inline-flex h-4.5 translate-y-[-1px] items-center gap-1 rounded-[5px]
bg-inset pr-[3px] pl-[3px] align-middle font-mono text-[10.5px] text-ink-2 shadow-hairline
transition-colors duration-150 hover:bg-hover hover:text-ink"
style={{ animation: "pop-in 250ms cubic-bezier(0.23,1,0.32,1) both" }}
>
<img src={sourceImage(source)} alt="" className="source-avatar size-3 rounded-[3px]" />
<span>{source.domain}</span>
</a>
);
}
const ACTION_ICONS: React.ReactNode[] = [
<g key="copy"><rect x="9" y="9" width="12" height="12" rx="2.5" /><path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1" /></g>,
<path key="retry" d="M21 12a9 9 0 1 1-2.64-6.36M21 3v6h-6" />,
<path key="up" d="M7 10v12M15 5.88L14 10h5.83a2 2 0 0 1 1.92 2.56l-2.33 8A2 2 0 0 1 17.5 22H4a2 2 0 0 1-2-2v-8a2 2 0 0 1 2-2h2.76a2 2 0 0 0 1.79-1.11L12 2a3.13 3.13 0 0 1 3 3.88z" />,
<path key="down" d="M17 14V2M9 18.12L10 14H4.17a2 2 0 0 1-1.92-2.56l2.33-8A2 2 0 0 1 6.5 2H20a2 2 0 0 1 2 2v8a2 2 0 0 1-2 2h-2.76a2 2 0 0 0-1.79 1.11L12 22a3.13 3.13 0 0 1-3-3.88z" />,
];
export type StreamingLabels = {
/** label on the collapsed sources toggle */
sources: string;
/** heading above the follow-up prompts */
followUps: string;
};
const DEFAULT_LABELS: StreamingLabels = {
sources: "10 sources",
followUps: "Follow-ups",
};
export default function StreamingText({
content = TOKENS,
sources = SOURCES,
followUps = FOLLOW_UPS,
labels,
loop = true,
fill = false,
onDone,
onFollowUp,
}: {
variant?: string;
/** the streamed tokens; `cite` tokens render an inline source chip */
content?: StreamingToken[];
/** cited sources shown in the chip, avatar stack, and expanded list */
sources?: StreamingSource[];
/** follow-up prompt suggestions shown once the stream completes */
followUps?: string[];
/** prominent copy strings */
labels?: Partial<StreamingLabels>;
/** restart the stream after a hold; turn off when embedding in a real thread */
loop?: boolean;
/** fill the parent width instead of the gallery's fixed measure */
fill?: boolean;
onDone?: () => void;
/** fired when a follow-up prompt is chosen */
onFollowUp?: (text: string, index: number) => void;
} = {}) {
const l = { ...DEFAULT_LABELS, ...labels };
const [count, setCount] = useState(0);
const [sourcesOpen, setSourcesOpen] = useState(false);
const done = count >= content.length;
useEffect(() => {
if (done && !loop) {
onDone?.();
return;
}
const t = setTimeout(
() => setCount((c) => (c >= content.length ? 0 : c + 1)),
done ? HOLD_MS : WORD_MS,
);
return () => clearTimeout(t);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [count, done, loop]);
return (
<div className={fill ? "w-full" : "min-h-[15.5rem] w-full max-w-95"}>
<p className="text-[13px] leading-relaxed text-ink">
{content.slice(0, count).map((token, i) =>
token.cite ? (
<SourceChip key={i} source={sources[0]} />
) : (
<span key={i} className="inline">
{token.text}{" "}
</span>
),
)}
{!done && (
<span
className="ml-0.5 inline-block h-3 w-0.5 translate-y-0.5 rounded-full bg-ink"
style={{ animation: "fade-in 150ms ease-out both" }}
/>
)}
</p>
{/* action icons row */}
<div
className="mt-2 flex items-center gap-0.5 transition-opacity duration-400"
style={{ opacity: done ? 1 : 0, pointerEvents: done ? "auto" : "none" }}
>
{ACTION_ICONS.map((icon, i) => (
<button
key={i}
type="button"
aria-label="Action"
className="flex size-6 items-center justify-center rounded-[6px] text-ink-3
transition-colors duration-100 hover:bg-hover-2 hover:text-ink-2"
>
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round">
{icon}
</svg>
</button>
))}
<button
type="button"
aria-expanded={sourcesOpen}
onClick={() => setSourcesOpen((current) => !current)}
className="ml-1.5 flex items-center gap-1.5 rounded-[6px] px-1 py-0.5 text-left transition-colors duration-150 hover:bg-hover"
>
<span className="flex -space-x-1">
{sources.map((source) => (
<img
key={source.domain}
src={sourceImage(source)}
alt=""
className="source-avatar size-3.5 rounded-full bg-surface shadow-[0_0_0_1.5px_var(--canvas)]"
/>
))}
</span>
<span className="text-[12px] text-ink-2">{l.sources}</span>
</button>
</div>
<div
className="grid transition-[grid-template-rows,opacity] duration-300"
style={{
gridTemplateRows: done && sourcesOpen ? "1fr" : "0fr",
opacity: done && sourcesOpen ? 1 : 0,
transitionTimingFunction: "cubic-bezier(0.23, 1, 0.32, 1)",
}}
>
<div className="overflow-hidden">
<div className="mt-1.5 flex flex-col rounded-[10px] bg-inset p-1 shadow-hairline">
{sources.map((source) => (
<a
key={source.domain}
href={source.href}
target="_blank"
rel="noreferrer"
className="flex items-center gap-2 rounded-[6px] px-1.5 py-1 text-[12px] text-ink-2 transition-colors duration-150 hover:bg-hover hover:text-ink"
>
<img src={sourceImage(source)} alt="" className="source-avatar size-4 rounded-[4px]" />
<span className="animated-underline">{source.name}</span>
<span className="ml-auto font-mono text-[10.5px] text-ink-3">{source.domain}</span>
</a>
))}
</div>
</div>
</div>
{/* follow-ups */}
<div
className="mt-2.5 transition-opacity duration-400"
style={{ opacity: done ? 1 : 0, pointerEvents: done ? "auto" : "none" }}
>
<p className="text-[12px] font-medium text-ink-2">{l.followUps}</p>
<div className="mt-0.5 flex flex-col">
{followUps.map((text, i) => (
<button
key={text}
onClick={() => onFollowUp?.(text, i)}
className="-mx-1.5 flex items-center gap-2 rounded-[7px] border-b border-line
px-1.5 py-1.5 text-left text-[12.5px] text-ink transition-colors
duration-100 hover:bg-hover-2"
style={
done
? { animation: `fade-up 350ms cubic-bezier(0.23,1,0.32,1) ${i * 90}ms both` }
: { opacity: 0 }
}
>
<svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="var(--ink-3)" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" className="shrink-0">
<path d="M9 10l-5 5 5 5" />
<path d="M20 4v7a4 4 0 0 1-4 4H4" />
</svg>
{text}
</button>
))}
</div>
</div>
</div>
);
}
@@ -0,0 +1,302 @@
/* Vendored from BeautifulUI registry: https://www.beautifului.dev/r/thinking-state.json
* Spike #293 — DO NOT ship in templates/frontend. Imports rewritten to @/spike/beautifui/*.
* License: MIT (upstream https://github.com/slev12397/beautiful-ui). */
import { useEffect, useLayoutEffect, useRef, useState, type ReactNode } from "react";
/* ─────────────────────────────────────────────────────────
* THINKING — expandable agent trace, four variants
*
* Steps step list with spinner → muted checks
* Reasoning prose reasoning that expands, then settles
* Search web-search trace: query + sources read
* Coding tool trace: files read, edits, commands
*
* The trace runs once, settles, and remains expandable.
* ───────────────────────────────────────────────────────── */
const STAGES = [800, 600, 1800, 2600, 1600];
function useSequence(steps: number[]) {
const [stage, setStage] = useState(0);
useEffect(() => {
if (stage >= steps.length - 1) return;
const t = setTimeout(() => setStage((s) => s + 1), steps[stage]);
return () => clearTimeout(t);
}, [stage, steps]);
return stage;
}
type Row = {
primary: string;
secondary?: string;
mono?: boolean;
add?: number;
del?: number;
href?: string;
};
const VARIANTS: Record<
string,
{ active: string; done: string; rows: Row[]; query?: string }
> = {
Steps: {
active: "Thinking",
done: "Thought for 4 seconds",
rows: [
{ primary: "Reading flavor briefs" },
{ primary: "Scanning supplier lists" },
{ primary: "Comparing tasting notes", secondary: "6 flavors" },
{ primary: "Writing the scoop report" },
],
},
Reasoning: {
active: "Thinking",
done: "Thought for 4 seconds",
rows: [
{ primary: "Summer demand spikes for stone-fruit flavors — peach and apricot lead." },
{ primary: "I should check cone inventory before promoting a waffle-bowl special." },
],
},
Search: {
active: "Searching the web",
done: "Searched the web",
query: "best waffle cone supplier",
rows: [
{ primary: "Joy Cone", secondary: "joycone.com", href: "https://joycone.com/fs_products/waffle-cones/" },
{ primary: "WebstaurantStore", secondary: "webstaurantstore.com", href: "https://www.webstaurantstore.com/ice-cream-shop-supplies.html" },
{ primary: "The Konery", secondary: "thekonery.com", href: "https://www.thekonery.com/" },
],
},
Coding: {
active: "Running tools",
done: "Ran 3 tools",
rows: [
{ primary: "Read", secondary: "flavors.ts", mono: true },
{ primary: "Edit", secondary: "ChurnSchedule.tsx", mono: true, add: 74, del: 41 },
{ primary: "Run", secondary: "npm run freeze", mono: true },
],
},
};
function Dot({ tone }: { tone: string }) {
return (
<span className={`flex size-3.5 shrink-0 items-center justify-center rounded-full text-white ${tone}`}>
<svg width="9" height="9" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5">
<circle cx="12" cy="12" r="9" />
<path d="M3.5 12h17M12 3a14 14 0 0 1 0 18M12 3a14 14 0 0 0 0 18" />
</svg>
</span>
);
}
const TONES = ["bg-accent", "bg-orange", "bg-green"];
export default function ThinkingState({
variant = "Steps",
onSettled,
rows,
active,
done,
icon,
}: {
variant?: string;
onSettled?: () => void;
/** override the built-in trace content (keeps the primitive reusable) */
rows?: Row[];
active?: string;
done?: string;
/** override the header glyph (defaults to the sparkle) */
icon?: ReactNode;
}) {
const stage = useSequence(STAGES);
const [manualExpanded, setManualExpanded] = useState<boolean | null>(null);
const [selectedTool, setSelectedTool] = useState<string | null>(null);
const base = VARIANTS[variant] ?? VARIANTS.Steps;
const v = {
...base,
rows: rows ?? base.rows,
active: active ?? base.active,
done: done ?? base.done,
};
const autoExpanded = stage >= 1 && stage < 4;
const expanded = manualExpanded ?? autoExpanded;
const working = stage < 3;
const visible = stage < 2 ? 0 : stage === 2 ? Math.min(2, v.rows.length) : v.rows.length;
const traceRef = useRef<HTMLDivElement>(null);
const [lineHeight, setLineHeight] = useState(0);
useLayoutEffect(() => {
if (traceRef.current) setLineHeight(traceRef.current.offsetHeight);
}, [visible, expanded, variant, stage]);
/* let embedders sequence content after the trace settles */
const settledRef = useRef(false);
useEffect(() => {
if (working || settledRef.current) return;
settledRef.current = true;
onSettled?.();
}, [working, onSettled]);
return (
<div
key={variant}
className="flex w-full max-w-95 flex-col"
style={{
minHeight: working || expanded ? 176 : undefined,
transition: "min-height 400ms cubic-bezier(0.23,1,0.32,1)",
}}
>
{/* header — shared across variants */}
<button
type="button"
aria-expanded={expanded}
onClick={() => setManualExpanded((current) => !(current ?? autoExpanded))}
className="-mx-1.5 flex w-fit items-center gap-2 rounded-control px-1.5 py-1
transition-colors duration-100 hover:bg-hover-2"
>
{icon ? (
<span className="flex shrink-0 transition-colors duration-200" style={{ color: working ? "var(--ink-2)" : "var(--ink-3)" }}>
{icon}
</span>
) : (
<svg width="16" height="16" viewBox="0 0 24 24" fill={working ? "var(--ink-2)" : "var(--ink-3)"}>
<path d="M12 2l2.4 7.2L22 12l-7.6 2.8L12 22l-2.4-7.2L2 12l7.6-2.8z" />
</svg>
)}
<span role="status" className="contents">
{working ? (
<span
className="bg-clip-text text-[13px] font-medium whitespace-nowrap text-transparent"
style={{
backgroundImage:
"linear-gradient(90deg, var(--ink-3) 35%, var(--ink) 50%, var(--ink-3) 65%)",
backgroundSize: "200% 100%",
animation: "shimmer-text 1.4s linear infinite",
}}
>
{v.active}
</span>
) : (
<span
className="text-[13px] font-medium whitespace-nowrap text-ink-2"
style={{ animation: "fade-in 350ms ease-out both" }}
>
{v.done}
</span>
)}
</span>
<svg
width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="var(--ink-3)" strokeWidth="2.2" strokeLinecap="round" strokeLinejoin="round"
className="transition-transform duration-300"
style={{ transform: expanded ? "rotate(180deg)" : "rotate(0)" }}
>
<path d="M6 9l6 6 6-6" />
</svg>
</button>
{/* expandable trace */}
<div
className="grid transition-[grid-template-rows,opacity] duration-400"
style={{
gridTemplateRows: expanded ? "1fr" : "0fr",
opacity: expanded ? 1 : 0,
transitionTimingFunction: "cubic-bezier(0.23, 1, 0.32, 1)",
}}
>
<div className="overflow-hidden">
<div className="relative mt-1 ml-[5px] pl-4">
<span
aria-hidden
className="absolute left-[3px] w-px bg-line"
style={{ top: -8, height: lineHeight ? lineHeight - 2 : 0, transition: "height 500ms cubic-bezier(0.23,1,0.32,1)" }}
/>
<div ref={traceRef} className="flex flex-col gap-1 py-1">
{v.query && (
<div className="flex h-6 items-center gap-2 px-1.5" style={{ animation: expanded ? "fade-up 300ms cubic-bezier(0.23,1,0.32,1) both" : undefined }}>
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="var(--ink-3)" strokeWidth="2" strokeLinecap="round" className="shrink-0">
<circle cx="11" cy="11" r="7" />
<path d="M21 21l-4.3-4.3" />
</svg>
<span className="text-[12.5px] text-ink-2">{v.query}</span>
</div>
)}
{v.rows.slice(0, visible).map((row, i) => {
const content = (
<>
{variant === "Search" && <Dot tone={TONES[i % 3]} />}
{variant === "Steps" && (
i < visible - 1 || !working ? (
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="var(--ink-3)" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round" className="shrink-0">
<path d="M20 6L9 17l-5-5" />
</svg>
) : (
<span className="size-3 shrink-0 rounded-full border-[1.5px] border-line-strong border-t-ink-2" style={{ animation: "spin 700ms linear infinite" }} />
)
)}
<span className={`min-w-0 truncate text-[12.5px] ${variant === "Reasoning" ? "whitespace-normal leading-relaxed text-ink-2" : "font-medium text-ink"} ${variant === "Search" ? "animated-underline" : ""}`}>
{row.primary}
</span>
{row.secondary && (
<span className={`shrink-0 text-[11.5px] text-ink-3 ${row.mono ? "font-mono" : ""}`}>
{row.secondary}
</span>
)}
{row.add !== undefined && (
<span className="shrink-0 font-mono text-[11px] tabular-nums">
<span className="text-green">+{row.add}</span>{" "}
<span className="text-red">−{row.del}</span>
</span>
)}
</>
);
const rowClass = "flex min-h-7 w-full items-center gap-2 rounded-[6px] px-1.5 py-0.5 text-left";
const animation = { animation: `fade-up 320ms cubic-bezier(0.23,1,0.32,1) ${i * 120}ms both` };
if (variant === "Search") {
return (
<a
key={row.primary}
href={row.href}
target="_blank"
rel="noreferrer"
className={`${rowClass} transition-colors duration-150 hover:bg-hover`}
style={animation}
>
{content}
</a>
);
}
if (variant === "Coding") {
const selected = selectedTool === row.primary;
return (
<button
key={row.primary}
type="button"
aria-pressed={selected}
onClick={() => setSelectedTool(selected ? null : row.primary)}
className={`${rowClass} transition-colors duration-150 ${selected ? "bg-inset" : "hover:bg-hover"}`}
style={animation}
>
{content}
</button>
);
}
return (
<div key={row.primary} className={rowClass} style={animation}>
{content}
</div>
);
})}
{variant === "Search" && stage >= 3 && (
<span className="text-[12px] text-ink-3" style={{ animation: "fade-in 300ms ease-out both" }}>
+7 more
</span>
)}
</div>
</div>
</div>
</div>
</div>
);
}
+11
View File
@@ -0,0 +1,11 @@
/* Spike #293 — dev-only entry. Not part of the production bundle. */
import { StrictMode } from "react";
import { createRoot } from "react-dom/client";
import "@/spike/beautifui/foundation.css";
import SpikePage from "@/spike/SpikePage";
createRoot(document.getElementById("root")!).render(
<StrictMode>
<SpikePage />
</StrictMode>,
);
+91
View File
@@ -0,0 +1,91 @@
/* Spike #293 — PraisonAIUI-shaped mock event stream.
*
* These events use the real `SSEEvent` contract from `@/types` so the spike
* proves the BeautifulUI primitives can render against the *actual* event
* vocabulary the chat surface emits (see src/praisonaiui/templates/frontend/
* plugins/views/chat.js and RunEventType in src/frontend/src/types.ts).
*
* Nothing here talks to a live socket — that is an explicit non-goal of the
* spike. The timeline below is replayed by SpikePage with setTimeout to
* emulate SSE deltas / tool lifecycle / a HITL approval request.
*/
import type { SSEEvent } from "@/types";
/** A scripted SSE frame plus the delay (ms) before it fires. */
export type MockFrame = { at: number; event: SSEEvent };
/** approval_request payload shaped like a row from GET /api/approvals. */
export type ApprovalRequest = {
id: string;
tool: string;
args: Record<string, unknown>;
questions: { q: string; type: "radio" | "check"; options: string[] }[];
};
/* SSE-style text deltas — streamed word-by-word as `run_content`. */
const ANSWER =
"Pistachio is your fastest-growing flavor — sales are up 23% this month " +
"and margins beat vanilla by 8 points. Stone-fruit flavors trend in the same range.";
function contentDeltas(startAt: number, stepMs = 55): MockFrame[] {
const words = ANSWER.split(" ");
return words.map((w, i) => ({
at: startAt + i * stepMs,
event: { type: "run_content", token: w + " ", content: w + " " } as SSEEvent,
}));
}
const LAST_DELTA_AT = 300 + ANSWER.split(" ").length * 55;
/* Reasoning + tool lifecycle, mapped to ThinkingState rows. */
export const REASONING_STREAM: SSEEvent[] = [
{ type: "reasoning_started" },
{ type: "reasoning_step", step: "Reading flavor briefs", step_number: 1 },
{ type: "tool_call_started", name: "sql.query", tool_call_id: "t1", args: { table: "sales" } },
{ type: "tool_call_completed", name: "sql.query", tool_call_id: "t1", formatted_result: "6 flavors" },
{ type: "reasoning_step", step: "Comparing tasting notes", step_number: 2 },
{ type: "tool_call_started", name: "trends.lookup", tool_call_id: "t2", args: { window: "30d" } },
{ type: "tool_call_completed", name: "trends.lookup", tool_call_id: "t2", formatted_result: "+23%" },
{ type: "reasoning_completed" },
];
/* approval_request payload — the /api/approvals shape a HITL gate would push. */
export const APPROVAL_REQUEST: ApprovalRequest = {
id: "appr_9f2",
tool: "menu.publish",
args: { flavors: 3, season: "winter" },
questions: [
{ q: "How many flavors should we launch?", type: "radio", options: ["Three (core line)", "Five (full case)", "Just one hero"] },
{ q: "Which mix-ins should we stock?", type: "check", options: ["Chocolate chips", "Waffle bits", "Sprinkles"] },
],
};
/** Full scripted timeline for the streaming panel. */
export function streamingTimeline(): MockFrame[] {
return [
{ at: 0, event: { type: "run_started" } as SSEEvent },
...contentDeltas(300),
{ at: LAST_DELTA_AT + 100, event: { type: "run_completed" } as SSEEvent },
];
}
/* ── Adapters: PraisonAIUI events → BeautifulUI primitive props ── */
/** run_content deltas → StreamingText tokens (`cite` marks a source chip slot). */
export function tokensFromDeltas(frames: MockFrame[]): { text: string; cite?: boolean }[] {
return frames
.filter((f) => f.event.type === "run_content")
.map((f) => ({ text: (f.event.token ?? f.event.content ?? "").trim() }));
}
/** reasoning_step / tool_call_* → ThinkingState rows. */
export function rowsFromReasoning(events: SSEEvent[]): { primary: string; secondary?: string; mono?: boolean }[] {
const rows: { primary: string; secondary?: string; mono?: boolean }[] = [];
for (const e of events) {
if (e.type === "reasoning_step" && e.step) rows.push({ primary: e.step });
else if (e.type === "tool_call_started" && e.name) rows.push({ primary: e.name, mono: true });
else if (e.type === "tool_call_completed" && e.name)
rows.push({ primary: e.name, secondary: e.formatted_result, mono: true });
}
return rows;
}
+1 -1
View File
@@ -1,3 +1,3 @@
"""Version information."""
__version__ = "0.3.143"
__version__ = "0.3.151"
+48
View File
@@ -44,6 +44,43 @@ def _icon(symbol: str, fallback: str) -> str:
return symbol if _supports_unicode() else fallback
def _load_env_files() -> int:
"""Load environment variables from the standard PraisonAI ``.env`` files.
Mirrors the PraisonAI CLI convention so ``env:VAR`` references (e.g. in
Channels config) resolve when the server was started without the shell
exporting the tokens. Reads ``~/.praisonai/.env`` first, then a ``.env`` in
the current working directory. Existing process env vars are never
overridden, and parsing is intentionally dependency-free (no python-dotenv).
Returns the number of variables newly set into ``os.environ``.
"""
import os
loaded = 0
for env_path in (Path.home() / ".praisonai" / ".env", Path.cwd() / ".env"):
try:
if not env_path.is_file():
continue
for line in env_path.read_text(encoding="utf-8").splitlines():
line = line.strip()
if not line or line.startswith("#") or "=" not in line:
continue
if line.startswith("export "):
line = line[len("export "):]
key, value = line.split("=", 1)
key = key.strip()
value = value.strip().strip('"').strip("'")
if key and key not in os.environ:
os.environ[key] = value
loaded += 1
except Exception:
# Never let env loading break startup — resolution falls back to
# the existing process environment.
continue
return loaded
app = typer.Typer(
name="aiui",
help="PraisonAIUI - YAML-driven website generator",
@@ -1380,6 +1417,17 @@ def run(
console.print(f"[red]Error:[/red] App file not found: {app_file}")
raise typer.Exit(code=1)
# Auto-load standard PraisonAI env files so `env:VAR` references (e.g. in
# Channels config) resolve even when the shell did not export the tokens.
# This matches the PraisonAI CLI convention and fixes the "Connected but
# bot never replies" class of issues caused by empty env-resolved tokens.
_loaded = _load_env_files()
if _loaded:
console.print(
f"[green]{_icon('✓', 'OK')}[/green] Loaded {_loaded} environment "
f"variable(s) from PraisonAI config"
)
is_yaml = app_file.suffix in (".yaml", ".yml")
if is_yaml:
+1
View File
@@ -80,6 +80,7 @@ def auto_register_defaults() -> None:
(".tts", "TTSFeature"),
(".usage", "UsageFeature"),
(".realtime", "RealtimeFeature"),
(".replay", "ReplayFeature"),
(".workflows", "WorkflowsFeature"),
(".guardrails", "GuardrailsFeature"),
(".eval", "EvalFeature"),
+517
View File
@@ -0,0 +1,517 @@
"""Stdlib reader for PraisonAI context-trace JSONL files (Replay contract v1, section 1).
One file per run at ``<traces dir>/<run_or_session_id>.jsonl``; one JSON object
(``ContextEvent.to_dict()``) per line, in append order. Readers must tolerate:
* a partially written last line (it is skipped until it is complete);
* unknown fields and unknown ``event_type`` values (passed through untouched);
* schema 1.0 files (no ``run_id`` / ``parent_seq`` / ``node_id``).
Shared by the ``replay`` feature (runs, events, SSE tail) and the ``tracing``
feature (spans from start/end pairs). No third-party imports.
"""
from __future__ import annotations
import json
import os
import re
import threading
import time
from pathlib import Path
from typing import Any, Dict, Iterable, List, Optional, Tuple
__all__ = [
"RUN_ID_RE",
"LIVE_WINDOW_S",
"traces_dir",
"is_valid_run_id",
"run_path",
"list_run_files",
"read_events",
"TailReader",
"EndTracker",
"find_graph",
"summarize_run",
"build_spans",
]
#: Allowed run ids: one path segment, no separators (path-traversal safe).
RUN_ID_RE = re.compile(r"^[A-Za-z0-9._-]+$")
#: A run without an end marker counts as live while its file changed this recently.
LIVE_WINDOW_S = 15 * 60
#: start event type -> the end event type that closes it.
START_END: Dict[str, str] = {
"session_start": "session_end",
"workflow_start": "workflow_end",
"task_start": "task_end",
"step_start": "step_end",
"agent_start": "agent_end",
"tool_call_start": "tool_call_end",
"llm_request": "llm_response",
"approval_requested": "approval_decided",
}
END_START: Dict[str, str] = {v: k for k, v in START_END.items()}
_SPAN_KIND = {
"session_start": "session",
"workflow_start": "workflow",
"task_start": "task",
"step_start": "step",
"agent_start": "agent",
"tool_call_start": "tool",
"llm_request": "llm",
"approval_requested": "approval",
}
# ── Location ──────────────────────────────────────────────────────────
def traces_dir() -> Path:
"""Resolve the traces directory.
Precedence: ``$PRAISONAI_TRACES_DIR`` > ``$PRAISON_HOME/traces`` > ``~/.praison/traces``.
Never creates the directory (this module is read-only).
"""
override = os.environ.get("PRAISONAI_TRACES_DIR")
if override:
return Path(override).expanduser()
home = os.environ.get("PRAISON_HOME")
base = Path(home).expanduser() if home else Path.home() / ".praison"
return base / "traces"
def is_valid_run_id(run_id: Any) -> bool:
"""True when *run_id* is a single safe path segment."""
return (
isinstance(run_id, str)
and 0 < len(run_id) <= 200
and run_id not in (".", "..")
and bool(RUN_ID_RE.match(run_id))
)
def run_path(run_id: str, directory: Optional[Path] = None) -> Optional[Path]:
"""Return the JSONL path for *run_id*, or ``None`` when the id is unsafe.
The resolved path is additionally checked to sit directly inside the
traces directory, so a symlinked or odd id can never escape it.
"""
if not is_valid_run_id(run_id):
return None
base = directory or traces_dir()
candidate = base / f"{run_id}.jsonl"
try:
if candidate.resolve().parent != base.resolve():
return None
except OSError:
return None
return candidate
def list_run_files(directory: Optional[Path] = None) -> List[Path]:
"""All ``*.jsonl`` files with a valid id, newest (mtime) first."""
base = directory or traces_dir()
if not base.is_dir():
return []
items: List[Tuple[float, Path]] = []
for p in base.iterdir():
if p.suffix != ".jsonl" or not is_valid_run_id(p.stem):
continue
try:
st = p.stat()
except OSError:
continue
if p.is_file():
items.append((st.st_mtime, p))
items.sort(key=lambda it: (it[0], it[1].name), reverse=True)
return [p for _, p in items]
# ── Reading ───────────────────────────────────────────────────────────
def _parse_line(raw: bytes) -> Optional[Dict[str, Any]]:
raw = raw.strip()
if not raw:
return None
try:
obj = json.loads(raw.decode("utf-8"))
except (UnicodeDecodeError, ValueError):
return None
return obj if isinstance(obj, dict) else None
class TailReader:
"""Incrementally read complete JSON lines from a growing file.
Keeps a byte offset; each :meth:`poll` returns only events whose line is
complete. A trailing fragment without a newline is consumed only when it
already parses as a JSON object (a proper prefix of a JSON object never
does), so a half-written line is never surfaced. If the file shrinks
(the writer truncates on restart), reading restarts from the top.
"""
def __init__(self, path: Path) -> None:
self.path = Path(path)
self.offset = 0
def poll(self) -> List[Dict[str, Any]]:
try:
size = self.path.stat().st_size
except OSError:
return []
if size < self.offset:
self.offset = 0
if size == self.offset:
return []
try:
with open(self.path, "rb") as fh:
fh.seek(self.offset)
chunk = fh.read(size - self.offset)
except OSError:
return []
events: List[Dict[str, Any]] = []
consumed = 0
start = 0
while True:
nl = chunk.find(b"\n", start)
if nl < 0:
break
ev = _parse_line(chunk[start:nl])
if ev is not None:
events.append(ev)
start = nl + 1
consumed = start
tail = chunk[start:]
if tail.strip():
ev = _parse_line(tail)
if ev is not None: # complete object, newline not written yet
events.append(ev)
consumed = len(chunk)
self.offset += consumed
return events
_CACHE_LOCK = threading.Lock()
_EVENT_CACHE: Dict[str, Tuple[Tuple[int, int], List[Dict[str, Any]]]] = {}
_SUMMARY_CACHE: Dict[str, Tuple[Tuple[int, int, int], Dict[str, Any]]] = {}
_EVENT_CACHE_MAX = 8
_SUMMARY_CACHE_MAX = 512
def _sig(path: Path) -> Optional[Tuple[int, int]]:
try:
st = path.stat()
except OSError:
return None
return (st.st_mtime_ns, st.st_size)
def _seq(ev: Dict[str, Any], default: int = -1) -> int:
s = ev.get("sequence_num")
return s if isinstance(s, int) and not isinstance(s, bool) else default
def read_events(path: Path, after_seq: Optional[int] = None) -> List[Dict[str, Any]]:
"""All complete events in *path* (append order), optionally ``sequence_num > after_seq``."""
path = Path(path)
sig = _sig(path)
if sig is None:
return []
key = str(path)
with _CACHE_LOCK:
hit = _EVENT_CACHE.get(key)
if hit and hit[0] == sig:
events = hit[1]
else:
events = TailReader(path).poll()
with _CACHE_LOCK:
_EVENT_CACHE.pop(key, None)
_EVENT_CACHE[key] = (sig, events)
while len(_EVENT_CACHE) > _EVENT_CACHE_MAX:
_EVENT_CACHE.pop(next(iter(_EVENT_CACHE)))
if after_seq is None:
return list(events)
return [ev for ev in events if _seq(ev) > after_seq]
# ── Interpretation ────────────────────────────────────────────────────
class EndTracker:
"""Decide when a run has finished, feeding events one at a time.
The outermost scope is the first ``session_start`` or, failing that, the
first top-level ``workflow_start``. The run has ended once that scope's
end event arrives (``session_end`` always ends it).
"""
def __init__(self) -> None:
self.outer: Optional[Tuple[str, int]] = None
self.first_workflow: Optional[int] = None
self.ended = False
self.status: Optional[str] = None
self.ended_at: Optional[float] = None
def feed(self, ev: Dict[str, Any]) -> None:
et = ev.get("event_type")
seq = _seq(ev)
if et == "session_start" and self.outer is None:
self.outer = ("session", seq)
elif et == "workflow_start":
if self.first_workflow is None:
self.first_workflow = seq
if self.outer is None and ev.get("parent_seq") is None:
self.outer = ("workflow", seq)
elif et == "workflow_end":
data = ev.get("data") or {}
ps = ev.get("parent_seq")
if ps is None or ps == self.first_workflow:
if isinstance(data, dict) and data.get("status"):
self.status = str(data["status"])
if self.outer is None or (self.outer[0] == "workflow" and ps in (None, self.outer[1])):
self._end(ev)
elif et == "session_end":
self._end(ev)
def _end(self, ev: Dict[str, Any]) -> None:
self.ended = True
ts = ev.get("timestamp")
if isinstance(ts, (int, float)):
self.ended_at = float(ts)
def find_graph(events: Iterable[Dict[str, Any]]) -> Optional[Dict[str, Any]]:
"""The graph from the first ``workflow_start`` carrying one, else ``None``."""
for ev in events:
if ev.get("event_type") == "workflow_start":
data = ev.get("data")
if isinstance(data, dict) and isinstance(data.get("graph"), dict):
return data["graph"]
return None
def _num(v: Any) -> float:
return float(v) if isinstance(v, (int, float)) and not isinstance(v, bool) else 0.0
def summarize_run(path: Path, now: Optional[float] = None) -> Optional[Dict[str, Any]]:
"""Run-picker row for one trace file (cached on mtime/size)."""
path = Path(path)
try:
st = path.stat()
except OSError:
return None
now = time.time() if now is None else now
sig = (st.st_mtime_ns, st.st_size, int(now - st.st_mtime < LIVE_WINDOW_S))
key = str(path)
with _CACHE_LOCK:
hit = _SUMMARY_CACHE.get(key)
if hit and hit[0] == sig:
return dict(hit[1])
events = read_events(path)
tracker = EndTracker()
agents: List[str] = []
cost = 0.0
session_id = None
event_run_id = None
started_at = None
kind, name, process = "session", None, None
schema = None
for ev in events:
tracker.feed(ev)
if session_id is None and ev.get("session_id"):
session_id = ev.get("session_id")
if event_run_id is None and ev.get("run_id"):
event_run_id = ev.get("run_id")
if schema is None and ev.get("schema_version"):
schema = str(ev.get("schema_version"))
ts = ev.get("timestamp")
if started_at is None and isinstance(ts, (int, float)):
started_at = float(ts)
agent = ev.get("agent_name")
if isinstance(agent, str) and agent and agent not in agents:
agents.append(agent)
cost += _num(ev.get("cost_usd"))
if ev.get("event_type") == "workflow_start" and name is None:
data = ev.get("data") if isinstance(ev.get("data"), dict) else {}
kind = str(data.get("kind") or "workflow")
name = data.get("name")
process = data.get("process")
live = (not tracker.ended) and (now - st.st_mtime) < LIVE_WINDOW_S
if tracker.ended:
status = tracker.status or "completed"
else:
status = "running" if live else "incomplete"
summary: Dict[str, Any] = {
"run_id": path.stem,
"session_id": session_id,
"path": str(path),
"started_at": started_at,
"status": status,
"kind": kind,
"name": name or (agents[0] if agents else path.stem),
"agents": agents,
"events": len(events),
"cost_usd": round(cost, 6),
"live": live,
"schema_version": schema,
}
if tracker.ended_at is not None:
summary["ended_at"] = tracker.ended_at
if process:
summary["process"] = process
if event_run_id and event_run_id != path.stem:
summary["event_run_id"] = event_run_id
with _CACHE_LOCK:
_SUMMARY_CACHE[key] = (sig, summary)
while len(_SUMMARY_CACHE) > _SUMMARY_CACHE_MAX:
_SUMMARY_CACHE.pop(next(iter(_SUMMARY_CACHE)))
return dict(summary)
def _span_name(ev: Dict[str, Any]) -> str:
et = ev.get("event_type")
data = ev.get("data") if isinstance(ev.get("data"), dict) else {}
agent = ev.get("agent_name") or ""
if et == "tool_call_start":
return str(data.get("tool_name") or "tool")
if et == "llm_request":
model = data.get("model")
return f"llm {model}" if model else "llm"
if et == "approval_requested":
return f"approval {data.get('tool') or ''}".strip()
if et in ("task_start", "step_start", "workflow_start"):
return str(data.get("name") or ev.get("node_id") or et.split("_")[0])
if et == "agent_start":
return agent or "agent"
return "session"
def _match_key(ev: Dict[str, Any]) -> Tuple[Any, ...]:
"""Identity used to pair a start with its end when ``parent_seq`` is absent."""
et = ev.get("event_type")
data = ev.get("data") if isinstance(ev.get("data"), dict) else {}
base = END_START.get(et, et)
extra: Any = None
if base == "tool_call_start":
extra = data.get("tool_name")
elif base == "approval_requested":
extra = data.get("approval_id")
elif base in ("task_start", "step_start"):
extra = ev.get("node_id") or data.get("name")
agent = ev.get("agent_name") or ""
if base in ("approval_requested", "session_start", "workflow_start"):
agent = "" # decisions/ends are often emitted by a different emitter name
return (base, agent, ev.get("branch_id"), extra)
def build_spans(events: List[Dict[str, Any]], trace_id: str) -> List[Dict[str, Any]]:
"""Spans from start/end pairs.
Schema 1.1: an end event's ``parent_seq`` names its start; a start's
``parent_seq`` names the enclosing span. Schema 1.0 (no ``parent_seq``):
ends pair with the oldest open start of the same kind/agent/tool, and the
parent is the innermost span open when the start was seen (sequence order).
``llm_response`` is a point event whose ``parent_seq`` is its enclosing
scope, so LLM spans always pair by order.
"""
ordered = sorted(enumerate(events), key=lambda it: (_seq(it[1], it[0]), it[0]))
spans: Dict[int, Dict[str, Any]] = {}
start_type: Dict[int, str] = {}
start_key: Dict[int, Tuple[Any, ...]] = {}
open_by_key: Dict[Tuple[Any, ...], List[int]] = {}
stack: List[int] = []
order: List[int] = []
for idx, ev in ordered:
et = ev.get("event_type")
seq = _seq(ev, idx)
ts = _num(ev.get("timestamp"))
if et in START_END:
parent = ev.get("parent_seq")
if not (isinstance(parent, int) and parent in spans):
parent = next((s for s in reversed(stack) if spans[s]["status"] == "open"), None)
data = ev.get("data") if isinstance(ev.get("data"), dict) else {}
span = {
"id": f"{trace_id}:{seq}",
"trace_id": trace_id,
"parent_span_id": f"{trace_id}:{parent}" if parent is not None else None,
"agent_id": ev.get("agent_name") or "",
"name": _span_name(ev),
"kind": _SPAN_KIND[et],
"status": "open",
"start_time": ts,
"end_time": None,
"duration_ms": None,
"timestamp": ts,
"attributes": {
k: v
for k, v in {
"node_id": ev.get("node_id"),
"branch_id": ev.get("branch_id"),
"start_seq": seq,
"input": data.get("tool_args") if et == "tool_call_start" else None,
}.items()
if v is not None
},
"events": [],
}
spans[seq] = span
start_type[seq] = et
start_key[seq] = _match_key(ev)
order.append(seq)
stack.append(seq)
open_by_key.setdefault(start_key[seq], []).append(seq)
continue
if et not in END_START:
continue
start_seq = None
ps = ev.get("parent_seq")
key = _match_key(ev)
if (
et != "llm_response"
and isinstance(ps, int)
and ps in spans
and spans[ps]["status"] == "open"
and START_END.get(start_type[ps]) == et
):
start_seq = ps
else:
queue = open_by_key.get(key) or []
while queue and spans[queue[0]]["status"] != "open":
queue.pop(0)
if queue:
start_seq = queue[0]
if start_seq is None:
continue
q = open_by_key.get(start_key[start_seq])
if q and start_seq in q:
q.remove(start_seq)
span = spans[start_seq]
data = ev.get("data") if isinstance(ev.get("data"), dict) else {}
dur = data.get("duration_ms")
span["end_time"] = ts
span["duration_ms"] = (
float(dur) if isinstance(dur, (int, float)) and not isinstance(dur, bool)
else round(max(0.0, ts - span["start_time"]) * 1000, 3)
)
failed = bool(data.get("error")) or data.get("status") == "failed" or data.get("approved") is False
span["status"] = "error" if failed else "ok"
attrs = span["attributes"]
for k in ("cost_usd", "prompt_tokens", "completion_tokens"):
v = ev.get(k)
if isinstance(v, (int, float)) and not isinstance(v, bool) and v:
attrs[k] = v
if data.get("error"):
attrs["error"] = data.get("error")
if start_seq in stack:
stack.remove(start_seq)
return [spans[s] for s in order]
+240 -49
View File
@@ -13,6 +13,7 @@ from __future__ import annotations
import asyncio
import logging
import threading
import time
import uuid
from abc import ABC, abstractmethod
@@ -66,7 +67,13 @@ class ApprovalProtocol(ABC):
@abstractmethod
def approve(
self, approval_id: str, reason: str = "", approver: str = "user", always: bool = False
) -> Optional[Dict[str, Any]]: ...
) -> Optional[Dict[str, Any]]:
"""Approve a pending request.
Implementations may also accept ``modified_args: dict`` (replacement
tool arguments); the HTTP handler only passes it when the client sent it.
"""
...
@abstractmethod
def deny(
@@ -86,6 +93,66 @@ class ApprovalProtocol(ABC):
return {"status": "ok", "provider": self.__class__.__name__}
# ── Decision waiter ──────────────────────────────────────────────────
class _DecisionWaiter:
"""One pending decision that both threads and coroutines can wait on.
``set()`` may be called from any thread (normally the server's event loop
handling ``POST /api/approvals/{id}/approve``); sync waiters block on a
``threading.Event`` and async waiters get their future resolved on their
own loop via ``call_soon_threadsafe``, so neither blocks the other.
"""
def __init__(self) -> None:
self._event = threading.Event()
self._lock = threading.Lock()
self._result: Optional[Dict[str, Any]] = None
self._futures: List[tuple] = []
@property
def done(self) -> bool:
return self._event.is_set()
def set(self, entry: Dict[str, Any]) -> None:
with self._lock:
if self._event.is_set():
return
self._result = entry
self._event.set()
futures, self._futures = self._futures, []
for loop, fut in futures:
try:
loop.call_soon_threadsafe(_resolve_future, fut, entry)
except RuntimeError: # loop already closed
pass
def wait(self, timeout: Optional[float]) -> Optional[Dict[str, Any]]:
return self._result if self._event.wait(timeout) else None
async def wait_async(self, timeout: Optional[float]) -> Optional[Dict[str, Any]]:
loop = asyncio.get_running_loop()
fut = loop.create_future()
with self._lock:
if self._event.is_set():
return self._result
self._futures.append((loop, fut))
try:
return await asyncio.wait_for(fut, timeout)
except asyncio.TimeoutError:
return None
finally:
with self._lock:
if (loop, fut) in self._futures:
self._futures.remove((loop, fut))
def _resolve_future(fut: "asyncio.Future", value: Any) -> None:
if not fut.done():
fut.set_result(value)
# ── Simple Approval Manager ──────────────────────────────────────────
@@ -95,6 +162,8 @@ class SimpleApprovalManager(ApprovalProtocol):
def __init__(self) -> None:
self._pending: Dict[str, Dict[str, Any]] = {}
self._history: deque = deque(maxlen=500)
self._lock = threading.RLock()
self._waiters: Dict[str, _DecisionWaiter] = {}
self._policies: Dict[str, Any] = {
"auto_approve_tools": [],
"always_deny_tools": [],
@@ -136,44 +205,67 @@ class SimpleApprovalManager(ApprovalProtocol):
entry.get("agent_name", ""),
entry.get("risk_level", "medium"),
)
if auto:
entry["status"] = auto
entry["resolved_at"] = time.time()
entry["approver"] = "auto-policy"
entry["reason"] = f"Auto-{auto} by policy"
self._history.appendleft(entry)
else:
self._pending[approval_id] = entry
with self._lock:
if auto:
entry["status"] = auto
entry["resolved_at"] = time.time()
entry["approver"] = "auto-policy"
entry["reason"] = f"Auto-{auto} by policy"
self._history.appendleft(entry)
else:
self._pending[approval_id] = entry
self._waiters.setdefault(approval_id, _DecisionWaiter())
return entry
def waiter(self, approval_id: str) -> Optional[_DecisionWaiter]:
"""The waiter for a pending request (``None`` once it is resolved)."""
with self._lock:
return self._waiters.get(approval_id)
def _signal(self, approval_id: str, entry: Dict[str, Any]) -> None:
waiter = self._waiters.pop(approval_id, None)
if waiter is not None:
waiter.set(entry)
def approve(
self, approval_id: str, reason: str = "", approver: str = "user", always: bool = False
self,
approval_id: str,
reason: str = "",
approver: str = "user",
always: bool = False,
modified_args: Optional[Dict[str, Any]] = None,
) -> Optional[Dict[str, Any]]:
if approval_id not in self._pending:
return None
entry = self._pending.pop(approval_id)
entry["status"] = "approved"
entry["reason"] = reason
entry["resolved_at"] = time.time()
entry["approver"] = approver
if always and entry["tool_name"] not in self._policies["auto_approve_tools"]:
self._policies["auto_approve_tools"].append(entry["tool_name"])
self._history.appendleft(entry)
with self._lock:
if approval_id not in self._pending:
return None
entry = self._pending.pop(approval_id)
entry["status"] = "approved"
entry["reason"] = reason
entry["resolved_at"] = time.time()
entry["approver"] = approver
if modified_args is not None:
entry["modified_args"] = dict(modified_args)
if always and entry["tool_name"] not in self._policies["auto_approve_tools"]:
self._policies["auto_approve_tools"].append(entry["tool_name"])
self._history.appendleft(entry)
self._signal(approval_id, entry)
return entry
def deny(
self, approval_id: str, reason: str = "", approver: str = "user", always: bool = False
) -> Optional[Dict[str, Any]]:
if approval_id not in self._pending:
return None
entry = self._pending.pop(approval_id)
entry["status"] = "denied"
entry["reason"] = reason
entry["resolved_at"] = time.time()
entry["approver"] = approver
if always and entry["tool_name"] not in self._policies["always_deny_tools"]:
self._policies["always_deny_tools"].append(entry["tool_name"])
self._history.appendleft(entry)
with self._lock:
if approval_id not in self._pending:
return None
entry = self._pending.pop(approval_id)
entry["status"] = "denied"
entry["reason"] = reason
entry["resolved_at"] = time.time()
entry["approver"] = approver
if always and entry["tool_name"] not in self._policies["always_deny_tools"]:
self._policies["always_deny_tools"].append(entry["tool_name"])
self._history.appendleft(entry)
self._signal(approval_id, entry)
return entry
def get(self, approval_id: str) -> Optional[Dict[str, Any]]:
@@ -237,9 +329,14 @@ class SDKApprovalManager(ApprovalProtocol):
return result
def approve(
self, approval_id: str, reason: str = "", approver: str = "user", always: bool = False
self,
approval_id: str,
reason: str = "",
approver: str = "user",
always: bool = False,
modified_args: Optional[Dict[str, Any]] = None,
) -> Optional[Dict[str, Any]]:
entry = self._simple.approve(approval_id, reason, approver, always)
entry = self._simple.approve(approval_id, reason, approver, always, modified_args)
if entry and always and entry.get("tool_name"):
self._registry.add_requirement(entry["tool_name"], entry.get("risk_level", "high"))
return entry
@@ -273,31 +370,121 @@ class SDKApprovalManager(ApprovalProtocol):
class _UIStoreBackend:
"""Routes agent approval requests into the UI SimpleApprovalManager."""
"""Routes agent approval requests into the UI store and waits for a human.
def __init__(self, store: SimpleApprovalManager) -> None:
Each request becomes a pending entry keyed by ``request.approval_id`` and
the caller blocks (sync: on a ``threading.Event``; async: on a future of
its own loop) until ``POST /api/approvals/{id}/approve|deny`` resolves it.
No answer within the timeout is a denial (fail-closed) and the entry is
resolved as ``denied`` with ``approver="timeout"`` so the queue stays honest.
"""
def __init__(self, store: SimpleApprovalManager, timeout: Optional[float] = None) -> None:
self._store = store
self._timeout = timeout
def request_approval_sync(self, request) -> Any:
def _effective_timeout(self, timeout: Optional[float]) -> float:
if timeout is not None:
return float(timeout)
if self._timeout is not None:
return float(self._timeout)
try:
from praisonaiagents.approval import get_approval_registry
return float(getattr(get_approval_registry(), "timeout", 300.0))
except Exception:
return 300.0
def _submit(self, request) -> Dict[str, Any]:
entry: Dict[str, Any] = {
"tool_name": request.tool_name,
"agent_name": getattr(request, "agent_name", "") or "",
"risk_level": request.risk_level or "medium",
"arguments": request.arguments or {},
}
approval_id = getattr(request, "approval_id", None)
if approval_id:
entry["id"] = str(approval_id)
session_id = getattr(request, "session_id", None)
if session_id:
entry["session_id"] = session_id
return self._store.request_approval(entry)
@staticmethod
def _decision(entry: Optional[Dict[str, Any]]) -> Any:
from praisonaiagents.approval.protocols import ApprovalDecision
entry = self._store.request_approval(
{
"tool_name": request.tool_name,
"agent_name": getattr(request, "agent_name", "") or "",
"risk_level": request.risk_level or "medium",
"arguments": request.arguments or {},
}
if entry is None:
return ApprovalDecision(
approved=False, reason="Timed out waiting for UI approval", approver="timeout"
)
if entry.get("status") == "approved":
return ApprovalDecision(
approved=True,
reason=entry.get("reason", ""),
modified_args=dict(entry.get("modified_args") or {}),
approver=entry.get("approver"),
)
return ApprovalDecision(
approved=False,
reason=entry.get("reason") or "denied",
approver=entry.get("approver"),
)
status = entry.get("status", "pending")
if status == "approved":
return ApprovalDecision(approved=True, reason=entry.get("reason", ""))
if status == "denied":
return ApprovalDecision(approved=False, reason=entry.get("reason", "denied"))
return ApprovalDecision(approved=False, reason="Awaiting UI approval")
def _expire(
self,
approval_id: str,
reason: str = "Timed out waiting for UI approval",
approver: str = "timeout",
) -> Optional[Dict[str, Any]]:
"""Deny a request nobody answered; return the entry if a human won the race."""
denied = self._store.deny(approval_id, reason=reason, approver=approver)
if denied is not None:
return None
return self._store.get(approval_id)
def request_approval_sync(self, request, timeout: Optional[float] = None) -> Any:
from praisonaiagents.approval.protocols import ApprovalDecision
entry = self._submit(request)
if entry.get("status") != "pending":
return self._decision(entry)
approval_id = entry["id"]
try:
asyncio.get_running_loop()
except RuntimeError:
pass
else:
# Blocking here would freeze this thread's event loop (and with it,
# possibly the very server that has to deliver the answer).
reason = "UI approval cannot block an event-loop thread; use the async approval path"
self._expire(approval_id, reason=reason, approver="ui")
return ApprovalDecision(approved=False, reason=reason, approver="ui")
waiter = self._store.waiter(approval_id)
result = waiter.wait(self._effective_timeout(timeout)) if waiter else self._store.get(approval_id)
if result is None:
result = self._expire(approval_id)
return self._decision(result)
async def request_approval(self, request) -> Any:
return self.request_approval_sync(request)
entry = self._submit(request)
if entry.get("status") != "pending":
return self._decision(entry)
approval_id = entry["id"]
waiter = self._store.waiter(approval_id)
try:
result = (
await waiter.wait_async(self._effective_timeout(None))
if waiter
else self._store.get(approval_id)
)
except asyncio.CancelledError:
# The registry's own wait_for timed out (or the turn was cancelled).
self._expire(approval_id)
raise
if result is None:
result = self._expire(approval_id)
return self._decision(result)
# ── Manager singleton ────────────────────────────────────────────────
@@ -449,11 +636,15 @@ class ApprovalsFeature(BaseFeatureProtocol):
mgr = get_approval_manager()
approval_id = request.path_params["approval_id"]
body = await request.json() if request.headers.get("content-length") else {}
modified_args = body.get("modified_args")
if modified_args is not None and not isinstance(modified_args, dict):
return JSONResponse({"error": "modified_args must be an object"}, status_code=400)
entry = mgr.approve(
approval_id,
reason=body.get("reason", ""),
approver=body.get("approver", "user"),
always=body.get("always", False),
**({"modified_args": modified_args} if modified_args is not None else {}),
)
if entry is None:
return JSONResponse({"error": "Approval not found"}, status_code=404)
+40 -3
View File
@@ -315,6 +315,13 @@ class ChannelsFeature(BaseFeatureProtocol):
Returns None on success, or an error string on failure.
"""
# Idempotent start (#277): stop any bot already running for this channel
# before creating a new one. Without this, a toggle off/on, a re-add, or
# an autostart+manual race leaves a duplicate bot with duplicate event
# handlers attached while _live_bots only tracks the latest task.
if channel_id in _live_bots:
await self._stop_channel_bot(channel_id)
platform = entry["platform"]
config = entry.get("config", {})
fallback_errors: List[str] = []
@@ -645,13 +652,24 @@ class ChannelsFeature(BaseFeatureProtocol):
if hasattr(bot, "_session") and hasattr(bot._session, "chat"):
_original_chat = bot._session.chat
async def _wrapped_chat(agent: Any, user_id: str, text: str) -> str:
async def _wrapped_chat(
agent: Any,
user_id: str,
text: str,
*args: Any,
**kwargs: Any,
) -> str:
"""Wrap agent chat to broadcast responses AND intermediate
tool/step events to the Chat UI.
Hooks into the agent's StreamEventEmitter (same pattern as
PraisonAIProvider._run_direct_mode) to capture tool_call,
reasoning, and content events during execution.
Any extra positional/keyword arguments (e.g. ``chat_id``,
``platform``, ``thread_id``) are forwarded untouched to the
wrapped session ``chat`` so per-conversation memory/session
continuity is preserved instead of raising ``TypeError``.
"""
from .chat import _enrich_tool_payload, get_chat_manager
@@ -824,7 +842,7 @@ class ChannelsFeature(BaseFeatureProtocol):
drain_task = asyncio.create_task(_drain_events())
try:
response = await _original_chat(agent, user_id, text)
response = await _original_chat(agent, user_id, text, *args, **kwargs)
finally:
# Signal drain to stop and clean up callback
try:
@@ -905,9 +923,15 @@ class ChannelsFeature(BaseFeatureProtocol):
try:
if hasattr(bot, "stop"):
await bot.stop()
# Cancel the task if still running
# Cancel the task and await it so the old bot is fully torn down
# before this returns — otherwise a restart (#277) can spawn a new
# bot while the previous task is still cancelling (a zombie).
if info and info.get("task") and not info["task"].done():
info["task"].cancel()
try:
await info["task"]
except asyncio.CancelledError:
pass
logger.info(f"Stopped bot for channel '{channel_id}'")
except Exception as e:
logger.error(f"Error stopping bot for '{channel_id}': {e}")
@@ -918,6 +942,19 @@ class ChannelsFeature(BaseFeatureProtocol):
gw = self._get_gateway()
if gw is not None:
getattr(gw, "_channel_bots", {}).pop(channel_id, None)
# Mirror start-time registration: drop the task from the gateway
# too, so a restart (#277) leaves no stale/cancelled task behind.
# The dict form is overwritten on restart, but the list form would
# otherwise accumulate a cancelled task on every restart.
channel_tasks = getattr(gw, "_channel_tasks", None)
task = info.get("task") if info else None
if isinstance(channel_tasks, dict):
channel_tasks.pop(channel_id, None)
elif isinstance(channel_tasks, list) and task is not None:
try:
channel_tasks.remove(task)
except ValueError:
pass
# Update local state
ch = _channels.get(channel_id)
+21
View File
@@ -462,6 +462,24 @@ async def _run_and_broadcast(
collected_tool_calls: Dict[str, Dict] = {} # Merge STARTED+COMPLETED per tool_call_id
collected_elements: List[Dict[str, Any]] = []
# Register this run so ``abort_run(run_id)`` can cancel it, and announce the
# run to WS clients up-front. Without this the ``_active_runs`` registry was
# never populated, so the Stop button and ``POST /api/chat/abort`` were
# silent no-ops that always returned ``no_active_run`` (issue #274). The
# ``run_started`` event also lets the frontend capture ``run_id`` and enable
# the Stop button immediately, before the first token arrives.
self_task = asyncio.current_task()
if self_task is not None:
mgr._active_runs[run_id] = self_task
started_event: Dict[str, Any] = {
"type": "run_started",
"session_id": session_id,
"run_id": run_id,
}
if agent_name:
started_event["agent_name"] = agent_name
await mgr.broadcast(session_id, started_event)
try:
# Pass image attachments to provider for SDK native multimodal handling
run_kwargs = {}
@@ -644,6 +662,9 @@ async def _run_and_broadcast(
"error": str(e),
},
)
finally:
# Always deregister the run, on normal completion, error, or abort.
mgr._active_runs.pop(run_id, None)
# Save assistant response
if full_response or collected_elements:
+21 -10
View File
@@ -168,7 +168,9 @@ async def run_startup_hooks() -> None:
"""Execute all registered startup hooks.
Called by the server during application startup.
Blocks until all hooks complete.
Has a timeout from AIUI_STARTUP_TIMEOUT (default 60s) so a hung hook
cannot block the server from reaching the lifespan ``yield`` — startup is
marked completed on timeout so ``/health/live`` still responds.
"""
if _lifecycle_state["startup_completed"]:
_log.warning("Startup hooks already executed")
@@ -177,15 +179,24 @@ async def run_startup_hooks() -> None:
_log.info(f"Running {len(_startup_hooks)} startup hooks")
start_time = time.perf_counter()
for hook in _startup_hooks:
try:
_log.debug(f"Executing startup hook: {getattr(hook, '__name__', str(hook))}")
result = hook()
if asyncio.iscoroutine(result):
await result
except Exception as e:
_log.error(f"Startup hook failed: {getattr(hook, '__name__', str(hook))}: {e}")
# Continue with other hooks - individual failures shouldn't break startup
# Get startup timeout from environment (mirrors AIUI_SHUTDOWN_TIMEOUT)
timeout = float(os.environ.get("AIUI_STARTUP_TIMEOUT", "60.0"))
async def _execute_hooks():
for hook in _startup_hooks:
try:
_log.debug(f"Executing startup hook: {getattr(hook, '__name__', str(hook))}")
result = hook()
if asyncio.iscoroutine(result):
await result
except Exception as e:
_log.error(f"Startup hook failed: {getattr(hook, '__name__', str(hook))}: {e}")
# Continue with other hooks - individual failures shouldn't break startup
try:
await asyncio.wait_for(_execute_hooks(), timeout=timeout)
except asyncio.TimeoutError:
_log.error(f"Startup hooks timed out after {timeout}s")
execution_time = time.perf_counter() - start_time
_lifecycle_state.update(
+70 -35
View File
@@ -9,6 +9,8 @@ Provides first-class MCP client functionality with:
from __future__ import annotations
import asyncio
import contextlib
import json
import logging
import subprocess
@@ -109,50 +111,52 @@ class StdioMCPClient:
self.args = args
self.session: Optional[ClientSession] = None
self._connected = False
self._session_manager = None
self._exit_stack: contextlib.AsyncExitStack | None = None
# For test backward compatibility
self.process: Optional[subprocess.Popen] = None
async def connect(self) -> bool:
"""Connect via stdio subprocess using official MCP SDK."""
# Store the stack on self before entering contexts so that a caller
# cancellation (e.g. asyncio.wait_for timeout, which raises the
# BaseException CancelledError past the except below) can still find
# and close the partially-entered stack via disconnect().
exit_stack = contextlib.AsyncExitStack()
self._exit_stack = exit_stack
try:
# Use official MCP stdio_client instead of raw subprocess
server_params = StdioServerParameters(command=self.command, args=self.args)
# Create a new session using stdio_client context manager
session_manager = stdio_client(server_params)
self.session = await session_manager.__aenter__()
# Initialize the MCP session
read_stream, write_stream = await exit_stack.enter_async_context(
stdio_client(server_params)
)
self.session = await exit_stack.enter_async_context(
ClientSession(read_stream, write_stream)
)
await self.session.initialize()
# Store the context manager for cleanup
self._session_manager = session_manager
self._connected = True
logger.info(f"Connected to MCP stdio server: {self.command}")
return True
except Exception as e:
logger.error(f"Failed to connect to MCP stdio server: {e}")
await exit_stack.aclose()
self._exit_stack = None
self._connected = False
self.session = None
return False
async def disconnect(self) -> None:
"""Properly disconnect the MCP session."""
if self.session and self._connected:
if self._exit_stack:
try:
# Properly exit the context manager
if hasattr(self, "_session_manager"):
await self._session_manager.__aexit__(None, None, None)
else:
await self.session.close()
await self._exit_stack.aclose()
except Exception as e:
logger.warning(f"Error during MCP session close: {e}")
finally:
self._exit_stack = None
self.session = None
self._connected = False
self.process = None # Reset for backward compatibility
self.process = None
async def list_tools(self) -> List[ToolInfo]:
"""List tools via proper MCP protocol."""
@@ -203,42 +207,45 @@ class SSEMCPClient:
self.headers = headers or {}
self.session: Optional[ClientSession] = None
self._connected = False
self._session_manager = None
self._exit_stack: contextlib.AsyncExitStack | None = None
async def connect(self) -> bool:
"""Connect via SSE using official MCP SDK."""
# Store the stack on self before entering contexts so a caller
# cancellation (e.g. asyncio.wait_for timeout) can still close the
# partially-entered stack via disconnect(). See StdioMCPClient.connect.
exit_stack = contextlib.AsyncExitStack()
self._exit_stack = exit_stack
try:
# Use official MCP sse_client context manager
session_manager = sse_client(self.url, headers=self.headers)
self.session = await session_manager.__aenter__()
# Initialize the MCP session
read_stream, write_stream = await exit_stack.enter_async_context(
sse_client(self.url, headers=self.headers)
)
self.session = await exit_stack.enter_async_context(
ClientSession(read_stream, write_stream)
)
await self.session.initialize()
# Store the context manager for cleanup
self._session_manager = session_manager
self._connected = True
logger.info(f"Connected to MCP SSE server at {self.url}")
return True
except Exception as e:
logger.error(f"Failed to connect to MCP SSE server: {e}")
await exit_stack.aclose()
self._exit_stack = None
self._connected = False
self.session = None
return False
async def disconnect(self) -> None:
"""Properly disconnect SSE."""
if self.session and self._connected:
if self._exit_stack:
try:
# Properly exit the context manager
if hasattr(self, "_session_manager") and self._session_manager:
await self._session_manager.__aexit__(None, None, None)
else:
await self.session.close()
await self._exit_stack.aclose()
except Exception as e:
logger.warning(f"Error during MCP SSE session close: {e}")
finally:
self._exit_stack = None
self.session = None
self._connected = False
@@ -354,6 +361,13 @@ class MCPClientManager:
connection_data=server_config,
)
# Disconnect any existing client for this name (reconnect) before
# replacing it, otherwise the previous stdio subprocess/streams are
# orphaned when the reference is overwritten.
previous_client = self._clients.pop(name, None)
if previous_client is not None:
await self._safe_disconnect(name, previous_client)
# Register server
_mcp_servers[name] = server
self._clients[name] = client
@@ -362,8 +376,7 @@ class MCPClientManager:
await self._notify_status_change(server)
try:
# Attempt connection
connected = await client.connect()
connected = await asyncio.wait_for(client.connect(), timeout=120.0)
if connected:
server.status = MCPStatus.CONNECTED
server.tools = await client.list_tools()
@@ -374,16 +387,38 @@ class MCPClientManager:
else:
server.status = MCPStatus.ERROR
server.last_error = "Connection failed"
await self._cleanup_failed_client(name, client)
except asyncio.TimeoutError:
logger.error("Timed out connecting MCP server %s", name)
server.status = MCPStatus.ERROR
server.last_error = "Connection timed out after 120s (check npx/Node and server args)"
await client.disconnect()
except Exception as e:
logger.exception(f"Failed to connect MCP server {name}")
server.status = MCPStatus.ERROR
server.last_error = str(e)
await self._cleanup_failed_client(name, client)
# Notify final status
await self._notify_status_change(server)
return server
async def _safe_disconnect(self, name: str, client: MCPClientProtocol) -> None:
"""Disconnect a client, logging (not raising) on failure."""
try:
await client.disconnect()
except Exception:
logger.exception(f"Error disconnecting MCP client {name}")
async def _cleanup_failed_client(self, name: str, client: MCPClientProtocol) -> None:
"""Release a client whose connection failed and drop it from the registry."""
await self._safe_disconnect(name, client)
# Only remove if this client is still the registered one (avoid evicting
# a newer client from a concurrent reconnect).
if self._clients.get(name) is client:
del self._clients[name]
async def disconnect_server(
self, name: str, session_context: Optional[Dict[str, Any]] = None
) -> bool:
+441
View File
@@ -0,0 +1,441 @@
"""PraisonAI Replay feature: read-only access to recorded agent runs.
Routes (contract v1, section 5):
* ``GET /api/replay/runs`` run picker rows, newest first
* ``GET /api/replay/runs/{run_id}/events`` ``{events, graph, run}``; ``?after_seq=N``;
``?wait=1`` answers ``{events: [], graph: null, run: null}`` (not 404) for a run
that has not written its first event yet
* ``GET /api/replay/runs/{run_id}/stream`` SSE tail of the JSONL file
The trace directory is ``$PRAISONAI_TRACES_DIR``, else ``$PRAISON_HOME/traces``,
else ``~/.praison/traces``. Run ids must match ``^[A-Za-z0-9._-]+$``.
The browser side lives in ``templates/frontend/plugins/views/replay-core.js``
(pure functions) and ``replay.js`` (DOM/SVG). :func:`standalone_html` inlines
both into one self-contained page for ``praisonai replay export --format html``.
:func:`create_replay_app` is the same view as a small ASGI app (no dashboard) to
mount inside your own app, optionally with a Run box that starts runs::
app.mount("/replay", create_replay_app(run=my_runner))
"""
from __future__ import annotations
import asyncio
import html
import inspect
import json
import logging
import threading
import time
import uuid
from pathlib import Path
from typing import Any, Callable, Dict, List, Optional
from urllib.parse import quote
from starlette.concurrency import run_in_threadpool
from starlette.requests import Request
from starlette.responses import FileResponse, HTMLResponse, JSONResponse, StreamingResponse
from starlette.routing import Route
from . import _trace_jsonl as tj
from ._base import BaseFeatureProtocol
logger = logging.getLogger(__name__)
#: Page id of the dashboard view; open ``/replay?run=<id>`` (or ``/#/replay?run=<id>``).
REPLAY_PAGE_ID = "replay"
#: Replay's own mark (three lanes and a cursor), used as the page's ``icon_svg``.
REPLAY_MARK_SVG = (
'<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16" width="1em" height="1em" '
'aria-hidden="true" focusable="false" '
'style="display:inline-block;vertical-align:-0.125em">'
'<rect x="1" y="3" width="8" height="2.2" rx="1.1" fill="currentColor"/>'
'<rect x="3" y="7" width="10" height="2.2" rx="1.1" fill="currentColor"/>'
'<rect x="1" y="11" width="6" height="2.2" rx="1.1" fill="currentColor"/>'
'<rect x="11.2" y="1" width="1.6" height="14" rx="0.8" fill="currentColor" opacity="0.55"/>'
"</svg>"
)
#: Pages a replay-only app should whitelist (Replay first, then its companions).
REPLAY_PAGES = ("replay", "approvals", "traces")
_VIEWS_DIR = Path(__file__).resolve().parent.parent / "templates" / "frontend" / "plugins" / "views"
#: Stable on-disk locations of the renderer (also served at /plugins/views/...).
REPLAY_CORE_JS = _VIEWS_DIR / "replay-core.js"
REPLAY_VIEW_JS = _VIEWS_DIR / "replay.js"
def configure_replay_app() -> None:
"""Configure aiui as a Replay-first dashboard (call before the server starts).
Equivalent to ``aiui.set_style("dashboard")`` plus
``aiui.set_pages(["replay", "approvals", "traces"])``; Replay sorts first,
so ``/`` lands on it. Used by ``praisonaiui/replay_app.py``.
"""
from praisonaiui.server import set_pages, set_style
set_style("dashboard")
set_pages(list(REPLAY_PAGES))
def _json_for_script(obj: Any) -> str:
"""JSON that is safe inside an inline ``<script>`` element."""
text = json.dumps(obj, default=str, separators=(",", ":"))
return (
text.replace("<", "\\u003c")
.replace(">", "\\u003e")
.replace("&", "\\u0026")
.replace("\u2028", "\\u2028")
.replace("\u2029", "\\u2029")
)
def standalone_html(
events: List[Dict[str, Any]],
graph: Optional[Dict[str, Any]] = None,
title: str = "PraisonAI Replay",
) -> str:
"""One self-contained HTML page (no CDN, no network) rendering *events*.
Inlines ``replay-core.js`` and ``replay.js`` and sets
``window.__REPLAY_DATA__ = {events, graph}``; the view boots itself.
Approval buttons are hidden because there is no server to answer them.
"""
core = REPLAY_CORE_JS.read_text(encoding="utf-8").replace("</script", "<\\/script")
view = REPLAY_VIEW_JS.read_text(encoding="utf-8").replace("</script", "<\\/script")
if graph is None:
graph = tj.find_graph(events)
data = _json_for_script({"events": list(events), "graph": graph})
safe_title = (
str(title).replace("&", "&amp;").replace("<", "&lt;").replace(">", "&gt;")
)
return (
"<!doctype html>\n<html lang=\"en\">\n<head>\n<meta charset=\"utf-8\">\n"
"<meta name=\"viewport\" content=\"width=device-width, initial-scale=1\">\n"
f"<title>{safe_title}</title>\n"
"<style>body{margin:0;font-family:system-ui,-apple-system,'Segoe UI',sans-serif}</style>\n"
"</head>\n<body>\n<main id=\"replay-root\"></main>\n"
f"<script>window.__REPLAY_DATA__ = {data};</script>\n"
f"<script type=\"module\">\n{core}\n</script>\n"
f"<script type=\"module\">\n{view}\n</script>\n"
"</body>\n</html>\n"
)
def _bad_request(msg: str) -> JSONResponse:
return JSONResponse({"error": msg}, status_code=400)
def _parse_after_seq(request: Request) -> tuple[Optional[int], Optional[JSONResponse]]:
"""``?after_seq=N``; an SSE reconnect's ``Last-Event-ID`` wins when it is later."""
values = []
for raw, strict in (
(request.query_params.get("after_seq"), True),
(request.headers.get("last-event-id"), False),
):
if raw in (None, ""):
continue
try:
values.append(int(raw))
except (TypeError, ValueError):
if strict:
return None, _bad_request("after_seq must be an integer")
return (max(values) if values else None), None
def _resolve(request: Request) -> tuple[Optional[Path], Optional[JSONResponse]]:
run_id = request.path_params.get("run_id", "")
path = tj.run_path(run_id)
if path is None:
return None, _bad_request("invalid run id (allowed: A-Z a-z 0-9 . _ -)")
if not path.is_file():
return None, JSONResponse({"error": "run not found"}, status_code=404)
return path, None
def _seq_of(ev: Dict[str, Any]) -> Optional[int]:
s = ev.get("sequence_num")
return s if isinstance(s, int) and not isinstance(s, bool) else None
class ReplayFeature(BaseFeatureProtocol):
"""Recorded-run timeline, map and live tail (PraisonAI Replay)."""
feature_name = "replay"
feature_description = "Replay recorded agent runs (timeline, map, live tail)"
# Stream timings; instance attributes so tests can shorten them.
poll_interval = 0.25
ping_interval = 15.0
quiet_period = 2.0
@property
def name(self) -> str:
return self.feature_name
@property
def description(self) -> str:
return self.feature_description
def routes(self) -> List[Route]:
return [
Route("/api/replay/runs", self._runs, methods=["GET"]),
Route("/api/replay/runs/{run_id}/events", self._events, methods=["GET"]),
Route("/api/replay/runs/{run_id}/stream", self._stream, methods=["GET"]),
]
async def health(self) -> Dict[str, Any]:
d = tj.traces_dir()
return {
"status": "ok",
"feature": self.name,
"traces_dir": str(d),
"runs": len(tj.list_run_files(d)),
}
def cli_commands(self) -> List[Dict[str, Any]]:
return [
{
"name": "replay",
"help": "List recorded runs (PraisonAI Replay)",
"commands": {"list": {"help": "List recorded runs", "handler": self._cli_list}},
}
]
# ── data ──────────────────────────────────────────────────────────
@staticmethod
def list_runs(limit: int = 200) -> List[Dict[str, Any]]:
rows = []
now = time.time()
for path in tj.list_run_files()[: max(1, limit)]:
row = tj.summarize_run(path, now=now)
if row is not None:
rows.append(row)
rows.sort(key=lambda r: (r.get("started_at") or 0.0, r["run_id"]), reverse=True)
return rows
def _cli_list(self) -> str:
rows = self.list_runs()
if not rows:
return f"No recorded runs in {tj.traces_dir()}"
lines = [f"Runs ({len(rows)}):"]
for r in rows:
live = " LIVE" if r.get("live") else ""
lines.append(
f" {r['run_id']} {r.get('name')} {r['status']}{live} "
f"events={r['events']} ${r['cost_usd']:.4f}"
)
return "\n".join(lines)
# ── handlers ──────────────────────────────────────────────────────
async def _runs(self, request: Request) -> JSONResponse:
try:
limit = int(request.query_params.get("limit", "200"))
except ValueError:
return _bad_request("limit must be an integer")
rows = await run_in_threadpool(self.list_runs, limit)
return JSONResponse(rows)
async def _events(self, request: Request) -> JSONResponse:
path, err = _resolve(request)
if err is not None:
if err.status_code == 404 and request.query_params.get("wait") == "1":
return JSONResponse({"events": [], "graph": None, "run": None})
return err
after_seq, err = _parse_after_seq(request)
if err is not None:
return err
def load() -> Dict[str, Any]:
all_events = tj.read_events(path)
events = all_events if after_seq is None else tj.read_events(path, after_seq)
return {
"events": events,
"graph": tj.find_graph(all_events),
"run": tj.summarize_run(path),
}
return JSONResponse(await run_in_threadpool(load))
async def _stream(self, request: Request) -> StreamingResponse:
path, err = _resolve(request)
if err is not None:
return err # type: ignore[return-value]
after_seq, err = _parse_after_seq(request)
if err is not None:
return err # type: ignore[return-value]
poll, ping, quiet = self.poll_interval, self.ping_interval, self.quiet_period
async def gen():
tail = tj.TailReader(path)
tracker = tj.EndTracker()
last_seq = after_seq
loop = asyncio.get_running_loop()
last_activity = last_ping = loop.time()
yield "retry: 3000\n\n"
while True:
events = await run_in_threadpool(tail.poll)
now = loop.time()
if events:
last_activity = now
for ev in events:
tracker.feed(ev)
seq = _seq_of(ev)
if last_seq is not None and seq is not None and seq <= last_seq:
continue
if seq is not None:
last_seq = seq
sid = f"id: {seq}\n" if seq is not None else ""
yield f"{sid}event: ctx\ndata: {json.dumps(ev, default=str)}\n\n"
idle = now - last_activity
if tracker.ended and idle >= quiet:
yield "event: end\ndata: {}\n\n"
return
if not path.exists() or idle >= tj.LIVE_WINDOW_S:
yield 'event: end\ndata: {"reason": "stale"}\n\n'
return
if now - last_ping >= ping:
last_ping = now
yield "event: ping\ndata: {}\n\n"
await asyncio.sleep(poll)
return StreamingResponse(
gen(),
media_type="text/event-stream",
headers={"Cache-Control": "no-cache", "Connection": "keep-alive", "X-Accel-Buffering": "no"},
)
# ── embeddable app ─────────────────────────────────────────────────────
#: Longest input the Run box accepts (characters).
MAX_RUN_INPUT = 20_000
def _embed_page(base: str, title: str, cfg: Dict[str, Any]) -> str:
"""The page at the app's root: the view, mounted with *cfg*, and nothing else."""
return (
"<!doctype html>\n<html lang=\"en\">\n<head>\n<meta charset=\"utf-8\">\n"
"<meta name=\"viewport\" content=\"width=device-width, initial-scale=1\">\n"
f"<title>{html.escape(title)}</title>\n"
f"<link rel=\"icon\" href=\"data:image/svg+xml,{quote(REPLAY_MARK_SVG.replace('currentColor', '#1A56DB'))}\">\n"
"<style>body{margin:0;font-family:system-ui,-apple-system,'Segoe UI',sans-serif}"
"#replay-root{padding:12px;box-sizing:border-box;max-width:100%}</style>\n"
"</head>\n<body>\n<main id=\"replay-root\"></main>\n"
"<script type=\"module\">\n"
f"import {{ render }} from {_json_for_script(base + '/replay.js')};\n"
f"render(document.getElementById('replay-root'), {_json_for_script(cfg)});\n"
"</script>\n</body>\n</html>\n"
)
def create_replay_app(
run: Optional[Callable[[str, str], Any]] = None,
*,
title: str = "PraisonAI Replay",
input: Optional[str] = "",
placeholder: str = "",
):
"""PraisonAI Replay as a small ASGI app to mount in your own app.
Serves the view at ``/`` (no dashboard around it), its two scripts, and the
``/api/replay/...`` routes, all relative to where it is mounted::
from fastapi import FastAPI
from praisonaiui.features.replay import create_replay_app
app = FastAPI()
app.mount("/replay", create_replay_app())
# <iframe src="/replay/?run=RUN_ID"> shows one run live; /replay/ lists runs
Args:
run: Optional ``run(input, run_id)``. When given, the page shows an
input box and a **Run** button; each click calls it in a worker
thread with a new ``run_id``, and the page follows that run live.
It must record the run under ``run_id`` (``praisonai.replay.app()``
does this for an agent, team or flow). One run at a time.
title: Page title.
input: Text the input box starts with; ``None`` for no input box
(just a Run button, e.g. for a YAML file that has its own topic).
placeholder: Placeholder text for the input box.
Traces are read from the same folder as the rest of Replay
(``$PRAISONAI_TRACES_DIR`` > ``$PRAISON_HOME/traces`` > ``~/.praison/traces``).
Anyone who can reach the app can read the runs (and, with ``run``, start
them): keep it on localhost or behind your app's auth.
"""
from starlette.applications import Starlette
feature = ReplayFeature()
busy = threading.Lock()
async def page(request: Request) -> HTMLResponse:
base = str(request.scope.get("root_path", "")).rstrip("/")
cfg: Dict[str, Any] = {"base": base, "approve": False}
wanted = request.query_params.get("run")
if wanted and tj.is_valid_run_id(wanted):
cfg["run"] = wanted
if run is not None:
cfg["runner"] = {"input": input or "", "placeholder": placeholder, "ask": input is not None}
return HTMLResponse(_embed_page(base, title, cfg), headers={"Cache-Control": "no-store"})
def script(path: Path):
async def serve(request: Request) -> FileResponse:
return FileResponse(path, media_type="text/javascript", headers={"Cache-Control": "no-cache"})
return serve
def _call(text: str, run_id: str) -> None:
try:
result = run(text, run_id) # type: ignore[misc]
if inspect.isawaitable(result):
asyncio.run(result)
except Exception:
logger.exception("replay run %s failed", run_id)
finally:
busy.release()
async def start(request: Request) -> JSONResponse:
# JSON only: a cross-site form post cannot set this content type without
# a CORS preflight, which this app never answers.
if not request.headers.get("content-type", "").lower().startswith("application/json"):
return JSONResponse({"error": "send JSON: {\"input\": \"...\"}"}, status_code=415)
try:
body = await request.json()
except ValueError:
return _bad_request("body must be JSON")
text = body.get("input", "") if isinstance(body, dict) else None
if not isinstance(text, str) or len(text) > MAX_RUN_INPUT:
return _bad_request(f"input must be a string of at most {MAX_RUN_INPUT} characters")
if not busy.acquire(blocking=False):
return JSONResponse({"error": "a run is already going; wait for it to finish"}, status_code=409)
run_id = f"run-{uuid.uuid4().hex[:12]}"
try:
threading.Thread(target=_call, args=(text, run_id), name=f"replay-{run_id}", daemon=True).start()
except Exception:
busy.release()
raise
# Answer once the trace file exists (usually milliseconds), so the page
# can open the run straight away; it waits on its own if this times out.
path = tj.run_path(run_id)
for _ in range(40):
if path is not None and path.is_file():
break
await asyncio.sleep(0.05)
return JSONResponse({"run_id": run_id})
routes = [
Route("/", page, methods=["GET"]),
Route("/replay.js", script(REPLAY_VIEW_JS), methods=["GET"]),
Route("/replay-core.js", script(REPLAY_CORE_JS), methods=["GET"]),
*feature.routes(),
]
if run is not None:
routes.append(Route("/api/replay/run", start, methods=["POST"]))
return Starlette(routes=routes)
+109 -90
View File
@@ -2,8 +2,8 @@
Architecture:
TracingProtocol (ABC) <- any backend implements this
├── SimpleTracingManager <- default in-memory (no deps)
└── SDKTracingManager <- wraps praisonaiagents.obs + trace
├── SimpleTracingManager <- in-memory (no deps)
└── JsonlTracingManager <- recorded runs (context-trace JSONL) + in-memory
PraisonAITracing (BaseFeatureProtocol)
└── delegates to active TracingProtocol implementation
@@ -110,56 +110,112 @@ class SimpleTracingManager(TracingProtocol):
}
# ── SDK Tracing Manager ──────────────────────────────────────────────
# ── JSONL Tracing Manager ────────────────────────────────────────────
class SDKTracingManager(TracingProtocol):
"""Wraps praisonaiagents.obs + trace for production use."""
class JsonlTracingManager(TracingProtocol):
"""Reads recorded runs from the context-trace JSONL directory.
One ``<id>.jsonl`` file is one trace; spans come from start/end event
pairs (``parent_seq`` on schema 1.1, sequence order on 1.0). The
directory is ``$PRAISONAI_TRACES_DIR`` > ``$PRAISON_HOME/traces`` >
``~/.praison/traces`` (shared with the ``replay`` feature). Traces and
spans posted to ``/api/traces/record`` stay in memory alongside.
"""
#: Upper bound on files scanned for an unfiltered span listing.
MAX_FILES_FOR_SPANS = 20
def __init__(self) -> None:
self._trace_available = False
self._obs_available = False
try:
from praisonaiagents.trace import ActionEvent # noqa: F401
self._trace_available = True
except (ImportError, AttributeError):
pass
try:
from praisonaiagents.obs import Span, Trace # noqa: F401
self._obs_available = True
except ImportError:
pass
if not (self._trace_available or self._obs_available):
raise ImportError("Neither praisonaiagents.trace nor praisonaiagents.obs available")
self._simple = SimpleTracingManager()
logger.info(
"SDKTracingManager initialized (trace=%s, obs=%s)",
self._trace_available,
self._obs_available,
# -- JSONL side ---------------------------------------------------
@staticmethod
def _trace_from_file(path) -> Optional[Dict[str, Any]]:
from . import _trace_jsonl as tj
row = tj.summarize_run(path)
if row is None:
return None
events = tj.read_events(path)
started = row.get("started_at") or 0.0
ended = row.get("ended_at")
last_ts = max(
(e["timestamp"] for e in events if isinstance(e.get("timestamp"), (int, float))),
default=started,
)
agents = row.get("agents") or []
return {
"id": row["run_id"],
"agent_id": agents[0] if agents else "",
"agents": agents,
"name": row.get("name") or row["run_id"],
"status": row["status"],
"start_time": started,
"end_time": ended,
"timestamp": started,
"duration_ms": round(((ended or last_ts) - started) * 1000, 3),
"span_count": len(tj.build_spans(events, row["run_id"])),
"source": "jsonl",
"metadata": {
"path": row["path"],
"session_id": row.get("session_id"),
"kind": row.get("kind"),
"events": row["events"],
"cost_usd": row["cost_usd"],
"live": row["live"],
},
}
@staticmethod
def _files():
from . import _trace_jsonl as tj
return tj.list_run_files()
def list_traces(
self, *, limit: int = 50, agent_id: Optional[str] = None
) -> List[Dict[str, Any]]:
sdk_traces = self._list_traces_from_sdk(limit=limit, agent_id=agent_id)
if sdk_traces is not None:
return sdk_traces
return self._simple.list_traces(limit=limit, agent_id=agent_id)
items: List[Dict[str, Any]] = []
for path in self._files():
if len(items) >= limit:
break
trace = self._trace_from_file(path)
if trace is None:
continue
if agent_id and agent_id not in trace["agents"]:
continue
items.append(trace)
remaining = max(0, limit - len(items))
if remaining:
items.extend(self._simple.list_traces(limit=remaining, agent_id=agent_id))
return items
def list_spans(
self, *, limit: int = 100, trace_id: Optional[str] = None
) -> List[Dict[str, Any]]:
sdk_spans = self._list_spans_from_sdk(limit=limit, trace_id=trace_id)
if sdk_spans is not None:
return sdk_spans
return self._simple.list_spans(limit=limit, trace_id=trace_id)
from . import _trace_jsonl as tj
if trace_id:
path = tj.run_path(trace_id)
if path is not None and path.is_file():
return tj.build_spans(tj.read_events(path), trace_id)[:limit]
return self._simple.list_spans(limit=limit, trace_id=trace_id)
spans: List[Dict[str, Any]] = []
for path in self._files()[: self.MAX_FILES_FOR_SPANS]:
spans.extend(tj.build_spans(tj.read_events(path), path.stem))
if len(spans) >= limit:
return spans[:limit]
spans.extend(self._simple.list_spans(limit=limit - len(spans)))
return spans[:limit]
def get_trace(self, trace_id: str) -> Optional[Dict[str, Any]]:
sdk_trace = self._get_trace_from_sdk(trace_id)
if sdk_trace is not None:
return sdk_trace
from . import _trace_jsonl as tj
path = tj.run_path(trace_id)
if path is not None and path.is_file():
return self._trace_from_file(path)
return self._simple.get_trace(trace_id)
def record_trace(self, trace: Dict[str, Any]) -> str:
@@ -168,69 +224,34 @@ class SDKTracingManager(TracingProtocol):
def record_span(self, span: Dict[str, Any]) -> str:
return self._simple.record_span(span)
def _list_traces_from_sdk(
self, *, limit: int, agent_id: Optional[str]
) -> Optional[List[Dict[str, Any]]]:
try:
from praisonaiagents import trace as trace_module
getter = getattr(trace_module, "list_traces", None)
if not callable(getter):
return None
items = getter(limit=limit, agent_id=agent_id)
return items if isinstance(items, list) else None
except Exception:
return None
def _list_spans_from_sdk(
self, *, limit: int, trace_id: Optional[str]
) -> Optional[List[Dict[str, Any]]]:
try:
from praisonaiagents import trace as trace_module
getter = getattr(trace_module, "list_spans", None)
if not callable(getter):
return None
items = getter(limit=limit, trace_id=trace_id)
return items if isinstance(items, list) else None
except Exception:
return None
def _get_trace_from_sdk(self, trace_id: str) -> Optional[Dict[str, Any]]:
try:
from praisonaiagents import trace as trace_module
getter = getattr(trace_module, "get_trace", None)
if not callable(getter):
return None
trace = getter(trace_id)
return trace if isinstance(trace, dict) else None
except Exception:
return None
def health(self) -> Dict[str, Any]:
from . import _trace_jsonl as tj
h = self._simple.health()
h["provider"] = "SDKTracingManager"
h["trace_available"] = self._trace_available
h["obs_available"] = self._obs_available
files = self._files()
h["provider"] = self.__class__.__name__
h["source"] = "jsonl" if files else "memory"
h["traces_dir"] = str(tj.traces_dir())
h["jsonl_traces"] = len(files)
return h
#: Deprecated alias. The old class looked up ``praisonaiagents.trace.list_traces``
#: / ``list_spans`` / ``get_trace``, which never existed, so it always fell back
#: to the in-memory store. Recorded runs are JSONL files; read those instead.
SDKTracingManager = JsonlTracingManager
# ── Manager singleton ────────────────────────────────────────────────
_tracing_manager: Optional[TracingProtocol] = None
def get_tracing_manager() -> TracingProtocol:
"""Get the active tracing manager (SDK-first, fallback to Simple)."""
"""Get the active tracing manager (recorded JSONL runs + in-memory records)."""
global _tracing_manager
if _tracing_manager is None:
try:
_tracing_manager = SDKTracingManager()
logger.info("Using SDKTracingManager")
except Exception as e:
logger.debug("SDKTracingManager init failed (%s), using SimpleTracingManager", e)
_tracing_manager = SimpleTracingManager()
_tracing_manager = JsonlTracingManager()
return _tracing_manager
@@ -301,12 +322,10 @@ class TracingFeature(BaseFeatureProtocol):
h = mgr.health()
lines = ["Tracing Status:"]
lines.append(f" Provider: {h.get('provider', 'unknown')}")
if "source" in h:
lines.append(f" Source: {h['source']} ({h.get('traces_dir', '')})")
lines.append(f" Total traces: {h.get('total_traces', 0)}")
lines.append(f" Total spans: {h.get('total_spans', 0)}")
if "trace_available" in h:
lines.append(f" SDK trace: {'available' if h['trace_available'] else 'unavailable'}")
if "obs_available" in h:
lines.append(f" SDK obs: {'available' if h['obs_available'] else 'unavailable'}")
return "\n".join(lines)
def _cli_list(self, limit: int = 20) -> str:
+15
View File
@@ -0,0 +1,15 @@
"""Ready-made aiui app that opens straight to PraisonAI Replay.
Usage (what ``praisonai replay view`` runs)::
aiui run "$(python -c 'import praisonaiui.replay_app as m; print(m.__file__)')" \
--style dashboard --host 127.0.0.1 --port 8090
then open ``http://127.0.0.1:8090/replay?run=<run_id>`` (``/#/replay?run=<run_id>``
is accepted too). The trace directory follows ``PRAISONAI_TRACES_DIR`` >
``PRAISON_HOME/traces`` > ``~/.praison/traces``.
"""
from praisonaiui.features.replay import configure_replay_app
configure_replay_app()
+18 -2
View File
@@ -1303,9 +1303,11 @@ async def patch_session(request: Request) -> JSONResponse:
body = await request.json()
except Exception:
return JSONResponse({"error": "Invalid JSON"}, status_code=400)
title = body.get("title")
title = body.get("title") if isinstance(body, dict) else None
if title is not None:
await _datastore.update_session(session_id, title=title)
if not isinstance(title, str) or not title.strip():
return JSONResponse({"error": "title must be a non-empty string"}, status_code=400)
await _datastore.update_session(session_id, title=title.strip()[:200])
return JSONResponse({"status": "updated", "session_id": session_id})
@@ -3280,6 +3282,8 @@ def create_app(
routes.extend(feature.routes())
# Register built-in dashboard pages via the same protocol
from praisonaiui.features.replay import REPLAY_MARK_SVG
_builtin_pages = [
{
"id": "overview",
@@ -3296,6 +3300,7 @@ def create_app(
"group": "Agent",
"description": "AI agent chat",
"order": 1,
"layout": "full-bleed",
},
{
"id": "chat-canvas",
@@ -3304,6 +3309,7 @@ def create_app(
"group": "Agent",
"description": "Chat with live A2UI preview panel",
"order": 2,
"layout": "full-bleed",
},
{
"id": "canvas",
@@ -3345,6 +3351,15 @@ def create_app(
"description": "Run flight recorder — timeline debugger",
"order": 22,
},
{
"id": "replay",
"title": "Replay",
"icon": "⏮️",
"icon_svg": REPLAY_MARK_SVG,
"group": "Control",
"description": "Recorded agent runs, step by step: who did what, when, and at what cost",
"order": 21,
},
{
"id": "usage",
"title": "Usage",
@@ -3582,6 +3597,7 @@ def create_app(
_page_api_overrides = {
"sessions": "/sessions",
"runs": "/api/sessions",
"replay": "/api/replay/runs",
"cron": "/api/schedules",
"channels": "/api/channels",
"mcp": "/api/mcp/servers",
@@ -147,6 +147,7 @@ const BUILTIN_VIEWS = {
agents: '/plugins/views/agents.js',
sessions: '/plugins/views/sessions.js',
runs: '/plugins/views/run-recorder.js',
replay: '/plugins/views/replay.js',
memory: '/plugins/views/memory.js',
knowledge: '/plugins/views/knowledge.js',
logs: '/plugins/views/logs.js',
@@ -252,6 +253,7 @@ window.aiui.registerSurfaceRenderer = function(surfaceId, renderFn) {
window.aiui.surfaces = SURFACE_REGISTRY;
window.aiui.version = '1';
window.aiui.components = COMPONENT_REGISTRY;
window.aiui.renderComponent = renderComponent;
window.aiui.registerSlot = function (name, renderFn) { SLOT_REGISTRY[name] = renderFn; };
window.aiui.slots = SLOT_REGISTRY;
window.aiui.sdk = aiuiSdk;
@@ -376,8 +378,12 @@ const DASHBOARD_STYLE = `
.db-main { flex: 1; padding: 32px 40px; overflow-y: auto; min-width: 0; }
.db-main.db-clean { padding: 0; display: flex; flex-direction: column; overflow: hidden; height: 100vh; }
.db-main.db-clean > #db-page-content { flex: 1; display: flex; flex-direction: column; min-height: 0; }
/* Per-page full bleed (page.layout === 'full-bleed'): view fills the main column. */
.db-main.db-fill { display: flex; flex-direction: column; overflow: hidden; height: 100vh; box-sizing: border-box; }
.db-main.db-fill > #db-page-content { flex: 1; display: flex; flex-direction: column; min-height: 0; }
.db-page-header { margin-bottom: 28px; }
.db-page-title { font-size: 26px; font-weight: 700; letter-spacing: -0.02em; margin: 0 0 4px; }
.db-page-icon svg, .db-nav-icon svg { display: inline-block; vertical-align: -0.125em; }
.db-page-desc { color: var(--db-text-dim); font-size: 14px; margin: 0; }
/* Generic data viewer */
@@ -456,10 +462,20 @@ const DASHBOARD_STYLE = `
.db-mobile-overlay { display: none; position: fixed; inset: 0; z-index: 35; background: rgba(0,0,0,0.5); }
@media (max-width: 1023px) {
.db-mobile-header { display: flex; }
.db-sidebar { position: fixed; z-index: 45; transform: translateX(-100%); transition: transform 0.2s ease; }
/* !important: buildSidebar() sets an inline position:relative for its toggle,
which otherwise keeps the hidden sidebar's 260px in the flex row. */
.db-sidebar { position: fixed !important; top: 0; left: 0; z-index: 45; transform: translateX(-100%); transition: transform 0.2s ease; }
.db-sidebar.db-sidebar-open { transform: translateX(0); }
.db-mobile-overlay.db-visible { display: block; }
.db-main { padding-top: 48px; }
/* The shell header slot sits in the same flex row; give it its own line. */
#root.db-active { flex-wrap: wrap; }
#db-shell-header-slot { flex: 0 0 100%; margin-top: 48px; }
#db-shell-header-slot + .db-main { padding-top: 16px; }
}
@media (max-width: 600px) {
.db-main { padding: 64px 16px 16px; }
#db-shell-header-slot + .db-main { padding-top: 16px; }
}
.db-card {
background: var(--db-card-bg); border: 1px solid var(--db-border);
@@ -1751,6 +1767,13 @@ async function buildDashboard() {
renderSlot('shell:header', shellHeader);
if (shellHeader.children.length) root.insertBefore(shellHeader, main);
// Hash deep links (e.g. /#/replay?run=<id> from `praisonai replay view`)
// become the path form (/replay?run=<id>) before the page is resolved.
const hashRoute = location.hash.match(/^#\/([A-Za-z0-9_-]+)(\?[^#]*)?$/);
if (hashRoute && pagesData.find(p => p.id === hashRoute[1])) {
history.replaceState(null, '', `/${hashRoute[1]}${hashRoute[2] || ''}`);
}
// Resolve initial page from URL path or default to first page
const pathId = location.pathname.replace(/^\//, '') || '';
const initialPage = pathId && pagesData.find(p => p.id === pathId)
@@ -1767,6 +1790,29 @@ async function buildDashboard() {
});
}
// Pages may carry an inline SVG mark (`icon_svg`) next to the text `icon`.
// It is parsed as SVG and dropped if it holds scripts, foreignObject,
// event handlers or javascript: URLs; otherwise the text icon is used.
function safeSvgIcon(markup) {
if (typeof markup !== 'string' || !markup.trim().startsWith('<svg')) return null;
try {
const doc = new DOMParser().parseFromString(markup, 'image/svg+xml');
const svg = doc.documentElement;
if (!svg || svg.nodeName !== 'svg' || doc.querySelector('parsererror, script, foreignObject')) return null;
for (const el of [svg, ...svg.querySelectorAll('*')]) {
for (const a of [...el.attributes]) {
if (/^on/i.test(a.name) || /javascript:/i.test(a.value)) return null;
}
}
return document.importNode(svg, true);
} catch (e) { return null; }
}
function setPageIcon(span, page, fallback) {
const svg = safeSvgIcon(page.icon_svg);
if (svg) { span.textContent = ''; span.appendChild(svg); } else span.textContent = page.icon || fallback;
}
function buildSidebar(pages) {
const sidebar = document.createElement('nav');
sidebar.className = 'db-sidebar';
@@ -1865,7 +1911,7 @@ function buildSidebar(pages) {
item.dataset.navId = page.id;
const iconSpan = document.createElement('span');
iconSpan.className = 'db-nav-icon';
iconSpan.textContent = page.icon || '📄';
setPageIcon(iconSpan, page, '📄');
item.appendChild(iconSpan);
item.appendChild(document.createTextNode(' ' + page.title));
item.addEventListener('click', () => selectPage(page.id));
@@ -1887,7 +1933,7 @@ function buildSidebar(pages) {
btn.className = 'db-nav-item';
const iconSpan = document.createElement('span');
iconSpan.className = 'db-nav-icon';
iconSpan.textContent = page.icon || '⚙';
setPageIcon(iconSpan, page, '⚙');
btn.appendChild(iconSpan);
btn.appendChild(document.createTextNode(' ' + page.title));
btn.addEventListener('click', () => {
@@ -1949,14 +1995,18 @@ async function selectPage(pageId) {
return;
}
// Full-bleed pages (e.g. chat) fill the main column and carry their own chrome
const fullBleed = page.layout === 'full-bleed';
main.classList.toggle('db-fill', fullBleed);
// Set page header (controlled by dashboard config protocol)
const showPageHeader = main.dataset.showPageHeader !== 'false';
const showPageHeader = main.dataset.showPageHeader !== 'false' && !fullBleed;
const toolbarSlotId = `page:${pageId}:toolbar`;
const headerHtml = showPageHeader ? `
<div class="db-page-header">
<div style="display:flex;align-items:flex-start;justify-content:space-between;gap:12px;flex-wrap:wrap">
<div>
<h1 class="db-page-title">${page.icon || ''} ${page.title}</h1>
<h1 class="db-page-title">${page.icon_svg ? '<span class="db-page-icon"></span>' : (page.icon || '')} ${page.title}</h1>
${page.description ? `<p class="db-page-desc">${page.description}</p>` : ''}
</div>
<div id="db-page-toolbar-slot" class="db-slot-host" data-slot="${toolbarSlotId}"></div>
@@ -1964,6 +2014,8 @@ async function selectPage(pageId) {
</div>
` : '';
main.innerHTML = `${headerHtml}<div data-page="${pageId}" id="db-page-content"></div>`;
const pageIcon = main.querySelector('.db-page-icon');
if (pageIcon) setPageIcon(pageIcon, page, '');
if (showPageHeader) {
const toolbarHost = document.getElementById('db-page-toolbar-slot');
if (toolbarHost && SLOT_REGISTRY[toolbarSlotId]) {
@@ -164,6 +164,20 @@ function renderProgressBar(percentage) {
`;
}
function escapeAttr(value) {
return String(value).replace(/&/g, '&amp;').replace(/"/g, '&quot;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
}
// Navigate to Chat and load the job's linked session (same event the
// session-search palette and run recorder use).
function openJobSession(sessionId) {
if (!sessionId) return;
const nav = (typeof window.aiui?.selectPage === 'function') ? window.aiui.selectPage('chat') : null;
Promise.resolve(nav).finally(() => {
window.dispatchEvent(new CustomEvent('aiui:session-select', { detail: { sessionId } }));
});
}
function renderJobRow(job) {
const status = job.status || 'queued';
const isTerminal = ['succeeded', 'failed', 'cancelled'].includes(status);
@@ -199,6 +213,7 @@ function renderJobRow(job) {
` : `
${_jobsCfg.backend !== 'praisonai' ? `<button class="aiui-btn aiui-btn-sm aiui-btn-delete" onclick="window.aiuiDeleteJob('${job.id}')" title="Delete">🗑️</button>` : ''}
`}
${job.session_id ? `<button class="aiui-btn aiui-btn-sm aiui-btn-open-session" data-session-id="${escapeAttr(job.session_id)}" onclick="window.aiuiOpenJobSession(this.dataset.sessionId)" title="Open session in Chat">💬</button>` : ''}
<button class="aiui-btn aiui-btn-sm aiui-btn-view" onclick="window.aiuiViewJob('${job.id}')" title="View Details">
👁️
</button>
@@ -599,6 +614,7 @@ function checkForJobsPage(root) {
window.aiuiCancelJob = cancelJob;
window.aiuiDeleteJob = deleteJob;
window.aiuiViewJob = viewJob;
window.aiuiOpenJobSession = openJobSession;
export default {
name: 'jobs',
@@ -1,65 +1,154 @@
/**
* Approvals View — execution approval queue
* API: /api/approvals
*
* Pending items render as an Approval Card (human-in-the-loop): risk, question,
* optional preset options / custom answer / confidence copy — surfaced only when
* the API supplies them, so the card stays a plain approve/deny prompt otherwise.
*/
import { isSkillApproval } from '/plugins/views/_helpers.js';
import { isSkillApproval, esc, emptyState } from '/plugins/views/_helpers.js';
const RISK = {
low: { icon: '✅', color: '#22c55e' },
medium: { icon: '⚠️', color: '#eab308' },
high: { icon: '🟠', color: '#f97316' },
critical: { icon: '🔴', color: '#ef4444' },
};
/**
* Normalise a raw /api/approvals item into an ApprovalPromptModel-shaped object.
* Keeps the field-mapping in one place; only carries what the API actually sends.
*/
function toApprovalModel(a) {
const id = a.id || a.approval_id || '';
const risk = String(a.risk_level || '').toLowerCase();
const options = Array.isArray(a.options)
? a.options.map(o => (typeof o === 'string' ? { value: o, label: o } : { value: o.value ?? o.id ?? o.label, label: o.label ?? String(o.value ?? o.id) }))
: [];
const confidence = typeof a.confidence === 'number' ? Math.round(a.confidence * (a.confidence <= 1 ? 100 : 1)) : null;
return {
id,
title: a.tool_name || a.action || 'Unknown',
question: a.reason || a.description || a.question || '',
agent: a.agent_name || '',
risk,
riskMeta: RISK[risk] || { icon: '❓', color: 'var(--db-text-dim)' },
createdAt: a.created_at ? new Date(a.created_at * 1000).toLocaleString() : '',
options,
allowCustom: a.allow_custom_answer === true || (options.length === 0 && a.custom_answer !== false && !!a.question),
confidence,
isSkill: isSkillApproval(a),
};
}
function approvalCard(m) {
const optionsHtml = m.options.length
? `<div class="db-approval-options" role="radiogroup" aria-label="Response options" style="display:flex;flex-wrap:wrap;gap:6px;margin-top:10px">
${m.options.map((o, i) => `
<label style="display:inline-flex;align-items:center;gap:6px;font-size:12px;padding:4px 10px;border:1px solid var(--db-border);border-radius:6px;cursor:pointer">
<input type="radio" name="opt-${esc(m.id)}" value="${esc(o.value)}" ${i === 0 ? 'checked' : ''}> ${esc(o.label)}
</label>`).join('')}
</div>`
: '';
const customHtml = m.allowCustom
? `<input class="approv-custom" data-id="${esc(m.id)}" type="text" placeholder="Custom answer (optional)"
aria-label="Custom answer for ${esc(m.title)}"
style="margin-top:10px;width:100%;padding:6px 10px;font-size:12px;background:var(--db-bg);color:var(--db-text);border:1px solid var(--db-border);border-radius:6px">`
: '';
const confidenceHtml = m.confidence != null
? `<div style="font-size:11px;color:var(--db-text-dim);margin-top:6px">Model confidence: ${m.confidence}%</div>`
: '';
const skillHtml = m.isSkill
? `<a href="/skills?tab=pending&approval_id=${encodeURIComponent(m.id)}" data-skill-studio="1" style="display:inline-block;margin-top:8px;font-size:11px;color:var(--db-accent);text-decoration:none">Skill write? Open Skills Studio →</a>`
: '';
return `
<div class="db-card db-approval-card" data-id="${esc(m.id)}" role="group"
aria-label="Approval request: ${esc(m.title)}"
style="margin-bottom:12px;padding:16px 20px;border-left:3px solid ${m.riskMeta.color}">
<div style="display:flex;align-items:flex-start;justify-content:space-between;gap:12px">
<div style="flex:1;min-width:0">
<div style="font-weight:600;font-size:14px">${m.riskMeta.icon} ${esc(m.title)}</div>
${m.question ? `<div style="font-size:13px;color:var(--db-text);margin-top:6px">${esc(m.question)}</div>` : ''}
<div style="font-size:12px;color:var(--db-text-dim);margin-top:4px">Agent: ${esc(m.agent) || '—'}${m.risk ? ` · risk: ${esc(m.risk)}` : ''}</div>
${m.createdAt ? `<div style="font-size:11px;color:var(--db-text-dim)">${esc(m.createdAt)}</div>` : ''}
${optionsHtml}
${customHtml}
${confidenceHtml}
${skillHtml}
</div>
<div style="display:flex;gap:8px;flex-shrink:0">
<button class="approv-yes" data-id="${esc(m.id)}" aria-label="Approve ${esc(m.title)}"
style="padding:6px 16px;background:rgba(34,197,94,0.15);color:#22c55e;border:1px solid rgba(34,197,94,0.3);border-radius:6px;cursor:pointer;font-size:12px">✓ Approve</button>
<button class="approv-no" data-id="${esc(m.id)}" aria-label="Deny ${esc(m.title)}"
style="padding:6px 16px;background:rgba(239,68,68,0.15);color:#ef4444;border:1px solid rgba(239,68,68,0.3);border-radius:6px;cursor:pointer;font-size:12px">✗ Deny</button>
</div>
</div>
</div>`;
}
export async function render(container) {
container.innerHTML = '<div class="db-loading"><div class="db-spinner"></div></div>';
let pending = [], history = [], policies = {};
try { const r = await fetch('/api/approvals/pending'); pending = (await r.json()).approvals || []; } catch(e) {}
try { const r = await fetch('/api/approvals/history'); history = (await r.json()).approvals || (await r.json()).history || []; } catch(e) {}
try { const r = await fetch('/api/approvals/history'); const d = await r.json(); history = d.approvals || d.history || []; } catch(e) {}
try { const r = await fetch('/api/approvals/policies'); policies = await r.json(); } catch(e) {}
const riskIcons = { low: '✅', medium: '⚠️', high: '🟠', critical: '🔴' };
const models = pending.map(toApprovalModel);
container.innerHTML = `
<div class="db-columns" style="grid-template-columns:repeat(3,1fr);margin-bottom:24px">
<div class="db-card"><div class="db-card-title">Pending</div><div class="db-card-value" style="color:#eab308">${pending.length}</div></div>
<div class="db-card"><div class="db-card-title">Risk Threshold</div><div class="db-card-value" style="font-size:18px">${policies.risk_threshold || 'high'}</div></div>
<div class="db-card"><div class="db-card-title">Pending</div><div class="db-card-value" style="color:#eab308">${models.length}</div></div>
<div class="db-card"><div class="db-card-title">Risk Threshold</div><div class="db-card-value" style="font-size:18px">${esc(policies.risk_threshold || 'high')}</div></div>
<div class="db-card"><div class="db-card-title">Auto-Approve Tools</div><div class="db-card-value" style="font-size:18px">${(policies.auto_approve_tools || []).length}</div></div>
</div>
<h3 style="margin:0 0 12px;font-size:15px;font-weight:600">Pending Approvals</h3>
<div id="approv-pending" style="margin-bottom:28px">
${pending.length === 0 ? '<div class="db-viewer"><pre>No pending approvals</pre></div>' : ''}
${models.length === 0 ? emptyState({ icon: '✅', title: 'No pending approvals', body: 'All clear — nothing needs your decision.' }) : models.map(approvalCard).join('')}
</div>
<h3 style="margin:0 0 12px;font-size:15px;font-weight:600">Recent History</h3>
<div id="approv-history"></div>
`;
const pendingEl = container.querySelector('#approv-pending');
pending.forEach(a => {
const card = document.createElement('div');
card.className = 'db-card';
card.style.cssText = 'margin-bottom:10px;padding:16px 20px;display:flex;align-items:center;justify-content:space-between';
const isSkill = isSkillApproval(a);
card.innerHTML = `
<div style="flex:1">
<div style="font-weight:500;font-size:14px">${riskIcons[a.risk_level] || '❓'} ${a.tool_name || a.action || 'Unknown'}</div>
<div style="font-size:12px;color:var(--db-text-dim);margin-top:4px">Agent: ${a.agent_name || '—'} · ${a.reason || ''}</div>
<div style="font-size:11px;color:var(--db-text-dim)">${a.created_at ? new Date(a.created_at * 1000).toLocaleString() : ''}</div>
${isSkill ? `<a href="/skills?tab=pending&approval_id=${encodeURIComponent(a.id || a.approval_id || '')}" data-skill-studio="1" style="font-size:11px;color:var(--db-accent);text-decoration:none">Skill write? Open Skills Studio →</a>` : ''}
</div>
<div style="display:flex;gap:8px">
<button class="approv-yes" data-id="${a.id || a.approval_id}" style="padding:6px 16px;background:rgba(34,197,94,0.15);color:#22c55e;border:1px solid rgba(34,197,94,0.3);border-radius:6px;cursor:pointer;font-size:12px">✓ Approve</button>
<button class="approv-no" data-id="${a.id || a.approval_id}" style="padding:6px 16px;background:rgba(239,68,68,0.15);color:#ef4444;border:1px solid rgba(239,68,68,0.3);border-radius:6px;cursor:pointer;font-size:12px">✗ Deny</button>
</div>
`;
pendingEl.appendChild(card);
});
function collectExtra(id) {
const card = container.querySelector(`.db-approval-card[data-id="${CSS.escape(id)}"]`);
const body = {};
if (!card) return body;
const opt = card.querySelector(`input[name="opt-${CSS.escape(id)}"]:checked`);
if (opt) body.option = opt.value;
const custom = card.querySelector('.approv-custom');
if (custom && custom.value.trim()) body.custom_answer = custom.value.trim();
// Also surface the human's choice as `reason` — the only extra field the
// approve/deny endpoints persist today — so the answer isn't dropped while
// `option`/`custom_answer` stay for a future richer backend.
const reason = body.custom_answer || body.option;
if (reason != null && reason !== '') body.reason = String(reason);
return body;
}
container.querySelectorAll('.approv-yes').forEach(b => b.addEventListener('click', async () => { try { await fetch(`/api/approvals/${b.dataset.id}/approve`, {method:'POST'}); render(container); } catch(e){} }));
container.querySelectorAll('.approv-no').forEach(b => b.addEventListener('click', async () => { try { await fetch(`/api/approvals/${b.dataset.id}/deny`, {method:'POST'}); render(container); } catch(e){} }));
async function decide(id, action) {
try {
await fetch(`/api/approvals/${encodeURIComponent(id)}/${action}`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(collectExtra(id)),
});
render(container);
} catch(e) {}
}
container.querySelectorAll('.approv-yes').forEach(b => b.addEventListener('click', () => decide(b.dataset.id, 'approve')));
container.querySelectorAll('.approv-no').forEach(b => b.addEventListener('click', () => decide(b.dataset.id, 'deny')));
const historyEl = container.querySelector('#approv-history');
(Array.isArray(history) ? history : []).slice(0, 20).forEach(h => {
const div = document.createElement('div');
div.style.cssText = 'padding:8px 0;border-bottom:1px solid var(--db-border);font-size:13px;display:flex;justify-content:space-between';
const approved = h.status === 'approved' || h.approved;
div.innerHTML = `<span>${h.tool_name || h.action || '?'} — ${h.agent_name || ''}</span><span style="color:${approved ? '#22c55e' : '#ef4444'}">${approved ? '✓ approved' : '✗ denied'}</span>`;
div.innerHTML = `<span>${esc(h.tool_name || h.action || '?')} — ${esc(h.agent_name || '')}</span><span style="color:${approved ? '#22c55e' : '#ef4444'}">${approved ? '✓ approved' : '✗ denied'}</span>`;
historyEl.appendChild(div);
});
if (!history.length) historyEl.innerHTML = '<div style="font-size:13px;color:var(--db-text-dim)">No history</div>';
@@ -21,6 +21,7 @@ function injectStyles() {
overflow: hidden;
}
.db-clean .chat-canvas-root { height: 100vh; }
.db-fill .chat-canvas-root { flex: 1; min-height: 0; height: auto; }
.chat-canvas-chat {
flex: 1 1 60%;
min-width: 0;
@@ -19,6 +19,7 @@ let isStreaming = false;
let streamingSessionId = null; // tracks which session is actively streaming
let currentRunId = null;
let agents = [];
let allSessions = []; // last fetched session list, filtered client-side by the rail search
let messageQueue = [];
let focusMode = false;
let sessionSelectHandler = null;
@@ -221,6 +222,11 @@ function injectStyles() {
style.textContent = `
.chat-root { display:flex; height:calc(100vh - 140px); gap:0; border-radius:12px; overflow:hidden; border:1px solid var(--db-border); background:var(--db-card-bg); }
.db-clean .chat-root { height:100%; border-radius:0; border:none; }
.db-fill .chat-root { flex:1; min-height:0; height:auto; }
@media (max-width:900px) {
.db-fill .chat-root { flex-direction:column; }
.db-fill .chat-sidebar-panel { width:100%; min-width:0; max-height:40vh; border-right:none; border-bottom:1px solid var(--db-border); }
}
.chat-root.focus-mode .chat-sidebar-panel { display:none; }
.chat-root.focus-mode .chat-main-panel { border-left:none; }
@@ -230,12 +236,17 @@ function injectStyles() {
.chat-sidebar-agent { padding:10px 16px; border-bottom:1px solid var(--db-border); }
.chat-sidebar-agent label { font-size:11px; color:var(--db-text-dim); display:block; margin-bottom:4px; text-transform:uppercase; font-weight:600; letter-spacing:.5px; }
.chat-sidebar-agent select { width:100%; padding:6px 10px; background:var(--db-card-bg); border:1px solid var(--db-border); border-radius:6px; color:var(--db-text); font-size:13px; }
.chat-sess-search { padding:8px 16px 0; }
.chat-sess-search input { width:100%; box-sizing:border-box; padding:6px 10px; background:var(--db-card-bg); border:1px solid var(--db-border); border-radius:6px; color:var(--db-text); font-size:13px; }
.chat-sess-list { flex:1; overflow-y:auto; padding:8px; }
.chat-sess-item { padding:10px 12px; border-radius:8px; cursor:pointer; margin-bottom:4px; font-size:13px; transition:background .15s; display:flex; flex-direction:column; gap:2px; }
.chat-sess-item:hover { background:rgba(var(--db-accent-rgb,100,100,255),.08); }
.chat-sess-item.active { background:rgba(var(--db-accent-rgb,100,100,255),.15); }
.chat-sess-item .sess-title { font-weight:500; white-space:nowrap; overflow:hidden; text-overflow:ellipsis; }
.chat-sess-item .sess-meta { font-size:11px; color:var(--db-text-dim); }
.chat-sess-item { position:relative; }
.chat-sess-item .sess-rename { position:absolute; top:6px; right:6px; display:none; background:none; border:none; color:var(--db-text-dim); cursor:pointer; font-size:12px; padding:2px 4px; }
.chat-sess-item:hover .sess-rename, .chat-sess-item .sess-rename:focus { display:block; }
.chat-main-panel { flex:1; display:flex; flex-direction:column; min-width:0; }
.chat-top-bar { padding:10px 16px; border-bottom:1px solid var(--db-border); display:flex; align-items:center; justify-content:space-between; gap:10px; }
@@ -812,6 +823,9 @@ export async function render(container) {
<label>Agent</label>
<select id="chat-agent-selector"><option value="">Auto (default)</option></select>
</div>
<div class="chat-sess-search">
<input type="search" id="chat-session-search" placeholder="Search sessions…" aria-label="Search sessions" autocomplete="off">
</div>
<div class="chat-sess-list" id="chat-sessions-list"></div>
</div>
<div class="chat-main-panel">
@@ -892,6 +906,8 @@ export async function render(container) {
const agentSelector = document.getElementById('chat-agent-selector');
const attachBtn = document.getElementById('chat-attach-btn');
const fileInput = document.getElementById('chat-file-input');
const sessionSearch = document.getElementById('chat-session-search');
if (sessionSearch) sessionSearch.addEventListener('input', renderSessionList);
// Guard: if DOM elements aren't present yet (race condition), bail out
if (!input || !sendBtn) {
console.warn('[chat] DOM elements not ready — skipping event binding');
@@ -1039,47 +1055,92 @@ async function loadSessions() {
resp = await fetch('/sessions');
}
const data = await resp.json();
const sessions = data.sessions || data || [];
const list = document.getElementById('chat-sessions-list');
if (!list) return;
list.innerHTML = '';
if (sessions.length === 0) {
list.innerHTML = '<div style="padding:16px;text-align:center;color:var(--db-text-dim);font-size:12px">No sessions yet</div>';
return;
}
sessions.slice(0, 30).forEach((s) => {
const el = document.createElement('div');
const id = s.id || s.session_id || '';
el.className = 'chat-sess-item' + (id === currentSessionId ? ' active' : '');
// Show platform icon for channel sessions (e.g. Slack with SVG)
const platformName = s.platform || '';
const platSvg = _getPlatformSVG(platformName);
let title;
let titlePrefix = '';
if (platformName) {
const displayName = platformName.charAt(0).toUpperCase() + platformName.slice(1);
if (platSvg) {
titlePrefix = '<span class="sess-platform-icon ' + _getPlatformClass(platformName) + '">' + platSvg + '</span>';
}
title = displayName;
} else {
title = s.name || s.title || s.label || id.substring(0, 12) + '…';
}
const agent = s.agent_id || s.agent || '';
const time = timeAgo(s.created_at || s.updated_at);
el.innerHTML = `
<div class="sess-title">${titlePrefix}${escapeHtml(title)}</div>
<div class="sess-meta">${agent ? '🤖 ' + escapeHtml(agent) + ' · ' : ''}${time}</div>
`;
el.title = id;
el.addEventListener('click', () => loadSession(id));
list.appendChild(el);
});
const fetched = data.sessions || data || [];
allSessions = Array.isArray(fetched) ? fetched : [];
renderSessionList();
} catch (e) {
console.warn('[Chat] Failed to load sessions:', e);
}
}
function _sessionMatches(s, q) {
const fields = [s.id, s.session_id, s.name, s.title, s.label, s.agent_id, s.agent, s.platform];
return fields.some((v) => typeof v === 'string' && v.toLowerCase().includes(q));
}
function renderSessionList() {
const list = document.getElementById('chat-sessions-list');
if (!list) return;
const input = document.getElementById('chat-session-search');
const q = (input && input.value || '').trim().toLowerCase();
const sessions = q ? allSessions.filter((s) => _sessionMatches(s, q)) : allSessions;
list.innerHTML = '';
if (sessions.length === 0) {
const msg = q ? 'No matching sessions' : 'No sessions yet';
list.innerHTML = '<div style="padding:16px;text-align:center;color:var(--db-text-dim);font-size:12px">' + msg + '</div>';
return;
}
sessions.slice(0, 30).forEach((s) => {
const el = document.createElement('div');
const id = s.id || s.session_id || '';
el.className = 'chat-sess-item' + (id === currentSessionId ? ' active' : '');
// Show platform icon for channel sessions (e.g. Slack with SVG)
const platformName = s.platform || '';
const platSvg = _getPlatformSVG(platformName);
let title;
let titlePrefix = '';
if (platformName) {
const displayName = platformName.charAt(0).toUpperCase() + platformName.slice(1);
if (platSvg) {
titlePrefix = '<span class="sess-platform-icon ' + _getPlatformClass(platformName) + '">' + platSvg + '</span>';
}
title = displayName;
} else {
title = s.name || s.title || s.label || id.substring(0, 12) + '…';
}
const agent = s.agent_id || s.agent || '';
const time = timeAgo(s.created_at || s.updated_at);
el.innerHTML = `
<div class="sess-title">${titlePrefix}${escapeHtml(title)}</div>
<div class="sess-meta">${agent ? '🤖 ' + escapeHtml(agent) + ' · ' : ''}${time}</div>
`;
el.title = id;
el.addEventListener('click', () => loadSession(id));
if (!platformName && id) {
const btn = document.createElement('button');
btn.className = 'sess-rename';
btn.title = 'Rename session';
btn.setAttribute('aria-label', 'Rename session');
btn.textContent = '✎';
btn.addEventListener('click', (e) => { e.stopPropagation(); renameSession(id, title); });
el.appendChild(btn);
}
list.appendChild(el);
});
}
async function renameSession(sessionId, currentTitle) {
const next = (window.prompt('Rename session', currentTitle || '') || '').trim();
if (!next || next === currentTitle) return;
try {
const resp = await fetch('/sessions/' + encodeURIComponent(sessionId), {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ title: next }),
});
if (!resp.ok) throw new Error('HTTP ' + resp.status);
const s = allSessions.find((x) => (x.id || x.session_id) === sessionId);
if (s) { s.title = next; delete s.name; }
renderSessionList();
if (sessionId === currentSessionId) {
const header = document.getElementById('chat-header-title');
if (header) header.textContent = next;
}
} catch (e) {
console.warn('[Chat] Failed to rename session:', e);
}
}
async function loadSession(sessionId) {
currentSessionId = sessionId;
refreshStreamingUI();
@@ -1131,7 +1192,8 @@ async function loadSession(sessionId) {
title.textContent = (sessionIcon || '📨') + ' ' + displayName;
}
} else if (title) {
title.textContent = 'Session ' + sessionId.substring(0, 8);
const cached = allSessions.find((x) => (x.id || x.session_id) === sessionId);
title.textContent = (cached && (cached.name || cached.title)) || ('Session ' + sessionId.substring(0, 8));
}
} catch (e) {
console.warn('[Chat] Failed to load session:', e);
@@ -116,7 +116,7 @@ function openConnectForm(host, container) {
<h3 style="margin:0 0 16px;font-size:14px;font-weight:600">Connect MCP Server</h3>
<div class="db-form-group">
<label class="db-form-label">Name</label>
<input id="mcp-f-name" class="db-form-input" placeholder="my-server">
<input id="mcp-f-name" class="db-form-input" value="everything-demo" placeholder="my-server">
</div>
<div class="db-form-group">
<label class="db-form-label">Transport</label>
@@ -128,11 +128,11 @@ function openConnectForm(host, container) {
<div id="mcp-f-stdio">
<div class="db-form-group">
<label class="db-form-label">Command</label>
<input id="mcp-f-command" class="db-form-input" placeholder="npx">
<input id="mcp-f-command" class="db-form-input" value="npx" placeholder="npx" required>
</div>
<div class="db-form-group">
<label class="db-form-label">Arguments (space-separated)</label>
<input id="mcp-f-args" class="db-form-input" placeholder="-y @modelcontextprotocol/server-filesystem /tmp">
<input id="mcp-f-args" class="db-form-input" value="-y @modelcontextprotocol/server-everything" placeholder="-y @modelcontextprotocol/server-everything">
</div>
</div>
<div id="mcp-f-url" style="display:none">
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
+524
View File
@@ -0,0 +1,524 @@
"""Browser smoke + axe accessibility check for the Replay view (dev-only, never shipped).
Opt-in: RUN_BROWSER_TESTS=1 AXE_CORE_JS=/path/to/axe.min.js pytest tests/browser -o addopts=""
* Needs the ``playwright`` Python package and a Chrome install (``channel="chrome"``)
or a Playwright Chromium (``python -m playwright install chromium``).
* axe-core (MPL-2.0) is loaded from ``AXE_CORE_JS`` (e.g. ``npm i axe-core`` in
any scratch dir); the axe checks are skipped when it is not provided.
"""
from __future__ import annotations
import json
import os
import shutil
import socket
import threading
import time
from pathlib import Path
import pytest
if os.environ.get("RUN_BROWSER_TESTS") != "1":
pytest.skip("browser tests are opt-in (RUN_BROWSER_TESTS=1)", allow_module_level=True)
sync_api = pytest.importorskip("playwright.sync_api")
ROOT = Path(__file__).resolve().parents[2]
FIXTURES = ROOT / "tests" / "fixtures" / "replay"
AXE = os.environ.get("AXE_CORE_JS")
WCAG_TAGS = ["wcag2a", "wcag2aa", "wcag21a", "wcag21aa", "wcag22aa"]
def _events(name: str) -> list[dict]:
return [json.loads(line) for line in (FIXTURES / f"{name}.jsonl").read_text(encoding="utf-8").splitlines() if line.strip()]
@pytest.fixture(scope="module")
def browser():
with sync_api.sync_playwright() as p:
try:
b = p.chromium.launch(channel="chrome")
except Exception:
try:
b = p.chromium.launch()
except Exception as exc: # no browser available
pytest.skip(f"no Chromium/Chrome available: {exc}")
yield b
b.close()
def _page(browser, scheme: str = "light", reduced: bool = False):
page = browser.new_page(
viewport={"width": 1366, "height": 1000},
color_scheme=scheme,
reduced_motion="reduce" if reduced else "no-preference",
)
errors: list[str] = []
page.on("pageerror", lambda e: errors.append(str(e)))
page.on("console", lambda m: errors.append(m.text) if m.type == "error" else None)
return page, errors
def _axe(page, selector: str = ".rp-root") -> list[dict]:
if not AXE:
return []
page.add_script_tag(path=AXE)
result = page.evaluate(
"""async ([sel, tags]) => {
const r = await axe.run(document.querySelector(sel), {runOnly: {type: 'tag', values: tags}});
return r.violations.map(v => ({id: v.id, impact: v.impact, help: v.help, nodes: v.nodes.slice(0, 3).map(n => n.target.join(' '))}));
}""",
[selector, WCAG_TAGS],
)
return result
def _standalone(tmp_path: Path, name: str) -> str:
from praisonaiui.features.replay import standalone_html
out = tmp_path / f"{name}.html"
out.write_text(standalone_html(_events(name), title=name), encoding="utf-8")
return out.as_uri()
def _current_index(page) -> int:
return int(page.get_attribute('.rp-step[aria-current="step"]', "data-i"))
@pytest.mark.parametrize("name", ["team_handoff", "flow_parallel_loop", "legacy_v10", "team_outside"])
def test_standalone_steps_with_arrow_keys(browser, tmp_path, name):
page, errors = _page(browser)
page.goto(_standalone(tmp_path, name))
page.wait_for_selector(".rp-step")
total = page.locator(".rp-step").count()
assert total > 5
assert _current_index(page) == total - 1 # opens at the end (whole run visible)
page.locator(".rp-step").nth(total - 1).focus()
page.keyboard.press("Home")
assert _current_index(page) == 0
for _ in range(3):
page.keyboard.press("ArrowRight")
assert _current_index(page) == 3
page.keyboard.press("ArrowLeft")
assert _current_index(page) == 2
# focus follows the cursor in the step list, the scrubber and the drawer agree
assert page.evaluate("document.activeElement.getAttribute('data-i')") == "2"
assert page.input_value("#rp-scrub-range") == "2"
step_text = page.locator('.rp-step[aria-current="step"] .rp-step-text').inner_text()
assert page.locator(".rp-drawer h3").inner_text() == step_text
# replay: later blocks are drawn as "not reached yet"
assert page.locator(".rp-block.rp-future").count() > 0
page.keyboard.press("End")
assert _current_index(page) == total - 1
assert page.locator(".rp-block.rp-future").count() == 0
assert not errors, errors
def test_standalone_drawer_tabs_map_and_axe(browser, tmp_path):
page, errors = _page(browser)
page.goto(_standalone(tmp_path, "team_handoff"))
page.wait_for_selector(".rp-step")
# select the hand-off step and read the Why tab
page.locator(".rp-step", has_text="handed off to Writer").click()
page.locator('[role="tab"]', has_text="Why").click()
why = page.locator("#rp-tabpanel").inner_text()
assert "Hand-off reason" in why and "Facts gathered" in why
# keyboard on the tablist moves between tabs
page.keyboard.press("ArrowRight")
assert page.get_attribute('[role="tab"][aria-selected="true"]', "id") == "rp-tab-raw"
assert '"event_type": "agent_handoff"' in page.locator("#rp-tabpanel pre").inner_text()
# manager's task snapshot shows up for the delegated task
page.locator(".rp-step", has_text="Task 'research' went to Researcher").click()
page.locator('[role="tab"]', has_text="Why").click()
assert "Manager's task snapshot" in page.locator("#rp-tabpanel").inner_text()
# approval evidence
page.locator(".rp-step", has_text="You approved write_file").click()
assert "Decided by" in page.locator("#rp-tabpanel").inner_text()
violations = _axe(page)
assert violations == [], json.dumps(violations, indent=2)
page.click("[data-key='overlay']")
page.click("[data-key='view:map']")
page.wait_for_selector(".rp-node")
assert page.locator(".rp-node").count() == 10
labels = page.eval_on_selector_all(".rp-edge text", "els => els.map(e => e.textContent)")
assert "hands off" in labels and any(t.startswith("delegates") for t in labels)
page.locator(".rp-node").first.focus()
page.keyboard.press("ArrowDown")
page.keyboard.press("Enter")
assert page.locator(".rp-drawer h3").inner_text()
violations = _axe(page)
assert violations == [], json.dumps(violations, indent=2)
page.select_option("[data-key='theme']", "dark")
assert page.get_attribute(".rp-root", "data-theme") == "dark"
violations = _axe(page)
assert violations == [], json.dumps(violations, indent=2)
page.click("[data-key='view:timeline']")
violations = _axe(page)
assert violations == [], json.dumps(violations, indent=2)
assert not errors, errors
def test_reduced_motion_has_no_animation(browser, tmp_path):
page, _ = _page(browser, reduced=True)
page.goto(_standalone(tmp_path, "flow_parallel_loop"))
page.wait_for_selector(".rp-step")
names = page.evaluate(
"[...document.querySelectorAll('.rp-root *')].map(e => getComputedStyle(e).animationName).filter(n => n && n !== 'none')"
)
assert names == []
def _boxes(page, selector: str) -> list[dict]:
return page.eval_on_selector_all(
selector,
"els => els.map(e => { const r = e.getBoundingClientRect(); return {x: r.x, y: r.y, w: r.width, h: r.height, id: e.closest('[data-block]')?.getAttribute('data-block')}; })",
)
@pytest.mark.parametrize("name", ["team_outside", "team_handoff", "flow_parallel_loop"])
def test_timeline_blocks_never_overlap(browser, tmp_path, name):
"""An LLM span encloses its tool calls in real runs; tools go on their own row."""
page, errors = _page(browser)
page.goto(_standalone(tmp_path, name))
page.wait_for_selector(".rp-block")
boxes = _boxes(page, ".rp-block .rp-body")
for i, a in enumerate(boxes):
for b in boxes[i + 1:]:
apart = a["x"] + a["w"] <= b["x"] + 0.5 or b["x"] + b["w"] <= a["x"] + 0.5 or a["y"] + a["h"] <= b["y"] + 0.5 or b["y"] + b["h"] <= a["y"] + 0.5
assert apart, f"{a['id']} overlaps {b['id']}"
# a label that does not fit is ellipsized, and the full text is in the title and name
llm = page.locator(".rp-block.rp-k-llm").first
name_attr = llm.get_attribute("aria-label")
title = llm.locator("title").text_content()
assert name_attr.startswith(title)
llm.focus()
assert page.locator(".rp-tip").get_attribute("visibility") == "visible"
assert page.locator(".rp-tip text").text_content() == title
assert not errors, errors
def test_why_tab_by_role_and_keyboard(browser, tmp_path):
page, errors = _page(browser)
page.goto(_standalone(tmp_path, "team_outside"))
page.wait_for_selector(".rp-step")
page.locator(".rp-step", has_text="Triage handed off to Billing").click()
page.get_by_role("tab", name="Why").click()
assert page.get_by_role("tab", name="Why").get_attribute("aria-selected") == "true"
why = page.get_by_role("tabpanel").inner_text()
assert "Hand-off reason" in why and "Billing question" in why and "Context passed" in why
# the task input
page.locator(".rp-step", has_text="Task 'research' went to Researcher").click()
assert "Task description" in page.get_by_role("tabpanel").inner_text()
assert "Get one fact about electric vehicles" in page.get_by_role("tabpanel").inner_text()
# keyboard: focus the selected tab, arrow keys move between tabs
page.get_by_role("tab", name="Why").focus()
page.keyboard.press("ArrowLeft")
assert page.get_by_role("tab", name="What happened").get_attribute("aria-selected") == "true"
page.keyboard.press("ArrowRight")
assert page.get_by_role("tab", name="Why").get_attribute("aria-selected") == "true"
assert page.evaluate("document.activeElement.getAttribute('role')") == "tab"
# route chosen and approval decision
page.goto(_standalone(tmp_path, "flow_parallel_loop"))
page.wait_for_selector(".rp-step")
page.locator(".rp-step", has_text="chose route 'publish'").click()
page.get_by_role("tab", name="Why").click()
assert "Route chosen" in page.get_by_role("tabpanel").inner_text()
page.goto(_standalone(tmp_path, "team_handoff"))
page.wait_for_selector(".rp-step")
page.locator(".rp-step", has_text="You approved write_file").click()
page.get_by_role("tab", name="Why").click()
panel = page.get_by_role("tabpanel").inner_text()
assert "Decision" in panel and "Approved with edited arguments" in panel
assert not errors, errors
def test_theme_follows_system_with_override(browser, tmp_path):
url = _standalone(tmp_path, "team_handoff")
bg = "getComputedStyle(document.querySelector('.rp-root')).backgroundColor"
page, _ = _page(browser, scheme="dark")
page.goto(url)
page.wait_for_selector(".rp-step")
assert page.get_attribute(".rp-root", "data-theme") == "system"
assert page.evaluate(bg) == "rgb(22, 23, 27)"
page.select_option("[data-key='theme']", "light")
assert page.evaluate(bg) == "rgb(255, 255, 255)"
page.close()
page, _ = _page(browser, scheme="light")
page.goto(url)
page.wait_for_selector(".rp-step")
assert page.evaluate(bg) == "rgb(255, 255, 255)"
def test_map_fits_on_open_and_groups_outside_agents(browser, tmp_path):
page, errors = _page(browser)
page.goto(_standalone(tmp_path, "team_outside"))
page.wait_for_selector(".rp-step")
page.get_by_role("button", name="Map").click()
page.wait_for_selector(".rp-node")
box = page.evaluate("(() => { const c = document.querySelector('.rp-canvas'); const r = c.getBoundingClientRect(); return {l: r.left, r: r.right, sw: c.scrollWidth, cw: c.clientWidth}; })()")
assert box["sw"] <= box["cw"] + 1, "map overflows on open"
for n in _boxes(page, ".rp-node .rp-card"):
assert box["l"] - 1 <= n["x"] and n["x"] + n["w"] <= box["r"] + 1
assert "Outside the team" in page.locator(".rp-group text").text_content()
outside = page.get_by_role("group", name="Outside the team")
assert outside.locator(".rp-node").count() == 3
labels = page.eval_on_selector_all(".rp-edge text", "els => els.map(e => e.textContent)")
assert "hands off" in labels
before = float(page.get_attribute(".rp-canvas svg", "width"))
page.get_by_role("button", name="Zoom in").click()
assert float(page.get_attribute(".rp-canvas svg", "width")) == pytest.approx(before * 1.5, abs=2)
page.get_by_role("button", name="Fit").click()
assert float(page.get_attribute(".rp-canvas svg", "width")) == pytest.approx(before, abs=2)
assert not errors, errors
@pytest.mark.parametrize("name", ["team_outside", "flow_parallel_loop"])
def test_phone_width_has_no_page_scroll(browser, tmp_path, name):
page, errors = _page(browser)
page.set_viewport_size({"width": 400, "height": 860})
page.goto(_standalone(tmp_path, name))
page.wait_for_selector(".rp-step")
assert page.evaluate("document.documentElement.scrollWidth") <= 400
r = page.evaluate("(() => { const b = document.querySelector('.rp-root').getBoundingClientRect(); return [b.left, b.right]; })()")
assert r[0] == pytest.approx(16, abs=1) and r[1] == pytest.approx(384, abs=1)
page.get_by_role("button", name="Map").click()
assert page.evaluate("document.documentElement.scrollWidth") <= 400
assert not errors, errors
@pytest.mark.parametrize("order", ["core-first", "view-first"])
def test_wrapper_style_export_boots(browser, tmp_path, order):
"""The wrapper inlines both files as module scripts into #replay-root (which holds
a fallback table) after window.__REPLAY_DATA__; the view must replace it."""
views = ROOT / "src" / "praisonaiui" / "templates" / "frontend" / "plugins" / "views"
core = (views / "replay-core.js").read_text(encoding="utf-8").replace("</", "<\\/")
view = (views / "replay.js").read_text(encoding="utf-8").replace("</", "<\\/")
scripts = [core, view] if order == "core-first" else [view, core]
data = json.dumps({"run_id": "team-outside-01", "events": _events("team_outside"), "graph": None}).replace("<", "\\u003c")
html = (
"<!doctype html><html lang=en><head><meta charset=utf-8><title>PraisonAI Replay: x</title></head><body>"
"<div id=replay-root><table><tr><td>fallback</td></tr></table></div>"
f"<script id=replay-data>window.__REPLAY_DATA__ = {data};</script>"
+ "".join(f"<script type=module>\n{js}\n</script>" for js in scripts)
+ "</body></html>"
)
out = tmp_path / f"export-{order}.html"
out.write_text(html, encoding="utf-8")
page, errors = _page(browser)
page.goto(out.as_uri())
page.wait_for_selector(".rp-step")
assert page.locator("#replay-root table").count() == 0
assert page.locator(".rp-title").inner_text() == "team-outside-01"
# graph:null -> taken from workflow_start.data.graph
page.get_by_role("button", name="Map").click()
assert page.locator(".rp-node").count() == 10
assert page.locator(".rp-ask").count() == 0
assert not errors, errors
# ── dashboard + live run + approval through the You lane ─────────────
_PROVIDER_KEYS = ("OPENAI_API_KEY", "ANTHROPIC_API_KEY", "GEMINI_API_KEY", "GOOGLE_API_KEY", "GROQ_API_KEY")
def _no_provider_keys(monkeypatch) -> None:
"""A real aiui server runs startup hooks; keep them from calling paid APIs."""
for key in _PROVIDER_KEYS:
monkeypatch.delenv(key, raising=False)
def _free_port() -> int:
with socket.socket() as s:
s.bind(("127.0.0.1", 0))
return s.getsockname()[1]
@pytest.fixture()
def live_server(tmp_path, monkeypatch):
import uvicorn
import praisonaiui.server as server
from praisonaiui.features import approvals
from praisonaiui.features.replay import configure_replay_app
traces = tmp_path / "traces"
traces.mkdir()
for name in ("team_handoff", "flow_parallel_loop"):
shutil.copy(FIXTURES / f"{name}.jsonl", traces / f"{name}.jsonl")
lines = (FIXTURES / "team_handoff.jsonl").read_text(encoding="utf-8").splitlines(keepends=True)
cut = next(i for i, line in enumerate(lines) if '"approval_decided"' in line)
live = traces / "live-team.jsonl"
live.write_text("".join(lines[:cut]), encoding="utf-8")
monkeypatch.setenv("PRAISONAI_TRACES_DIR", str(traces))
monkeypatch.setenv("AIUI_DATA_DIR", str(tmp_path / "aiui-data"))
monkeypatch.setenv("PRAISONAI_HOME", str(tmp_path / "praisonai-home"))
_no_provider_keys(monkeypatch)
store = approvals.SimpleApprovalManager()
monkeypatch.setattr(approvals, "_approval_manager", store)
server.reset_state()
configure_replay_app()
app = server.create_app()
port = _free_port()
srv = uvicorn.Server(uvicorn.Config(app, host="127.0.0.1", port=port, log_level="warning"))
t = threading.Thread(target=srv.run, daemon=True)
t.start()
for _ in range(100):
if srv.started:
break
time.sleep(0.05)
yield {"url": f"http://127.0.0.1:{port}", "store": store, "live": live, "lines": lines, "cut": cut}
srv.should_exit = True
t.join(timeout=5)
server.reset_state()
def test_dashboard_live_run_and_approval(browser, live_server):
pytest.importorskip("praisonaiagents.approval.protocols")
from praisonaiagents.approval.protocols import ApprovalRequest
from praisonaiui.features.approvals import _UIStoreBackend
store = live_server["store"]
backend = _UIStoreBackend(store, timeout=30)
box = {}
req = ApprovalRequest(tool_name="write_file", arguments={"path": "draft.md", "content": "hello"},
risk_level="high", agent_name="Writer", approval_id="appr-0001")
t = threading.Thread(target=lambda: box.setdefault("d", backend.request_approval_sync(req)), daemon=True)
t.start()
page, errors = _page(browser)
page.goto(f"{live_server['url']}/#/replay?run=live-team")
page.wait_for_selector(".rp-run")
assert page.evaluate("location.pathname + location.search") == "/replay?run=live-team"
assert page.locator(".rp-run[aria-current='true']").inner_text().startswith("Solar sail brief")
assert page.locator(".rp-badge.rp-b-live").count() >= 1
card = page.locator(".rp-ask")
card.wait_for()
assert "Writer asks to run write_file" in card.inner_text()
page.get_by_role("button", name="Edit arguments…").click()
ta = page.locator(".rp-ask textarea")
ta.wait_for()
assert json.loads(ta.input_value()) == {"path": "draft.md", "content": "hello"} # server-held args, not redacted
ta.fill("{not json")
assert ta.get_attribute("aria-invalid") == "true"
assert "Not valid JSON" in page.locator(".rp-ask .rp-err").inner_text()
ta.fill('{"path": "docs/draft.md", "content": "hello"}')
assert ta.get_attribute("aria-invalid") == "false"
violations = _axe(page)
assert violations == [], json.dumps(violations, indent=2)
page.get_by_role("button", name="Approve with these arguments").click()
t.join(timeout=10)
decision = box["d"]
assert decision.approved is True
assert decision.modified_args == {"path": "docs/draft.md", "content": "hello"}
page.locator(".rp-ask [role='status']").wait_for()
# the run continues: the rest of the file streams in over SSE
with open(live_server["live"], "a", encoding="utf-8") as fh:
fh.write("".join(live_server["lines"][live_server["cut"]:]))
page.wait_for_selector(".rp-step:has-text('You approved write_file')", timeout=10000)
page.wait_for_selector(".rp-step:has-text('Recording ended.')", timeout=10000)
assert page.locator(".rp-ask").count() == 0
page.wait_for_selector(".rp-bar .rp-b-completed", timeout=15000)
assert not [e for e in errors if "favicon" not in e], errors
def test_empty_state_explains_how_to_record(browser, tmp_path, monkeypatch):
import uvicorn
import praisonaiui.server as server
from praisonaiui.features.replay import configure_replay_app
monkeypatch.setenv("PRAISONAI_TRACES_DIR", str(tmp_path / "empty"))
monkeypatch.setenv("AIUI_DATA_DIR", str(tmp_path / "aiui-data"))
monkeypatch.setenv("PRAISONAI_HOME", str(tmp_path / "praisonai-home"))
_no_provider_keys(monkeypatch)
server.reset_state()
configure_replay_app()
app = server.create_app()
port = _free_port()
srv = uvicorn.Server(uvicorn.Config(app, host="127.0.0.1", port=port, log_level="warning"))
th = threading.Thread(target=srv.run, daemon=True)
th.start()
try:
for _ in range(100):
if srv.started:
break
time.sleep(0.05)
page, _ = _page(browser)
page.goto(f"http://127.0.0.1:{port}/")
page.wait_for_selector(".rp-empty")
text = page.locator(".rp-empty").inner_text()
assert "praisonai replay view agents.yaml" in text
assert "with record(live=True):" in text
violations = _axe(page)
assert violations == [], json.dumps(violations, indent=2)
finally:
srv.should_exit = True
th.join(timeout=5)
server.reset_state()
def test_embedded_run_box_draws_each_step_live_then_plays_back(browser, tmp_path, monkeypatch):
"""create_replay_app(run=...) mounted in a host app: Run, watch the map, then Play."""
import uvicorn
from starlette.applications import Starlette
from starlette.routing import Mount
from praisonaiui.features.replay import create_replay_app
traces = tmp_path / "traces"
traces.mkdir()
monkeypatch.setenv("PRAISONAI_TRACES_DIR", str(traces))
lines = (FIXTURES / "flow_parallel_loop.jsonl").read_text(encoding="utf-8").splitlines(keepends=True)
seen = []
def runner(text, run_id): # writes a recorded flow slowly, like a live run
seen.append(text)
with open(traces / f"{run_id}.jsonl", "w", encoding="utf-8") as fh:
for line in lines:
fh.write(line)
fh.flush()
time.sleep(0.08)
host = Starlette(routes=[Mount("/replay", app=create_replay_app(runner, input="weekly digest"))])
port = _free_port()
srv = uvicorn.Server(uvicorn.Config(host, host="127.0.0.1", port=port, log_level="warning"))
t = threading.Thread(target=srv.run, daemon=True)
t.start()
for _ in range(100):
if srv.started:
break
time.sleep(0.05)
try:
page, errors = _page(browser)
page.add_init_script("try{localStorage.setItem('rp-view','map')}catch(e){}")
page.goto(f"http://127.0.0.1:{port}/replay/")
page.wait_for_selector(".rp-empty h2:has-text('Nothing has run yet')")
page.get_by_role("button", name="▶ Run").click()
page.wait_for_selector(".rp-node.rp-st-running", timeout=10000) # a step is drawn as running
assert page.locator(".rp-node.rp-unvisited").count() > 0 # later steps not reached yet
page.wait_for_selector(".rp-bar .rp-b-completed", timeout=20000)
assert seen == ["weekly digest"]
assert page.locator(".rp-node.rp-st-running").count() == 0
assert page.get_by_role("button", name="▶ Run").is_enabled()
page.get_by_role("button", name="▶ Play").click() # from the end: restarts at step 1
page.wait_for_timeout(1300)
assert page.locator("[data-key=play]").inner_text() == "❚❚ Pause"
assert page.locator(".rp-node.rp-unvisited").count() > 0 # the map rewound with the cursor
page.locator("[data-key=play]").click()
violations = _axe(page)
assert violations == [], json.dumps(violations, indent=2)
assert not errors, errors
finally:
srv.should_exit = True
t.join(timeout=5)
Loaded 100 of 123 files, more files were not shown because too many files have changed in this diff. Show more