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
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
223 changed files with 21339 additions and 3115 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"
}
+4
View File
@@ -4,6 +4,10 @@
module.exports = {
repoFullName: 'MervinPraison/PraisonAIUI',
gitUser: 'MervinPraison',
gitEmail: '[email protected]',
triggerLogins: ['MervinPraison', 'github-actions[bot]'],
allowedTriageBots: ['praisonai-triage-agent[bot]'],
productPathPrefixes: ['src/praisonaiui/', 'src/frontend/', 'tests/'],
sensitivePathPatterns: [
/^\.github\/workflows\//,
+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. 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). Reject scope creep; request changes or recommend closing if value is unclear\n5. Layering: UI/frontend/server features here; core agent runtime belongs in praisonaiagents. Agent-callable tools → PraisonAI-Tools\n6. Do not bloat server.py or create_app() with new params — prefer existing YAML config, plugins, and feature modules; reject knobs/exports with no live consumer\n\n**Phase 2: FIX Valid Issues**\n7. For any VALID bugs or architectural flaws found by Gemini, CodeRabbit, Qodo, Copilot, or any other reviewer: implement the fix\n8. Push all code fixes directly to THIS branch (do NOT create a new PR)\n9. Comment a summary of exact files modified and what you skipped\n\n**Phase 3: Final Verdict**\n10. If all issues are resolved, approve the PR / close the Issue\n11. 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 = { skipCopilot: 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 }
: { skipCopilot: 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
+5 -25
View File
@@ -1,7 +1,5 @@
name: Bot PR Recovery
# Belt-and-braces: kick CodeRabbit/Qodo when pull_request:opened never fired for bot PRs.
on:
schedule:
- cron: '*/15 * * * *'
@@ -18,26 +16,8 @@ permissions:
jobs:
recover:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
with:
persist-credentials: false
- name: Find and kick stalled bot PRs
uses: actions/github-script@v7
env:
PR_NUMBER_INPUT: ${{ github.event.inputs.pr_number }}
with:
github-token: ${{ secrets.GH_TOKEN }}
script: |
const path = require('path');
const chain = require(path.join(process.env.GITHUB_WORKSPACE, '.github/scripts/bot-pr-review-chain.js'));
const owner = context.repo.owner;
const repo = context.repo.repo;
const manual = (process.env.PR_NUMBER_INPUT || '').trim();
const prNumber = manual ? parseInt(manual, 10) : null;
if (manual && Number.isNaN(prNumber)) {
throw new Error(`Invalid pr_number: ${manual}`);
}
await chain.recoverStalledBotPrs(github, owner, repo, { prNumber }, core);
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 -469
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,473 +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 PraisonAIUI 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 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.
4. 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.
5. Do not bloat server.py or create_app() with additional params — only if absolutely required; prefer existing config/plugin patterns.
6. If server.py or create_app() adds >100 lines or new top-level params without a clear live consumer, respond MERGE_GATE_VERDICT: BLOCK (manual review required).
7. 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 product code changes without tests. Label `merge-gate-ci-only` exempts CI-only changes under `.github/workflows/`, `.github/actions/`, or merge-gate scripts.
8. Confirm no CHANGES_REQUESTED reviews and CI green on HEAD ${{ matrix.head_sha }}.
9. 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 -539
View File
@@ -1,557 +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.
Product value gate: change must strengthen PraisonAIUI UX (no scope creep). BLOCK params/modules/exports
duplicating existing UI capabilities or knobs with no live consumer. Core agent logic belongs in
praisonaiagents; agent-callable tools in PraisonAI-Tools — not here.
**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:
• AIM: This package must stay LIGHTWEIGHT AND POWERFUL. Do a CRITICAL REVIEW at each stage
(issue triage, fix planning, PR review/implementation) and reject scope creep for the sake
of adding features. Prefer existing YAML config, plugins, feature modules, and server hooks
over new server.py/create_app() params or exports. Even when fixing a raised issue, keep the
fix minimal — do NOT expand surface area or add knobs with no live consumer.
• 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 duplicate existing UI capability via YAML config, plugins, or feature modules
instead of adding new server params? → Reject as scope creep; prefer existing patterns.
• Does it add core agent runtime logic here instead of praisonaiagents, or agent-callable
tools here instead of PraisonAI-Tools? → Route externally; do not implement here.
• Does it bloat server.py or create_app() with new params without a clear live consumer?
→ Prefer existing config/plugin hooks; only add params if absolutely required.
• 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
+17 -136
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]
@@ -16,7 +14,6 @@ concurrency:
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,145 +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.',
'Do not bloat server.py or create_app() with new params — prefer existing YAML config, plugins, and feature modules unless absolutely required.',
'Keep the resolution minimal — the aim is a lightweight and powerful UI package; do not introduce scope creep while resolving conflicts.',
].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 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' };
}
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
```
@@ -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.142"
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>
+206 -82
View File
@@ -1,5 +1,5 @@
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'
@@ -11,6 +11,8 @@ 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
@@ -32,17 +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(() => window.location.pathname.replace(/\/$/, '') || '/')
const [currentPath, setCurrentPath] = useState(() => normalizeDocPath(window.location.pathname))
const [mobileNavOpen, setMobileNavOpen] = useState(false)
const [activeTabIndex, setActiveTabIndex] = useState(0)
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 = path.replace(/\/$/, '') || '/'
const normalizedPath = normalizeDocPath(path)
const findItem = (items: NavItem[]): NavItem | null => {
for (const item of items) {
const itemPath = (item.path ?? '').replace(/\/$/, '')
if (itemPath === normalizedPath || itemPath === normalizedPath.replace(/^\//, '')) {
const itemPath = normalizeDocPath(item.path ?? '')
if (itemPath === normalizedPath) {
return item
}
if (item.children) {
@@ -56,88 +71,187 @@ export default function App() {
return navData.items ? findItem(navData.items) : null
}
const updateSEO = (title: string, path: string, description?: string, configOverride?: UIConfig) => {
const cfg = configOverride ?? config
// Update title using SEO titleTemplate if available
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} - ${cfg.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 (cfg.seo?.twitter) {
Object.entries(cfg.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 (cfg.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 = cfg.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, item.path || '/')
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 {
@@ -159,28 +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.replace(/\/$/, '') || '/'
setCurrentPath(currentPath)
if (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, found.path || currentPath, undefined, configData)
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, docsHome.path || '/docs/index', undefined, configData)
updateSEO(docsHome.title, normalizeDocPath(docsHome.path || '/docs/index'), docsHome.description, configData, docsHome.noindex)
}
}
} catch (err) {
@@ -196,7 +315,7 @@ export default function App() {
// Handle browser back/forward navigation in a separate effect
useEffect(() => {
const handlePopState = () => {
const path = window.location.pathname.replace(/\/$/, '') || '/'
const path = normalizeDocPath(window.location.pathname)
setCurrentPath(path)
const found = findNavItemByPath(nav, path)
@@ -219,12 +338,9 @@ export default function App() {
}, [nav, config])
const handleItemClick = (item: NavItem) => {
setSelectedItem(item)
setActiveItemPath(item.path || item.title)
const path = item.path || `/${item.title.toLowerCase().replace(/\s+/g, '-')}`
setCurrentPath(path.replace(/\/$/, '') || '/')
const path = normalizeDocPath(item.path || `/${item.title.toLowerCase().replace(/\s+/g, '-')}`)
window.history.pushState({ path }, item.title, path)
updateSEO(item.title, path)
window.dispatchEvent(new CustomEvent('aiui:navigate', { detail: { path } }))
}
const templateMatch = resolveTemplate(
@@ -237,14 +353,14 @@ export default function App() {
const layout = templateMatch?.layout ?? activeTemplate?.layout ?? 'ThreeColumnLayout'
const zones = activeTemplate?.zones
const showToc = shouldShowToc(templateMatch)
const showGlobalFooter = !(layout === 'FlexibleLayout' && (zones?.footer?.length ?? 0) > 0)
const showGlobalFooter = true
const docsChrome = (
<>
<MobileNavSheet
open={mobileNavOpen}
onOpenChange={setMobileNavOpen}
nav={nav}
nav={filteredNav}
activeItem={activeItemPath}
onItemClick={handleItemClick}
title={config.site?.title || 'Navigation'}
@@ -253,13 +369,16 @@ export default function App() {
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>
)
@@ -267,7 +386,7 @@ 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>
@@ -281,7 +400,7 @@ export default function App() {
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':
@@ -289,15 +408,20 @@ export default function App() {
<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':
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': {
@@ -319,7 +443,7 @@ 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} showToc={showToc} />
</div>
@@ -339,9 +463,9 @@ export default function App() {
case 'ThreeColumnLayout':
default:
return (
<div className="flex">
<Sidebar nav={nav} activeItem={activeItemPath} onItemClick={handleItemClick} />
<Content config={config} routes={routes} selectedItem={selectedItem} />
<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>
)
+114 -44
View File
@@ -1,122 +1,191 @@
// Content component — markdown rendering and landing page
import { useEffect, useRef, useState } from 'react'
import { Children, isValidElement, useEffect, useRef, useState } from 'react'
import Markdown from 'react-markdown'
import remarkGfm from 'remark-gfm'
import { Separator } from '@/components/ui/separator'
import { SHADCN_THEMES } from './themes'
import { enhanceMkdocsDom } from './markdown/mkdocsEnhance'
import { preprocessMkdocsMarkdown } from './markdown/mkdocsPreprocess'
import { MermaidDiagram } from './markdown/MermaidDiagram'
import { slugify } from './markdown/slug'
import { MobileToc } from './Toc'
import { docPathToMarkdown, isInternalDocHref, normalizeDocHref, normalizeDocPath } from './pathUtils'
import type { UIConfig, NavItem, RouteManifest } from './types'
interface ContentProps {
config: UIConfig
routes: RouteManifest
selectedItem: NavItem | null
currentPath: string
}
export function Content({ config, routes, selectedItem }: ContentProps) {
export function Content({ config, routes, selectedItem, currentPath }: ContentProps) {
const [markdown, setMarkdown] = useState<string>('')
const [loadedPath, setLoadedPath] = useState('')
const [loadingContent, setLoadingContent] = useState(false)
const articleRef = useRef<HTMLElement>(null)
const theme = config.site?.theme
// Load markdown content when selected item changes
// Load markdown from URL path (source of truth) to avoid stale selectedItem races
useEffect(() => {
if (!selectedItem?.path) {
setMarkdown('')
const urlPath = normalizeDocPath(currentPath)
if (urlPath === '/') {
setLoadingContent(false)
setLoadedPath('')
if (!selectedItem?.path) {
setMarkdown('')
}
return
}
const mdUrl = docPathToMarkdown(urlPath)
const pageTitle = urlPath.split('/').pop()?.replace(/-/g, ' ') ?? 'page'
const controller = new AbortController()
setMarkdown('')
setLoadedPath('')
setLoadingContent(true)
const loadContent = async () => {
setLoadingContent(true)
try {
const docPath = (selectedItem.path ?? '').replace(/^\//, '') + '.md'
const response = await fetch(`/${docPath}`)
const response = await fetch(mdUrl, { signal: controller.signal })
if (controller.signal.aborted) return
if (response.ok) {
const content = await response.text()
setMarkdown(preprocessMkdocsMarkdown(content))
setLoadedPath(urlPath)
window.dispatchEvent(new CustomEvent('aiui:content-loaded', {
detail: { path: urlPath },
}))
} else {
setMarkdown(`*Content for **${selectedItem.title}** not found.*`)
setMarkdown(`*Content for **${pageTitle}** not found.*`)
setLoadedPath(urlPath)
}
} catch {
setMarkdown(`*Failed to load content for **${selectedItem.title}**.*`)
} catch (err) {
if (controller.signal.aborted) return
setMarkdown(`*Failed to load content for **${pageTitle}**.*`)
setLoadedPath(urlPath)
} finally {
setLoadingContent(false)
if (!controller.signal.aborted) {
setLoadingContent(false)
}
}
}
loadContent()
}, [selectedItem])
return () => {
controller.abort()
setLoadingContent(false)
}
}, [currentPath, selectedItem?.path])
useEffect(() => {
if (!markdown || !articleRef.current) return
enhanceMkdocsDom(articleRef.current)
window.dispatchEvent(new CustomEvent('aiui:content-loaded', { detail: { root: articleRef.current } }))
const article = articleRef.current
const frame = window.requestAnimationFrame(() => {
enhanceMkdocsDom(article)
})
return () => window.cancelAnimationFrame(frame)
}, [markdown])
const headingId = (children?: React.ReactNode) => slugify(String(children ?? ''))
const mermaidChartFromPre = (children?: React.ReactNode): string | null => {
try {
const child = Children.only(children)
if (!isValidElement<{ className?: string; children?: React.ReactNode }>(child)) return null
const className = child.props.className ?? ''
if (!className.includes('language-mermaid')) return null
return String(child.props.children ?? '').replace(/\n$/, '').trim()
} catch {
return null
}
}
const markdownComponents = {
h1: ({ children }: { children?: React.ReactNode }) => <h1 id={headingId(children)} className="text-3xl font-bold mt-8 mb-4 scroll-mt-20">{children}</h1>,
h2: ({ children }: { children?: React.ReactNode }) => <h2 id={headingId(children)} className="text-2xl font-semibold mt-8 mb-4 text-primary scroll-mt-20">{children}</h2>,
h3: ({ children }: { children?: React.ReactNode }) => <h3 id={headingId(children)} className="text-xl font-semibold mt-6 mb-3 scroll-mt-20">{children}</h3>,
h4: ({ children }: { children?: React.ReactNode }) => <h4 id={headingId(children)} className="text-lg font-semibold mt-4 mb-2 scroll-mt-20">{children}</h4>,
p: ({ children }: { children?: React.ReactNode }) => <p className="my-3 text-muted-foreground leading-relaxed">{children}</p>,
a: ({ href, children }: { href?: string; children?: React.ReactNode }) => <a href={href} className="text-primary hover:underline">{children}</a>,
p: ({ children }: { children?: React.ReactNode }) => <p className="my-3 text-foreground leading-relaxed">{children}</p>,
a: ({ href, children }: { href?: string; children?: React.ReactNode }) => {
const seoHref = href && isInternalDocHref(href) ? normalizeDocHref(href) : href
return (
<a href={seoHref} className="text-primary hover:underline">
{children}
</a>
)
},
ul: ({ children }: { children?: React.ReactNode }) => <ul className="list-disc pl-6 my-4 space-y-1">{children}</ul>,
ol: ({ children }: { children?: React.ReactNode }) => <ol className="list-decimal pl-6 my-4 space-y-1">{children}</ol>,
li: ({ children }: { children?: React.ReactNode }) => <li className="text-muted-foreground">{children}</li>,
blockquote: ({ children }: { children?: React.ReactNode }) => <blockquote className="border-l-4 border-primary pl-4 my-4 italic text-muted-foreground">{children}</blockquote>,
li: ({ children }: { children?: React.ReactNode }) => <li className="text-foreground">{children}</li>,
blockquote: ({ children }: { children?: React.ReactNode }) => <blockquote className="border-l-4 border-primary pl-4 my-4 italic text-foreground/80">{children}</blockquote>,
code: ({ className, children }: { className?: string; children?: React.ReactNode }) => {
if (className?.includes('language-mermaid')) {
return <MermaidDiagram chart={String(children ?? '').replace(/\n$/, '').trim()} />
}
const isInline = !className && String(children).indexOf('\n') === -1
if (isInline) {
return <code className="bg-primary/10 text-primary px-1.5 py-0.5 rounded text-sm font-mono">{children}</code>
}
return <code className={`block font-mono text-foreground ${className ?? ''}`}>{children}</code>
},
pre: ({ children }: { children?: React.ReactNode }) => (
<pre className="bg-muted text-foreground border border-border p-4 rounded-lg text-sm overflow-auto my-4 font-mono whitespace-pre">{children}</pre>
),
pre: ({ children }: { children?: React.ReactNode }) => {
if (isValidElement(children) && children.type === MermaidDiagram) {
return children
}
const chart = mermaidChartFromPre(children)
if (chart) return <MermaidDiagram chart={chart} />
return (
<pre className="bg-muted text-foreground border border-border p-4 rounded-lg text-sm overflow-auto my-4 font-mono whitespace-pre">{children}</pre>
)
},
table: ({ children }: { children?: React.ReactNode }) => <div className="overflow-auto my-4"><table className="w-full border-collapse text-sm">{children}</table></div>,
thead: ({ children }: { children?: React.ReactNode }) => <thead className="bg-muted/50">{children}</thead>,
tr: ({ children }: { children?: React.ReactNode }) => <tr className="border-b">{children}</tr>,
th: ({ children }: { children?: React.ReactNode }) => <th className="px-4 py-2 text-left font-medium">{children}</th>,
td: ({ children }: { children?: React.ReactNode }) => <td className="px-4 py-2 text-muted-foreground">{children}</td>,
td: ({ children }: { children?: React.ReactNode }) => <td className="px-4 py-2 text-foreground">{children}</td>,
hr: () => <hr className="my-6 border-border" />,
strong: ({ children }: { children?: React.ReactNode }) => <strong className="font-semibold text-foreground">{children}</strong>,
em: ({ children }: { children?: React.ReactNode }) => <em>{children}</em>,
}
const contentReady = loadedPath === normalizeDocPath(currentPath) && markdown.length > 0
if (selectedItem) {
return (
<main id="main-content" className="flex-1 p-8 max-w-3xl">
<MobileToc selectedItem={selectedItem} />
{loadingContent ? (
<div className="text-muted-foreground">Loading content...</div>
) : markdown ? (
<article
ref={articleRef}
className="prose prose-neutral dark:prose-invert max-w-none prose-pre:bg-muted prose-pre:text-foreground prose-code:text-foreground"
>
<Markdown remarkPlugins={[remarkGfm]} components={markdownComponents}>
{markdown}
</Markdown>
</article>
) : (
<div className="bg-muted/50 border rounded-lg p-6">
<p className="text-muted-foreground">
Content for <strong className="text-primary">{selectedItem.title}</strong> would be displayed here.
</p>
</div>
)}
<main id="main-content" className="docs-main flex-1 min-w-0">
<div className="docs-main-inner mx-auto w-full max-w-[720px] px-6 py-8 lg:px-8">
<MobileToc selectedItem={selectedItem} />
{loadingContent && (
<div className="text-muted-foreground animate-pulse">Loading content...</div>
)}
{contentReady && (
<article
key={currentPath}
ref={articleRef}
className="prose prose-neutral dark:prose-invert max-w-none prose-pre:bg-muted prose-pre:text-foreground prose-code:text-foreground"
>
<Markdown remarkPlugins={[remarkGfm]} components={markdownComponents}>
{markdown}
</Markdown>
</article>
)}
{!loadingContent && !contentReady && loadedPath === normalizeDocPath(currentPath) && (
<div className="bg-muted/50 border rounded-lg p-6">
<p className="text-muted-foreground">
Content for <strong className="text-primary">{selectedItem.title}</strong> would be displayed here.
</p>
</div>
)}
</div>
</main>
)
}
return (
<main className="flex-1 p-8 max-w-3xl">
<main id="main-content" className="docs-main flex-1 min-w-0">
<div className="docs-main-inner mx-auto w-full max-w-[720px] px-6 py-8 lg:px-8">
<h1 className="text-4xl font-bold tracking-tight mb-4">
{config.site?.title || 'Documentation'}
</h1>
@@ -166,6 +235,7 @@ export function Content({ config, routes, selectedItem }: ContentProps) {
<pre className="bg-muted text-foreground border border-border p-4 rounded-lg text-sm overflow-auto">
{JSON.stringify(routes.routes?.slice(0, 3), null, 2)}
</pre>
</div>
</main>
)
}
+109 -30
View File
@@ -1,18 +1,27 @@
// Header component
import { Menu } from 'lucide-react'
import type { UIConfig } from './types'
// Header component — Mintlify-style docs chrome
import { Github, Menu, Search } from 'lucide-react'
import type { NavTabConfig, UIConfig } from './types'
import { LocaleSwitcher } from './i18n'
import { Button } from '@/components/ui/button'
import { ThemeToggle } from '@/components/theme-toggle'
interface HeaderProps {
config: UIConfig
templateKey?: string
onMenuClick?: () => void
navTabs?: NavTabConfig[]
activeTabIndex?: number
onTabChange?: (index: number) => void
}
export function Header({
config,
templateKey = 'docs',
onMenuClick,
}: {
config: UIConfig
templateKey?: string
onMenuClick?: () => void
}) {
navTabs,
activeTabIndex = 0,
onTabChange,
}: HeaderProps) {
const headerSlot = config.templates?.[templateKey]?.slots?.header
const headerRef = headerSlot?.ref
const headerComponent = headerRef
@@ -29,16 +38,36 @@ export function Header({
const isDarkMode = document.documentElement.classList.contains('dark')
const logoSrc = header?.logoImage || (logoConfig ? (isDarkMode ? (logoConfig.dark || logoConfig.light) : (logoConfig.light || logoConfig.dark)) : null)
const logoHref = logoConfig?.href || '/'
const logoText = header?.logoText || config.site?.title || 'PraisonAIUI'
const tabs = navTabs ?? config.navigation?.tabs ?? []
const navbar = config.navbar
const navbarLinks = navbar?.links ?? header?.links ?? []
const primary = navbar?.primary ?? (header?.cta ? { type: 'button' as const, label: header.cta.label, href: header.cta.href } : undefined)
const showMintlifyNav = tabs.length > 0
const onLogoClick = (event: React.MouseEvent) => {
event.preventDefault()
window.history.pushState({}, '', logoHref)
window.dispatchEvent(new CustomEvent('aiui:navigate', { detail: { path: logoHref } }))
}
const onSearchClick = () => {
window.dispatchEvent(new CustomEvent('aiui:search-open'))
}
return (
<header className="sticky top-0 z-50 border-b border-border/50 bg-background/80 backdrop-blur-xl supports-[backdrop-filter]:bg-background/60">
<div className="max-w-screen-2xl mx-auto flex h-16 items-center px-6 gap-4">
<header
className="sticky top-0 z-50 border-b border-border/40 bg-background/90 backdrop-blur-xl supports-[backdrop-filter]:bg-background/75"
data-aiui-header-tabs={showMintlifyNav ? 'true' : undefined}
>
<div className="flex h-14 items-center gap-4 px-4 lg:px-6">
{onMenuClick && (
<Button
type="button"
variant="ghost"
size="icon"
className="md:hidden shrink-0"
className="md:hidden shrink-0 -ml-1"
onClick={onMenuClick}
aria-label="Open navigation menu"
>
@@ -46,47 +75,97 @@ export function Header({
</Button>
)}
<a href={logoHref} className="flex items-center gap-3 group cursor-pointer min-w-0" onClick={(e) => {
e.preventDefault()
window.history.pushState({}, '', logoHref)
window.location.reload()
}}>
<a
href={logoHref}
className="flex items-center gap-2.5 group cursor-pointer shrink-0 min-w-0"
onClick={onLogoClick}
>
{logoSrc ? (
<img
src={logoSrc}
alt="Logo"
className="w-8 h-8 rounded-lg shadow-lg shadow-primary/20 group-hover:shadow-primary/40 transition-shadow object-contain"
className="h-7 w-7 rounded-md object-contain"
/>
) : (
<div className="w-8 h-8 rounded-lg bg-gradient-to-br from-primary/80 to-primary flex items-center justify-center shadow-lg shadow-primary/20 group-hover:shadow-primary/40 transition-shadow">
<span className="text-primary-foreground text-xs font-bold">AI</span>
<div className="h-7 w-7 rounded-md bg-gradient-to-br from-primary/80 to-primary flex items-center justify-center">
<span className="text-primary-foreground text-[10px] font-bold">AI</span>
</div>
)}
<span className="font-semibold text-lg tracking-tight truncate">{header?.logoText || config.site?.title || 'PraisonAIUI'}</span>
<span className="font-semibold text-[15px] tracking-tight truncate hidden sm:inline">{logoText}</span>
</a>
<nav className="hidden md:flex items-center gap-1 flex-1">
{header?.links?.map((link) => (
{showMintlifyNav && (
<nav className="hidden md:flex items-center gap-1 ml-2" aria-label="Primary docs sections">
{tabs.map((tab, index) => (
<button
key={tab.tab}
type="button"
onClick={() => onTabChange?.(index)}
className={`px-3 py-1.5 text-sm rounded-md transition-colors ${index === activeTabIndex
? 'text-foreground font-medium'
: 'text-muted-foreground hover:text-foreground'
}`}
>
{tab.tab}
</button>
))}
</nav>
)}
{!showMintlifyNav && (
<nav className="hidden md:flex items-center gap-1 flex-1 ml-2">
{navbarLinks.map((link) => (
<a
key={link.href}
href={link.href}
className="px-3 py-1.5 text-sm text-muted-foreground hover:text-foreground transition-colors rounded-md hover:bg-muted/50"
>
{link.label}
</a>
))}
</nav>
)}
<div className="flex-1 min-w-0" />
{config.search?.enabled !== false && showMintlifyNav && (
<button
type="button"
onClick={onSearchClick}
className="hidden sm:flex items-center gap-2 h-9 w-full max-w-[220px] px-3 text-sm text-muted-foreground border border-border/60 rounded-lg bg-muted/30 hover:bg-muted/50 transition-colors"
aria-label="Search documentation"
>
<Search className="h-4 w-4 shrink-0" />
<span className="flex-1 text-left truncate">Search...</span>
<kbd className="hidden lg:inline text-[10px] font-medium text-muted-foreground/80 border border-border/60 rounded px-1.5 py-0.5">⌘K</kbd>
</button>
)}
<div className="flex items-center gap-1.5 shrink-0">
{showMintlifyNav && navbarLinks.map((link) => (
<a
key={link.href}
href={link.href}
className="px-3 py-2 text-sm text-muted-foreground hover:text-foreground transition-colors rounded-md hover:bg-muted/50"
className="hidden lg:inline-flex px-3 py-1.5 text-sm text-muted-foreground hover:text-foreground transition-colors"
>
{link.label}
</a>
))}
</nav>
<div className="flex items-center gap-2 ml-auto">
<LocaleSwitcher />
{header?.cta && (
{primary && (
<a
href={header.cta.href}
className="inline-flex items-center justify-center rounded-md text-sm font-medium bg-primary text-primary-foreground shadow h-9 px-4 hover:bg-primary/90 transition-colors"
href={primary.href}
target={primary.href.startsWith('http') ? '_blank' : undefined}
rel={primary.href.startsWith('http') ? 'noopener noreferrer' : undefined}
className="inline-flex items-center justify-center gap-1.5 rounded-lg text-sm font-medium bg-primary text-primary-foreground h-9 px-3.5 hover:bg-primary/90 transition-colors"
>
{header.cta.label}
{primary.type === 'github' && <Github className="h-4 w-4" />}
{primary.label ?? (primary.type === 'github' ? 'GitHub' : 'Get started')}
</a>
)}
<ThemeToggle />
<LocaleSwitcher />
</div>
</div>
</header>
+30 -19
View File
@@ -1,12 +1,13 @@
// Sidebar component — navigation tree
// Sidebar component — Mintlify-style navigation tree
import { ChevronRight } from 'lucide-react'
import { ScrollArea } from '@/components/ui/scroll-area'
import { Separator } from '@/components/ui/separator'
import {
Sheet,
SheetContent,
SheetHeader,
SheetTitle,
} from '@/components/ui/sheet'
import { normalizeDocPath } from './pathUtils'
import type { DocsNav, NavItem } from './types'
interface NavTreeProps {
@@ -22,35 +23,44 @@ export function NavTree({ nav, activeItem, onItemClick }: NavTreeProps) {
if (hasChildren) {
return (
<div key={item.title + (item.path || '')}>
<div className="flex items-center gap-2 px-3 py-1.5 text-xs font-semibold text-muted-foreground/70 uppercase tracking-widest mt-4 first:mt-0">
<div key={item.title + (item.path || '')} className="mb-4">
<div className="flex items-center gap-1 px-3 py-1.5 text-[11px] font-semibold text-muted-foreground/80 uppercase tracking-wider">
{item.title}
</div>
<div className="space-y-0.5">
<div className="space-y-0.5 mt-1">
{item.children!.map((child) => renderItem(child, depth + 1))}
</div>
</div>
)
}
const href = normalizeDocPath(item.path || '/')
return (
<button
<a
key={item.title + (item.path || '')}
type="button"
onClick={() => onItemClick(item)}
className={`w-full text-left px-3 py-1.5 text-sm rounded-md transition-all duration-150 ${isActive
? 'bg-primary/10 text-primary font-medium border-l-2 border-primary'
: 'text-muted-foreground hover:text-foreground hover:bg-muted/50'
href={href}
data-nav-path={item.path || ''}
onClick={(event) => {
event.preventDefault()
onItemClick(item)
}}
className={`group flex items-center gap-1.5 w-full text-left px-3 py-1.5 text-[13px] rounded-md transition-colors ${isActive
? 'text-primary font-medium bg-primary/10'
: 'text-muted-foreground hover:text-foreground hover:bg-muted/40'
}`}
style={{ paddingLeft: `${12 + depth * 12}px` }}
style={{ paddingLeft: `${12 + depth * 10}px` }}
>
{item.title}
</button>
{depth > 0 && (
<ChevronRight className={`h-3 w-3 shrink-0 opacity-0 group-hover:opacity-40 ${isActive ? 'opacity-60 text-primary' : ''}`} />
)}
<span className="truncate">{item.title}</span>
</a>
)
}
return (
<nav className="space-y-0.5">
<nav className="space-y-0.5 pb-6">
{nav.items?.map((group) => renderItem(group))}
</nav>
)
@@ -62,10 +72,11 @@ interface SidebarProps extends NavTreeProps {
export function Sidebar({ nav, activeItem, onItemClick }: SidebarProps) {
return (
<aside className="w-64 min-w-[16rem] border-r border-border/50 hidden md:block">
<ScrollArea className="h-[calc(100vh-4rem)] py-4 px-2">
<NavTree nav={nav} activeItem={activeItem} onItemClick={onItemClick} />
<Separator className="my-4" />
<aside className="docs-sidebar w-[17rem] min-w-[17rem] shrink-0 hidden md:block border-r border-border/40">
<ScrollArea className="h-[calc(100vh-3.5rem)] sticky top-14">
<div className="py-5 px-2">
<NavTree nav={nav} activeItem={activeItem} onItemClick={onItemClick} />
</div>
</ScrollArea>
</aside>
)
+60 -28
View File
@@ -22,6 +22,7 @@ function collectHeadings(): TocHeading[] {
function usePageHeadings(selectedItem: NavItem | null) {
const [headings, setHeadings] = useState<TocHeading[]>([])
const [activeId, setActiveId] = useState('')
const refreshHeadings = useCallback(() => {
setHeadings(collectHeadings())
@@ -30,48 +31,79 @@ function usePageHeadings(selectedItem: NavItem | null) {
useEffect(() => {
refreshHeadings()
const timer = window.setTimeout(refreshHeadings, 100)
return () => window.clearTimeout(timer)
const timer2 = window.setTimeout(refreshHeadings, 500)
return () => {
window.clearTimeout(timer)
window.clearTimeout(timer2)
}
}, [selectedItem, refreshHeadings])
useEffect(() => {
const onContentLoaded = () => refreshHeadings()
window.addEventListener('aiui:content-loaded', onContentLoaded)
return () => window.removeEventListener('aiui:content-loaded', onContentLoaded)
}, [refreshHeadings])
if (!headings.length) return
return headings
const observer = new IntersectionObserver(
(entries) => {
const visible = entries
.filter((entry) => entry.isIntersecting)
.sort((a, b) => a.boundingClientRect.top - b.boundingClientRect.top)
if (visible[0]?.target.id) {
setActiveId(visible[0].target.id)
}
},
{ rootMargin: '-20% 0px -70% 0px', threshold: 0 },
)
for (const heading of headings) {
const el = document.getElementById(heading.id)
if (el) observer.observe(el)
}
return () => observer.disconnect()
}, [headings])
return { headings, activeId }
}
function TocLinks({ headings }: { headings: TocHeading[] }) {
function TocLinks({ headings, activeId }: { headings: TocHeading[]; activeId: string }) {
if (headings.length === 0) {
return <p className="text-sm text-muted-foreground">No sections on this page.</p>
}
return (
<div className="space-y-2">
{headings.map((heading) => (
<a
key={heading.id}
href={`#${heading.id}`}
className={`flex items-center gap-2 text-muted-foreground hover:text-foreground transition-colors ${heading.level === 3 ? 'pl-3 text-sm' : heading.level === 4 ? 'pl-6 text-sm' : 'font-medium'}`}
>
<span className="w-1 h-1 rounded-full bg-muted-foreground/30 shrink-0" />
{heading.text}
</a>
))}
<div className="space-y-2.5">
{headings.map((heading) => {
const isActive = heading.id === activeId
return (
<a
key={heading.id}
href={`#${heading.id}`}
className={`flex items-start gap-2 text-[13px] leading-snug transition-colors ${heading.level === 3 ? 'pl-3' : heading.level === 4 ? 'pl-5' : ''
} ${isActive
? 'text-primary font-medium'
: 'text-muted-foreground hover:text-foreground'
}`}
>
<span
className={`mt-[0.45rem] h-1.5 w-1.5 shrink-0 rounded-full ${isActive ? 'bg-primary' : 'bg-muted-foreground/30'
}`}
/>
<span>{heading.text}</span>
</a>
)
})}
</div>
)
}
export function MobileToc({ selectedItem }: { selectedItem: NavItem | null }) {
const headings = usePageHeadings(selectedItem)
const { headings, activeId } = usePageHeadings(selectedItem)
if (!selectedItem || headings.length === 0) return null
return (
<details className="lg:hidden border border-border rounded-lg mb-6 px-4 py-3 bg-muted/20">
<details className="lg:hidden border border-border/60 rounded-lg mb-6 px-4 py-3 bg-muted/20">
<summary className="text-sm font-medium cursor-pointer">On this page</summary>
<nav className="mt-3">
<TocLinks headings={headings} />
<TocLinks headings={headings} activeId={activeId} />
</nav>
</details>
)
@@ -86,7 +118,7 @@ export function Toc({
zones?: ZonesConfig
showToc?: boolean
}) {
const headings = usePageHeadings(selectedItem)
const { headings, activeId } = usePageHeadings(selectedItem)
const rightSidebarWidgets = zones?.rightSidebar || []
if (!showToc && rightSidebarWidgets.filter((w) => w.type !== 'Toc').length === 0) {
@@ -94,21 +126,21 @@ export function Toc({
}
return (
<aside className="w-64 hidden lg:block border-l border-border/50">
<div className="sticky top-20 px-4 py-6 space-y-4">
<aside className="docs-toc w-[16rem] min-w-[16rem] shrink-0 hidden lg:block">
<div className="sticky top-14 h-[calc(100vh-3.5rem)] overflow-y-auto px-4 py-6">
{showToc && (
<div>
<h4 className="text-xs font-semibold text-muted-foreground/70 uppercase tracking-widest mb-4">
<h4 className="text-[11px] font-semibold text-muted-foreground/80 uppercase tracking-wider mb-4">
On this page
</h4>
<nav className="text-sm">
<TocLinks headings={headings} />
<nav>
<TocLinks headings={headings} activeId={activeId} />
</nav>
</div>
)}
{rightSidebarWidgets.filter((w) => w.type !== 'Toc').length > 0 && (
<div className="pt-4 border-t border-border/50">
<div className="pt-4 mt-4 border-t border-border/40">
<ZoneWidgets widgets={rightSidebarWidgets.filter((w) => w.type !== 'Toc')} />
</div>
)}
+5 -11
View File
@@ -6,22 +6,16 @@ import {
DropdownMenuItem,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu"
type Theme = "light" | "dark" | "system"
import { setThemePreference, type ThemePreference } from "../themes"
interface ThemeToggleProps {
theme?: Theme
onThemeChange?: (theme: Theme) => void
theme?: ThemePreference
onThemeChange?: (theme: ThemePreference) => void
}
export function ThemeToggle({ onThemeChange }: ThemeToggleProps) {
const setTheme = (newTheme: Theme) => {
if (newTheme === "system") {
const systemDark = window.matchMedia("(prefers-color-scheme: dark)").matches
document.documentElement.classList.toggle("dark", systemDark)
} else {
document.documentElement.classList.toggle("dark", newTheme === "dark")
}
const setTheme = (newTheme: ThemePreference) => {
setThemePreference(newTheme)
onThemeChange?.(newTheme)
}
+89
View File
@@ -0,0 +1,89 @@
import type { NavItem, NavTabConfig } from '../types'
function sectionTitle(item: NavItem): string {
return item.title.trim().toLowerCase()
}
function collectPaths(item: NavItem): string[] {
const paths: string[] = []
if (item.path) paths.push(item.path.toLowerCase())
for (const child of item.children ?? []) {
paths.push(...collectPaths(child))
}
return paths
}
function pathMatchesPage(docPath: string, page: string): boolean {
const normalizedPage = page.replace(/^\/+/, '').toLowerCase()
const normalizedPath = docPath.replace(/\/+$/, '').toLowerCase()
if (normalizedPage === 'index') {
return normalizedPath.endsWith('/index') || normalizedPath === '/docs'
}
const suffix = `/${normalizedPage}`
return normalizedPath.endsWith(suffix) || normalizedPath.endsWith(`${suffix}/index`)
}
/** Whether a sidebar group belongs to the given navigation tab (from topnav plugin logic). */
export function navItemBelongsToTab(item: NavItem, tab: NavTabConfig): boolean {
if (!tab.groups?.length) return false
const header = sectionTitle(item)
const paths = collectPaths(item)
for (const grp of tab.groups) {
const groupName = grp.group.toLowerCase()
if (header === groupName) return true
if (grp.prefix) {
const prefix = grp.prefix.toLowerCase()
if (header === prefix || header === prefix.replace(/-/g, ' ')) return true
const prefixPath = `/docs/${prefix}`
if (paths.some((path) => path === prefixPath || path.startsWith(`${prefixPath}/`))) {
return true
}
}
if (grp.pages?.some((page) => paths.some((path) => pathMatchesPage(path, page)))) {
return true
}
if (!item.children?.length && grp.pages?.some((page) => {
const slug = page.split('/').pop()?.replace(/-/g, ' ') ?? ''
return header === slug || header === page.toLowerCase()
})) {
return true
}
}
return false
}
/** Pick the active tab index from the current URL path. */
export function detectActiveTabIndex(tabs: NavTabConfig[], path: string): number {
const normalized = path.toLowerCase()
let bestMatch = -1
let bestMatchLen = 0
for (let i = 0; i < tabs.length; i++) {
const tab = tabs[i]
for (const grp of tab.groups ?? []) {
if (!grp.prefix) continue
const needle = `/${grp.prefix.toLowerCase()}/`
if (normalized.includes(needle) && needle.length > bestMatchLen) {
bestMatch = i
bestMatchLen = needle.length
}
}
}
if (bestMatch >= 0) return bestMatch
return 0
}
export function filterNavByTab(items: NavItem[], tab: NavTabConfig | undefined): NavItem[] {
if (!tab) return items
return items.filter((item) => navItemBelongsToTab(item, tab))
}
+152 -37
View File
@@ -12,37 +12,37 @@
--radius-2xl: calc(var(--radius) + 8px);
--radius-3xl: calc(var(--radius) + 12px);
--radius-4xl: calc(var(--radius) + 16px);
--color-background: var(--background);
--color-foreground: var(--foreground);
--color-card: var(--card);
--color-card-foreground: var(--card-foreground);
--color-popover: var(--popover);
--color-popover-foreground: var(--popover-foreground);
--color-primary: var(--primary);
--color-primary-foreground: var(--primary-foreground);
--color-secondary: var(--secondary);
--color-secondary-foreground: var(--secondary-foreground);
--color-muted: var(--muted);
--color-muted-foreground: var(--muted-foreground);
--color-accent: var(--accent);
--color-accent-foreground: var(--accent-foreground);
--color-destructive: var(--destructive);
--color-border: var(--border);
--color-input: var(--input);
--color-ring: var(--ring);
--color-chart-1: var(--chart-1);
--color-chart-2: var(--chart-2);
--color-chart-3: var(--chart-3);
--color-chart-4: var(--chart-4);
--color-chart-5: var(--chart-5);
--color-sidebar: var(--sidebar);
--color-sidebar-foreground: var(--sidebar-foreground);
--color-sidebar-primary: var(--sidebar-primary);
--color-sidebar-primary-foreground: var(--sidebar-primary-foreground);
--color-sidebar-accent: var(--sidebar-accent);
--color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
--color-sidebar-border: var(--sidebar-border);
--color-sidebar-ring: var(--sidebar-ring);
--color-background: hsl(var(--background));
--color-foreground: hsl(var(--foreground));
--color-card: hsl(var(--card));
--color-card-foreground: hsl(var(--card-foreground));
--color-popover: hsl(var(--popover));
--color-popover-foreground: hsl(var(--popover-foreground));
--color-primary: hsl(var(--primary));
--color-primary-foreground: hsl(var(--primary-foreground));
--color-secondary: hsl(var(--secondary));
--color-secondary-foreground: hsl(var(--secondary-foreground));
--color-muted: hsl(var(--muted));
--color-muted-foreground: hsl(var(--muted-foreground));
--color-accent: hsl(var(--accent));
--color-accent-foreground: hsl(var(--accent-foreground));
--color-destructive: hsl(var(--destructive));
--color-border: hsl(var(--border));
--color-input: hsl(var(--input));
--color-ring: hsl(var(--ring));
--color-chart-1: hsl(var(--chart-1));
--color-chart-2: hsl(var(--chart-2));
--color-chart-3: hsl(var(--chart-3));
--color-chart-4: hsl(var(--chart-4));
--color-chart-5: hsl(var(--chart-5));
--color-sidebar: hsl(var(--sidebar));
--color-sidebar-foreground: hsl(var(--sidebar-foreground));
--color-sidebar-primary: hsl(var(--sidebar-primary));
--color-sidebar-primary-foreground: hsl(var(--sidebar-primary-foreground));
--color-sidebar-accent: hsl(var(--sidebar-accent));
--color-sidebar-accent-foreground: hsl(var(--sidebar-accent-foreground));
--color-sidebar-border: hsl(var(--sidebar-border));
--color-sidebar-ring: hsl(var(--sidebar-ring));
}
/* Color variables are injected via theme.css at build time.
@@ -52,20 +52,37 @@
* {
@apply border-border outline-ring/50;
}
html,
body {
@apply bg-background text-foreground;
background-color: hsl(var(--background));
color: hsl(var(--foreground));
}
}
@layer components {
/* Readable code blocks in light and dark prose */
.prose pre {
@apply bg-muted text-foreground border border-border;
/* Readable prose in light and dark mode */
.prose-neutral {
--tw-prose-body: hsl(var(--foreground));
--tw-prose-headings: hsl(var(--foreground));
--tw-prose-bold: hsl(var(--foreground));
--tw-prose-counters: hsl(var(--muted-foreground));
--tw-prose-bullets: hsl(var(--muted-foreground));
--tw-prose-pre-code: hsl(var(--foreground));
--tw-prose-pre-bg: hsl(var(--muted));
--tw-prose-code: hsl(var(--foreground));
}
.prose pre code {
.prose pre,
.prose-neutral :where(pre) {
@apply bg-muted text-foreground border border-border;
color: hsl(var(--foreground)) !important;
background-color: hsl(var(--muted)) !important;
}
.prose pre code,
.prose-neutral :where(pre code) {
@apply text-foreground bg-transparent;
color: inherit;
color: inherit !important;
}
.prose :not(pre) > code {
@@ -79,4 +96,102 @@
.prose .aiui-tabs pre {
@apply my-2;
}
.mermaid-diagram {
display: block;
width: 100%;
margin: 1.5rem 0;
padding: 1rem 1.25rem;
border-radius: 12px;
overflow-x: auto;
transition: border-color 0.3s ease, box-shadow 0.3s ease;
}
.mermaid-diagram-svg {
width: 100%;
min-width: 100%;
display: block;
}
:root:not(.dark) .mermaid-diagram:not(.mermaid-error) {
background: hsl(var(--muted));
border: 1px solid hsl(var(--border));
}
.dark .mermaid-diagram:not(.mermaid-error) {
background: rgba(15, 23, 42, 0.5);
border: 1px solid rgba(148, 163, 184, 0.15);
}
.mermaid-diagram:not(.mermaid-error):hover {
border-color: rgba(20, 184, 166, 0.4);
box-shadow: 0 0 24px rgba(13, 148, 136, 0.12);
}
.mermaid-diagram svg {
width: 100% !important;
max-width: 100%;
height: auto !important;
display: block;
}
.mermaid-diagram .nodeLabel,
.mermaid-diagram .label div,
.mermaid-diagram .cluster-label .nodeLabel {
color: #ffffff !important;
fill: #ffffff !important;
font-family: "Inter", system-ui, -apple-system, sans-serif !important;
font-weight: 500 !important;
}
.mermaid-diagram .edgeLabel {
color: #e2e8f0 !important;
fill: #e2e8f0 !important;
background-color: rgba(15, 23, 42, 0.85) !important;
padding: 2px 6px;
border-radius: 4px;
}
.mermaid-diagram .cluster-label .nodeLabel {
font-weight: 600 !important;
font-size: 0.95em;
}
.mermaid-diagram .node rect,
.mermaid-diagram .node circle,
.mermaid-diagram .node polygon {
rx: 8;
ry: 8;
}
/* Mintlify-style docs shell */
.docs-shell {
width: 100%;
}
.docs-main-inner {
min-height: calc(100vh - 3.5rem);
}
}
/* Prose readability — must beat @tailwindcss/typography defaults (unlayered) */
article.prose :where(p, li, td, th, blockquote, strong, em, h1, h3, h4, h5, h6) {
color: hsl(var(--foreground)) !important;
-webkit-text-fill-color: unset !important;
opacity: 1 !important;
}
article.prose :where(h2) {
color: hsl(var(--primary)) !important;
-webkit-text-fill-color: unset !important;
opacity: 1 !important;
}
article.prose .aiui-feature-grid > li {
color: hsl(var(--foreground)) !important;
}
.dark article.prose.dark\:prose-invert :where(p, li, td, th, blockquote, strong, h1, h2, h3, h4) {
color: hsl(var(--foreground)) !important;
-webkit-text-fill-color: unset !important;
}
@@ -0,0 +1,98 @@
import { useEffect, useId, useRef, useState } from 'react'
import { fitMermaidSvg, getMermaidConfig, isDarkMode } from './mermaidThemes'
let renderCounter = 0
function nextRenderId(baseId: string): string {
renderCounter += 1
return `mermaid-${baseId.replace(/:/g, '')}-${renderCounter}`
}
export function MermaidDiagram({ chart }: { chart: string }) {
const baseId = useId()
const containerRef = useRef<HTMLDivElement>(null)
const svgRef = useRef<HTMLDivElement>(null)
const bindFunctionsRef = useRef<((element: Element) => void) | undefined>(undefined)
const [status, setStatus] = useState<'loading' | 'ready' | 'error'>('loading')
const [errorMessage, setErrorMessage] = useState('')
const [svgMarkup, setSvgMarkup] = useState('')
useEffect(() => {
const container = containerRef.current
if (!container) return
let cancelled = false
const render = async () => {
setStatus('loading')
setErrorMessage('')
setSvgMarkup('')
const mermaid = (await import('mermaid')).default
mermaid.initialize(getMermaidConfig(isDarkMode()))
const styles = getComputedStyle(container)
const paddingX = parseFloat(styles.paddingLeft) + parseFloat(styles.paddingRight)
const containerWidth = container.clientWidth - paddingX
try {
const { svg, bindFunctions } = await mermaid.render(nextRenderId(baseId), chart)
if (cancelled) return
bindFunctionsRef.current = bindFunctions
setSvgMarkup(fitMermaidSvg(svg, containerWidth))
setStatus('ready')
} catch (err) {
if (cancelled) return
setStatus('error')
setErrorMessage(err instanceof Error ? err.message : 'Failed to render diagram')
}
}
void render()
const observer = new ResizeObserver(() => {
void render()
})
observer.observe(container)
const themeObserver = new MutationObserver(() => {
void render()
})
themeObserver.observe(document.documentElement, {
attributes: true,
attributeFilter: ['class'],
})
return () => {
cancelled = true
observer.disconnect()
themeObserver.disconnect()
}
}, [chart, baseId])
useEffect(() => {
if (status !== 'ready' || !svgRef.current) return
bindFunctionsRef.current?.(svgRef.current)
}, [status, svgMarkup])
if (status === 'error') {
return (
<pre className="mermaid-diagram mermaid-error text-destructive text-sm my-4 p-4 rounded-lg border border-destructive/30 bg-destructive/5 overflow-x-auto">
{errorMessage}
</pre>
)
}
return (
<div
ref={containerRef}
className={`mermaid-diagram w-full${status === 'loading' ? ' mermaid-loading text-muted-foreground text-sm' : ''}`}
aria-busy={status === 'loading'}
aria-label={status === 'loading' ? 'Loading diagram' : 'Mermaid diagram'}
>
{status === 'loading' ? 'Loading diagram…' : null}
{svgMarkup ? (
<div ref={svgRef} className="mermaid-diagram-svg w-full" dangerouslySetInnerHTML={{ __html: svgMarkup }} />
) : null}
</div>
)
}
+118
View File
@@ -0,0 +1,118 @@
/** Mermaid theme config — aligned with plugins/mermaid.js */
export const MERMAID_DARK_THEME = {
theme: 'base' as const,
themeVariables: {
background: 'transparent',
mainBkg: '#0d9488',
primaryColor: '#0d9488',
primaryBorderColor: '#14b8a6',
primaryTextColor: '#ffffff',
secondaryColor: '#6366f1',
secondaryBorderColor: '#818cf8',
secondaryTextColor: '#ffffff',
tertiaryColor: '#e11d48',
tertiaryBorderColor: '#fb7185',
tertiaryTextColor: '#ffffff',
textColor: '#f1f5f9',
labelTextColor: '#f1f5f9',
lineColor: '#94a3b8',
arrowheadColor: '#cbd5e1',
nodeBorder: '#14b8a6',
clusterBkg: 'rgba(13, 148, 136, 0.08)',
clusterBorder: 'rgba(20, 184, 166, 0.4)',
defaultLinkColor: '#94a3b8',
edgeLabelBackground: 'rgba(15, 23, 42, 0.85)',
nodeTextColor: '#ffffff',
actorBkg: '#0d9488',
actorBorder: '#14b8a6',
actorTextColor: '#ffffff',
actorLineColor: '#64748b',
signalColor: '#cbd5e1',
signalTextColor: '#f1f5f9',
activationBorderColor: '#14b8a6',
activationBkgColor: 'rgba(13, 148, 136, 0.2)',
sequenceNumberColor: '#ffffff',
noteBkgColor: 'rgba(99, 102, 241, 0.2)',
noteBorderColor: '#818cf8',
noteTextColor: '#f1f5f9',
titleColor: '#f8fafc',
fontFamily: '"Inter", system-ui, -apple-system, sans-serif',
fontSize: '16px',
},
}
export const MERMAID_LIGHT_THEME = {
theme: 'base' as const,
themeVariables: {
background: 'transparent',
mainBkg: '#0d9488',
primaryColor: '#0d9488',
primaryBorderColor: '#0f766e',
primaryTextColor: '#ffffff',
secondaryColor: '#6366f1',
secondaryBorderColor: '#4f46e5',
secondaryTextColor: '#ffffff',
tertiaryColor: '#e11d48',
tertiaryBorderColor: '#be123c',
tertiaryTextColor: '#ffffff',
textColor: '#1e293b',
labelTextColor: '#334155',
lineColor: '#94a3b8',
arrowheadColor: '#64748b',
nodeBorder: '#0f766e',
clusterBkg: 'rgba(13, 148, 136, 0.06)',
clusterBorder: 'rgba(15, 118, 110, 0.3)',
defaultLinkColor: '#94a3b8',
edgeLabelBackground: 'rgba(255, 255, 255, 0.9)',
nodeTextColor: '#ffffff',
actorBkg: '#0d9488',
actorBorder: '#0f766e',
actorTextColor: '#ffffff',
actorLineColor: '#cbd5e1',
signalColor: '#64748b',
signalTextColor: '#1e293b',
noteBkgColor: 'rgba(99, 102, 241, 0.1)',
noteBorderColor: '#6366f1',
noteTextColor: '#1e293b',
titleColor: '#0f172a',
fontFamily: '"Inter", system-ui, -apple-system, sans-serif',
fontSize: '16px',
},
}
export function isDarkMode(): boolean {
return document.documentElement.classList.contains('dark')
}
export function getMermaidConfig(dark: boolean) {
const theme = dark ? MERMAID_DARK_THEME : MERMAID_LIGHT_THEME
return {
startOnLoad: false,
securityLevel: 'strict' as const,
...theme,
flowchart: {
useMaxWidth: true,
htmlLabels: true,
curve: 'basis' as const,
},
sequence: {
useMaxWidth: true,
wrap: true,
},
er: { useMaxWidth: true },
gantt: { useMaxWidth: true },
}
}
/** Ensure rendered SVG stretches to the diagram container (Mintlify-style). */
export function fitMermaidSvg(svg: string, containerWidth: number): string {
const width = Math.max(Math.floor(containerWidth), 320)
return svg.replace(/<svg([^>]*)>/, (_match, attrs: string) => {
const cleaned = attrs
.replace(/\swidth="[^"]*"/g, '')
.replace(/\sheight="[^"]*"/g, '')
.replace(/\sstyle="[^"]*"/g, '')
return `<svg${cleaned} width="${width}" style="width:100%;max-width:100%;height:auto;display:block;">`
})
}
+9 -103
View File
@@ -1,25 +1,23 @@
import { isMkdocsTabHeading, mkdocsTabTitle } from './mkdocsPreprocess'
/** Post-render DOM fixes for MkDocs-style content (tabs, feature cards). */
/** Post-render class-only fixes for MkDocs-style content. No DOM moves — React owns the tree. */
export function enhanceMkdocsDom(root: HTMLElement): void {
const articles = new Set<HTMLElement>()
if (root.matches('article.prose, main .prose, .prose, main.flex-1, #main-content')) {
if (root.matches('article.prose, #main-content article')) {
articles.add(root)
}
root.querySelectorAll<HTMLElement>('article.prose, main .prose, .prose').forEach((el) => articles.add(el))
root.querySelectorAll<HTMLElement>('article.prose, #main-content article').forEach((el) => articles.add(el))
for (const article of articles) {
removeRawDivParagraphs(article)
hideRawDivParagraphs(article)
enhanceFeatureGrid(article)
enhanceTabs(article)
}
}
function removeRawDivParagraphs(article: HTMLElement): void {
/** Hide MkDocs artefact paragraphs — never remove nodes React may still own. */
function hideRawDivParagraphs(article: HTMLElement): void {
for (const p of article.querySelectorAll('p')) {
const text = p.textContent?.trim() ?? ''
if (/^<\/?div[\s>]/.test(text) || text === '</div>') {
p.remove()
p.classList.add('hidden')
}
}
}
@@ -53,97 +51,5 @@ function enhanceFeatureGrid(article: HTMLElement): void {
}
}
interface TabSection {
heading: HTMLHeadingElement
title: string
nodes: Node[]
}
function enhanceTabs(article: HTMLElement): void {
if (article.querySelector('[data-aiui-tabs]')) return
const tabHeadings = Array.from(article.querySelectorAll<HTMLHeadingElement>('h3')).filter((h3) =>
isMkdocsTabHeading(h3.textContent?.trim() ?? ''),
)
if (tabHeadings.length < 2) return
const sections: TabSection[] = tabHeadings.map((heading) => {
const title = mkdocsTabTitle(heading.textContent?.trim() ?? '')
const nodes: Node[] = []
let sibling = heading.nextSibling
while (sibling) {
if (sibling.nodeType === Node.ELEMENT_NODE) {
const el = sibling as HTMLElement
if (el.tagName === 'H3' && isMkdocsTabHeading(el.textContent?.trim() ?? '')) break
if (['H1', 'H2'].includes(el.tagName)) break
}
const next = sibling.nextSibling
nodes.push(sibling)
sibling = next
}
return { heading, title, nodes }
})
const wrapper = document.createElement('div')
wrapper.className = 'aiui-tabs my-6 border border-border rounded-lg overflow-hidden'
wrapper.dataset.aiuiTabs = 'true'
const buttons = document.createElement('div')
buttons.className = 'aiui-tab-buttons flex flex-wrap gap-0 border-b border-border bg-muted/30'
wrapper.appendChild(buttons)
const panels = document.createElement('div')
panels.className = 'aiui-tab-panels'
wrapper.appendChild(panels)
sections.forEach((section, index) => {
const btn = document.createElement('button')
btn.type = 'button'
btn.className = [
'aiui-tab-button px-4 py-2 text-sm font-medium transition-colors',
index === 0
? 'bg-background text-foreground border-b-2 border-primary'
: 'text-muted-foreground hover:text-foreground',
].join(' ')
btn.textContent = section.title
btn.dataset.tabIndex = String(index)
buttons.appendChild(btn)
const panel = document.createElement('div')
panel.className = index === 0 ? 'aiui-tab-panel p-4 block' : 'aiui-tab-panel p-4 hidden'
panel.dataset.tabPanel = String(index)
for (const node of section.nodes) {
panel.appendChild(node)
}
panels.appendChild(panel)
})
const insertPoint = sections[0].heading
article.insertBefore(wrapper, insertPoint)
for (const section of sections) {
section.heading.remove()
}
buttons.addEventListener('click', (event) => {
const target = (event.target as HTMLElement).closest<HTMLButtonElement>('.aiui-tab-button')
if (!target) return
const index = target.dataset.tabIndex ?? '0'
for (const btn of buttons.querySelectorAll<HTMLButtonElement>('.aiui-tab-button')) {
const active = btn.dataset.tabIndex === index
btn.classList.toggle('bg-background', active)
btn.classList.toggle('text-foreground', active)
btn.classList.toggle('border-b-2', active)
btn.classList.toggle('border-primary', active)
btn.classList.toggle('text-muted-foreground', !active)
}
for (const panel of panels.querySelectorAll<HTMLElement>('.aiui-tab-panel')) {
panel.classList.toggle('hidden', panel.dataset.tabPanel !== index)
panel.classList.toggle('block', panel.dataset.tabPanel === index)
}
})
}
/** No-op — React article must not receive foreign plugin nodes. */
export function teardownMkdocsEnhancements(_root: HTMLElement): void {}
+37
View File
@@ -0,0 +1,37 @@
/** Canonical docs path: no trailing slash except root. */
export function normalizeDocPath(path: string): string {
const cleaned = (path || '/').replace(/\/+$/, '')
return cleaned || '/'
}
/** Normalise an internal docs href for SEO-friendly crawlable links. */
export function normalizeDocHref(href: string): string {
if (!href || href.startsWith('#')) return href
try {
const url = new URL(href, window.location.origin)
if (url.origin !== window.location.origin) return href
return normalizeDocPath(url.pathname) + url.search + url.hash
} catch {
return href
}
}
export function isInternalDocHref(href?: string): boolean {
if (!href || href.startsWith('#')) return false
if (href.startsWith('/docs')) return true
try {
const url = new URL(href, window.location.origin)
return url.origin === window.location.origin && url.pathname.startsWith('/docs')
} catch {
return false
}
}
/** Map a docs URL path to a markdown file path (leading slash, .md suffix). */
export function docPathToMarkdown(path: string): string {
const normalized = normalizeDocPath(path)
if (normalized === '/') {
return '/docs/index.md'
}
return `${normalized}.md`
}
+112
View File
@@ -0,0 +1,112 @@
# Spike #293 — BeautifulUI primitives in PraisonAIUI
Time-boxed evaluation proving BeautifulUI's AI-native primitives can build inside
PraisonAIUI's React 19 + Vite + Tailwind v4 toolchain and render against **mock,
PraisonAIUI-shaped** agent event streams.
**This folder is a spike. It is intentionally isolated from the shipped bundle:**
nothing here is imported by `src/main.tsx`, and none of it is synced into
`src/praisonaiui/templates/frontend`. Removing `src/frontend/src/spike/` and
`src/frontend/spike.html` fully reverts the spike.
## How to run
```bash
cd src/frontend
npm install
npm run dev # then open http://localhost:5173/spike.html
```
`SpikePage.tsx` drives the three primitives from `mockEvents.ts`, which uses the
real `SSEEvent` contract from `src/types.ts` (`run_content` deltas,
`reasoning_step` / `tool_call_started` / `tool_call_completed`, and an
`approval_request` payload shaped like a row from `GET /api/approvals`).
## What was vendored (upstream pin)
- Registry index: `https://www.beautifului.dev/r/registry.json`
- Component JSON: `https://www.beautifului.dev/r/<name>.json`
- Upstream repo: `slev12397/beautiful-ui` @ **`44a274e598395ab61e7c96c26fda2758780253b7`** (2026-09-22)
- License: **MIT** (upstream repo)
| Primitive | Registry item | Source | Registry deps | npm deps |
|---|---|---|---|---|
| StreamingText | `streaming-text` | `beautifui/primitives/StreamingText.tsx` (10.3 KB) | foundation | **none** (`react` only) |
| ThinkingState | `thinking-state` | `beautifui/primitives/ThinkingState.tsx` (11.3 KB) | foundation | **none** (`react` only) |
| ApprovalCard | `approval-card` | `beautifui/primitives/ApprovalCard.tsx` (16.5 KB) | foundation, button, glide-menu | **none** (`react` only) |
| Button (dep) | `button` | `beautifui/atoms/Button.tsx` (2.0 KB) | foundation | `class-variance-authority` *(already a PraisonAIUI dep)* |
| GlideMenu (dep) | `glide-menu` | `beautifui/primitives/GlideMenu.tsx` (1.9 KB) | foundation | **none** (`react` only) |
| foundation | `foundation` | `beautifui/foundation.css` (17.5 KB) | — | `shadow-plugin` *(see deviation)* |
### npm packages pulled by the registry & licence check
For the three chosen primitives (+ their two registry deps) the registry pulls
**zero new runtime npm packages**. The only npm dependency any of them declares is
`class-variance-authority` (via `button`), which PraisonAIUI already ships.
- `liveline`, `glimm` — **not pulled.** These are referenced only by chart-style
components (InsightCards) that we did not adopt. No licence review needed for this spike.
- `@central-icons-react` — **avoided.** All three primitives render icons as
**inline SVG** (no icon package at all), so no `lucide-react` substitution was
even required. This is the cleanest possible outcome for the icon concern.
## Deviations from a raw `shadcn add`
1. **`shadow-plugin` removed.** `foundation.css` imports `shadow-plugin/unprefixed`
(an extra Tailwind plugin dependency). To keep the spike lightweight we replaced
it with a small local `@theme` block defining the `--shadow-*` scale. Visual
shadows are approximate but faithful.
2. **Unbalanced-brace fix (upstream bug).** The flattened `foundation.css` emits a
**stray extra `}`** around `.records-footer-hint` (a `@media` block that lost its
opening line in the registry export). This makes Tailwind v4 fail with
`Missing opening {`. We removed the stray brace. **This is a real upstream blocker
worth reporting to `beautiful-ui`.**
3. **Imports rewritten** from `@/components/*` to `@/spike/beautifui/*` so the spike
stays isolated and cannot collide with the existing shadcn `components/ui/button.tsx`.
4. **`"use client"` directives dropped** (Vite SPA, not RSC).
## Build result
`vite build` of the spike entry (`spike.html`) succeeds:
```
✓ 39 modules transformed
spike.css 97.82 kB │ gzip: 17.48 kB (Tailwind + foundation)
spike.js 247.94 kB │ gzip: 78.52 kB (React runtime + 3 primitives + 2 deps)
```
- **Bundle size:** the primitives are cheap — self-contained TSX with no extra npm
deps. The JS weight is dominated by the React runtime; the incremental cost of the
three primitives is on the order of ~40 KB raw / low-teens KB gzip. The CSS is
full Tailwind + the ~17 KB foundation token/keyframe layer.
- **Motion / `prefers-reduced-motion`:** foundation ships two
`@media (prefers-reduced-motion: reduce)` guards (global animation kill-switch +
a StreamText-specific one). Motion is handled responsibly out of the box.
- **Dark mode parity:** foundation defines its **own** token namespace (`--page`,
`--canvas`, `--ink`, `--accent`, …) with its own `.dark` block, *separate* from
PraisonAIUI's shadcn tokens (`--background`/`--foreground` via `hsl(var(--…))`).
Good news: **no token collision** with the dashboard. Trade-off: the primitives do
**not** automatically inherit `chat.css`/YAML-driven theme colours — they'd need a
token bridge (map `--ink`→`--foreground`, `--surface`→`--card`, `--accent`→`--primary`)
before they visually match the shipped chat surface in both themes.
## Assessment
**Fit score: 7/10** for a spike, with caveats for production migration.
**Blockers / gaps:**
- Upstream `foundation.css` brace bug (fixed here; report upstream).
- No automatic theme inheritance — needs a token bridge to match `chat.css` dark/light.
- Primitives embed **demo content** (ice-cream sales copy, fake sources). They accept
props (`content`, `rows`, `questions`, `onSubmitted`, …) so they *can* be driven by
real events — proven in `SpikePage` — but the built-in defaults must be replaced.
- PraisonAIUI already has native equivalents: `chat/ThinkingSteps.tsx`,
`chat/ToolCallDisplay.tsx`, streaming in `chat/ChatMessages.tsx`. Adopting
BeautifulUI should be weighed against enhancing these rather than duplicating them.
**Recommended primitive order for migration (lowest risk → highest):**
1. **StreamingText** — pure token-stream UX, `react`-only, maps 1:1 to `run_content`.
2. **ThinkingState** — reasoning/tool trace, `react`-only, maps to
`reasoning_step` + `tool_call_*`; overlaps existing `ThinkingSteps.tsx`.
3. **ApprovalCard** — highest value (HITL) but most surface: pulls `button` +
`glide-menu` and needs wiring to `POST /api/approvals/{id}/resolve`.
+90
View File
@@ -0,0 +1,90 @@
/* Spike #293 — BeautifulUI primitives driven by PraisonAIUI-shaped mock events.
*
* Dev-only harness. Run with `npm run dev` and open /spike.html. This page is
* NOT imported by the shipped library entry (main.tsx) and is NOT synced into
* src/praisonaiui/templates/frontend — per the issue's non-goals it must not
* touch the production bundle.
*/
import { useEffect, useState } from "react";
import StreamingText from "@/spike/beautifui/primitives/StreamingText";
import ThinkingState from "@/spike/beautifui/primitives/ThinkingState";
import ApprovalCard from "@/spike/beautifui/primitives/ApprovalCard";
import {
streamingTimeline,
tokensFromDeltas,
rowsFromReasoning,
REASONING_STREAM,
APPROVAL_REQUEST,
} from "@/spike/mockEvents";
function Panel({ title, children }: { title: string; children: React.ReactNode }) {
return (
<section style={{ marginBottom: 40 }}>
<h2 style={{ font: "600 13px/1.4 ui-monospace, monospace", opacity: 0.6, marginBottom: 12 }}>{title}</h2>
{children}
</section>
);
}
export default function SpikePage() {
const [dark, setDark] = useState(false);
const [log, setLog] = useState<string[]>([]);
useEffect(() => {
document.documentElement.classList.toggle("dark", dark);
}, [dark]);
// Emulate the SSE timeline so we exercise real delta pacing (logs only —
// the primitives below are fed the fully-resolved token/row arrays).
useEffect(() => {
const frames = streamingTimeline();
const timers = frames.map((f) =>
setTimeout(() => setLog((l) => [...l.slice(-4), `${f.event.type}: ${f.event.token ?? ""}`]), f.at),
);
return () => timers.forEach(clearTimeout);
}, []);
const tokens = tokensFromDeltas(streamingTimeline());
const rows = rowsFromReasoning(REASONING_STREAM);
return (
<div style={{ maxWidth: 720, margin: "0 auto", padding: 32, fontFamily: "system-ui, sans-serif" }}>
<header style={{ display: "flex", justifyContent: "space-between", alignItems: "center", marginBottom: 32 }}>
<div>
<h1 style={{ font: "700 20px/1.2 system-ui" }}>BeautifulUI spike</h1>
<p style={{ opacity: 0.6, fontSize: 13 }}>Issue #293 · mock PraisonAIUI event streams</p>
</div>
<button onClick={() => setDark((d) => !d)} style={{ padding: "6px 12px", borderRadius: 8 }}>
{dark ? "☀ light" : "🌙 dark"}
</button>
</header>
<Panel title="StreamingText ← run_content deltas">
<StreamingText content={tokens} loop={false} sources={[]} followUps={[]} />
</Panel>
<Panel title="ThinkingState ← reasoning_step / tool_call_*">
<ThinkingState rows={rows} active="Thinking" done={`Ran ${rows.length} steps`} />
</Panel>
<Panel title="ApprovalCard ← approval_request (/api/approvals)">
<ApprovalCard
questions={APPROVAL_REQUEST.questions}
onSubmitted={(answers) =>
// In production this maps to POST /api/approvals/{id}/resolve.
setLog((l) => [
...l.slice(-4),
`resolve ${APPROVAL_REQUEST.id}: ${JSON.stringify(answers)}`,
])
}
/>
</Panel>
<Panel title="event log (last 5)">
<pre style={{ font: "12px/1.5 ui-monospace, monospace", opacity: 0.7, whiteSpace: "pre-wrap" }}>
{log.join("\n") || "…"}
</pre>
</Panel>
</div>
);
}
@@ -0,0 +1,48 @@
/* Vendored from BeautifulUI registry: https://www.beautifului.dev/r/button.json
* Spike #293 — DO NOT ship in templates/frontend. Imports rewritten to @/spike/beautifui/*.
* License: MIT (upstream https://github.com/slev12397/beautiful-ui). */
import type { ButtonHTMLAttributes } from "react";
import { cva, type VariantProps } from "class-variance-authority";
import { cn } from "@/lib/utils";
const filledShadow = "shadow-[inset_0_1px_0_rgba(255,255,255,0.14)]";
/* Pill-shaped by default — the app's core button style. Explicit symmetric
* padding (not a fixed height) so the top/bottom spacing is always equal. */
export const buttonVariants = cva(
`inline-flex items-center justify-center font-medium select-none
transition-[transform,background-color,opacity] duration-150 ease-out
active:scale-[0.96] disabled:opacity-50 disabled:pointer-events-none`,
{
variants: {
variant: {
primary: `bg-ink text-canvas hover:opacity-90 dark:bg-ink dark:text-canvas ${filledShadow}`,
secondary: "bg-surface text-ink shadow-btn hover:bg-inset aria-expanded:bg-hover",
ghost: "bg-hover-2 text-ink hover:bg-line-strong",
accent: `bg-accent text-white hover:bg-accent-ink ${filledShadow}`,
success: `bg-green text-white hover:brightness-95 ${filledShadow}`,
/* transparent until hovered — for dense toolbars/action rows */
quiet: "text-ink hover:bg-hover",
},
size: {
/* compact toolbar pill — fixed height, lighter weight */
xs: "h-7 rounded-full px-2.5 text-[12px] font-normal leading-none gap-1",
/* canonical action pill — 27px tall, roomy sides */
sm: "h-[27px] px-3 text-[13px] leading-none rounded-full gap-1.5",
md: "px-4 py-[9px] text-sm leading-none rounded-full gap-2",
},
},
defaultVariants: { variant: "secondary", size: "md" },
},
);
export type ButtonVariant = NonNullable<VariantProps<typeof buttonVariants>["variant"]>;
export function Button({
variant,
size,
className,
...props
}: ButtonHTMLAttributes<HTMLButtonElement> & VariantProps<typeof buttonVariants>) {
return <button className={cn(buttonVariants({ variant, size }), className)} {...props} />;
}
+659
View File
@@ -0,0 +1,659 @@
/* Vendored from BeautifulUI foundation: https://www.beautifului.dev/r/foundation.json
* Spike #293. shadow-plugin dependency neutralized (see note below). License: MIT. */
@import "tailwindcss";
/* shadow-plugin (flornkm) — smooth, layered shadow stacks. The unprefixed
* entry maps them onto Tailwind's --shadow-xs…2xl scale; our semantic tokens
* below build on those values so the whole app picks up the smooth shadows. */
/* @import "shadow-plugin/unprefixed"; -- removed for spike */
/* SPIKE deviation (#293): upstream imports `shadow-plugin/unprefixed` (extra npm dep).
* To keep the spike lightweight we define the --shadow-* scale locally instead. */
@theme inline {
--shadow-2xs: 0 1px 0 0 oklch(0 0 0 / 0.03);
--shadow-xs: 0 1px 2px 0 oklch(0 0 0 / 0.05);
--shadow-sm: 0 1px 2px 0 oklch(0 0 0 / 0.06), 0 1px 1px -1px oklch(0 0 0 / 0.04);
--shadow-md: 0 2px 4px -1px oklch(0 0 0 / 0.07), 0 1px 2px -1px oklch(0 0 0 / 0.05);
--shadow-lg: 0 8px 16px -4px oklch(0 0 0 / 0.10), 0 2px 4px -2px oklch(0 0 0 / 0.06);
--shadow-xl: 0 16px 32px -8px oklch(0 0 0 / 0.12);
--shadow-2xl: 0 24px 48px -12px oklch(0 0 0 / 0.18);
}
@custom-variant dark (&:where(.dark, .dark *));
/* GENERIC FOUNDATION — tokens, @theme, base rules, the primitive-*
* spacing utilities, and shared keyframes below are needed by EVERY
* component. Blocks tagged COMPONENT-SPECIFIC further down belong to
* one primitive each. */
/* ──────────────────────────────────────────────────────────
* Beautiful UI — design tokens
* Cool near-white canvas, white cards, hairline borders, layered
* single-digit-opacity shadows, a neutral ink ramp, and semantic
* color used sparingly as a condiment.
* ────────────────────────────────────────────────────────── */
:root {
/* surfaces — cool, blue-tinted neutrals */
--page: oklch(0.985 0.001 286.376);
--canvas: oklch(0.961 0.002 247.84);
--surface: oklch(1 0 0);
--inset: oklch(0.979 0.002 247.839);
--hover: oklch(0.97 0.002 247.839);
--hover-2: oklch(0.933 0.003 247.86);
/* ink ramp */
--ink: oklch(0.247 0.006 258.361);
--ink-2: oklch(0.506 0.01 264.477);
--ink-3: oklch(0.695 0.009 264.505);
/* borders — solid and crisp, not alpha */
--line: oklch(0.946 0.003 264.542);
--line-strong: oklch(0.912 0.005 258.326);
--line-soft: oklch(0.966 0.002 264.542);
--grid-line: color-mix(in srgb, var(--line) 78%, transparent);
--field: oklch(0.961 0.001 286.375);
--stripe: oklch(0.405 0 0 / 0.075);
--stripe-bg: oklch(0.97 0 0);
/* accent */
--accent: oklch(0.626 0.205 254.947);
--accent-ink: oklch(0.556 0.187 255.617);
--accent-tint: oklch(0.96 0.019 252.878);
/* semantic */
--green: oklch(0.603 0.155 150.883);
--green-tint: oklch(0.958 0.017 159.118);
--orange: oklch(0.689 0.179 49.902);
--orange-tint: oklch(0.964 0.021 67.581);
--red: oklch(0.621 0.192 23.042);
--red-tint: oklch(0.956 0.017 17.462);
/* chart overlays — neutral system colors with enough contrast on either theme */
--tooltip-bg: oklch(0.272 0.008 264.435);
--tooltip-fg: oklch(0.976 0.002 247.839);
--tooltip-muted: oklch(0.731 0.008 260.731);
--tooltip-border: oklch(0.356 0.007 264.474);
/* shadows — our solid hairline ring + shadow-plugin's smooth layered stacks
* (--shadow-xs…lg come from shadow-plugin/unprefixed) so every surface reads
* consistently across the app. */
--shadow-hairline: 0 0 0 1px var(--line);
--shadow-btn: 0 0 0 1px var(--line-strong), var(--shadow-xs);
--shadow-card: 0 0 0 1px var(--line), var(--shadow-sm);
--shadow-raised: 0 0 0 1px var(--line), var(--shadow-md);
--shadow-overlay: 0 0 0 1px var(--line), var(--shadow-lg);
--shadow-inset-field: inset 0 1px 2px oklch(0 0 0 / 0.12);
}
.dark {
--page: oklch(0.209 0.004 264.477);
--canvas: oklch(0.231 0.004 264.487);
--surface: oklch(0.26 0.006 271.191);
--inset: oklch(0.243 0.004 264.492);
--hover: oklch(0.289 0.006 271.22);
--hover-2: oklch(0.318 0.007 274.747);
--ink: oklch(0.964 0.002 247.839);
--ink-2: oklch(0.731 0.008 260.731);
--ink-3: oklch(0.541 0.01 264.484);
--line: oklch(0.308 0.006 258.354);
--line-strong: oklch(0.356 0.007 264.474);
--line-soft: oklch(0.278 0.006 258.354);
--field: oklch(0.293 0.006 271.223);
--stripe: oklch(1 0 0 / 0.055);
--stripe-bg: oklch(0.226 0.004 264.485);
--accent: oklch(0.68 0.173 253.301);
--accent-ink: oklch(0.788 0.113 248.33);
--accent-tint: oklch(0.68 0.173 253.301 / 0.16);
--green: oklch(0.705 0.154 153.814);
--green-tint: oklch(0.705 0.154 153.814 / 0.14);
--orange: oklch(0.746 0.156 55.642);
--orange-tint: oklch(0.746 0.156 55.642 / 0.14);
--red: oklch(0.666 0.18 21.433);
--red-tint: oklch(0.666 0.18 21.433 / 0.14);
--tooltip-bg: oklch(0.182 0.004 264.459);
--tooltip-fg: oklch(0.964 0.002 247.839);
--tooltip-muted: oklch(0.731 0.008 260.731);
--tooltip-border: oklch(0.308 0.006 258.354);
--shadow-hairline: 0 0 0 1px var(--line);
/* Elevation rings go to a subtle *light* hairline in dark mode: the ring
* paints over the surface behind the element, so a low-alpha white edge
* dissolves into the shadow as one continuous stroke (per smooth-shadow-ring),
* graduated by how far the surface floats. */
--shadow-btn: 0 0 0 1px oklch(1 0 0 / 0.1), 0 1px 2px oklch(0 0 0 / 0.3);
--shadow-card:
0 0 0 1px oklch(1 0 0 / 0.11), 0 1px 2px oklch(0 0 0 / 0.2),
0 2px 6px oklch(0 0 0 / 0.2);
--shadow-raised:
0 0 0 1px oklch(1 0 0 / 0.13), 0 2px 10px oklch(0 0 0 / 0.22);
--shadow-overlay:
0 0 0 1px oklch(1 0 0 / 0.15), 0 8px 28px oklch(0 0 0 / 0.34);
--shadow-inset-field: inset 0 1px 2px oklch(0 0 0 / 0.4);
}
@theme inline {
/* colors */
--color-page: var(--page);
--color-canvas: var(--canvas);
--color-surface: var(--surface);
--color-inset: var(--inset);
--color-ink: var(--ink);
--color-ink-2: var(--ink-2);
--color-ink-3: var(--ink-3);
--color-hover: var(--hover);
--color-hover-2: var(--hover-2);
--color-field: var(--field);
--color-line: var(--line);
--color-line-strong: var(--line-strong);
--color-accent: var(--accent);
--color-accent-ink: var(--accent-ink);
--color-accent-tint: var(--accent-tint);
--color-green: var(--green);
--color-green-tint: var(--green-tint);
--color-orange: var(--orange);
--color-orange-tint: var(--orange-tint);
--color-red: var(--red);
--color-red-tint: var(--red-tint);
/* shadows */
--shadow-hairline: var(--shadow-hairline);
--shadow-btn: var(--shadow-btn);
--shadow-inset-field: var(--shadow-inset-field);
--shadow-card: var(--shadow-card);
--shadow-raised: var(--shadow-raised);
--shadow-overlay: var(--shadow-overlay);
/* type */
--font-sans: var(--font-inter), ui-sans-serif, system-ui, sans-serif;
--font-mono: var(--font-mono-face), ui-monospace, "SF Mono", monospace;
/* radii — chips 6, controls 8, cards 10, windows 14, pills 999 */
--radius-chip: 6px;
--radius-control: 8px;
--radius-card: 10px;
--radius-window: 14px;
/* easing — strong curves; built-ins are too weak */
--ease-out-strong: cubic-bezier(0.23, 1, 0.32, 1);
--ease-in-out-strong: cubic-bezier(0.77, 0, 0.175, 1);
--ease-link: cubic-bezier(0.16, 1, 0.3, 1);
}
/* ── base ─────────────────────────────────────────────── */
html {
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
text-rendering: optimizeLegibility;
}
body {
background-color: var(--stripe-bg);
background-image: repeating-linear-gradient(
-45deg,
transparent 0,
transparent 7px,
var(--stripe) 7px,
var(--stripe) 8px
);
/* Pin the stripe pattern to the viewport so it stays put on scroll. */
background-attachment: fixed;
color: var(--ink);
font-feature-settings: "cv11", "ss01";
font-size: 14px;
letter-spacing: -0.01em;
line-height: 1.5;
}
::selection {
background: var(--accent-tint);
color: var(--ink);
}
/* ── interface hygiene (interfaces.dev cheat sheet) ────── */
/* headings balance; descriptions avoid orphans (skip long-form — we have none) */
h1,
h2,
h3,
h4 {
text-wrap: balance;
}
p {
text-wrap: pretty;
}
/* underlines clear descenders */
a {
text-underline-position: from-font;
text-decoration-skip-ink: auto;
}
/* theme switches swap every token at once — freeze transitions so the flip
* is one clean repaint instead of hundreds of mismatched color fades */
.theme-switching *,
.theme-switching *::before,
.theme-switching *::after {
transition: none !important;
}
/* touch: :hover sticks after a tap, so neutralize row/header hovers there */
@media (hover: none) {
.records-row:hover > .records-cell {
background: transparent;
}
.records-row:hover > td.is-colsel {
background: color-mix(in srgb, var(--accent) 4%, var(--surface));
}
.records-header-button:hover {
background: transparent;
color: var(--ink-2);
}
}
/* Keep the component nav scrollable but hide its scrollbar. */
.component-nav-scroll {
scrollbar-width: none; /* Firefox */
-ms-overflow-style: none; /* legacy Edge */
}
.component-nav-scroll::-webkit-scrollbar {
display: none; /* WebKit */
}
@media (min-width: 64rem) {
.component-nav-scroll {
-webkit-mask-image: linear-gradient(
to bottom,
oklch(0 0 0) 0,
oklch(0 0 0) calc(100% - 6rem),
transparent 100%
);
mask-image: linear-gradient(
to bottom,
oklch(0 0 0) 0,
oklch(0 0 0) calc(100% - 6rem),
transparent 100%
);
}
}
/* Mobile demo gestures should operate controls and visualizations rather than
* selecting surrounding labels. Text entry and code remain intentionally
* selectable. */
@media (hover: none) and (pointer: coarse) {
.primitive-showcase {
-webkit-user-select: none;
user-select: none;
-webkit-touch-callout: none;
}
.primitive-showcase :is(input, textarea, [contenteditable="true"], pre, code) {
-webkit-user-select: text;
user-select: text;
-webkit-touch-callout: default;
}
.primitive-showcase :is(a, button, [role="button"]) {
-webkit-tap-highlight-color: transparent;
}
.primitive-showcase :is(a, button, [role="button"]):focus-visible {
outline: none;
}
.primitive-showcase :is(img, svg, canvas) {
-webkit-user-drag: none;
}
.insight-chart-stage {
touch-action: pan-y;
}
}
/* Tailwind v4 resets buttons to cursor:default — anything
* clickable must invite the click */
button:not(:disabled),
[role="button"]:not([aria-disabled="true"]),
label:has(input),
a {
cursor: pointer;
}
:focus-visible {
outline: 2px solid var(--accent);
outline-offset: 2px;
border-radius: 4px;
}
input:focus-visible,
textarea:focus-visible,
select:focus-visible,
[contenteditable]:focus-visible {
outline: none;
}
.animated-underline {
position: relative;
display: inline-block;
}
.animated-underline::after {
position: absolute;
right: 0;
bottom: -1px;
left: 0;
height: 1px;
content: "";
background: currentColor;
transform: scaleX(0);
transform-origin: left center;
transition: transform 280ms var(--ease-link);
}
a:hover .animated-underline::after,
a:focus-visible .animated-underline::after {
transform: scaleX(1);
}
/* ══════════════════════════════════════════════════════════
* COMPONENT-SPECIFIC — StreamingText / ContextCards
* Favicon source avatars.
* Copy this block only alongside StreamingText / ContextCards; it is not part of the
* generic foundation above.
* ══════════════════════════════════════════════════════════ */
.source-avatar {
box-shadow: 0 0 0 1px oklch(0.21 0.034 263.436 / 0.1);
}
.dark .source-avatar {
box-shadow: 0 0 0 1px oklch(1 0 0 / 0.08);
}
/* ── primitive spacing system ─────────────────────────────
* Shared optical offsets for small demo cards. Use these
* instead of hand-tuning 10/12/14px per component.
*/
.primitive-card-pad {
padding: 12px;
}
.primitive-card-bar {
padding: 10px 12px;
}
.primitive-card-footer {
padding: 10px;
}
.primitive-table-cell {
padding: 10px 12px;
}
.primitive-icon-button {
display: inline-flex;
width: 28px;
height: 28px;
align-items: center;
justify-content: center;
border-radius: var(--radius-control);
}
/* ── shared keyframes ─────────────────────────────────── */
@keyframes shimmer-text {
from {
background-position: 150% center;
}
to {
background-position: -50% center;
}
}
@keyframes fade-up {
from {
opacity: 0;
transform: translateY(8px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
.home-reveal {
will-change: opacity, transform, filter;
}
/* The sidebar is one persistent 224px tree clipped by a 52px shell.
* Every rail glyph remains centered at x=26 in both states. */
.records-footer-hint {
display: none;
}
/* SPIKE fix (#293): upstream foundation.css emits a stray extra `}` here — a
* flattened @media block that lost its opening line. Removed so Tailwind v4
* can parse the stylesheet. See spike report. */
/* ── filter table electric status colors ─────────────────── */
/* ══════════════════════════════════════════════════════════
* COMPONENT-SPECIFIC — FilterTable
* Electric status-pill colors.
* Copy this block only alongside FilterTable; it is not part of the
* generic foundation above.
* ══════════════════════════════════════════════════════════ */
.filter-status-todo,
.filter-status-progress,
.filter-status-done {
border: 1px solid transparent;
}
/* ══════════════════════════════════════════════════════════
* COMPONENT-SPECIFIC — InsightCards
* Liveline chart tooltip + cursor.
* Copy this block only alongside InsightCards; it is not part of the
* generic foundation above.
* ══════════════════════════════════════════════════════════ */
.insight-chart-stage {
overflow: hidden;
}
.insight-chart-cursor {
position: absolute;
z-index: 4;
top: 0;
bottom: 0;
width: 1px;
background: var(--ink);
opacity: 0.26;
pointer-events: none;
}
.insight-chart-tooltip-anchor {
position: absolute;
z-index: 5;
top: 8px;
transform: translateX(-50%);
pointer-events: none;
}
.insight-chart-tooltip {
display: flex;
align-items: center;
gap: 12px;
padding: 5px 9px;
border: 1px solid var(--line-strong);
border-radius: 8px;
color: var(--tooltip-fg);
background: var(--tooltip-bg);
box-shadow: var(--shadow-overlay);
font-size: 11.5px;
font-variant-numeric: tabular-nums;
white-space: nowrap;
}
.insight-chart-tooltip-item {
display: inline-flex;
align-items: center;
gap: 6px;
}
.insight-chart-tooltip-dot {
width: 8px;
height: 8px;
flex: 0 0 8px;
border-radius: 50%;
}
/* Status pills share the same base-hue → color-mix derivation as .records-tag,
* so both components stay consistent across light and dark. */
.filter-status-todo {
--tag-base: oklch(0.757 0.153 66.401);
}
.filter-status-progress {
--tag-base: oklch(0.671 0.118 219.351);
}
.filter-status-done {
--tag-base: oklch(0.652 0.131 162.865);
}
.filter-status-todo,
.filter-status-progress,
.filter-status-done {
color: color-mix(in srgb, var(--tag-base) 92%, var(--ink));
background: color-mix(in srgb, var(--tag-base) 20%, var(--surface));
border-color: color-mix(in srgb, var(--tag-base) 34%, var(--surface));
}
/* Dark mode — a richer, saturated same-hue fill
* with bright same-hue text and no visible border (light mode keeps the soft
* tinted pastels above). Applies to both the records grid and status pills. */
.dark .records-tag,
.dark .filter-status-todo,
.dark .filter-status-progress,
.dark .filter-status-done {
color: color-mix(in srgb, var(--tag-base) 86%, white);
background: color-mix(in srgb, var(--tag-base) 34%, var(--surface));
border-color: color-mix(in srgb, var(--tag-base) 34%, var(--surface));
}
@keyframes fade-in {
from {
opacity: 0;
}
to {
opacity: 1;
}
}
@keyframes eq-bounce {
0%,
100% {
transform: scaleY(0.35);
}
50% {
transform: scaleY(1);
}
}
/* ── StreamText primitive ─────────────────────────────────
* Fast character reveal: the newest few characters carry a
* soft, masked blur so the leading edge resolves smoothly as
* text flows in. The caret holds solid while streaming, then
* blinks once the string settles. */
/* ══════════════════════════════════════════════════════════
* COMPONENT-SPECIFIC — StreamText
* The streaming caret + tail.
* Copy this block only alongside StreamText; it is not part of the
* generic foundation above.
* ══════════════════════════════════════════════════════════ */
.stream-tail {
filter: blur(1.6px);
-webkit-mask-image: linear-gradient(to right, oklch(0 0 0) 20%, oklch(0 0 0 / 0.2));
mask-image: linear-gradient(to right, oklch(0 0 0) 20%, oklch(0 0 0 / 0.2));
}
.stream-caret {
display: inline-block;
width: 2px;
height: 1.05em;
margin-left: 1.5px;
translate: 0 -0.5px;
border-radius: 1px;
background: var(--ink);
vertical-align: text-bottom;
animation: caret-blink 1s step-end infinite;
}
.stream-caret.is-streaming {
animation: none;
}
@keyframes caret-blink {
0%,
100% {
opacity: 1;
}
50% {
opacity: 0;
}
}
@media (prefers-reduced-motion: reduce) {
.stream-caret {
animation: none;
}
.stream-tail {
filter: none;
-webkit-mask-image: none;
mask-image: none;
}
}
@keyframes pop-in {
from {
opacity: 0;
transform: scale(0.95);
}
to {
opacity: 1;
transform: scale(1);
}
}
@keyframes spin {
to {
transform: rotate(360deg);
}
}
/* pixel-grid loader — brighten plateau, staggered per cell */
@keyframes pixel-on {
0%, 100% {
opacity: 0.15;
}
18%, 42% {
opacity: 1;
}
62% {
opacity: 0.15;
}
}
@media (prefers-reduced-motion: reduce) {
*,
*::before,
*::after {
animation-duration: 0.01ms !important;
animation-iteration-count: 1 !important;
transition-duration: 0.01ms !important;
}
}
@@ -0,0 +1,420 @@
/* Vendored from BeautifulUI registry: https://www.beautifului.dev/r/approval-card.json
* Spike #293 — DO NOT ship in templates/frontend. Imports rewritten to @/spike/beautifui/*.
* License: MIT (upstream https://github.com/slev12397/beautiful-ui). */
import { useEffect, useLayoutEffect, useRef, useState, type CSSProperties } from "react";
import { Button } from "@/spike/beautifui/atoms/Button";
import GlideMenu from "@/spike/beautifui/primitives/GlideMenu";
/* ─────────────────────────────────────────────────────────
* APPROVAL CARD (human-in-the-loop)
* One question at a time. The stack slides vertically as you
* move between questions (the card's height animates to fit),
* the step counter rolls like an odometer, and the footer uses
* pill actions — a quiet Skip and a dark Continue with a ⏎.
* Single-choice answers auto-advance; multi-select waits.
* ───────────────────────────────────────────────────────── */
export type ApprovalQuestion = {
q: string;
type: "radio" | "check";
options: string[];
};
const QUESTIONS: ApprovalQuestion[] = [
{
q: "How many flavors should we launch?",
type: "radio",
options: ["Three (core line)", "Five (full case)", "Just one hero"],
},
{
q: "Which mix-ins should we stock?",
type: "check",
options: ["Chocolate chips", "Waffle bits", "Sprinkles"],
},
{
q: "Which market do we enter first?",
type: "radio",
options: ["Food trucks", "Grocery freezers", "Scoop shops"],
},
];
export type ApprovalLabels = {
skip: string;
continue: string;
send: string;
customPlaceholder: string;
sentMessage: string;
};
const DEFAULT_LABELS: ApprovalLabels = {
skip: "Skip",
continue: "Continue",
send: "Send",
customPlaceholder: "Something else…",
sentMessage: "Answers sent",
};
const ROLL_MS = 400;
const SLIDE = "360ms cubic-bezier(0.22, 1, 0.36, 1)";
/* odometer digits — each character that changes rolls up (or down) */
function RollingDigits({ value }: { value: string }) {
const prevRef = useRef(value);
const [oldVal, setOldVal] = useState(value);
const [newVal, setNewVal] = useState(value);
const [rolling, setRolling] = useState(false);
const [shifted, setShifted] = useState(false);
const [dir, setDir] = useState<"up" | "down">("up");
useEffect(() => {
if (prevRef.current === value) return;
const from = prevRef.current;
prevRef.current = value;
const fromN = parseInt(from, 10);
const toN = parseInt(value, 10);
setDir(Number.isFinite(fromN) && Number.isFinite(toN) && toN < fromN ? "down" : "up");
setOldVal(from);
setNewVal(value);
setRolling(true);
setShifted(false);
let raf2 = 0;
const raf1 = requestAnimationFrame(() => {
raf2 = requestAnimationFrame(() => setShifted(true));
});
const done = setTimeout(() => {
setRolling(false);
setOldVal(value);
setShifted(false);
}, ROLL_MS);
return () => {
cancelAnimationFrame(raf1);
cancelAnimationFrame(raf2);
clearTimeout(done);
};
}, [value]);
const chars = rolling ? newVal : oldVal;
return (
<>
{Array.from({ length: chars.length }, (_, i) => {
const o = oldVal[i] ?? "";
const n = chars[i] ?? "";
if (!rolling || o === n) {
return <span key={`${i}-${n}`}>{n}</span>;
}
const top = dir === "down" ? n : o;
const bottom = dir === "down" ? o : n;
const restY = dir === "down" ? "0" : "-1em";
const startY = dir === "down" ? "-1em" : "0";
return (
<span
key={`${i}-${o}-${n}-${dir}`}
style={{ display: "inline-block", position: "relative", overflow: "hidden", height: "1em", lineHeight: "1em", verticalAlign: "-0.05em" }}
>
<span
style={{
display: "flex",
flexDirection: "column",
transition: "transform 350ms cubic-bezier(0.4, 0, 0.2, 1)",
transform: `translateY(${shifted ? restY : startY})`,
}}
>
<span style={{ height: "1em", lineHeight: "1em" }}>{top}</span>
<span style={{ height: "1em", lineHeight: "1em" }}>{bottom}</span>
</span>
</span>
);
})}
</>
);
}
function Ico({ path, size = 14, sw = 2 }: { path: React.ReactNode; size?: number; sw?: number }) {
return (
<svg width={size} height={size} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={sw} strokeLinecap="round" strokeLinejoin="round" aria-hidden>
{path}
</svg>
);
}
export default function ApprovalCard({
questions = QUESTIONS,
labels,
onSubmitted,
onAnswerChange,
resettable = true,
}: {
questions?: ApprovalQuestion[];
labels?: Partial<ApprovalLabels>;
onSubmitted?: (answers: Record<number, number[]>) => void;
onAnswerChange?: (questionIndex: number, answer: number[]) => void;
resettable?: boolean;
variant?: string;
} = {}) {
const t = { ...DEFAULT_LABELS, ...labels };
const [qi, setQi] = useState(0);
const [answers, setAnswers] = useState<Record<number, number[]>>({});
const [custom, setCustom] = useState<Record<number, string>>({});
const [sent, setSent] = useState(false);
const [open, setOpen] = useState(true);
const advanceTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
const questionRefs = useRef<(HTMLDivElement | null)[]>([]);
const measured = useRef(false);
const [viewportH, setViewportH] = useState<number | undefined>(undefined);
const [trackY, setTrackY] = useState(0);
const [animate, setAnimate] = useState(false);
// Until the first question is measured, render only the active one so the
// initial (and SSR) height is Q1's height — not all questions stacked, which
// would flash to full height and then shrink on mount.
const [ready, setReady] = useState(false);
const last = qi === questions.length - 1;
const selected = answers[qi] ?? [];
const hasAnswer = selected.length > 0 || Boolean(custom[qi]?.trim());
const sync = (withAnim: boolean) => {
const item = questionRefs.current[qi];
if (!item) return;
const reduce = typeof window !== "undefined" && window.matchMedia("(prefers-reduced-motion: reduce)").matches;
setViewportH(item.offsetHeight);
setTrackY(item.offsetTop);
setAnimate(withAnim && !reduce);
};
useLayoutEffect(() => {
const withAnim = measured.current;
measured.current = true;
sync(withAnim);
setReady(true);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [qi, answers, custom, open, sent]);
useEffect(() => {
const id = requestAnimationFrame(() => sync(measured.current));
return () => cancelAnimationFrame(id);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [qi]);
useEffect(() => () => { if (advanceTimer.current) clearTimeout(advanceTimer.current); }, []);
const goTo = (next: number) => {
if (advanceTimer.current) clearTimeout(advanceTimer.current);
setQi(Math.min(Math.max(next, 0), questions.length - 1));
};
const send = () => {
if (advanceTimer.current) clearTimeout(advanceTimer.current);
setSent(true);
onSubmitted?.(answers);
};
const advance = () => {
if (last) send();
else goTo(qi + 1);
};
const toggle = (index: number) => {
const type = questions[qi].type;
setAnswers((current) => {
const picked = current[qi] ?? [];
const next = type === "radio"
? [index]
: picked.includes(index)
? picked.filter((item) => item !== index)
: [...picked, index];
onAnswerChange?.(qi, next);
return { ...current, [qi]: next };
});
if (type === "radio") {
setCustom((current) => ({ ...current, [qi]: "" }));
if (advanceTimer.current) clearTimeout(advanceTimer.current);
advanceTimer.current = setTimeout(() => {
if (last) send();
else setQi((current) => Math.min(questions.length - 1, current + 1));
}, 480);
}
};
const reset = () => {
setQi(0);
setAnswers({});
setCustom({});
setSent(false);
setOpen(true);
measured.current = false;
};
if (!open) {
return (
<button type="button" onClick={() => setOpen(true)} className="rounded-control bg-surface px-3 py-2 text-[12.5px] font-medium text-ink shadow-btn transition-colors duration-150 hover:bg-hover">
Open approval
</button>
);
}
if (sent) {
return (
<div className="flex w-full max-w-80 items-center gap-3" style={{ animation: "pop-in 260ms cubic-bezier(0.23,1,0.32,1) both" }}>
<span className="inline-flex items-center gap-1.5 rounded-full bg-green-tint py-1 pr-2.5 pl-1 text-[12.5px] font-medium text-green">
<span className="flex size-4.5 items-center justify-center rounded-full bg-green text-white">
<svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="3" strokeLinecap="round" strokeLinejoin="round"><path d="M20 6L9 17l-5-5" /></svg>
</span>
{t.sentMessage}
</span>
{resettable && (
<button type="button" onClick={reset} className="text-[12px] font-medium text-ink-3 transition-colors duration-150 hover:text-ink">
Start over
</button>
)}
</div>
);
}
return (
<div className="w-full max-w-80">
<div className="relative overflow-hidden rounded-card bg-surface shadow-card" style={{ animation: "fade-up 380ms cubic-bezier(0.23,1,0.32,1) both" }}>
<button
type="button"
aria-label="Dismiss"
onClick={() => setOpen(false)}
className="primitive-icon-button absolute right-2.5 top-2.5 z-10 text-ink-3 transition-colors duration-100 hover:bg-hover hover:text-ink"
>
<Ico size={14} sw={2.2} path={<path d="M18 6L6 18M6 6l12 12" />} />
</button>
<div className="primitive-card-pad">
{/* the question itself is the heading */}
<div
className="overflow-hidden"
style={{ height: viewportH, transition: animate ? `height ${SLIDE}` : undefined }}
aria-live="polite"
>
<div
style={{
display: "flex",
flexDirection: "column",
gap: 26,
transform: `translate3d(0, ${-trackY}px, 0)`,
transition: animate ? `transform ${SLIDE}` : undefined,
willChange: "transform",
}}
>
{questions.map((question, qIdx) => {
const active = qIdx === qi;
// Before the first measure, mount only the active question so the
// card opens at its real height instead of flashing to full height.
if (!ready && !active) return null;
const picked = answers[qIdx] ?? [];
const questionStyle: CSSProperties = {
opacity: active ? 1 : 0,
transition: animate ? `opacity ${SLIDE}` : undefined,
pointerEvents: active ? undefined : "none",
};
return (
<div
key={qIdx}
ref={(el) => { questionRefs.current[qIdx] = el; }}
aria-hidden={active ? undefined : true}
style={questionStyle}
>
<div className="pr-7 text-[14px] font-medium text-ink">{question.q}</div>
<GlideMenu className="mt-2.5 flex flex-col gap-1" highlightClassName="inset-x-0 rounded-control bg-hover">
{question.options.map((option, i) => {
const on = picked.includes(i);
return (
<button
key={option}
type="button"
data-menu-row
aria-pressed={on}
tabIndex={active ? 0 : -1}
onClick={() => { if (active) toggle(i); }}
className="relative z-10 flex items-center gap-1.5 rounded-control pl-1 pr-2 py-1 text-left transition-colors duration-100"
>
<span
className={`flex size-4 shrink-0 items-center justify-center transition-colors duration-200
${question.type === "radio" ? "rounded-full" : "rounded-[5px]"}
${on ? "bg-ink text-canvas" : "shadow-[inset_0_0_0_1.5px_var(--line-strong)] text-transparent"}`}
>
{question.type === "radio" ? (
<span className="size-1.5 rounded-full bg-canvas transition-transform duration-200" style={{ transform: on ? "scale(1)" : "scale(0)" }} />
) : (
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="3" strokeLinecap="round" strokeLinejoin="round"><path d="M20 6L9 17l-5-5" /></svg>
)}
</span>
<span className={`text-[13px] leading-none transition-colors duration-200 ${on ? "text-ink" : "text-ink-2"}`}>
{option}
</span>
</button>
);
})}
<label data-menu-row className="relative z-10 flex items-center gap-1.5 rounded-control pl-1 pr-2 py-1 transition-colors duration-100">
<input
value={custom[qIdx] ?? ""}
tabIndex={active ? 0 : -1}
onChange={(event) => {
if (!active) return;
setCustom((current) => ({ ...current, [qIdx]: event.target.value }));
if (question.type === "radio") setAnswers((current) => ({ ...current, [qIdx]: [] }));
}}
onKeyDown={(event) => {
if (event.key === "Enter" && hasAnswer) {
event.preventDefault();
advance();
}
}}
placeholder={t.customPlaceholder}
aria-label="Custom answer"
className="min-w-0 flex-1 bg-transparent pl-1.5 text-[13px] text-ink outline-none placeholder:text-ink-3"
/>
</label>
</GlideMenu>
</div>
);
})}
</div>
</div>
</div>
{/* footer — step nav (rolling counter) + pill actions */}
<div className="primitive-card-footer flex items-center justify-between gap-3">
<div className="flex items-center gap-1 text-ink-3">
<button
type="button"
aria-label="Previous question"
disabled={qi <= 0}
onClick={() => goTo(qi - 1)}
className="flex size-[18px] items-center justify-center rounded-[5px] transition-colors duration-100 enabled:hover:text-ink disabled:opacity-30"
>
<Ico size={14} path={<path d="M18 15l-6-6-6 6" />} />
</button>
<span className="inline-flex items-center text-[12px] font-medium tabular-nums text-ink-3" style={{ letterSpacing: "-0.1px", lineHeight: 1 }}>
<RollingDigits value={`${qi + 1} / ${questions.length}`} />
</span>
<button
type="button"
aria-label="Next question"
disabled={last}
onClick={() => goTo(qi + 1)}
className="flex size-[18px] items-center justify-center rounded-[5px] transition-colors duration-100 enabled:hover:text-ink disabled:opacity-30"
>
<Ico size={14} path={<path d="M6 9l6 6 6-6" />} />
</button>
</div>
<div className="-mr-0.5 flex items-center gap-1.5">
<Button variant="ghost" size="sm" onClick={() => (last ? setOpen(false) : goTo(qi + 1))}>
{t.skip}
</Button>
<Button variant="accent" size="sm" disabled={!hasAnswer} onClick={advance}>
{last ? t.send : t.continue}
</Button>
</div>
</div>
</div>
</div>
);
}
@@ -0,0 +1,60 @@
/* Vendored from BeautifulUI registry: https://www.beautifului.dev/r/glide-menu.json
* Spike #293 — DO NOT ship in templates/frontend. Imports rewritten to @/spike/beautifui/*.
* License: MIT (upstream https://github.com/slev12397/beautiful-ui). */
import { useRef, useState, type ReactNode } from "react";
type GlideMenuProps = {
children: ReactNode;
className?: string;
highlightClassName?: string;
rowSelector?: string;
};
/** A single hover layer that glides between interactive menu rows. */
export default function GlideMenu({
children,
className = "",
highlightClassName = "inset-x-0 rounded-[8px] bg-hover",
rowSelector = "[data-menu-row]",
}: GlideMenuProps) {
const ref = useRef<HTMLDivElement>(null);
const [box, setBox] = useState<{ top: number; height: number } | null>(null);
const [visible, setVisible] = useState(false);
const moveTo = (target: EventTarget | null) => {
const container = ref.current;
if (!(target instanceof Element) || !container) return;
const row = target.closest(rowSelector);
if (!(row instanceof HTMLElement) || !container.contains(row)) return;
const containerRect = container.getBoundingClientRect();
const rowRect = row.getBoundingClientRect();
setBox({ top: rowRect.top - containerRect.top, height: rowRect.height });
setVisible(true);
};
return (
<div
ref={ref}
onMouseOver={(event) => moveTo(event.target)}
onMouseLeave={() => setVisible(false)}
onFocusCapture={(event) => moveTo(event.target)}
onBlurCapture={(event) => {
if (!ref.current?.contains(event.relatedTarget as Node | null)) setVisible(false);
}}
className={`group/glide-menu relative ${className}`}
>
<span
aria-hidden
className={`pointer-events-none absolute ${highlightClassName}`}
style={{
top: box?.top ?? 0,
height: box?.height ?? 0,
opacity: box && visible ? 1 : 0,
transition:
"top 220ms cubic-bezier(0.23,1,0.32,1), height 220ms cubic-bezier(0.23,1,0.32,1), opacity 150ms ease",
}}
/>
{children}
</div>
);
}
@@ -0,0 +1,253 @@
/* Vendored from BeautifulUI registry: https://www.beautifului.dev/r/streaming-text.json
* Spike #293 — DO NOT ship in templates/frontend. Imports rewritten to @/spike/beautifui/*.
* License: MIT (upstream https://github.com/slev12397/beautiful-ui). */
import { useEffect, useState } from "react";
/* ─────────────────────────────────────────────────────────
* STREAMING TEXT
* Words resolve out of blur, inline citations appear in
* context, then actions and follow-up prompts become usable.
* ───────────────────────────────────────────────────────── */
const WORD_MS = 55;
const HOLD_MS = 3400;
/* one streamed word, or a `cite` placeholder that renders an inline source chip */
export type StreamingToken = { text: string; cite?: boolean };
const TOKENS: StreamingToken[] = [
..."Pistachio is your fastest-growing flavor — sales are up 23% this month and margins beat vanilla by 8 points."
.split(" ")
.map((text) => ({ text })),
{ text: "", cite: true },
..."Stone-fruit flavors are trending in the same range."
.split(" ")
.map((text) => ({ text })),
];
const FOLLOW_UPS = [
"Which flavors sell best in winter",
"Compare gelato and soft serve margins",
];
const SOURCE_IMAGES = {
scoop:
"data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 64 64'%3E%3Crect width='64' height='64' rx='16' fill='%231f7a5f'/%3E%3Cpath d='M20 36c0 7 5.4 12 12 12s12-5 12-12H20Z' fill='%23fff'/%3E%3Ccircle cx='32' cy='25' r='11' fill='%23bff3dd'/%3E%3Cpath d='M24 24c4-7 13-7 17 0' fill='none' stroke='%231f7a5f' stroke-width='4' stroke-linecap='round'/%3E%3C/svg%3E",
trends:
"data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 64 64'%3E%3Crect width='64' height='64' rx='16' fill='%232f6fec'/%3E%3Cpath d='M15 43 27 31l8 7 14-18' fill='none' stroke='%23fff' stroke-width='7' stroke-linecap='round' stroke-linejoin='round'/%3E%3Ccircle cx='49' cy='20' r='5' fill='%23bfe0ff'/%3E%3C/svg%3E",
market:
"data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 64 64'%3E%3Crect width='64' height='64' rx='16' fill='%23e56d24'/%3E%3Cpath d='M17 45V25h8v20h-8Zm11 0V16h8v29h-8Zm11 0V30h8v15h-8Z' fill='%23fff'/%3E%3Cpath d='M16 49h32' stroke='%23ffd6b8' stroke-width='4' stroke-linecap='round'/%3E%3C/svg%3E",
};
/* one cited source rendered as an inline chip and in the sources list */
export type StreamingSource = { name: string; domain: string; href: string; image: string };
const SOURCES: StreamingSource[] = [
{ name: "Scoop Data", domain: "scoopdata.io", href: "https://scoopdata.io/", image: SOURCE_IMAGES.scoop },
{ name: "Trends Index", domain: "trends.google.com", href: "https://trends.google.com/trends/", image: SOURCE_IMAGES.trends },
{ name: "Market Basket", domain: "marketbasket.io", href: "https://marketbasket.io/", image: SOURCE_IMAGES.market },
];
function sourceImage(source: StreamingSource) {
return source.image;
}
function SourceChip({ source }: { source?: StreamingSource }) {
if (!source) return null;
return (
<a
href={source.href}
target="_blank"
rel="noreferrer"
className="ml-0 mr-1 inline-flex h-4.5 translate-y-[-1px] items-center gap-1 rounded-[5px]
bg-inset pr-[3px] pl-[3px] align-middle font-mono text-[10.5px] text-ink-2 shadow-hairline
transition-colors duration-150 hover:bg-hover hover:text-ink"
style={{ animation: "pop-in 250ms cubic-bezier(0.23,1,0.32,1) both" }}
>
<img src={sourceImage(source)} alt="" className="source-avatar size-3 rounded-[3px]" />
<span>{source.domain}</span>
</a>
);
}
const ACTION_ICONS: React.ReactNode[] = [
<g key="copy"><rect x="9" y="9" width="12" height="12" rx="2.5" /><path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1" /></g>,
<path key="retry" d="M21 12a9 9 0 1 1-2.64-6.36M21 3v6h-6" />,
<path key="up" d="M7 10v12M15 5.88L14 10h5.83a2 2 0 0 1 1.92 2.56l-2.33 8A2 2 0 0 1 17.5 22H4a2 2 0 0 1-2-2v-8a2 2 0 0 1 2-2h2.76a2 2 0 0 0 1.79-1.11L12 2a3.13 3.13 0 0 1 3 3.88z" />,
<path key="down" d="M17 14V2M9 18.12L10 14H4.17a2 2 0 0 1-1.92-2.56l2.33-8A2 2 0 0 1 6.5 2H20a2 2 0 0 1 2 2v8a2 2 0 0 1-2 2h-2.76a2 2 0 0 0-1.79 1.11L12 22a3.13 3.13 0 0 1-3-3.88z" />,
];
export type StreamingLabels = {
/** label on the collapsed sources toggle */
sources: string;
/** heading above the follow-up prompts */
followUps: string;
};
const DEFAULT_LABELS: StreamingLabels = {
sources: "10 sources",
followUps: "Follow-ups",
};
export default function StreamingText({
content = TOKENS,
sources = SOURCES,
followUps = FOLLOW_UPS,
labels,
loop = true,
fill = false,
onDone,
onFollowUp,
}: {
variant?: string;
/** the streamed tokens; `cite` tokens render an inline source chip */
content?: StreamingToken[];
/** cited sources shown in the chip, avatar stack, and expanded list */
sources?: StreamingSource[];
/** follow-up prompt suggestions shown once the stream completes */
followUps?: string[];
/** prominent copy strings */
labels?: Partial<StreamingLabels>;
/** restart the stream after a hold; turn off when embedding in a real thread */
loop?: boolean;
/** fill the parent width instead of the gallery's fixed measure */
fill?: boolean;
onDone?: () => void;
/** fired when a follow-up prompt is chosen */
onFollowUp?: (text: string, index: number) => void;
} = {}) {
const l = { ...DEFAULT_LABELS, ...labels };
const [count, setCount] = useState(0);
const [sourcesOpen, setSourcesOpen] = useState(false);
const done = count >= content.length;
useEffect(() => {
if (done && !loop) {
onDone?.();
return;
}
const t = setTimeout(
() => setCount((c) => (c >= content.length ? 0 : c + 1)),
done ? HOLD_MS : WORD_MS,
);
return () => clearTimeout(t);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [count, done, loop]);
return (
<div className={fill ? "w-full" : "min-h-[15.5rem] w-full max-w-95"}>
<p className="text-[13px] leading-relaxed text-ink">
{content.slice(0, count).map((token, i) =>
token.cite ? (
<SourceChip key={i} source={sources[0]} />
) : (
<span key={i} className="inline">
{token.text}{" "}
</span>
),
)}
{!done && (
<span
className="ml-0.5 inline-block h-3 w-0.5 translate-y-0.5 rounded-full bg-ink"
style={{ animation: "fade-in 150ms ease-out both" }}
/>
)}
</p>
{/* action icons row */}
<div
className="mt-2 flex items-center gap-0.5 transition-opacity duration-400"
style={{ opacity: done ? 1 : 0, pointerEvents: done ? "auto" : "none" }}
>
{ACTION_ICONS.map((icon, i) => (
<button
key={i}
type="button"
aria-label="Action"
className="flex size-6 items-center justify-center rounded-[6px] text-ink-3
transition-colors duration-100 hover:bg-hover-2 hover:text-ink-2"
>
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round">
{icon}
</svg>
</button>
))}
<button
type="button"
aria-expanded={sourcesOpen}
onClick={() => setSourcesOpen((current) => !current)}
className="ml-1.5 flex items-center gap-1.5 rounded-[6px] px-1 py-0.5 text-left transition-colors duration-150 hover:bg-hover"
>
<span className="flex -space-x-1">
{sources.map((source) => (
<img
key={source.domain}
src={sourceImage(source)}
alt=""
className="source-avatar size-3.5 rounded-full bg-surface shadow-[0_0_0_1.5px_var(--canvas)]"
/>
))}
</span>
<span className="text-[12px] text-ink-2">{l.sources}</span>
</button>
</div>
<div
className="grid transition-[grid-template-rows,opacity] duration-300"
style={{
gridTemplateRows: done && sourcesOpen ? "1fr" : "0fr",
opacity: done && sourcesOpen ? 1 : 0,
transitionTimingFunction: "cubic-bezier(0.23, 1, 0.32, 1)",
}}
>
<div className="overflow-hidden">
<div className="mt-1.5 flex flex-col rounded-[10px] bg-inset p-1 shadow-hairline">
{sources.map((source) => (
<a
key={source.domain}
href={source.href}
target="_blank"
rel="noreferrer"
className="flex items-center gap-2 rounded-[6px] px-1.5 py-1 text-[12px] text-ink-2 transition-colors duration-150 hover:bg-hover hover:text-ink"
>
<img src={sourceImage(source)} alt="" className="source-avatar size-4 rounded-[4px]" />
<span className="animated-underline">{source.name}</span>
<span className="ml-auto font-mono text-[10.5px] text-ink-3">{source.domain}</span>
</a>
))}
</div>
</div>
</div>
{/* follow-ups */}
<div
className="mt-2.5 transition-opacity duration-400"
style={{ opacity: done ? 1 : 0, pointerEvents: done ? "auto" : "none" }}
>
<p className="text-[12px] font-medium text-ink-2">{l.followUps}</p>
<div className="mt-0.5 flex flex-col">
{followUps.map((text, i) => (
<button
key={text}
onClick={() => onFollowUp?.(text, i)}
className="-mx-1.5 flex items-center gap-2 rounded-[7px] border-b border-line
px-1.5 py-1.5 text-left text-[12.5px] text-ink transition-colors
duration-100 hover:bg-hover-2"
style={
done
? { animation: `fade-up 350ms cubic-bezier(0.23,1,0.32,1) ${i * 90}ms both` }
: { opacity: 0 }
}
>
<svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="var(--ink-3)" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" className="shrink-0">
<path d="M9 10l-5 5 5 5" />
<path d="M20 4v7a4 4 0 0 1-4 4H4" />
</svg>
{text}
</button>
))}
</div>
</div>
</div>
);
}
@@ -0,0 +1,302 @@
/* Vendored from BeautifulUI registry: https://www.beautifului.dev/r/thinking-state.json
* Spike #293 — DO NOT ship in templates/frontend. Imports rewritten to @/spike/beautifui/*.
* License: MIT (upstream https://github.com/slev12397/beautiful-ui). */
import { useEffect, useLayoutEffect, useRef, useState, type ReactNode } from "react";
/* ─────────────────────────────────────────────────────────
* THINKING — expandable agent trace, four variants
*
* Steps step list with spinner → muted checks
* Reasoning prose reasoning that expands, then settles
* Search web-search trace: query + sources read
* Coding tool trace: files read, edits, commands
*
* The trace runs once, settles, and remains expandable.
* ───────────────────────────────────────────────────────── */
const STAGES = [800, 600, 1800, 2600, 1600];
function useSequence(steps: number[]) {
const [stage, setStage] = useState(0);
useEffect(() => {
if (stage >= steps.length - 1) return;
const t = setTimeout(() => setStage((s) => s + 1), steps[stage]);
return () => clearTimeout(t);
}, [stage, steps]);
return stage;
}
type Row = {
primary: string;
secondary?: string;
mono?: boolean;
add?: number;
del?: number;
href?: string;
};
const VARIANTS: Record<
string,
{ active: string; done: string; rows: Row[]; query?: string }
> = {
Steps: {
active: "Thinking",
done: "Thought for 4 seconds",
rows: [
{ primary: "Reading flavor briefs" },
{ primary: "Scanning supplier lists" },
{ primary: "Comparing tasting notes", secondary: "6 flavors" },
{ primary: "Writing the scoop report" },
],
},
Reasoning: {
active: "Thinking",
done: "Thought for 4 seconds",
rows: [
{ primary: "Summer demand spikes for stone-fruit flavors — peach and apricot lead." },
{ primary: "I should check cone inventory before promoting a waffle-bowl special." },
],
},
Search: {
active: "Searching the web",
done: "Searched the web",
query: "best waffle cone supplier",
rows: [
{ primary: "Joy Cone", secondary: "joycone.com", href: "https://joycone.com/fs_products/waffle-cones/" },
{ primary: "WebstaurantStore", secondary: "webstaurantstore.com", href: "https://www.webstaurantstore.com/ice-cream-shop-supplies.html" },
{ primary: "The Konery", secondary: "thekonery.com", href: "https://www.thekonery.com/" },
],
},
Coding: {
active: "Running tools",
done: "Ran 3 tools",
rows: [
{ primary: "Read", secondary: "flavors.ts", mono: true },
{ primary: "Edit", secondary: "ChurnSchedule.tsx", mono: true, add: 74, del: 41 },
{ primary: "Run", secondary: "npm run freeze", mono: true },
],
},
};
function Dot({ tone }: { tone: string }) {
return (
<span className={`flex size-3.5 shrink-0 items-center justify-center rounded-full text-white ${tone}`}>
<svg width="9" height="9" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5">
<circle cx="12" cy="12" r="9" />
<path d="M3.5 12h17M12 3a14 14 0 0 1 0 18M12 3a14 14 0 0 0 0 18" />
</svg>
</span>
);
}
const TONES = ["bg-accent", "bg-orange", "bg-green"];
export default function ThinkingState({
variant = "Steps",
onSettled,
rows,
active,
done,
icon,
}: {
variant?: string;
onSettled?: () => void;
/** override the built-in trace content (keeps the primitive reusable) */
rows?: Row[];
active?: string;
done?: string;
/** override the header glyph (defaults to the sparkle) */
icon?: ReactNode;
}) {
const stage = useSequence(STAGES);
const [manualExpanded, setManualExpanded] = useState<boolean | null>(null);
const [selectedTool, setSelectedTool] = useState<string | null>(null);
const base = VARIANTS[variant] ?? VARIANTS.Steps;
const v = {
...base,
rows: rows ?? base.rows,
active: active ?? base.active,
done: done ?? base.done,
};
const autoExpanded = stage >= 1 && stage < 4;
const expanded = manualExpanded ?? autoExpanded;
const working = stage < 3;
const visible = stage < 2 ? 0 : stage === 2 ? Math.min(2, v.rows.length) : v.rows.length;
const traceRef = useRef<HTMLDivElement>(null);
const [lineHeight, setLineHeight] = useState(0);
useLayoutEffect(() => {
if (traceRef.current) setLineHeight(traceRef.current.offsetHeight);
}, [visible, expanded, variant, stage]);
/* let embedders sequence content after the trace settles */
const settledRef = useRef(false);
useEffect(() => {
if (working || settledRef.current) return;
settledRef.current = true;
onSettled?.();
}, [working, onSettled]);
return (
<div
key={variant}
className="flex w-full max-w-95 flex-col"
style={{
minHeight: working || expanded ? 176 : undefined,
transition: "min-height 400ms cubic-bezier(0.23,1,0.32,1)",
}}
>
{/* header — shared across variants */}
<button
type="button"
aria-expanded={expanded}
onClick={() => setManualExpanded((current) => !(current ?? autoExpanded))}
className="-mx-1.5 flex w-fit items-center gap-2 rounded-control px-1.5 py-1
transition-colors duration-100 hover:bg-hover-2"
>
{icon ? (
<span className="flex shrink-0 transition-colors duration-200" style={{ color: working ? "var(--ink-2)" : "var(--ink-3)" }}>
{icon}
</span>
) : (
<svg width="16" height="16" viewBox="0 0 24 24" fill={working ? "var(--ink-2)" : "var(--ink-3)"}>
<path d="M12 2l2.4 7.2L22 12l-7.6 2.8L12 22l-2.4-7.2L2 12l7.6-2.8z" />
</svg>
)}
<span role="status" className="contents">
{working ? (
<span
className="bg-clip-text text-[13px] font-medium whitespace-nowrap text-transparent"
style={{
backgroundImage:
"linear-gradient(90deg, var(--ink-3) 35%, var(--ink) 50%, var(--ink-3) 65%)",
backgroundSize: "200% 100%",
animation: "shimmer-text 1.4s linear infinite",
}}
>
{v.active}
</span>
) : (
<span
className="text-[13px] font-medium whitespace-nowrap text-ink-2"
style={{ animation: "fade-in 350ms ease-out both" }}
>
{v.done}
</span>
)}
</span>
<svg
width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="var(--ink-3)" strokeWidth="2.2" strokeLinecap="round" strokeLinejoin="round"
className="transition-transform duration-300"
style={{ transform: expanded ? "rotate(180deg)" : "rotate(0)" }}
>
<path d="M6 9l6 6 6-6" />
</svg>
</button>
{/* expandable trace */}
<div
className="grid transition-[grid-template-rows,opacity] duration-400"
style={{
gridTemplateRows: expanded ? "1fr" : "0fr",
opacity: expanded ? 1 : 0,
transitionTimingFunction: "cubic-bezier(0.23, 1, 0.32, 1)",
}}
>
<div className="overflow-hidden">
<div className="relative mt-1 ml-[5px] pl-4">
<span
aria-hidden
className="absolute left-[3px] w-px bg-line"
style={{ top: -8, height: lineHeight ? lineHeight - 2 : 0, transition: "height 500ms cubic-bezier(0.23,1,0.32,1)" }}
/>
<div ref={traceRef} className="flex flex-col gap-1 py-1">
{v.query && (
<div className="flex h-6 items-center gap-2 px-1.5" style={{ animation: expanded ? "fade-up 300ms cubic-bezier(0.23,1,0.32,1) both" : undefined }}>
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="var(--ink-3)" strokeWidth="2" strokeLinecap="round" className="shrink-0">
<circle cx="11" cy="11" r="7" />
<path d="M21 21l-4.3-4.3" />
</svg>
<span className="text-[12.5px] text-ink-2">{v.query}</span>
</div>
)}
{v.rows.slice(0, visible).map((row, i) => {
const content = (
<>
{variant === "Search" && <Dot tone={TONES[i % 3]} />}
{variant === "Steps" && (
i < visible - 1 || !working ? (
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="var(--ink-3)" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round" className="shrink-0">
<path d="M20 6L9 17l-5-5" />
</svg>
) : (
<span className="size-3 shrink-0 rounded-full border-[1.5px] border-line-strong border-t-ink-2" style={{ animation: "spin 700ms linear infinite" }} />
)
)}
<span className={`min-w-0 truncate text-[12.5px] ${variant === "Reasoning" ? "whitespace-normal leading-relaxed text-ink-2" : "font-medium text-ink"} ${variant === "Search" ? "animated-underline" : ""}`}>
{row.primary}
</span>
{row.secondary && (
<span className={`shrink-0 text-[11.5px] text-ink-3 ${row.mono ? "font-mono" : ""}`}>
{row.secondary}
</span>
)}
{row.add !== undefined && (
<span className="shrink-0 font-mono text-[11px] tabular-nums">
<span className="text-green">+{row.add}</span>{" "}
<span className="text-red">−{row.del}</span>
</span>
)}
</>
);
const rowClass = "flex min-h-7 w-full items-center gap-2 rounded-[6px] px-1.5 py-0.5 text-left";
const animation = { animation: `fade-up 320ms cubic-bezier(0.23,1,0.32,1) ${i * 120}ms both` };
if (variant === "Search") {
return (
<a
key={row.primary}
href={row.href}
target="_blank"
rel="noreferrer"
className={`${rowClass} transition-colors duration-150 hover:bg-hover`}
style={animation}
>
{content}
</a>
);
}
if (variant === "Coding") {
const selected = selectedTool === row.primary;
return (
<button
key={row.primary}
type="button"
aria-pressed={selected}
onClick={() => setSelectedTool(selected ? null : row.primary)}
className={`${rowClass} transition-colors duration-150 ${selected ? "bg-inset" : "hover:bg-hover"}`}
style={animation}
>
{content}
</button>
);
}
return (
<div key={row.primary} className={rowClass} style={animation}>
{content}
</div>
);
})}
{variant === "Search" && stage >= 3 && (
<span className="text-[12px] text-ink-3" style={{ animation: "fade-in 300ms ease-out both" }}>
+7 more
</span>
)}
</div>
</div>
</div>
</div>
</div>
);
}
+11
View File
@@ -0,0 +1,11 @@
/* Spike #293 — dev-only entry. Not part of the production bundle. */
import { StrictMode } from "react";
import { createRoot } from "react-dom/client";
import "@/spike/beautifui/foundation.css";
import SpikePage from "@/spike/SpikePage";
createRoot(document.getElementById("root")!).render(
<StrictMode>
<SpikePage />
</StrictMode>,
);
+91
View File
@@ -0,0 +1,91 @@
/* Spike #293 — PraisonAIUI-shaped mock event stream.
*
* These events use the real `SSEEvent` contract from `@/types` so the spike
* proves the BeautifulUI primitives can render against the *actual* event
* vocabulary the chat surface emits (see src/praisonaiui/templates/frontend/
* plugins/views/chat.js and RunEventType in src/frontend/src/types.ts).
*
* Nothing here talks to a live socket — that is an explicit non-goal of the
* spike. The timeline below is replayed by SpikePage with setTimeout to
* emulate SSE deltas / tool lifecycle / a HITL approval request.
*/
import type { SSEEvent } from "@/types";
/** A scripted SSE frame plus the delay (ms) before it fires. */
export type MockFrame = { at: number; event: SSEEvent };
/** approval_request payload shaped like a row from GET /api/approvals. */
export type ApprovalRequest = {
id: string;
tool: string;
args: Record<string, unknown>;
questions: { q: string; type: "radio" | "check"; options: string[] }[];
};
/* SSE-style text deltas — streamed word-by-word as `run_content`. */
const ANSWER =
"Pistachio is your fastest-growing flavor — sales are up 23% this month " +
"and margins beat vanilla by 8 points. Stone-fruit flavors trend in the same range.";
function contentDeltas(startAt: number, stepMs = 55): MockFrame[] {
const words = ANSWER.split(" ");
return words.map((w, i) => ({
at: startAt + i * stepMs,
event: { type: "run_content", token: w + " ", content: w + " " } as SSEEvent,
}));
}
const LAST_DELTA_AT = 300 + ANSWER.split(" ").length * 55;
/* Reasoning + tool lifecycle, mapped to ThinkingState rows. */
export const REASONING_STREAM: SSEEvent[] = [
{ type: "reasoning_started" },
{ type: "reasoning_step", step: "Reading flavor briefs", step_number: 1 },
{ type: "tool_call_started", name: "sql.query", tool_call_id: "t1", args: { table: "sales" } },
{ type: "tool_call_completed", name: "sql.query", tool_call_id: "t1", formatted_result: "6 flavors" },
{ type: "reasoning_step", step: "Comparing tasting notes", step_number: 2 },
{ type: "tool_call_started", name: "trends.lookup", tool_call_id: "t2", args: { window: "30d" } },
{ type: "tool_call_completed", name: "trends.lookup", tool_call_id: "t2", formatted_result: "+23%" },
{ type: "reasoning_completed" },
];
/* approval_request payload — the /api/approvals shape a HITL gate would push. */
export const APPROVAL_REQUEST: ApprovalRequest = {
id: "appr_9f2",
tool: "menu.publish",
args: { flavors: 3, season: "winter" },
questions: [
{ q: "How many flavors should we launch?", type: "radio", options: ["Three (core line)", "Five (full case)", "Just one hero"] },
{ q: "Which mix-ins should we stock?", type: "check", options: ["Chocolate chips", "Waffle bits", "Sprinkles"] },
],
};
/** Full scripted timeline for the streaming panel. */
export function streamingTimeline(): MockFrame[] {
return [
{ at: 0, event: { type: "run_started" } as SSEEvent },
...contentDeltas(300),
{ at: LAST_DELTA_AT + 100, event: { type: "run_completed" } as SSEEvent },
];
}
/* ── Adapters: PraisonAIUI events → BeautifulUI primitive props ── */
/** run_content deltas → StreamingText tokens (`cite` marks a source chip slot). */
export function tokensFromDeltas(frames: MockFrame[]): { text: string; cite?: boolean }[] {
return frames
.filter((f) => f.event.type === "run_content")
.map((f) => ({ text: (f.event.token ?? f.event.content ?? "").trim() }));
}
/** reasoning_step / tool_call_* → ThinkingState rows. */
export function rowsFromReasoning(events: SSEEvent[]): { primary: string; secondary?: string; mono?: boolean }[] {
const rows: { primary: string; secondary?: string; mono?: boolean }[] = [];
for (const e of events) {
if (e.type === "reasoning_step" && e.step) rows.push({ primary: e.step });
else if (e.type === "tool_call_started" && e.name) rows.push({ primary: e.name, mono: true });
else if (e.type === "tool_call_completed" && e.name)
rows.push({ primary: e.name, secondary: e.formatted_result, mono: true });
}
return rows;
}
+42 -1
View File
@@ -136,7 +136,9 @@ export const NAMED_THEME_PRESETS = {
export type NamedThemePreset = keyof typeof NAMED_THEME_PRESETS
export type ThemePreset = typeof THEME_PRESETS[number]
export const THEME_STORAGE_KEY = 'aiui-theme-preference'
export type ThemePreference = 'light' | 'dark' | 'system'
export const RADIUS_PRESETS = {
none: '0',
@@ -146,6 +148,8 @@ export const RADIUS_PRESETS = {
xl: '1rem',
} as const
export type ThemePreset = typeof THEME_PRESETS[number]
export type RadiusPreset = keyof typeof RADIUS_PRESETS
// CSS variable names that shadcn/Tailwind expects
@@ -199,6 +203,43 @@ export function isDarkMode(): boolean {
return document.documentElement.classList.contains('dark')
}
/**
* Apply stored user theme preference over YAML default dark mode.
*/
export function applyStoredThemePreference(yamlDefaultDark: boolean): void {
let stored: string | null = null
try {
stored = localStorage.getItem(THEME_STORAGE_KEY)
} catch {
return
}
if (stored === 'light') {
applyDarkMode(false)
} else if (stored === 'dark') {
applyDarkMode(true)
} else if (stored === 'system') {
applyDarkMode(window.matchMedia('(prefers-color-scheme: dark)').matches)
} else {
applyDarkMode(yamlDefaultDark)
}
}
/**
* Persist and apply a theme preference.
*/
export function setThemePreference(preference: ThemePreference): void {
try {
localStorage.setItem(THEME_STORAGE_KEY, preference)
} catch {
// ignore storage failures
}
if (preference === 'system') {
applyDarkMode(window.matchMedia('(prefers-color-scheme: dark)').matches)
} else {
applyDarkMode(preference === 'dark')
}
}
/**
* Get list of available theme presets
*/
+49
View File
@@ -4,6 +4,8 @@ export interface NavItem {
title: string
href?: string
path?: string
description?: string
noindex?: boolean
children?: NavItem[]
}
@@ -24,6 +26,8 @@ export interface SEOConfig {
titleTemplate?: string
defaultImage?: string
twitter?: Record<string, string>
siteUrl?: string
robots?: { index?: boolean; follow?: boolean }
}
export interface I18nConfig {
@@ -47,6 +51,44 @@ export interface LogoConfig {
href?: string
}
export interface NavTabGroupConfig {
group: string
icon?: string
prefix?: string
pages?: string[]
}
export interface NavTabConfig {
tab: string
url?: string
groups?: NavTabGroupConfig[]
}
export interface NavigationConfig {
tabs?: NavTabConfig[]
}
export interface NavbarLinkConfig {
label: string
href: string
}
export interface NavbarPrimaryConfig {
type?: 'button' | 'github'
label?: string
href: string
}
export interface NavbarConfig {
primary?: NavbarPrimaryConfig
links?: NavbarLinkConfig[]
}
export interface SearchConfig {
enabled?: boolean
provider?: string
}
export interface WidgetConfig {
type: string
props?: Record<string, unknown>
@@ -89,6 +131,13 @@ export interface UIConfig {
i18n?: I18nConfig
a11y?: A11yConfig
logo?: LogoConfig
navigation?: NavigationConfig
navbar?: NavbarConfig
search?: SearchConfig
footer?: {
socials?: Record<string, string>
links?: { header: string; items: NavbarLinkConfig[] }[]
}
}
export interface DocsNav {
Loaded 100 of 223 files, more files were not shown because too many files have changed in this diff. Show more