Compare commits

...
Author SHA1 Message Date
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
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 4f8ea88a5c fix: preserve channel message metadata in SDKFileDataStore (fixes #275)
SDKFileDataStore only copied toolCalls/elements into SDK message
metadata on write and only restored those two keys on read, so
channel/voice fields (platform, sender, icon, channel_id) were
silently dropped after reload — despite being persisted correctly by
the SQLAlchemy backend.

Write path now preserves all non-core keys (excluding None), and the
read path restores every persisted metadata key (skipping internal
sentinel keys, without clobbering role/content), matching the
SQLAlchemy backend. Backward compatible: existing toolCalls/elements
round-trips are unchanged and no migration is required.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
2026-09-14 09:47:29 +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
Mervin Praison 597280a6c6 Merge pull request #265 from Dhivya-Bharathy/feat/meeting-agent-phase-2-recall
feat(meeting-agent): Phase 2 Recall.ai live bots (Part A)
2026-09-08 16:39:01 +01:00
MervinPraisonandClaude Opus 4.8 fe954cbd4a fix(meeting-agent): drop leftover hardcoded Recall workspace UUID from part_b test
The personal contributor workspace UUID (33a035c7-...) was still hardcoded
in the test_meeting_agent_part_b.py env fixture, even though the same value
was cleaned to a neutral placeholder everywhere else (config default,
.env.example, smoke_recall.py, and test_meeting_agent_recall.py). Aligned it
with the sibling test's 'test-workspace' placeholder. No assertion depends on
the value, so behavior is unchanged.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
2026-09-08 09:43:30 +00:00
MervinPraisonandClaude Opus 4.8 ab9ef2544d fix(meeting-agent): point Part B tests at renamed meeting_integrations dir
The Part A branch renamed integrations/recall -> meeting_integrations/recall
to avoid a tests/unit/integrations module-name collision, but the Part B test
loaders (test_meeting_agent_part_b.py) still resolved the old integrations/recall
paths, raising FileNotFoundError on CI (py3.10/3.11/3.12). Repoint the 9 _load()
paths at meeting_integrations/recall to match the source layout and the already-
updated test_meeting_agent_recall.py.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
2026-09-08 09:39:03 +00:00
MervinPraisonandCursor e15b3c0718 merge: resolve conflicts with main (Part B + voice-agent merged)
Reconcile meeting-agent Phase A with main after #266/#267: keep
meeting_integrations/ package rename, merge Part B live-transcript and
Calendar V2 routes, and preserve Part A fixes (atomic webhook idempotency,
background task refs, workspace ID placeholders, py3.10 compiler compat).
Update part_b tests to meeting_integrations paths.

Co-authored-by: Cursor <[email protected]>
2026-09-08 10:34:47 +01:00
MervinPraison 6ecb80e977 Release v0.3.143 2026-09-08 09:05:53 +00:00
Mervin Praison 7ebd97d359 Merge pull request #266 from Dhivya-Bharathy/feat/meeting-agent-phase-2-part-b
feat(meeting-agent): Phase 2 Part B — live transcript + Calendar V2
2026-09-08 10:04:46 +01:00
Mervin Praison bd6b67f38b Merge pull request #267 from Dhivya-Bharathy/feat/voice-agent-phase-1
feat(voice-agent): Phase 1 telephony webhooks, tools, and call dashboard
2026-09-08 10:03:17 +01:00
MervinPraisonandClaude Opus 4.8 442e2d6dfe fix(meeting-agent): retain strong refs to webhook background tasks
asyncio keeps only weak references to tasks created via create_task, so a
fire-and-forget webhook dispatch could be garbage-collected mid-execution and
silently drop the event (a transcript line persist or the post-transcript
pipeline). Route both Recall and calendar webhook dispatches through a helper
that holds a strong reference in a module-level set until the task completes.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
2026-09-08 08:17:46 +00:00
MervinPraisonandClaude Opus 4.8 3608d011c6 fix(voice-agent): build stable webhook idempotency keys so status updates aren't dropped
webhook_event_key derived the dedup key from a 200-char JSON prefix for
status-update/conversation-update/end-of-call-report. When a large static
field (e.g. `call`) sorts before the discriminating field, successive
events collapse to the same prefix and later ones are dropped as
duplicates, freezing the dashboard status on the first value (e.g. stuck
on 'ringing'). Derive keys from the fields that actually distinguish each
event, matching the existing transcript-event pattern. No new deps,
params, or exports.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
2026-09-07 23:45:23 +00:00
MervinPraisonandClaude Opus 4.8 d615e2e0a3 fix(tests): warm up liveness endpoint before timing to fix flaky 3.10 CI
test_health_live_is_fast timed the first request through the Starlette
TestClient, which pays one-time route/client initialization cost and
measured 0.15s on a slow Python 3.10 runner (threshold 0.1s). Add a
warmup request so the assertion measures steady-state latency, matching
the endpoint's own <500ms target. No production code change.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
2026-09-07 19:26:06 +00:00
MervinPraison 352023db8d fix(meeting-agent): serialize live-transcript metadata read-modify-write
Recall webhooks are dispatched one-thread-each via asyncio.to_thread, so
rapid-fire transcript.data events ran _upsert_live_transcript concurrently.
Each did an unsynchronized read (get_meeting) -> append -> UPDATE of the
persisted meeting transcript, so two concurrent captions could read the same
base and one write would clobber the other, dropping transcript lines.

Add a shared reentrant lock (pipeline._metadata_lock) around every metadata
read-modify-write in merge_meeting_metadata, and hold it across the read in
_upsert_live_transcript so the whole read-append-write is atomic. In-memory
fan-out (live.py) already had a lock; this closes the same gap for the
persisted transcript. Example-only, no package params/exports/deps added.
2026-09-07 19:23:01 +00:00
MervinPraisonandClaude Opus 4.8 bfb1eeee06 fix(voice-agent): preserve live call status on transcript/conversation events
upsert_call overwrote status with excluded.status and defaulted to
'unknown', so transcript and conversation-update events (which pass no
status) reset an in-progress call to 'unknown' in the dashboard. Preserve
the prior status when the caller omits one, matching the existing
COALESCE handling for summary/customer_number. Adds a regression test.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
2026-09-07 19:21:10 +00:00
MervinPraisonandClaude Opus 4.8 12b42e88be fix(meeting-agent): make Recall webhook idempotency check atomic
Replace the non-atomic SELECT-then-INSERT in mark_event_processed with an
atomic INSERT OR IGNORE + rowcount check. Under concurrent duplicate webhook
deliveries (Svix retries/fan-out) both callers could pass the SELECT and the
second INSERT would raise an unhandled IntegrityError on the primary key,
silently dropping that event's processing after a 200 was returned.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
2026-09-07 15:29:26 +00:00
MervinPraisonandClaude Opus 4.8 5e80f11535 fix(meeting-agent): drop hardcoded personal Recall workspace ID from example
The RECALL_WORKSPACE_ID default was a real contributor workspace UUID
committed across the example (config default, README, .env.example, smoke
script, test). It is non-functional — the Recall API key already scopes
requests to a workspace and the value is only echoed back by the
config-status endpoint. Default it to an empty string and replace the
committed UUID with neutral placeholders.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
2026-09-07 15:25:58 +00:00
MervinPraisonandClaude Opus 4.8 84ae1fd0ac fix(meeting-agent): retain webhook background task refs to prevent GC
asyncio only holds weak references to tasks created via create_task, so the
fire-and-forget Recall webhook processing task could be garbage-collected
mid-execution, silently dropping transcript/status updates. Keep strong refs
in a module-level set and discard on completion.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
2026-09-07 15:21:44 +00:00
MervinPraisonandClaude Opus 4.8 b59af41d88 fix(compiler): use timezone.utc for Python 3.10 compatibility
datetime.UTC was added in Python 3.11, so importing it broke the
python (3.10) CI job with ImportError in _format_lastmod. Switch to
timezone.utc which is equivalent and available on all supported
Python versions (3.10+).

Co-Authored-By: Claude Opus 4.8 <[email protected]>
2026-09-07 15:17:41 +00:00
MervinPraisonandClaude Opus 4.8 fb999aba3c fix(seo): use timezone.utc for Python 3.10 compatibility in sitemap lastmod
datetime.UTC was added in Python 3.11, but requires-python is >=3.10, so
Compiler._format_lastmod raised ImportError on 3.10, failing the SEO build
tests and blocking CI. timezone.utc is equivalent and available since 3.2.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
2026-09-07 15:15:55 +00:00
MervinPraisonandClaude Opus 4.8 8e5c4d40e2 fix(meeting-agent): resolve CI failures on Phase 2 Recall PR
- Rename example package integrations/ -> meeting_integrations/ to avoid a
  top-level module-name collision with tests/unit/integrations (imported as
  top-level 'integrations' because tests/unit has no __init__.py). The
  collision made 'meeting_integrations.recall' unresolvable once the test
  session imported the tests integrations package first, causing 8 errors +
  3 failures. Updated all absolute imports and test path references.
- Guard praisonai_tools-dependent tests with pytest.importorskip: the meeting
  tools live in the external PraisonAI-Tools package and are not installed in
  this repo's CI, so those tests skip cleanly instead of erroring.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
2026-09-07 15:15:01 +00:00
MervinPraisonandClaude Opus 4.8 c7dc8a751a fix(tests): skip meeting-agent tests when praisonai_tools is unavailable
The 6 new meeting-agent tests invoke the external PraisonAI-Tools
'meeting_tools' module, which is not a PraisonAIUI CI dependency, causing
ModuleNotFoundError failures on Python 3.10/3.11/3.12. Guard them with
pytest.importorskip so CI skips them cleanly instead of failing, matching
how the example treats its other optional heavy deps.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
2026-09-07 15:11:50 +00:00
MervinPraisonandClaude Opus 4.8 b5b640a2b7 fix(compiler): use timezone.utc for lastmod so Python 3.10 CI passes
datetime.UTC was added in Python 3.11, but the project supports >=3.10
(CI matrix + classifiers), so _format_lastmod raised ImportError on 3.10:
  cannot import name 'UTC' from 'datetime'
Switch to timezone.utc (available since 3.2) — identical behaviour, all
supported versions. Fixes the failing python (3.10) job on PR #267.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
2026-09-07 10:21:44 +00:00
MervinPraisonandClaude Opus 4.8 a188039480 fix(voice-agent): hold strong ref to background webhook task to prevent GC drop
asyncio only keeps a weak reference to tasks created via create_task, so a
fire-and-forget webhook-processing task could be garbage-collected mid-flight
and silently drop a status/transcript event. Track tasks in a set and discard
on completion (standard asyncio pattern).

Co-Authored-By: Claude Opus 4.8 <[email protected]>
2026-09-07 10:16:51 +00:00
MervinPraisonandClaude Opus 4.8 c71ad7d6da fix(voice-agent): isolate example package import in unit tests to fix CI
tests/unit/integrations ships a top-level 'integrations' package, which
shadowed the voice-agent example's integrations.voice package when the
voice test ran after the integration tests (ModuleNotFoundError: No module
named 'integrations.voice'), failing python 3.10/3.11/3.12 CI.

Import the example's modules inside a context manager that gives the
example dir priority on sys.path and restores the original 'integrations'
package afterwards. Example code is unchanged.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
2026-09-07 10:13:46 +00:00
MervinPraisonandClaude Opus 4.8 ecf2118bb2 fix(voice-agent): remove committed run logs, timing-safe webhook verify, lazy httpx
- Delete accidentally committed .app.err / .tunnel.log.err (leaked local
  paths, Cloudflare tunnel URLs, connector IDs); ignore *.err
- verify.py: use hmac.compare_digest for constant-time secret comparison
- client.py: lazy-import httpx (optional dep) per AGENTS.md lazy-import rule
- Fix import ordering (ruff I001) in app.py / voice_routes.py

Co-Authored-By: Claude Opus 4.8 <[email protected]>
2026-09-07 10:06:59 +00:00
Mervin Praison 43722c4901 Merge pull request #271 from MervinPraison/chore/hub-v1.1.6
chore(ci): hub v1.1.6 — fix Claude Assistant hub checkout
2026-09-07 11:02:21 +01:00
MervinPraisonandCursor 798e893153 chore(ci): bump hub to v1.1.6 (claude-code-action checkout fix)
Co-authored-by: Cursor <[email protected]>
2026-09-07 11:02:16 +01:00
Mervin Praison 696455db7b Merge pull request #270 from MervinPraison/chore/hub-v1.1.5
fix(ci): hub v1.1.5 Claude Assistant caller permissions
2026-09-07 11:00:42 +01:00
MervinPraisonandCursor 9735e1c4af fix(ci): hub v1.1.5 — Claude Assistant caller permissions
Co-authored-by: Cursor <[email protected]>
2026-09-07 11:00:33 +01:00
Mervin Praison effc3982d6 Merge pull request #269 from MervinPraison/chore/hub-v1.1.4
fix(ci): hub v1.1.4 caller permissions for Claude review chain
2026-09-07 10:59:06 +01:00
MervinPraisonandCursor e67075b799 fix(ci): hub v1.1.4 — grant reusable-workflow caller permissions
Repos with default_workflow_permissions: read reject Auto PR Comment and merge
gate when the caller only grants pull-requests: read but callee jobs need write.

Co-authored-by: Cursor <[email protected]>
2026-09-07 10:58:56 +01:00
Mervin Praison 993114bb55 Merge pull request #268 from MervinPraison/chore/hub-v1.1.3
chore(ci): bump github-automation hub to v1.1.3
2026-09-07 10:53:23 +01:00
MervinPraisonandCursor 8c59d2db85 chore(ci): bump github-automation hub to v1.1.3
Fixes broken reusable workflow refs (v1.1.1 dynamic hub-setup) and review-chain
callable YAML so Auto PR Comment / merge gate / CI-failure Claude run again.

Co-authored-by: Cursor <[email protected]>
2026-09-07 10:53:05 +01:00
Dhivya-Bharathy dc98a79f6e feat(voice-agent): Phase 1 telephony webhooks, tools, and call dashboard 2026-09-07 13:18:37 +05:30
Dhivya-BharathyandCursor ced5a52871 feat(meeting-agent): Phase 2 Part B — live transcript + Calendar V2
Co-authored-by: Cursor <[email protected]>
2026-09-04 17:55:33 +05:30
Dhivya-BharathyandCursor 5f3b026da8 feat(meeting-agent): Phase 2 Recall.ai live bots (Part A)
Add Recall integration for live Google Meet/Zoom/Teams capture: bot scheduling, verified webhooks, async transcription, and post-call summarize + index pipeline. Includes recovery script, smoke mode, and unit tests. Phase 1 upload flow unchanged.

Co-authored-by: Cursor <[email protected]>
2026-09-04 10:59:01 +05:30
MervinPraisonandCursor 15aa219cb3 chore(automation): upgrade to hub v1.1.1 and fix pre-labelled issue Claude trigger
Switch merge-gate, review chain, CI failure, and pipeline workflows to thin
hub callers. Fix auto-issue-comment and claude-dedupe so bot-routed issues
opened with the claude label still dispatch Claude.

Co-authored-by: Cursor <[email protected]>
2026-09-02 13:08:41 +01:00
Mervin Praison 5c88692009 Merge pull request #261 from MervinPraison/claude/issue-260-20260902-meeting-ui
feat: thin meeting-agent UI example (PAI-MEET-UI)
2026-09-02 10:33:55 +01:00
praisonai-triage-agent[bot]andMervin Praison 83c273eba3 fix(compiler): remove extraneous f-string prefix breaking ruff (CI)
CI job `python (3.12)` failed at `ruff check src/praisonaiui` with F541
(f-string without placeholders) on compiler.py:922, blocking pytest before
it ran. Removed the unnecessary `f` prefix on the static og:type meta tag.

Co-authored-by: Mervin Praison <[email protected]>
2026-09-02 09:31:10 +00:00
MervinPraisonandClaude ee44c38297 feat: add thin meeting-agent UI example composing existing features (fixes #260)
Adds examples/python/meeting-agent/ wiring existing attachments, knowledge,
chat and Slack features via YAML/config only. No new package, no new public
exports, no create_app()/server.py params, no eager heavy deps.

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <[email protected]>
2026-09-02 09:26:05 +00:00
Mervin Praison 8a79913cc4 Merge pull request #228 from MervinPraison/claude/issue-222-20260715-1318
feat: Training Lab UI for praisonai-train agent sessions (STITCH-012)
2026-09-02 10:09:12 +01:00
Mervin Praison af97f31e68 Merge pull request #229 from MervinPraison/claude/issue-217-20260715-run-recorder
feat: Run Flight Recorder — /runs three-pane run debugger (#217)
2026-09-02 10:09:00 +01:00
MervinPraisonandCursor 812c53abaa fix(docs): prevent stale content and full reloads on fast navigation
Abort in-flight markdown fetches, clear the article immediately on route change, and always intercept sidebar links so rapid clicks stay in the SPA instead of showing the wrong page.

Co-authored-by: Cursor <[email protected]>
2026-08-28 12:54:38 +01:00
MervinPraisonandCursor 4bc7497e99 feat(docs): Mintlify-style full-width layout and header navigation
Adopt a three-column docs shell with a centred 720px content column, integrated header tabs, search, and tab-filtered sidebar so navigation matches Mintlify-style sites.

Co-authored-by: Cursor <[email protected]>
2026-08-28 11:16:35 +01:00
MervinPraisonandCursor 4bf74be9c7 fix(docs): render Mermaid diagrams full width like Mintlify
Use useMaxWidth, measure the container width on render/resize, and scale
SVG to 100% of the content column. Widen the docs main area by removing
max-w-3xl so diagrams use space between sidebar and TOC.

Co-authored-by: Cursor <[email protected]>
2026-08-28 10:23:12 +01:00
MervinPraisonandCursor 800883aaf4 test: assert mermaid renders via code component unwrap
Co-authored-by: Cursor <[email protected]>
2026-08-27 14:58:06 +01:00
MervinPraisonandCursor fc28c77e84 fix(docs): detect mermaid fences in code component
Route ```mermaid blocks through the code markdown handler so diagrams
render reliably across react-markdown versions, with pre unwrapping the
MermaidDiagram output.

Co-authored-by: Cursor <[email protected]>
2026-08-27 14:47:03 +01:00
MervinPraisonandCursor 82980cee1a fix(docs): render Mermaid diagrams in React SPA
The mermaid plugin skips React-managed article content to avoid DOM
reconciler crashes, so fenced mermaid blocks showed as raw code. Add a
native MermaidDiagram component with lazy-loaded mermaid@11, theme sync,
and bundled frontend assets.

Co-authored-by: Cursor <[email protected]>
2026-08-27 14:37:34 +01:00
MervinPraisonandCursor 6bfc19a0ee fix(ci): use force_orphan for reliable GitHub Pages deploys
peaceiris was rm-ing gh-pages files then skipping commit when the tree
matched HEAD, leaving llms.txt and sitemap offline. force_orphan replaces
the branch on every deploy.

Co-authored-by: Cursor <[email protected]>
2026-08-26 22:28:25 +01:00
MervinPraisonandCursor 6f855aee3e feat(seo): complete crawl readiness — llms-full, lastmod, auto descriptions
Add llms-full.txt, sitemap lastmod dates, auto meta descriptions from first
paragraph, per-page noindex support, runtime JSON-LD/OG/Twitter updates, a
dedicated Deploy Docs workflow, and corrected SEO documentation.

Co-authored-by: Cursor <[email protected]>
2026-08-26 22:22:09 +01:00
MervinPraisonandCursor 203c57710a ci: allow manual workflow dispatch for docs deploy
Push webhooks stopped firing CI after 13:32 UTC; workflow_dispatch
lets us redeploy GitHub Pages with sitemap, robots.txt, and llms.txt.

Co-authored-by: Cursor <[email protected]>
2026-08-26 17:21:52 +01:00
MervinPraisonandCursor bd6d08b08e feat(seo): add llms.txt, crawl nav, and homepage sitemap entry
Generate llms.txt for AI crawlers, inject static nav links into prerendered
HTML, convert markdown links in noscript content, and include the site root
in sitemap.xml so Google can discover all doc pages once deployed.

Co-authored-by: Cursor <[email protected]>
2026-08-26 17:20:59 +01:00
MervinPraisonandCursor 90feb197b2 fix(docs): stop plugin DOM mutations that crash React SPA navigation
Guard plugin-loader, syntax-highlight, mermaid, and code-copy from touching
React-managed article content; emit flat doc HTML for canonical URLs; add SEO
sitemap/robots and trailing-slash normalisation so sidebar clicks stay stable.

Co-authored-by: Cursor <[email protected]>
2026-08-26 17:17:18 +01:00
MervinPraisonandCursor a391d55f74 fix(frontend): fetch content only when currentPath changes
Avoid re-running the markdown fetch when selectedItem syncs after
aiui:navigate, which was cancelling requests and leaving loading stuck.

Co-authored-by: Cursor <[email protected]>
2026-08-26 14:32:03 +01:00
MervinPraisonandCursor a5e6316021 fix(frontend): unstick "Loading content..." after SPA navigation
Reset loadingContent in effect cleanup when a fetch is cancelled, and
fetch only on currentPath changes so selectedItem sync does not abort
the in-flight request.

Co-authored-by: Cursor <[email protected]>
2026-08-26 14:31:48 +01:00
MervinPraisonandCursor 3c534c9637 fix(frontend): map /docs/index to /docs/index.md not /docs.md
Stripping a trailing /index segment broke the docs home page fetch,
causing "Content for PraisonAIUI not found" at /docs/index.

Co-authored-by: Cursor <[email protected]>
2026-08-26 14:30:49 +01:00
MervinPraisonandCursor e2486fe666 fix(frontend): stop empty pages and stale content on SPA nav
React now owns sidebar navigation content: sync selectedItem on
aiui:navigate, fetch markdown from currentPath with cancel-on-nav,
use canonical URLs without trailing slashes, and add data-nav-path on
sidebar buttons. Plugins teardown hide-mode instead of replacing React
DOM; mkdocs-compat no longer wipes main.flex-1.

Co-authored-by: Cursor <[email protected]>
2026-08-26 14:08:55 +01:00
MervinPraisonandCursor 60e9bbe2df fix(frontend): wrap theme tokens in hsl() so backgrounds render
Tailwind utilities used var(--background) directly, but shadcn tokens
store bare HSL components — backgrounds were transparent while prose
text used hsl(var(--foreground)), causing light-on-white in dark mode.
Also replace hardcoded dark anti-flicker with localStorage-aware bootstrap.

Co-authored-by: Cursor <[email protected]>
2026-08-26 12:07:06 +01:00
MervinPraisonandCursor 672070cd13 fix(frontend): restore light-mode contrast on plugin SPA path
Align content-loader and homepage plugins with React prose classes,
extend CSS contrast rules to all article.prose, fix server anti-flicker
hardcoded dark colours, wire theme toggle with localStorage, and add
guardrail tests for the plugin rendering path.

Co-authored-by: Cursor <[email protected]>
2026-08-26 11:23:46 +01:00
MervinPraisonandCursor db0672feec fix(frontend): restore light-mode prose and code contrast
Scope mermaid heading overrides to dark mode, fix hsl() theme tokens in
syntax-highlight, and force readable prose colours for body text, tables,
and code blocks in light mode.

Co-authored-by: Cursor <[email protected]>
2026-08-26 07:50:25 +01:00
MervinPraison 7fe03534de Release v0.3.142 2026-08-26 01:00:01 +00:00
MervinPraisonandCursor 23a193eb22 fix(frontend): complete docs UI P1 — routing, nav, TOC, mermaid
Wire route-manifest template resolution, mobile nav drawer, live TOC from
page headings, Mermaid async rendering via aiui:content-loaded, and
FlexibleLayout footer deduplication.

Co-authored-by: Cursor <[email protected]>
2026-08-25 23:58:43 +01:00
MervinPraison b6dc50180b Release v0.3.141 2026-08-25 22:48:37 +00:00
MervinPraisonandCursor d91c3baf21 fix(frontend): improve docs UI contrast and MkDocs rendering
Fix unreadable light-mode code blocks, transform MkDocs Material syntax
(tabs, icons, grid cards) in both React and plugin paths, and auto-select
nav items from the URL including top-level entries like /docs/index.

Co-authored-by: Cursor <[email protected]>
2026-08-25 23:45:46 +01:00
MervinPraisonandCursor 5c9e8b2356 docs(adp): harden protocol spec after multi-agent validation
Split component catalog schema from instance data, fix theme/slots
validation, clarify navigation model, add implementation status and
CI tests for ADP JSON Schema conformance.

Co-authored-by: Cursor <[email protected]>
2026-08-25 22:47:42 +01:00
MervinPraison a48f3ae4b2 fix(ci): enhance merge gate recovery and streamline auto-merge process 2026-08-25 22:40:35 +01:00
MervinPraison b4d705ce12 fix(ci): recognise Claude replies and unblock stale-FINAL after rebase
Port merge-gate recovery fixes from PraisonAI-Tools so auto-merge resumes after
conflict rebase pushes and MervinPraison Claude finished comments count.
2026-08-15 13:33:36 +01:00
MervinPraison 88c84d0c35 Release v0.3.140 2026-08-01 02:22:54 +00:00
MervinPraison 6d71fa4197 fix: port full scope-creep gates from PraisonAI with UI wording
Align merge gate, conflict/CI fix prompts, FINAL phases, and claude.yml
with PraisonAI lightweight-and-powerful checks — product value, layering
(praisonaiagents / PraisonAI-Tools), and server.py bloat — not Agent-class.
2026-07-31 14:04:16 +01:00
praisonai-triage-agent[bot] 576164960f Merge pull request #250 from MervinPraison/claude/issue-240-20260727-0912
feat: surface degraded knowledge search when embedding model unavailable
2026-07-31 12:08:20 +00:00
praisonai-triage-agent[bot] 9ec612b362 Merge pull request #249 from MervinPraison/claude/issue-235-20260727-0910
fix: prevent cp1252 UnicodeEncodeError in dashboard examples
2026-07-31 12:04:29 +00:00
praisonai-triage-agent[bot] 56760f843c Merge pull request #248 from MervinPraison/claude/issue-238-20260727-0912
fix: require explicit opt-in for GATEWAY_AUTH_TOKEN url auth
2026-07-31 12:00:49 +00:00
praisonai-triage-agent[bot] 39123500d5 Merge pull request #214 from MervinPraison/claude/issue-209-20260714-fix
fix: dedupe aiui:prefill-composer listener across chat re-navigations
2026-07-31 11:57:08 +00:00
praisonai-triage-agent[bot] 4098e5da4b Merge pull request #213 from MervinPraison/claude/issue-207-20260714-fix
fix: overview approval polling stacks duplicate click handlers (#207)
2026-07-31 11:53:16 +00:00
MervinPraison b3daa2fcfa Release v0.3.139 2026-07-31 11:52:38 +00:00
praisonai-triage-agent[bot] 7c7c121940 Merge pull request #215 from MervinPraison/claude/issue-211-20260714-work-hub-chat
feat: Work Hub Chat tab sends messages to agent via existing transport
2026-07-31 11:49:54 +00:00
praisonai-triage-agent[bot] c1da3fe0ae Merge pull request #212 from MervinPraison/claude/issue-210-20260714-1010
fix: honest gateway reachability probe on onboarding Screen 1
2026-07-31 11:48:48 +00:00
praisonai-triage-agent[bot]andMervin Praison a44776b18e fix: surface AC-4 opt-in on cross-origin CORS block in gateway probe
The reachability probe used default cors mode, so a cross-origin CORS
block rejected the fetch (landing in the 'network' branch) instead of
producing an opaque response. That made the 'opaque' branch and its
"Continue anyway" checkbox (AC-4) dead code. Now a rejected fetch on a
cross-origin URL maps to 'opaque' (ambiguous: CORS-blocked or down) so
the opt-in checkbox renders, while same-origin rejections remain a
definitive 'network' error.

Co-authored-by: Mervin Praison <[email protected]>
2026-07-31 10:56:40 +00:00
MervinPraison 0e095d73fe fix: unblock FINAL @claude when review bots stay silent
Port PraisonAI review-chain parity: 30-minute required-reviewer timeout,
skip Copilot in scheduled FINAL sweep, and selftests for timeout path.
2026-07-31 11:53:09 +01:00
praisonai-triage-agent[bot]andMervin Praison d9836a955e fix: drop superseded #238 token-auth changes (already landed via #251)
Main's #251/#253/#258 implements the #238 fix with a stricter, more secure
design: AIUI_URL_TOKEN / GATEWAY_AUTH_TOKEN alone are inert; URL-token auth
activates only on explicit opt-in (AIUI_REQUIRE_TOKEN, YAML auth.requireToken,
require_auth). This branch's earlier variant treated AIUI_URL_TOKEN presence
(and YAML auth.urlToken) as an opt-in signal, which forced 401 and broke
test_token_auth_optin.py::test_aiui_url_token_alone_does_not_force_401.

Revert server.py to main's canonical logic and drop the now-conflicting
tests/unit/test_gateway_token_optin.py (its cases are covered by main's
tests/unit/test_token_auth_optin.py).

Co-authored-by: Mervin Praison <[email protected]>
2026-07-30 06:34:03 +00:00
praisonai-triage-agent[bot]andMervin Praison 0f986461f9 fix: require explicit opt-in for GATEWAY_AUTH_TOKEN url auth (fixes #238)
GATEWAY_AUTH_TOKEN leaked into an interactive shell no longer silently
enforces 401 on every /api/* route in standalone create_app(). Enforcement
now requires explicit intent via AIUI_URL_TOKEN, AIUI_REQUIRE_TOKEN=1, or
YAML auth.urlToken. Secured gateway deployments keep working.

Co-authored-by: Mervin Praison <[email protected]>
2026-07-30 06:32:39 +00:00
MervinPraisonandClaude 6ffc84e9ba fix: scope overview approval re-bind to re-rendered subtrees (fixes #207)
refreshApprovals re-rendered only #ov-metrics and #ov-attention but then
called bindEvents on the whole overview root, stacking click/keydown
listeners on unchanged shell nodes (agent rows, omnibar) on every 10s poll.
Scope the re-bind to the two re-rendered subtrees so stale nodes keep a
single listener. paint() still does the full-tree bind on initial render.

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <[email protected]>
2026-07-30 06:32:21 +00:00
MervinPraisonandClaude d4aa8c2ec4 feat: wire Work Hub Chat tab to agent via shared mini-chat transport (fixes #211)
Add mini-chat.js reusing the existing /api/chat/ws protocol (run_content/
run_completed/run_error) and wire work-hub.js renderChat() to stream real
agent replies instead of only appending a local DOM bubble. Sessions/agents
rebind per card and the WebSocket is torn down on card/tab switch.

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <[email protected]>
2026-07-30 06:32:14 +00:00
MervinPraisonandClaude c4be709f8d fix: dedupe aiui:prefill-composer listener across chat re-navigations (fixes #209)
Register the Code Studio prefill listener as a module-scope singleton via
ensurePrefillListener() and remove it in cleanup(), preventing duplicate
window handlers from stacking on repeated SPA navigation between /chat and
/code-studio.

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <[email protected]>
2026-07-30 06:32:06 +00:00
praisonai-triage-agent[bot]andMervin Praison 92adfc6f6e fix: honest gateway reachability probe on onboarding Screen 1 (fixes #210)
Co-authored-by: Mervin Praison <[email protected]>
2026-07-30 06:32:04 +00:00
MervinPraison 90315326f7 Release v0.3.138 2026-07-30 01:56:27 +00:00
MervinPraison 0719c41399 fix: auto-trigger conflict and CI fixes without FINAL review gate
Remove the FINAL @claude prerequisite from merge-conflict and CI-failure
workflows, clear pipeline/blocked:conflict after rebase completion, add
bot-pr-recovery cron, and update gate selftests.
2026-07-29 18:15:42 +01:00
praisonai-triage-agent[bot] d008b6a250 Merge pull request #216 from MervinPraison/claude/issue-208-20260714-i18n-memory
feat: wire i18n strings and per-turn memory search into context ring UI
2026-07-29 14:38:03 +00:00
praisonai-triage-agent[bot]andMervin Praison 05fb59e890 feat: surface degraded knowledge search when embedding model unavailable (fixes #240)
Rebased onto latest main. Reconciled with the overlapping degraded-mode
work already merged from #234:
- Adopt main's canonical _is_embedding_error / _embedding_error naming,
  embedding health dict, and richer /v1/embeddings fallback (503 vs 500).
- Keep _is_embedding_unavailable helper (used by main's test suite).
- Align SimpleKnowledgeManager fallback-test to main's "local" mode.

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-authored-by: Mervin Praison <[email protected]>
2026-07-29 13:11:17 +00:00
praisonai-triage-agent[bot]andMervin Praison 8067429e83 fix: prevent cp1252 UnicodeEncodeError in dashboard examples (fixes #235)
Add examples/python/_shared/console.py with safe_print/icon helpers
mirroring the CLI _supports_unicode/_icon pattern, and wire safe_print
into the gateway and channel examples so emoji output degrades to ASCII
on legacy Windows consoles instead of crashing before the server starts.

Co-authored-by: Mervin Praison <[email protected]>
2026-07-29 13:07:27 +00:00
MervinPraisonandClaude 631ae74a29 feat: wire i18n strings and per-turn memory search into context ring UI (fixes #208)
Gap A: chat.js now loads locale strings via /api/i18n and renders ring
tooltip, compaction banner, compacted flash, and memory chip through a
t() helper with English fallback.

Gap B: refreshMemoryChip() posts the last user message to
/api/memory/search and shows per-turn hit count instead of the total
stored memory count from GET /api/memory.

Gap C: role="progressbar" and aria-value* moved to the ring wrapper div;
the send button is now type="button" with a send-only label.

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <[email protected]>
2026-07-29 13:05:17 +00:00
praisonai-triage-agent[bot] e85f6778dd Merge pull request #258 from MervinPraison/claude/issue-256-20260729-120000
fix: stop full-suite pollution flaking auth timing + health cache tests
2026-07-29 11:39:19 +00:00
praisonai-triage-agent[bot] d6354fade0 Merge pull request #257 from MervinPraison/claude/issue-255-20260729-000000
fix: Windows-safe test_default_sqlite_url (stub HOME/USERPROFILE)
2026-07-29 11:35:43 +00:00
MervinPraisonandClaude b16a5f6056 fix: stop full-suite pollution flaking auth timing + health cache tests (fixes #256)
Reset the global _features registry in the test_server client fixture so a
feature leaked by an earlier crashing test cannot slow /health and flake the
deep-health cache/timing assertions. Make test_timing_attack_resistance
noise-resistant by comparing the median of repeated samples with a warm-up
instead of a single sample, so CPU contention under the full suite no longer
trips the variance threshold.

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <[email protected]>
2026-07-29 09:28:32 +00:00
MervinPraisonandClaude 6521beb7b2 fix: stub HOME/USERPROFILE in test_default_sqlite_url for Windows (fixes #255)
🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <[email protected]>
2026-07-29 09:21:35 +00:00
MervinPraison 9e3384c40a Release v0.3.137 2026-07-29 02:06:56 +00:00
praisonai-triage-agent[bot] e12416e15b Merge pull request #254 from MervinPraison/claude/issue-252-20260728-000000
fix: make five unit tests pass on Windows
2026-07-28 11:38:05 +00:00
MervinPraisonandClaude 652d612d35 fix: make five unit tests pass on Windows (fixes #252)
Resolve cross-platform failures caused by Windows' coarse time.time()
resolution, backslash path separators, and reliance on the grep binary.

- lifecycle: measure hook durations with time.perf_counter() so
  startup_time/shutdown_time are non-zero on Windows' low-res wall clock
- datastore: build the default sqlite URL with Path.as_posix() for a
  consistent forward-slash URL across platforms
- test_audio_hooks: allow last_chunk_at == started_at (clock granularity)
- test_sqlalchemy_datastore: compare against as_posix() paths
- test_unified_naming: replace grep subprocess with a portable Python scan

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <[email protected]>
2026-07-28 10:17:52 +00:00
praisonai-triage-agent[bot]andMervin Praison 4d685af1da fix: correct timeline row selected-highlight CSS selector in /runs
The selected-row highlight targeted .rr-timeline-row.selected but rows
render with class .db-timeline-row, so clicking a timeline row never
visually highlighted it. Align the selector to .db-timeline-row.selected.

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-authored-by: Mervin Praison <[email protected]>
2026-07-16 07:55:06 +00:00
MervinPraisonandClaude a1c7a3c279 feat: add Run Flight Recorder /runs three-pane debugger (fixes #217)
Merges chat turns, trace spans, log lines and errors into one per-session
timeline. Reuses existing APIs only. Adds formatDuration/emptyState/
traceStatusBadge/timelineRow helpers, registers the runs page (Control,
order 22), and deep-links from Traces, Command Center and Work Hub.

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <[email protected]>
2026-07-16 07:55:06 +00:00
praisonai-triage-agent[bot]andMervin Praison 10f3bbe1ce fix(training): correct agent_id filter, apply dropdown source, and view nav order
- features/training.py: derive agent_id in _summarize_session so the
  /api/training/sessions?agent_id= filter actually matches (was always None).
- training-lab.js: fetch /api/agents/definitions (returns `id`) instead of
  /api/agents (returns `agent_id`) so the apply dropdown carries valid agent
  IDs matching the registry; fall back to agent_id defensively.
- agents.js: set the agent_id URL param before selectPage('training') so the
  Training Lab picks up the filter on initial render.
- tests: add regression test for agent_id session filtering.

Co-authored-by: Mervin Praison <[email protected]>
2026-07-15 13:47:40 +00:00
praisonai-triage-agent[bot]andMervin Praison c66a33c5d3 feat: add Training Lab UI for praisonai-train sessions (fixes #222)
Co-authored-by: Mervin Praison <[email protected]>
2026-07-15 13:26:32 +00:00
267 changed files with 25632 additions and 3255 deletions

No files matched your search

+9
View File
@@ -0,0 +1,9 @@
{
"templateRepo": "MervinPraison/github-automation-template",
"stack": "claude",
"profile": "sdk",
"mode": "hub",
"hubRef": "v1.1.8",
"templateVersion": "1.1.7",
"installedAt": "2026-09-07T10:02:11Z"
}
@@ -32,4 +32,13 @@ const comment = ciFix.buildCiFixComment({
});
assert('comment mentions product guardrails', comment.includes('Product guardrails'));
const skip = ciFix.shouldSkipCiFix({
comments: [],
headSha: 'abc1234567890abcdef1234567890abcdef12',
labels: [],
claudeInProgress: false,
failedChecks: [{ name: 'python', workflow: 'CI' }],
});
assert('ci fix without FINAL review allowed', !skip.skip);
process.exit(failed ? 1 : 0);
+4 -5
View File
@@ -46,10 +46,9 @@ function hasRecentCiFixComment(comments, headSha) {
});
}
function shouldSkipCiFix({ comments, headSha, labels, hasFinal, claudeInProgress, failedChecks }) {
function shouldSkipCiFix({ comments, headSha, labels, claudeInProgress, failedChecks }) {
if (!failedChecks.length) return { skip: true, reason: 'no failed checks' };
if (labels.includes(CI_FIX_LABEL)) return { skip: true, reason: 'ci fix pending' };
if (!hasFinal) return { skip: true, reason: 'awaiting final claude review trigger' };
if (claudeInProgress) return { skip: true, reason: 'claude in progress' };
if (hasCiFixCommentForSha(comments, headSha)) {
return { skip: true, reason: 'already commented for this sha' };
@@ -159,12 +158,14 @@ function buildCiFixComment({ headSha, failedChecks, failureSummaries }) {
'',
'**Product guardrails (AGENTS.md):**',
'- Do not disturb PraisonAIUI contracts or frontend/backend layering to accommodate scope creep.',
'- Core agent runtime belongs in praisonaiagents; agent-callable tools belong in PraisonAI-Tools — not here.',
'- Prefer existing YAML config, plugins, and feature modules over new server.py/create_app() params or knobs with no live consumer.',
'- Tests must continue to guard backward compatibility and UX behaviour — passing CI by lowering test standards is not acceptable.',
'- If the feature does not genuinely add product value, recommend reverting or narrowing scope instead of patching around failures.',
'',
'## What to do',
'1. State your verdict: **legitimate fix**, **regression fix**, or **needs human review** — and why (1–3 sentences).',
'2. Fix root cause with **minimal changes**; never bloat the Agent class with extra params.',
'2. Fix root cause with **minimal changes**; never bloat server.py or create_app() with extra params.',
exampleTest
? `3. Run failing tests locally, e.g. \`pytest ${exampleTest} -q\`, plus any related tests touched by the PR.`
: '3. Run failing tests locally with targeted pytest, plus any related tests touched by the PR.',
@@ -221,7 +222,6 @@ async function maybeTriggerCiFixClaude(github, owner, repo, prNumber, core, opts
return { skipped: true, reason: 'no failed checks yet (may be pending)' };
}
const hasFinal = mergeGate.hasFinalClaudeReviewTrigger(ctx.comments);
const claudeInProgress = await mergeGate.hasInProgressClaudeAssistant(
github, owner, repo, prNumber
);
@@ -229,7 +229,6 @@ async function maybeTriggerCiFixClaude(github, owner, repo, prNumber, core, opts
comments: ctx.comments,
headSha,
labels,
hasFinal,
claudeInProgress,
failedChecks: failedRuns,
});
+25 -2
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\//,
@@ -21,8 +25,27 @@ module.exports = {
'SCOPE: Focus ONLY on PraisonAIUI (src/praisonaiui, src/frontend, tests, docs). '
+ 'Do NOT expand into praisonaiagents or the monorepo unless the PR explicitly requires it.',
finalClaudeProductValue:
'4. Product value: review whether the change genuinely adds PraisonAIUI UX value and correct '
+ 'layering (UI/frontend here; core agent logic belongs in praisonaiagents). Reject scope creep.',
'4. Product value: review in depth whether the change genuinely adds PraisonAIUI UX value — '
+ 'never add features for the sake of adding them. It must strengthen the UI (simpler, '
+ 'more user-friendly, robust). If it does not clearly add value, request changes or recommend '
+ 'rejecting/closing rather than merging scope creep.\n'
+ '5. Layering: UI/frontend/server features here; core agent runtime belongs in praisonaiagents '
+ '(PraisonAI monorepo). Agent-callable tools → PraisonAI-Tools. Do not pull monorepo or SDK '
+ 'scope into this repo unless the PR explicitly requires it.\n'
+ '6. Do not bloat server.py or create_app() with new params — prefer existing YAML config, '
+ 'plugins, and feature modules; only add params if absolutely required. Reject knobs/exports '
+ 'with no live consumer.',
mergeGateProductValue:
'Confirm product value gate: change strengthens PraisonAIUI UX (no scope creep). The aim is a '
+ 'LIGHTWEIGHT AND POWERFUL package — BLOCK changes that add params/modules/exports duplicating '
+ 'existing UI capabilities (YAML config, plugins, feature modules, existing server hooks), or '
+ 'add knobs/fields with no live consumer, i.e. scope creep for the sake of it.',
mergeGateLayering:
'Layering routing: BLOCK if core agent logic was added here instead of praisonaiagents; BLOCK if '
+ 'agent-callable tools were added here instead of PraisonAI-Tools.',
mergeGateServerBloat:
'Do not bloat server.py or create_app() with additional params — only if absolutely required; '
+ 'prefer existing config/plugin patterns.',
agentPyChecks: false,
reviewBotLogins: [
'coderabbitai[bot]',
+1 -1
View File
@@ -205,7 +205,7 @@ const headAfterRebase = conflictIso(-21 * 60 * 1000);
const rebaseComments = [conflictTrigger, rebaseDone, finalAfterRebase];
assert('conflict blocks before rebase done', mg.hasRecentConflictComment([conflictTrigger], headAfterRebase));
assert('conflict clears after rebase + FINAL on HEAD', !mg.hasRecentConflictComment(rebaseComments, headAfterRebase));
assert('conflict still blocks without FINAL on HEAD', mg.hasRecentConflictComment(
assert('conflict clears after rebase without FINAL on HEAD', !mg.hasRecentConflictComment(
[conflictTrigger, rebaseDone],
headAfterRebase
));
+43 -3
View File
@@ -67,7 +67,11 @@ function isClaudeTriggerNoise(c) {
function isClaudeFinalReplyComment(c) {
const login = (c.user?.login || '').toLowerCase();
if (!login.includes('praisonai-triage')) return false;
const fromAutomation =
login.includes('praisonai-triage') ||
login.includes('github-actions') ||
AUTO_ACTORS.some((a) => a.toLowerCase() === login);
if (!fromAutomation) return false;
return (c.body || '').includes('Claude finished');
}
@@ -89,15 +93,47 @@ function isConflictRebaseTriggerComment(c) {
function isConflictRebaseCompletionComment(c) {
const login = (c.user?.login || '').toLowerCase();
if (!login.includes('praisonai-triage') && !login.includes('github-actions')) return false;
const fromAutomation =
login.includes('praisonai-triage') ||
login.includes('github-actions') ||
AUTO_ACTORS.some((a) => a.toLowerCase() === login);
if (!fromAutomation) return false;
const body = (c.body || '').toLowerCase();
return (
body.includes('rebase complete') ||
body.includes('rebase onto') ||
body.includes('force-pushed') ||
(body.includes('conflict') && body.includes('resolved'))
);
}
/** HEAD landed after conflict rebase but no FINAL @claude since that push. */
function needsFinalReReviewAfterConflictRebase(comments, headPushedAt) {
if (!headPushedAt) return false;
const headTime = new Date(headPushedAt).getTime();
const conflictTriggers = comments.filter(isConflictRebaseTriggerComment);
if (conflictTriggers.length === 0) return false;
const latestConflict = conflictTriggers.reduce((a, b) =>
new Date(a.created_at) > new Date(b.created_at) ? a : b
);
const conflictTime = new Date(latestConflict.created_at).getTime();
if (headTime <= conflictTime) return false;
const rebaseDone = comments.some(
(c) =>
new Date(c.created_at).getTime() > conflictTime && isConflictRebaseCompletionComment(c)
);
if (!rebaseDone) return false;
const finalsAfterHead = comments.filter(
(c) =>
isFinalClaudeTriggerComment(c) &&
new Date(c.created_at).getTime() >= headTime - 60000
);
return finalsAfterHead.length === 0;
}
function conflictRebaseQuiescent(comments, headPushedAt) {
const conflictTriggers = comments.filter(isConflictRebaseTriggerComment);
if (conflictTriggers.length === 0) return true;
@@ -113,7 +149,7 @@ function conflictRebaseQuiescent(comments, headPushedAt) {
);
if (!rebaseDone) return false;
return finalClaudeCompletedOnSha(comments, headPushedAt);
return true;
}
function hasRecentConflictComment(comments, headPushedAt = null) {
@@ -238,6 +274,9 @@ function shouldSkipStaleFinalRecovery(comments, headPushedAt, headPusherLogin =
if (headPusherLogin && isClaudeAutomationLogin(headPusherLogin)) {
return { skip: true, reason: 'head pushed by Claude automation' };
}
if (needsFinalReReviewAfterConflictRebase(comments, headPushedAt)) {
return { skip: false, reason: '' };
}
if (isPushSoonAfterLatestFinal(comments, headPushedAt)) {
return {
skip: true,
@@ -846,6 +885,7 @@ module.exports = {
hasRecentConflictComment,
isConflictRebaseTriggerComment,
isConflictRebaseCompletionComment,
needsFinalReReviewAfterConflictRebase,
conflictRebaseQuiescent,
hasHumanChangesRequested,
hasAnyChangesRequested,
@@ -0,0 +1,90 @@
#!/usr/bin/env node
const chain = require('./pr-review-chain.js');
let failed = 0;
function assert(name, cond) {
if (!cond) {
console.error('FAIL:', name);
failed++;
} else {
console.log('ok:', name);
}
}
const coderabbit = {
user: { login: 'coderabbitai[bot]' },
body: '<!-- summarize by coderabbit.ai --> summary',
created_at: '2026-07-02T07:16:00Z',
};
const greptile = {
user: { login: 'greptile-apps[bot]' },
body: '<h3>Greptile Summary</h3><p>Looks good</p>',
created_at: '2026-07-02T07:19:00Z',
};
const copilotKick = {
user: { login: 'MervinPraison' },
body: '@copilot Do a thorough review',
created_at: '2026-07-02T07:20:00Z',
};
const copilotReply = {
user: { login: 'copilot-swe-agent' },
body: 'Copilot review here',
created_at: '2026-07-02T07:22:00Z',
};
assert('prior not ready without greptile', !chain.priorReviewersReady([coderabbit]).ready);
assert('prior ready with coderabbit + greptile', chain.priorReviewersReady([coderabbit, greptile]).ready);
assert('copilot not ready before trigger', !chain.copilotReviewReady([coderabbit, greptile]).ready);
assert(
'copilot ready after reply',
chain.copilotReviewReady([coderabbit, greptile, copilotKick, copilotReply]).ready
);
assert(
'claude not ready before copilot',
!chain.claudeFinalReady([coderabbit, greptile]).ready
);
assert(
'claude ready after full chain',
chain.claudeFinalReady([coderabbit, greptile, copilotKick, copilotReply]).ready
);
assert(
'claude timeout fallback when copilot triggered but silent',
chain.claudeFinalReady([coderabbit, greptile, copilotKick], [], { allowCopilotTimeout: true }).ready
);
assert(
'no timeout fallback without copilot trigger',
!chain.claudeFinalReady([coderabbit, greptile], [], { allowCopilotTimeout: true }).ready
);
assert(
'skipCopilot proceeds after prior reviewers',
chain.claudeFinalReady([coderabbit, greptile], [], { skipCopilot: true }).ready
);
const oldPr = new Date(Date.now() - 31 * 60 * 1000).toISOString();
assert(
'prior not ready without coderabbit before timeout',
!chain.priorReviewersReady([greptile], [], { prCreatedAt: new Date().toISOString(), requiredWaitMs: 30 * 60 * 1000 }).ready
);
assert(
'prior timeout when coderabbit silent after 30m',
chain.priorReviewersReady([greptile], [], { prCreatedAt: oldPr, requiredWaitMs: 30 * 60 * 1000 }).ready
);
assert(
'claude FINAL timeout when required reviewer silent and no prior FINAL',
chain.claudeFinalReady([greptile], [], { prCreatedAt: oldPr, requiredWaitMs: 30 * 60 * 1000 }).ready
);
assert(
'claude FINAL timeout auto-skips copilot without explicit skipCopilot',
chain.claudeFinalReady([greptile], [], { prCreatedAt: oldPr, requiredWaitMs: 30 * 60 * 1000 }).copilotSkipped
);
const finalClaude = {
user: { login: 'github-actions[bot]' },
body: '@claude You are the FINAL architecture reviewer for this PR.',
created_at: '2026-07-02T07:25:00Z',
};
assert(
'claude FINAL not re-posted when already triggered',
!chain.claudeFinalReady([greptile, finalClaude], [], { prCreatedAt: oldPr, requiredWaitMs: 0 }).ready
);
process.exit(failed ? 1 : 0);
+21 -2
View File
@@ -16,6 +16,7 @@ const CLAUDE_TRIGGER_LOGINS = new Set(['MervinPraison', 'github-actions[bot]']);
const REQUIRED_PRIOR = ['coderabbit', 'greptile'];
const OPTIONAL_PRIOR = ['qodo', 'gemini'];
const OPTIONAL_PRIOR_WAIT_MS = 20 * 60 * 1000;
const REQUIRED_PRIOR_WAIT_MS = 30 * 60 * 1000;
function loginOf(item) {
return (item?.user?.login || '').toLowerCase();
@@ -75,11 +76,23 @@ function priorReviewerStatus(comments, reviews = []) {
function priorReviewersReady(comments, reviews = [], options = {}) {
const waitMs = options.optionalWaitMs ?? OPTIONAL_PRIOR_WAIT_MS;
const requiredWaitMs = options.requiredWaitMs ?? REQUIRED_PRIOR_WAIT_MS;
const prCreatedAt = options.prCreatedAt || null;
const status = priorReviewerStatus(comments, reviews);
const missingRequired = REQUIRED_PRIOR.filter((id) => !status[id]);
if (missingRequired.length) {
if (prCreatedAt) {
const age = Date.now() - new Date(prCreatedAt).getTime();
if (age >= requiredWaitMs) {
return {
ready: true,
reason: `required reviewer timeout (${missingRequired.join(', ')} silent after ${Math.round(requiredWaitMs / 60000)}m)`,
status,
requiredTimeout: true,
};
}
}
return { ready: false, reason: `waiting for ${missingRequired.join(', ')}`, status };
}
@@ -157,8 +170,13 @@ function claudeFinalReady(comments, reviews = [], options = {}) {
if (!prior.ready) {
return { ready: false, reason: prior.reason };
}
if (isSkipCopilot(options)) {
return { ready: true, reason: 'copilot skipped', copilotSkipped: true };
if (isSkipCopilot(options) || prior.requiredTimeout) {
return {
ready: true,
reason: prior.requiredTimeout ? prior.reason : 'copilot skipped',
copilotSkipped: true,
requiredTimeout: prior.requiredTimeout,
};
}
const copilot = copilotReviewReady(comments, reviews);
if (copilot.ready) {
@@ -283,6 +301,7 @@ module.exports = {
isSkipCopilot,
REQUIRED_PRIOR,
OPTIONAL_PRIOR,
REQUIRED_PRIOR_WAIT_MS,
COPILOT_REVIEW_BODY,
priorReviewerStatus,
priorReviewersReady,
+4 -3
View File
@@ -24,7 +24,7 @@ jobs:
trigger-claude:
if: |
github.event_name == 'workflow_dispatch' ||
(github.event_name == 'issues' && !contains(toJSON(github.event.issue.labels), 'claude'))
github.event_name == 'issues'
runs-on: ubuntu-latest
permissions:
issues: write
@@ -52,7 +52,8 @@ jobs:
});
const labels = (issue.labels || []).map(l => typeof l === 'string' ? l : l.name);
if (!labels.includes('claude')) {
const hadClaude = labels.includes('claude');
if (!hadClaude) {
await github.rest.issues.addLabels({
owner: context.repo.owner,
repo: context.repo.repo,
@@ -60,7 +61,7 @@ jobs:
labels: ['claude'],
});
} else {
console.log(`Issue #${issueNumber} already has 'claude' label.`);
console.log(`Issue #${issueNumber} already has 'claude' label — dispatching Claude only.`);
}
const body = [
+11 -787
View File
@@ -1,14 +1,5 @@
name: Auto PR Comment
# Review chain: CodeRabbit/Qodo/Gemini review first → Copilot → Claude (final)
# For human PRs: CodeRabbit/Qodo auto-review → this workflow triggers Copilot → then Claude
# For bot PRs: CodeRabbit/Qodo skip bots, so we trigger them explicitly first
#
# IMPORTANT: @copilot ignores bot comments. All @copilot mentions must use
# PAT_TOKEN so the comment posts as MervinPraison (a real user).
#
# Claude trigger runs IN-BAND (same workflow) to avoid GitHub Actions
# blocking bot-triggered workflows with action_required approval gates.
on:
issue_comment:
types: [created]
@@ -17,793 +8,26 @@ on:
pull_request:
types: [opened, synchronize]
pull_request_target:
types: [synchronize]
types: [opened, synchronize]
schedule:
- cron: '30 3,9,15,21 * * *'
workflow_dispatch:
inputs:
pr_number:
description: 'PR number for manual FINAL (leave empty to sweep all open PRs)'
required: false
type: number
env:
REVIEW_CHAIN_SKIP_COPILOT: '1'
permissions:
contents: read
issues: write
pull-requests: write
env:
MAX_POLL_ATTEMPTS: 20
POLL_DELAY_MS: 30000
CLAUDE_TRIGGER_LOGINS: '["MervinPraison","github-actions[bot]"]'
# GH_TOKEN (user PAT) posts as MervinPraison for @copilot; fallback to github.token for @claude
GH_TOKEN: ${{ secrets.GH_TOKEN || github.token }}
CLAUDE_UI_SCOPE: 'SCOPE: Focus ONLY on PraisonAIUI (src/praisonaiui, src/frontend, tests, docs). Do not expand into praisonaiagents or the monorepo unless the issue explicitly requires it.'
CLAUDE_FINAL_PHASES_COMPACT: "**Phase 1: Review per AGENTS.md** (lazy imports, backward compat, import-time <200ms; SDK/product value gate — genuine UI value, correct UI vs praisonaiagents layering, no scope creep)\n**Phase 2: FIX valid issues** found by prior reviewers — push to THIS branch\n**Phase 3: Final verdict** — approve PR if ready, else request changes"
CLAUDE_FINAL_PHASES_DETAILED: "**Phase 1: Review per AGENTS.md**\n1. Protocol-driven: check heavy implementations vs core SDK\n2. Backward compatible: ensure zero feature regressions\n3. Performance: no hot-path regressions (lazy imports, import-time <200ms)\n4. SDK/product value: review whether the change genuinely adds value — stronger PraisonAIUI UX and correct layering (UI/frontend here; core agent logic belongs in praisonaiagents). Reject scope creep; request changes or recommend closing if value is unclear\n\n**Phase 2: FIX Valid Issues**\n5. For any VALID bugs or architectural flaws found by Gemini, CodeRabbit, Qodo, Copilot, or any other reviewer: implement the fix\n6. Push all code fixes directly to THIS branch (do NOT create a new PR)\n7. Comment a summary of exact files modified and what you skipped\n\n**Phase 3: Final Verdict**\n8. If all issues are resolved, approve the PR / close the Issue\n9. If blocking issues remain, request changes / leave clear action items"
jobs:
# ================================================================
# HUMAN PRs: Trigger @copilot AFTER CodeRabbit posts its summary
# ================================================================
copilot-after-coderabbit:
if: |
github.event_name == 'issue_comment' &&
github.event.issue.pull_request &&
github.event.comment.user.login == 'coderabbitai[bot]' &&
contains(github.event.comment.body, 'summarize by coderabbit')
runs-on: ubuntu-latest
permissions:
pull-requests: write
outputs:
pr_number: ${{ steps.trigger.outputs.pr_number }}
triggered: ${{ steps.trigger.outputs.triggered }}
steps:
- name: Post Copilot review request (as user via PAT)
id: trigger
uses: actions/github-script@v7
with:
github-token: ${{ env.GH_TOKEN }}
script: |
const comments = await github.rest.issues.listComments({
issue_number: context.issue.number,
owner: context.repo.owner,
repo: context.repo.repo
});
// Check for duplicates — posted by MervinPraison (PAT) or github-actions[bot] (legacy)
const alreadyPosted = comments.data.some(c =>
c.body.includes('@copilot') &&
(c.user.login === 'MervinPraison' || c.user.login === 'github-actions[bot]')
);
if (alreadyPosted) {
console.log('Copilot already triggered, skipping');
core.setOutput('triggered', 'false');
core.setOutput('pr_number', context.issue.number.toString());
return;
}
await github.rest.issues.createComment({
issue_number: context.issue.number,
owner: context.repo.owner,
repo: context.repo.repo,
body: '@copilot Do a thorough review of this PR. Read ALL existing reviewer comments above from Qodo, Coderabbit, and Gemini first — incorporate their findings.\n\nReview areas:\n1. **Bloat check**: Are changes minimal and focused? Any unnecessary code or scope creep?\n2. **Security**: Any hardcoded secrets, unsafe eval/exec, missing input validation?\n3. **Performance**: Any module-level heavy imports? Hot-path regressions?\n4. **Tests**: Are tests included? Do they cover the changes adequately?\n5. **Backward compat**: Any public API changes without deprecation?\n6. **Code quality**: DRY violations, naming conventions, error handling?\n7. **Address reviewer feedback**: If Qodo, Coderabbit, or Gemini flagged valid issues, include them in your review\n8. Suggest specific improvements with code examples where possible'
});
core.setOutput('triggered', 'true');
core.setOutput('pr_number', context.issue.number.toString());
# Fallback: Trigger @copilot after Qodo review (if Coderabbit doesn't fire)
copilot-after-qodo:
if: |
github.event_name == 'pull_request_review' &&
github.event.review.user.login == 'qodo-code-review[bot]'
runs-on: ubuntu-latest
permissions:
pull-requests: write
outputs:
pr_number: ${{ steps.trigger.outputs.pr_number }}
triggered: ${{ steps.trigger.outputs.triggered }}
steps:
- name: Post Copilot review request (as user via PAT)
id: trigger
uses: actions/github-script@v7
with:
github-token: ${{ env.GH_TOKEN }}
script: |
const comments = await github.rest.issues.listComments({
issue_number: context.issue.number,
owner: context.repo.owner,
repo: context.repo.repo
});
const alreadyPosted = comments.data.some(c =>
c.body.includes('@copilot') &&
(c.user.login === 'MervinPraison' || c.user.login === 'github-actions[bot]')
);
if (alreadyPosted) {
console.log('Copilot already triggered, skipping');
core.setOutput('triggered', 'false');
core.setOutput('pr_number', context.issue.number.toString());
return;
}
await github.rest.issues.createComment({
issue_number: context.issue.number,
owner: context.repo.owner,
repo: context.repo.repo,
body: '@copilot Do a thorough review of this PR. Read ALL existing reviewer comments above first.\n\nReview areas:\n1. **Bloat check**: Are changes minimal and focused?\n2. **Security**: Any hardcoded secrets, unsafe eval/exec, missing input validation?\n3. **Performance**: Any module-level heavy imports? Hot-path regressions?\n4. **Tests**: Are tests included? Do they cover the changes adequately?\n5. **Backward compat**: Any public API changes without deprecation?\n6. **Code quality**: DRY violations, naming conventions, error handling?\n7. Suggest specific improvements with code examples where possible'
});
core.setOutput('triggered', 'true');
core.setOutput('pr_number', context.issue.number.toString());
# ================================================================
# FALLBACK: Claude trigger for cases where Copilot chain fails/skips
# Ensures Claude always runs after sufficient review time
# ================================================================
claude-fallback-timeout:
if: |
github.event_name == 'pull_request' &&
github.event.action == 'opened'
runs-on: ubuntu-latest
timeout-minutes: 20
review-chain:
# Grant union of callee job permissions (repos with default_workflow_permissions: read
# reject reusable calls when the caller ceiling is lower than any callee job).
permissions:
issues: write
pull-requests: write
steps:
- name: Wait for review window (15 min)
run: sleep 900
- name: Check if Claude already triggered
id: check_claude
uses: actions/github-script@v7
env:
TRIGGER_LOGINS: ${{ env.CLAUDE_TRIGGER_LOGINS }}
PR_NUMBER: ${{ github.event.pull_request.number }}
with:
github-token: ${{ env.GH_TOKEN }}
script: |
const pr = parseInt(process.env.PR_NUMBER, 10);
const triggerLogins = JSON.parse(process.env.TRIGGER_LOGINS);
const comments = await github.rest.issues.listComments({
issue_number: pr,
owner: context.repo.owner,
repo: context.repo.repo
});
const alreadyPosted = comments.data.some(c =>
c.body.includes('@claude') && triggerLogins.includes(c.user.login)
);
core.setOutput('already_triggered', alreadyPosted ? 'true' : 'false');
core.setOutput('comments_count', comments.data.length.toString());
const botReviews = comments.data.filter(c =>
['coderabbitai[bot]', 'qodo-code-review[bot]', 'gemini-code-assist[bot]',
'copilot-swe-agent', 'Copilot', 'greptile-apps[bot]'].some(bot =>
c.user.login.toLowerCase().includes(bot.toLowerCase())
)
);
core.setOutput('review_count', botReviews.length.toString());
- name: Aggregate reviews and trigger Claude
if: steps.check_claude.outputs.already_triggered != 'true'
uses: actions/github-script@v7
env:
PR_NUMBER: ${{ github.event.pull_request.number }}
with:
github-token: ${{ env.GH_TOKEN }}
script: |
const pr = parseInt(process.env.PR_NUMBER, 10);
const comments = await github.rest.issues.listComments({issue_number: pr, owner: context.repo.owner, repo: context.repo.repo, per_page: 100});
const reviews = {coderabbit: [], qodo: [], gemini: [], copilot: [], greptile: [], others: []};
comments.data.forEach(c => {
const login = c.user.login.toLowerCase();
const body = c.body.substring(0, 500);
if (login.includes('coderabbit')) reviews.coderabbit.push(body);
else if (login.includes('qodo')) reviews.qodo.push(body);
else if (login.includes('gemini')) reviews.gemini.push(body);
else if (login.includes('copilot')) reviews.copilot.push(body);
else if (login.includes('greptile')) reviews.greptile.push(body);
else if (c.user.type === 'Bot') reviews.others.push(body);
});
const summaryParts = [];
if (reviews.coderabbit.length) summaryParts.push('CodeRabbit: ' + reviews.coderabbit.length + ' comments');
if (reviews.qodo.length) summaryParts.push('Qodo: ' + reviews.qodo.length + ' comments');
if (reviews.gemini.length) summaryParts.push('Gemini: ' + reviews.gemini.length + ' comments');
if (reviews.copilot.length) summaryParts.push('Copilot: ' + reviews.copilot.length + ' comments');
if (reviews.greptile.length) summaryParts.push('Greptile: ' + reviews.greptile.length + ' comments');
const reviewSummary = summaryParts.length ? summaryParts.join(' | ') : 'No bot reviews detected';
const scope = process.env.CLAUDE_UI_SCOPE;
const phases = process.env.CLAUDE_FINAL_PHASES_COMPACT;
const claudeBody = '@claude You are the FINAL architecture reviewer. If the branch is under MervinPraison/PraisonAIUI (not a fork), you are able to make modifications to this branch and push directly. ' + scope + ' Read ALL prior reviewer comments. ' + reviewSummary + '.\n\n' +
'Review Context: ' + comments.data.length + ' total comments, ' +
(reviews.coderabbit.length + reviews.qodo.length + reviews.gemini.length + reviews.copilot.length + reviews.greptile.length) + ' bot reviews.\n\n' +
phases;
await github.rest.issues.createComment({issue_number: pr, owner: context.repo.owner, repo: context.repo.repo, body: claudeBody});
console.log('Claude fallback triggered on PR #' + pr + ' with ' + reviewSummary);
# ================================================================
# Claude FINAL review — polls for Copilot's response, then triggers
# This runs IN-BAND under the PAT context (MervinPraison), avoiding
# the action_required approval gate that blocks bot-triggered workflows.
# ================================================================
claude-after-copilot:
needs: [copilot-after-coderabbit]
if: |
always() &&
needs.copilot-after-coderabbit.result == 'success' &&
needs.copilot-after-coderabbit.outputs.triggered == 'true'
runs-on: ubuntu-latest
timeout-minutes: 15
permissions:
issues: write
pull-requests: write
steps:
- uses: actions/checkout@v4
with:
persist-credentials: false
- name: Wait for Copilot to respond (poll)
id: poll
uses: actions/github-script@v7
env:
PR_NUMBER: ${{ needs.copilot-after-coderabbit.outputs.pr_number }}
MAX_POLL_ATTEMPTS: ${{ env.MAX_POLL_ATTEMPTS }}
POLL_DELAY_MS: ${{ env.POLL_DELAY_MS }}
with:
github-token: ${{ env.GH_TOKEN }}
script: |
const pr = parseInt(process.env.PR_NUMBER, 10);
const owner = context.repo.owner;
const repo = context.repo.repo;
// Poll for Copilot's response — check every 30s for up to 10 minutes
const maxAttempts = parseInt(process.env.MAX_POLL_ATTEMPTS, 10);
const delayMs = parseInt(process.env.POLL_DELAY_MS, 10);
for (let i = 0; i < maxAttempts; i++) {
console.log(`Poll attempt ${i + 1}/${maxAttempts}...`);
const comments = await github.rest.issues.listComments({
issue_number: pr,
owner,
repo,
per_page: 50,
sort: 'created',
direction: 'desc'
});
// Look for Copilot's response (copilot-swe-agent or Copilot)
const copilotResponse = comments.data.find(c =>
(c.user.login === 'copilot-swe-agent' ||
c.user.login === 'Copilot' ||
c.user.login.toLowerCase().includes('copilot')) &&
c.user.type === 'Bot'
);
if (copilotResponse) {
console.log(`Copilot responded at ${copilotResponse.created_at}`);
core.setOutput('copilot_responded', 'true');
return;
}
// Also check PR reviews for Copilot
const reviews = await github.rest.pulls.listReviews({
pull_number: pr,
owner,
repo
});
const copilotReview = reviews.data.find(r =>
r.user.login.toLowerCase().includes('copilot')
);
if (copilotReview) {
console.log(`Copilot review submitted at ${copilotReview.submitted_at}`);
core.setOutput('copilot_responded', 'true');
return;
}
if (i < maxAttempts - 1) {
console.log(`Copilot hasn't responded yet, waiting ${delayMs/1000}s...`);
await new Promise(resolve => setTimeout(resolve, delayMs));
}
}
console.log('Copilot did not respond within timeout, triggering Claude anyway');
core.setOutput('copilot_responded', 'timeout');
- name: Check if Claude already triggered
id: check_claude
uses: actions/github-script@v7
env:
PR_NUMBER: ${{ needs.copilot-after-coderabbit.outputs.pr_number }}
TRIGGER_LOGINS: ${{ env.CLAUDE_TRIGGER_LOGINS }}
with:
github-token: ${{ env.GH_TOKEN }}
script: |
const pr = parseInt(process.env.PR_NUMBER, 10);
const triggerLogins = JSON.parse(process.env.TRIGGER_LOGINS);
const comments = await github.rest.issues.listComments({
issue_number: pr,
owner: context.repo.owner,
repo: context.repo.repo
});
const alreadyPosted = comments.data.some(c =>
c.body.includes('@claude') && triggerLogins.includes(c.user.login)
);
core.setOutput('already_triggered', alreadyPosted ? 'true' : 'false');
- name: Trigger Claude final review
if: steps.check_claude.outputs.already_triggered != 'true'
uses: actions/github-script@v7
env:
PR_NUMBER: ${{ needs.copilot-after-coderabbit.outputs.pr_number }}
with:
github-token: ${{ env.GH_TOKEN }}
script: |
const pr = parseInt(process.env.PR_NUMBER, 10);
const scope = process.env.CLAUDE_UI_SCOPE;
const phases = process.env.CLAUDE_FINAL_PHASES_DETAILED;
await github.rest.issues.createComment({
issue_number: pr,
owner: context.repo.owner,
repo: context.repo.repo,
body: '@claude You are the FINAL architecture reviewer. If the branch is under MervinPraison/PraisonAIUI (not a fork), you are able to make modifications to this branch and push directly. ' + scope + ' Read ALL comments above from Gemini, Qodo, CodeRabbit, and Copilot carefully before responding.\n\n' + phases
});
console.log(`Claude triggered on PR #${pr}`);
- name: Sync pipeline status labels
if: steps.check_claude.outputs.already_triggered != 'true'
uses: actions/github-script@v7
env:
PR_NUMBER: ${{ needs.copilot-after-coderabbit.outputs.pr_number }}
with:
github-token: ${{ env.GH_TOKEN }}
script: |
const path = require('path');
const ps = require(path.join(process.env.GITHUB_WORKSPACE, '.github/scripts/pipeline-status.js'));
const pr = parseInt(process.env.PR_NUMBER, 10);
await ps.ensurePipelineLabels(github, context.repo.owner, context.repo.repo, core);
await ps.syncPipelineLabels(github, context.repo.owner, context.repo.repo, pr, core);
# ================================================================
# Claude after Qodo fallback path (same polling approach)
# ================================================================
claude-after-copilot-qodo:
needs: [copilot-after-qodo]
if: |
always() &&
needs.copilot-after-qodo.result == 'success' &&
needs.copilot-after-qodo.outputs.triggered == 'true'
runs-on: ubuntu-latest
timeout-minutes: 15
permissions:
issues: write
pull-requests: write
steps:
- name: Wait for Copilot to respond (poll)
id: poll
uses: actions/github-script@v7
env:
PR_NUMBER: ${{ needs.copilot-after-qodo.outputs.pr_number }}
MAX_POLL_ATTEMPTS: ${{ env.MAX_POLL_ATTEMPTS }}
POLL_DELAY_MS: ${{ env.POLL_DELAY_MS }}
with:
github-token: ${{ env.GH_TOKEN }}
script: |
const pr = parseInt(process.env.PR_NUMBER, 10);
const owner = context.repo.owner;
const repo = context.repo.repo;
const maxAttempts = parseInt(process.env.MAX_POLL_ATTEMPTS, 10);
const delayMs = parseInt(process.env.POLL_DELAY_MS, 10);
for (let i = 0; i < maxAttempts; i++) {
console.log(`Poll attempt ${i + 1}/${maxAttempts}...`);
const comments = await github.rest.issues.listComments({
issue_number: pr, owner, repo,
per_page: 50, sort: 'created', direction: 'desc'
});
const copilotResponse = comments.data.find(c =>
(c.user.login === 'copilot-swe-agent' ||
c.user.login === 'Copilot' ||
c.user.login.toLowerCase().includes('copilot')) &&
c.user.type === 'Bot'
);
if (copilotResponse) {
console.log(`Copilot responded at ${copilotResponse.created_at}`);
core.setOutput('copilot_responded', 'true');
return;
}
const reviews = await github.rest.pulls.listReviews({
pull_number: pr, owner, repo
});
const copilotReview = reviews.data.find(r =>
r.user.login.toLowerCase().includes('copilot')
);
if (copilotReview) {
console.log(`Copilot review submitted at ${copilotReview.submitted_at}`);
core.setOutput('copilot_responded', 'true');
return;
}
if (i < maxAttempts - 1) {
await new Promise(resolve => setTimeout(resolve, delayMs));
}
}
console.log('Copilot did not respond within timeout, triggering Claude anyway');
core.setOutput('copilot_responded', 'timeout');
- name: Check if Claude already triggered
id: check_claude
uses: actions/github-script@v7
env:
PR_NUMBER: ${{ needs.copilot-after-qodo.outputs.pr_number }}
TRIGGER_LOGINS: ${{ env.CLAUDE_TRIGGER_LOGINS }}
with:
github-token: ${{ env.GH_TOKEN }}
script: |
const pr = parseInt(process.env.PR_NUMBER, 10);
const triggerLogins = JSON.parse(process.env.TRIGGER_LOGINS);
const comments = await github.rest.issues.listComments({
issue_number: pr,
owner: context.repo.owner,
repo: context.repo.repo
});
const alreadyPosted = comments.data.some(c =>
c.body.includes('@claude') && triggerLogins.includes(c.user.login)
);
core.setOutput('already_triggered', alreadyPosted ? 'true' : 'false');
- name: Trigger Claude final review
if: steps.check_claude.outputs.already_triggered != 'true'
uses: actions/github-script@v7
env:
PR_NUMBER: ${{ needs.copilot-after-qodo.outputs.pr_number }}
with:
github-token: ${{ env.GH_TOKEN }}
script: |
const pr = parseInt(process.env.PR_NUMBER, 10);
const scope = process.env.CLAUDE_UI_SCOPE;
const phases = process.env.CLAUDE_FINAL_PHASES_DETAILED;
await github.rest.issues.createComment({
issue_number: pr,
owner: context.repo.owner,
repo: context.repo.repo,
body: '@claude You are the FINAL architecture reviewer. If the branch is under MervinPraison/PraisonAIUI (not a fork), you are able to make modifications to this branch and push directly. ' + scope + ' Read ALL comments above from Gemini, Qodo, CodeRabbit, and Copilot carefully before responding.\n\n' + phases
});
console.log(`Claude triggered on PR #${pr}`);
# ================================================================
# Claude after Gemini — restores the trigger lost from the deleted
# chain-claude-after-copilot.yml workflow.
# Fires when Gemini posts a review comment on a PR or issue.
# ================================================================
claude-after-gemini:
if: |
github.event_name == 'issue_comment' &&
(
github.event.comment.user.login == 'gemini-code-assist[bot]' ||
contains(github.event.comment.body, 'Review completed by Gemini CLI') ||
contains(github.event.comment.body, 'The fix is ready for review. Please test')
)
runs-on: ubuntu-latest
permissions:
issues: write
pull-requests: write
steps:
- name: Check if Claude already triggered
id: check_claude
uses: actions/github-script@v7
env:
TRIGGER_LOGINS: ${{ env.CLAUDE_TRIGGER_LOGINS }}
with:
github-token: ${{ env.GH_TOKEN }}
script: |
const triggerLogins = JSON.parse(process.env.TRIGGER_LOGINS);
const comments = await github.rest.issues.listComments({
issue_number: context.issue.number,
owner: context.repo.owner,
repo: context.repo.repo
});
const alreadyPosted = comments.data.some(c =>
c.body.includes('@claude') && triggerLogins.includes(c.user.login)
);
core.setOutput('already_triggered', alreadyPosted ? 'true' : 'false');
- name: Post Claude analysis request (Lead Engineer)
if: steps.check_claude.outputs.already_triggered != 'true'
uses: actions/github-script@v7
with:
github-token: ${{ env.GH_TOKEN }}
script: |
const scope = process.env.CLAUDE_UI_SCOPE;
const phases = process.env.CLAUDE_FINAL_PHASES_DETAILED;
await github.rest.issues.createComment({
issue_number: context.issue.number,
owner: context.repo.owner,
repo: context.repo.repo,
body: '@claude You are the Lead Engineer. If the branch is under MervinPraison/PraisonAIUI (not a fork), you are able to make modifications to this branch and push directly. ' + scope + ' Read ALL analysis and reviews above carefully (Gemini, CodeRabbit, Qodo, Copilot, etc).\n\n' + phases
});
# ================================================================
# Recovery: re-post FINAL @claude after push or when chain missed
# ================================================================
claude-review-recovery:
if: |
github.event_name == 'pull_request_target' &&
github.event.action == 'synchronize'
runs-on: ubuntu-latest
permissions:
issues: write
pull-requests: read
steps:
- uses: actions/checkout@v4
- name: Re-post FINAL Claude review when stale or missing
uses: actions/github-script@v7
with:
github-token: ${{ env.GH_TOKEN }}
script: |
const path = require('path');
const mergeGate = require(path.join(process.env.GITHUB_WORKSPACE, '.github/scripts/merge-gate.js'));
const chain = require(path.join(process.env.GITHUB_WORKSPACE, '.github/scripts/pr-review-chain.js'));
const pr = context.payload.pull_request.number;
const owner = context.repo.owner;
const repo = context.repo.repo;
const comments = await mergeGate.listAllComments(github, owner, repo, pr);
const headPushedAt = (await mergeGate.getHeadCommitDate(github, owner, repo, pr))
|| context.payload.pull_request.updated_at;
const headPusher = context.payload.sender?.login || null;
const hasFinal = mergeGate.hasFinalClaudeReviewTrigger(comments);
const isStale = mergeGate.isStaleFinalAfterPush(comments, headPushedAt);
if (mergeGate.shouldSkipFinalRecovery(comments, headPushedAt)) {
core.info('Recent @claude within 35min and FINAL not stale — skip recovery');
return;
}
if (await mergeGate.hasInProgressClaudeAssistant(github, owner, repo, pr)) {
core.info('Claude workflow in progress — skip recovery');
return;
}
const finalBody = mergeGate.FINAL_CLAUDE_REVIEW_BODY;
const staleOpts = { allowStaleRepost: true, skipCopilot: true, optionalWaitMs: 0 };
const missingOpts = { allowCopilotTimeout: true, optionalWaitMs: 0 };
if (hasFinal && isStale) {
const gate = mergeGate.shouldSkipStaleFinalRecovery(comments, headPushedAt, headPusher);
if (gate.skip) {
core.info(`Skip stale-FINAL recovery: ${gate.reason}`);
return;
}
const result = await chain.maybeTriggerClaudeFinal(
github, owner, repo, pr, finalBody, core, staleOpts
);
if (result.posted) core.info(`Posted stale-FINAL recovery @claude on PR #${pr}`);
return;
}
if (hasFinal) {
core.info('FINAL Claude review current on HEAD — skip recovery');
return;
}
const result = await chain.maybeTriggerClaudeFinal(
github, owner, repo, pr, finalBody, core, missingOpts
);
if (result.posted) core.info(`Posted missing FINAL Claude review on PR #${pr}`);
- name: Sync pipeline status labels
uses: actions/github-script@v7
with:
github-token: ${{ env.GH_TOKEN }}
script: |
const path = require('path');
const ps = require(path.join(process.env.GITHUB_WORKSPACE, '.github/scripts/pipeline-status.js'));
const pr = context.payload.pull_request.number;
await ps.ensurePipelineLabels(github, context.repo.owner, context.repo.repo, core);
await ps.syncPipelineLabels(github, context.repo.owner, context.repo.repo, pr, core);
# Scheduled + manual: post missing or stale FINAL @claude on all open PRs
post-missing-claude-final:
if: |
github.event_name == 'schedule' ||
(github.event_name == 'workflow_dispatch' && !inputs.pr_number)
runs-on: ubuntu-latest
permissions:
issues: write
pull-requests: read
steps:
- uses: actions/checkout@v4
- name: Post missing/stale FINAL @claude on open PRs
uses: actions/github-script@v7
with:
github-token: ${{ env.GH_TOKEN }}
script: |
const path = require('path');
const mergeGate = require(path.join(process.env.GITHUB_WORKSPACE, '.github/scripts/merge-gate.js'));
const chain = require(path.join(process.env.GITHUB_WORKSPACE, '.github/scripts/pr-review-chain.js'));
const ps = require(path.join(process.env.GITHUB_WORKSPACE, '.github/scripts/pipeline-status.js'));
const owner = context.repo.owner;
const repo = context.repo.repo;
const prs = await github.paginate(github.rest.pulls.list, {
owner, repo, state: 'open', per_page: 100,
});
let posted = 0;
for (const pr of prs) {
if (pr.draft) continue;
const comments = await mergeGate.listAllComments(github, owner, repo, pr.number);
const headPushedAt = (await mergeGate.getHeadCommitDate(github, owner, repo, pr.number)) || pr.updated_at;
const hasFinal = mergeGate.hasFinalClaudeReviewTrigger(comments);
const isStale = mergeGate.isStaleFinalAfterPush(comments, headPushedAt);
if (hasFinal && !isStale) continue;
if (mergeGate.hasRecentClaudeTrigger(comments, 10)) {
core.info(`PR #${pr.number}: @claude within 10min — skip`);
continue;
}
if (await mergeGate.hasInProgressClaudeAssistant(github, owner, repo, pr.number)) {
core.info(`PR #${pr.number}: Claude in progress — skip`);
continue;
}
if (hasFinal && isStale) {
const gate = mergeGate.shouldSkipStaleFinalRecovery(comments, headPushedAt);
if (gate.skip) {
core.info(`PR #${pr.number}: skip stale — ${gate.reason}`);
continue;
}
}
const opts = hasFinal && isStale
? { allowStaleRepost: true, skipCopilot: true, optionalWaitMs: 0 }
: { allowCopilotTimeout: true, optionalWaitMs: 0 };
const result = await chain.maybeTriggerClaudeFinal(
github, owner, repo, pr.number,
mergeGate.FINAL_CLAUDE_REVIEW_BODY, core, opts
);
if (result.posted) {
posted++;
core.info(`Posted Claude FINAL on PR #${pr.number}`);
}
await ps.syncPipelineLabels(github, owner, repo, pr.number, core);
if (posted >= 10) break;
}
core.info(`Missing/stale Claude FINAL sweep complete (${posted} posted)`);
# ================================================================
# BOT PRs: CodeRabbit → Claude directly (skip Copilot when no user PAT)
# Copilot ignores github-actions[bot] comments; bot PRs use this path.
# ================================================================
claude-after-coderabbit-bot-pr:
if: |
github.event_name == 'issue_comment' &&
github.event.issue.pull_request &&
github.event.comment.user.login == 'coderabbitai[bot]' &&
contains(github.event.comment.body, 'summarize by coderabbit')
runs-on: ubuntu-latest
permissions:
issues: write
pull-requests: read
steps:
- name: Trigger Claude final review on bot PR
uses: actions/github-script@v7
env:
TRIGGER_LOGINS: ${{ env.CLAUDE_TRIGGER_LOGINS }}
with:
github-token: ${{ env.GH_TOKEN }}
script: |
const pr = context.issue.number;
const { data: pull } = await github.rest.pulls.get({
owner: context.repo.owner,
repo: context.repo.repo,
pull_number: pr,
});
const author = pull.user.login;
const isBot = author === 'github-actions[bot]' ||
author === 'praisonai-triage-agent[bot]' ||
pull.user.type === 'Bot';
if (!isBot) {
console.log('Human PR — Copilot chain handles Claude trigger');
return;
}
const triggerLogins = JSON.parse(process.env.TRIGGER_LOGINS);
const comments = await github.rest.issues.listComments({
issue_number: pr,
owner: context.repo.owner,
repo: context.repo.repo,
per_page: 100,
});
const alreadyPosted = comments.data.some(c =>
c.body.includes('@claude') && triggerLogins.includes(c.user.login)
);
if (alreadyPosted) {
console.log('Claude already triggered on PR #' + pr);
return;
}
await github.rest.issues.createComment({
issue_number: pr,
owner: context.repo.owner,
repo: context.repo.repo,
body: '@claude You are the FINAL architecture reviewer on this bot-authored PR. Push fixes directly to THIS branch (do NOT open a new PR). ' + process.env.CLAUDE_UI_SCOPE + ' Read ALL CodeRabbit/Qodo comments above.\n\n' + process.env.CLAUDE_FINAL_PHASES_COMPACT
});
console.log('Claude final review triggered on bot PR #' + pr);
# ================================================================
# Manual: trigger Claude final review on any PR
# ================================================================
claude-manual-dispatch:
if: github.event_name == 'workflow_dispatch' && inputs.pr_number
runs-on: ubuntu-latest
permissions:
issues: write
steps:
- name: Trigger Claude final review
uses: actions/github-script@v7
env:
PR_NUMBER: ${{ inputs.pr_number }}
TRIGGER_LOGINS: ${{ env.CLAUDE_TRIGGER_LOGINS }}
with:
github-token: ${{ env.GH_TOKEN }}
script: |
const pr = parseInt(process.env.PR_NUMBER, 10);
const triggerLogins = JSON.parse(process.env.TRIGGER_LOGINS);
const comments = await github.rest.issues.listComments({
issue_number: pr,
owner: context.repo.owner,
repo: context.repo.repo,
per_page: 100,
});
const alreadyPosted = comments.data.some(c =>
c.body.includes('@claude') && triggerLogins.includes(c.user.login)
);
if (alreadyPosted) {
console.log('Claude already triggered on PR #' + pr);
return;
}
await github.rest.issues.createComment({
issue_number: pr,
owner: context.repo.owner,
repo: context.repo.repo,
body: '@claude You are the FINAL architecture reviewer. If the branch is under MervinPraison/PraisonAIUI (not a fork), you are able to make modifications to this branch and push directly. ' + process.env.CLAUDE_UI_SCOPE + ' Read all prior reviewer comments.\n\n' + process.env.CLAUDE_FINAL_PHASES_COMPACT
});
console.log('Claude manually triggered on PR #' + pr);
# ================================================================
# BOT PRs: kick CodeRabbit + Qodo
# ================================================================
bot-pr-trigger-reviews:
if: |
github.event_name == 'pull_request' &&
github.event.action == 'opened' &&
(
github.event.pull_request.user.login == 'github-actions[bot]' ||
github.event.pull_request.user.login == 'praisonai-triage-agent[bot]' ||
github.event.pull_request.user.type == 'Bot'
)
runs-on: ubuntu-latest
permissions:
issues: write
pull-requests: write
steps:
- uses: actions/checkout@v4
with:
persist-credentials: false
- name: Trigger CodeRabbit and Qodo reviews
uses: actions/github-script@v7
with:
github-token: ${{ env.GH_TOKEN }}
script: |
const path = require('path');
const chain = require(path.join(process.env.GITHUB_WORKSPACE, '.github/scripts/bot-pr-review-chain.js'));
const pr = context.payload.pull_request.number;
await chain.kickReviewChain(github, context.repo.owner, context.repo.repo, pr, core);
- name: Sync pipeline status labels
uses: actions/github-script@v7
with:
github-token: ${{ env.GH_TOKEN }}
script: |
const path = require('path');
const ps = require(path.join(process.env.GITHUB_WORKSPACE, '.github/scripts/pipeline-status.js'));
const pr = context.payload.pull_request.number;
await ps.ensurePipelineLabels(github, context.repo.owner, context.repo.repo, core);
await ps.syncPipelineLabels(github, context.repo.owner, context.repo.repo, pr, core);
uses: MervinPraison/github-automation-template/.github/workflows/[email protected]
secrets: inherit
with:
hub_ref: v1.1.10
+23
View File
@@ -0,0 +1,23 @@
name: Bot PR Recovery
on:
schedule:
- cron: '*/15 * * * *'
workflow_dispatch:
inputs:
pr_number:
description: 'Specific PR number to kick (optional)'
required: false
type: string
permissions:
pull-requests: write
issues: write
jobs:
recover:
uses: MervinPraison/github-automation-template/.github/workflows/[email protected]
secrets: inherit
with:
hub_ref: v1.1.10
pr_number: ${{ github.event.inputs.pr_number || '' }}
+6 -76
View File
@@ -1,17 +1,14 @@
name: Auto CI Failure → Claude
# Detects internal PRs with failing CI and posts @claude with extracted failure details.
on:
workflow_run:
workflows: [CI]
workflows: [Core Tests, Optimized Test Suite]
types: [completed]
pull_request:
types: [opened, synchronize, reopened]
workflow_dispatch:
inputs:
pr_number:
description: 'Optional PR number to process (default: scan blocked PRs)'
required: false
type: string
schedule:
@@ -21,10 +18,6 @@ concurrency:
group: ci-failure-scan-${{ github.repository }}-${{ github.event.pull_request.number || github.event.workflow_run.id || github.run_id }}
cancel-in-progress: true
env:
# PAT posts as MervinPraison so issue_comment triggers claude.yml (GITHUB_TOKEN cannot chain workflows)
GH_TOKEN: ${{ secrets.GH_TOKEN || github.token }}
permissions:
pull-requests: read
issues: write
@@ -32,71 +25,8 @@ permissions:
jobs:
detect-and-trigger:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
with:
persist-credentials: false
- name: Detect CI failures and trigger Claude
uses: actions/github-script@v7
with:
github-token: ${{ env.GH_TOKEN }}
script: |
const path = require('path');
const ciFix = require(path.join(process.env.GITHUB_WORKSPACE, '.github/scripts/ci-failure-claude.js'));
const owner = context.repo.owner;
const repo = context.repo.repo;
const baseRepo = `${owner}/${repo}`;
async function processPR(prNumber) {
return ciFix.maybeTriggerCiFixClaude(github, owner, repo, prNumber, core);
}
let results = [];
if (context.eventName === 'workflow_run') {
const wr = context.payload.workflow_run;
const result = await ciFix.processWorkflowRunFailure(
github, owner, repo, wr, core
);
results.push({ event: 'workflow_run', workflow: wr.name, ...result });
} else if (context.eventName === 'pull_request') {
results.push({
pr: context.payload.pull_request.number,
...(await processPR(context.payload.pull_request.number)),
});
} else {
let prNumbers = [];
if (context.eventName === 'workflow_dispatch' && context.payload.inputs?.pr_number) {
prNumbers = [Number(context.payload.inputs.pr_number)];
} else {
const prs = await github.paginate(github.rest.pulls.list, {
owner,
repo,
state: 'open',
per_page: 100,
});
for (const pr of prs) {
if (pr.draft) continue;
const headRepo = pr.head?.repo?.full_name;
if (headRepo && headRepo !== baseRepo) continue;
const { data: issue } = await github.rest.issues.get({
owner,
repo,
issue_number: pr.number,
});
const labels = issue.labels.map((l) => l.name);
if (labels.includes('pipeline/blocked:ci') || labels.includes(ciFix.CI_FIX_LABEL)) {
prNumbers.push(pr.number);
}
}
}
for (const num of prNumbers) {
results.push({ pr: num, ...(await processPR(num)) });
}
}
core.summary.addRaw('```json\n' + JSON.stringify(results, null, 2) + '\n```');
await core.summary.write();
uses: MervinPraison/github-automation-template/.github/workflows/[email protected]
secrets: inherit
with:
hub_ref: v1.1.10
pr_number: ${{ github.event.inputs.pr_number || '' }}
+2
View File
@@ -5,6 +5,7 @@ on:
branches: [main]
pull_request:
branches: [main]
workflow_dispatch:
jobs:
python:
@@ -65,3 +66,4 @@ jobs:
with:
github_token: ${{ secrets.GITHUB_TOKEN }}
publish_dir: ./site
force_orphan: true
+25 -468
View File
@@ -1,46 +1,30 @@
name: Claude PR Merge Gate
# Read-only Claude assesses merge readiness; GH_TOKEN merges (no @claude, no gh pr review).
# Does not overlap with merge-conflict-claude.yml or auto-pr-comment @claude chains.
on:
schedule:
# Scan for merge-ready PRs every 10 minutes (assess runs only when candidates exist).
- cron: '*/10 * * * *'
workflow_dispatch:
inputs:
pr_number:
description: 'Optional PR number (scan all open internal PRs if empty)'
required: false
type: string
merge_method:
description: 'Merge method'
required: false
type: choice
default: merge
options:
- merge
- squash
- rebase
options: [merge, squash, rebase]
workflow_run:
workflows:
- Claude Assistant
- CI
types:
- completed
- Core Tests
types: [completed]
repository_dispatch:
types:
- claude-merge-gate
types: [claude-merge-gate]
concurrency:
group: claude-merge-gate-${{ github.repository }}-${{ github.event.inputs.pr_number || github.event.client_payload.pr_number || github.event.workflow_run.id || 'scan' }}
cancel-in-progress: false
env:
GH_TOKEN: ${{ secrets.GH_TOKEN || github.token }}
MAX_CANDIDATES: 3
DISPATCH_PR_NUMBER: ${{ github.event.inputs.pr_number || github.event.client_payload.pr_number || '' }}
permissions:
contents: read
pull-requests: read
@@ -59,7 +43,9 @@ jobs:
id-token: write
steps:
- uses: actions/checkout@v4
- uses: MervinPraison/github-automation-template/.github/actions/[email protected]
with:
hub_ref: v1.1.10
- name: Generate GitHub App token
id: app-token
uses: actions/create-github-app-token@v1
@@ -67,472 +53,43 @@ jobs:
app-id: ${{ secrets.CLAUDE_APP_ID }}
private-key: ${{ secrets.CLAUDE_APP_PRIVATE_KEY }}
owner: ${{ github.repository_owner }}
- name: Dispatch merge gate when PR pipeline is ready
uses: actions/github-script@v7
with:
github-token: ${{ steps.app-token.outputs.token }}
script: |
const path = require('path');
const mergeGate = require(path.join(process.env.GITHUB_WORKSPACE, '.github/scripts/merge-gate.js'));
const mergeGate = require(path.join(process.env.HUB_SCRIPTS, 'merge-gate.js'));
const wr = context.payload.workflow_run;
const owner = context.repo.owner;
const repo = context.repo.repo;
if (wr.conclusion !== 'success') {
core.info(`Skip dispatch: ${wr.name} conclusion=${wr.conclusion}`);
return;
}
const allowed = ['Claude Assistant', 'CI'];
if (!allowed.includes(wr.name)) {
core.info(`Skip dispatch: workflow ${wr.name}`);
return;
}
if (wr.conclusion !== 'success') return;
const allowed = ['Claude Assistant', 'Core Tests'];
if (!allowed.includes(wr.name)) return;
const prNumber = await mergeGate.resolvePrNumberFromWorkflowRun(github, owner, repo, wr);
if (!prNumber) {
core.info('Skip dispatch: could not resolve PR from workflow run');
return;
}
if (!prNumber) return;
const fromClaude = wr.name === 'Claude Assistant';
const check = await mergeGate.evaluatePipelineQuiescent(
github, owner, repo, prNumber, core, {
skipGlobalClaudeRunCheck: fromClaude,
skipRecentClaudeCooldown: fromClaude,
}
);
if (!check.ready) {
core.info(`PR #${prNumber} not ready: ${check.reasons.join(', ')}`);
return;
}
const check = await mergeGate.evaluatePipelineQuiescent(github, owner, repo, prNumber, core, {
skipGlobalClaudeRunCheck: fromClaude,
skipRecentClaudeCooldown: fromClaude,
});
if (!check.ready) return;
await github.rest.repos.createDispatchEvent({
owner,
repo,
event_type: 'claude-merge-gate',
owner, repo, event_type: 'claude-merge-gate',
client_payload: { pr_number: prNumber },
});
core.info(`Dispatched merge gate for PR #${prNumber} after ${wr.name}`);
scan-candidates:
merge-gate:
if: github.event_name != 'workflow_run'
runs-on: ubuntu-latest
permissions:
contents: read
pull-requests: read
issues: write
actions: read
id-token: write
outputs:
matrix: ${{ steps.scan.outputs.matrix }}
has_candidates: ${{ steps.scan.outputs.has_candidates }}
steps:
- uses: actions/checkout@v4
- name: Generate GitHub App token
id: app-token
uses: actions/create-github-app-token@v1
with:
app-id: ${{ secrets.CLAUDE_APP_ID }}
private-key: ${{ secrets.CLAUDE_APP_PRIVATE_KEY }}
owner: ${{ github.repository_owner }}
- name: Scan open PRs for merge gate eligibility
id: scan
uses: actions/github-script@v7
with:
github-token: ${{ steps.app-token.outputs.token }}
script: |
const path = require('path');
const mergeGate = require(path.join(process.env.GITHUB_WORKSPACE, '.github/scripts/merge-gate.js'));
const ps = require(path.join(process.env.GITHUB_WORKSPACE, '.github/scripts/pipeline-status.js'));
const owner = context.repo.owner;
const repo = context.repo.repo;
const maxCandidates = parseInt(process.env.MAX_CANDIDATES || '5', 10);
const inputPr = String(process.env.DISPATCH_PR_NUMBER || '').trim();
const candidates = [];
const skipped = [];
if (inputPr) {
const n = parseInt(inputPr, 10);
if (Number.isNaN(n)) {
throw new Error(`Invalid pr_number input: ${inputPr}`);
}
const result = await mergeGate.evaluatePipelineQuiescent(github, owner, repo, n, core);
if (result.ready) {
candidates.push({ pr_number: n, head_sha: result.headSha });
} else {
skipped.push({ pr: n, reasons: result.reasons });
}
} else {
const prNumbers = await mergeGate.listPrNumbersForMergeGateScan(
github, owner, repo, core
);
const selected = await mergeGate.selectMergeGateCandidates(
github, owner, repo, prNumbers, maxCandidates, core
);
candidates.push(...selected.candidates);
skipped.push(...selected.skipped);
}
core.info('Skipped: ' + JSON.stringify(skipped));
core.setOutput('matrix', JSON.stringify({ include: candidates }));
core.setOutput('has_candidates', candidates.length > 0 ? 'true' : 'false');
core.summary.addRaw('## Merge gate scan\n');
core.summary.addRaw(`Candidates: ${candidates.length}\n`);
core.summary.addRaw('```json\n' + JSON.stringify({ candidates, skipped }, null, 2) + '\n```');
await core.summary.write();
if (inputPr && skipped.length === 1 && skipped[0].pr === parseInt(inputPr, 10)) {
const reasons = skipped[0].reasons;
const body = [
'**Merge gate scan** — not eligible for auto-merge.',
'',
...reasons.map((r) => `- \`${r}\``),
'',
'**Actions:** wait for CI and the Claude review chain, or add label `needs-manual-review` and merge manually.',
'**Opt out:** label `no-auto-merge`.',
].join('\n');
await github.rest.issues.createComment({
owner, repo, issue_number: skipped[0].pr, body,
});
} else if (inputPr && candidates.length === 1) {
await github.rest.issues.createComment({
owner, repo, issue_number: candidates[0].pr_number,
body: '**Merge gate scan** — eligible for assessment. Claude merge gate will assess and may auto-merge if `MERGE_GATE_VERDICT: APPROVE`.',
});
}
await ps.syncOpenPullRequests(github, owner, repo, { maxPrs: 10 }, core);
claude-assess:
needs: scan-candidates
if: needs.scan-candidates.outputs.has_candidates == 'true'
runs-on: ubuntu-latest
timeout-minutes: 20
strategy:
fail-fast: false
matrix: ${{ fromJson(needs.scan-candidates.outputs.matrix) }}
permissions:
contents: read
pull-requests: read
issues: write
actions: read
id-token: write
steps:
- name: Generate GitHub App Token
id: app-token
uses: actions/create-github-app-token@v1
with:
app-id: ${{ secrets.CLAUDE_APP_ID }}
private-key: ${{ secrets.CLAUDE_APP_PRIVATE_KEY }}
owner: ${{ github.repository_owner }}
- uses: actions/checkout@v4
with:
fetch-depth: 0
token: ${{ steps.app-token.outputs.token }}
- name: Mark merge gate active
id: mark_active
uses: actions/github-script@v7
with:
github-token: ${{ steps.app-token.outputs.token }}
script: |
core.setOutput('started_at', new Date().toISOString());
await github.rest.issues.addLabels({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: ${{ matrix.pr_number }},
labels: ['claude-merge-gate-active'],
});
- name: Stash merge gate helpers
run: |
# Keep scripts together so require('./gate-config.js') works from /tmp
mkdir -p /tmp/merge-gate-scripts /tmp/gh-actions
cp .github/scripts/merge-gate.js .github/scripts/gate-config.js /tmp/merge-gate-scripts/
cp -R .github/actions/claude-code-action /tmp/gh-actions/
- name: Fetch PR branch
env:
PR_NUMBER: ${{ matrix.pr_number }}
run: |
git fetch origin "pull/${PR_NUMBER}/head:pr-${PR_NUMBER}"
git checkout "pr-${PR_NUMBER}"
mkdir -p .github/actions
rm -rf .github/actions/claude-code-action
cp -R /tmp/gh-actions/claude-code-action .github/actions/
- name: Claude merge gate assessment (read-only)
id: assess
uses: ./.github/actions/claude-code-action
env:
GH_TOKEN: ${{ steps.app-token.outputs.token }}
CLAUDE_CODE_SUBAGENT_MODEL: inherit
ANTHROPIC_DEFAULT_SONNET_MODEL: claude-sonnet-4-6
with:
claude_code_oauth_token: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }}
github_token: ${{ steps.app-token.outputs.token }}
model: claude-opus-4-8
trigger_phrase: '@claude-merge-gate-internal'
direct_prompt: |
You are the MERGE GATE reviewer for PraisonAI PR #${{ matrix.pr_number }}.
READ-ONLY: do not modify code, commit, push, rebase, or create branches.
CRITICAL OUTPUT RULE:
Post exactly one PR comment via GitHub MCP containing either:
MERGE_GATE_VERDICT: APPROVE
or
MERGE_GATE_VERDICT: BLOCK
followed by a short rationale on the next lines.
NEVER include @claude in any comment. NEVER mention merge conflict resolution.
Review checklist:
1. Read AGENTS.md and the PR diff on the current branch.
2. Read all PR comments and reviews (FINAL architecture reviewer / Lead Engineer must have run).
3. Confirm SDK value gate: change strengthens praisonaiagents (no scope creep).
4. Do not bloat the Agent class with additional params — only if absolutely required; we already support many params.
5. If agent.py adds >100 lines or new Agent __init__ params, respond MERGE_GATE_VERDICT: BLOCK (manual review required).
6. BLOCK if labels security/breaking-change/needs-manual-review/release, sensitive paths (.github/workflows, auth, pyproject.toml), secrets in diff, PR >800 lines or >30 files, or SDK changes without tests. Label `merge-gate-ci-only` exempts CI-only changes under `.github/workflows/`, `.github/actions/`, or merge-gate scripts.
7. Confirm no CHANGES_REQUESTED reviews and CI green on HEAD ${{ matrix.head_sha }}.
8. If ready to merge: MERGE_GATE_VERDICT: APPROVE. Otherwise: MERGE_GATE_VERDICT: BLOCK with reasons.
Do not approve or merge via GitHub UI/API — only post the verdict comment.
disallowed_tools: |
Bash
Edit
Write
Replace
allowed_tools: |
View
GlobTool
GrepTool
Read
timeout_minutes: 15
- name: Post fallback BLOCK if Opus did not comment
id: fallback_verdict
if: steps.assess.outcome != 'success'
uses: actions/github-script@v7
with:
github-token: ${{ steps.app-token.outputs.token }}
script: |
const mergeGate = require('/tmp/merge-gate-scripts/merge-gate.js');
const owner = context.repo.owner;
const repo = context.repo.repo;
const prNumber = ${{ matrix.pr_number }};
const ctx = await mergeGate.loadPrContext(github, owner, repo, prNumber);
const minCreatedAt = new Date(Date.now() - 25 * 60 * 1000).toISOString();
const existing = mergeGate.findMergeGateVerdict(
ctx.comments, minCreatedAt, ctx.headPushedAt, { excludeAutomatedFallback: true }
);
if (existing) {
core.info(`Opus verdict already posted: ${existing}`);
return;
}
const check = await mergeGate.evaluatePipelineQuiescent(
github, owner, repo, prNumber, core, { forMergeStep: true }
);
const lines = [
'MERGE_GATE_VERDICT: BLOCK',
'',
'Automated fallback — Opus merge gate assessment did not complete.',
];
if (check.ready) {
lines.push('Deterministic gates passed, but Opus MERGE_GATE_VERDICT is required before merge.');
} else {
lines.push('Blockers:', ...check.reasons.map((r) => `- ${r}`));
}
await github.rest.issues.createComment({
owner, repo, issue_number: prNumber,
body: lines.join('\n'),
});
core.info('Posted fallback BLOCK — Opus verdict required');
- name: Require Opus MERGE_GATE_VERDICT
uses: actions/github-script@v7
with:
github-token: ${{ steps.app-token.outputs.token }}
script: |
const mergeGate = require('/tmp/merge-gate-scripts/merge-gate.js');
const comments = await mergeGate.listAllComments(
github, context.repo.owner, context.repo.repo, ${{ matrix.pr_number }}
);
const ctx = await mergeGate.loadPrContext(
github, context.repo.owner, context.repo.repo, ${{ matrix.pr_number }}
);
const minCreatedAt = new Date(Date.now() - 25 * 60 * 1000).toISOString();
const verdict = mergeGate.findMergeGateVerdict(
comments, minCreatedAt, ctx.headPushedAt, { excludeAutomatedFallback: true }
);
if (!verdict) {
core.setFailed(
'Opus merge gate assessment must post MERGE_GATE_VERDICT on this PR (automated fallback APPROVE is not accepted).'
);
return;
}
core.info(`Opus merge gate verdict: ${verdict}`);
core.setOutput('verdict', verdict);
- name: Remove merge gate active label
if: always()
uses: actions/github-script@v7
with:
github-token: ${{ steps.app-token.outputs.token }}
script: |
const prNumber = ${{ matrix.pr_number }};
try {
await github.rest.issues.removeLabel({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: prNumber,
name: 'claude-merge-gate-active',
});
core.info(`Removed claude-merge-gate-active from PR #${prNumber}`);
} catch (e) {
core.info(`Label claude-merge-gate-active already removed or missing on PR #${prNumber}`);
}
- name: Fail job if assessment failed
if: steps.assess.outcome == 'failure'
run: exit 1
merge-only:
needs: [scan-candidates, claude-assess]
if: |
always() &&
needs.scan-candidates.outputs.has_candidates == 'true' &&
needs.claude-assess.result == 'success'
runs-on: ubuntu-latest
strategy:
fail-fast: false
matrix: ${{ fromJson(needs.scan-candidates.outputs.matrix) }}
permissions:
contents: write
pull-requests: write
issues: write
actions: read
id-token: write
steps:
- uses: actions/checkout@v4
- name: Generate GitHub App token
id: app-token
uses: actions/create-github-app-token@v1
with:
app-id: ${{ secrets.CLAUDE_APP_ID }}
private-key: ${{ secrets.CLAUDE_APP_PRIVATE_KEY }}
owner: ${{ github.repository_owner }}
- name: Re-check gates and merge
uses: actions/github-script@v7
env:
MERGE_METHOD: ${{ github.event.inputs.merge_method || 'merge' }}
SCAN_HEAD_SHA: ${{ matrix.head_sha }}
with:
github-token: ${{ steps.app-token.outputs.token }}
script: |
const path = require('path');
const mergeGate = require(path.join(process.env.GITHUB_WORKSPACE, '.github/scripts/merge-gate.js'));
const owner = context.repo.owner;
const repo = context.repo.repo;
const prNumber = ${{ matrix.pr_number }};
const scanHeadSha = process.env.SCAN_HEAD_SHA;
const removeLabels = async () => {
try {
await github.rest.issues.removeLabel({
owner, repo, issue_number: prNumber, name: 'claude-merge-gate-active',
});
} catch (e) {
core.info('Label claude-merge-gate-active already removed or missing');
}
};
try {
const ctx = await mergeGate.loadPrContext(github, owner, repo, prNumber);
const comments = ctx.comments;
const verdictMin = new Date(Date.now() - 25 * 60 * 1000).toISOString();
const verdict = mergeGate.findMergeGateVerdict(
comments, verdictMin, ctx.headPushedAt, { excludeAutomatedFallback: true }
);
if (verdict !== 'APPROVE') {
core.info(`Skip merge: verdict=${verdict || 'NONE'}`);
await removeLabels();
return;
}
const check = await mergeGate.evaluatePipelineQuiescent(
github, owner, repo, prNumber, core, { forMergeStep: true }
);
if (!check.ready) {
core.info(`Skip merge: ${check.reasons.join(', ')}`);
await removeLabels();
return;
}
if (check.headSha !== scanHeadSha) {
core.info(`Skip merge: HEAD moved ${scanHeadSha.slice(0, 7)} → ${check.headSha.slice(0, 7)}`);
await removeLabels();
return;
}
const method = process.env.MERGE_METHOD || 'merge';
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
let merged = false;
for (let attempt = 1; attempt <= 3; attempt++) {
try {
await github.rest.pulls.merge({
owner, repo, pull_number: prNumber, merge_method: method,
});
merged = true;
break;
} catch (e) {
const msg = (e.message || '').toLowerCase();
if (attempt < 3 && msg.includes('base branch was modified')) {
core.info(`Merge attempt ${attempt} failed (base moved), retrying in 8s...`);
await sleep(8000);
const recheck = await mergeGate.evaluatePipelineQuiescent(
github, owner, repo, prNumber, core, { forMergeStep: true }
);
if (!recheck.ready) {
core.info(`Skip merge retry: ${recheck.reasons.join(', ')}`);
await removeLabels();
return;
}
continue;
}
throw e;
}
}
if (!merged) {
core.info('Skip merge: exhausted retries');
await removeLabels();
return;
}
await github.rest.issues.addLabels({
owner, repo, issue_number: prNumber, labels: ['auto-merged-by-gate'],
});
await github.rest.issues.createComment({
owner, repo, issue_number: prNumber,
body: [
'Merged by **Claude PR merge gate** (`claude-merge-gate.yml`).',
`Verdict: MERGE_GATE_VERDICT: APPROVE`,
`SHA: \`${check.headSha.slice(0, 7)}\``,
`Method: ${method}`,
].join('\n'),
});
core.info(`Merged PR #${prNumber}`);
} finally {
await removeLabels();
}
uses: MervinPraison/github-automation-template/.github/workflows/[email protected]
secrets: inherit
with:
hub_ref: v1.1.10
pr_number: ${{ github.event.inputs.pr_number || github.event.client_payload.pr_number || '' }}
merge_method: ${{ github.event.inputs.merge_method || 'merge' }}
+6 -525
View File
@@ -1,543 +1,24 @@
name: Claude Assistant
# Triggers:
# - issues:opened → lightweight triage (labels + auto-claude for owner)
# - issues:labeled → full implementation when `claude` label applied
# - issues:assigned → full implementation when issue assigned to Claude
# - issue_comment → full implementation when @claude mentioned (issues + PRs)
# - pull_request_review → full implementation when @claude mentioned in review
# - pull_request_review_comment → full implementation when @claude mentioned inline
#
# Escalate an issue with the `claude` label OR an @claude comment — not both.
# If both fire within 3 minutes, the comment trigger is skipped (dedupe job).
#
# Focus: CLAUDE only. Copilot and CodeRabbit are GitHub Apps that react to PRs
# automatically — no workflow file needed. auto-pr-comment.yml orchestrates the
# CodeRabbit → Copilot → Claude review chain for PRs.
on:
issue_comment:
types: [created]
pull_request_review_comment:
types: [created]
issues:
types: [opened, labeled, assigned]
types: [assigned, labeled, opened]
pull_request_review:
types: [submitted]
workflow_dispatch:
inputs:
issue_number:
description: 'Issue number for Claude implementation'
required: true
type: string
jobs:
# ================================================================
# Job 1 — Issue triage (runs on ANY new issue, including external users)
# Posts welcome comment + triage labels. Auto-adds `claude` for owner issues.
# ================================================================
issue-triage:
if: |
github.event_name == 'issues' &&
github.event.action == 'opened' &&
!contains(toJSON(github.event.issue.labels), 'claude')
runs-on: ubuntu-latest
permissions:
issues: write
contents: read
steps:
- name: Generate GitHub App Token
id: app-token
continue-on-error: true
uses: actions/create-github-app-token@v1
with:
app-id: ${{ secrets.CLAUDE_APP_ID }}
private-key: ${{ secrets.CLAUDE_APP_PRIVATE_KEY }}
owner: ${{ github.repository_owner }}
- name: Resolve GitHub token
id: github-token
run: |
if [ -n "${{ steps.app-token.outputs.token }}" ]; then
echo "token=${{ steps.app-token.outputs.token }}" >> "$GITHUB_OUTPUT"
else
echo "token=${{ secrets.GITHUB_TOKEN }}" >> "$GITHUB_OUTPUT"
fi
- name: Triage and acknowledge issue
uses: actions/github-script@v7
with:
github-token: ${{ steps.github-token.outputs.token }}
script: |
const issue = context.payload.issue;
const title = (issue.title || '').toLowerCase();
const body = (issue.body || '').toLowerCase();
const content = title + ' ' + body;
const isOwner = ['OWNER', 'MEMBER', 'COLLABORATOR'].includes(
issue.author_association
);
const labels = [];
if (content.includes('feature request') || content.includes('[feature')) {
labels.push('enhancement');
} else if (content.includes('bug') || content.includes('error') || content.includes('crash') || content.includes('traceback')) {
labels.push('bug');
} else if (content.includes('question') || content.includes('how do i') || content.includes('how to')) {
labels.push('question');
}
if (content.includes('security') || content.includes('cve') || content.includes('vulnerability')) {
labels.push('security');
}
if (content.includes('performance') || content.includes('slow') || content.includes('memory leak')) {
labels.push('performance');
}
if (content.includes('documentation') || content.includes('docs')) {
labels.push('documentation');
}
if (content.includes('typescript') || content.includes('javascript') || content.includes('npm')) {
labels.push('javascript');
}
if (content.includes('frontend') || content.includes('react') || content.includes('vite')) {
labels.push('frontend');
}
if (content.includes('example') || content.includes('yaml')) {
labels.push('examples');
}
if (labels.length > 0) {
await github.rest.issues.addLabels({
issue_number: issue.number,
owner: context.repo.owner,
repo: context.repo.repo,
labels
});
}
if (!isOwner) {
await github.rest.issues.createComment({
issue_number: issue.number,
owner: context.repo.owner,
repo: context.repo.repo,
body: [
`👋 Thanks for opening this issue, @${issue.user.login}!`,
'',
'A maintainer will review this shortly. In the meantime:',
'- Make sure you\'ve included steps to reproduce (for bugs)',
'- Check [existing issues](https://github.com/MervinPraison/PraisonAIUI/issues) for duplicates',
'- Review [AGENTS.md](https://github.com/MervinPraison/PraisonAIUI/blob/main/AGENTS.md) for project conventions',
'',
'_A maintainer can trigger deeper analysis by commenting `@claude` on this issue._'
].join('\n')
});
}
if (isOwner) {
await github.rest.issues.addLabels({
issue_number: issue.number,
owner: context.repo.owner,
repo: context.repo.repo,
labels: ['claude']
});
}
# ================================================================
# Job 2 — Full Claude Code implementation
# Triggers on: labeled `claude`, @claude comments, assignments
#
# Dedup: label + @claude comment on the same issue fires two events.
# Skip the comment trigger when `claude` was labelled within 3 minutes.
# ================================================================
claude-dedupe:
if: |
github.event_name == 'workflow_dispatch' ||
(
github.event.action != 'opened' &&
(github.event.action != 'labeled' || github.event.label.name == 'claude') &&
(github.event_name != 'issue_comment' || contains(github.event.comment.body, '@claude')) &&
(
!contains(github.actor, '[bot]') ||
github.actor == 'github-actions[bot]' ||
github.actor == 'praisonai-triage-agent[bot]'
) &&
github.actor != 'dependabot[bot]' &&
github.actor != 'cursor[bot]' &&
github.actor != 'renovate[bot]'
)
runs-on: ubuntu-latest
outputs:
proceed: ${{ steps.check.outputs.proceed }}
permissions:
issues: read
pull-requests: read
steps:
- name: Skip duplicate comment after fresh claude label
id: check
uses: actions/github-script@v7
with:
script: |
if (context.eventName === 'workflow_dispatch') {
core.setOutput('proceed', 'true');
return;
}
if (context.eventName !== 'issue_comment' || context.payload.issue?.pull_request) {
core.setOutput('proceed', 'true');
return;
}
const issue = context.payload.issue;
const hasClaude = (issue.labels || []).some(l => l.name === 'claude');
if (!hasClaude) {
core.setOutput('proceed', 'true');
return;
}
const events = await github.rest.issues.listEvents({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: issue.number,
per_page: 30,
});
const windowMs = 3 * 60 * 1000;
const recentLabel = events.data.find(e =>
e.event === 'labeled' &&
e.label?.name === 'claude' &&
(Date.now() - new Date(e.created_at).getTime()) < windowMs
);
if (recentLabel) {
core.info('Skipping: claude label was added within 3 minutes — label trigger already handles this issue.');
core.setOutput('proceed', 'false');
return;
}
core.setOutput('proceed', 'true');
claude-response:
needs: claude-dedupe
env:
ISSUE_NUMBER_RESOLVED: ${{ github.event.issue.number || github.event.pull_request.number || inputs.issue_number }}
if: needs.claude-dedupe.outputs.proceed == 'true'
concurrency:
group: claude-${{ github.event.issue.number || github.event.pull_request.number || inputs.issue_number || github.run_id }}
cancel-in-progress: true
runs-on: ubuntu-latest
claude:
permissions:
contents: write
pull-requests: write
issues: write
actions: read
id-token: write
steps:
- name: Generate GitHub App Token
id: app-token
continue-on-error: true
uses: actions/create-github-app-token@v1
with:
app-id: ${{ secrets.CLAUDE_APP_ID }}
private-key: ${{ secrets.CLAUDE_APP_PRIVATE_KEY }}
owner: ${{ github.repository_owner }}
- name: Resolve GitHub token
id: github-token
run: |
if [ -n "${{ steps.app-token.outputs.token }}" ]; then
echo "token=${{ steps.app-token.outputs.token }}" >> "$GITHUB_OUTPUT"
else
echo "token=${{ secrets.GITHUB_TOKEN }}" >> "$GITHUB_OUTPUT"
fi
- name: Check for Fork PR
id: check_fork
uses: actions/github-script@v7
with:
github-token: ${{ steps.github-token.outputs.token }}
script: |
const isPR = context.eventName === 'pull_request' ||
context.eventName === 'pull_request_review' ||
context.eventName === 'pull_request_review_comment' ||
(context.payload.issue && context.payload.issue.pull_request);
if (isPR) {
core.setOutput('is_pr_review', 'true');
const prNumber = context.payload.pull_request
? context.payload.pull_request.number
: context.payload.issue.number;
core.setOutput('pr_number', String(prNumber));
const pr = await github.rest.pulls.get({
owner: context.repo.owner,
repo: context.repo.repo,
pull_number: prNumber,
});
core.setOutput('pr_branch', pr.data.head.ref);
const headRepo = pr.data.head.repo?.full_name || context.repo.full_name;
core.setOutput('head_repo', headRepo);
let mergeState = '';
try {
const gql = await github.graphql(
`query($owner: String!, $repo: String!, $number: Int!) {
repository(owner: $owner, name: $repo) {
pullRequest(number: $number) { mergeStateStatus }
}
}`,
{ owner: context.repo.owner, repo: context.repo.repo, number: prNumber }
);
mergeState = (gql.repository.pullRequest.mergeStateStatus || '').toUpperCase();
} catch (e) {
core.warning(`GraphQL mergeStateStatus failed: ${e.message}`);
}
if (!mergeState) {
mergeState = (pr.data.mergeable_state || pr.data.merge_state_status || '').toUpperCase();
}
core.setOutput('merge_state', mergeState);
if (headRepo !== `${context.repo.owner}/${context.repo.repo}`) {
core.setOutput('is_fork', 'true');
core.setOutput('clone_url', pr.data.head.repo.clone_url);
core.setOutput('branch', pr.data.head.ref);
return;
}
core.setOutput('is_fork', 'false');
return;
}
core.setOutput('is_pr_review', 'false');
core.setOutput('is_fork', 'false');
core.setOutput('merge_state', '');
- name: Checkout repository
uses: actions/checkout@v4
with:
persist-credentials: false
fetch-depth: 0
token: ${{ steps.github-token.outputs.token }}
ref: ${{ steps.check_fork.outputs.is_pr_review == 'true' && format('refs/pull/{0}/head', steps.check_fork.outputs.pr_number) || github.ref }}
- name: Checkout PR branch for edits
if: steps.check_fork.outputs.is_pr_review == 'true' && steps.check_fork.outputs.is_fork != 'true'
env:
PR_BRANCH: ${{ steps.check_fork.outputs.pr_branch }}
run: |
git checkout "${PR_BRANCH}"
git config --local user.name "MervinPraison"
git config --local user.email "[email protected]"
echo "On branch: $(git branch --show-current)"
- name: Fetch PR branch and Setup Remote
if: steps.check_fork.outputs.is_pr_review == 'true' && steps.check_fork.outputs.is_fork == 'true'
env:
PR_BRANCH: ${{ steps.check_fork.outputs.pr_branch }}
run: |
git fetch origin pull/${{ steps.check_fork.outputs.pr_number }}/head:"${PR_BRANCH}"
git checkout "${PR_BRANCH}"
git remote set-url origin file://$(pwd)
- name: Run Claude PR final review
if: steps.check_fork.outputs.is_pr_review == 'true'
uses: anthropics/claude-code-action@beta
env:
GH_TOKEN: ${{ steps.github-token.outputs.token }}
with:
allowed_bots: 'praisonai-triage-agent[bot]'
claude_code_oauth_token: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }}
github_token: ${{ steps.github-token.outputs.token }}
model: claude-opus-4-8
trigger_phrase: "@claude"
timeout_minutes: "30"
direct_prompt: |
${{ steps.check_fork.outputs.is_fork == 'true' && 'CRITICAL: FORK PR — comment only, do not push.\n\n' || 'PR on MervinPraison/PraisonAIUI — SAME REPO, NOT A FORK. You HAVE push permission to this branch. NEVER say this is a fork.\n\n' }}
${{ steps.check_fork.outputs.is_fork != 'true' && steps.check_fork.outputs.merge_state == 'DIRTY' && format('PRIORITY — RESOLVE MERGE CONFLICTS (branch: {0}, PR #{1}):\n- DO NOT create a new branch or PR\n- git fetch origin main && git rebase origin/main\n- Resolve conflicts: keep this PR intent, merge in main newer logic\n- git add -A && git rebase --continue (repeat until done)\n- git push --force-with-lease origin {0}\n- Run targeted pytest/ruff on changed paths\n- Comment files resolved and what you skipped\n- Only after conflicts are resolved, proceed with review phases below if still needed\n\n', steps.check_fork.outputs.pr_branch, steps.check_fork.outputs.pr_number) || '' }}
${{ steps.check_fork.outputs.merge_state != 'DIRTY' && format('CONTEXT: PR #{0} on branch {1}. merge_state={2}.\n\n', steps.check_fork.outputs.pr_number, steps.check_fork.outputs.pr_branch, steps.check_fork.outputs.merge_state) || '' }}
You are the Lead Engineer doing FINAL review on this open PR in MervinPraison/PraisonAIUI.
Read ALL reviewer comments (CodeRabbit, Gemini, Qodo, Copilot, human). Follow AGENTS.md.
SCOPE: src/praisonaiui, src/frontend, tests, docs only.
**Phase 1 — Review** architecture, security, tests, backward compat, import-time budget.
**Phase 2 — FIX (mandatory)** implement every VALID issue on THIS branch. Push commits before Phase 3.
Do NOT ask the author to fix. Do NOT open a new PR. Do NOT claim fork restrictions unless is_fork is true.
**Phase 3 — Verdict** approve only after fixes are pushed and tests pass; else request changes with specifics.
git config --global user.name "MervinPraison"
git config --global user.email "[email protected]"
gh auth setup-git
git status
git branch --show-current
allowed_tools: |
Bash(git:*)
Bash(python:*)
Bash(pip:*)
Bash(pytest:*)
Bash(ruff:*)
Bash(gh:*)
Bash(npm:*)
View
GlobTool
GrepTool
BatchTool
Edit
Replace
- name: Run Claude Code Action
if: steps.check_fork.outputs.is_pr_review != 'true'
uses: anthropics/claude-code-action@beta
env:
GH_TOKEN: ${{ steps.github-token.outputs.token }}
with:
allowed_bots: 'praisonai-triage-agent[bot]'
claude_code_oauth_token: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }}
github_token: ${{ steps.github-token.outputs.token }}
model: claude-opus-4-8
mode: ${{ github.event_name == 'workflow_dispatch' && 'agent' || 'tag' }}
trigger_phrase: "@claude"
label_trigger: "claude"
timeout_minutes: "30"
direct_prompt: |
${{ steps.check_fork.outputs.is_fork == 'true' && 'CRITICAL: THIS IS A PULL REQUEST FROM A FORK. YOU DO NOT HAVE PUSH PERMISSIONS TO THIS REPOSITORY. ONLY READ FILES, VALIDATE CODE, AND PROVIDE YOUR COMMENTS/FEEDBACK DIRECTLY IN THIS PR VIA COMMENTS. DO NOT ATTEMPT TO PUSH OR CREATE PULL REQUESTS.\n\n' || 'NOTE: The branch is under MervinPraison/PraisonAIUI (not a fork). You are able to make modifications and push directly to this branch.\n\n' }}
You are implementing changes in **MervinPraison/PraisonAIUI** (YAML-driven server-driven UI with agentic chat).
Follow `AGENTS.md` at the repository root. If a conflict arises between this prompt and `AGENTS.md`, `AGENTS.md` wins.
═══════════════════════════════════════════════════════════════
STEP 0 — GIT & GITHUB CLI
═══════════════════════════════════════════════════════════════
git config --global user.name "MervinPraison"
git config --global user.email "[email protected]"
gh auth setup-git
═══════════════════════════════════════════════════════════════
STEP 1 — READ GUIDELINES
═══════════════════════════════════════════════════════════════
Read `AGENTS.md` and any `docs/principles.md`. Key invariants:
• Cold import of `praisonaiui` must stay under 200 ms; module count must not grow.
• Heavy / optional deps (`mcp`, `langchain`, `llama_index`, `semantic_kernel`,
`slack_sdk`, `discord.py`, `botbuilder.core`, `openai`, `anthropic`, `mistralai`,
`google.generativeai`) must be **lazy-imported** — never eager.
• Public API is verb-first. New classes in `src/praisonaiui/message.py` ending
in `Message` with a verb prefix (Ask*, Do*, Request*, …) are forbidden —
use a module-level `async def` verb helper instead (see `prompt`, `error`).
• Deprecations must emit a runtime `warnings.warn(..., DeprecationWarning, stacklevel=2)`
on first access, not only in the docstring.
• Deterministic serialisation in anything reaching the wire (sorted keys, stable field
order). See existing `to_dict` patterns.
• Multi-agent + async safe: no global mutable state without a lock.
═══════════════════════════════════════════════════════════════
STEP 2 — ARCHITECTURE VALIDATION & ROUTING (MANDATORY before any code)
═══════════════════════════════════════════════════════════════
Answer these questions inline in your response, then proceed only if you pass:
ROUTING:
1. **Core aiui** (`src/praisonaiui/`) — protocols, message types, server, features.
2. **Features** (`src/praisonaiui/features/`) — opt-in capabilities (MCP, platforms, lifecycle, audio, auth, sharing, etc.).
3. **UI builders** (`src/praisonaiui/ui.py`) — declarative component helpers.
4. **Frontend** (`src/frontend/`) — React components / stores / SSE handlers.
5. **Tests** (`tests/unit/`, `tests/integration/`) — new features require both smoke and real-agentic tests.
6. **Docs** (`docs/`) — canonical name per feature, migration table for renames.
EXTERNAL REPOS (do NOT edit from within this repo — route the work to the right place and stop):
• Core SDK: `MervinPraison/PraisonAI` (`praisonaiagents`, `praisonai`) — agent runtime.
• Tools: `MervinPraison/PraisonAI-Tools` — tool implementations.
• Docs site: `MervinPraison/PraisonAIDocs` — product docs.
If the requested change belongs in an external repo, comment on the issue explaining
where it should live and DO NOT open a PR in this repo.
CONCEPTUAL CHECKS (if ANY fails — add a comment, do NOT open a PR):
• Does it duplicate an existing public symbol in `src/praisonaiui/__init__.py`?
• Does it add new class-first message types (`class Ask*Message`, `class *Message`
with a verb in the name)? → Use a verb-first helper instead.
• Does it add a new hard dependency to `pyproject.toml` `[project] dependencies`?
→ Move to `[project.optional-dependencies]` and wrap imports lazily.
• Does it break backward compat (rename, signature change, removal)? → Add
a deprecation alias that emits `DeprecationWarning`; never hard-remove before the
documented removal version (e.g. `0.5.0`).
═══════════════════════════════════════════════════════════════
STEP 3 — IMPLEMENT
═══════════════════════════════════════════════════════════════
Single branch for the whole flow (reuse the same name in STEP 5):
BRANCH="claude/issue-${{ env.ISSUE_NUMBER_RESOLVED }}-$(date +%Y%m%d-%H%M%S)"
git checkout -b "$BRANCH"
Make minimal, focused changes:
• Stay within the files listed in the issue's "Scope" / "Files to change" section.
• Prefer extending existing modules over adding new ones.
• No emojis, no comments, no markdown except where the user asked for it.
• Python ≥ 3.10 style (`list[...]` not `List[...]`, `X | None` not `Optional[X]`).
═══════════════════════════════════════════════════════════════
STEP 4 — TEST / VERIFY
═══════════════════════════════════════════════════════════════
Run these targeted checks (NOT the full suite):
# 1. Lint on only the files you changed:
CHANGED=$(git diff --name-only origin/main...HEAD | grep -E '\.py$' | tr '\n' ' ')
[ -n "$CHANGED" ] && ruff check $CHANGED
# 2. Unit tests for the module(s) you touched:
pytest tests/unit/<your_new_test_file>.py -v --tb=short
# 3. Import-time invariant (MUST stay under 200 ms cold):
python -c "import time, sys; t=time.time(); import praisonaiui; dt=(time.time()-t)*1000; heavy=[m for m in sys.modules if any(h in m for h in ['langchain','llama_index','mcp','slack','discord','botbuilder','openai.','anthropic.','mistralai','google.generativeai'])]; print(f'{dt:.1f}ms modules={len(sys.modules)} leaked={heavy}'); assert heavy == [], f'Heavy deps leaked: {heavy}'"
If anything fails, fix the root cause (never skip, xfail, or delete the test).
═══════════════════════════════════════════════════════════════
STEP 5 — COMMIT, PUSH, OPEN PR (MANDATORY if you produced commits)
═══════════════════════════════════════════════════════════════
git add -A
git commit -m "feat: <short summary> (fixes #${{ env.ISSUE_NUMBER_RESOLVED }})"
git push -u origin "$BRANCH"
REPO="${{ github.repository }}"
COUNT=$(gh pr list --repo "$REPO" --head "$BRANCH" --json number --jq 'length')
if [ "$COUNT" -gt 0 ]; then
gh pr list --repo "$REPO" --head "$BRANCH" --json url --jq '.[0].url'
else
gh pr create --repo "$REPO" --base main --head "$BRANCH" \
--title "feat: <short title>" \
--body "Fixes #${{ env.ISSUE_NUMBER_RESOLVED }}
## Summary
<what changed in one paragraph>
## Acceptance criteria
<copy from the issue; tick boxes only with commit SHA + file path evidence>
## Test evidence
\`\`\`
<paste pytest output>
\`\`\`
"
fi
CRITICAL: Do NOT stop after `git push`. You MUST complete `gh pr create` (or print the
existing PR URL from the step above) and post that URL on the issue. Never ask a
human to open the PR manually.
═══════════════════════════════════════════════════════════════
STEP 6 — RESPOND TO REVIEW FEEDBACK (if this run was triggered by a review comment)
═══════════════════════════════════════════════════════════════
If you were invoked by a PR review comment, you are the Lead Engineer for this PR:
• Read ALL reviewer comments in chronological order (CodeRabbit → Copilot → human).
• Fix every VALID issue by pushing commits to THIS branch (do NOT open a new PR).
• Reply with a summary of which comments you addressed, which you disagreed with (and why).
• Never ship a fix that removes/weakens a test.
allowed_tools: |
Bash(git:*)
Bash(python:*)
Bash(pip:*)
Bash(pytest:*)
Bash(ruff:*)
Bash(gh:*)
Bash(npm:*)
Bash(npx:*)
Bash(python -m pytest:*)
Bash(python -m pip:*)
Bash(python -m ruff:*)
View
GlobTool
GrepTool
BatchTool
Edit
Replace
mcp__github__get_issue
mcp__github__get_issue_comments
mcp__github__update_issue
uses: MervinPraison/github-automation-template/.github/workflows/[email protected]
secrets: inherit
with:
hub_ref: v1.1.10
+41
View File
@@ -0,0 +1,41 @@
name: Deploy Docs
on:
push:
branches: [main]
workflow_dispatch:
permissions:
contents: write
pages: write
id-token: write
concurrency:
group: deploy-docs
cancel-in-progress: true
jobs:
deploy:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Set up Python
uses: actions/setup-python@v5
with:
python-version: "3.11"
- name: Install AIUI
run: pip install -e .
- name: Build docs with AIUI
run: |
aiui build --output site --minify
cp CNAME site/CNAME
- name: Deploy to GitHub Pages
uses: peaceiris/actions-gh-pages@v4
with:
github_token: ${{ secrets.GITHUB_TOKEN }}
publish_dir: ./site
force_orphan: true
+18 -149
View File
@@ -1,7 +1,5 @@
name: Auto Merge Conflict → Claude
# Detects internal PRs with mergeStateStatus=DIRTY and posts @claude to trigger claude.yml.
on:
push:
branches: [main]
@@ -12,11 +10,10 @@ on:
- cron: '0 */6 * * *'
concurrency:
group: merge-conflict-scan-${{ github.repository }}
group: merge-conflict-scan-${{ github.repository }}-${{ github.event.pull_request.number || github.run_id }}
cancel-in-progress: true
env:
# PAT posts as MervinPraison so issue_comment triggers claude.yml (GITHUB_TOKEN cannot chain workflows)
GH_TOKEN: ${{ secrets.GH_TOKEN || github.token }}
permissions:
@@ -27,157 +24,29 @@ jobs:
detect-and-trigger:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: MervinPraison/github-automation-template/.github/actions/[email protected]
with:
hub_ref: v1.1.10
- name: Detect conflicting PRs and trigger Claude
uses: actions/github-script@v7
with:
github-token: ${{ env.GH_TOKEN }}
script: |
const path = require('path');
const mg = require(path.join(process.env.HUB_SCRIPTS, 'merge-gate.js'));
const owner = context.repo.owner;
const repo = context.repo.repo;
const baseRepo = `${owner}/${repo}`;
const COOLDOWN_MS = 12 * 60 * 60 * 1000;
const AUTO_ACTORS = ['github-actions[bot]', 'MervinPraison'];
const LABEL = 'claude-conflict-pending';
const COMMENT_BODY = [
'@claude this PR has merge conflicts with `main`.',
'Please rebase onto latest `main`, resolve conflicts (keep this PR\'s intent, merge in newer main logic),',
'run targeted tests, and force-push with `--force-with-lease`.',
'Comment which files you resolved.'
].join(' ');
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
async function getMergeState(prNumber) {
const query = `
query($owner: String!, $repo: String!, $number: Int!) {
repository(owner: $owner, name: $repo) {
pullRequest(number: $number) {
mergeStateStatus
maintainerCanModify
isDraft
headRef { repository { nameWithOwner } }
}
}
}
`;
for (let attempt = 0; attempt < 3; attempt++) {
const result = await github.graphql(query, { owner, repo, number: prNumber });
const prGql = result.repository.pullRequest;
const status = (prGql?.mergeStateStatus || '').toUpperCase();
if (status && status !== 'UNKNOWN') {
return {
status,
isDraft: prGql.isDraft,
headRepo: prGql.headRef?.repository?.nameWithOwner,
maintainerCanModify: prGql.maintainerCanModify === true,
};
}
if (attempt < 2) await sleep(10000);
}
const pr = await github.rest.pulls.get({ owner, repo, pull_number: prNumber });
return {
status: (pr.data.mergeable_state || '').toUpperCase(),
isDraft: pr.data.draft,
headRepo: pr.data.head.repo?.full_name,
maintainerCanModify: pr.data.maintainer_can_modify === true,
};
const prs = await github.paginate(github.rest.pulls.list, { owner, repo, state: 'open', per_page: 50 });
for (const pr of prs) {
if (pr.draft) continue;
if (pr.head?.repo?.full_name !== baseRepo) continue;
const state = await mg.getMergeState(github, owner, repo, pr.number);
if (state.status !== 'DIRTY') continue;
const { data: comments } = await github.rest.issues.listComments({ owner, repo, issue_number: pr.number, per_page: 100 });
if (mg.hasRecentConflictComment(comments)) continue;
const body = '@claude this PR has merge conflicts with `main`. Rebase onto latest main, resolve conflicts, run tests, force-push with --force-with-lease.';
await github.rest.issues.createComment({ owner, repo, issue_number: pr.number, body });
await github.rest.issues.addLabels({ owner, repo, issue_number: pr.number, labels: ['claude-conflict-pending'] });
}
function hasFinalClaudeReviewTrigger(comments) {
return comments.some((c) => {
const body = (c.body || '').toLowerCase();
if (!AUTO_ACTORS.includes(c.user.login)) return false;
if (!body.includes('@claude')) return false;
if (body.includes('merge conflict')) return false;
return body.includes('final architecture reviewer') || body.includes('lead engineer');
});
}
function hasRecentAutoComment(comments) {
const cutoff = Date.now() - COOLDOWN_MS;
return comments.some((c) => {
if (!AUTO_ACTORS.includes(c.user.login)) return false;
const body = (c.body || '').toLowerCase();
if (!body.includes('@claude') || !body.includes('merge conflict')) return false;
return new Date(c.created_at).getTime() > cutoff;
});
}
async function processPR(prNumber) {
const { status, isDraft, headRepo, maintainerCanModify } = await getMergeState(prNumber);
core.info(`PR #${prNumber}: mergeState=${status}, head=${headRepo}, draft=${isDraft}, maintainerCanModify=${maintainerCanModify}`);
const { data: issue } = await github.rest.issues.get({
owner,
repo,
issue_number: prNumber,
});
const labels = issue.labels.map((l) => l.name);
if (status !== 'DIRTY' && labels.includes(LABEL)) {
await github.rest.issues.removeLabel({
owner,
repo,
issue_number: prNumber,
name: LABEL,
});
core.info(`Removed ${LABEL} from PR #${prNumber}`);
}
if (status !== 'DIRTY') return { skipped: true, reason: 'not dirty' };
if (isDraft) return { skipped: true, reason: 'draft' };
if (headRepo && headRepo !== baseRepo && !maintainerCanModify) {
return { skipped: true, reason: 'fork without maintainer edits' };
}
if (labels.includes(LABEL)) {
return { skipped: true, reason: 'conflict resolution pending' };
}
const { data: comments } = await github.rest.issues.listComments({
owner,
repo,
issue_number: prNumber,
per_page: 100,
});
if (hasRecentAutoComment(comments)) {
return { skipped: true, reason: 'recent auto comment' };
}
if (!hasFinalClaudeReviewTrigger(comments)) {
return { skipped: true, reason: 'awaiting final claude review trigger' };
}
await github.rest.issues.addLabels({
owner,
repo,
issue_number: prNumber,
labels: [LABEL],
});
await github.rest.issues.createComment({
owner,
repo,
issue_number: prNumber,
body: COMMENT_BODY,
});
return { triggered: true };
}
let prNumbers = [];
if (context.eventName === 'pull_request') {
prNumbers = [context.payload.pull_request.number];
} else {
const prs = await github.paginate(github.rest.pulls.list, {
owner,
repo,
state: 'open',
per_page: 100,
});
prNumbers = prs.map((p) => p.number);
}
const results = [];
for (const num of prNumbers) {
results.push({ pr: num, ...(await processPR(num)) });
}
core.summary.addRaw('```json\n' + JSON.stringify(results, null, 2) + '\n```');
await core.summary.write();
+4 -27
View File
@@ -1,7 +1,5 @@
name: Pipeline Status Sync
# Sync pipeline/* stage and blocker labels on open internal PRs.
on:
schedule:
- cron: '*/10 * * * *'
@@ -15,28 +13,7 @@ permissions:
jobs:
sync:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
with:
persist-credentials: false
- name: Generate GitHub App token
id: app-token
uses: actions/create-github-app-token@v1
with:
app-id: ${{ secrets.CLAUDE_APP_ID }}
private-key: ${{ secrets.CLAUDE_APP_PRIVATE_KEY }}
owner: ${{ github.repository_owner }}
- name: Sync pipeline labels on open PRs
uses: actions/github-script@v7
with:
github-token: ${{ steps.app-token.outputs.token }}
script: |
const path = require('path');
const ps = require(path.join(process.env.GITHUB_WORKSPACE, '.github/scripts/pipeline-status.js'));
await ps.syncOpenPullRequests(
github, context.repo.owner, context.repo.repo,
{ maxPrs: 30, dispatchMergeGate: true }, core
);
uses: MervinPraison/github-automation-template/.github/workflows/[email protected]
secrets: inherit
with:
hub_ref: v1.1.10
+1
View File
@@ -44,6 +44,7 @@ Thumbs.db
# Project specific
aiui/
*.log
*.err
.praison/
.gemini/
.internal/
+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.
+13 -4
View File
@@ -9,6 +9,14 @@ site:
radius: "md"
darkMode: true
seo:
siteUrl: "https://ui.praison.ai"
titleTemplate: "%s | %s"
defaultImage: "/icon.svg"
robots:
index: true
follow: true
# Mintlify-style navigation: few tabs, many groups per tab
navigation:
tabs:
@@ -73,11 +81,12 @@ navigation:
# Navbar actions
navbar:
primary:
type: "github"
href: "https://github.com/MervinPraison/PraisonAIUI"
type: "button"
label: "Get started"
href: "/docs/getting-started/quickstart"
links:
- label: "Submit Issues"
href: "https://github.com/MervinPraison/PraisonAIUI/issues"
- label: "GitHub"
href: "https://github.com/MervinPraison/PraisonAIUI"
# Footer
footer:
+54 -51
View File
@@ -1,73 +1,76 @@
# SEO
Built-in SEO features for better search engine visibility.
Built-in SEO for documentation sites — sitemap, robots, llms.txt, per-page meta tags, and crawlable static HTML.
## Automatic Meta Tags
## Configuration
PraisonAIUI generates meta tags from your configuration:
Top-level `seo` block in `aiui.template.yaml`:
```yaml
site:
title: "My Documentation"
description: "Comprehensive guides for my product"
seo:
siteUrl: "https://ui.praison.ai"
titleTemplate: "%s | %s"
defaultImage: "/icon.svg"
robots:
index: true
follow: true
twitter:
handle: "@yourhandle"
```
Generates:
```html
<title>My Documentation</title>
<meta name="description" content="Comprehensive guides for my product">
```
`siteUrl` is required for absolute canonical URLs, sitemap, and social tags.
## Page-Level Overrides
## Build outputs
Use frontmatter to override per-page:
`aiui build` automatically generates:
| File | Purpose |
|------|---------|
| `sitemap.xml` | All doc URLs with `lastmod` dates |
| `robots.txt` | Crawl rules + sitemap pointer |
| `llms.txt` | AI crawler index ([llmstxt.org](https://llmstxt.org)) |
| `llms-full.txt` | Full markdown export for AI tools |
| `docs/**/*.html` | Per-route HTML with meta tags + crawl nav |
| `.nojekyll` | GitHub Pages Jekyll bypass |
No extra flags are needed — SEO files are emitted whenever `content.docs` is configured.
## Page-level overrides
Use frontmatter on any markdown page:
```markdown
---
title: "Custom Page Title"
description: "Custom meta description for this page"
description: "Custom meta description for search snippets"
noindex: true
---
# Page Content
```
## SEO Configuration
If `description` is omitted, the first paragraph is used automatically.
## Per-page HTML includes
Each route HTML file contains:
- `<link rel="canonical">` (absolute HTTPS)
- `<meta name="description">`
- Open Graph tags (`og:title`, `og:description`, `og:url`, `og:type`, `og:image`)
- Twitter Card tags
- JSON-LD `TechArticle` schema
- Static `<nav>` with links to every doc page (for non-JS crawlers)
- `<noscript>` pre-rendered page content
## Runtime (SPA navigation)
When users navigate via the sidebar, React updates title, canonical, OG, Twitter, robots, and JSON-LD to match the current page.
## Deploy
GitHub Pages deploy runs via the **Deploy Docs** workflow on every push to `main`. Submit your sitemap in [Google Search Console](https://search.google.com/search-console):
```yaml
site:
seo:
titleTemplate: "%s | My Site"
defaultImage: "/og-image.png"
twitterHandle: "@myhandle"
```
## Open Graph
Automatic Open Graph tags for social sharing:
```html
<meta property="og:title" content="Page Title">
<meta property="og:description" content="Page description">
<meta property="og:image" content="/og-image.png">
<meta property="og:type" content="website">
```
## Sitemap
Generate a sitemap during build:
```bash
aiui build --sitemap
```
Creates `sitemap.xml` with all pages.
## Robots.txt
```yaml
site:
seo:
robots:
index: true
follow: true
https://your-domain.com/sitemap.xml
```
+69
View File
@@ -0,0 +1,69 @@
# Application Design Protocol (ADP)
Declarative UI design standard for describing application layout, navigation, components, and theming from YAML or JSON manifests.
**Status:** v1alpha1 research draft — see [implementation-status.md](./implementation-status.md) for what is implemented today.
## Documents
| Document | Description |
|----------|-------------|
| [Research report](../application-design-protocol-report.md) | 46 standards compared; YAML vs JSON debate; design principles |
| [spec.md](./spec.md) | Normative specification (draft) |
| [validation-rules.md](./validation-rules.md) | Cross-reference rules R1–R30 |
| [aiui-mapping.md](./aiui-mapping.md) | PraisonAIUI field mapping |
| [implementation-status.md](./implementation-status.md) | Feature implementation tracker |
## Schemas
| File | Purpose |
|------|---------|
| [schema/application.schema.json](./schema/application.schema.json) | Root Application kind |
| [schema/component-catalog.schema.json](./schema/component-catalog.schema.json) | Catalog meta-schema |
| [schema/theme.schema.json](./schema/theme.schema.json) | Theme preset or DTCG tokens |
| [catalogs/default.catalog.json](./catalogs/default.catalog.json) | Default component and layout catalog |
## Examples
| File | Description |
|------|-------------|
| [examples/minimal.application.yaml](./examples/minimal.application.yaml) | Smallest valid app |
| [examples/docs-site.application.yaml](./examples/docs-site.application.yaml) | Full docs site |
| [examples/landing-page.application.yaml](./examples/landing-page.application.yaml) | Zone-based layout |
## Validate locally
```bash
pip install jsonschema pyyaml
pytest tests/unit/test_adp_schemas.py -v
```
## Quick start
```yaml
schemaVersion: 1
apiVersion: adp.io/v1alpha1
kind: Application
metadata:
name: my-app
version: "1.0.0"
spec:
site:
title: "My App"
theme:
preset: zinc
darkMode: true
templates:
docs:
layout: TwoColumnLayout
slots:
main: { type: DocContent }
routes:
- match: "/docs/**"
template: docs
content:
docs:
dir: "./docs"
```
+138
View File
@@ -0,0 +1,138 @@
# PraisonAIUI → ADP Field Mapping
Field-by-field mapping from current `aiui.template.yaml` (`Config` model in `src/praisonaiui/schema/models.py`) to ADP `Application` spec.
**Compatibility:** Flat `aiui.template.yaml` files work with the PraisonAIUI compiler today. ADP envelope format is **planned** — see [implementation-status.md](./implementation-status.md).
---
## Envelope (new in ADP)
| ADP field | PraisonAIUI today | Status | Change |
|-----------|-------------------|--------|--------|
| `schemaVersion: 1` | `schemaVersion: 1` | Implemented | Compatible |
| `apiVersion: adp.io/v1alpha1` | Not present | Planned | Add when envelope parser lands |
| `kind: Application` | Not present | Planned | Add when envelope parser lands |
| `metadata.name` | Not present | Planned | Derive from `site.title` slug |
| `metadata.version` | Not present | Planned | Recommend SemVer |
## Top-level → spec wrapper
| PraisonAIUI (flat) | ADP (envelope) | Status | Change |
|--------------------|----------------|--------|--------|
| `site:` | `spec.site:` | Planned | Wrap under `spec` |
| `site.theme` | `spec.theme:` | Planned | Promote theme to spec level |
| `navigation:` | `spec.navigation:` | Partial | Tabs compiled; not rendered |
| `navbar:` | `spec.navbar:` | Partial | Compiled; not rendered |
| `footer:` | `spec.footer:` | Partial | Compiled; not rendered |
| `search:` | `spec.search:` | Partial | Compiled; not rendered |
| `content:` | `spec.content:` | Implemented | Same |
| `components:` | `spec.components:` | Implemented | Same |
| `templates:` | `spec.templates:` | Implemented | Same |
| `routes:` | `spec.routes:` | Partial | Compiled; routing not honoured |
| `dependencies:` | `spec.dependencies:` | Implemented | Same |
| `logo:` (top-level) | Future ADP field | Implemented | Top-level on `Config`, not under `site` |
| `style:`, `chat:`, `auth:`, `dashboard:` | Not in ADP v1alpha1 | N/A | Separate kinds in future |
## Navigation fields
| Field | PraisonAIUI | ADP | Status | Notes |
|-------|-------------|-----|--------|-------|
| `navigation.tabs[]` | Mintlify-style tabs | `spec.navigation.tabs` | Partial | Compiled, not rendered |
| `content.docs.nav.mode` | `NavConfig.mode` | Same | Gap | Defined but unused by compiler |
| Top-level `navigation.mode` | Not present | Removed from ADP | N/A | Use `content.docs.nav.mode` instead |
## Theme fields
| Field | PraisonAIUI | ADP | Status |
|-------|-------------|-----|--------|
| `site.theme.preset` | Implemented | `spec.theme.preset` | Planned (envelope) |
| `site.theme.radius` | Implemented | Same | Same |
| `site.theme.darkMode` | Implemented | Same | Same |
| `theme.tokens` | Not present | DTCG file ref | Planned |
## Component fields
| Field | PraisonAIUI | ADP | Status |
|-------|-------------|-----|--------|
| `components.{id}.type` | String literal | Catalog allowlist | Planned (R6) |
| `components.{id}.props` | `dict[str, Any]` | Catalog schema | Planned (R7) |
## Template fields
| Field | PraisonAIUI | ADP | Status |
|-------|-------------|-----|--------|
| `templates.{id}.layout` | String | Catalog enum | Same values |
| `templates.{id}.slots` | `{ ref \| type }` | Same | Same |
| `templates.{id}.zones` | Widget arrays | Same | Same |
| Slots + zones on same template | Allowed | R13: one or the other | Stricter in ADP |
## Route fields
| Field | PraisonAIUI | ADP | Status |
|-------|-------------|-----|--------|
| `routes[].match` | Glob string | Same | Same |
| `routes[].template` | Template key | Same | Same |
| Runtime route resolution | Not implemented | R28 required | Gap |
## Migration example
### Today (PraisonAIUI)
```yaml
schemaVersion: 1
site:
title: "My Docs"
theme:
preset: zinc
templates:
docs:
layout: TwoColumnLayout
slots:
main: { type: DocContent }
routes:
- match: "/docs/**"
template: docs
```
### Target (ADP)
```yaml
schemaVersion: 1
apiVersion: adp.io/v1alpha1
kind: Application
metadata:
name: my-docs
version: "1.0.0"
spec:
site:
title: "My Docs"
theme:
preset: zinc
templates:
docs:
layout: TwoColumnLayout
slots:
main: { type: DocContent }
routes:
- match: "/docs/**"
template: docs
content:
docs:
dir: "./docs"
```
## Breaking vs compatible changes
| Change | Breaking? | Mitigation |
|--------|-----------|------------|
| Add envelope fields | No | Envelope parser normalises to flat `Config` |
| Catalog validation | Soft break | Warn first, error in strict mode |
| Route runtime resolution | No (fix) | Frontend change |
| R13 slots/zones exclusivity | Soft break | Warn on violation |
| Remove chat/dashboard from ADP | No | Separate kinds in future |
@@ -0,0 +1,128 @@
{
"name": "adp-default-catalog",
"version": "1.0.0",
"description": "Default ADP component and layout catalog for PraisonAIUI",
"components": {
"Header": {
"description": "Site header with logo and navigation",
"props": {
"type": "object",
"properties": {
"logoText": { "type": "string" },
"logoHref": { "type": "string", "default": "/" }
},
"required": ["logoText"],
"additionalProperties": false
},
"aria": { "role": "banner" },
"slots": ["header"]
},
"Footer": {
"description": "Site footer",
"props": {
"type": "object",
"properties": {
"text": { "type": "string" }
},
"additionalProperties": false
},
"aria": { "role": "contentinfo" },
"slots": ["footer"]
},
"DocsSidebar": {
"description": "Documentation sidebar navigation",
"props": {
"type": "object",
"properties": {
"source": { "type": "string", "enum": ["docs-nav", "manual"], "default": "docs-nav" }
},
"additionalProperties": false
},
"aria": { "role": "navigation" },
"slots": ["left", "leftSidebar"]
},
"DocContent": {
"description": "Main documentation content area",
"props": { "type": "object", "additionalProperties": false },
"aria": { "role": "main" },
"slots": ["main"]
},
"Toc": {
"description": "Table of contents for current page",
"props": { "type": "object", "additionalProperties": false },
"aria": { "role": "navigation" },
"slots": ["right", "rightSidebar"]
},
"HeroBanner": {
"description": "Hero banner with title and CTA",
"props": {
"type": "object",
"properties": {
"title": { "type": "string" },
"subtitle": { "type": "string" },
"ctaLabel": { "type": "string" },
"ctaHref": { "type": "string" }
},
"required": ["title"],
"additionalProperties": false
},
"slots": ["hero"]
},
"SocialLinks": {
"description": "Social media link row",
"props": {
"type": "object",
"properties": {
"links": {
"type": "array",
"items": {
"type": "object",
"properties": {
"platform": { "type": "string" },
"href": { "type": "string" }
},
"required": ["platform", "href"],
"additionalProperties": false
}
}
},
"additionalProperties": false
},
"slots": ["footer"]
},
"Copyright": {
"description": "Copyright notice",
"props": {
"type": "object",
"properties": {
"text": { "type": "string" }
},
"required": ["text"],
"additionalProperties": false
},
"slots": ["footer"]
}
},
"layouts": {
"ThreeColumnLayout": {
"description": "Header + left sidebar + main + right TOC + footer",
"slots": ["header", "left", "main", "right", "footer"]
},
"TwoColumnLayout": {
"description": "Sidebar + main content",
"slots": ["left", "main"]
},
"CenteredLayout": {
"description": "Centred single column",
"slots": ["main"]
},
"FullWidthLayout": {
"description": "Full-width content",
"slots": ["header", "main", "footer"]
},
"FlexibleLayout": {
"description": "WordPress-style zone grid",
"zones": ["header", "topNav", "hero", "leftSidebar", "main", "rightSidebar", "bottomNav", "footer"]
}
}
}
@@ -0,0 +1,104 @@
schemaVersion: 1
apiVersion: adp.io/v1alpha1
kind: Application
metadata:
name: praisonaiui-docs
version: "1.0.0"
labels:
generator: praisonaiui
spec:
site:
title: "PraisonAIUI Docs"
description: "Documentation for PraisonAIUI - YAML-driven website generator"
ui: shadcn
theme:
preset: zinc
radius: md
darkMode: true
navigation:
tabs:
- tab: "Documentation"
groups:
- group: "Getting Started"
icon: rocket
prefix: getting-started
pages:
- index
- getting-started/installation
- getting-started/quickstart
- group: "Core Concepts"
icon: lightbulb
prefix: concepts
pages:
- concepts/architecture
- concepts/configuration
- concepts/templates
- tab: "API Reference"
groups:
- group: "API Reference"
icon: code
prefix: api
pages:
- api/cli
- api/python
navbar:
primary:
type: github
href: "https://github.com/MervinPraison/PraisonAIUI"
links:
- label: "Submit Issues"
href: "https://github.com/MervinPraison/PraisonAIUI/issues"
footer:
socials:
github: "https://github.com/MervinPraison/PraisonAI"
links:
- header: "Resources"
items:
- label: "GitHub"
href: "https://github.com/MervinPraison/PraisonAI"
search:
enabled: true
provider: fusejs
content:
docs:
dir: "./docs"
include:
- "**/*.md"
- "**/*.mdx"
nav:
mode: manual
components:
header_main:
type: Header
props:
logoText: "PraisonAIUI"
footer_main:
type: Footer
props:
text: "© 2026 PraisonAI"
sidebar_docs:
type: DocsSidebar
props:
source: docs-nav
templates:
docs:
layout: ThreeColumnLayout
slots:
header: { ref: header_main }
left: { ref: sidebar_docs }
main: { type: DocContent }
footer: { ref: footer_main }
routes:
- match: "/docs/**"
template: docs
@@ -0,0 +1,58 @@
schemaVersion: 1
apiVersion: adp.io/v1alpha1
kind: Application
metadata:
name: colorful-landing
version: "1.0.0"
spec:
site:
title: "Colorful Docs"
description: "Zone-based landing page with hero and footer widgets"
ui: shadcn
theme:
preset: rose
radius: xl
darkMode: true
content:
docs:
dir: "./docs"
nav:
mode: auto
collapsible: true
templates:
docs:
layout: FlexibleLayout
zones:
hero:
- type: HeroBanner
props:
title: "Colorful Documentation"
subtitle: "Vibrant, playful, and beautifully designed"
ctaLabel: "Get Started"
ctaHref: "/docs/getting-started/quickstart"
rightSidebar:
- type: Toc
footer:
- type: SocialLinks
props:
links:
- platform: Twitter
href: "https://twitter.com"
- platform: GitHub
href: "https://github.com"
- type: Copyright
props:
text: "Made with love by Colorful Team"
routes:
- match: "/docs/**"
template: docs
a11y:
skipToContent: true
reduceMotion: false
@@ -0,0 +1,32 @@
schemaVersion: 1
apiVersion: adp.io/v1alpha1
kind: Application
metadata:
name: minimal-docs
version: "1.0.0"
spec:
site:
title: "Minimal Docs"
description: "Smallest valid ADP Application manifest"
ui: shadcn
theme:
preset: zinc
radius: md
darkMode: true
content:
docs:
dir: "./docs"
templates:
docs:
layout: TwoColumnLayout
slots:
main: { type: DocContent }
routes:
- match: "/docs/**"
template: docs
@@ -0,0 +1,36 @@
# ADP Implementation Status
Honest status of ADP v1alpha1 features against the PraisonAIUI reference implementation.
**Last updated:** August 2026
| Feature | Spec reference | Status | Notes |
|---------|----------------|--------|-------|
| JSON Schema validation in CLI | spec §6 | Planned | Schemas published; no `aiui validate --adp` yet |
| ADP envelope parsing (`apiVersion/kind/spec`) | spec §3 | Planned | Flat `aiui.template.yaml` works today |
| Component catalog validation (R6–R7) | spec §5 | Planned | Catalog split to `catalogs/default.catalog.json` |
| `$yaml:` / `$json:` file includes | spec §7 | Planned | Not implemented in compiler |
| Strict mode unknown-field rejection (R4) | validation-rules R4 | Planned | Pydantic allows extra fields on some models |
| Route-driven template resolution (R28) | validation-rules R28 | Gap | Frontend hardcodes `templates.docs` |
| Sidebar nav mode (R29) | validation-rules R29 | Gap | Filesystem-only sidebar today |
| Catalog ARIA roles at render (R30) | validation-rules R30 | Gap | No catalog-driven a11y in renderer |
| `navigation.tabs` rendering | spec §4.3 | Gap | Compiled to manifest but not consumed by React |
| DTCG token file validation (R26) | validation-rules R26 | Planned | Preset shorthand works via Tailwind |
| Published `$schema` URLs | README | Partial | Local schemas only; `https://adp.io/` is placeholder |
## What works today
- Flat `aiui.template.yaml` → Pydantic `Config` → compiler → JSON manifests → React SPA
- Components, templates, routes, theme presets, content scanning
- ADP example manifests validate against `application.schema.json` (standalone)
- Unit tests in `tests/unit/test_adp_schemas.py`
## Migration path
1. Add envelope normalisation layer before `Config.model_validate`
2. Wire `route-manifest.json` into frontend routing (R28)
3. Honour `navigation.tabs` and `content.docs.nav.mode` (R29)
4. Add `aiui validate --schema adp` using published JSON Schema
5. Apply catalog ARIA hints at render time (R30)
See [aiui-mapping.md](./aiui-mapping.md) for field-level compatibility.
@@ -0,0 +1,449 @@
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "application.schema.json",
"title": "ADP Application",
"description": "Application Design Protocol — Application kind manifest",
"type": "object",
"required": ["schemaVersion", "apiVersion", "kind", "metadata", "spec"],
"additionalProperties": false,
"patternProperties": {
"^x-adp-": {}
},
"properties": {
"schemaVersion": {
"type": "integer",
"const": 1,
"description": "ADP envelope version"
},
"apiVersion": {
"type": "string",
"const": "adp.io/v1alpha1"
},
"kind": {
"type": "string",
"const": "Application"
},
"metadata": {
"type": "object",
"required": ["name"],
"additionalProperties": false,
"patternProperties": {
"^x-adp-": {}
},
"properties": {
"name": { "type": "string", "minLength": 1 },
"version": { "type": "string" },
"labels": {
"type": "object",
"additionalProperties": { "type": "string" }
},
"annotations": {
"type": "object",
"additionalProperties": { "type": "string" }
}
}
},
"spec": {
"type": "object",
"required": ["site", "templates", "routes"],
"additionalProperties": false,
"patternProperties": {
"^x-adp-": {}
},
"properties": {
"site": { "$ref": "#/$defs/site" },
"theme": { "$ref": "#/$defs/theme" },
"navigation": { "$ref": "#/$defs/navigation" },
"navbar": { "$ref": "#/$defs/navbar" },
"footer": { "$ref": "#/$defs/footer" },
"search": { "$ref": "#/$defs/search" },
"components": { "$ref": "#/$defs/components" },
"templates": { "$ref": "#/$defs/templates" },
"routes": { "$ref": "#/$defs/routes" },
"content": { "$ref": "#/$defs/content" },
"dependencies": { "$ref": "#/$defs/dependencies" },
"seo": { "$ref": "#/$defs/seo" },
"i18n": { "$ref": "#/$defs/i18n" },
"a11y": { "$ref": "#/$defs/a11y" }
}
}
},
"$defs": {
"site": {
"type": "object",
"required": ["title"],
"additionalProperties": false,
"patternProperties": {
"^x-adp-": {}
},
"properties": {
"title": { "type": "string" },
"description": { "type": "string" },
"ui": {
"type": "string",
"enum": ["shadcn", "mui", "chakra"],
"default": "shadcn"
},
"routeBaseDocs": { "type": "string", "default": "/docs" },
"customCss": { "type": "string" },
"plugins": {
"type": "array",
"items": { "type": "string" }
}
}
},
"theme": {
"oneOf": [
{
"title": "Preset theme",
"type": "object",
"additionalProperties": false,
"properties": {
"preset": {
"type": "string",
"enum": [
"zinc", "slate", "stone", "gray", "neutral",
"red", "orange", "amber", "yellow", "lime", "green",
"emerald", "teal", "cyan", "sky", "blue", "indigo",
"violet", "purple", "fuchsia", "pink", "rose"
]
},
"radius": {
"type": "string",
"enum": ["none", "sm", "md", "lg", "xl"]
},
"darkMode": { "type": "boolean" }
},
"required": ["preset"]
},
{
"title": "DTCG token reference",
"type": "object",
"additionalProperties": false,
"properties": {
"tokens": {
"type": "string",
"description": "Path or URL to DTCG 2025.10 compliant tokens.json"
},
"darkMode": { "type": "boolean" }
},
"required": ["tokens"]
}
]
},
"slotRef": {
"oneOf": [
{ "type": "null" },
{
"type": "object",
"required": ["ref"],
"additionalProperties": false,
"properties": {
"ref": { "type": "string" }
}
},
{
"type": "object",
"required": ["type"],
"additionalProperties": false,
"properties": {
"type": { "type": "string" },
"props": {
"type": "object",
"additionalProperties": true
}
}
}
]
},
"widget": {
"type": "object",
"required": ["type"],
"additionalProperties": false,
"properties": {
"type": { "type": "string" },
"props": {
"type": "object",
"additionalProperties": true
}
}
},
"components": {
"type": "object",
"additionalProperties": {
"type": "object",
"required": ["type"],
"additionalProperties": false,
"properties": {
"type": { "type": "string" },
"props": {
"type": "object",
"additionalProperties": true
}
}
}
},
"template": {
"type": "object",
"required": ["layout"],
"allOf": [
{
"oneOf": [
{ "required": ["slots"] },
{ "required": ["zones"] }
]
}
],
"additionalProperties": false,
"properties": {
"layout": {
"type": "string",
"enum": [
"ThreeColumnLayout",
"TwoColumnLayout",
"CenteredLayout",
"FullWidthLayout",
"FlexibleLayout"
]
},
"slots": {
"type": "object",
"additionalProperties": { "$ref": "#/$defs/slotRef" }
},
"zones": {
"type": "object",
"additionalProperties": {
"type": "array",
"items": { "$ref": "#/$defs/widget" }
}
}
}
},
"templates": {
"type": "object",
"minProperties": 1,
"additionalProperties": { "$ref": "#/$defs/template" }
},
"route": {
"type": "object",
"required": ["match", "template"],
"additionalProperties": false,
"properties": {
"match": { "type": "string" },
"template": { "type": "string" },
"slots": {
"type": "object",
"additionalProperties": { "$ref": "#/$defs/slotRef" }
}
}
},
"routes": {
"type": "array",
"minItems": 1,
"items": { "$ref": "#/$defs/route" }
},
"navGroup": {
"type": "object",
"required": ["group"],
"additionalProperties": false,
"properties": {
"group": { "type": "string" },
"icon": { "type": "string" },
"prefix": { "type": "string" },
"pages": {
"type": "array",
"items": { "type": "string" }
}
}
},
"navTab": {
"type": "object",
"required": ["tab"],
"additionalProperties": false,
"properties": {
"tab": { "type": "string" },
"url": { "type": "string" },
"groups": {
"type": "array",
"items": { "$ref": "#/$defs/navGroup" }
}
}
},
"navigation": {
"type": "object",
"additionalProperties": false,
"properties": {
"tabs": {
"type": "array",
"items": { "$ref": "#/$defs/navTab" }
}
}
},
"navbar": {
"type": "object",
"additionalProperties": false,
"properties": {
"primary": {
"type": "object",
"additionalProperties": false,
"properties": {
"type": { "type": "string", "enum": ["button", "github"] },
"label": { "type": "string" },
"href": { "type": "string" }
}
},
"links": {
"type": "array",
"items": {
"type": "object",
"required": ["label", "href"],
"additionalProperties": false,
"properties": {
"label": { "type": "string" },
"href": { "type": "string" }
}
}
}
}
},
"footer": {
"type": "object",
"additionalProperties": false,
"properties": {
"socials": {
"type": "object",
"additionalProperties": { "type": "string" }
},
"links": {
"type": "array",
"items": {
"type": "object",
"required": ["header"],
"additionalProperties": false,
"properties": {
"header": { "type": "string" },
"items": {
"type": "array",
"items": {
"type": "object",
"required": ["label", "href"],
"additionalProperties": false,
"properties": {
"label": { "type": "string" },
"href": { "type": "string" }
}
}
}
}
}
}
}
},
"search": {
"type": "object",
"additionalProperties": false,
"properties": {
"enabled": { "type": "boolean" },
"provider": {
"type": "string",
"enum": ["fusejs", "lunrjs", "pagefind"]
}
}
},
"dependencies": {
"type": "object",
"additionalProperties": false,
"properties": {
"shadcn": {
"type": "array",
"items": { "type": "string" }
}
}
},
"contentSource": {
"type": "object",
"required": ["dir"],
"additionalProperties": false,
"properties": {
"dir": { "type": "string" },
"include": {
"type": "array",
"items": { "type": "string" }
},
"exclude": {
"type": "array",
"items": { "type": "string" }
},
"indexFiles": {
"type": "array",
"items": { "type": "string" }
},
"nav": {
"type": "object",
"additionalProperties": false,
"properties": {
"mode": { "type": "string", "enum": ["auto", "manual"] },
"sort": { "type": "string", "enum": ["filesystem", "alpha", "date"] },
"collapsible": { "type": "boolean" },
"maxDepth": { "type": "integer", "minimum": 1, "maximum": 10 }
},
"allOf": [
{
"if": {
"properties": { "mode": { "const": "manual" } },
"required": ["mode"]
},
"then": {
"description": "Manual sidebar mode requires navigation.tabs at spec level"
}
}
]
}
}
},
"content": {
"type": "object",
"additionalProperties": false,
"properties": {
"docs": { "$ref": "#/$defs/contentSource" },
"blog": { "$ref": "#/$defs/contentSource" }
}
},
"seo": {
"type": "object",
"additionalProperties": false,
"properties": {
"titleTemplate": { "type": "string" },
"defaultImage": { "type": "string" },
"twitter": {
"type": "object",
"additionalProperties": { "type": "string" }
}
}
},
"i18n": {
"type": "object",
"additionalProperties": false,
"properties": {
"defaultLocale": { "type": "string" },
"locales": { "type": "array", "items": { "type": "string" } },
"rtlLocales": { "type": "array", "items": { "type": "string" } },
"fallbackLocale": { "type": "string" },
"translationsDir": { "type": "string" }
}
},
"a11y": {
"type": "object",
"additionalProperties": false,
"properties": {
"skipToContent": { "type": "boolean" },
"focusVisible": { "type": "boolean" },
"reduceMotion": { "type": "boolean" },
"ariaLabels": {
"type": "object",
"additionalProperties": { "type": "string" }
}
}
}
}
}
@@ -0,0 +1,82 @@
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "component-catalog.schema.json",
"title": "ADP Component Catalog",
"description": "Allowlisted component types for ADP Application manifests",
"type": "object",
"required": ["name", "version", "components"],
"additionalProperties": false,
"patternProperties": {
"^x-adp-": {}
},
"properties": {
"name": { "type": "string" },
"version": { "type": "string" },
"description": { "type": "string" },
"components": {
"type": "object",
"additionalProperties": { "$ref": "#/$defs/componentType" }
},
"layouts": {
"type": "object",
"additionalProperties": { "$ref": "#/$defs/layoutType" }
}
},
"$defs": {
"componentType": {
"type": "object",
"required": ["description", "props"],
"additionalProperties": false,
"properties": {
"description": { "type": "string" },
"props": { "type": "object" },
"aria": {
"type": "object",
"additionalProperties": false,
"properties": {
"role": { "type": "string" },
"label": { "type": "string" }
}
},
"slots": {
"type": "array",
"items": { "type": "string" }
},
"deprecated": { "type": "boolean" },
"x-sunset": { "type": "string", "format": "date" }
}
},
"layoutType": {
"oneOf": [
{
"title": "Slot-based layout",
"type": "object",
"required": ["description", "slots"],
"additionalProperties": false,
"properties": {
"description": { "type": "string" },
"slots": {
"type": "array",
"items": { "type": "string" },
"minItems": 1
}
}
},
{
"title": "Zone-based layout",
"type": "object",
"required": ["description", "zones"],
"additionalProperties": false,
"properties": {
"description": { "type": "string" },
"zones": {
"type": "array",
"items": { "type": "string" },
"minItems": 1
}
}
}
]
}
}
}
@@ -0,0 +1,47 @@
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "theme.schema.json",
"title": "ADP Theme",
"description": "Theme configuration — preset shorthand or DTCG 2025.10 token reference",
"oneOf": [
{
"title": "Preset theme",
"type": "object",
"additionalProperties": false,
"properties": {
"preset": {
"type": "string",
"enum": [
"zinc", "slate", "stone", "gray", "neutral",
"red", "orange", "amber", "yellow", "lime", "green",
"emerald", "teal", "cyan", "sky", "blue", "indigo",
"violet", "purple", "fuchsia", "pink", "rose"
]
},
"radius": {
"type": "string",
"enum": ["none", "sm", "md", "lg", "xl"],
"default": "md"
},
"darkMode": {
"type": "boolean",
"default": true
}
},
"required": ["preset"]
},
{
"title": "DTCG token reference",
"type": "object",
"additionalProperties": false,
"properties": {
"tokens": {
"type": "string",
"description": "Path or URL to DTCG 2025.10 compliant tokens.json"
},
"darkMode": { "type": "boolean" }
},
"required": ["tokens"]
}
]
}
+314
View File
@@ -0,0 +1,314 @@
# Application Design Protocol (ADP) — Normative Specification
**Version:** v1alpha1 (draft)
**Status:** Draft — not yet normative
**Date:** August 2026
This document uses [RFC 2119](https://datatracker.ietf.org/doc/html/rfc2119) keywords: MUST, MUST NOT, SHOULD, MAY.
---
## 1. Scope
ADP defines a declarative format for describing **application UI design**: site metadata, theming, navigation, reusable components, page templates, and URL routing. It does NOT define:
- Server-side APIs or data models
- Agent orchestration or workflow graphs
- Infrastructure or deployment configuration
- Executable code or templating logic in manifests
Implementations MUST treat ADP manifests as **data**, not code.
---
## 2. Terminology
| Term | Definition |
|------|------------|
| **Manifest** | A YAML or JSON document conforming to this spec |
| **Application** | The root `kind: Application` manifest describing a complete UI |
| **Component** | A named, reusable UI block with a `type` and `props` |
| **Template** | A layout definition assigning components to slots or zones |
| **Slot** | A named region within a layout (e.g. `header`, `main`) |
| **Zone** | An ordered list of widgets in a slot (WordPress-style) |
| **Route** | A URL pattern mapping to a template |
| **Catalog** | Published JSON Schema defining allowed component types |
| **Compiler** | Tool that validates and emits runtime JSON manifests |
| **Renderer** | Runtime that consumes compiled manifests and renders UI |
---
## 3. Document envelope
Every ADP manifest MUST include:
```yaml
schemaVersion: 1 # Integer; ADP envelope version
apiVersion: adp.io/v1alpha1
kind: Application # Application | Theme | ComponentCatalog (future)
metadata:
name: string # Required; unique identifier
version: string # SHOULD use SemVer
labels: object # MAY contain key-value pairs
annotations: object # MAY contain tooling metadata
spec: object # Required; kind-specific payload
```
### 3.1 Versioning policy
- `schemaVersion` — integer bumped on breaking envelope changes
- `apiVersion` — string group/version (e.g. `adp.io/v1alpha1`, `adp.io/v1`)
- `metadata.version` — application SemVer (independent of protocol version)
- Breaking changes MUST increment `apiVersion` (e.g. v1alpha1 → v1)
- Deprecated fields MUST include `deprecated: true` and SHOULD include `x-sunset` date
### 3.2 Authoring formats
Manifests MAY be authored as YAML (`.application.yaml`) or JSON (`.application.json`). Semantics MUST be identical. Runtimes MUST NOT consume YAML directly; compilers MUST normalise to JSON before validation.
---
## 4. Application spec
An `Application` spec MUST contain at minimum:
| Field | Required | Description |
|-------|----------|-------------|
| `site.title` | YES | Application display name |
| `templates` | YES | At least one template |
| `routes` | YES | At least one route |
All other fields are OPTIONAL.
### 4.1 Site (`spec.site`)
```yaml
site:
title: string # Required
description: string # Optional
ui: shadcn # UI framework hint; default shadcn
routeBaseDocs: /docs # Base path for documentation routes
```
### 4.2 Theme (`spec.theme`)
Theme MAY use preset shorthand or reference a DTCG token file:
```yaml
theme:
preset: zinc # Tailwind-style preset name
radius: md # none | sm | md | lg | xl
darkMode: true
# OR
tokens: $yaml: ./theme.tokens.json # DTCG 2025.10 file
```
### 4.3 Navigation
ADP separates **top tab bar** from **sidebar source**:
```yaml
navigation:
tabs: # Top tab bar (Mintlify-style)
- tab: Documentation
groups:
- group: Getting Started
pages: [index, getting-started/installation]
content:
docs:
dir: ./docs
nav:
mode: auto # auto | manual — sidebar source
sort: filesystem
collapsible: true
maxDepth: 4
```
- **`navigation.tabs`** — top-level tab bar with grouped pages
- **`content.docs.nav.mode: auto`** — sidebar generated from filesystem scan
- **`content.docs.nav.mode: manual`** — sidebar driven by `navigation.tabs` groups
- Renderers MUST honour `content.docs.nav.mode` when building sidebar (R29)
### 4.4 Components (`spec.components`)
```yaml
components:
header_main:
type: Header # MUST exist in component catalog
props:
logoText: PraisonAIUI
```
Rules:
- Component IDs MUST be unique within the manifest
- `type` MUST match an entry in the published component catalog
- `props` MUST validate against the catalog JSON Schema for that type
### 4.5 Templates (`spec.templates`)
```yaml
templates:
docs:
layout: ThreeColumnLayout
slots:
header: { ref: header_main }
left: { ref: sidebar_docs }
main: { type: DocContent }
right: null # Explicitly hide slot
landing:
layout: FlexibleLayout
zones:
hero:
- type: HeroBanner
props: { title: "Welcome" }
```
Rules:
- `layout` MUST be a known layout type in the catalog
- Each slot value MUST be `{ ref: id }`, `{ type: ComponentType }`, or `null`
- Zones MUST use zone names valid for `FlexibleLayout`
- A template MUST declare either `slots` or `zones`, not both (R13)
### 4.6 Routes (`spec.routes`)
```yaml
routes:
- match: /docs/changelog
template: changelog
slots: # Optional per-route slot overrides
right: null
- match: /docs/**
template: docs
```
Rules:
- Routes MUST be ordered most-specific-first
- `match` MUST be a glob pattern
- `template` MUST reference a key in `spec.templates`
- Renderers MUST resolve routes at runtime using compiled `route-manifest.json`
### 4.7 Content (`spec.content`)
```yaml
content:
docs:
dir: ./docs
include: ["**/*.md", "**/*.mdx"]
exclude: []
nav:
mode: auto
sort: filesystem
collapsible: true
maxDepth: 4
```
---
## 5. Component catalog
Implementations MUST publish a `component-catalog.schema.json` (meta-schema) and a catalog instance such as [`catalogs/default.catalog.json`](./catalogs/default.catalog.json):
```json
{
"name": "adp-default-catalog",
"version": "1.0.0",
"components": {
"Header": {
"description": "Site header with logo and navigation",
"props": {
"type": "object",
"properties": { "logoText": { "type": "string" } },
"required": ["logoText"],
"additionalProperties": false
},
"aria": { "role": "banner" },
"slots": ["header"]
}
},
"layouts": {
"ThreeColumnLayout": {
"description": "Header + sidebar + main + TOC + footer",
"slots": ["header", "left", "main", "right", "footer"]
}
}
}
```
Custom component types MUST be registered via plugin schema layers. Unknown types MUST cause validation failure in strict mode.
---
## 6. Compilation pipeline
A conforming compiler MUST:
1. Parse YAML or JSON manifest
2. Normalise to JSON
3. Validate against `application.schema.json` (JSON Schema 2020-12)
4. Validate cross-reference rules (see [validation-rules.md](./validation-rules.md))
5. Validate component props against catalog schema
6. Emit runtime manifests:
- `ui-config.json` — site, components, templates, theme, navigation
- `route-manifest.json` — ordered routes with template refs
- `docs-nav.json` — filesystem-derived navigation (if mode auto)
---
## 7. File composition
Manifests MAY include external files using Backstage-style placeholders:
```yaml
theme:
$yaml: ./theme.yaml
navigation:
$yaml: ./navigation.yaml
```
Rules:
- Paths MUST be relative to the containing manifest
- Path traversal (`../../`) MUST be rejected
- Supported placeholders: `$yaml:`, `$json:`, `$text:`
---
## 8. Extensions
Vendor extensions MUST use the `x-adp-` prefix:
```yaml
spec:
site:
x-adp-customAnalytics: true
```
Implementations MUST preserve unknown `x-adp-*` fields. Non-prefixed unknown fields MUST be rejected in strict mode.
---
## 9. Conformance
An implementation is **ADP-conformant** if and only if it:
1. Accepts manifests with `apiVersion: adp.io/v1alpha1` and `kind: Application`
2. Validates all MUST-level requirements in this spec and validation-rules.md
3. Rejects manifests with unknown fields in strict mode
4. Emits runtime JSON manifests from valid Application specs
5. Resolves routes to templates at render time
---
## 10. Related documents
- [Research report](../application-design-protocol-report.md)
- [Implementation status](./implementation-status.md)
- [PraisonAIUI mapping](./aiui-mapping.md)
- [JSON Schema: application](./schema/application.schema.json)
- [JSON Schema: component catalog](./schema/component-catalog.schema.json)
- [JSON Schema: theme](./schema/theme.schema.json)
- [Examples](./examples/)
+115
View File
@@ -0,0 +1,115 @@
# ADP Validation Rules
Cross-reference validation rules for Application Design Protocol manifests. These constraints cannot be expressed in JSON Schema alone and MUST be enforced by conforming compilers.
Rules use RFC 2119 keywords: MUST, MUST NOT, SHOULD, MAY.
Inspired by AWP validation rules R1–R32 pattern.
---
## Envelope rules
| Rule | Level | Description |
|------|-------|-------------|
| **R1** | MUST | `schemaVersion` MUST equal `1` for `apiVersion: adp.io/v1alpha1` |
| **R2** | MUST | `kind` MUST be `Application` for root manifests |
| **R3** | MUST | `metadata.name` MUST be unique within a deployment scope |
| **R4** | MUST NOT | Unknown top-level fields outside `x-adp-*` namespace in strict mode |
## Component rules
| Rule | Level | Description |
|------|-------|-------------|
| **R5** | MUST | Every `spec.components` entry MUST have a unique ID |
| **R6** | MUST | Every component `type` MUST exist in the published component catalog |
| **R7** | MUST | Component `props` MUST validate against catalog JSON Schema for that type |
| **R8** | MUST NOT | A component ID be referenced by `ref:` unless it exists in `spec.components` |
| **R9** | SHOULD | Unused components (defined but never referenced) SHOULD emit a warning |
## Template rules
| Rule | Level | Description |
|------|-------|-------------|
| **R10** | MUST | Every `spec.templates` entry MUST declare a valid `layout` from the catalog |
| **R11** | MUST | Slot names in `templates.*.slots` MUST be valid for the declared layout |
| **R12** | MUST | Zone names in `templates.*.zones` MUST be valid for `FlexibleLayout` |
| **R13** | MUST | Each template MUST declare either `slots` or `zones`, not both |
| **R14** | MUST | Inline slot `{ type: X }` types MUST exist in the component catalog |
| **R15** | MUST | Slot `{ ref: id }` MUST resolve to a component in `spec.components` |
## Route rules
| Rule | Level | Description |
|------|-------|-------------|
| **R16** | MUST | Every route `template` MUST reference a key in `spec.templates` |
| **R17** | MUST | Routes MUST be ordered most-specific-first (longer/more precise patterns before wildcards) |
| **R18** | MUST | Route slot overrides MUST use valid slot names for the target template's layout |
| **R19** | SHOULD | At least one route SHOULD match the site root or docs base path |
## Content and navigation rules
| Rule | Level | Description |
|------|-------|-------------|
| **R20** | MUST | If `content.docs.dir` is declared, the directory MUST exist at compile time |
| **R21** | MUST | If `content.docs.nav.mode` is `manual`, `navigation.tabs` MUST be non-empty |
| **R22** | SHOULD | If `content.docs.nav.mode` is `auto`, a content source MUST be declared |
Navigation model:
- **`navigation.tabs`** — top tab bar (Mintlify-style)
- **`content.docs.nav.mode`** — sidebar source: `auto` (filesystem scan) or `manual` (use `navigation.tabs` groups)
## File include rules
| Rule | Level | Description |
|------|-------|-------------|
| **R23** | MUST NOT | `$yaml:`, `$json:`, or `$text:` paths traverse outside the project root |
| **R24** | MUST | Included files MUST parse as valid YAML/JSON/text respectively |
| **R25** | MUST NOT | Circular file includes (A includes B includes A) |
## Theme rules
| Rule | Level | Description |
|------|-------|-------------|
| **R26** | MUST | If `theme.tokens` is declared, the referenced file MUST exist and validate against DTCG 2025.10 |
| **R27** | MUST NOT | Both `theme.preset` and `theme.tokens` be declared simultaneously |
## Renderer conformance (target — not yet implemented in PraisonAIUI)
These rules define the target behaviour for ADP-conformant renderers. See [implementation-status.md](./implementation-status.md).
| Rule | Level | Description |
|------|-------|-------------|
| **R28** | MUST | Renderers MUST resolve the active template from `route-manifest.json` at runtime |
| **R29** | MUST | Renderers MUST honour `content.docs.nav.mode` when building sidebar |
| **R30** | MUST | Renderers MUST apply ARIA roles from catalog for each rendered component |
---
## Validation pipeline order
```
1. Parse YAML/JSON
2. Resolve $yaml/$json/$text includes (R23–R25)
3. JSON Schema validate (application.schema.json)
4. Cross-ref rules R1–R27
5. Component props validate against catalog (R6–R7)
6. Emit runtime manifests
7. Runtime conformance R28–R30 (renderer responsibility)
```
## Error format
Validation failures MUST report:
```json
{
"rule": "R8",
"path": "spec.templates.docs.slots.header.ref",
"message": "Component ref 'missing_header' not found in spec.components",
"severity": "error"
}
```
Warnings (R9) use `"severity": "warning"`.
@@ -0,0 +1,427 @@
# Application Design Protocol (ADP) — Research Report
**Version:** 1.0 (draft)
**Date:** August 2026
**Scope:** UI, layout, navigation, theming — declarative application design from text manifests
**Status:** Research and recommendation; not yet a published standard
---
## 1. Executive summary
There is no single existing standard for **designing full application UI from a declarative text file**. Instead, the problem is addressed in fragments:
- **Docs-site generators** (Mintlify, PraisonAIUI, Hugo) declare navigation and layout in config files.
- **Form libraries** (JSON Forms, RJSF) declare layout trees from JSON Schema.
- **Agent UI protocols** (A2UI, AG-UI) declare streaming component surfaces for LLM-generated UI.
- **Catalog standards** (OpenUI, Backstage) declare component metadata without runtime layout.
- **Design token standards** (DTCG 2025.10) declare theming without structure.
**Recommendation:** Define an **Application Design Protocol (ADP)** that synthesises proven patterns from 46 surveyed standards into a UI-only manifest format.
PraisonAIUI already implements approximately 60% of this model. ADP formalises, extends, and publishes what exists today.
---
## 2. Problem statement
### 2.1 What we need
A protocol that lets a developer (or LLM) describe an application's visual design — pages, navigation, components, themes, routes — in **one or more text files**, without writing application code.
### 2.2 What exists today (fragmented)
| Need | Who solves it | Gap |
|------|---------------|-----|
| Docs site from config | Mintlify, PraisonAIUI, Docusaurus | No shared spec; Mintlify uses JSON, others use YAML or JS |
| Component catalog | OpenUI, A2UI | No layout or routing |
| Layout trees | JSON Forms uiSchema | Forms only, not app shells |
| Theming | DTCG, Material 3 | Tokens only, no pages |
| Agent-generated UI | A2UI | Runtime streaming, not human-authored static sites |
| Software metadata | Backstage | Catalog entities, not UI composition |
### 2.3 Design goals for ADP
1. **Human-readable** — editable without IDE plugins
2. **LLM-safe** — structural schema rejects unknown fields
3. **Portable** — same manifest renders on any ADP-compliant runtime
4. **Validatable** — fail fast at build time, not in the browser
5. **Extensible** — plugins add catalog entries, not inline code
6. **Accessible** — ARIA hints required in component catalog
---
## 3. Standards landscape — 46 protocols in 10 categories
Each entry describes **how UI/layout/navigation is declared** — the primary comparison dimension.
### Category A: Web platform baseline (5)
| Standard | Format | How UI/layout/navigation is declared | Schema | Versioning |
|----------|--------|--------------------------------------|--------|------------|
| **HTML + CSS** | `.html` + `.css` | Semantic elements; CSS Flexbox/Grid; `<nav>` + links | Living spec grammar | Browser Baseline |
| **Web Components + DSD** | HTML + JS | Custom element tree; slots; Declarative Shadow DOM | DOM IDL | Platform flags |
| **WAI-ARIA + APG** | HTML attributes | Landmarks, widget roles, keyboard patterns | WAI-ARIA 1.2 | Rec track |
| **DTCG Design Tokens** | JSON | Token groups: `$value`, `$type`, `{alias}` refs — **no layout** | DTCG 2025.10 | Date editions |
| **OpenUI** | YAML/JSON | Component catalog: `components{id: props}` — **no layout tree** | Prop definitions | Per-library version |
### Category B: Schema-driven forms and layout (3)
| Standard | Format | How UI/layout/navigation is declared | Schema | Versioning |
|----------|--------|--------------------------------------|--------|------------|
| **JSON Forms** | JSON Schema + UI Schema | `VerticalLayout`, `Control`, `Categorization` with `scope` pointers | JSON Schema + uiSchema | Package SemVer |
| **RJSF** | JSON Schema + uiSchema | Schema-tree mirroring; `ui:widget`, `ui:options` | React convention | Package SemVer |
| **Google A2UI** | JSONL stream | Flat adjacency-list; `createSurface`; catalog-constrained types | JSON Schema catalog | v0.9.1 / v1.0 RC |
### Category C: Docs-site generators (6)
| Standard | Format | How UI/layout/navigation is declared | Schema | Versioning |
|----------|--------|--------------------------------------|--------|------------|
| **Mintlify** | JSON (`docs.json`) | `navigation.groups[].pages[]`; `$schema` URL; `$ref` splitting | Published JSON Schema URL | Platform |
| **PraisonAIUI** | YAML | `components` → `templates{layout, slots, zones}` → `routes` | Pydantic (internal) | `schemaVersion: 1` |
| **Docusaurus** | JS config | `themeConfig.navbar`, plugin sidebar config | TypeScript types | Package SemVer |
| **Hugo** | TOML/YAML | `menu.main[]`, theme selection | Hugo schema | Release train |
| **Jekyll** | YAML | Frontmatter per page; `_data/` nav | Conventions | SemVer |
| **Gatsby** | JS/TS | `siteMetadata` + `plugins[]` | JS object | Major versions |
### Category D: Native declarative UI — reference only (5)
| Standard | Format | Serialised wire format? | Navigation model |
|----------|--------|-------------------------|------------------|
| **Jetpack Compose** | Kotlin | No | `NavHost` + routes |
| **SwiftUI** | Swift | No | `NavigationStack` |
| **Flutter** | Dart | No | `Router` / `Navigator` |
| **Qt QML** | `.qml` | Yes | `StackView`, bindings |
| **XAML** | XML | Yes (dialect-fragmented) | `Frame`, `NavigationView` |
### Category E: Design systems (2)
| Standard | Format | Theming model |
|----------|--------|---------------|
| **Material Design 3** | DTCG JSON | reference → system → component token tiers |
| **Bootstrap 5.3** | SCSS → CSS vars | Sass maps → `--bs-*` custom properties |
### Category F: Design-to-code bridges — not source-of-truth (3)
| Standard | Output | Why not ADP source |
|----------|--------|-------------------|
| **Figma MCP** | React+Tailwind context | Proprietary; bridge only |
| **Webflow export** | HTML/CSS/JS ZIP | No schema; paid export |
| **Framer export** | React bundles | Platform lock-in |
### Category G: Component documentation (1)
| Standard | Format | Navigation |
|----------|--------|------------|
| **Storybook CSF 3** | TS modules | Sidebar via `title` path hierarchy |
### Category H: Configuration and packaging (12)
| Standard | Format | Composition mechanism | ADP borrow |
|----------|--------|----------------------|------------|
| **Kubernetes CRD** | YAML | `apiVersion/kind/spec`; structural OpenAPI | Structural validation |
| **Helm** | YAML + templates | Subcharts; values merge; `values.schema.json` | Defaults + overrides |
| **Backstage catalog** | YAML | `$yaml:`/`$json:` includes; relation graph | Envelope + includes |
| **OpenAPI 3.1** | YAML/JSON | `$ref`, `components` registry | JSON Schema foundation |
| **AsyncAPI 3.x** | YAML/JSON | Channels → messages | Future event UI |
| **JSON Schema 2020-12** | JSON | `$ref`, `$defs`, conditionals | Validation dialect |
| **CloudEvents** | JSON envelope | Fixed envelope + opaque payload | Future event wrapper |
| **Docker Compose** | YAML | Multi-file merge; profiles | Override stacking |
| **CUE** | CUE | Unify schema + data | Power-user compile layer |
| **Jsonnet** | Jsonnet → JSON | `import` + merge | Theme variants |
| **Pkl** | `.pkl` | `amends`, mixins | Typed config model |
| **OCI Artifact** | JSON manifest | Multi-layer blobs | Bundle packaging |
### Category I: Agent and workflow protocols (7)
| Standard | Format | UI/state declaration | ADP borrow |
|----------|--------|---------------------|------------|
| **MCP** | JSON-RPC | Tool schemas — no UI layout | Future widget actions |
| **AG-UI** | Streaming JSON | EventType enum; snapshots + deltas | Streaming model |
| **A2A** | Proto → JSON-RPC | Agent Cards | Transport |
| **AWP** | Multi-file YAML | 7 layers; rules R1–R32 | Layer + validation model |
| **LangGraph** | workflow.yaml | Graph nodes + edges | Layout graph |
| **AutoGen** | ComponentModel JSON | `{provider, type, version, config}` | Pluggable components |
| **CrewAI** | JSONC/YAML | Agent arrays — weak schema | Anti-pattern |
### Category J: Application packaging (2)
| Standard | Format | Application declaration |
|----------|--------|------------------------|
| **OAM** | YAML | `Application` with `components[]`, `traits[]` |
| **CNAB** | bundle.json | Parameters, credentials, actions |
---
## 4. How they do it — mechanism comparison matrix
| Mechanism | Standards | How it works | ADP |
|-----------|-----------|--------------|-----|
| **Envelope document** | K8s, Backstage, OAM, AWP | `apiVersion` + `kind` + `metadata` + `spec` | Adopt |
| **Component registry + ref** | PraisonAIUI, OpenUI | Named map; templates use `ref:` | Adopt |
| **Layout + slots** | PraisonAIUI, JSON Forms | Layout type + named regions | Adopt (unified) |
| **Route → template** | PraisonAIUI, Flutter | URL glob → template | Adopt |
| **Navigation tree** | Mintlify, Storybook | Hierarchical groups → pages | Adopt |
| **Design token file** | DTCG, Material 3 | Token JSON with aliases | Adopt |
| **Catalog allowlist** | A2UI, OpenUI | Closed component vocabulary | Adopt |
| **Schema + uiSchema split** | JSON Forms, RJSF | Data vs presentation | Partial |
| **Streaming surfaces** | A2UI, AG-UI | Incremental JSON updates | Future |
| **Layered spec** | AWP, Helm | Separate files merged at compile | Adopt |
| **File includes** | Backstage, Mintlify | `$yaml:` / `$ref` external files | Adopt |
| **Structural pruning** | K8s CRD | Reject unknown fields | Adopt |
| **Validation rules R1–Rn** | AWP | Cross-ref beyond JSON Schema | Adopt |
| **JS executable config** | Docusaurus, Gatsby | Code config | Reject |
| **Visual canvas** | Figma, Webflow | Proprietary designer | Reject |
| **Code-only UI** | Compose, SwiftUI | No wire format | Out of scope |
---
## 5. Side-by-side: four closest analogues
| Dimension | PraisonAIUI | Mintlify | A2UI | Backstage |
|-----------|-------------|----------|------|-----------|
| **Authoring format** | YAML | JSON | JSON (stream) | YAML |
| **Envelope** | `schemaVersion` only | Flat JSON | Message `version` | `apiVersion/kind/metadata/spec` |
| **Navigation** | `navigation.tabs[]` + filesystem | `navigation.groups[]` | N/A | N/A (metadata catalog) |
| **Components** | `components{id: type, props}` | N/A | Catalog + adjacency list | Entity kinds |
| **Layout** | `templates{layout, slots, zones}` | Theme only | Surfaces | N/A |
| **Routes** | `routes{match, template}` | Page paths in nav | N/A | N/A |
| **Schema published** | Pydantic only | `$schema` URL | JSON Schema catalog | JSON Schema per kind |
| **Runtime** | React SPA | Hosted platform | Any renderer | Catalog API |
| **Primary use** | Docs sites | Docs sites | Agent-generated UI | Software inventory |
**Synthesis:** ADP combines PraisonAIUI's composition model, Mintlify's schema publishing, A2UI's catalog allowlist, and Backstage's envelope and file includes.
---
## 6. YAML vs alternatives — structured debate
### 6.1 The assumption
YAML is the best authoring format for application design manifests.
### 6.2 Counter-evidence
**Mintlify** — the closest commercial docs-site analogue — uses **JSON** (`docs.json`) with a published `$schema` URL for IDE autocomplete. This works well for flat-to-moderately-nested config but becomes verbose for deep layout trees.
**A2UI** uses **JSON** as both authoring and wire format because LLMs emit structured JSON reliably and ambiguity is unacceptable at runtime.
**Docusaurus and Gatsby** use **JavaScript/TypeScript** config for programmatic power — but this sacrifices portability and LLM generation safety.
### 6.3 Format comparison
| Format | Strengths | Weaknesses for ADP |
|--------|-----------|-------------------|
| **YAML** | Comments; readable nesting; LLM-friendly; PraisonAIUI continuity | Ambiguous typing; merge key footguns; no native DRY |
| **JSON** | Unambiguous; `$schema` IDE support; Mintlify-proven | No comments; verbose for layout trees |
| **TOML** | Clear flat tables (Hugo) | Poor for nested templates/zones |
| **CUE / Jsonnet / Pkl** | Typed composition without templating | Learning curve; not LLM-default |
| **HCL** | Modules, expressions | Ecosystem-bound; overkill for UI-only |
| **JS/TS** | Full programmatic power | Not portable; not LLM-safe |
### 6.4 Recommendation
```
Author (human/LLM) → YAML (default) or JSON (alternate)
↓
ADP compiler/validator (JSON Schema 2020-12)
↓
Normalised JSON manifest (runtime)
↓
UI renderer (catalog-constrained)
```
- **Primary authoring:** YAML — best for nested `templates` / `zones` trees
- **Alternate authoring:** JSON — for teams preferring Mintlify-style `$schema` tooling
- **Never at runtime:** YAML — always compile to JSON before serve
- **Composition:** `$yaml:` file includes (Backstage), not Helm Go templates
- **Power users:** Optional CUE/Jsonnet compiles to YAML before validation
**Verdict:** YAML remains the recommended default, but ADP MUST accept JSON as an equivalent authoring format with identical semantics.
---
## 7. ADP design principles
1. **Declarative, not executable** — manifests describe intent; runtimes render; no inline code or templating logic in core spec.
2. **Schema-first** — JSON Schema 2020-12 is canonical; YAML is authoring convenience.
3. **Structural strictness** — unknown fields rejected in strict mode (K8s CRD pattern).
4. **Catalog allowlist** — component `type` must exist in published catalog (A2UI pattern).
5. **Envelope consistency** — every document uses `apiVersion`, `kind`, `metadata`, `spec`.
6. **Separation of concerns** — theme, navigation, components, templates, routes are distinct spec sections.
7. **Compile, don't interpret** — YAML → validated JSON manifest → static assets at build time.
8. **Accessibility by default** — catalog entries include ARIA role hints (WAI-ARIA pattern).
9. **Versioned protocol** — `schemaVersion` integer + `apiVersion` string; breaking changes bump version.
10. **Extension namespace** — `x-adp-*` prefix reserved; plugins register catalog layers.
11. **File composition** — `$yaml:` includes for modular configs (Backstage pattern).
12. **Route-driven rendering** — URL resolution selects template; no hardcoded layout in renderer.
---
## 8. Proposed ADP document model
```yaml
schemaVersion: 1
apiVersion: adp.io/v1alpha1
kind: Application
metadata:
name: my-docs-site
version: "1.0.0"
labels: {}
spec:
site:
title: "My Site"
description: "..."
theme:
preset: zinc # or DTCG token ref
radius: md
darkMode: true
navigation:
mode: auto # auto | manual
tabs: [...]
components:
header_main:
type: Header
props: { logoText: "..." }
templates:
docs:
layout: ThreeColumnLayout
slots:
header: { ref: header_main }
main: { type: DocContent }
routes:
- match: "/docs/**"
template: docs
content:
docs:
dir: "./docs"
```
### Data flow
```
application.yaml → Parse → JSON Schema validate → Cross-ref rules (R1–R30)
→ Compile → ui-config.json + route-manifest.json + docs-nav.json
→ React / static renderer
```
---
## 9. PraisonAIUI alignment and gaps
### Already aligned
| PraisonAIUI today | ADP equivalent |
|-------------------|----------------|
| `schemaVersion: 1` | `schemaVersion` + `apiVersion` |
| `components` + `ref:` | `spec.components` + slot refs |
| `templates.layout` + `slots` | `spec.templates` |
| `routes` | `spec.routes` |
| `site.theme` | `spec.theme` |
| Compiler → JSON manifests | ADP compile pipeline |
| Pydantic validation | JSON Schema + cross-ref rules |
### Gaps to close
| Gap | ADP fix |
|-----|---------|
| No published JSON Schema | Publish `application.schema.json` |
| `navigation.tabs` compiled but not rendered | Mandate runtime consumption in spec |
| `route-manifest.json` not used for routing | Mandate route-driven template resolution |
| Dual slots + zones models | Unified: zones = ordered slot arrays |
| Closed hardcoded component types | Published `component-catalog.schema.json` |
| No envelope (`kind`, `metadata`) | Add Backstage-style envelope |
---
## 10. Migration path
Existing `aiui.template.yaml` files remain valid. Migration steps:
1. Add `apiVersion: adp.io/v1alpha1` and `kind: Application` (optional during transition).
2. Wrap top-level keys under `spec:` (compiler accepts both shapes during v1alpha1).
3. Rename `site` fields unchanged; map `navigation`, `templates`, `routes` as-is.
4. Publish catalog; register custom component types via plugin schema layers.
See [adp/aiui-mapping.md](./adp/aiui-mapping.md) for field-by-field mapping.
---
## 11. Open questions
1. **Should ADP standardise layout type names** (`ThreeColumnLayout`) or allow runtime-defined layouts?
2. **Should navigation support both filesystem-auto and manual tabs** in one spec, or require a mode switch?
3. **Should agent-driven surfaces (A2UI) be a separate `kind: Surface`** or embedded in Application?
4. **Should theme reference DTCG token files directly** or use simplified preset shorthand?
---
## 12. Appendix A — Standards index
| # | Standard | URL |
|---|----------|-----|
| 1 | A2UI | https://a2ui.org/ |
| 2 | AG-UI | https://docs.ag-ui.com/ |
| 3 | AWP | https://github.com/veegee82/agent-workflow-protocol |
| 4 | AsyncAPI | https://www.asyncapi.com/ |
| 5 | Backstage catalog | https://backstage.io/docs/features/software-catalog/descriptor-format |
| 6 | Bootstrap | https://getbootstrap.com/ |
| 7 | CloudEvents | https://cloudevents.io/ |
| 8 | CNAB | https://cnab.io/ |
| 9 | CrewAI | https://docs.crewai.com/ |
| 10 | CUE | https://cuelang.org/ |
| 11 | Docusaurus | https://docusaurus.io/ |
| 12 | DTCG Design Tokens | https://www.designtokens.org/ |
| 13 | Figma Code Connect | https://developers.figma.com/docs/code-connect/ |
| 14 | Framer | https://www.framer.com/ |
| 15 | Gatsby | https://www.gatsbyjs.com/ |
| 16 | Helm | https://helm.sh/ |
| 17 | Hugo | https://gohugo.io/ |
| 18 | Jekyll | https://jekyllrb.com/ |
| 19 | JSON Forms | https://jsonforms.io/ |
| 20 | JSON Schema | https://json-schema.org/ |
| 21 | Kubernetes CRD | https://kubernetes.io/docs/concepts/extend-kubernetes/api-extension/custom-resources/ |
| 22 | LangGraph | https://langchain-ai.github.io/langgraph/ |
| 23 | Material Design 3 | https://m3.material.io/ |
| 24 | MCP | https://modelcontextprotocol.io/ |
| 25 | Mintlify | https://mintlify.com/docs |
| 26 | OAM | https://oam.dev/ |
| 27 | OCI Artifact | https://opencontainers.org/ |
| 28 | OpenAPI | https://www.openapis.org/ |
| 29 | OpenUI | https://openuispec.org/ |
| 30 | Pkl | https://pkl-lang.org/ |
| 31 | PraisonAIUI | https://github.com/MervinPraison/PraisonAIUI |
| 32 | RJSF | https://rjsf-team.github.io/react-jsonschema-form/ |
| 33 | Storybook CSF | https://storybook.js.org/docs/api/csf |
| 34 | W3C ARIA APG | https://www.w3.org/WAI/ARIA/apg/ |
| 35 | Web Components | https://web.dev/articles/declarative-shadow-dom |
| 36 | Webflow | https://webflow.com/ |
| 37 | AutoGen | https://microsoft.github.io/autogen/ |
| 38 | A2A | https://google.github.io/A2A/ |
| 39 | Jsonnet | https://jsonnet.org/ |
| 40 | Docker Compose | https://docs.docker.com/compose/ |
| 41 | Qt QML | https://doc.qt.io/qt-6/qmlapplications.html |
| 42 | XAML | https://learn.microsoft.com/en-us/windows/apps/develop/platform/xaml/xaml-overview |
| 43 | Jetpack Compose | https://developer.android.com/develop/ui/compose |
| 44 | SwiftUI | https://developer.apple.com/xcode/swiftui/ |
| 45 | HTML + CSS | https://html.spec.whatwg.org/ |
| 46 | Flutter | https://flutter.dev/ |
---
## 13. Conclusion
ADP is feasible and largely prefigured by PraisonAIUI's existing architecture. The research across 46 standards yields a clear synthesis:
- **Envelope** from Backstage/K8s
- **Composition** from PraisonAIUI (components → templates → routes)
- **Catalog** from A2UI/OpenUI
- **Theming** from DTCG
- **Validation** from AWP (structural schema + R-rules)
- **Authoring** in YAML (default) with JSON alternate (Mintlify pattern)
**Status:** Research draft — see [adp/implementation-status.md](./adp/implementation-status.md) for implementation gaps.
The draft spec, JSON Schema files, and example manifests are published alongside this report in [`docs/protocols/adp/`](./adp/).
@@ -25,6 +25,7 @@ from praisonaiui.server import create_app
# Use shared seed data helper
sys.path.insert(0, os.path.join(os.path.dirname(__file__), ".."))
from _shared.console import safe_print as print
from _shared.seed_data import seed_demo_data
seed_demo_data()
+1
View File
@@ -20,6 +20,7 @@ import sys
# Use shared seed data helper
sys.path.insert(0, os.path.join(os.path.dirname(__file__), ".."))
from _shared.console import safe_print as print
from _shared.seed_data import seed_demo_data
+1
View File
@@ -26,6 +26,7 @@ import time
import praisonaiui as aiui
sys.path.insert(0, os.path.join(os.path.dirname(__file__), '..', '_shared'))
from console import safe_print as print
from stream_bridge import StreamBridge
try:
+6
View File
@@ -4,9 +4,15 @@ Demonstrates ALL 16 PraisonAIUI features with real API calls.
Run: aiui run app.py
"""
import os
import sys
from starlette.testclient import TestClient
from praisonaiui.server import create_app
sys.path.insert(0, os.path.join(os.path.dirname(__file__), ".."))
from _shared.console import safe_print as print
def test_all_features():
"""Test all 16 feature APIs with real data."""
@@ -0,0 +1,34 @@
# Meeting Agent example — copy to .env and fill in. Never commit real secrets.
# Used by the PraisonAI agent for summarization/embeddings.
OPENAI_API_KEY=
# Optional: enable Slack summary delivery via the built-in SlackChannelAdapter.
SLACK_BOT_TOKEN=
SLACK_APP_TOKEN=
# Local dev server port for `python app.py`.
MEETING_AGENT_PORT=8000
# ── Recall.ai (EU workspace: Praison) ───────────────────────────────────────
# Region must match dashboard: eu-central-1 (Frankfurt).
RECALL_REGION=eu-central-1
# Optional: your Recall workspace ID (shown in the config-status API only; the
# API key already scopes requests to a workspace).
RECALL_WORKSPACE_ID=
# REST API key from Recall dashboard (Developers → API Keys). Server-side only.
RECALL_API_KEY=
# Workspace verification secret (whsec_…) from Recall MCP or dashboard.
RECALL_WEBHOOK_VERIFICATION_SECRET=
# Stable public HTTPS backend URL (reserved ngrok domain in dev). Required for
# webhooks and Google Calendar V2 OAuth callback forwarding.
PUBLIC_API_BASE_URL=
# Bot display name in meetings.
RECALL_BOT_NAME=Praison Meeting Agent
# Calendar V2: when true (default), eligible future events with video links get bots.
CALENDAR_AUTO_RECORD=true
+115
View File
@@ -0,0 +1,115 @@
# Meeting Agent (Phase 1 upload + Phase 2 Recall.ai live bots)
A PraisonAIUI example that wires the dashboard to **PraisonAI-Tools** meeting
tools for upload → transcribe → summarize → index → search, plus **Recall.ai**
Meeting Bots for live Zoom / Meet / Teams capture.
## What this example includes
| Page | Built from |
|------|-----------|
| Upload | `POST /api/meetings/upload` (audio → Phase 1 pipeline) |
| Live bot | `POST /api/recall/bots` — send a Recall bot to a meeting URL |
| Meetings | dashboard table backed by `list_meetings` |
| Meeting detail | transcript / summary / action items + live status |
| Calendar | Calendar V2 setup instructions + status API |
| Chat | meeting agent with `search_meetings` + optional `schedule_recall_bot` tool |
## Recall.ai workspace
This integration targets your Recall workspace in **EU (Frankfurt)** by default:
- Workspace ID: set `RECALL_WORKSPACE_ID` to your own (optional — the API key
already scopes requests to a workspace)
- API region: `eu-central-1`
Follow the [Recall onboarding guide](https://docs.recall.ai/docs/agent-quickstarts) via Recall MCP (`recall://guides/onboarding`) for credential and webhook setup.
## Run
```bash
cp .env.example .env # set OPENAI_API_KEY + Recall vars (server-side only)
pip install praisonai-tools[meeting] httpx
aiui run app.py
# or
python app.py # honours MEETING_AGENT_PORT
python app.py --smoke # non-binding Recall boundary smoke
```
## Schedule a live bot
```bash
curl -X POST http://127.0.0.1:8000/api/recall/bots \
-H "Content-Type: application/json" \
-d '{"meeting_url":"https://zoom.us/j/123456789","title":"Standup"}'
```
Flow: **Create Bot** → webhooks (`bot.*`, `recording.done`, `transcript.done`) →
async Recall transcription → Phase 1 summarize + index → dashboard `ready`.
## Webhooks (required)
Set `PUBLIC_API_BASE_URL` to a stable **HTTPS** backend URL (reserved ngrok
domain in dev — not localhost). Register in Recall:
- URL: `{PUBLIC_API_BASE_URL}/webhooks/recall`
- Events: `bot.joining_call`, `bot.in_call_recording`, `bot.done`, `bot.fatal`,
`recording.done`, `transcript.done`, `transcript.failed`, `transcript.data`
- Calendar (Part B): `calendar.sync_events`, `calendar.update` on the same URL
Use Recall MCP `create_webhook_endpoint` or the dashboard. Verify with
`send_test_webhook_endpoint`.
## Google Calendar V2
Default **CALENDAR_AUTO_RECORD=true** schedules bots for eligible future events
with video links once Calendar V2 is connected.
1. Set `PUBLIC_API_BASE_URL` (HTTPS tunnel).
2. Run Recall MCP `start_calendar_integration_setup` with
`production_redirect_uri={PUBLIC_API_BASE_URL}/api/recall/calendar/callback`.
3. Bootstrap the app: `POST /api/recall/calendar/setup/bootstrap` with
`setup_id` and `regional_callback_uri` from MCP.
4. Complete Google OAuth per `recall://guides/calendar-v2-setup-google`.
**Recording opt-in:** calendar sync ≠ automatic recording for every meeting;
this app uses `CALENDAR_AUTO_RECORD` (default on). Set `false` to disable
auto-schedule while keeping calendar connected.
## Environment
See `.env.example`:
| Variable | Purpose |
|----------|---------|
| `OPENAI_API_KEY` | Summaries, embeddings, chat |
| `RECALL_REGION` | `eu-central-1` |
| `RECALL_API_KEY` | Recall REST API (backend only) |
| `RECALL_WEBHOOK_VERIFICATION_SECRET` | Webhook signature verification |
| `PUBLIC_API_BASE_URL` | Public HTTPS origin for webhooks + calendar callback |
| `CALENDAR_AUTO_RECORD` | Auto-schedule bots from calendar (default `true`) |
Never commit real secrets. Reuse the existing REST key (`Mervin Praison`) when
its purpose is clear; otherwise create a purpose-named key via Recall MCP.
## Phase 2 Part B — live transcript + calendar auto-schedule
| Feature | API |
|---------|-----|
| Live transcript (SSE) | `GET /api/meetings/{id}/live-transcript` (`Accept: text/event-stream`) |
| Live transcript (poll) | Same URL, default JSON |
| Upcoming calendar events | `GET /api/recall/calendar/upcoming?hours=24` |
| Manual calendar sync | `POST /api/recall/calendar/sync` `{"calendar_id":"..."}` |
| Agent tool | `get_upcoming_meetings(hours=24)` in chat when Recall is configured |
Every new bot schedule **automatically** attaches `recording_config.realtime_endpoints`
pointing at `{PUBLIC_API_BASE_URL}/webhooks/recall` with `transcript.data` — no Recall
dashboard toggle for live transcript. Requires `PUBLIC_API_BASE_URL` in `.env`.
## Tests
From repo root:
```bash
pytest tests/unit/test_meeting_agent_example.py tests/unit/test_meeting_agent_pipeline.py tests/unit/test_meeting_agent_recall.py tests/unit/test_meeting_agent_part_b.py -v
```
@@ -0,0 +1,46 @@
# Meeting Agent — PraisonAIUI example config (PAI-MEET-UI, issue #260)
# Composes existing features (attachments, knowledge, chat, Slack) via YAML.
# Run with: aiui run app.py
site:
title: Meeting Agent
description: Upload meetings, browse summaries and action items, ask questions.
logo: "🗓️"
theme:
preset: slate
darkMode: true
radius: md
style: dashboard
layout:
mode: dashboard
dashboard:
sidebar: true
pageHeader: true
pages:
- upload
- meetings
- meeting-detail
- chat
chat:
enabled: true
name: Meeting Assistant
features:
streaming: true
reasoning: true
tools: true
fileUpload: true
feedback: true
input:
placeholder: "Ask about a past meeting..."
starters:
- label: "What did we decide about billing?"
message: "What did we decide about billing?"
icon: "🔎"
- label: "List open action items"
message: "List open action items and their owners"
icon: "✅"
+733
View File
@@ -0,0 +1,733 @@
"""Meeting Agent — PraisonAIUI example with Phase 1 upload pipeline + Recall.ai live bots.
Composes existing PraisonAIUI features plus PraisonAI-Tools meeting tools:
- Upload API → POST /api/meetings/upload (transcribe → summarize → index)
- Live bot API → POST /api/recall/bots (Recall Meeting Bot join)
- Recall webhooks → POST /webhooks/recall (verified, async processing)
- Calendar callback → GET /api/recall/calendar/callback (Calendar V2 OAuth)
- Meetings list → dashboard page backed by ``list_meetings``
- Meeting detail → latest stored meeting (transcript / summary / actions)
- Chat → PraisonAI Agent with ``search_meetings`` + meeting tools
- Slack delivery → optional via SlackChannelAdapter
Run:
aiui run app.py
python app.py
python smoke_recall.py # non-binding integration smoke
"""
from __future__ import annotations
import asyncio
import os
import sys
import tempfile
from pathlib import Path
from typing import Any
import praisonaiui as aiui
from praisonaiui.server import create_app
from starlette.requests import Request
from starlette.responses import JSONResponse
from starlette.routing import Route
_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:
"""Load ``.env`` from the example dir when vars are not already set."""
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 and key not in os.environ:
os.environ[key] = value
_load_local_env()
aiui.set_custom_js(_EXAMPLE_DIR / "plugin.js")
from pipeline import retry_pipeline, run_ingest_pipeline
from recall_routes import (
api_meeting_live_transcript,
api_recall_bot_status,
api_recall_calendar_bootstrap,
api_recall_calendar_callback,
api_recall_calendar_status,
api_recall_calendar_sync,
api_recall_calendar_upcoming,
api_recall_cancel_bot,
api_recall_config_status,
api_recall_schedule_bot,
webhook_recall,
)
CHAT_SYSTEM_PROMPT = (
"You are a meeting assistant. For any question about past meetings, decisions, "
"owners, or action items you MUST call the search_meetings tool first and answer "
"only from the retrieved transcripts and summaries. Never answer historical "
"questions from memory. Cite every claim with a citation chip showing the meeting "
"title linked to its detail page."
)
# Fallback demo rows for Slack block-shape tests and offline dashboard preview.
SAMPLE_MEETINGS: list[dict] = [
{
"id": "m-1001",
"title": "Q3 Planning Kickoff",
"date": "2026-07-01",
"status": "Ready",
"duration": "48m",
"transcript": "Alice: Let's align on Q3 goals...\nBob: Priorities are onboarding and billing.",
"summary": "Team aligned on Q3 priorities: onboarding revamp and billing reliability.",
"actions": [
{"task": "Draft onboarding spec", "owner": "Alice", "due": "2026-07-08"},
{"task": "Audit billing retries", "owner": "Bob", "due": "2026-07-10"},
],
},
]
_agent: Any | None = None
def _recall_schedule_tool():
from praisonai_tools.tools.decorator import tool
from meeting_integrations.recall.service import schedule_recall_bot as _schedule
@tool
def schedule_recall_bot(meeting_url: str, title: str = "") -> dict:
"""Schedule a Recall.ai bot to join a meeting URL (Zoom, Meet, Teams)."""
return _schedule(meeting_url, title)
return schedule_recall_bot
def _upcoming_meetings_tool():
from praisonai_tools.tools.decorator import tool
from meeting_integrations.recall.calendar_service import list_upcoming_calendar_meetings
@tool
def get_upcoming_meetings(hours: int = 24) -> list[dict]:
"""List upcoming calendar events with video links and bot schedule status."""
return list_upcoming_calendar_meetings(hours=hours)
return get_upcoming_meetings
def _load_meeting_tools():
"""Lazy-load meeting tools (keeps import-time deps light)."""
from praisonai_tools.tools.meeting_tools import (
extract_action_items,
get_meeting,
index_meeting,
list_meetings,
save_meeting,
search_meetings,
summarize_transcript,
transcribe_file,
)
tools = [
transcribe_file,
save_meeting,
get_meeting,
list_meetings,
summarize_transcript,
extract_action_items,
index_meeting,
search_meetings,
]
if os.getenv("RECALL_API_KEY"):
tools.append(_recall_schedule_tool())
tools.append(_upcoming_meetings_tool())
return tools
def get_agent():
"""Return the meeting agent (created lazily on first chat)."""
global _agent
if _agent is not None:
return _agent
try:
from praisonaiagents import Agent
except ImportError as exc:
raise ImportError(
"praisonaiagents required. Install with: pip install praisonai"
) from exc
_agent = Agent(
name="Meeting Assistant",
instructions=CHAT_SYSTEM_PROMPT,
model="gpt-4o-mini",
tools=_load_meeting_tools(),
)
# Default loop guard kills chat after 120s while search_meetings runs.
from praisonaiagents.escalation.loop_guard import LoopGuard, LoopGuardConfig
max_turn_sec = float(os.getenv("MEETING_AGENT_LOOP_GUARD_MAX_SEC", "600"))
_agent._loop_guard = LoopGuard(
LoopGuardConfig(enabled=True, max_time_per_turn=max_turn_sec)
)
return _agent
def _sync_stale_recall_status(record: dict[str, Any]) -> dict[str, Any]:
"""Refresh Recall bot status for meetings still marked joining/scheduled."""
meta = record.get("metadata") or {}
live = (meta.get("live_status") or "").lower()
bot_id = meta.get("recall_bot_id")
if not bot_id or live in ("ready", "failed", "cancelled", "processing"):
return record
if live not in ("joining", "scheduled", "waiting_room", "live", "ended", ""):
return record
if not os.getenv("RECALL_API_KEY"):
return record
try:
from meeting_integrations.recall.client import RecallClient
from meeting_integrations.recall.config import load_recall_settings
from pipeline import merge_meeting_metadata
bot = RecallClient(load_recall_settings()).get_bot(str(bot_id))
changes = bot.get("status_changes") or []
status = (changes[-1].get("code") if changes else bot.get("status") or "").lower()
meeting_id = record.get("meeting_id", "")
patch: dict[str, Any] = {}
if status == "in_waiting_room":
patch = {
"live_status": "waiting_room",
"status": "Scheduled",
"error": (
"Bot is in the Google Meet waiting room — admit "
"'Praison Meeting Agent' to start recording."
),
}
elif status in ("done", "call_ended") and not (bot.get("recordings") or []):
patch = {
"live_status": "failed",
"status": "failed",
"error": "Meeting ended but bot never recorded (likely not admitted from waiting room).",
}
elif status == "done" and (bot.get("recordings") or []):
patch = {"live_status": "processing"}
if patch and meeting_id:
merge_meeting_metadata(meeting_id, patch)
meta = {**meta, **patch}
record = {**record, "metadata": meta}
except Exception:
pass
return record
def _record_to_view(record: dict[str, Any]) -> dict[str, Any]:
"""Normalise a tool meeting record for dashboard rendering."""
meta = record.get("metadata") or {}
actions = meta.get("action_items") or []
return {
"id": record.get("meeting_id", ""),
"title": record.get("title", "Untitled"),
"date": (record.get("created_at") or "")[:10],
"status": meta.get("status", "Ready").replace("ready", "Ready").title(),
"live_status": meta.get("live_status") or "",
"recall_bot_id": meta.get("recall_bot_id") or "",
"meeting_url": meta.get("meeting_url") or record.get("source") or "",
"duration": _format_duration(meta.get("duration_seconds")),
"transcript": meta.get("transcript") or "",
"summary": meta.get("summary") or "",
"error": meta.get("error") or "",
"actions": [
{
"task": a.get("description", ""),
"owner": a.get("owner") or "—",
"due": a.get("due_date") or "—",
}
for a in actions
if isinstance(a, dict)
],
}
def _format_duration(seconds: Any) -> str:
if not seconds:
return "—"
try:
total = int(float(seconds))
except (TypeError, ValueError):
return "—"
minutes, secs = divmod(total, 60)
return f"{minutes}m {secs}s" if minutes else f"{secs}s"
def _list_meeting_views() -> list[dict[str, Any]]:
try:
from praisonai_tools.tools.meeting_tools import list_meetings
rows = list_meetings.__wrapped__(limit=50, offset=0)
if rows and isinstance(rows[0], dict) and "error" in rows[0]:
return SAMPLE_MEETINGS
rows = [_sync_stale_recall_status(r) for r in rows]
views = [_record_to_view(r) for r in rows]
return views or SAMPLE_MEETINGS
except ImportError:
return SAMPLE_MEETINGS
def _meeting(meeting_id: str) -> dict | None:
try:
from praisonai_tools.tools.meeting_tools import get_meeting
record = get_meeting.__wrapped__(meeting_id)
if "error" not in record:
return _record_to_view(record)
except ImportError:
pass
return next((m for m in SAMPLE_MEETINGS if m["id"] == meeting_id), None)
def _pick_detail_meeting(meetings: list[dict[str, Any]]) -> dict[str, Any]:
"""Prefer an in-progress live meeting for the detail view."""
for live in ("live", "joining", "waiting_room"):
for m in meetings:
if (m.get("live_status") or "").lower() == live:
return m
return meetings[0] if meetings else SAMPLE_MEETINGS[0]
def _status_variant(status: str) -> str:
return {
"Ready": "default",
"Indexing": "secondary",
"Summarizing": "secondary",
"Transcribing": "secondary",
"Uploading": "secondary",
"Error": "destructive",
"Failed": "destructive",
"Scheduled": "secondary",
"Live": "default",
"Processing": "secondary",
}.get(status, "outline")
aiui.set_style("dashboard")
aiui.set_branding(title="Meeting Agent", logo="🗓️")
aiui.set_pages(
["upload", "live-bot", "meetings", "meeting-detail", "calendar", "chat"]
)
aiui.set_dashboard(sidebar=True, page_header=True)
aiui.set_chat_features(file_upload=True, feedback=True)
async def _emit_pipeline_chat_result(result: dict[str, Any]) -> None:
"""Surface pipeline progress/errors via standard chat status events."""
if result.get("status") == "ready":
await aiui.say(
f"✅ **{result.get('title', 'Meeting')}** is ready. "
f"Indexed {result.get('chunks_indexed', 0)} chunk(s)."
)
return
if "error" in result:
await aiui.say(f"❌ {result['error']}")
meeting_id = result.get("meeting_id")
if meeting_id:
await aiui.action_buttons(
[
{
"name": "retry-meeting",
"label": "Retry",
"payload": {"meeting_id": meeting_id},
}
]
)
return
await aiui.say(str(result))
async def _chat_run_pipeline(fn, *args: Any) -> None:
await aiui.think("Running meeting pipeline...")
result = await asyncio.to_thread(fn, *args)
await _emit_pipeline_chat_result(result)
@aiui.reply
async def on_message(message: str):
"""Chat: Q&A via meeting agent; retry failed uploads via chat commands."""
text = str(message).strip()
lower = text.lower()
if lower.startswith("retry meeting "):
meeting_id = text.split(maxsplit=2)[-1].strip()
await _chat_run_pipeline(retry_pipeline, meeting_id)
return
if lower.startswith("/retry "):
meeting_id = text.split(maxsplit=1)[-1].strip()
await _chat_run_pipeline(retry_pipeline, meeting_id)
return
if not os.getenv("OPENAI_API_KEY"):
await aiui.say("Set `OPENAI_API_KEY` to chat with the meeting assistant.")
return
await aiui.think("Searching meetings before answering...")
agent = get_agent()
response = await asyncio.to_thread(agent.chat, text)
await aiui.say(str(response))
def _register_retry_action() -> None:
from praisonaiui.actions import register_action_callback
async def _on_retry_meeting(action) -> None:
meeting_id = (action.payload or {}).get("meeting_id", "")
if not meeting_id:
await aiui.say("Missing meeting id for retry.")
return
await _chat_run_pipeline(retry_pipeline, meeting_id)
register_action_callback("retry-meeting", _on_retry_meeting)
_register_retry_action()
@aiui.page("upload", title="Upload", icon="⬆️", group="Meetings", order=1)
async def upload_page():
steps = ["Uploading", "Transcribing", "Summarizing", "Indexing", "Ready"]
return aiui.layout(
[
aiui.text("Drop an audio or transcript file to add a meeting."),
aiui.alert(
"POST a multipart file to /api/meetings/upload with field ``file`` "
"(optional ``title``). Supported: mp3, mp4, wav, webm, m4a, ogg.",
variant="info",
title="Upload API",
),
aiui.code_block(
'curl -F "[email protected]" -F "title=Weekly standup" '
"http://127.0.0.1:8000/api/meetings/upload",
language="bash",
),
aiui.card("Processing pipeline", footer=" → ".join(steps)),
aiui.text(
"On error the pipeline surfaces a retry action via the standard "
"chat status events; no bespoke state machine lives in the UI. "
"In Chat, send ``retry meeting <id>`` or click **Retry** when offered."
),
]
)
@aiui.page("live-bot", title="Live bot", icon="🤖", group="Meetings", order=2)
async def live_bot_page():
port = os.environ.get("MEETING_AGENT_PORT", "8000")
return aiui.layout(
[
aiui.text(
"Send a Recall.ai Meeting Bot to a Zoom, Google Meet, or Teams URL. "
"Real-time ``transcript.data`` chunks stream during the call; when the "
"call ends, the Phase 1 summarize + index pipeline runs on the full transcript."
),
aiui.alert(
"Requires RECALL_API_KEY, RECALL_WEBHOOK_VERIFICATION_SECRET, and "
"PUBLIC_API_BASE_URL (HTTPS tunnel). Register ``transcript.data`` on the "
"same /webhooks/recall endpoint for live captions.",
variant="info",
title="Recall credentials",
),
aiui.code_block(
'curl -X POST http://127.0.0.1:'
f"{port}/api/recall/bots "
'-H "Content-Type: application/json" '
'-d \'{"meeting_url":"https://zoom.us/j/123456789","title":"Standup"}\'',
language="bash",
),
aiui.text(
"Live transcript SSE: ``GET /api/meetings/{meeting_id}/live-transcript`` "
"with ``Accept: text/event-stream``. Poll fallback: same URL without SSE."
),
]
)
@aiui.page("calendar", title="Calendar", icon="📅", group="Meetings", order=5)
async def calendar_page():
upcoming: list[dict[str, Any]] = []
status_note = "Set RECALL_API_KEY to load calendar status."
if os.getenv("RECALL_API_KEY"):
try:
from meeting_integrations.recall.calendar_service import list_upcoming_calendar_meetings
upcoming = list_upcoming_calendar_meetings(hours=48)
status_note = f"{len(upcoming)} upcoming event(s) with video links (48h window)."
except Exception as exc: # noqa: BLE001
status_note = f"Calendar API unavailable: {exc}"
rows = [
[
e.get("title", "—"),
str(e.get("start", ""))[:16],
e.get("platform") or "—",
"Yes" if e.get("bot_scheduled") else "No",
]
for e in upcoming[:10]
]
return aiui.layout(
[
aiui.text(
"Google Calendar V2 auto-schedules Recall bots for eligible future "
"events with video links when CALENDAR_AUTO_RECORD=true (default)."
),
aiui.alert(status_note, variant="info", title="Calendar status"),
aiui.table(
headers=["Title", "Start", "Platform", "Bot scheduled"],
rows=rows or [["—", "—", "—", "—"]],
),
aiui.alert(
"Complete Calendar V2 setup via Recall MCP, bootstrap OAuth callback, "
"then register calendar webhooks (calendar.sync_events, calendar.update) "
"on /webhooks/recall.",
variant="info",
title="Setup",
),
aiui.code_block(
"GET /api/recall/calendar/status\n"
"GET /api/recall/calendar/upcoming?hours=24\n"
"POST /api/recall/calendar/sync {\"calendar_id\":\"...\"}",
language="bash",
),
]
)
@aiui.page("meetings", title="Meetings", icon="📋", group="Meetings", order=3)
async def meetings_page():
meetings = _list_meeting_views()
rows = [
[
m["title"],
m["date"],
m["status"],
m.get("live_status") or "—",
m["id"],
m["duration"],
]
for m in meetings
]
return aiui.layout(
[
aiui.metric("Meetings", value=len(meetings)),
aiui.table(
headers=["Title", "Date", "Status", "Live", "ID", "Duration"],
rows=rows,
),
]
)
@aiui.page("meeting-detail", title="Meeting detail", icon="📝", group="Meetings", order=4)
async def meeting_detail_page():
meetings = _list_meeting_views()
meeting = _pick_detail_meeting(meetings)
action_rows = [[a["task"], a["owner"], a["due"]] for a in meeting["actions"]]
live = (meeting.get("live_status") or "").lower()
header: list[Any] = [
aiui.text(meeting["title"]),
aiui.badge(meeting["status"], variant=_status_variant(meeting["status"])),
aiui.text(f"ID: {meeting['id']}"),
]
if live:
variant = "default" if live == "live" else "outline"
header.append(aiui.badge(live.title(), variant=variant))
body: list[Any] = []
if meeting.get("error"):
body.append(aiui.alert(meeting["error"], variant="warning"))
if live == "live":
body.append(
aiui.alert(
"Live transcript auto-refreshes every 3 seconds on this page.",
variant="info",
title="Live",
)
)
return aiui.layout(
header
+ body
+ [
aiui.tabs(
[
{
"label": "Transcript",
"children": [aiui.code_block(meeting["transcript"] or "(empty)", language="text")],
},
{
"label": "Summary",
"children": [aiui.text(meeting["summary"] or "(not summarised yet)")],
},
{
"label": "Action items",
"children": [
aiui.table(
headers=["Task", "Owner", "Due"],
rows=action_rows or [["—", "—", "—"]],
)
],
},
]
),
]
)
def _slack_adapter():
"""Construct a Slack adapter only when tokens are configured (lazy slack_sdk)."""
bot = os.environ.get("SLACK_BOT_TOKEN", "")
app_token = os.environ.get("SLACK_APP_TOKEN", "")
if not bot or not app_token:
return None
from praisonaiui.features.platform_adapters.slack import SlackChannelAdapter
return SlackChannelAdapter({"bot_token": bot, "app_token": app_token})
def _summary_blocks(meeting: dict) -> list[dict]:
action_lines = "\n".join(
f"• {a['task']} — {a['owner']} (due {a['due']})" for a in meeting["actions"]
) or "• (none)"
return [
{"type": "header", "text": {"type": "plain_text", "text": meeting["title"]}},
{"type": "section", "text": {"type": "mrkdwn", "text": f"*Date:* {meeting['date']}"}},
{"type": "section", "text": {"type": "mrkdwn", "text": meeting["summary"]}},
{"type": "section", "text": {"type": "mrkdwn", "text": f"*Action items*\n{action_lines}"}},
{
"type": "context",
"elements": [{"type": "mrkdwn", "text": f"Dashboard: /meeting-detail#{meeting['id']}"}],
},
]
async def post_meeting_summary(meeting_id: str, channel_id: str) -> bool:
"""Deliver a meeting summary to Slack (/meeting-summary <id>)."""
meeting = _meeting(meeting_id)
if meeting is None:
return False
adapter = _slack_adapter()
if adapter is None:
return False
await adapter.start()
try:
await adapter.send_message(
channel_id,
meeting["summary"],
blocks=_summary_blocks(meeting),
)
finally:
await adapter.stop()
return True
async def api_meetings_upload(request: Request) -> JSONResponse:
"""Accept multipart upload and run the ingest pipeline."""
form = await request.form()
upload = form.get("file")
title = str(form.get("title") or "").strip() or None
if upload is None or not getattr(upload, "filename", None):
return JSONResponse({"error": "file field is required"}, status_code=400)
suffix = Path(upload.filename).suffix.lower() or ".bin"
from pipeline import AUDIO_SUFFIXES
if suffix not in AUDIO_SUFFIXES:
return JSONResponse(
{"error": f"Unsupported file type: {suffix}. Allowed: {sorted(AUDIO_SUFFIXES)}"},
status_code=400,
)
data = await upload.read()
if not data:
return JSONResponse({"error": "empty file"}, status_code=400)
uploads_dir = Path(tempfile.gettempdir()) / "praisonai_meeting_uploads"
uploads_dir.mkdir(parents=True, exist_ok=True)
dest = uploads_dir / f"{os.urandom(8).hex()}{suffix}"
dest.write_bytes(data)
result = await asyncio.to_thread(run_ingest_pipeline, str(dest), title)
if "error" in result and result.get("meeting_id"):
result["retry_hint"] = f"retry meeting {result['meeting_id']}"
result["retry_url"] = f"/api/meetings/{result['meeting_id']}/retry"
status = 200 if result.get("status") == "ready" else 400 if "error" in result else 500
return JSONResponse(result, status_code=status)
async def api_meeting_retry(request: Request) -> JSONResponse:
meeting_id = request.path_params["meeting_id"]
result = await asyncio.to_thread(retry_pipeline, meeting_id)
if "error" in result and result.get("meeting_id"):
result["retry_hint"] = f"retry meeting {result['meeting_id']}"
status = 200 if result.get("status") == "ready" else 400 if "error" in result else 500
return JSONResponse(result, status_code=status)
async def api_meetings_list(_request: Request) -> JSONResponse:
return JSONResponse({"meetings": _list_meeting_views()})
async def api_meeting_detail(request: Request) -> JSONResponse:
meeting_id = request.path_params["meeting_id"]
meeting = _meeting(meeting_id)
if meeting is None:
return JSONResponse({"error": "not found"}, status_code=404)
return JSONResponse(meeting)
app = create_app()
_meeting_routes = [
Route("/api/meetings/upload", api_meetings_upload, methods=["POST"]),
Route("/api/meetings/{meeting_id}/retry", api_meeting_retry, methods=["POST"]),
Route("/api/meetings", api_meetings_list, methods=["GET"]),
Route("/api/meetings/{meeting_id}", api_meeting_detail, methods=["GET"]),
Route("/api/recall/bots", api_recall_schedule_bot, methods=["POST"]),
Route("/api/recall/bots/{meeting_id}", api_recall_bot_status, methods=["GET"]),
Route("/api/recall/bots/{meeting_id}/cancel", api_recall_cancel_bot, methods=["POST"]),
Route("/api/recall/config", api_recall_config_status, methods=["GET"]),
Route("/webhooks/recall", webhook_recall, methods=["POST"]),
Route("/api/recall/calendar/callback", api_recall_calendar_callback, methods=["GET"]),
Route(
"/api/recall/calendar/setup/bootstrap",
api_recall_calendar_bootstrap,
methods=["POST"],
),
Route("/api/recall/calendar/status", api_recall_calendar_status, methods=["GET"]),
Route("/api/recall/calendar/upcoming", api_recall_calendar_upcoming, methods=["GET"]),
Route("/api/recall/calendar/sync", api_recall_calendar_sync, methods=["POST"]),
Route(
"/api/meetings/{meeting_id}/live-transcript",
api_meeting_live_transcript,
methods=["GET"],
),
]
# Prepend so custom /api/* routes win over the SPA catch-all from create_app().
app.routes[0:0] = _meeting_routes
if __name__ == "__main__":
if "--smoke" in sys.argv:
from smoke_recall import run_smoke
run_smoke()
raise SystemExit(0)
import uvicorn
host = os.environ.get("HOST", "127.0.0.1")
port = int(os.environ.get("MEETING_AGENT_PORT", "8000"))
uvicorn.run(app, host=host, port=port)
@@ -0,0 +1 @@
"""Meeting-agent integrations (Recall.ai, etc.)."""
@@ -0,0 +1,13 @@
"""Recall.ai Meeting Bot integration for the meeting-agent example."""
from meeting_integrations.recall.client import RecallClient
from meeting_integrations.recall.config import RecallSettings, load_recall_settings
from meeting_integrations.recall.verify import VerificationError, verify_request_from_recall
__all__ = [
"RecallClient",
"RecallSettings",
"VerificationError",
"load_recall_settings",
"verify_request_from_recall",
]
@@ -0,0 +1,33 @@
"""Calendar V2 OAuth callback forwarding for Recall-hosted setup."""
from __future__ import annotations
import logging
from typing import Any
from urllib.parse import urlencode
import httpx
logger = logging.getLogger(__name__)
FORWARD_PARAMS = ("state", "code", "error", "recall_calendar_setup_probe")
def forward_calendar_callback(
*,
regional_callback_uri: str,
query_params: dict[str, str],
) -> tuple[int, str]:
"""Forward allowed query params to Recall's regional callback URI."""
filtered = {k: v for k, v in query_params.items() if k in FORWARD_PARAMS and v}
if not filtered.get("state") and "recall_calendar_setup_probe" not in filtered:
return 400, "missing state"
url = regional_callback_uri
if filtered:
url = f"{url}?{urlencode(filtered)}"
logger.info("Forwarding calendar callback to Recall (state redacted)")
with httpx.Client(timeout=30.0, follow_redirects=True) as client:
response = client.get(url)
return response.status_code, response.text[:500]
@@ -0,0 +1,261 @@
"""Google Calendar V2 sync and auto-schedule via Recall Calendar API."""
from __future__ import annotations
import logging
from datetime import datetime, timedelta, timezone
from typing import Any
from meeting_integrations.recall.client import RecallAPIError, RecallClient
from meeting_integrations.recall.config import RecallSettings
from meeting_integrations.recall.store import RecallStore
logger = logging.getLogger(__name__)
def _parse_iso(value: str | None) -> datetime | None:
if not value:
return None
try:
normalized = value.replace("Z", "+00:00")
if normalized.endswith("+0000"):
normalized = normalized[:-5] + "+00:00"
return datetime.fromisoformat(normalized)
except ValueError:
return None
def _event_title(event: dict[str, Any]) -> str:
raw = event.get("raw") or {}
if isinstance(raw, dict):
summary = raw.get("summary") or raw.get("subject")
if summary:
return str(summary)
return "Calendar meeting"
def _deduplication_key(event: dict[str, Any]) -> str:
start = str(event.get("start_time") or "")
url = str(event.get("meeting_url") or "")
return f"{start}-{url}"
def is_eligible_for_auto_record(event: dict[str, Any]) -> bool:
"""Return True when Recall should schedule a bot for this calendar event."""
if event.get("is_deleted"):
return False
if not event.get("meeting_url"):
return False
end = _parse_iso(str(event.get("end_time") or ""))
if end and end < datetime.now(timezone.utc):
return False
raw = event.get("raw") or {}
if isinstance(raw, dict):
status = str(raw.get("status") or "").lower()
if status == "cancelled":
return False
return True
def list_upcoming_calendar_meetings(
*,
hours: int = 24,
settings: RecallSettings | None = None,
client: RecallClient | None = None,
) -> list[dict[str, Any]]:
"""List upcoming Recall calendar events with video links (for UI + agent tool)."""
from meeting_integrations.recall.config import load_recall_settings
settings = settings or load_recall_settings()
client = client or RecallClient(settings)
try:
events = client.list_calendar_events(is_deleted=False)
except RecallAPIError as exc:
logger.warning("Calendar events unavailable: %s", exc)
return []
now = datetime.now(timezone.utc)
horizon = now + timedelta(hours=max(1, hours))
rows: list[dict[str, Any]] = []
for event in events:
if not is_eligible_for_auto_record(event):
continue
start = _parse_iso(str(event.get("start_time") or ""))
if start and start > horizon:
continue
bots = event.get("bots") or []
rows.append(
{
"event_id": event.get("id"),
"title": _event_title(event),
"start": event.get("start_time"),
"end": event.get("end_time"),
"platform": (event.get("meeting_platform") or {}).get("name")
if isinstance(event.get("meeting_platform"), dict)
else event.get("meeting_platform"),
"meeting_url": event.get("meeting_url"),
"bot_scheduled": bool(bots),
"calendar_id": event.get("calendar_id"),
}
)
rows.sort(key=lambda r: str(r.get("start") or ""))
return rows
def _ensure_local_meeting_for_event(
event: dict[str, Any],
*,
settings: RecallSettings,
) -> str:
"""Create or reuse a local meeting row for a calendar event."""
event_id = str(event.get("id") or "")
if RecallStore.is_event_scheduled(event_id):
row = RecallStore.get_scheduled_bot(event_id)
if row and row.get("meeting_id"):
return str(row["meeting_id"])
from praisonai_tools.tools.meeting_tools import save_meeting
title = _event_title(event)
url = str(event.get("meeting_url") or "")
saved = save_meeting.__wrapped__(
title=title,
source=url,
metadata={
"status": "scheduled",
"live_status": "scheduled",
"meeting_url": url,
"source": "calendar",
"recall_calendar_event_id": event_id,
},
)
if "error" in saved:
raise RecallAPIError(0, saved["error"])
return str(saved["meeting_id"])
def sync_calendar_events(
calendar_id: str,
*,
updated_since: str | None = None,
settings: RecallSettings,
client: RecallClient | None = None,
auto_record: bool | None = None,
) -> dict[str, Any]:
"""Fetch changed calendar events and schedule/remove bots per policy."""
client = client or RecallClient(settings)
should_record = (
settings.calendar_auto_record if auto_record is None else auto_record
)
events = client.list_calendar_events(
calendar_id=calendar_id,
updated_at_gte=updated_since,
)
scheduled = 0
removed = 0
skipped = 0
errors: list[str] = []
for event in events:
event_id = str(event.get("id") or "")
if not event_id:
continue
eligible = is_eligible_for_auto_record(event) and should_record
has_bots = bool(event.get("bots"))
if not eligible:
if has_bots and (event.get("is_deleted") or not should_record):
try:
client.remove_bot_from_calendar_event(event_id)
removed += 1
except RecallAPIError as exc:
errors.append(f"{event_id}: remove failed: {exc.detail}")
else:
skipped += 1
continue
if RecallStore.is_event_scheduled(event_id) and has_bots:
skipped += 1
continue
try:
meeting_id = _ensure_local_meeting_for_event(event, settings=settings)
bot_config = client.build_bot_config(
meeting_id=meeting_id,
title=_event_title(event),
)
updated = client.schedule_bot_for_calendar_event(
event_id,
deduplication_key=_deduplication_key(event),
bot_config=bot_config,
)
bot_id = None
bots = updated.get("bots") or []
if bots and isinstance(bots[0], dict):
bot_id = bots[0].get("bot_id") or bots[0].get("id")
RecallStore.record_scheduled_bot(event_id, meeting_id, bot_id)
from pipeline import merge_meeting_metadata
merge_meeting_metadata(
meeting_id,
{
"recall_calendar_event_id": event_id,
"recall_bot_id": bot_id,
"status": "scheduled",
"live_status": "scheduled",
},
)
scheduled += 1
except RecallAPIError as exc:
logger.warning("Calendar schedule failed for %s: %s", event_id, exc.detail)
errors.append(f"{event_id}: {exc.detail}")
return {
"calendar_id": calendar_id,
"fetched": len(events),
"scheduled": scheduled,
"removed": removed,
"skipped": skipped,
"errors": errors,
}
def process_calendar_webhook(
event_type: str,
payload: dict[str, Any],
*,
settings: RecallSettings,
client: RecallClient | None = None,
) -> None:
"""Handle Recall Calendar V2 webhooks (calendar.update, calendar.sync_events)."""
data = payload.get("data") or {}
if not isinstance(data, dict):
return
calendar_id = str(data.get("calendar_id") or "")
if not calendar_id:
logger.warning("Calendar webhook %s missing calendar_id", event_type)
return
if event_type == "calendar.update":
logger.info("Calendar %s updated — refresh status in UI", calendar_id)
return
if event_type == "calendar.sync_events":
updated_since = str(data.get("last_updated_ts") or "") or None
result = sync_calendar_events(
calendar_id,
updated_since=updated_since,
settings=settings,
client=client,
)
logger.info("Calendar sync %s: %s", calendar_id, result)
return
logger.debug("Ignoring calendar webhook event %s", event_type)
@@ -0,0 +1,290 @@
"""Minimal Recall.ai REST client (EU region, v1.11)."""
from __future__ import annotations
import json
import logging
import random
import time
from typing import Any
import httpx
from meeting_integrations.recall.config import RecallSettings
logger = logging.getLogger(__name__)
class RecallAPIError(RuntimeError):
"""Recall API returned a non-success response."""
def __init__(self, status_code: int, detail: str):
super().__init__(detail)
self.status_code = status_code
self.detail = detail
class RecallClient:
"""HTTP client for Recall Meeting Bots and async transcription."""
def __init__(self, settings: RecallSettings):
self._settings = settings
self._base = settings.api_base.rstrip("/")
def _headers(self) -> dict[str, str]:
return {
"Authorization": self._settings.api_key,
"Accept": "application/json",
"Content-Type": "application/json",
}
def _request(
self,
method: str,
path: str,
*,
json_body: dict[str, Any] | None = None,
max_attempts: int = 4,
) -> dict[str, Any]:
url = f"{self._base}{path}"
last_error: Exception | None = None
for attempt in range(max_attempts):
try:
with httpx.Client(timeout=30.0) as client:
response = client.request(
method,
url,
headers=self._headers(),
json=json_body,
)
except httpx.HTTPError as exc:
last_error = exc
if attempt + 1 >= max_attempts:
raise RecallAPIError(0, str(exc)) from exc
time.sleep(min(2**attempt + random.random(), 8))
continue
if response.status_code == 429:
retry_after = response.headers.get("Retry-After")
delay = float(retry_after) if retry_after else min(2**attempt, 30)
time.sleep(delay + random.random())
continue
if response.status_code in {503, 507} and attempt + 1 < max_attempts:
time.sleep(min(2**attempt + random.random(), 8))
continue
if response.status_code >= 400:
detail = response.text[:500] or response.reason_phrase
raise RecallAPIError(response.status_code, detail)
if not response.content:
return {}
return response.json()
raise RecallAPIError(0, str(last_error or "request failed"))
def create_bot(
self,
*,
meeting_url: str,
meeting_id: str,
title: str,
join_at: str | None = None,
enable_realtime_transcription: bool = True,
) -> dict[str, Any]:
"""Schedule a Recall bot; attach local meeting_id in metadata for webhook routing."""
body: dict[str, Any] = {
"meeting_url": meeting_url,
"bot_name": self._settings.bot_name,
"metadata": {"meeting_id": meeting_id, "title": title},
}
if join_at:
body["join_at"] = join_at
if enable_realtime_transcription:
recording_config = self._settings.realtime_recording_config()
if recording_config:
body["recording_config"] = recording_config
logger.info(
"Live transcript enabled for meeting_id=%s → %s",
meeting_id,
self._settings.webhook_url,
)
else:
logger.warning(
"Live transcript skipped for meeting_id=%s — set PUBLIC_API_BASE_URL",
meeting_id,
)
logger.info(
"Creating Recall bot for meeting_id=%s (url redacted)",
meeting_id,
)
return self._request("POST", "/api/v1/bot/", json_body=body)
def build_bot_config(
self,
*,
meeting_id: str,
title: str,
enable_realtime_transcription: bool = True,
) -> dict[str, Any]:
"""Bot config payload for Calendar V2 schedule-bot requests."""
config: dict[str, Any] = {
"bot_name": self._settings.bot_name,
"metadata": {"meeting_id": meeting_id, "title": title},
}
if enable_realtime_transcription:
recording_config = self._settings.realtime_recording_config()
if recording_config:
config["recording_config"] = recording_config
return config
def get_bot(self, bot_id: str) -> dict[str, Any]:
return self._request("GET", f"/api/v1/bot/{bot_id}/")
def cancel_bot(self, bot_id: str) -> dict[str, Any]:
return self._request("POST", f"/api/v1/bot/{bot_id}/leave_call/")
def create_async_transcript(self, recording_id: str) -> dict[str, Any]:
body = {
"provider": {"recallai_async": {"language_code": "auto"}},
"diarization": {"use_separate_streams_when_available": True},
}
return self._request(
"POST",
f"/api/v1/recording/{recording_id}/create_transcript/",
json_body=body,
)
def get_transcript(self, transcript_id: str) -> dict[str, Any]:
return self._request("GET", f"/api/v1/transcript/{transcript_id}/")
@staticmethod
def extract_recording_id(payload: dict[str, Any]) -> str | None:
data = payload.get("data") or {}
if isinstance(data, dict):
recording = data.get("recording") or {}
if isinstance(recording, dict) and recording.get("id"):
return str(recording["id"])
inner = data.get("data") or {}
if isinstance(inner, dict):
rec = inner.get("recording") or {}
if isinstance(rec, dict) and rec.get("id"):
return str(rec["id"])
return None
@staticmethod
def extract_transcript_id(payload: dict[str, Any]) -> str | None:
data = payload.get("data") or {}
if isinstance(data, dict):
transcript = data.get("transcript") or {}
if isinstance(transcript, dict) and transcript.get("id"):
return str(transcript["id"])
return None
@staticmethod
def extract_bot_id(payload: dict[str, Any]) -> str | None:
data = payload.get("data") or {}
if isinstance(data, dict):
bot = data.get("bot") or {}
if isinstance(bot, dict) and bot.get("id"):
return str(bot["id"])
return None
@staticmethod
def extract_meeting_id_from_bot_metadata(payload: dict[str, Any]) -> str | None:
data = payload.get("data") or {}
if not isinstance(data, dict):
return None
bot = data.get("bot") or {}
if not isinstance(bot, dict):
return None
metadata = bot.get("metadata") or {}
if isinstance(metadata, dict):
mid = metadata.get("meeting_id")
return str(mid) if mid else None
return None
@staticmethod
def download_json(url: str) -> Any:
with httpx.Client(timeout=60.0) as client:
response = client.get(url)
response.raise_for_status()
return response.json()
def list_calendars(self, *, status: str | None = "connected") -> list[dict[str, Any]]:
params: dict[str, str] = {}
if status:
params["status"] = status
results: list[dict[str, Any]] = []
cursor: str | None = None
while True:
query = dict(params)
if cursor:
query["cursor"] = cursor
path = "/api/v2/calendars/"
if query:
from urllib.parse import urlencode
path = f"{path}?{urlencode(query)}"
page = self._request("GET", path)
batch = page.get("results") or []
if isinstance(batch, list):
results.extend(item for item in batch if isinstance(item, dict))
cursor = page.get("next")
if not cursor:
break
return results
def list_calendar_events(
self,
*,
calendar_id: str | None = None,
updated_at_gte: str | None = None,
is_deleted: bool | None = False,
) -> list[dict[str, Any]]:
from urllib.parse import urlencode
params: dict[str, str] = {}
if calendar_id:
params["calendar_id"] = calendar_id
if updated_at_gte:
params["updated_at__gte"] = updated_at_gte
if is_deleted is not None:
params["is_deleted"] = "true" if is_deleted else "false"
results: list[dict[str, Any]] = []
cursor: str | None = None
while True:
query = dict(params)
if cursor:
query["cursor"] = cursor
path = f"/api/v2/calendar-events/?{urlencode(query)}" if query else "/api/v2/calendar-events/"
page = self._request("GET", path)
batch = page.get("results") or []
if isinstance(batch, list):
results.extend(item for item in batch if isinstance(item, dict))
cursor = page.get("next")
if not cursor:
break
return results
def schedule_bot_for_calendar_event(
self,
event_id: str,
*,
deduplication_key: str,
bot_config: dict[str, Any],
) -> dict[str, Any]:
body = {
"deduplication_key": deduplication_key,
"bot_config": bot_config,
}
return self._request(
"POST",
f"/api/v2/calendar-events/{event_id}/bot/",
json_body=body,
)
def remove_bot_from_calendar_event(self, event_id: str) -> dict[str, Any]:
return self._request("DELETE", f"/api/v2/calendar-events/{event_id}/bot/")
@@ -0,0 +1,100 @@
"""Recall.ai runtime configuration — credentials stay server-side only."""
from __future__ import annotations
import os
from dataclasses import dataclass
from typing import Any
from urllib.parse import urlparse
class RecallConfigError(ValueError):
"""Raised when required Recall settings are missing or invalid."""
@dataclass(frozen=True)
class RecallSettings:
"""Validated Recall workspace settings injected into the API client."""
region: str
api_key: str
webhook_verification_secret: str
public_api_base_url: str
workspace_id: str
bot_name: str
calendar_auto_record: bool
@property
def api_base(self) -> str:
return f"https://{self.region}.recall.ai"
@property
def webhook_url(self) -> str:
return f"{self.public_api_base_url.rstrip('/')}/webhooks/recall"
@property
def calendar_callback_url(self) -> str:
return f"{self.public_api_base_url.rstrip('/')}/api/recall/calendar/callback"
def realtime_recording_config(self) -> dict[str, Any] | None:
"""Recording config with live ``transcript.data`` webhooks (no dashboard toggle)."""
if not self.public_api_base_url:
return None
return {
"transcript": {
"provider": {
"recallai_streaming": {
"mode": "prioritize_low_latency",
"language_code": "en",
}
},
"diarization": {
"use_separate_streams_when_available": True,
},
},
"realtime_endpoints": [
{
"type": "webhook",
"url": self.webhook_url,
"events": ["transcript.data", "transcript.partial_data"],
}
],
}
def _require(name: str) -> str:
value = os.getenv(name, "").strip()
if not value:
raise RecallConfigError(f"{name} is required for Recall integration")
return value
def load_recall_settings(*, require_public_url: bool = False) -> RecallSettings:
"""Load Recall settings from the process environment."""
region = os.getenv("RECALL_REGION", "eu-central-1").strip() or "eu-central-1"
public_url = os.getenv("PUBLIC_API_BASE_URL", "").strip()
if require_public_url and not public_url:
raise RecallConfigError(
"PUBLIC_API_BASE_URL is required (stable HTTPS backend URL for webhooks "
"and calendar OAuth callback forwarding)"
)
if public_url:
parsed = urlparse(public_url)
if parsed.scheme not in {"https", "http"} or not parsed.netloc:
raise RecallConfigError("PUBLIC_API_BASE_URL must be an absolute http(s) URL")
if parsed.hostname in {"localhost", "127.0.0.1"}:
raise RecallConfigError(
"PUBLIC_API_BASE_URL must not be localhost; use a reserved tunnel domain"
)
auto_record = os.getenv("CALENDAR_AUTO_RECORD", "true").strip().lower()
return RecallSettings(
region=region,
api_key=_require("RECALL_API_KEY"),
webhook_verification_secret=_require("RECALL_WEBHOOK_VERIFICATION_SECRET"),
public_api_base_url=public_url,
workspace_id=os.getenv("RECALL_WORKSPACE_ID", "").strip(),
bot_name=os.getenv("RECALL_BOT_NAME", "Praison Meeting Agent").strip()
or "Praison Meeting Agent",
calendar_auto_record=auto_record not in {"0", "false", "no", "off"},
)
@@ -0,0 +1,83 @@
"""In-process live transcript fan-out for SSE subscribers."""
from __future__ import annotations
import threading
from collections import defaultdict
from dataclasses import dataclass, field
from typing import Any
@dataclass
class LiveTranscriptState:
"""Latest live transcript snapshot for a meeting."""
lines: list[str] = field(default_factory=list)
text: str = ""
live_status: str = ""
_lock = threading.Lock()
_states: dict[str, LiveTranscriptState] = {}
_subscribers: dict[str, list[threading.Event]] = defaultdict(list)
def get_live_state(meeting_id: str) -> LiveTranscriptState | None:
with _lock:
return _states.get(meeting_id)
def publish_live_chunk(
meeting_id: str,
*,
line: str,
full_text: str,
live_status: str = "live",
) -> None:
"""Record a new utterance and notify SSE waiters."""
with _lock:
state = _states.setdefault(meeting_id, LiveTranscriptState())
if line:
state.lines.append(line)
state.text = full_text
state.live_status = live_status
waiters = list(_subscribers.get(meeting_id, []))
for event in waiters:
event.set()
def set_live_status(meeting_id: str, live_status: str) -> None:
with _lock:
state = _states.setdefault(meeting_id, LiveTranscriptState())
state.live_status = live_status
waiters = list(_subscribers.get(meeting_id, []))
for event in waiters:
event.set()
def wait_for_update(meeting_id: str, timeout: float = 25.0) -> LiveTranscriptState | None:
"""Block until the meeting transcript changes or timeout (for SSE long-poll)."""
event = threading.Event()
with _lock:
_subscribers[meeting_id].append(event)
state = _states.get(meeting_id)
event.wait(timeout=timeout)
with _lock:
subs = _subscribers.get(meeting_id, [])
if event in subs:
subs.remove(event)
return _states.get(meeting_id)
def snapshot_for_api(meeting_id: str, record: dict[str, Any] | None = None) -> dict[str, Any]:
"""Merge in-memory live state with persisted meeting metadata."""
meta = (record or {}).get("metadata") or {}
live = get_live_state(meeting_id)
text = (live.text if live and live.text else meta.get("transcript")) or ""
return {
"meeting_id": meeting_id,
"live_status": (live.live_status if live and live.live_status else meta.get("live_status"))
or "",
"transcript": text,
"line_count": len([ln for ln in text.splitlines() if ln.strip()]),
}
@@ -0,0 +1,226 @@
"""Async Recall webhook event processing."""
from __future__ import annotations
import logging
from typing import Any
from meeting_integrations.recall.client import RecallAPIError, RecallClient
from meeting_integrations.recall.config import RecallSettings
from meeting_integrations.recall.store import RecallStore, webhook_event_key
from meeting_integrations.recall.transcript import transcript_data_to_line, transcript_download_to_text
logger = logging.getLogger(__name__)
def _speaker_prefix(line: str) -> str:
if ":" in line:
return line.split(":", 1)[0] + ":"
return ""
def _upsert_live_transcript(meeting_id: str, line: str, *, partial: bool = False) -> str:
"""Merge one live caption line; partial updates replace the last same-speaker line."""
from praisonai_tools.tools.meeting_tools import get_meeting
from meeting_integrations.recall.live import publish_live_chunk
from pipeline import _metadata_lock
# Hold the merge lock across the read too: streaming transcript events are
# dispatched one-thread-each, so an unsynchronized read-append-write would
# let concurrent captions clobber each other and drop lines.
with _metadata_lock:
record = get_meeting.__wrapped__(meeting_id)
meta = record.get("metadata") or {} if isinstance(record, dict) else {}
existing = str(meta.get("transcript") or "")
lines = existing.splitlines()
prefix = _speaker_prefix(line)
if lines and lines[-1] == line:
full = existing
elif partial and lines and prefix and lines[-1].startswith(prefix):
lines[-1] = line
full = "\n".join(lines)
elif not partial and lines and prefix and lines[-1].startswith(prefix):
# Final utterance replaces the in-progress partial for this speaker.
lines[-1] = line
full = "\n".join(lines)
else:
full = f"{existing}\n{line}".strip() if existing else line
_merge_metadata(
meeting_id,
{"transcript": full, "live_status": "live", "status": "live"},
)
publish_live_chunk(meeting_id, line=line, full_text=full, live_status="live")
return full
def _append_live_transcript(meeting_id: str, line: str) -> str:
return _upsert_live_transcript(meeting_id, line, partial=False)
def _merge_metadata(meeting_id: str, patch: dict[str, Any]) -> None:
from pipeline import merge_meeting_metadata
merge_meeting_metadata(meeting_id, patch)
def _resolve_meeting_id(payload: dict[str, Any], bot_id: str | None) -> str | None:
meeting_id = RecallClient.extract_meeting_id_from_bot_metadata(payload)
if meeting_id:
return meeting_id
if not bot_id:
return None
try:
from praisonai_tools.tools.meeting_tools import list_meetings
rows = list_meetings.__wrapped__(limit=200, offset=0)
for row in rows:
if not isinstance(row, dict):
continue
meta = row.get("metadata") or {}
if meta.get("recall_bot_id") == bot_id:
return row.get("meeting_id")
except Exception as exc: # noqa: BLE001
logger.warning("Could not resolve meeting by bot_id: %s", exc)
return None
def process_recall_webhook(
event_type: str,
payload: dict[str, Any],
*,
settings: RecallSettings,
client: RecallClient | None = None,
) -> None:
"""Handle a verified Recall webhook (runs off the request thread)."""
event_key = webhook_event_key(event_type, payload)
if not RecallStore.mark_event_processed(event_key, event_type):
logger.info("Skipping duplicate Recall event %s", event_type)
return
client = client or RecallClient(settings)
bot_id = RecallClient.extract_bot_id(payload)
meeting_id = _resolve_meeting_id(payload, bot_id)
if event_type == "bot.joining_call" and meeting_id:
_merge_metadata(meeting_id, {"live_status": "joining", "recall_bot_id": bot_id})
return
if event_type == "bot.in_waiting_room" and meeting_id:
_merge_metadata(
meeting_id,
{
"live_status": "waiting_room",
"status": "Scheduled",
"error": "Bot is in the meeting waiting room — admit 'Praison Meeting Agent' to record.",
},
)
return
if event_type == "bot.in_call_recording" and meeting_id:
_merge_metadata(meeting_id, {"live_status": "live", "status": "live", "error": ""})
from meeting_integrations.recall.live import set_live_status
set_live_status(meeting_id, "live")
return
if event_type == "transcript.data" and meeting_id:
line = transcript_data_to_line(payload)
if line:
_append_live_transcript(meeting_id, line)
return
if event_type == "transcript.partial_data" and meeting_id:
line = transcript_data_to_line(payload)
if line:
_upsert_live_transcript(meeting_id, line, partial=True)
return
if event_type == "bot.call_ended" and meeting_id:
_merge_metadata(meeting_id, {"live_status": "ended"})
from meeting_integrations.recall.live import set_live_status
set_live_status(meeting_id, "ended")
return
if event_type == "bot.fatal" and meeting_id:
sub = ((payload.get("data") or {}).get("data") or {}).get("sub_code")
_merge_metadata(
meeting_id,
{
"live_status": "failed",
"status": "failed",
"error": f"Recall bot fatal: {sub or 'unknown'}",
},
)
return
if event_type == "bot.done" and meeting_id:
_merge_metadata(meeting_id, {"live_status": "processing"})
return
if event_type == "recording.done":
recording_id = RecallClient.extract_recording_id(payload)
if not recording_id:
logger.warning("recording.done without recording id")
return
if meeting_id:
_merge_metadata(meeting_id, {"recall_recording_id": recording_id})
started_at = ((payload.get("data") or {}).get("recording") or {}).get("started_at")
completed_at = ((payload.get("data") or {}).get("recording") or {}).get("completed_at")
if started_at and completed_at:
try:
from datetime import datetime
fmt = "%Y-%m-%dT%H:%M:%S.%f%z"
start = datetime.strptime(started_at.replace("Z", "+0000"), fmt)
end = datetime.strptime(completed_at.replace("Z", "+0000"), fmt)
_merge_metadata(
meeting_id,
{"duration_seconds": int((end - start).total_seconds())},
)
except (TypeError, ValueError):
pass
try:
client.create_async_transcript(recording_id)
logger.info("Started async transcript for recording %s", recording_id)
except RecallAPIError as exc:
logger.error("create_async_transcript failed: %s", exc)
if meeting_id:
_merge_metadata(
meeting_id,
{"status": "failed", "error": f"Transcript start failed: {exc}"},
)
return
if event_type == "transcript.failed" and meeting_id:
_merge_metadata(
meeting_id,
{"status": "failed", "error": "Recall transcript generation failed"},
)
return
if event_type == "transcript.done":
transcript_id = RecallClient.extract_transcript_id(payload)
if not transcript_id:
logger.warning("transcript.done without transcript id")
return
if not meeting_id:
logger.warning("transcript.done without meeting_id")
return
try:
artifact = client.get_transcript(transcript_id)
download_url = (artifact.get("data") or {}).get("download_url")
if not download_url:
raise RecallAPIError(0, "missing transcript download_url")
raw = client.download_json(download_url)
text = transcript_download_to_text(raw)
if not text.strip():
raise RecallAPIError(0, "empty transcript")
from pipeline import run_post_transcript_pipeline
run_post_transcript_pipeline(meeting_id, text)
except Exception as exc: # noqa: BLE001
logger.exception("Post-transcript pipeline failed")
_merge_metadata(meeting_id, {"status": "failed", "error": str(exc)})
@@ -0,0 +1,138 @@
"""High-level Recall bot scheduling used by API routes and the agent."""
from __future__ import annotations
import logging
import re
from typing import Any
from meeting_integrations.recall.client import RecallAPIError, RecallClient
from meeting_integrations.recall.config import RecallConfigError, RecallSettings, load_recall_settings
logger = logging.getLogger(__name__)
_MEETING_URL_RE = re.compile(r"^https?://", re.I)
def schedule_recall_bot(
meeting_url: str,
title: str = "",
*,
join_at: str | None = None,
settings: RecallSettings | None = None,
client: RecallClient | None = None,
) -> dict[str, Any]:
"""Persist local intent, create the Recall bot, and store the returned bot id."""
url = (meeting_url or "").strip()
if not _MEETING_URL_RE.match(url):
return {"error": "meeting_url must be an http(s) URL"}
meeting_title = (title or "Live meeting").strip() or "Live meeting"
try:
settings = settings or load_recall_settings()
except RecallConfigError as exc:
return {"error": str(exc)}
from praisonai_tools.tools.meeting_tools import save_meeting
saved = save_meeting.__wrapped__(
title=meeting_title,
source=url,
metadata={
"status": "scheduled",
"live_status": "scheduled",
"meeting_url": url,
"source": "recall",
},
)
if "error" in saved:
return saved
meeting_id = saved["meeting_id"]
api = client or RecallClient(settings)
try:
bot = api.create_bot(
meeting_url=url,
meeting_id=meeting_id,
title=meeting_title,
join_at=join_at,
)
except RecallAPIError as exc:
from pipeline import merge_meeting_metadata
merge_meeting_metadata(
meeting_id,
{
"status": "failed",
"live_status": "failed",
"error": f"Recall create bot failed: {exc.detail}",
},
)
return {
"meeting_id": meeting_id,
"error": f"Recall create bot failed: {exc.detail}",
}
bot_id = bot.get("id")
from pipeline import merge_meeting_metadata
merge_meeting_metadata(
meeting_id,
{
"recall_bot_id": bot_id,
"status": "scheduled",
"live_status": "scheduled",
"meeting_url": url,
},
)
realtime = settings.realtime_recording_config() is not None
logger.info("Scheduled Recall bot %s for meeting %s", bot_id, meeting_id)
return {
"meeting_id": meeting_id,
"recall_bot_id": bot_id,
"status": "scheduled",
"live_status": "scheduled",
"title": meeting_title,
"meeting_url": url,
"realtime_transcript_enabled": realtime,
"realtime_webhook_url": settings.webhook_url if realtime else None,
}
def cancel_recall_bot(
meeting_id: str,
*,
settings: RecallSettings | None = None,
client: RecallClient | None = None,
) -> dict[str, Any]:
"""Cancel a scheduled or in-call Recall bot."""
from praisonai_tools.tools.meeting_tools import get_meeting
from pipeline import merge_meeting_metadata
record = get_meeting.__wrapped__(meeting_id)
if "error" in record:
return record
bot_id = (record.get("metadata") or {}).get("recall_bot_id")
if not bot_id:
return {"error": "No Recall bot linked to this meeting", "meeting_id": meeting_id}
try:
settings = settings or load_recall_settings()
except RecallConfigError as exc:
return {"error": str(exc)}
api = client or RecallClient(settings)
try:
api.cancel_bot(str(bot_id))
except RecallAPIError as exc:
return {"meeting_id": meeting_id, "error": str(exc)}
merge_meeting_metadata(
meeting_id,
{"live_status": "cancelled", "status": "cancelled"},
)
return {"meeting_id": meeting_id, "recall_bot_id": bot_id, "status": "cancelled"}
@@ -0,0 +1,213 @@
"""Persist Recall webhook idempotency and calendar setup state."""
from __future__ import annotations
import json
import os
import sqlite3
from contextlib import closing
from pathlib import Path
from typing import Any
from meeting_integrations.recall.client import RecallClient
def _state_db_path() -> str:
env = os.getenv("PRAISONAI_MEETINGS_DIR")
base = Path(env) if env else Path.home() / ".praisonai" / "meetings"
base.mkdir(parents=True, exist_ok=True)
return str(base / "recall_state.db")
def _connect() -> sqlite3.Connection:
conn = sqlite3.connect(_state_db_path())
conn.execute(
"""
CREATE TABLE IF NOT EXISTS recall_webhook_events (
event_key TEXT PRIMARY KEY,
event_type TEXT NOT NULL,
processed_at TEXT NOT NULL
)
"""
)
conn.execute(
"""
CREATE TABLE IF NOT EXISTS recall_calendar_setup (
setup_id TEXT PRIMARY KEY,
platform TEXT NOT NULL,
state_json TEXT NOT NULL,
updated_at TEXT NOT NULL
)
"""
)
conn.execute(
"""
CREATE TABLE IF NOT EXISTS recall_scheduled_bots (
event_id TEXT PRIMARY KEY,
meeting_id TEXT NOT NULL,
bot_id TEXT,
scheduled_at TEXT NOT NULL
)
"""
)
return conn
def webhook_event_key(event_type: str, payload: dict[str, Any]) -> str:
"""Build a stable idempotency key from webhook metadata."""
data = payload.get("data") or {}
# Streaming transcript events fire many times per bot — never dedupe on bot_id alone.
if event_type in ("transcript.data", "transcript.partial_data"):
inner = data.get("data") if isinstance(data, dict) else {}
bot_id = RecallClient.extract_bot_id(payload) or ""
if isinstance(inner, dict):
words = inner.get("words")
if isinstance(words, list) and words:
first = words[0] if isinstance(words[0], dict) else {}
last = words[-1] if isinstance(words[-1], dict) else {}
word_text = " ".join(
w.get("text", "") for w in words if isinstance(w, dict)
).strip()
return (
f"{event_type}:{bot_id}:"
f"{first.get('start', first.get('start_time', ''))}:"
f"{last.get('end', last.get('end_time', ''))}:"
f"{word_text}"
)
text = str(inner.get("text") or "").strip()
start = inner.get("start") or inner.get("start_time") or ""
end = inner.get("end") or inner.get("end_time") or ""
speaker = inner.get("speaker") or inner.get("speaker_name") or ""
return f"{event_type}:{bot_id}:{start}:{end}:{speaker}:{text}"
return f"{event_type}:{json.dumps(payload, sort_keys=True)}"
for part in (
payload.get("id"),
data.get("id") if isinstance(data, dict) else None,
):
if part:
return f"{event_type}:{part}"
bot_id = RecallStore.extract_bot_id(payload)
recording_id = RecallStore.extract_recording_id(payload)
transcript_id = RecallStore.extract_transcript_id(payload)
suffix = bot_id or recording_id or transcript_id or json.dumps(payload, sort_keys=True)
return f"{event_type}:{suffix}"
class RecallStore:
"""SQLite-backed Recall integration state."""
@staticmethod
def extract_bot_id(payload: dict[str, Any]) -> str | None:
from meeting_integrations.recall.client import RecallClient
return RecallClient.extract_bot_id(payload)
@staticmethod
def extract_recording_id(payload: dict[str, Any]) -> str | None:
from meeting_integrations.recall.client import RecallClient
return RecallClient.extract_recording_id(payload)
@staticmethod
def extract_transcript_id(payload: dict[str, Any]) -> str | None:
from meeting_integrations.recall.client import RecallClient
return RecallClient.extract_transcript_id(payload)
@staticmethod
def mark_event_processed(event_key: str, event_type: str) -> bool:
"""Claim an event for processing; return False if already processed.
Uses an atomic ``INSERT OR IGNORE`` so concurrent duplicate webhook
deliveries (Svix retries/fan-out) cannot both pass the check — the
second insert is ignored and reports zero changed rows.
"""
from datetime import datetime, timezone
with closing(_connect()) as conn, conn:
cursor = conn.execute(
"""
INSERT OR IGNORE INTO recall_webhook_events
(event_key, event_type, processed_at)
VALUES (?, ?, ?)
""",
(event_key, event_type, datetime.now(timezone.utc).isoformat()),
)
return cursor.rowcount > 0
@staticmethod
def save_calendar_setup(setup_id: str, platform: str, state: dict[str, Any]) -> None:
from datetime import datetime, timezone
with closing(_connect()) as conn, conn:
conn.execute(
"""
INSERT INTO recall_calendar_setup (setup_id, platform, state_json, updated_at)
VALUES (?, ?, ?, ?)
ON CONFLICT(setup_id) DO UPDATE SET
platform = excluded.platform,
state_json = excluded.state_json,
updated_at = excluded.updated_at
""",
(
setup_id,
platform,
json.dumps(state, sort_keys=True),
datetime.now(timezone.utc).isoformat(),
),
)
@staticmethod
def get_calendar_setup(setup_id: str) -> dict[str, Any] | None:
with closing(_connect()) as conn:
row = conn.execute(
"SELECT state_json FROM recall_calendar_setup WHERE setup_id = ?",
(setup_id,),
).fetchone()
if not row:
return None
return json.loads(row[0])
@staticmethod
def record_scheduled_bot(event_id: str, meeting_id: str, bot_id: str | None) -> None:
from datetime import datetime, timezone
with closing(_connect()) as conn, conn:
conn.execute(
"""
INSERT INTO recall_scheduled_bots (event_id, meeting_id, bot_id, scheduled_at)
VALUES (?, ?, ?, ?)
ON CONFLICT(event_id) DO NOTHING
""",
(event_id, meeting_id, bot_id, datetime.now(timezone.utc).isoformat()),
)
@staticmethod
def is_event_scheduled(event_id: str) -> bool:
with closing(_connect()) as conn:
row = conn.execute(
"SELECT 1 FROM recall_scheduled_bots WHERE event_id = ?",
(event_id,),
).fetchone()
return row is not None
@staticmethod
def get_scheduled_bot(event_id: str) -> dict[str, Any] | None:
with closing(_connect()) as conn:
row = conn.execute(
"""
SELECT event_id, meeting_id, bot_id, scheduled_at
FROM recall_scheduled_bots WHERE event_id = ?
""",
(event_id,),
).fetchone()
if not row:
return None
return {
"event_id": row[0],
"meeting_id": row[1],
"bot_id": row[2],
"scheduled_at": row[3],
}
@@ -0,0 +1,62 @@
"""Convert Recall transcript download payloads to plain text."""
from __future__ import annotations
from typing import Any
def transcript_data_to_line(payload: dict[str, Any]) -> str:
"""Flatten a verified ``transcript.data`` webhook payload to one line."""
data = payload.get("data") or {}
if not isinstance(data, dict):
return ""
inner = data.get("data") or data
if not isinstance(inner, dict):
return ""
words = inner.get("words")
if isinstance(words, list) and words:
text = " ".join(
w.get("text", "") for w in words if isinstance(w, dict)
).strip()
else:
text = str(inner.get("text") or "").strip()
if not text:
return ""
speaker = inner.get("speaker") or inner.get("speaker_name")
participant = inner.get("participant")
if not speaker and isinstance(participant, dict):
speaker = participant.get("name")
return f"{speaker}: {text}" if speaker else text
def transcript_download_to_text(data: Any) -> str:
"""Flatten Recall transcript JSON into a readable multi-line string."""
if isinstance(data, str):
return data.strip()
if not isinstance(data, list):
return str(data)
lines: list[str] = []
for item in data:
if not isinstance(item, dict):
continue
speaker = item.get("speaker") or item.get("speaker_name")
participant = item.get("participant")
if not speaker and isinstance(participant, dict):
speaker = participant.get("name")
words = item.get("words")
if isinstance(words, list) and words:
text = " ".join(
w.get("text", "") for w in words if isinstance(w, dict)
).strip()
else:
text = str(item.get("text") or "").strip()
if not text:
continue
prefix = f"{speaker}: " if speaker else ""
lines.append(f"{prefix}{text}")
return "\n".join(lines)
@@ -0,0 +1,48 @@
"""Verify incoming Recall webhook and callback requests."""
from __future__ import annotations
import base64
import hmac
import hashlib
class VerificationError(ValueError):
"""Signature verification failed."""
def verify_request_from_recall(
*,
secret: str,
headers: dict[str, str],
payload: str | None,
) -> None:
"""Verify Recall/Svix-style webhook signatures on the raw request body."""
if not secret or not secret.startswith("whsec_"):
raise VerificationError("Verification secret is missing or invalid")
lowered = {k.lower(): v for k, v in headers.items()}
msg_id = lowered.get("webhook-id") or lowered.get("svix-id")
msg_timestamp = lowered.get("webhook-timestamp") or lowered.get("svix-timestamp")
msg_signature = lowered.get("webhook-signature") or lowered.get("svix-signature")
if not msg_id or not msg_timestamp or not msg_signature:
raise VerificationError("Missing webhook id, timestamp, or signature")
key = base64.b64decode(secret.removeprefix("whsec_"))
payload_str = payload or ""
to_sign = f"{msg_id}.{msg_timestamp}.{payload_str}"
expected_sig = base64.b64encode(
hmac.new(key, to_sign.encode("utf-8"), hashlib.sha256).digest()
).decode("ascii")
for versioned_sig in msg_signature.split(" "):
if "," not in versioned_sig:
continue
version, signature = versioned_sig.split(",", 1)
if version != "v1":
continue
if hmac.compare_digest(signature, expected_sig):
return
raise VerificationError("No matching signature found")
+295
View File
@@ -0,0 +1,295 @@
"""Meeting ingest pipeline — upload → transcribe → summarize → index.
Uses PraisonAI-Tools meeting tools with lazy imports so the UI app stays
lightweight at import time. Updates meeting metadata in SQLite between steps.
Status lives in meeting metadata (tools layer), not in UI state.
"""
from __future__ import annotations
import json
import logging
import re
import threading
from contextlib import closing
from pathlib import Path
from typing import Any
logger = logging.getLogger(__name__)
# Serializes metadata read-modify-write. Webhooks are dispatched one-thread-each
# (asyncio.to_thread), so concurrent transcript events would otherwise clobber
# each other's appends. Reentrant so callers can hold it across their own
# read + merge (see processor._upsert_live_transcript).
_metadata_lock = threading.RLock()
AUDIO_SUFFIXES = {".mp3", ".mp4", ".m4a", ".wav", ".webm", ".ogg", ".mpeg", ".mpga"}
_MEETING_ID_RE = re.compile(
r"^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$",
re.I,
)
def merge_meeting_metadata(meeting_id: str, patch: dict[str, Any]) -> dict[str, Any]:
"""Merge keys into a meeting's metadata blob."""
from praisonai_tools.tools import meeting_tools as mt
with _metadata_lock:
record = mt.get_meeting.__wrapped__(meeting_id)
if "error" in record:
return record
meta = {**(record.get("metadata") or {}), **patch}
try:
with closing(mt._connect()) as conn, conn:
conn.execute(
"UPDATE meetings SET metadata = ? WHERE meeting_id = ?",
(json.dumps(meta, sort_keys=True), meeting_id),
)
except Exception as exc: # noqa: BLE001
logger.error("merge_meeting_metadata failed: %s", exc)
return {"error": str(exc)}
return {"meeting_id": meeting_id, "metadata": meta}
def _pipeline_from_transcribe(
meeting_id: str,
file_path: str,
meeting_title: str,
) -> dict[str, Any]:
"""Shared tail: transcribe → summarize → index for a known meeting_id."""
from praisonai_tools.tools.meeting_tools import (
extract_action_items,
index_meeting,
summarize_transcript,
transcribe_file,
)
path = Path(file_path)
if not path.exists():
merge_meeting_metadata(
meeting_id,
{"status": "failed", "error": f"File not found: {file_path}"},
)
return {"meeting_id": meeting_id, "error": f"File not found: {file_path}"}
merge_meeting_metadata(
meeting_id,
{"status": "transcribing", "file_path": str(path), "error": None},
)
transcript_result = transcribe_file.__wrapped__(str(path))
if "error" in transcript_result:
merge_meeting_metadata(
meeting_id,
{"status": "failed", "error": transcript_result["error"]},
)
return {"meeting_id": meeting_id, **transcript_result}
transcript = transcript_result.get("text", "")
segments = transcript_result.get("segments") or []
merge_meeting_metadata(
meeting_id,
{
"status": "summarizing",
"transcript": transcript,
"segments": segments,
"duration_seconds": transcript_result.get("duration_seconds"),
"language": transcript_result.get("language"),
},
)
summary_result = summarize_transcript.__wrapped__(transcript)
if "error" in summary_result:
merge_meeting_metadata(
meeting_id,
{"status": "failed", "error": summary_result["error"]},
)
return {"meeting_id": meeting_id, **summary_result}
actions_result = extract_action_items.__wrapped__(transcript)
if "error" in actions_result:
merge_meeting_metadata(
meeting_id,
{"status": "failed", "error": actions_result["error"]},
)
return {"meeting_id": meeting_id, **actions_result}
merge_meeting_metadata(
meeting_id,
{
"status": "indexing",
"summary": summary_result.get("summary", ""),
"decisions": summary_result.get("decisions", []),
"topics": summary_result.get("topics", []),
"key_quotes": summary_result.get("key_quotes", []),
"action_items": actions_result.get("action_items", []),
},
)
index_result = index_meeting.__wrapped__(
meeting_id,
transcript=transcript,
segments=segments,
)
if "error" in index_result:
merge_meeting_metadata(
meeting_id,
{"status": "failed", "error": index_result["error"]},
)
return {"meeting_id": meeting_id, **index_result}
merge_meeting_metadata(meeting_id, {"status": "ready", "error": None})
return {
"meeting_id": meeting_id,
"status": "ready",
"title": meeting_title,
"summary": summary_result.get("summary", ""),
"action_items": actions_result.get("action_items", []),
"chunks_indexed": index_result.get("chunks_indexed", 0),
}
def run_ingest_pipeline(
file_path: str,
title: str | None = None,
*,
meeting_id: str | None = None,
) -> dict[str, Any]:
"""Run the full Phase 1 pipeline for an audio/video upload."""
from praisonai_tools.tools.meeting_tools import get_meeting, save_meeting
path = Path(file_path)
if not path.exists():
return {"error": f"File not found: {file_path}"}
meeting_title = (title or path.stem).strip() or "Untitled meeting"
if meeting_id:
record = get_meeting.__wrapped__(meeting_id)
if "error" in record:
return record
if title:
meeting_title = title
elif record.get("title"):
meeting_title = record["title"]
else:
saved = save_meeting.__wrapped__(
title=meeting_title,
source=str(path),
metadata={"status": "transcribing", "file_path": str(path)},
)
if "error" in saved:
return saved
meeting_id = saved["meeting_id"]
return _pipeline_from_transcribe(meeting_id, str(path), meeting_title)
def run_post_transcript_pipeline(
meeting_id: str,
transcript: str,
*,
segments: list[dict[str, Any]] | None = None,
) -> dict[str, Any]:
"""Run summarize → index for a Recall (or other) transcript without transcribe step."""
from praisonai_tools.tools.meeting_tools import (
extract_action_items,
get_meeting,
index_meeting,
summarize_transcript,
)
record = get_meeting.__wrapped__(meeting_id)
if "error" in record:
return record
meeting_title = record.get("title") or "Untitled meeting"
merge_meeting_metadata(
meeting_id,
{
"status": "summarizing",
"transcript": transcript,
"segments": segments or [],
"source": "recall",
"live_status": "ready",
"error": None,
},
)
summary_result = summarize_transcript.__wrapped__(transcript)
if "error" in summary_result:
merge_meeting_metadata(
meeting_id,
{"status": "failed", "error": summary_result["error"]},
)
return {"meeting_id": meeting_id, **summary_result}
actions_result = extract_action_items.__wrapped__(transcript)
if "error" in actions_result:
merge_meeting_metadata(
meeting_id,
{"status": "failed", "error": actions_result["error"]},
)
return {"meeting_id": meeting_id, **actions_result}
merge_meeting_metadata(
meeting_id,
{
"status": "indexing",
"summary": summary_result.get("summary", ""),
"decisions": summary_result.get("decisions", []),
"topics": summary_result.get("topics", []),
"key_quotes": summary_result.get("key_quotes", []),
"action_items": actions_result.get("action_items", []),
},
)
index_result = index_meeting.__wrapped__(
meeting_id,
transcript=transcript,
segments=segments or [],
)
if "error" in index_result:
merge_meeting_metadata(
meeting_id,
{"status": "failed", "error": index_result["error"]},
)
return {"meeting_id": meeting_id, **index_result}
merge_meeting_metadata(meeting_id, {"status": "ready", "error": None})
return {
"meeting_id": meeting_id,
"status": "ready",
"title": meeting_title,
"summary": summary_result.get("summary", ""),
"action_items": actions_result.get("action_items", []),
"chunks_indexed": index_result.get("chunks_indexed", 0),
}
def retry_pipeline(meeting_id: str) -> dict[str, Any]:
"""Re-run ingest for a failed meeting using its stored file path."""
if not meeting_id or not _MEETING_ID_RE.match(meeting_id):
return {"error": "invalid meeting_id", "meeting_id": meeting_id}
from praisonai_tools.tools.meeting_tools import get_meeting
record = get_meeting.__wrapped__(meeting_id)
if "error" in record:
return record
meta = record.get("metadata") or {}
file_path = meta.get("file_path") or record.get("source")
if not file_path:
return {
"meeting_id": meeting_id,
"error": "No uploaded file stored for this meeting",
}
return run_ingest_pipeline(
str(file_path),
title=record.get("title"),
meeting_id=meeting_id,
)
+57
View File
@@ -0,0 +1,57 @@
/**
* Meeting Agent dashboard hooks — auto-refresh live transcript on meeting-detail.
*/
(function () {
'use strict';
let pollTimer = null;
function stopPolling() {
if (pollTimer) {
clearInterval(pollTimer);
pollTimer = null;
}
}
async function refreshLiveTranscript() {
const host = document.querySelector('[data-page="meeting-detail"]');
if (!host) return;
const code = host.querySelector('.db-code-block code');
if (!code) return;
try {
const meetingsRes = await fetch('/api/meetings');
if (!meetingsRes.ok) return;
const meetingsJson = await meetingsRes.json();
const meetings = meetingsJson.meetings || [];
const live = meetings.find((m) =>
['live', 'joining', 'waiting_room'].includes(String(m.live_status || '').toLowerCase())
);
if (!live || !live.id) return;
const snapRes = await fetch(`/api/meetings/${live.id}/live-transcript`);
if (!snapRes.ok) return;
const snap = await snapRes.json();
const text = snap.transcript || '(empty)';
if (code.textContent !== text) {
code.textContent = text;
}
} catch (_err) {
/* ignore transient network errors */
}
}
function startPolling() {
stopPolling();
refreshLiveTranscript();
pollTimer = setInterval(refreshLiveTranscript, 3000);
}
window.addEventListener('aiui:page-change', (event) => {
stopPolling();
if (event.detail && event.detail.pageId === 'meeting-detail') {
startPolling();
}
});
})();
@@ -0,0 +1,61 @@
# Poll live transcript for a meeting (Part B dev test).
# Usage:
# .\poll_live_transcript.ps1
# .\poll_live_transcript.ps1 -MeetingId "aecf34bc-7194-4123-9c67-a298b56011da"
# .\poll_live_transcript.ps1 -IntervalSec 2
param(
[string]$MeetingId = "",
[int]$Port = 8000,
[int]$IntervalSec = 3
)
$ErrorActionPreference = "Stop"
$base = "http://127.0.0.1:$Port"
if (-not $MeetingId) {
$meetings = Invoke-RestMethod -Uri "$base/api/meetings"
$live = $meetings.meetings | Where-Object { @("live","joining","waiting_room") -contains ($_.live_status).ToLower() } | Select-Object -First 1
if ($live) {
$MeetingId = $live.id
} elseif ($meetings.meetings.Count -gt 0) {
$MeetingId = $meetings.meetings[0].id
} else {
throw "No meetings found. Schedule a bot first."
}
}
$url = "$base/api/meetings/$MeetingId/live-transcript"
Write-Host "Polling $url every ${IntervalSec}s (Ctrl+C to stop)"
Write-Host ""
$lastCount = -1
$lastText = ""
while ($true) {
try {
$r = Invoke-RestMethod -Uri $url -TimeoutSec 10
$count = [int]$r.line_count
$status = $r.live_status
$text = [string]$r.transcript
if ($count -ne $lastCount -or $text -ne $lastText) {
$ts = Get-Date -Format "HH:mm:ss"
Write-Host "[$ts] live_status=$status lines=$count"
if ($text) {
Write-Host "---"
Write-Host $text
Write-Host "---"
} else {
Write-Host "(empty transcript)"
}
Write-Host ""
$lastCount = $count
$lastText = $text
}
} catch {
Write-Host "[$(Get-Date -Format 'HH:mm:ss')] ERROR: $($_.Exception.Message)"
}
Start-Sleep -Seconds $IntervalSec
}
@@ -0,0 +1,337 @@
"""Recall.ai HTTP routes for the meeting-agent example."""
from __future__ import annotations
import asyncio
import json
import logging
import os
from typing import Any
from starlette.requests import Request
from starlette.responses import JSONResponse, PlainTextResponse, Response, StreamingResponse
from meeting_integrations.recall.calendar import forward_calendar_callback
from meeting_integrations.recall.calendar_service import (
list_upcoming_calendar_meetings,
process_calendar_webhook,
sync_calendar_events,
)
from meeting_integrations.recall.config import RecallConfigError, load_recall_settings
from meeting_integrations.recall.live import snapshot_for_api, wait_for_update
from meeting_integrations.recall.processor import process_recall_webhook
from meeting_integrations.recall.service import cancel_recall_bot, schedule_recall_bot
from meeting_integrations.recall.store import RecallStore
from meeting_integrations.recall.verify import VerificationError, verify_request_from_recall
logger = logging.getLogger(__name__)
_calendar_regional_uri: str | None = None
# asyncio keeps only weak references to tasks, so a bare create_task() can be
# garbage-collected mid-flight and silently drop a webhook event. Hold a strong
# reference until each background task finishes.
_background_tasks: set[asyncio.Task] = set()
def _dispatch_background(coro) -> None:
task = asyncio.create_task(coro)
_background_tasks.add(task)
task.add_done_callback(_background_tasks.discard)
def set_calendar_regional_callback_uri(uri: str | None) -> None:
global _calendar_regional_uri
_calendar_regional_uri = uri
async def api_recall_schedule_bot(request: Request) -> JSONResponse:
try:
body = await request.json()
except json.JSONDecodeError:
return JSONResponse({"error": "invalid JSON body"}, status_code=400)
meeting_url = str(body.get("meeting_url") or "").strip()
title = str(body.get("title") or "").strip()
join_at = body.get("join_at")
join_at_str = str(join_at).strip() if join_at else None
result = await asyncio.to_thread(
schedule_recall_bot,
meeting_url,
title,
join_at=join_at_str,
)
status = 201 if result.get("recall_bot_id") else 400 if "error" in result else 500
return JSONResponse(result, status_code=status)
async def api_recall_cancel_bot(request: Request) -> JSONResponse:
meeting_id = request.path_params["meeting_id"]
result = await asyncio.to_thread(cancel_recall_bot, meeting_id)
status = 200 if "error" not in result else 400
return JSONResponse(result, status_code=status)
async def api_recall_bot_status(request: Request) -> JSONResponse:
meeting_id = request.path_params["meeting_id"]
try:
from praisonai_tools.tools.meeting_tools import get_meeting
record = get_meeting.__wrapped__(meeting_id)
except ImportError:
return JSONResponse({"error": "meeting tools unavailable"}, status_code=503)
if "error" in record:
return JSONResponse(record, status_code=404)
meta = record.get("metadata") or {}
return JSONResponse(
{
"meeting_id": meeting_id,
"title": record.get("title"),
"status": meta.get("status"),
"live_status": meta.get("live_status"),
"recall_bot_id": meta.get("recall_bot_id"),
"meeting_url": meta.get("meeting_url"),
"transcript_preview": (meta.get("transcript") or "")[:500],
}
)
async def webhook_recall(request: Request) -> Response:
raw = (await request.body()).decode("utf-8")
try:
settings = load_recall_settings()
except RecallConfigError as exc:
return JSONResponse({"error": str(exc)}, status_code=503)
headers = {k: v for k, v in request.headers.items()}
try:
verify_request_from_recall(
secret=settings.webhook_verification_secret,
headers=headers,
payload=raw,
)
except VerificationError:
return JSONResponse({"error": "invalid signature"}, status_code=401)
try:
payload = json.loads(raw) if raw else {}
except json.JSONDecodeError:
return JSONResponse({"error": "invalid JSON"}, status_code=400)
event_type = str(payload.get("event") or payload.get("type") or "unknown")
if event_type.startswith("calendar."):
_dispatch_background(
asyncio.to_thread(
process_calendar_webhook,
event_type,
payload,
settings=settings,
)
)
return PlainTextResponse("ok", status_code=200)
_dispatch_background(
asyncio.to_thread(process_recall_webhook, event_type, payload, settings=settings)
)
return PlainTextResponse("ok", status_code=200)
async def api_recall_calendar_callback(request: Request) -> Response:
if not _calendar_regional_uri:
return JSONResponse(
{
"error": "Calendar callback not configured. Bootstrap setup via "
"POST /api/recall/calendar/setup/bootstrap with regional_callback_uri "
"from Recall MCP start_calendar_integration_setup."
},
status_code=503,
)
params = dict(request.query_params)
status, body = await asyncio.to_thread(
forward_calendar_callback,
regional_callback_uri=_calendar_regional_uri,
query_params=params,
)
return PlainTextResponse(body, status_code=status)
async def api_recall_calendar_bootstrap(request: Request) -> JSONResponse:
try:
body = await request.json()
except json.JSONDecodeError:
return JSONResponse({"error": "invalid JSON"}, status_code=400)
setup_id = str(body.get("setup_id") or "").strip()
platform = str(body.get("platform") or "google_calendar").strip()
regional_uri = str(body.get("regional_callback_uri") or "").strip()
state = body.get("state") if isinstance(body.get("state"), dict) else {}
if not setup_id or not regional_uri:
return JSONResponse(
{"error": "setup_id and regional_callback_uri are required"},
status_code=400,
)
set_calendar_regional_callback_uri(regional_uri)
RecallStore.save_calendar_setup(
setup_id,
platform,
{
**state,
"regional_callback_uri": regional_uri,
"platform": platform,
},
)
callback_url = "(set PUBLIC_API_BASE_URL)"
if os.getenv("PUBLIC_API_BASE_URL", "").strip():
try:
callback_url = load_recall_settings().calendar_callback_url
except RecallConfigError:
pass
return JSONResponse(
{
"setup_id": setup_id,
"callback_url": callback_url,
"status": "bootstrapped",
}
)
async def api_recall_calendar_status(_request: Request) -> JSONResponse:
auto_record = os.getenv("CALENDAR_AUTO_RECORD", "true").strip().lower() not in {
"0",
"false",
"no",
"off",
}
public_url = os.getenv("PUBLIC_API_BASE_URL", "").strip()
calendars: list[dict[str, Any]] = []
try:
from meeting_integrations.recall.client import RecallClient
settings = load_recall_settings()
calendars = RecallClient(settings).list_calendars(status="connected")
except Exception as exc: # noqa: BLE001
logger.debug("Could not list calendars: %s", exc)
return JSONResponse(
{
"calendar_v2": {
"auto_record_eligible_events": auto_record,
"connected_calendars": len(calendars),
"calendars": [
{
"id": c.get("id"),
"platform": c.get("platform"),
"email": c.get("platform_email") or c.get("email"),
"status": c.get("status"),
}
for c in calendars
if isinstance(c, dict)
],
"callback_configured": bool(_calendar_regional_uri),
"public_api_base_url_set": bool(public_url),
"webhook_events": ["calendar.sync_events", "calendar.update"],
"next_step": (
"Register calendar webhooks on the same /webhooks/recall URL, "
"then complete Google Calendar V2 OAuth via Recall MCP."
),
}
}
)
async def api_recall_calendar_upcoming(request: Request) -> JSONResponse:
hours = int(request.query_params.get("hours", "24"))
try:
rows = await asyncio.to_thread(list_upcoming_calendar_meetings, hours=hours)
except RecallConfigError as exc:
return JSONResponse({"error": str(exc)}, status_code=503)
except Exception as exc: # noqa: BLE001
return JSONResponse({"error": str(exc)}, status_code=502)
return JSONResponse({"events": rows, "hours": hours})
async def api_recall_calendar_sync(request: Request) -> JSONResponse:
try:
body = await request.json()
except json.JSONDecodeError:
body = {}
calendar_id = str(body.get("calendar_id") or request.query_params.get("calendar_id") or "").strip()
if not calendar_id:
return JSONResponse({"error": "calendar_id is required"}, status_code=400)
try:
settings = load_recall_settings()
result = await asyncio.to_thread(
sync_calendar_events,
calendar_id,
updated_since=body.get("updated_at__gte"),
settings=settings,
)
except RecallConfigError as exc:
return JSONResponse({"error": str(exc)}, status_code=503)
except Exception as exc: # noqa: BLE001
return JSONResponse({"error": str(exc)}, status_code=502)
return JSONResponse(result)
async def api_meeting_live_transcript(request: Request) -> Response:
meeting_id = request.path_params["meeting_id"]
accept = request.headers.get("accept", "")
try:
from praisonai_tools.tools.meeting_tools import get_meeting
record = get_meeting.__wrapped__(meeting_id)
except ImportError:
return JSONResponse({"error": "meeting tools unavailable"}, status_code=503)
if "error" in record:
return JSONResponse(record, status_code=404)
if "text/event-stream" not in accept:
return JSONResponse(snapshot_for_api(meeting_id, record))
async def event_stream():
last_text = ""
while True:
try:
from praisonai_tools.tools.meeting_tools import get_meeting
record = get_meeting.__wrapped__(meeting_id)
except ImportError:
break
snap = snapshot_for_api(meeting_id, record)
text = snap.get("transcript") or ""
live = (snap.get("live_status") or "").lower()
if text != last_text:
yield f"data: {json.dumps(snap)}\n\n"
last_text = text
if live not in ("live", "joining", "waiting_room", "scheduled", ""):
break
await asyncio.to_thread(wait_for_update, meeting_id, 25.0)
return StreamingResponse(event_stream(), media_type="text/event-stream")
async def api_recall_config_status(_request: Request) -> JSONResponse:
try:
settings = load_recall_settings()
configured = True
except RecallConfigError as exc:
settings = None
configured = False
error = str(exc)
else:
error = None
return JSONResponse(
{
"configured": configured,
"error": error,
"region": settings.region if settings else os.getenv("RECALL_REGION"),
"workspace_id": settings.workspace_id if settings else None,
"webhook_url": settings.webhook_url if settings and settings.public_api_base_url else None,
"bot_name": settings.bot_name if settings else None,
}
)
@@ -0,0 +1,133 @@
"""Recover a Recall meeting when webhooks missed (stale tunnel URL).
Usage:
py -3.13 recover_recall_meeting.py <meeting_id> [bot_id]
"""
from __future__ import annotations
import sys
import time
from datetime import datetime
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_env() -> None:
import os
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 and key not in os.environ:
os.environ[key] = value
def _duration_seconds(started_at: str | None, completed_at: str | None) -> int | None:
if not started_at or not completed_at:
return None
try:
fmt = "%Y-%m-%dT%H:%M:%S.%f%z"
start = datetime.strptime(started_at.replace("Z", "+0000"), fmt)
end = datetime.strptime(completed_at.replace("Z", "+0000"), fmt)
return int((end - start).total_seconds())
except (TypeError, ValueError):
return None
def recover(meeting_id: str, bot_id: str | None = None) -> dict:
from meeting_integrations.recall.client import RecallClient
from meeting_integrations.recall.config import load_recall_settings
from meeting_integrations.recall.transcript import transcript_download_to_text
from pipeline import merge_meeting_metadata, run_post_transcript_pipeline
from praisonai_tools.tools.meeting_tools import get_meeting
_load_env()
settings = load_recall_settings()
client = RecallClient(settings)
record = get_meeting.__wrapped__(meeting_id)
if "error" in record:
raise SystemExit(record["error"])
meta = record.get("metadata") or {}
bot_id = bot_id or meta.get("recall_bot_id")
if not bot_id:
raise SystemExit("No recall_bot_id — pass bot_id as second argument")
bot = client.get_bot(str(bot_id))
recordings = bot.get("recordings") or []
if not recordings:
raise SystemExit("Bot has no recordings yet")
rec_wrap = recordings[0].get("recording") or recordings[0]
recording_id = rec_wrap.get("id")
if not recording_id:
raise SystemExit("Missing recording id on bot")
duration = _duration_seconds(rec_wrap.get("started_at"), rec_wrap.get("completed_at"))
merge_meeting_metadata(
meeting_id,
{
"recall_bot_id": bot_id,
"recall_recording_id": recording_id,
"live_status": "processing",
"status": "transcribing",
**({"duration_seconds": duration} if duration else {}),
},
)
transcript_id = None
for _ in range(30):
bot = client.get_bot(str(bot_id))
recs = bot.get("recordings") or []
if recs:
rec = recs[0].get("recording") or recs[0]
shortcuts = rec.get("media_shortcuts") or {}
tr = shortcuts.get("transcript")
if isinstance(tr, dict) and tr.get("id"):
transcript_id = str(tr["id"])
break
artifacts = recs[0].get("transcript_artifacts") or rec.get("transcript_artifacts") or []
if artifacts:
transcript_id = str(artifacts[0].get("id") or "")
if transcript_id:
break
if not transcript_id:
print("Starting async transcript…")
client.create_async_transcript(str(recording_id))
time.sleep(10)
else:
break
if not transcript_id:
raise SystemExit("Timed out waiting for transcript — webhook may still deliver it")
artifact = client.get_transcript(transcript_id)
download_url = (artifact.get("data") or {}).get("download_url")
if not download_url:
raise SystemExit("Transcript ready but no download_url")
raw = client.download_json(download_url)
text = transcript_download_to_text(raw)
if not text.strip():
raise SystemExit("Transcript download was empty")
result = run_post_transcript_pipeline(meeting_id, text)
print("Recovered:", result)
return result
if __name__ == "__main__":
if len(sys.argv) < 2:
raise SystemExit("Usage: recover_recall_meeting.py <meeting_id> [bot_id]")
recover(sys.argv[1], sys.argv[2] if len(sys.argv) > 2 else None)
@@ -0,0 +1,87 @@
"""Non-binding smoke checks for Recall integration boundaries."""
from __future__ import annotations
import base64
import hashlib
import hmac
import json
import os
import sys
from pathlib import Path
from unittest.mock import patch
from starlette.testclient import TestClient
_EXAMPLE_DIR = Path(__file__).resolve().parent
_REPO_SRC = _EXAMPLE_DIR.parents[2] / "src"
for _p in (_EXAMPLE_DIR, str(_REPO_SRC)):
if str(_p) not in sys.path:
sys.path.insert(0, str(_p))
def _sign_payload(secret: str, body: str) -> dict[str, str]:
key = base64.b64decode(secret.removeprefix("whsec_"))
msg_id = "msg_smoke_test"
timestamp = "1700000000"
to_sign = f"{msg_id}.{timestamp}.{body}"
signature = base64.b64encode(
hmac.new(key, to_sign.encode("utf-8"), hashlib.sha256).digest()
).decode("ascii")
return {
"webhook-id": msg_id,
"webhook-timestamp": timestamp,
"webhook-signature": f"v1,{signature}",
}
def run_smoke() -> None:
os.environ.setdefault("RECALL_REGION", "eu-central-1")
os.environ.setdefault("RECALL_API_KEY", "smoke-test-key")
os.environ.setdefault("RECALL_WEBHOOK_VERIFICATION_SECRET", "whsec_" + base64.b64encode(b"smoke-secret-key-32-bytes-long!!").decode())
os.environ.setdefault("RECALL_WORKSPACE_ID", "smoke-workspace")
os.environ.setdefault("PUBLIC_API_BASE_URL", "https://smoke.example.test")
import app as meeting_app # noqa: WPS433
from meeting_integrations.recall.config import load_recall_settings
from meeting_integrations.recall.verify import verify_request_from_recall
settings = load_recall_settings()
assert settings.api_key == "smoke-test-key"
body = json.dumps({"event": "bot.joining_call", "data": {"bot": {"id": "b1", "metadata": {"meeting_id": "m1"}}}})
headers = _sign_payload(settings.webhook_verification_secret, body)
verify_request_from_recall(
secret=settings.webhook_verification_secret,
headers=headers,
payload=body,
)
client = TestClient(meeting_app.app)
assert client.get("/health").status_code == 200
bad = client.post("/webhooks/recall", content=body, headers={"webhook-id": "x"})
assert bad.status_code == 401
ok = client.post("/webhooks/recall", content=body, headers=headers)
assert ok.status_code == 200
with patch(
"recall_routes.schedule_recall_bot",
return_value={
"meeting_id": "m-smoke",
"recall_bot_id": "bot-smoke",
"status": "scheduled",
},
):
schedule = client.post(
"/api/recall/bots",
json={"meeting_url": "https://zoom.us/j/123", "title": "Smoke"},
)
assert schedule.status_code == 201
print("Recall smoke checks passed")
if __name__ == "__main__":
run_smoke()
+112
View File
@@ -0,0 +1,112 @@
# Start meeting-agent (Part B) + Cloudflare tunnel for Recall webhooks.
# Usage: .\start_dev.ps1
# Optional: $env:TEST_MEETING_URL = "https://meet.google.com/..." ; .\start_dev.ps1 -ScheduleBot
param(
[switch]$ScheduleBot,
[string]$MeetingUrl = $env:TEST_MEETING_URL,
[int]$Port = 8000
)
$ErrorActionPreference = "Stop"
$Root = Split-Path -Parent $MyInvocation.MyCommand.Path
Set-Location $Root
function Load-DotEnv {
$envFile = Join-Path $Root ".env"
if (-not (Test-Path $envFile)) { return }
Get-Content $envFile | ForEach-Object {
$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)) {
Set-Item -Path "Env:$key" -Value $value.Trim()
}
}
}
function Test-PortListening([int]$p) {
try {
$r = Invoke-WebRequest -Uri "http://127.0.0.1:$p/api/recall/config" -UseBasicParsing -TimeoutSec 2
return $r.StatusCode -eq 200
} catch { return $false }
}
function Update-PublicUrl([string]$baseUrl) {
$envFile = Join-Path $Root ".env"
$content = Get-Content $envFile -Raw
if ($content -match "PUBLIC_API_BASE_URL=.*") {
$newContent = $content -replace "PUBLIC_API_BASE_URL=.*", "PUBLIC_API_BASE_URL=$baseUrl"
} else {
$newContent = $content.TrimEnd() + "`nPUBLIC_API_BASE_URL=$baseUrl`n"
}
Set-Content -Path $envFile -Value $newContent -NoNewline
$env:PUBLIC_API_BASE_URL = $baseUrl
Write-Host "Updated .env PUBLIC_API_BASE_URL=$baseUrl"
}
Load-DotEnv
$env:MEETING_AGENT_PORT = "$Port"
if (-not (Test-PortListening $Port)) {
Write-Host "Starting meeting-agent on port $Port..."
Start-Process -FilePath "py" -ArgumentList "-3.13", "app.py" -WorkingDirectory $Root -WindowStyle Minimized
$deadline = (Get-Date).AddSeconds(45)
while ((Get-Date) -lt $deadline) {
if (Test-PortListening $Port) { break }
Start-Sleep -Seconds 2
}
if (-not (Test-PortListening $Port)) {
throw "App did not become ready on http://127.0.0.1:$Port"
}
Write-Host "App ready: http://127.0.0.1:$Port"
} else {
Write-Host "App already running on port $Port"
}
$public = $env:PUBLIC_API_BASE_URL
try {
$null = Invoke-WebRequest -Uri "$public/api/recall/config" -UseBasicParsing -TimeoutSec 5
Write-Host "Tunnel OK: $public"
} catch {
Write-Host "Starting Cloudflare tunnel (quick tunnel - URL may change)..."
$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 (or $log). Check cloudflared is installed." }
Update-PublicUrl $newUrl
Write-Host "Tunnel ready: $newUrl"
Write-Host "If Recall webhook URL changed, update it in Recall dashboard Webhooks"
}
$config = Invoke-RestMethod -Uri "http://127.0.0.1:$Port/api/recall/config"
Write-Host "Recall configured: $($config.configured)"
if ($ScheduleBot) {
if (-not $MeetingUrl) {
Write-Host "No TEST_MEETING_URL - skip bot schedule. Set env TEST_MEETING_URL or pass -MeetingUrl"
} else {
$body = @{ meeting_url = $MeetingUrl; title = "Part B live transcript test" } | ConvertTo-Json
$result = Invoke-RestMethod -Method Post -Uri "http://127.0.0.1:$Port/api/recall/bots" `
-ContentType "application/json" -Body $body
Write-Host "Bot scheduled:" ($result | ConvertTo-Json -Compress)
}
}
Write-Host "Done. Dashboard: http://127.0.0.1:$Port"
+51
View File
@@ -0,0 +1,51 @@
# Voice Agent — local secrets. Never commit.
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=
+118
View File
@@ -0,0 +1,118 @@
# Voice Agent example — PraisonAIUI
Phone and web voice agents using an external telephony/voice platform for STT/TTS/calls and PraisonAI for tools + dashboard.
## Quick start
```powershell
cd examples/python/voice-agent
copy .env.example .env
# Fill VOICE_API_BASE, VOICE_PRIVATE_API_KEY, VOICE_ASSISTANT_ID, etc.
# Fully automatic (app + tunnel + provider config):
.\setup_all.ps1
# Or step by step:
.\start_dev.ps1
```
Dashboard: http://127.0.0.1:8001
## Provider setup
1. Create an **assistant** with tools `get_current_time` and `echo_message`
2. Set **webhook URL** to `{PUBLIC_API_BASE_URL}/webhooks/voice`
3. Set **webhook secret header** `X-Voice-Webhook-Secret` → same as `VOICE_SERVER_URL_SECRET` in `.env`
4. Add a phone number and copy `VOICE_PHONE_NUMBER_ID`
Or run `py -3.13 setup_voice_provider.py` after `start_dev.ps1`.
## API
| Route | Method | Purpose |
|-------|--------|---------|
| `/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 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).
## Tests
```bash
pytest tests/unit/test_voice_agent.py -v
```
+285
View File
@@ -0,0 +1,285 @@
"""Voice Agent — PraisonAIUI example with telephony webhooks.
- Outbound calls → POST /api/voice/calls
- Provider webhooks → POST /webhooks/voice (tool-calls, transcript, end-of-call)
- Dashboard → Calls list + live call detail + web talk + chat bridge
Run:
cd examples/python/voice-agent
pip install httpx
copy .env.example .env
.\\start_dev.ps1
python app.py
"""
from __future__ import annotations
import asyncio
import os
import sys
from pathlib import Path
from typing import Any
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:
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
_load_local_env()
aiui.set_pages(
[
"chat",
"voice-calls",
"call-detail",
"web-talk",
"eleven-talk",
"realtime-talk",
"mcp",
"config",
]
)
aiui.set_style("dashboard")
aiui.set_custom_js(_EXAMPLE_DIR / "plugin.js")
def get_agent():
from integrations.voice.agent_runner import get_voice_agent
return get_voice_agent()
aiui.register_agent("voice-assistant", get_agent())
@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)
if demo is not None:
await aiui.say(demo)
return
if not os.getenv("OPENAI_API_KEY"):
await aiui.say(
"Set `OPENAI_API_KEY` for open-ended chat. "
'Demo without API key: "What time is it?" or "Echo hello world".'
)
return
await aiui.think("Running voice assistant...")
agent = get_agent()
response = await asyncio.to_thread(agent.chat, text)
await aiui.say(str(response))
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()
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]:
live = next((c for c in calls if "progress" in (c.get("status") or "").lower()), None)
return live or (calls[0] if calls else {"id": "—", "status": "none", "customer_number": "—", "transcript_preview": ""})
@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["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", "Duration", "Turns", "Updated"],
rows=rows or [["—", "—", "—", "—", "—", "—"]],
),
aiui.alert(
"Open **Call detail** for live transcript (SSE). "
"Voice sessions also appear in **Chat** sidebar as 📞 entries.",
variant="info",
title="Phase 2",
),
]
)
@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
if record:
return build_call_detail_layout(record)
return aiui.layout(
[
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",
),
]
)
app = create_app()
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__":
import uvicorn
host = os.environ.get("HOST", "127.0.0.1")
port = int(os.environ.get("VOICE_AGENT_PORT", "8001"))
uvicorn.run(app, host=host, port=port)
+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 @@
"""Voice agent integrations."""
@@ -0,0 +1 @@
"""Voice telephony provider integration."""
@@ -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())
@@ -0,0 +1,76 @@
"""Minimal voice provider REST client."""
from __future__ import annotations
import logging
from typing import Any
from integrations.voice.config import VoiceSettings
logger = logging.getLogger(__name__)
class VoiceAPIError(RuntimeError):
def __init__(self, status_code: int, detail: str):
super().__init__(detail)
self.status_code = status_code
self.detail = detail
class VoiceClient:
"""HTTP client for voice provider calls and assistants."""
def __init__(self, settings: VoiceSettings):
self._settings = settings
def _headers(self) -> dict[str, str]:
return {
"Authorization": f"Bearer {self._settings.api_key}",
"Accept": "application/json",
"Content-Type": "application/json",
}
def _request(
self,
method: str,
path: str,
*,
json_body: dict[str, Any] | None = None,
) -> dict[str, Any]:
import httpx # lazy: optional dependency for the example (pip install httpx)
url = f"{self._settings.api_base.rstrip('/')}{path}"
with httpx.Client(timeout=30.0) as client:
response = client.request(method, url, headers=self._headers(), json=json_body)
if response.status_code >= 400:
raise VoiceAPIError(response.status_code, response.text[:500] or response.reason_phrase)
if not response.content:
return {}
data = response.json()
return data if isinstance(data, dict) else {"data": data}
def create_call(
self,
*,
customer_number: str,
assistant_id: str | None = None,
phone_number_id: str | None = None,
metadata: dict[str, Any] | None = None,
) -> dict[str, Any]:
assistant_id = assistant_id or self._settings.default_assistant_id
phone_number_id = phone_number_id or self._settings.default_phone_number_id
if not assistant_id:
raise VoiceAPIError(0, "assistant_id is required (set VOICE_ASSISTANT_ID or pass explicitly)")
if not phone_number_id:
raise VoiceAPIError(0, "phone_number_id is required (set VOICE_PHONE_NUMBER_ID or pass explicitly)")
body: dict[str, Any] = {
"assistantId": assistant_id,
"phoneNumberId": phone_number_id,
"customer": {"number": customer_number},
}
if metadata:
body["metadata"] = metadata
return self._request("POST", "/call", json_body=body)
def get_call(self, call_id: str) -> dict[str, Any]:
return self._request("GET", f"/call/{call_id}")
@@ -0,0 +1,74 @@
"""Voice provider configuration loaded from environment."""
from __future__ import annotations
import os
from dataclasses import dataclass
from pathlib import Path
class VoiceConfigError(ValueError):
"""Missing or invalid voice provider configuration."""
@dataclass(frozen=True)
class VoiceSettings:
api_key: str
server_url_secret: str | None
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
def webhook_url(self) -> str | None:
if not self.public_api_base_url:
return None
return f"{self.public_api_base_url.rstrip('/')}/webhooks/voice"
@property
def configured(self) -> bool:
return bool(self.api_key and self.api_base)
def _read_dotenv(name: str) -> str | None:
root = Path(__file__).resolve().parents[2]
env_path = root / ".env"
if not env_path.is_file():
return None
prefix = f"{name}="
for raw in env_path.read_text(encoding="utf-8").splitlines():
line = raw.strip()
if line.startswith(prefix):
return line[len(prefix) :].strip() or None
return None
def _env(name: str) -> str:
return os.getenv(name, "").strip() or (_read_dotenv(name) or "")
def load_voice_settings(*, require_key: bool = True) -> VoiceSettings:
api_key = _env("VOICE_PRIVATE_API_KEY") or _env("VOICE_API_KEY")
api_base = _env("VOICE_API_BASE")
if require_key and not api_key:
raise VoiceConfigError("VOICE_PRIVATE_API_KEY (or VOICE_API_KEY) is required")
if require_key and not api_base:
raise VoiceConfigError("VOICE_API_BASE is required")
return VoiceSettings(
api_key=api_key,
server_url_secret=_env("VOICE_SERVER_URL_SECRET") or None,
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()
@@ -0,0 +1,180 @@
"""Process voice provider webhook events."""
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
logger = logging.getLogger(__name__)
def _message(payload: dict[str, Any]) -> dict[str, Any]:
msg = payload.get("message")
return msg if isinstance(msg, dict) else payload
def _call_id(payload: dict[str, Any]) -> str | None:
msg = _message(payload)
call = msg.get("call")
if isinstance(call, dict) and call.get("id"):
return str(call["id"])
return None
def _customer_number(payload: dict[str, Any]) -> str | None:
msg = _message(payload)
call = msg.get("call") if isinstance(msg.get("call"), dict) else {}
customer = call.get("customer") if isinstance(call, dict) else {}
if isinstance(customer, dict) and customer.get("number"):
return str(customer["number"])
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:
"""Handle a verified provider webhook POST. Returns response body when required."""
event_key = webhook_event_key(event_type, payload)
if event_type not in ("tool-calls", "assistant-request", "transfer-destination-request", "knowledge-base-request"):
if not VoiceCallStore.mark_event_processed(event_key, event_type):
logger.info("Skipping duplicate voice event %s", event_type)
return None
msg = _message(payload)
call_id = _call_id(payload)
if event_type == "assistant-request":
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 "")
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}
if event_type == "status-update" and call_id:
status = str(msg.get("status") or "unknown")
VoiceCallStore.upsert_call(call_id, status=status, customer_number=_customer_number(payload))
return None
if event_type == "transcript" and call_id:
role = str(msg.get("role") or "unknown")
text = str(msg.get("transcript") or "").strip()
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:
artifact = msg.get("artifact") if isinstance(msg.get("artifact"), dict) else {}
transcript = artifact.get("transcript") if isinstance(artifact, dict) else None
summary = str(msg.get("summary") or "")
ended = str(msg.get("endedReason") or "ended")
VoiceCallStore.upsert_call(
call_id,
status=f"ended ({ended})",
customer_number=_customer_number(payload),
transcript=str(transcript) if transcript else None,
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:
messages = msg.get("messages") or []
lines = []
for item in messages:
if not isinstance(item, dict):
continue
role = item.get("role") or "unknown"
text = item.get("message") or item.get("content") or ""
if text:
lines.append(f"{role}: {text}")
if lines:
VoiceCallStore.upsert_call(
call_id, transcript="\n".join(lines), customer_number=_customer_number(payload)
)
return None
logger.debug("Unhandled voice event %s: %s", event_type, json.dumps(payload)[:300])
return None
@@ -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)
@@ -0,0 +1,252 @@
"""Persist voice call state and webhook idempotency."""
from __future__ import annotations
import json
import os
import sqlite3
from contextlib import closing
from datetime import datetime, timezone
from pathlib import Path
from typing import Any
def _state_db_path() -> str:
env = os.getenv("PRAISONAI_VOICE_DIR")
base = Path(env) if env else Path.home() / ".praisonai" / "voice"
base.mkdir(parents=True, exist_ok=True)
return str(base / "voice_state.db")
def _connect() -> sqlite3.Connection:
conn = sqlite3.connect(_state_db_path())
conn.execute(
"""
CREATE TABLE IF NOT EXISTS voice_calls (
call_id TEXT PRIMARY KEY,
status TEXT NOT NULL,
customer_number TEXT,
transcript TEXT NOT NULL DEFAULT '',
summary TEXT NOT NULL DEFAULT '',
metadata_json TEXT NOT NULL DEFAULT '{}',
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL
)
"""
)
conn.execute(
"""
CREATE TABLE IF NOT EXISTS voice_webhook_events (
event_key TEXT PRIMARY KEY,
event_type TEXT NOT NULL,
processed_at TEXT NOT NULL
)
"""
)
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
def webhook_event_key(event_type: str, payload: dict[str, Any]) -> str:
message = payload.get("message") if isinstance(payload.get("message"), dict) else payload
call = (message or {}).get("call") if isinstance(message, dict) else {}
call_id = call.get("id") if isinstance(call, dict) else None
if isinstance(message, dict):
# Derive the key from the fields that actually distinguish one event from
# the next. A truncated JSON dump is unsafe here: when a large static field
# (e.g. ``call``) sorts before the discriminating field, successive events
# collapse to the same prefix and later ones get dropped as "duplicates"
# (e.g. a status-update stuck on "ringing").
if event_type == "transcript":
text = str(message.get("transcript") or "")
role = str(message.get("role") or "")
kind = str(message.get("transcriptType") or "")
return f"{event_type}:{call_id}:{role}:{kind}:{text}"
if event_type == "status-update":
return f"{event_type}:{call_id}:{message.get('status')}"
if event_type == "conversation-update":
messages = message.get("messages")
count = len(messages) if isinstance(messages, list) else 0
return f"{event_type}:{call_id}:{count}"
if event_type == "end-of-call-report":
return f"{event_type}:{call_id}:{message.get('endedReason')}"
if call_id:
return f"{event_type}:{call_id}:{json.dumps(message, sort_keys=True)[:200]}"
return f"{event_type}:{json.dumps(payload, sort_keys=True)[:300]}"
class VoiceCallStore:
@staticmethod
def mark_event_processed(event_key: str, event_type: str) -> bool:
now = datetime.now(timezone.utc).isoformat()
with closing(_connect()) as conn, conn:
row = conn.execute(
"SELECT 1 FROM voice_webhook_events WHERE event_key = ?",
(event_key,),
).fetchone()
if row:
return False
conn.execute(
"""
INSERT INTO voice_webhook_events (event_key, event_type, processed_at)
VALUES (?, ?, ?)
""",
(event_key, event_type, now),
)
return True
@staticmethod
def upsert_call(
call_id: str,
*,
status: str | None = None,
customer_number: str | None = None,
transcript: str | None = None,
summary: str | None = None,
metadata: dict[str, Any] | None = None,
) -> None:
now = datetime.now(timezone.utc).isoformat()
with closing(_connect()) as conn, conn:
row = conn.execute(
"SELECT transcript, summary, metadata_json, customer_number, status FROM voice_calls WHERE call_id = ?",
(call_id,),
).fetchone()
existing_transcript = row[0] if row else ""
existing_summary = row[1] if row else ""
existing_meta = json.loads(row[2]) if row and row[2] else {}
existing_number = row[3] if row else None
existing_status = row[4] if row else None
# Preserve prior status when the caller doesn't supply one (e.g. transcript
# / conversation-update events) so partial updates don't clobber live state.
final_status = status if status is not None else (existing_status or "unknown")
merged_meta = {**existing_meta, **(metadata or {})}
conn.execute(
"""
INSERT INTO voice_calls (
call_id, status, customer_number, transcript, summary, metadata_json, created_at, updated_at
) VALUES (?, ?, ?, ?, ?, ?, ?, ?)
ON CONFLICT(call_id) DO UPDATE SET
status = excluded.status,
customer_number = COALESCE(excluded.customer_number, voice_calls.customer_number),
transcript = excluded.transcript,
summary = COALESCE(excluded.summary, voice_calls.summary),
metadata_json = excluded.metadata_json,
updated_at = excluded.updated_at
""",
(
call_id,
final_status,
customer_number or existing_number,
transcript if transcript is not None else existing_transcript,
summary if summary is not None else existing_summary,
json.dumps(merged_meta, sort_keys=True),
now,
now,
),
)
@staticmethod
def append_transcript_line(call_id: str, line: str) -> str:
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
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
def list_calls(limit: int = 50) -> list[dict[str, Any]]:
with closing(_connect()) as conn:
rows = conn.execute(
"""
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),),
).fetchall()
out: list[dict[str, Any]] = []
for row in rows:
out.append(
{
"call_id": row[0],
"status": row[1],
"customer_number": row[2],
"transcript": row[3],
"summary": row[4],
"metadata": json.loads(row[5] or "{}"),
"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,
created_at, updated_at
FROM voice_calls WHERE call_id = ?
""",
(call_id,),
).fetchone()
if not row:
return None
return {
"call_id": row[0],
"status": row[1],
"customer_number": row[2],
"transcript": row[3],
"summary": row[4],
"metadata": json.loads(row[5] or "{}"),
"created_at": row[6],
"updated_at": row[7],
}
@@ -0,0 +1,108 @@
"""Tools callable from voice provider ``tool-calls`` webhook events."""
from __future__ import annotations
import json
from datetime import datetime, timezone
from typing import Any, Callable
def get_current_time() -> dict[str, Any]:
"""Return current UTC time for voice agent demos."""
now = datetime.now(timezone.utc).isoformat()
return {"utc": now, "spoken": f"The current UTC time is {now}"}
def echo_message(message: str = "") -> dict[str, Any]:
"""Echo a message back to the caller."""
text = message.strip() or "nothing"
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,
}
def register_tool(name: str, fn: Callable[..., Any]) -> None:
_TOOL_REGISTRY[name] = fn
def execute_tool(name: str, parameters: dict[str, Any] | None) -> str:
"""Run a registered tool and return a JSON string for the provider."""
fn = _TOOL_REGISTRY.get(name)
if fn is None:
return json.dumps({"error": f"Unknown tool: {name}"})
try:
result = fn(**(parameters or {}))
if isinstance(result, str):
return result
return json.dumps(result)
except TypeError:
try:
result = fn(parameters or {})
return json.dumps(result)
except Exception as exc: # noqa: BLE001
return json.dumps({"error": str(exc)})
except Exception as exc: # noqa: BLE001
return json.dumps({"error": str(exc)})
@@ -0,0 +1,21 @@
"""Verify incoming voice provider webhook requests."""
from __future__ import annotations
import hmac
_SECRET_HEADERS = ("x-voice-webhook-secret", "x-webhook-secret")
class VerificationError(ValueError):
"""Webhook secret verification failed."""
def verify_webhook_request(*, secret: str | None, headers: dict[str, str]) -> None:
"""Validate webhook secret header when ``VOICE_SERVER_URL_SECRET`` is configured."""
if not secret:
return
lowered = {k.lower(): v for k, v in headers.items()}
incoming = next((lowered[name] for name in _SECRET_HEADERS if lowered.get(name)), None)
if not incoming or not hmac.compare_digest(incoming, secret):
raise VerificationError("Invalid or missing webhook secret header")
@@ -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
+11
View File
@@ -0,0 +1,11 @@
# One command: start app + tunnel + configure voice provider automatically.
Set-Location $PSScriptRoot
if (-not (Test-Path .env)) { Copy-Item .env.example .env; Write-Host "Created .env — add VOICE_* keys" ; exit 1 }
.\start_dev.ps1
if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE }
Write-Host ""
Write-Host "Configuring voice provider via API..." -ForegroundColor Cyan
py -3.13 setup_voice_provider.py
exit $LASTEXITCODE
@@ -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())
@@ -0,0 +1,369 @@
"""Configure voice provider assistant: tools + webhook URL + auth header.
Usage:
cd examples/python/voice-agent
py -3.13 setup_voice_provider.py
"""
from __future__ import annotations
import os
import uuid
from pathlib import Path
from typing import Any
import httpx
_ROOT = Path(__file__).resolve().parent
ENV_PATH = _ROOT / ".env"
WEBHOOK_SECRET_HEADER = "X-Voice-Webhook-Secret"
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 _api_key() -> str:
return os.getenv("VOICE_PRIVATE_API_KEY", "").strip() or os.getenv("VOICE_API_KEY", "").strip()
def _api_base() -> str:
return os.getenv("VOICE_API_BASE", "").strip().rstrip("/")
def _request(
method: str,
path: str,
*,
api_key: str,
api_base: str,
body: dict | None = None,
) -> dict | list | None:
url = f"{api_base}{path}"
headers = {"Authorization": f"Bearer {api_key}", "Content-Type": "application/json"}
response = httpx.request(method, url, headers=headers, json=body, timeout=30.0)
if response.status_code >= 400:
print(f"ERROR {method} {path} -> {response.status_code}")
print(response.text[:1500])
return None
if not response.content:
return {}
return response.json()
def _tool_specs() -> list[dict[str, Any]]:
return [
{
"type": "function",
"function": {
"name": "get_current_time",
"description": "Returns the current UTC time when the caller asks for the time.",
"parameters": {"type": "object", "properties": {}, "required": []},
},
"async": False,
},
{
"type": "function",
"function": {
"name": "echo_message",
"description": "Repeats back a message the caller provides.",
"parameters": {
"type": "object",
"properties": {
"message": {
"type": "string",
"description": "The message to repeat back to the caller.",
}
},
"required": ["message"],
},
},
"async": False,
},
]
def ensure_server_secret() -> str:
secret = os.getenv("VOICE_SERVER_URL_SECRET", "").strip()
if not secret:
secret = str(uuid.uuid4())
_write_env_value("VOICE_SERVER_URL_SECRET", secret)
print("Generated VOICE_SERVER_URL_SECRET")
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] = {}
if isinstance(existing, list):
for item in existing:
fn = item.get("function") if isinstance(item, dict) else None
if isinstance(fn, dict) and fn.get("name") and item.get("id"):
by_name[str(fn["name"])] = str(item["id"])
tool_ids: list[str] = []
for spec in _tool_specs():
name = spec["function"]["name"]
if name in by_name:
tool_id = by_name[name]
print(f"Reusing tool {name}: {tool_id}")
else:
created = _request("POST", "/tool", api_key=api_key, api_base=api_base, body=spec)
if not isinstance(created, dict) or not created.get("id"):
raise RuntimeError(f"Failed to create tool {name}")
tool_id = str(created["id"])
print(f"Created tool {name}: {tool_id}")
tool_ids.append(tool_id)
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 {})
if not model.get("provider"):
raise RuntimeError("Assistant has no model.provider — configure a model in the provider dashboard first")
existing_ids = [str(x) for x in (model.get("toolIds") or [])]
model["toolIds"] = list(dict.fromkeys(existing_ids + tool_ids))
messages = list(model.get("messages") or [])
system_hint = (
"You are a helpful voice assistant. When the caller asks for the time, "
"call get_current_time. When they ask you to repeat something, call echo_message."
)
if not messages:
model["messages"] = [{"role": "system", "content": system_hint}]
else:
for msg in messages:
if isinstance(msg, dict) and msg.get("role") == "system":
content = str(msg.get("content") or "")
if "get_current_time" not in content:
msg["content"] = f"{content.rstrip()}\n\n{system_hint}"
break
else:
messages.insert(0, {"role": "system", "content": system_hint})
model["messages"] = messages
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 tools to assistant")
print("Attached tool IDs:", model["toolIds"])
return tool_ids
def ensure_webhook(api_key: str, api_base: str, assistant_id: str, webhook_url: str, secret: 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 webhook setup")
server = dict(assistant.get("server") or {})
server["url"] = webhook_url.rstrip("/")
server["headers"] = {WEBHOOK_SECRET_HEADER: secret}
server.pop("credentialId", None)
server.pop("secret", None)
patched = _request(
"PATCH",
f"/assistant/{assistant_id}",
api_key=api_key,
api_base=api_base,
body={"server": server},
)
if patched is None:
raise RuntimeError("Failed to configure assistant server URL")
print(f"Server URL: {webhook_url}")
print(f"Webhook auth header: {WEBHOOK_SECRET_HEADER}")
def main() -> int:
_load_env()
api_key = _api_key()
api_base = _api_base()
assistant_id = os.getenv("VOICE_ASSISTANT_ID", "").strip()
public_base = os.getenv("PUBLIC_API_BASE_URL", "").strip().rstrip("/")
if not api_key:
print("Set VOICE_PRIVATE_API_KEY in .env")
return 1
if not api_base:
print("Set VOICE_API_BASE in .env")
return 1
if not assistant_id:
print("Set VOICE_ASSISTANT_ID in .env")
return 1
if not public_base:
print("Set PUBLIC_API_BASE_URL in .env (run .\\start_dev.ps1 first)")
return 1
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:
print(f"Setup failed: {exc}")
return 1
print("Voice provider auto-setup complete.")
return 0
if __name__ == "__main__":
raise SystemExit(main())
@@ -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())
+428
View File
@@ -0,0 +1,428 @@
# Start voice-agent + Cloudflare tunnel for provider webhook URL.
# Usage: .\start_dev.ps1
# .\start_dev.ps1 -Restart # kill old app and reload code
param(
[int]$Port = 8001,
[switch]$Restart
)
$ErrorActionPreference = "Stop"
$Root = Split-Path -Parent $MyInvocation.MyCommand.Path
Set-Location $Root
function Load-DotEnv {
$envFile = Join-Path $Root ".env"
if (-not (Test-Path $envFile)) { return }
Get-Content $envFile | ForEach-Object {
$line = $_.Trim()
if (-not $line -or $line.StartsWith("#") -or $line -notmatch "=") { return }
$key, $value = $line -split "=", 2
$key = $key.Trim()
if ($key -and -not [string]::IsNullOrWhiteSpace($key)) {
Set-Item -Path "Env:$key" -Value $value.Trim()
}
}
}
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
if ($r.StatusCode -eq 200) { return $true }
} catch { continue }
}
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
foreach ($conn in $conns) {
if ($conn.OwningProcess) {
Stop-Process -Id $conn.OwningProcess -Force -ErrorAction SilentlyContinue
}
}
if ($conns) {
Start-Sleep -Seconds 2
Write-Host "Stopped previous process on port $p"
}
} catch { }
}
function Clear-LogFile([string]$path) {
if (-not (Test-Path $path)) { return }
try {
Remove-Item $path -Force -ErrorAction Stop
} catch {
try { Clear-Content $path -ErrorAction SilentlyContinue } catch { }
}
}
function Start-VoiceApp([int]$p) {
Write-Host "Starting voice-agent on port $p..."
Stop-PortProcess $p
$appLog = Join-Path $Root ".app.log"
$appErr = Join-Path $Root ".app.err"
Clear-LogFile $appLog
Clear-LogFile $appErr
Start-Process -FilePath "py" -ArgumentList "-3.13", "app.py" -WorkingDirectory $Root -WindowStyle Minimized `
-RedirectStandardOutput $appLog -RedirectStandardError $appErr
$deadline = (Get-Date).AddSeconds(120)
while ((Get-Date) -lt $deadline) {
if (Test-AppHttpReady $p) { break }
Start-Sleep -Seconds 3
}
if (-not (Test-AppHttpReady $p)) {
$errText = ""
if (Test-Path $appErr) { $errText = Get-Content $appErr -Raw -ErrorAction SilentlyContinue }
if ($errText -match "Uvicorn running") {
Write-Host "Server process up — waiting for HTTP endpoints..."
Start-Sleep -Seconds 10
}
}
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 $_ }
}
throw "App did not become ready on http://127.0.0.1:$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
if ($content -match "PUBLIC_API_BASE_URL=.*") {
$newContent = $content -replace "PUBLIC_API_BASE_URL=.*", "PUBLIC_API_BASE_URL=$baseUrl"
} else {
$newContent = $content.TrimEnd() + "`nPUBLIC_API_BASE_URL=$baseUrl`n"
}
Set-Content -Path $envFile -Value $newContent -NoNewline
$env:PUBLIC_API_BASE_URL = $baseUrl
Write-Host "Updated .env PUBLIC_API_BASE_URL=$baseUrl"
}
function Ensure-ServerSecret {
$envFile = Join-Path $Root ".env"
if (-not (Test-Path $envFile)) { return }
$lines = Get-Content $envFile
$secret = $env:VOICE_SERVER_URL_SECRET
foreach ($line in $lines) {
if ($line -match '^\s*VOICE_SERVER_URL_SECRET=(.+)$') {
$secret = $Matches[1].Trim()
break
}
}
if (-not $secret) {
$secret = [guid]::NewGuid().ToString()
$updated = $false
$newLines = foreach ($line in $lines) {
if ($line -match '^\s*VOICE_SERVER_URL_SECRET=') {
$updated = $true
"VOICE_SERVER_URL_SECRET=$secret"
} else { $line }
}
if (-not $updated) { $newLines += "VOICE_SERVER_URL_SECRET=$secret" }
Set-Content -Path $envFile -Value ($newLines -join "`n")
Write-Host "Generated VOICE_SERVER_URL_SECRET in .env"
}
$env:VOICE_SERVER_URL_SECRET = $secret
}
function Show-ProviderSetupSteps([string]$webhookUrl, [string]$secret) {
Write-Host ""
Write-Host "=== Provider webhook setup (Assistant -> Advanced -> Webhook Server) ===" -ForegroundColor Cyan
Write-Host "Webhook URL:"
Write-Host " $webhookUrl"
Write-Host ""
Write-Host "Secret header:"
Write-Host " Name: X-Voice-Webhook-Secret"
Write-Host " Value: $secret"
Write-Host "================================================================" -ForegroundColor Cyan
}
Load-DotEnv
Ensure-ServerSecret
$env:VOICE_AGENT_PORT = "$Port"
$needsRestart = $Restart.IsPresent
if (Test-TcpPortListening $Port) {
if ($needsRestart) {
Stop-PortProcess $Port
} else {
try {
$cfg = Invoke-RestMethod -Uri "http://127.0.0.1:$Port/api/voice/config" -TimeoutSec 3
if (-not $cfg.webhook_url -and $env:PUBLIC_API_BASE_URL) {
Write-Host "App running but missing webhook URL — restarting to reload .env"
$needsRestart = $true
Stop-PortProcess $Port
} else {
Write-Host "App already running on port $Port"
}
} catch {
Write-Host "App already running on port $Port"
}
}
}
$public = $env:PUBLIC_API_BASE_URL
$mainTunnelRecreated = $false
if (Test-TunnelUrl $public) {
Write-Host "Tunnel OK: $public"
} 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"
Write-Host "Voice configured: $($config.configured)"
Write-Host "Webhook URL: $($config.webhook_url)"
$webhook = if ($config.webhook_url) { $config.webhook_url } else { "$($env:PUBLIC_API_BASE_URL)/webhooks/voice" }
Write-Host ""
Write-Host "Configuring voice provider via API..." -ForegroundColor Cyan
py -3.13 setup_voice_provider.py
if ($LASTEXITCODE -eq 0) {
Write-Host "Voice provider auto-configured (tools + webhook URL + auth)." -ForegroundColor Green
} else {
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."
+183
View File
@@ -0,0 +1,183 @@
"""Voice agent HTTP routes."""
from __future__ import annotations
import asyncio
import json
import logging
from integrations.voice.client import VoiceClient
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, StreamingResponse
logger = logging.getLogger(__name__)
# Hold strong references to background tasks so the event loop (which only keeps
# weak references) cannot garbage-collect them mid-flight and drop a webhook event.
_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()
configured = True
error = None
except VoiceConfigError as exc:
settings = None
configured = False
error = str(exc)
return JSONResponse(
{
"configured": configured,
"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",
}
)
async def api_voice_create_call(request: Request) -> JSONResponse:
try:
body = await request.json()
except json.JSONDecodeError:
return JSONResponse({"error": "invalid JSON body"}, status_code=400)
number = str(body.get("customer_number") or body.get("phone_number") or "").strip()
if not number:
return JSONResponse({"error": "customer_number is required"}, status_code=400)
try:
settings = load_voice_settings()
client = VoiceClient(settings)
result = await asyncio.to_thread(
client.create_call,
customer_number=number,
assistant_id=body.get("assistant_id"),
phone_number_id=body.get("phone_number_id"),
metadata=body.get("metadata") if isinstance(body.get("metadata"), dict) else None,
)
except VoiceConfigError as exc:
return JSONResponse({"error": str(exc)}, status_code=503)
except Exception as exc: # noqa: BLE001
return JSONResponse({"error": str(exc)}, status_code=502)
call_id = result.get("id")
if call_id:
VoiceCallStore.upsert_call(str(call_id), status="scheduled", customer_number=number)
return JSONResponse(result, status_code=201)
async def api_voice_list_calls(_request: Request) -> JSONResponse:
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)
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:
raw = (await request.body()).decode("utf-8")
try:
settings = load_voice_settings(require_key=False)
except VoiceConfigError as exc:
return JSONResponse({"error": str(exc)}, status_code=503)
headers = {k: v for k, v in request.headers.items()}
try:
verify_webhook_request(secret=settings.server_url_secret, headers=headers)
except VerificationError:
return JSONResponse({"error": "invalid secret"}, status_code=401)
try:
payload = json.loads(raw) if raw else {}
except json.JSONDecodeError:
return JSONResponse({"error": "invalid JSON"}, status_code=400)
msg = payload.get("message") if isinstance(payload.get("message"), dict) else payload
event_type = str((msg or {}).get("type") or payload.get("type") or "unknown")
if event_type in ("tool-calls", "assistant-request", "transfer-destination-request", "knowledge-base-request"):
result = await asyncio.to_thread(process_voice_webhook, event_type, payload, settings=settings)
return JSONResponse(result or {})
_schedule_webhook_processing(event_type, payload, settings=settings)
return PlainTextResponse("ok", status_code=200)
+1 -1
View File
@@ -1,6 +1,6 @@
[project]
name = "aiui"
version = "0.3.136"
version = "0.3.146"
description = "YAML-driven website generator - CLI and compiler"
readme = "README.md"
license = {text = "MIT"}
+1 -3
View File
@@ -11,9 +11,7 @@
<link rel="icon" type="image/svg+xml" href="/icon.svg" />
<title>Documentation</title>
<script>
// Anti-flicker: apply dark mode class before first paint
// Default to dark to prevent flash; React will correct if needed
document.documentElement.classList.add('dark');
(function(){try{var t=localStorage.getItem('aiui-theme-preference');var d=__AIUI_DARK_DEFAULT__;if(t==='dark'||(t==='system'&&matchMedia('(prefers-color-scheme: dark)').matches)||(!t&&d))document.documentElement.classList.add('dark');else if(t==='light')document.documentElement.classList.remove('dark');}catch(e){}})();
</script>
<link rel="stylesheet" href="/assets/theme.css">
</head>
+903 -2
View File
File diff suppressed because it is too large. Load diff
+1
View File
@@ -89,6 +89,7 @@
"date-fns": "^4.1.0",
"embla-carousel-react": "^8.6.0",
"lucide-react": "^0.563.0",
"mermaid": "^11.17.2",
"react-day-picker": "^9.13.0",
"react-markdown": "^10.1.0",
"react-resizable-panels": "^4.5.4",
+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>
+274 -119
View File
@@ -1,15 +1,18 @@
import { useEffect, useState } from 'react'
import { applyTheme } from './themes'
import { useEffect, useMemo, useState } from 'react'
import { applyTheme, applyStoredThemePreference } from './themes'
import './index.css'
import type { UIConfig, DocsNav, RouteManifest, NavItem } from './types'
import { Header } from './Header'
import { Sidebar } from './Sidebar'
import { Sidebar, MobileNavSheet } from './Sidebar'
import { Content } from './Content'
import { ZoneWidgets } from './Widgets'
import { Toc } from './Toc'
import { Footer } from './Footer'
import { ChatLayout, AgentUILayout, CopilotWidget, PlaygroundLayout } from './layouts'
import { LocaleProvider } from './i18n'
import { resolveTemplate, shouldShowToc } from './resolver'
import { normalizeDocPath } from './pathUtils'
import { detectActiveTabIndex, filterNavByTab } from './docs/navTabs'
function SkipLink({ enabled }: { enabled?: boolean }) {
if (!enabled) return null
@@ -31,12 +34,30 @@ export default function App() {
const [error, setError] = useState<string | null>(null)
const [selectedItem, setSelectedItem] = useState<NavItem | null>(null)
const [activeItemPath, setActiveItemPath] = useState<string>('')
const [currentPath, setCurrentPath] = useState(() => normalizeDocPath(window.location.pathname))
const [mobileNavOpen, setMobileNavOpen] = useState(false)
const [activeTabIndex, setActiveTabIndex] = useState(0)
// Helper function to find nav item by path
const navTabs = config.navigation?.tabs ?? []
const filteredNav = useMemo<DocsNav>(() => {
if (!navTabs.length) return nav
return { items: filterNavByTab(nav.items ?? [], navTabs[activeTabIndex]) }
}, [nav, navTabs, activeTabIndex])
useEffect(() => {
if (navTabs.length) {
setActiveTabIndex(detectActiveTabIndex(navTabs, currentPath))
}
}, [currentPath, navTabs])
// Helper function to find nav item by path (searches top-level items and nested children)
const findNavItemByPath = (navData: DocsNav, path: string): NavItem | null => {
const normalizedPath = normalizeDocPath(path)
const findItem = (items: NavItem[]): NavItem | null => {
for (const item of items) {
if (item.path === path || item.path === path.replace(/^\//, '')) {
const itemPath = normalizeDocPath(item.path ?? '')
if (itemPath === normalizedPath) {
return item
}
if (item.children) {
@@ -47,94 +68,190 @@ export default function App() {
return null
}
// Search all nav groups
if (navData.items) {
for (const group of navData.items) {
if (group.children) {
const found = findItem(group.children)
if (found) return found
}
}
}
return null
return navData.items ? findItem(navData.items) : null
}
// Update SEO meta tags dynamically
const updateSEO = (title: string, path: string, description?: string) => {
// Update title using SEO titleTemplate if available
const titleTemplate = config.seo?.titleTemplate || '%s | %s'
const siteName = config.site?.title || 'Documentation'
const seoOrigin = (cfg: UIConfig) => (cfg.seo?.siteUrl || window.location.origin).replace(/\/+$/, '')
const formatSeoTitle = (title: string, cfg: UIConfig) => {
const titleTemplate = cfg.seo?.titleTemplate || '%s | %s'
const siteName = cfg.site?.title || 'Documentation'
if (titleTemplate.includes('%s')) {
// Replace first %s with page title, second %s (if exists) with site name
let formattedTitle = titleTemplate.replace('%s', title)
if (formattedTitle.includes('%s')) {
formattedTitle = formattedTitle.replace('%s', siteName)
}
document.title = formattedTitle
} else {
document.title = `${title} | ${siteName}`
return formattedTitle
}
return `${title} | ${siteName}`
}
const updateSEO = (
title: string,
path: string,
description?: string,
configOverride?: UIConfig,
noindex?: boolean,
) => {
const cfg = configOverride ?? config
const canonicalPath = normalizeDocPath(path)
const formattedTitle = formatSeoTitle(title, cfg)
const metaDescription = description || `${title} - ${cfg.site?.description || ''}`
const absoluteUrl = `${seoOrigin(cfg)}${canonicalPath}`
document.title = formattedTitle
// Update canonical URL
let canonical = document.querySelector('link[rel="canonical"]') as HTMLLinkElement
if (!canonical) {
canonical = document.createElement('link')
canonical.rel = 'canonical'
document.head.appendChild(canonical)
}
canonical.href = window.location.origin + path
canonical.href = absoluteUrl
// Update meta description
let metaDesc = document.querySelector('meta[name="description"]') as HTMLMetaElement
if (!metaDesc) {
metaDesc = document.createElement('meta')
metaDesc.name = 'description'
document.head.appendChild(metaDesc)
}
metaDesc.content = description || `${title} - ${config.site?.description || ''}`
metaDesc.content = metaDescription
// Update Open Graph tags
let ogTitle = document.querySelector('meta[property="og:title"]') as HTMLMetaElement
if (!ogTitle) {
ogTitle = document.createElement('meta')
ogTitle.setAttribute('property', 'og:title')
document.head.appendChild(ogTitle)
}
ogTitle.content = title
let ogUrl = document.querySelector('meta[property="og:url"]') as HTMLMetaElement
if (!ogUrl) {
ogUrl = document.createElement('meta')
ogUrl.setAttribute('property', 'og:url')
document.head.appendChild(ogUrl)
}
ogUrl.content = window.location.origin + path
// Update Twitter meta tags if configured
if (config.seo?.twitter) {
Object.entries(config.seo.twitter).forEach(([key, value]) => {
let twitterMeta = document.querySelector(`meta[name="twitter:${key}"]`) as HTMLMetaElement
if (!twitterMeta) {
twitterMeta = document.createElement('meta')
twitterMeta.name = `twitter:${key}`
document.head.appendChild(twitterMeta)
}
twitterMeta.content = value
})
}
// Set default image if available
if (config.seo?.defaultImage) {
let ogImage = document.querySelector('meta[property="og:image"]') as HTMLMetaElement
if (!ogImage) {
ogImage = document.createElement('meta')
ogImage.setAttribute('property', 'og:image')
document.head.appendChild(ogImage)
const ensureMeta = (selector: string, create: () => HTMLMetaElement) => {
let el = document.querySelector(selector) as HTMLMetaElement | null
if (!el) {
el = create()
document.head.appendChild(el)
}
ogImage.content = config.seo.defaultImage
return el
}
ensureMeta('meta[name="robots"]', () => {
const el = document.createElement('meta')
el.name = 'robots'
return el
}).content = noindex ? 'noindex, nofollow' : 'index, follow'
ensureMeta('meta[property="og:title"]', () => {
const el = document.createElement('meta')
el.setAttribute('property', 'og:title')
return el
}).content = formattedTitle
ensureMeta('meta[property="og:description"]', () => {
const el = document.createElement('meta')
el.setAttribute('property', 'og:description')
return el
}).content = metaDescription
ensureMeta('meta[property="og:url"]', () => {
const el = document.createElement('meta')
el.setAttribute('property', 'og:url')
return el
}).content = absoluteUrl
ensureMeta('meta[property="og:type"]', () => {
const el = document.createElement('meta')
el.setAttribute('property', 'og:type')
return el
}).content = 'article'
ensureMeta('meta[name="twitter:card"]', () => {
const el = document.createElement('meta')
el.name = 'twitter:card'
return el
}).content = 'summary'
ensureMeta('meta[name="twitter:title"]', () => {
const el = document.createElement('meta')
el.name = 'twitter:title'
return el
}).content = formattedTitle
ensureMeta('meta[name="twitter:description"]', () => {
const el = document.createElement('meta')
el.name = 'twitter:description'
return el
}).content = metaDescription
if (cfg.seo?.twitter?.handle) {
ensureMeta('meta[name="twitter:site"]', () => {
const el = document.createElement('meta')
el.name = 'twitter:site'
return el
}).content = cfg.seo.twitter.handle
}
if (cfg.seo?.defaultImage) {
const image = cfg.seo.defaultImage.startsWith('http')
? cfg.seo.defaultImage
: `${seoOrigin(cfg)}${cfg.seo.defaultImage}`
ensureMeta('meta[property="og:image"]', () => {
const el = document.createElement('meta')
el.setAttribute('property', 'og:image')
return el
}).content = image
ensureMeta('meta[name="twitter:image"]', () => {
const el = document.createElement('meta')
el.name = 'twitter:image'
return el
}).content = image
}
let jsonLd = document.querySelector('script#aiui-jsonld') as HTMLScriptElement | null
if (!jsonLd) {
jsonLd = document.createElement('script')
jsonLd.type = 'application/ld+json'
jsonLd.id = 'aiui-jsonld'
document.head.appendChild(jsonLd)
}
jsonLd.textContent = JSON.stringify({
'@context': 'https://schema.org',
'@type': 'TechArticle',
headline: title,
description: metaDescription,
url: absoluteUrl,
isPartOf: {
'@type': 'WebSite',
name: cfg.site?.title || 'Documentation',
url: `${seoOrigin(cfg)}/`,
},
})
}
const selectNavItem = (item: NavItem) => {
const path = normalizeDocPath(item.path || '/')
setSelectedItem(item)
setActiveItemPath(item.path || item.title)
updateSEO(item.title, path, item.description, undefined, item.noindex)
}
// Keep React nav state in sync when plugins perform SPA navigation
useEffect(() => {
const onPluginNavigate = (event: Event) => {
const detail = (event as CustomEvent<{ path?: string }>).detail
const path = normalizeDocPath(detail?.path ?? window.location.pathname)
setCurrentPath(path)
setMobileNavOpen(false)
const found = findNavItemByPath(nav, path)
if (found) {
selectNavItem(found)
return
}
if (path === '/') {
const docsHome = findNavItemByPath(nav, '/docs/index')
if (docsHome) {
selectNavItem(docsHome)
}
}
}
window.addEventListener('aiui:navigate', onPluginNavigate)
return () => window.removeEventListener('aiui:navigate', onPluginNavigate)
}, [nav, config])
useEffect(() => {
async function loadManifests() {
try {
@@ -156,19 +273,33 @@ export default function App() {
// Apply theme from YAML config
const theme = configData.site?.theme
const yamlDark = theme?.darkMode !== false
applyTheme(
theme?.preset || 'zinc',
theme?.darkMode !== false,
yamlDark,
theme?.radius || 'md'
)
applyStoredThemePreference(yamlDark)
// Handle initial URL path for SPA routing
const currentPath = window.location.pathname
if (currentPath && currentPath !== '/') {
const found = findNavItemByPath(navData, currentPath)
const initialPath = normalizeDocPath(window.location.pathname)
setCurrentPath(initialPath)
if (initialPath !== window.location.pathname) {
window.history.replaceState(null, '', initialPath + window.location.search + window.location.hash)
}
if (initialPath !== '/') {
const found = findNavItemByPath(navData, initialPath)
if (found) {
setSelectedItem(found)
setActiveItemPath(found.path || found.title)
updateSEO(found.title, initialPath, found.description, configData, found.noindex)
}
} else {
const docsHome = findNavItemByPath(navData, '/docs/index')
if (docsHome) {
setSelectedItem(docsHome)
setActiveItemPath(docsHome.path || docsHome.title)
updateSEO(docsHome.title, normalizeDocPath(docsHome.path || '/docs/index'), docsHome.description, configData, docsHome.noindex)
}
}
} catch (err) {
@@ -184,19 +315,21 @@ export default function App() {
// Handle browser back/forward navigation in a separate effect
useEffect(() => {
const handlePopState = () => {
const path = window.location.pathname
const path = normalizeDocPath(window.location.pathname)
setCurrentPath(path)
const found = findNavItemByPath(nav, path)
if (found) {
setSelectedItem(found)
setActiveItemPath(found.path || found.title)
// Use updateSEO function for consistency
updateSEO(found.title, path)
selectNavItem(found)
} else if (path === '/') {
setSelectedItem(null)
setActiveItemPath('')
// Reset title to site title
document.title = config.site?.title || 'Documentation'
const docsHome = findNavItemByPath(nav, '/docs/index')
if (docsHome) {
selectNavItem(docsHome)
} else {
setSelectedItem(null)
setActiveItemPath('')
document.title = config.site?.title || 'Documentation'
}
}
}
@@ -205,18 +338,47 @@ export default function App() {
}, [nav, config])
const handleItemClick = (item: NavItem) => {
setSelectedItem(item)
setActiveItemPath(item.path || item.title)
// Use History API for SEO-friendly URLs
const path = item.path || `/${item.title.toLowerCase().replace(/\s+/g, '-')}`
const path = normalizeDocPath(item.path || `/${item.title.toLowerCase().replace(/\s+/g, '-')}`)
window.history.pushState({ path }, item.title, path)
// Update SEO meta tags
updateSEO(item.title, path)
window.dispatchEvent(new CustomEvent('aiui:navigate', { detail: { path } }))
}
const templateMatch = resolveTemplate(
currentPath.replace(/^\//, ''),
routes,
config.templates ?? {},
)
const activeTemplateKey = templateMatch?.template ?? 'docs'
const activeTemplate = config.templates?.[activeTemplateKey] ?? config.templates?.docs
const layout = templateMatch?.layout ?? activeTemplate?.layout ?? 'ThreeColumnLayout'
const zones = activeTemplate?.zones
const showToc = shouldShowToc(templateMatch)
const showGlobalFooter = true
const docsChrome = (
<>
<MobileNavSheet
open={mobileNavOpen}
onOpenChange={setMobileNavOpen}
nav={filteredNav}
activeItem={activeItemPath}
onItemClick={handleItemClick}
title={config.site?.title || 'Navigation'}
/>
<Header
config={config}
templateKey={activeTemplateKey}
onMenuClick={() => setMobileNavOpen(true)}
navTabs={navTabs}
activeTabIndex={activeTabIndex}
onTabChange={setActiveTabIndex}
/>
</>
)
if (loading) {
return (
<div className="dark flex items-center justify-center min-h-screen bg-background">
<div className="flex items-center justify-center min-h-screen bg-background">
<span className="text-muted-foreground">Loading...</span>
</div>
)
@@ -224,53 +386,49 @@ export default function App() {
if (error) {
return (
<div className="dark flex flex-col items-center justify-center min-h-screen gap-4 bg-background">
<div className="flex flex-col items-center justify-center min-h-screen gap-4 bg-background">
<h2 className="text-xl font-semibold text-destructive">Failed to load</h2>
<p className="text-muted-foreground">{error}</p>
</div>
)
}
// Determine layout from templates config
const layout = config.templates?.docs?.layout || 'ThreeColumnLayout'
const zones = config.templates?.docs?.zones
// Render based on layout type
const renderLayout = () => {
switch (layout) {
case 'TwoColumnLayout':
// Sidebar + Content (no TOC)
return (
<div className="flex">
<Sidebar nav={nav} activeItem={activeItemPath} onItemClick={handleItemClick} />
<Content config={config} routes={routes} selectedItem={selectedItem} />
<Content config={config} routes={routes} selectedItem={selectedItem} currentPath={currentPath} />
</div>
)
case 'CenteredLayout':
// Centered content, no sidebar
return (
<div className="flex justify-center">
<div className="w-full max-w-4xl px-6">
{zones?.hero && zones.hero.length > 0 && <ZoneWidgets widgets={zones.hero} />}
<Content config={config} routes={routes} selectedItem={selectedItem} />
<Content config={config} routes={routes} selectedItem={selectedItem} currentPath={currentPath} />
</div>
</div>
)
case 'FullWidthLayout':
// Full width content
return (
<div className="px-6">
{zones?.hero && zones.hero.length > 0 && <ZoneWidgets widgets={zones.hero} />}
<Content config={config} routes={routes} selectedItem={selectedItem} />
<div className="flex">
{(nav?.items?.length ?? 0) > 0 && (
<Sidebar nav={nav} activeItem={activeItemPath} onItemClick={handleItemClick} />
)}
<div className="flex-1 px-6">
{zones?.hero && zones.hero.length > 0 && <ZoneWidgets widgets={zones.hero} />}
<Content config={config} routes={routes} selectedItem={selectedItem} currentPath={currentPath} />
</div>
</div>
)
case 'FlexibleLayout': {
// WordPress-style zones layout with navigation invariant
// When docs nav exists but no leftSidebar zone configured, render default nav
const hasExplicitLeftSidebar = (zones?.leftSidebar?.length ?? 0) > 0
const hasNavigation = (nav?.items?.length ?? 0) > 0
const shouldRenderNavigation = hasExplicitLeftSidebar || hasNavigation
return (
<div className="flex flex-col">
{zones?.hero && zones.hero.length > 0 && <ZoneWidgets widgets={zones.hero} />}
@@ -285,9 +443,9 @@ export default function App() {
</aside>
)}
<div className="flex-1">
<Content config={config} routes={routes} selectedItem={selectedItem} />
<Content config={config} routes={routes} selectedItem={selectedItem} currentPath={currentPath} />
</div>
<Toc selectedItem={selectedItem} zones={zones} />
<Toc selectedItem={selectedItem} zones={zones} showToc={showToc} />
</div>
{zones?.bottomNav && zones.bottomNav.length > 0 && (
<div className="border-t p-4 bg-muted/30">
@@ -304,12 +462,11 @@ export default function App() {
}
case 'ThreeColumnLayout':
default:
// Classic: Sidebar + Content + TOC with zones
return (
<div className="flex">
<Sidebar nav={nav} activeItem={activeItemPath} onItemClick={handleItemClick} />
<Content config={config} routes={routes} selectedItem={selectedItem} />
<Toc selectedItem={selectedItem} zones={zones} />
<div className="docs-shell flex w-full min-h-[calc(100vh-3.5rem)]">
<Sidebar nav={filteredNav} activeItem={activeItemPath} onItemClick={handleItemClick} />
<Content config={config} routes={routes} selectedItem={selectedItem} currentPath={currentPath} />
{showToc && <Toc selectedItem={selectedItem} zones={zones} showToc={showToc} />}
</div>
)
}
@@ -347,14 +504,13 @@ export default function App() {
// Dashboard is handled entirely by plugin dashboard.js — React yields
return null
case 'custom':
// For custom, show docs with copilot widget if chat enabled
if (config.chat?.enabled) {
return (
<div className="min-h-screen bg-background text-foreground">
<SkipLink enabled={config.a11y?.skipToContent} />
<Header config={config} />
{docsChrome}
{renderLayout()}
<Footer config={config} />
{showGlobalFooter && <Footer config={config} templateKey={activeTemplateKey} />}
<CopilotWidget config={config.chat} layout={config.layout} />
</div>
)
@@ -362,23 +518,22 @@ export default function App() {
return (
<div className="min-h-screen bg-background text-foreground">
<SkipLink enabled={config.a11y?.skipToContent} />
<Header config={config} />
{docsChrome}
{renderLayout()}
<Footer config={config} />
{showGlobalFooter && <Footer config={config} templateKey={activeTemplateKey} />}
</div>
)
case 'docs':
default:
// Docs mode - optionally with copilot widget
if (config.chat?.enabled) {
const layoutMode = config.layout?.mode
if (layoutMode && ['bottom-right', 'bottom-left', 'top-right', 'top-left'].includes(layoutMode)) {
return (
<div className="min-h-screen bg-background text-foreground">
<SkipLink enabled={config.a11y?.skipToContent} />
<Header config={config} />
{docsChrome}
{renderLayout()}
<Footer config={config} />
{showGlobalFooter && <Footer config={config} templateKey={activeTemplateKey} />}
<CopilotWidget config={config.chat} layout={config.layout} />
</div>
)
@@ -387,9 +542,9 @@ export default function App() {
return (
<div className="min-h-screen bg-background text-foreground">
<SkipLink enabled={config.a11y?.skipToContent} />
<Header config={config} />
{docsChrome}
{renderLayout()}
<Footer config={config} />
{showGlobalFooter && <Footer config={config} templateKey={activeTemplateKey} />}
</div>
)
}
Loaded 100 of 267 files, more files were not shown because too many files have changed in this diff. Show more