Compare commits

...
Author SHA1 Message Date
MervinPraisonandClaude b84b9d3ee7 feat: composer context token ring, memory chip, compaction banner (fixes #196)
Add a server-driven context budget indicator to the chat composer:
- GET /api/chat/context-stats returns a char-based token estimate with a
  deterministic breakdown (system/tools/messages/memory).
- Token ring wraps the send button with healthy/warning/critical/over
  states, dashed arc for estimates, and a breakdown tooltip.
- Compaction banner surfaces at >=85% usage; a memory chip shows retrieved
  memory count per turn and opens the existing memory panel.
- Ring refreshes on run completion, WS context_update/context_compacted
  events, and a 5s poll fallback.
- i18n keys added for en/es/fr.

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

Co-Authored-By: Claude <[email protected]>
2026-07-14 07:27:36 +00:00
MervinPraison 2ebb56f609 Release v0.3.132 2026-07-14 02:00:30 +00:00
praisonai-triage-agent[bot] 6725a5bdf7 Merge pull request #194 from MervinPraison/claude/issue-191-20260713-0819
fix: honor aiui run --port for gateway examples that delegate to async main()
2026-07-13 10:12:12 +00:00
MervinPraison f1f953be76 Release v0.3.131 2026-07-13 10:08:48 +00:00
praisonai-triage-agent[bot] ee994b586d Merge pull request #193 from MervinPraison/claude/issue-192-20260713-120000
fix: isolate OPENAI_API_KEY in realtime lifecycle test
2026-07-13 10:04:40 +00:00
praisonai-triage-agent[bot]andMervin Praison 44428e5caf fix: propagate --port/--host to gateway main() delegation (fixes #191)
Set PORT/HOST env before delegating to a user-defined async main() so
gateway examples that read os.getenv("PORT") honor the CLI --port flag.
Explicit env set by the user takes precedence over the CLI flag.

Co-authored-by: Mervin Praison <[email protected]>
2026-07-13 08:25:16 +00:00
MervinPraisonandClaude 1748d6d023 fix: isolate OPENAI_API_KEY in realtime session lifecycle test (fixes #192)
🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <[email protected]>
2026-07-13 08:21:25 +00:00
MervinPraison a45c3af006 Release v0.3.130 2026-07-10 10:49:34 +00:00
praisonai-triage-agent[bot] b54d94dd56 Merge pull request #190 from MervinPraison/claude/issue-189-20260710-0829
feat: restore Channels dashboard Add/Delete/Test CRUD UI
2026-07-10 10:46:59 +00:00
praisonai-triage-agent[bot]andMervin Praison d9e8b12628 feat: restore Channels dashboard Add/Delete/Test CRUD UI (fixes #189)
Restore the + Add Channel modal, per-platform credential fields, setup
hints, and card actions (Enable/Disable, Restart, Test, Delete) that were
removed in the 3961508 refactor. Aligned with #176 secret policy (env
references, no plaintext prefill, inline 400 errors surfaced) and #172
dropdown contrast fix (.db-form-select). Adds POST /api/channels preset to
the API Explorer and neutralizes the legacy read-only plugin so it no
longer clobbers the managed view.

Co-authored-by: Mervin Praison <[email protected]>
2026-07-10 08:34:43 +00:00
MervinPraison 2fb299a454 Release v0.3.129 2026-07-09 17:05:28 +00:00
praisonai-triage-agent[bot] e288fca9ca Merge pull request #184 from MervinPraison/claude/issue-174-20260708-0849
fix: dashboard generator emits main before shellHeader insertBefore (#174)
2026-07-09 16:53:45 +00:00
praisonai-triage-agent[bot] f3dd9b218d Merge pull request #182 from MervinPraison/claude/issue-177-20260708-120000
feat: surface user-visible errors for offline chat send
2026-07-09 16:51:10 +00:00
praisonai-triage-agent[bot] 949a1862da Merge pull request #181 from MervinPraison/claude/issue-178-20260708-120000
fix: expose Ctrl+K command palette globally + guard init idempotency
2026-07-09 16:47:38 +00:00
praisonai-triage-agent[bot]andMervin Praison 7167546900 test: align shell-error assertions with showDashboardInitError after rebase
The rebase onto main kept main's showDashboardInitError() helper, which
supersedes this PR's showShellError() and renders the same .db-error
banner with role=alert. Update the RC-2 assertions to match the surviving
helper name; UX guarantee (visible shell error on missing #db-main-content)
is unchanged.

Co-authored-by: Mervin Praison <[email protected]>
2026-07-09 16:10:20 +00:00
MervinPraisonandClaude e029f6e063 feat: surface user-visible errors for offline chat send (fixes #177)
Add defensive shell error UI in dashboard.js selectPage, offline-aware
HTTP fallback messaging with toast, role=alert on system error bubbles,
and a try/catch guard around ws.send in chat.js sendMessage.

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

Co-Authored-By: Claude <[email protected]>
2026-07-09 17:06:25 +01:00
praisonai-triage-agent[bot] 32e8552840 Merge pull request #173 from MervinPraison/claude/issue-172-20260630-120000
fix: dark-theme dropdown contrast and sidebar header overlap
2026-07-09 16:04:43 +00:00
Mervin Praison e31df90d0e Merge pull request #179 from MervinPraison/claude/issue-175-20260708-000000
fix: surface selectPage error when dashboard shell incomplete
2026-07-09 17:04:40 +01:00
praisonai-triage-agent[bot] 58c66b65e5 Merge pull request #180 from MervinPraison/claude/issue-176-20260708-120000
fix: redact channel secrets in all API responses
2026-07-09 16:04:10 +00:00
praisonai-triage-agent[bot]andMervin Praison f0c298802b fix: emit main before shellHeader insertBefore in dashboard generator (fixes #174)
The dashboard.js generator emitted root.insertBefore(shellHeader, main)
before root.appendChild(main), so regenerating the bundle re-introduced
the #166/#174 insertBefore NotFoundError. Reorder the generator to append
main first, and lock it with a source-parsing regression test.

Co-authored-by: Mervin Praison <[email protected]>
2026-07-09 17:00:21 +01:00
praisonai-triage-agent[bot]andMervin Praison 45a5f0cd2d test: align session-search Ctrl+K test with command-palette ownership
The Ctrl+K/Cmd+K binding moved to initCommandPalette; initSessionSearch
now only owns Escape-to-close. Update test_ctrl_k_keydown_handler_present
to assert the current architecture instead of the outdated contract.

Co-authored-by: Mervin Praison <[email protected]>
2026-07-09 17:00:18 +01:00
MervinPraisonandClaude 1b0e1646c9 fix: expose command palette globally and guard init idempotency (fixes #178)
BQA-002 (insertBefore) and the base command palette are already present in
the shipped dashboard bundle. This closes the remaining acceptance gaps:
openCommandPalette is now reachable in the global scope
(typeof openCommandPalette === 'function') and initCommandPalette no longer
double-registers its keydown handler on HMR / full reload.

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

Co-Authored-By: Claude <[email protected]>
2026-07-09 17:00:18 +01:00
praisonai-triage-agent[bot]andpraisonai-triage-agent[bot] 2fe1a83c9a fix(ci): guard find_spec so missing praisonai parent pkg skips instead of erroring
find_spec("praisonai.jobs") raises ModuleNotFoundError (not returns None)
when the parent 'praisonai' package is absent, aborting collection of the
entire tests/unit suite in CI. Wrap in a helper that treats the missing
parent as unavailable so the SDK test skips cleanly.

Co-authored-by: praisonai-triage-agent[bot] <praisonai-triage-agent[bot]@users.noreply.github.com>
2026-07-09 17:00:15 +01:00
praisonai-triage-agent[bot]andMervin Praison 971033b905 fix(channels): align write-path secret validation with read-path redaction
_validate_config_security used the narrow exact-match SECRET_FIELD_PATTERNS
while _redact_config_secrets was hardened to also catch *_token/*_key/*_secret/
*_password suffix keys. This closed the read leak but still let inline
app_token/signing_secret/client_secret values be stored on write. Route both
paths through the shared _is_secret_key helper so suffix secrets are rejected
at write time too, and add regression tests.

Co-authored-by: Mervin Praison <[email protected]>
2026-07-09 17:00:15 +01:00
MervinPraisonandClaude 0626d0bb96 fix: redact channel secrets in all API responses (fixes #176)
GET /api/channels and related handlers serialized the in-memory _channels
dict verbatim, exposing config.bot_token and other secrets. Harden
_redact_config_secrets to cover *_token/*_key/*_secret/*_password suffix
keys (app_token, signing_secret, client_secret) and emit a <key>_set flag,
and redact the previously-unredacted _toggle response. Write path still
accepts and uses real tokens for bot startup.

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

Co-Authored-By: Claude <[email protected]>
2026-07-09 17:00:15 +01:00
MervinPraisonandClaude 581df433ed fix: surface selectPage error when dashboard shell incomplete (fixes #175)
Replace the silent `if (!main || !page) return;` early return in
selectPage() with explicit guards that log a console error and, when
#db-main-content is absent, render a visible db-error banner via the new
showDashboardInitError helper.

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

Co-Authored-By: Claude <[email protected]>
2026-07-09 17:00:13 +01:00
MervinPraisonandClaude bead6e4542 fix: dark-theme dropdown contrast and sidebar header overlap (fixes #172)
Native <select> option popups inherited the dashboard light foreground
while the browser painted a white system surface, producing unreadable
white-on-white platform labels. Add explicit dark-surface/light-text
styling for .db-form-select option and any dashboard select option, plus
an accent highlight for the checked option.

Harden the sidebar header so a long brand title truncates with an
ellipsis and reserves space for the toggle, preventing text overlap.

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

Co-Authored-By: Claude <[email protected]>
2026-07-09 17:00:11 +01:00
MervinPraison d0c8e060ff Release v0.3.128 2026-07-09 15:24:52 +00:00
Mervin Praison cdd6b21d3b Merge pull request #188 from MervinPraison/fix/merge-gate-stash-config
fix(merge-gate): stash gate-config.js with merge-gate.js
2026-07-09 16:21:34 +01:00
praisonai-triage-agent[bot]andMervin Praison b5f35214ef fix(ci): sync version and update stale registry/session-search tests
Pre-existing failures on main unrelated to the merge-gate workflow diff:
- sync __version__.py to 0.3.127 to match pyproject.toml
- i18n graduated to IMPLEMENTED (0.5.0); update feature-registry tests
- Ctrl+K binding is owned by initCommandPalette; assert the UX contract
  (Ctrl+K routes to session search) instead of the old implementation detail

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

Co-authored-by: Mervin Praison <[email protected]>
2026-07-09 15:03:46 +00:00
MervinPraison 2f73a94808 fix(merge-gate): stash gate-config.js with merge-gate.js 2026-07-09 15:57:21 +01:00
Mervin Praison 611c06f31b Merge pull request #187 from MervinPraison/fix/ci-sdk-job-store-import
fix(ci): skip SDK job store test when praisonai missing
2026-07-09 14:37:21 +01:00
MervinPraison 7f41bcbd9c fix(ci): skip SDK job store test when praisonai missing
find_spec(\"praisonai.jobs\") raises ModuleNotFoundError when the parent
package is absent, aborting pytest collection. Catch that and skip.
Also add claude-code-action and restore it after PR checkout for merge gate.
2026-07-09 14:30:26 +01:00
Mervin Praison f47b9f3824 Merge pull request #186 from MervinPraison/fix/ruff-channels-whitespace
fix(ci): clear ruff whitespace blocking main CI
2026-07-09 10:03:50 +01:00
MervinPraison 0e1a90b9f8 fix(ci): clear ruff W291/W293 whitespace in channels.py
Unblocks main and all open PRs blocked on python lint in CI.
2026-07-09 10:00:20 +01:00
Mervin Praison f86baee258 Merge pull request #185 from MervinPraison/infra/stale-final-recovery
infra: stale-FINAL recovery for open PRs
2026-07-08 10:56:33 +01:00
MervinPraison 69e469e71f infra: add stale-FINAL recovery for open PRs
Port claude-review-recovery and scheduled post-missing-claude-final from
PraisonAI, with allowStaleRepost so FINAL can be re-posted after new commits.
Remove duplicate bot-pr-trigger-reviews job.
2026-07-08 10:45:46 +01:00
Mervin Praison 1b2103e3a8 Merge pull request #183 from MervinPraison/infra/gate-setup
infra: merge gate, release gate, and pipeline automation
2026-07-08 09:48:11 +01:00
MervinPraison 88307cf5b0 infra: add merge gate, release gate, and pipeline automation
Port PraisonAI gate scripts with gate-config.js for PraisonAIUI paths,
single-package release preflight, pipeline label sync, Opus merge gate,
CI failure Claude trigger, pypi-auto patch releases, and review-chain hooks.
2026-07-08 09:36:56 +01:00
MervinPraison 140dd3a443 fix(version): update aiui to version 0.3.127 in project files
Updated the version in pyproject.toml and uv.lock to reflect the latest release.
2026-07-05 06:21:52 +01:00
MervinPraison 20b29caae3 chore(version): update aiui version to 0.3.126
Bumped the version in pyproject.toml and uv.lock to reflect the latest release.
2026-06-29 10:06:23 +01:00
MervinPraison d6cff26261 feat(version): update aiui version to 0.3.125
Incremented the version of the aiui project to 0.3.125 in both
pyproject.toml and uv.lock files to reflect the latest release.
2026-06-29 10:05:57 +01:00
Mervin Praison 7018271836 Merge pull request #171 from MervinPraison/claude/issue-170-20260628-120000
feat: world-class kanban board — dense cards, editable drawer, debounced live updates
2026-06-29 10:05:26 +01:00
Mervin Praison 61e5fd04ed Merge pull request #169 from MervinPraison/claude/issue-167-20260626-120000
fix: harden channel auto-start lifecycle and running state
2026-06-29 10:04:59 +01:00
Mervin Praison 80deeb451a Merge pull request #168 from MervinPraison/claude/issue-166-20260626-000000
fix: dashboard buildDashboard insertBefore NotFoundError (#166)
2026-06-29 10:04:46 +01:00
MervinPraisonandClaude 9726411010 feat: enrich kanban cards, editable drawer, debounced live updates (fixes #170)
Gap 1: card DTO now carries tenant, created_at, comment_count and child
progress; cardElement renders dense chrome (checkbox, status dot, id,
priority badge, tenant chip, assignee, age, comment count, progress pill).

Gap 2: new POST /api/kanban/tasks/{id}/comments route plus add_comment on
the store protocol; drawer is now editable (title/assignee/priority),
markdown-rendered (XSS-safe), shows a comment thread with Enter-to-submit
and run/event history rows.

Gap 3: SSE consumer refetches on a 300ms debounce, the open drawer
live-refreshes on events for its task, and destructive moves/delete prompt
for confirmation.

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

Co-Authored-By: Claude <[email protected]>
2026-06-28 04:23:35 +00:00
MervinPraisonandClaude 7cb20bca32 fix: harden channel auto-start lifecycle and running state (fixes #167)
Gateway _channel_tasks may be a dict or a list depending on SDK version;
calling .append() on a dict raised 'dict' object has no attribute 'append'
during auto-start. Register defensively for both shapes and never let a
gateway bookkeeping failure abort an already-started bot.

Also force running=False whenever start_error is set (in _sync_running_status
and the auto-start failure paths) so the API no longer reports running=true
alongside an error, and log full tracebacks via logger.exception.

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

Co-Authored-By: Claude <[email protected]>
2026-06-26 06:20:46 +00:00
MervinPraisonandClaude 11898f2c7d fix: append main before insertBefore in dashboard buildDashboard (fixes #166)
buildDashboard() called root.insertBefore(shellHeader, main) before main was
a child of root, throwing NotFoundError and aborting the dashboard plugin
init, which blocked Ctrl+K and the browser chat input. Append main to root
first so it is a valid reference node for the guarded header insert.

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

Co-Authored-By: Claude <[email protected]>
2026-06-26 06:19:37 +00:00
MervinPraisonandCursor 0b62e7dcec feat: add PyPI Release workflow for aiui package
Manual workflow_dispatch release matching PraisonAI: patch bump,
uv publish, PyPI wait, commit, tag, and GitHub release. Update
instruction.md with PraisonAIUI publish steps.

Co-authored-by: Cursor <[email protected]>
2026-06-25 07:48:12 +01:00
MervinPraisonandCursor fdef44b303 feat: add SDK/product value gate to Claude FINAL review prompts
Centralise PraisonAIUI scope and phase text in workflow env vars and
require Claude to evaluate genuine UI value, correct layering vs
praisonaiagents, and reject scope creep before approving PRs.

Co-authored-by: Cursor <[email protected]>
2026-06-25 07:47:13 +01:00
Mervin Praison 596bb2684c Merge pull request #164 from MervinPraison/claude/issue-162-20260625-0601
fix: sync __version__.py to 0.3.124 (fixes #162)
2026-06-25 07:46:27 +01:00
Mervin Praison 047b86b89f Merge pull request #165 from MervinPraison/claude/issue-163-20260625-0601
feat: parallelize legacy /health and add short-TTL cache
2026-06-25 07:46:10 +01:00
praisonai-triage-agent[bot]andpraisonai-triage-agent[bot] f60f81e086 feat: parallelize legacy /health and add short-TTL cache (fixes #163)
Run provider.health() concurrently with feature checks under a single
bounded gather instead of awaiting the provider first, and cache the deep
health payload for a short TTL so repeat probes stay fast.

Co-authored-by: praisonai-triage-agent[bot] <praisonai-triage-agent[bot]@users.noreply.github.com>
2026-06-25 06:04:59 +00:00
praisonai-triage-agent[bot]andMervin Praison 62d20465b5 fix: sync __version__.py to 0.3.124 (fixes #162)
Co-authored-by: Mervin Praison <[email protected]>
2026-06-25 06:02:41 +00:00
MervinPraison c2f8af6ad0 chore(release): bump version to 0.3.124
Updated version in pyproject.toml and uv.lock to reflect new release.
2026-06-24 23:50:34 +01:00
Mervin Praison 9bb312c8c4 Merge pull request #160 from MervinPraison/claude/issue-155-20260624-120000
feat: Ctrl+K session search in dashboard style (C-01, #155)
2026-06-24 23:50:10 +01:00
MervinPraison d07e41e69b fix(version): update aiui version to 0.3.123
Updated the version in pyproject.toml and uv.lock to reflect the new release.
2026-06-24 23:49:50 +01:00
MervinPraisonandClaude 043aa5c711 feat: port Ctrl+K session search to dashboard style (fixes #155)
Completes C-01 parity for the dashboard presentation plane. PR #145 shipped
SessionSearch in React layouts only, so the primary ops example
(15-dashboard-test, style="dashboard") never mounted React and Ctrl+K did
nothing. This adds a vanilla-JS command palette to dashboard.js (Ctrl+K /
Cmd+K toggle, debounced filter, arrow-key nav, Enter/click select, Esc close)
backed by GET /sessions, and wires chat.js to load the chosen session via an
aiui:session-select event.

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

Co-Authored-By: Claude <[email protected]>
2026-06-24 22:23:18 +00:00
MervinPraisonandCursor 4c5dadd255 fix: dedupe merge-conflict Claude triggers on main push bursts
Main pushes bypassed the 12h cooldown and concurrent merge runs each
posted @claude for every open DIRTY PR. Apply cooldown always, skip when
claude-conflict-pending label exists, cancel stale scans, and sync fork
handling plus FINAL review gate from PraisonAI.

Co-authored-by: Cursor <[email protected]>
2026-06-24 23:22:54 +01:00
Mervin Praison 86a6018c81 Merge pull request #156 from MervinPraison/claude/issue-153-20260624-0952
fix: sync __version__.py to 0.3.122 (fixes #153)
2026-06-24 23:18:29 +01:00
Mervin Praison 5f37843e3d Merge pull request #157 from MervinPraison/claude/issue-150-20260624-000000
fix(compiler): write all build output as UTF-8 (Windows cp1252)
2026-06-24 23:18:20 +01:00
Mervin Praison 4e2e14699c Merge pull request #158 from MervinPraison/claude/issue-151-20260624-000000
feat: implement /api/provider/health JSON route + guard /api/* catch-all
2026-06-24 23:18:06 +01:00
Mervin Praison 5e512d28da Merge pull request #159 from MervinPraison/claude/issue-152-20260624-0952
feat: parallelize legacy /health and migrate consumers to fast probes
2026-06-24 23:17:44 +01:00
Mervin Praison 420f3776d8 Merge pull request #161 from MervinPraison/claude/issue-154-20260624-0952
feat: MCP manager view + Ctrl+K command palette (B-08, B-02)
2026-06-24 23:17:08 +01:00
praisonai-triage-agent[bot]andMervin Praison 6afb282d76 feat: add MCP manager view and Ctrl+K command palette to dashboard (fixes #154)
Implements B-08 (MCP manager) and B-02 (command palette) from the OpenClaw
Control UI parity umbrella. MCP view lists/connects/disconnects servers via
the existing /api/mcp/* routes; command palette navigates pages via Ctrl+K.

Co-authored-by: Mervin Praison <[email protected]>
2026-06-24 09:58:56 +00:00
praisonai-triage-agent[bot]andMervin Praison 67137d4ba4 feat: parallelize legacy /health and migrate consumers to fast probes (fixes #152)
Legacy /health ran sequential per-feature health checks (~2.8s with 42
features). Parallelize with asyncio.gather + 0.5s per-feature timeout
matching /health/ready, and short-circuit ?deep=false to liveness.
Migrate CLI health-check/doctor status to /health/live and provider
status to /health/ready?deep=false. Update K8s/Docker probe docs to
/health/live.

Co-authored-by: Mervin Praison <[email protected]>
2026-06-24 09:57:46 +00:00
MervinPraisonandClaude faec8047b4 feat: implement /api/provider/health JSON route and guard /api/* catch-all (fixes #151)
Register a dedicated GET /api/provider/health handler before the SPA
catch-all so API clients receive JSON instead of dashboard HTML. Unmatched
/api/* paths now return a JSON 404 in both the static-mount fallback and the
path:path catch-all. Docs updated to match the implementation schema.

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

Co-Authored-By: Claude <[email protected]>
2026-06-24 09:57:43 +00:00
MervinPraisonandClaude 0d3baebaf3 fix(compiler): write all text output as UTF-8 (fixes #150)
Path.write_text/open default to the locale encoding (cp1252 on
Windows), causing UnicodeEncodeError when compiled HTML/JSON/CSS
contains non-ASCII content such as the U+2728 sparkle from docs.
Add explicit encoding="utf-8" to every text write in the build
and config pipeline.

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

Co-Authored-By: Claude <[email protected]>
2026-06-24 09:57:42 +00:00
praisonai-triage-agent[bot]andMervin Praison fe1bcd5e28 fix: sync __version__.py to 0.3.122 (fixes #153)
Co-authored-by: Mervin Praison <[email protected]>
2026-06-24 09:54:25 +00:00
MervinPraison ee62c7b908 feat(release): bump version to 0.3.122 and update dependencies
Updated the project version in `pyproject.toml` and `uv.lock`.
Changed dependency versions for `praisonai` and `praisonaiagents`
to their latest compatible versions, ensuring better stability and features.
2026-06-24 09:48:15 +01:00
Mervin Praison da9ff14884 Merge pull request #145 from MervinPraison/claude/issue-128-20260617-0955
feat: add session search palette with Ctrl+K shortcut
2026-06-24 09:47:14 +01:00
MervinPraison c55303684a chore: remove merge conflict CI test marker 2026-06-24 09:04:52 +01:00
MervinPraison d516d9e538 fix(ci): use GH_TOKEN PAT so @claude comment triggers claude.yml 2026-06-24 09:02:27 +01:00
MervinPraison a8d7e4598c fix(ci): grant pull-requests write for merge-conflict Claude comments 2026-06-24 09:00:40 +01:00
MervinPraison dcef9ff502 test: main-side merge conflict marker for CI automation test 2026-06-24 08:59:59 +01:00
MervinPraison fd038d054e ci: auto-trigger Claude on merge conflicts across PRs
Add merge-conflict-claude workflow and conflict rebase prompt with GraphQL merge_state detection in claude.yml.
2026-06-24 08:58:36 +01:00
Mervin Praison a2b941dd24 Merge pull request #146 from MervinPraison/claude/issue-132-20260623-0941
fix: Windows CLI UnicodeEncodeError for Rich output (fixes #132)
2026-06-23 12:33:09 +01:00
Mervin Praison 6d9d0727f2 Merge pull request #147 from MervinPraison/claude/issue-133-20260623-0941
fix: explicit UTF-8 encoding for text file reads on Windows (fixes #133)
2026-06-23 12:32:57 +01:00
Mervin Praison 1f887bd142 Merge pull request #148 from MervinPraison/claude/issue-134-20260623-0941
fix: doctor Gateway check uses /api/provider endpoint (fixes #134)
2026-06-23 12:32:50 +01:00
praisonai-triage-agent[bot]andMervin Praison 6facab2366 fix: use dynamic _icon() fallback for Windows-safe Rich output
Address Gemini reviewer feedback on the inconsistent hard-coded ASCII
replacements. Introduce _supports_unicode()/_icon() helpers that keep the
rich emoji UI on UTF-aware terminals (macOS/Linux/Windows Terminal) and
degrade to ASCII only on legacy code pages (cp1252/cp437), so output is
both crash-safe and free of UX regressions.

Co-authored-by: Mervin Praison <[email protected]>
2026-06-23 11:08:41 +00:00
praisonai-triage-agent[bot]andMervin Praison e9d5cff7db fix: doctor Gateway extractor reads flat /api/provider JSON (fixes #134)
The /api/provider endpoint flattens provider.health() into the top-level
response via info.update(health_data), so there is no nested "health"
object. The previous extractor read data.get("health") which was always
None, forcing status to "unknown" and the Gateway check to always "warn".
Read status/provider directly from the top-level dict.

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

Co-authored-by: Mervin Praison <[email protected]>
2026-06-23 11:06:15 +00:00
praisonai-triage-agent[bot]andMervin Praison ff54833c84 fix: address review feedback on session search palette
- useSessionSearch: add `enabled` param so global Ctrl+K is only
  intercepted when the palette is rendered; case-insensitive key check
- ChatLayout: only enable the shortcut in fullscreen mode
- AgentUILayout: pass currentSessionId/onSessionChange to ChatArea so
  session selection is functional
- SessionSearch: guard async fetch with an active flag, hoist date
  objects out of the reduce loop, use a div for the loading state
  (CommandEmpty only renders post-filter)
- tests: patch async _datastore with AsyncMock and build the app via
  create_app() so the /sessions endpoint tests actually run

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

Co-authored-by: Mervin Praison <[email protected]>
2026-06-23 11:04:04 +00:00
MervinPraison 193dfcdef5 chore(ci): pin Claude Actions model to claude-opus-4-8 2026-06-23 11:39:36 +01:00
Mervin Praison 56181c8532 Merge pull request #130 from MervinPraison/claude/issue-127-20260617-0955
feat: implement exec approval modal (B-01) for OpenClaw UI parity
2026-06-23 11:39:10 +01:00
Mervin Praison 69461ebef3 Merge pull request #131 from MervinPraison/claude/issue-126-20260617
feat: wire i18n config to frontend runtime
2026-06-23 11:39:05 +01:00
Mervin Praison 7cc2031477 Merge pull request #143 from MervinPraison/claude/issue-137-fix-popstate
feat: fix browser Back/Forward navigation in docs SPA
2026-06-23 11:39:00 +01:00
Mervin Praison 9624f0b8df Merge pull request #144 from MervinPraison/claude/issue-139-20260623
feat: standardize health status checking to accept both 'ok' and 'healthy'
2026-06-23 11:38:55 +01:00
Mervin Praison 81aa5bf1e4 Merge pull request #141 from MervinPraison/claude/issue-136-health-alias
feat: add 'health' alias for 'health-check' command
2026-06-23 11:38:49 +01:00
Mervin Praison ec5d3087ab Merge pull request #142 from MervinPraison/claude/issue-135-health-performance
feat: optimize health endpoints for performance (fixes #135)
2026-06-23 11:38:43 +01:00
Mervin Praison d0e02dfd6d Merge pull request #129 from MervinPraison/claude/issue-125-remove-deprecated-dashboard
feat: remove deprecated 12-agent-dashboard example (XSS vulnerability)
2026-06-23 11:38:38 +01:00
Mervin Praison 3ad900a293 Merge pull request #140 from MervinPraison/claude/issue-138-version-sync
feat: sync package version from 0.3.114 to 0.3.121 (fixes #138)
2026-06-23 11:38:29 +01:00
praisonai-triage-agent[bot]andMervin Praison 963735699c refactor: eliminate code duplication in App.tsx navigation logic
- Extract findNavItemByPath as a reusable helper function
- Remove duplicate findItem function definitions
- Use consistent updateSEO function in both handleItemClick and popstate handler
- Move updateSEO function definition before its usage to fix reference order

This improves code maintainability by reducing duplication and ensuring
consistent SEO update behavior across all navigation paths.

Co-authored-by: Mervin Praison <[email protected]>
2026-06-23 10:16:56 +00:00
praisonai-triage-agent[bot]andMervin Praison e6e4c5a3e8 fix: extend health status standardization to features commands and test_runner
- Updated features list and features status commands to use is_success_status utility
- Updated test_runner health check to accept both 'ok' and 'healthy' statuses
- Added comprehensive unit tests for features commands with both status values
- Ensures consistent health status handling across entire CLI

Co-authored-by: Mervin Praison <[email protected]>
2026-06-23 10:16:09 +00:00
MervinPraison 2b9e78ea50 feat: standardize health status checking to accept both 'ok' and 'healthy' (fixes #139)
- Add health_utils.py with is_success_status() helper function
- Update CLI health-check command to treat both 'ok' and 'healthy' as success
- Update doctor command health extractor to use shared helper
- Update provider status command to use shared helper
- Add comprehensive unit tests for both status strings
- Document health status values in API documentation

The system now consistently accepts both 'ok' and 'healthy' as success
statuses, eliminating false alarms in CLI and doctor commands while
maintaining compatibility with existing dashboard behavior.
2026-06-23 09:51:59 +00:00
MervinPraisonandClaude de6d655250 feat: fix browser Back/Forward navigation in docs SPA (fixes #137)
- Fix popstate event handler to properly update route state when browser Back/Forward buttons are used
- Improve title template handling to support single %s placeholder in SEO config
- Add comprehensive tests for frontend navigation functionality
- Rebuild and update frontend bundle with the fixes

The popstate handler now correctly:
- Finds and sets the matching nav item based on URL path
- Updates both selectedItem and activeItemPath state
- Updates the document title using SEO template
- Handles root path navigation

🤖 Generated with Claude Code

Co-Authored-By: Claude <[email protected]>
2026-06-23 09:51:08 +00:00
MervinPraisonandClaude d9e1f9497d feat: optimize health endpoints for performance (fixes #135)
- Add /health/live for fast liveness checks (<100ms)
- Add /health/ready with parallel feature health checks (timeout: 500ms)
- Implement caching for /api/health endpoint (30s TTL)
- Maintain backward compatibility with existing /health endpoint
- Add comprehensive unit tests for all health endpoints
- Document deployment configurations for K8s, Docker, AWS

Addresses performance issue where /health was taking ~5s due to
sequential feature health checks. New endpoints provide:
- Liveness: <500ms p95 (typically <100ms)
- Readiness: <1s with parallel execution
- Cached: <50ms after first request

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

Co-Authored-By: Claude <[email protected]>
2026-06-23 09:50:00 +00:00
praisonai-triage-agent[bot]andMervin Praison 5c3b93f452 fix: add explicit UTF-8 encoding to all text file reads for Windows compatibility
- Add encoding="utf-8" to all Path.read_text() calls
- Add encoding="utf-8" to all open() calls for text files
- Fixes UnicodeDecodeError on Windows where default encoding is cp1252
- Affects CLI commands, compiler, config store, and various features

Co-authored-by: Mervin Praison <[email protected]>
2026-06-23 09:48:31 +00:00
MervinPraisonandClaude 1e81b32f1d feat: add 'health' alias for 'health-check' command (fixes #136)
- Register 'health' as an alias for 'health-check' to support both command names
- Update documentation to use canonical 'health-check' with notes about 'health' alias
- Add tests to verify both commands work correctly

This ensures backward compatibility for users who expect 'aiui health' to work
while maintaining 'health-check' as the canonical command name.

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

Co-Authored-By: Claude <[email protected]>
2026-06-23 09:47:46 +00:00
MervinPraisonandClaude 862f0c4e5e feat: sync package version from 0.3.114 to 0.3.121 (fixes #138)
- Updated __version__.py to match pyproject.toml (0.3.121)
- Added unit test to enforce version consistency
- Ensures CLI, imports, and package metadata report same version

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

Co-Authored-By: Claude <[email protected]>
2026-06-23 09:46:36 +00:00
praisonai-triage-agent[bot]andMervin Praison 27298ebfb2 fix: Windows CLI crashes with UnicodeEncodeError (fixes #132)
- Add Windows UTF-8 reconfiguration at module load
- Replace Unicode symbols with ASCII fallbacks in CLI output
- Update status icons to use [PASS]/[WARN]/[FAIL] instead of emojis
- Replace Unicode arrows and borders with ASCII characters
- Add comprehensive unit tests for Windows encoding

Co-authored-by: Mervin Praison <[email protected]>
2026-06-23 09:46:30 +00:00
praisonai-triage-agent[bot]andMervin Praison 33391d6775 fix: update doctor Gateway check to use existing /api/provider endpoint (fixes #134)
The doctor Gateway Status check was calling /api/provider/health which doesn't exist,
causing it to always show HTTP 404. Updated to use the existing /api/provider endpoint
and extract health data from the nested health object.

Co-authored-by: Mervin Praison <[email protected]>
2026-06-23 09:45:23 +00:00
praisonai-triage-agent[bot]andMervin Praison e1bb77ff1f fix(security): add input validation and XSS protection to i18n
- Add regex escaping in LocaleProvider to prevent regex injection
- Add locale format validation (e.g., 'en', 'en-US')
- Add key format validation for translation keys
- Pre-compile regex patterns for better performance
- Add AbortController to handle race conditions
- Add loading state to context for better UX

Co-authored-by: Mervin Praison <[email protected]>
2026-06-17 10:29:41 +00:00
praisonai-triage-agent[bot]andMervin Praison 4fe5667e06 fix: address architecture review findings for approval modal
- Fix RunEvent type safety by using RUN_PAUSED instead of invalid 'approval_required' string
- Optimize imports with module-level caching to avoid repeated imports on every tool call
- Update frontend to handle approval events via extra_data type checking
- Fix linting issues (whitespace in blank lines)
- Maintain backward compatibility with existing event handling

Import time remains under 200ms limit (178.5ms tested)

Co-authored-by: Mervin Praison <[email protected]>
2026-06-17 10:24:23 +00:00
MervinPraisonandClaude d9aaec3c1b feat: wire i18n config to frontend runtime (fixes #126)
- Promote i18n from EXPERIMENTAL to IMPLEMENTED status in feature registry
- Create LocaleProvider React context for managing locale state
- Add LocaleSwitcher component in header when multiple locales configured
- Connect frontend to existing i18n API endpoints for translation strings
- Implement RTL support with automatic dir attribute switching
- Add comprehensive tests for i18n implementation status
- Update compiler to remove experimental comment for i18n

This completes the i18n wiring gap identified in issue #126, enabling full
internationalization support in the frontend runtime with locale switching,
RTL support, and translation loading from the backend API.

Co-Authored-By: Claude <[email protected]>
2026-06-17 10:06:35 +00:00
praisonai-triage-agent[bot]andMervin Praison 92e257306f feat: add session search palette with Ctrl+K shortcut (fixes #128)
- Create SessionSearch component with command dialog UI
- Add useSessionSearch hook for keyboard shortcut handling
- Integrate into DashboardLayout, ChatLayout, and AgentUILayout
- Add fuzzy search by title and ID with grouping by date
- Include unit tests for session search functionality

Co-authored-by: Mervin Praison <[email protected]>
2026-06-17 10:06:06 +00:00
praisonai-triage-agent[bot]andMervin Praison 35a8030078 feat: implement exec approval modal for tool execution consent (fixes #127)
- Add approval modal system to dashboard with risk-based UI
- Integrate approval checking in provider for high-risk tools
- Connect SSE stream for real-time approval notifications
- Support approve/deny with optional 'always' policy
- Maintain import-time constraint under 200ms (186.8ms verified)

Co-authored-by: Mervin Praison <[email protected]>
2026-06-17 10:02:58 +00:00
MervinPraison 7ad2411cb5 feat: remove deprecated 12-agent-dashboard example (fixes #125)
Removes the deprecated examples/python/12-agent-dashboard/ directory
which contained XSS vulnerabilities via innerHTML usage. This example
was marked for removal in v0.5.0 due to unsafe rendering patterns.

- Deleted examples/python/12-agent-dashboard/ entirely
- Updated examples/README.md to redirect to secure alternatives (15-dashboard-test)
- Removed obsolete reference in 13-real-dashboard docstring

The example used innerHTML with API-derived data in 7+ locations,
creating stored XSS risks. Users should use examples 13 or 15 which
implement secure component-based rendering.
2026-06-17 09:59:49 +00:00
MervinPraisonandCursor 6b565c7210 fix(ci): use agent mode for workflow_dispatch Claude runs
Co-authored-by: Cursor <[email protected]>
2026-06-17 10:35:31 +01:00
MervinPraisonandCursor e67db90331 fix(ci): workflow_dispatch issue_number as string
Co-authored-by: Cursor <[email protected]>
2026-06-17 10:33:26 +01:00
MervinPraisonandCursor 1887ae6ae4 fix(ci): dispatch Claude directly from auto-issue-comment
GITHUB_TOKEN label events do not chain to other workflows; dispatch
claude.yml via workflow_dispatch after adding the claude label.

Co-authored-by: Cursor <[email protected]>
2026-06-17 10:32:02 +01:00
MervinPraisonandCursor ae919445a0 fix(ci): auto-escalate all new issues to Claude and use opus model
Enable issues:opened on auto-issue-comment.yml and set model: opus on
claude.yml to match praisonai-package.

Co-authored-by: Cursor <[email protected]>
2026-06-17 10:28:12 +01:00
MervinPraison 984ddf74d2 feat: add jobs proxy configuration for external job servers
- Introduced `set_jobs_proxy` function to allow forwarding of `/api/jobs/*` requests to an external jobs server.
- Updated documentation to reflect the new proxy setup and its usage.
- Enhanced server state reset to accommodate the new jobs API configuration.
- Added tests to ensure proper functionality of the jobs API configuration and proxy settings.

This change facilitates better integration when the dashboard and jobs server operate on different ports.
2026-06-17 10:20:55 +01:00
MervinPraison 5dd3cd81e4 docs: add A2UI canvas guide and surface API references
Document chat-canvas split preview, surface CLI, coerce_a2ui_tool_messages, and empty-canvas API behaviour; fix stale 29-a2ui-canvas paths.
2026-06-15 18:56:15 +01:00
a433c483c1 fix: document bootstrap entrypoints in examples (fixes #71) (#96)
* feat: standardize bootstrap entrypoint to 'aiui run app.py' across examples

Standardizes platform bootstrap patterns across examples per issue #71:

- Update examples README table to use 'aiui run app.py' consistently
- Update docstrings in examples 10, 12, 19-21 to show 'aiui run' instead of 'python app.py'
- Remove fragmented bootstrap approaches (uvicorn.run, asyncio patterns)
- Enforce single bootstrap SSOT as designed in CLI

Co-authored-by: Mervin Praison <[email protected]>

* feat: consolidate bootstrap entrypoints - auto-detect gateway apps (fixes #71)

- Add bootstrap detection in CLI run command
- Auto-detect gateway pattern (async main + AIUIGateway imports)
- Auto-switch to praisonai backend for gateway apps
- Delegate to user-defined main() when present
- Remove sys.path hacks from example 14
- Update examples README to show unified aiui run entrypoint
- Add BOOTSTRAP.md documentation for migration patterns

Co-authored-by: Mervin Praison <[email protected]>

* fix: resolve critical bootstrap entrypoint issues

- Fix AST parsing bug in gateway auto-detection (cli.py:1419)
- Revert background bots (email-bot, agentmail-bot) to python app.py
- Revert custom UI apps (agent-dashboard) to python app.py
- Update README.md to reflect correct run commands
- Fix import formatting and remove unused imports

These changes ensure that:
1. Gateway auto-detection works correctly by properly checking ast.alias.name
2. Background bots execute their __main__ blocks and don't start unnecessary web servers
3. Custom UI apps register their custom routes in __main__ blocks
4. Documentation matches actual working run commands

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

Co-authored-by: Mervin Praison <[email protected]>

---------

Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
Co-authored-by: Mervin Praison <[email protected]>
Co-authored-by: Mervin Praison <[email protected]>
Co-authored-by: Cursor <[email protected]>
2026-06-15 16:36:35 +01:00
814a2e1636 fix: honour zones.footer in FlexibleLayout runtime (fixes #63) (#89)
* feat: render footer zone in FlexibleLayout (fixes #63)

Adds missing footer zone rendering to inline FlexibleLayout case in App.tsx
to honor the zone schema contract. The footer zone was previously configured
in YAML but dropped at runtime due to incomplete implementation.

Co-authored-by: Mervin Praison <[email protected]>

* feat: add missing footer zone rendering to FlexibleLayout (fixes #63)

The inline FlexibleLayout implementation in App.tsx was missing the footer
zone rendering, causing zones.footer widgets to be schema-valid but
runtime-dropped. This adds the missing footer zone section to match the
proper FlexibleLayout component, ensuring footer widgets from examples
07-colorful and 08-corporate now render correctly.

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

Co-authored-by: Claude <[email protected]>

* fix: add array length checks for all zone conditionals in FlexibleLayout

- Fix footer zone rendering to check both existence AND non-empty length
- Apply consistent fix to all zone conditionals (hero, leftSidebar, bottomNav, footer)
- Prevents rendering empty styled wrappers when zones array is empty but defined
- Addresses issue identified in Gemini Code Assist review

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

Co-authored-by: Mervin Praison <[email protected]>

---------

Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
Co-authored-by: Mervin Praison <[email protected]>
Co-authored-by: Claude <[email protected]>
Co-authored-by: MervinPraison <[email protected]>
Co-authored-by: Cursor <[email protected]>
2026-06-15 16:35:08 +01:00
01e6618833 fix: session memory governance in examples (fixes #67) (#92)
* feat: implement SessionGovernor for bounded session memory management (fixes #67)

- Add SessionGovernor with configurable max_turns/max_sessions limits
- Replace unbounded _contexts dict in example 03 with SessionGovernor
- Fix resource churn in examples 25/26 by reusing AsyncOpenAI client
- Implement documented --datastore json flag in example 04
- Export SessionGovernor in praisonaiui.__init__ for public API
- Enforce context window caps and LRU eviction policies
- Prevent OOM conditions in production deployments

Co-authored-by: Mervin Praison <[email protected]>

* fix: critical bugs in SessionGovernor memory management

Fixes two production-critical bugs in session_governor.py:

1. Line 199: Fixed eviction loop condition (>= → >) to prevent:
   - KeyError crashes when active session gets evicted
   - Unnecessary eviction when at limit
   - Double eviction causing under-capacity

2. Line 89-90: Fixed slicing logic for non-positive keep_count:
   - Added guard to prevent messages[-0:] returning all messages
   - Ensures proper memory governance when system messages exceed turn limits

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

Co-authored-by: Mervin Praison <[email protected]>

---------

Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
Co-authored-by: Mervin Praison <[email protected]>
Co-authored-by: Mervin Praison <[email protected]>
2026-06-15 16:34:23 +01:00
b3a5341785 fix: YAML profile semantic binding (fixes #61) (#88)
* feat: wire YAML profile selection to agent instructions (fixes #61)

- Modified _get_agent() in cli.py to read selected profile via get_selected_profile()
- Added profile-aware agent caching using profile ID as cache key
- Profiles can now include optional 'instructions' field for custom behavior
- Falls back to root-level instructions when profile has no specific instructions
- Maintains backward compatibility with existing YAML configs
- Resolves persona model split where UI profiles were cosmetic only

Co-authored-by: Claude <[email protected]>

* feat: fix persona model split - wire profile selection to agent behavior in YAML mode (fixes #61)

- Modified _get_agent() in cli.py to use selected profile for agent resolution
- Added support for per-profile instructions, model, and name fields in YAML
- Profile selection now creates distinct agent instances with different cache keys
- Updated example YAML files to demonstrate profile-specific instructions
- Added comprehensive unit tests validating the fix
- Maintains backward compatibility with existing configurations

Co-authored-by: Mervin Praison <[email protected]>

* fix: implement all gemini-code-assist defensive programming and test improvements

- Add defensive dict type checking in profile iteration (prevents AttributeError)
- Improve fallback logic using 'or' instead of dict.get() to handle None/empty values
- Enhance test coverage with actual behavioral validation instead of trivial assert

Fixes identified architectural issues for robust YAML profile handling.

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

Co-authored-by: Claude <[email protected]>

---------

Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
Co-authored-by: Claude <[email protected]>
Co-authored-by: Mervin Praison <[email protected]>
Co-authored-by: MervinPraison <[email protected]>
2026-06-15 16:33:57 +01:00
06ac1372eb fix: FlexibleLayout docs nav contract (fixes #56) (#84)
* feat: fix FlexibleLayout navigation invariant (fixes #56)

Implement runtime default navigation injection in FlexibleLayout case.
When zones.leftSidebar is not configured but navigation data exists,
render default Sidebar component to ensure docs sites remain navigable.

This fixes the layout subsystem contract violation where navigation
data was computed but not rendered, making docs sites unusable.

Co-authored-by: Mervin Praison <[email protected]>

* feat: implement navigation invariant in FlexibleLayout (fixes #56)

Fixes FlexibleLayout breaking docs site navigation contract by ensuring navigation
renders when content.docs exists, even without explicit leftSidebar zone config.

- Add navigation detection in FlexibleLayout case
- Render default Sidebar component when nav exists but no leftSidebar zone
- Preserve explicit zone override capability
- Fix ESLint error with proper case block scoping

Co-authored-by: Mervin Praison <[email protected]>

* fix: type safety violations in FlexibleLayout navigation invariant

- Use strict boolean expressions for zones and nav checks
- Add optional chaining for zones.leftSidebar access
- Fix TypeScript compilation under strict mode
- Addresses gemini-code-assist review feedback

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

Co-Authored-By: Mervin Praison <[email protected]>

---------

Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
Co-authored-by: Mervin Praison <[email protected]>
2026-06-15 16:33:39 +01:00
7a320bde1b fix: surface platform-board integration errors (fixes #74) (#78)
* fix: surface platform-board integration errors instead of empty state (fixes #74)

Distinguish connection, auth, and HTTP failures from zero issues. Add
__main__ uvicorn bootstrap with HOST/PORT env overrides.

Co-authored-by: Cursor <[email protected]>

* fix: improve platform-board error handling

- Add explicit 200 status code check to prevent 3xx/204 from proceeding to JSON parsing
- Add safe JSON parsing with try/catch to handle invalid JSON responses
- Add defensive type checking for non-dict/non-list JSON responses

Addresses reviewer feedback from Gemini Code Assist review.

Co-authored-by: Mervin Praison <[email protected]>

---------

Co-authored-by: MervinPraison <[email protected]>
Co-authored-by: Cursor <[email protected]>
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
Co-authored-by: Mervin Praison <[email protected]>
2026-06-15 16:33:32 +01:00
2162b61629 fix: replace eval()-based calculate tool with safe math interpreter (fixes #60) (#76)
* fix: replace eval()-based calculate tool with safe math interpreter (fixes #60)

Add AST-walking eval_math_expression() with no eval/exec. Wire into YAML
calculate tool and the provider-praisonai example.

Co-authored-by: Cursor <[email protected]>

* fix: add exponent size limits to prevent DoS attacks in math evaluator

- Add validation to reject exponents with abs(exponent) > 100
- Prevents CPU/memory exhaustion via expressions like 2**1000000000
- Add comprehensive test coverage for exponent limits
- Addresses security concern identified in code review

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

Co-authored-by: MervinPraison <[email protected]>

---------

Co-authored-by: MervinPraison <[email protected]>
Co-authored-by: Cursor <[email protected]>
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
Co-authored-by: MervinPraison <[email protected]>
2026-06-15 16:33:25 +01:00
MervinPraisonandCursor e14b5817b1 fix(ci): restore missing uses in claude-response job
Co-authored-by: Cursor <[email protected]>
2026-06-15 16:26:34 +01:00
77d7d4241a fix: safe rendering in agent-dashboard (fixes #59) (#87)
* feat: deprecate unsafe innerHTML examples and fix XSS vulnerabilities (fixes #59)

- Add deprecation warning to example 12-agent-dashboard with runtime DeprecationWarning
- Replace innerHTML usage in plugin.js with safe DOM APIs (textContent, createElement)
- Example 12 now warns users to migrate to examples/15-dashboard-test/ or examples/13-real-dashboard/
- Prevents XSS injection through API responses in admin dashboard

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

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

* fix: replace innerHTML with safe DOM methods to prevent XSS (fixes #59)

- Fixed renderApiPageData function XSS in error handling
- Fixed renderComponents function XSS in JSON viewer
- Fixed renderCard function XSS in title, value, footer fields
- Fixed renderChart function XSS in title field
- Fixed renderTable function XSS in headers and cell data
- Fixed aiui.createBoard error handler XSS in error messages
- Fixed navigation items XSS in page titles and icons
- Fixed header branding XSS in site title and logo
- Fixed A2UI surface fallback XSS in JSON output
- Fixed component default case XSS in JSON output
- Fixed key-value list XSS in labels and values

All innerHTML usage with user-controlled API data has been replaced
with safe DOM methods (createElement, textContent, appendChild) to
prevent XSS vulnerabilities in the admin dashboard.

Co-authored-by: Mervin Praison <[email protected]>

---------

Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
Co-authored-by: Claude <[email protected]>
Co-authored-by: Mervin Praison <[email protected]>
Co-authored-by: MervinPraison <[email protected]>
2026-06-15 16:25:58 +01:00
MervinPraisonandCursor 86063a3ed0 fix(ci): checkout PR branch and require Claude to push fixes on final review
PR @claude runs now check out the PR head, set git identity, and use a
dedicated final-review prompt that forbids false fork excuses.

Co-authored-by: Cursor <[email protected]>
2026-06-15 16:25:05 +01:00
75fb931a23 docs: resolve tutorial namespace collisions (fixes #73) (#97)
* fix: update tutorial namespace references in examples

- Fixed stale chat-yaml-* references to use current yaml/* paths
- Updated 12-playground title to clarify agents style usage
- Ensures curriculum progression comments are accurate

Fixes #73

* fix: resolve namespace collisions in tutorial examples (fixes #73)

- Move yaml/02-clean-chat to yaml/03-clean-chat to resolve collision with yaml/02-profiles
- Move python/19-email-bot to python/20-email-bot to resolve collision with python/19-components-showcase
- Shift all subsequent numbered examples to maintain monotonic progression
- Update stale comments referencing chat-yaml-* namespaces to proper yaml/* paths
- Fix cross-references between YAML and Python examples

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

Co-authored-by: Mervin Praison <[email protected]>

---------

Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
Co-authored-by: Mervin Praison <[email protected]>
Co-authored-by: MervinPraison <[email protected]>
2026-06-15 15:47:54 +01:00
7d098f4097 fix: composition wiring for corporate example (fixes #70) (#95)
* feat: add CompositionResolver for FlexibleLayout auto-bridging (fixes #70)

- Implement minimal CompositionResolver in compiler that auto-bridges registered
  components to appropriate zones for FlexibleLayout templates
- Auto-bridge components.sidebar → zones.leftSidebar
- Auto-bridge components.header → zones.header
- Auto-bridge components.footer → zones.footer
- Only applies to FlexibleLayout to preserve ThreeColumnLayout slot/ref paradigm
- Prevents duplicate components when already explicitly placed in zones
- Add comprehensive tests covering auto-bridging, layout specificity, and duplicates
- Fixes fractured composition model between slot/ref and zone paradigms
- Maintains <200ms import performance constraint

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

Co-authored-by: Mervin Praison <[email protected]>

* feat: implement CompositionResolver for component-zone auto-wiring (fixes #70)

- Add CompositionResolver to auto-wire components to FlexibleLayout zones
  - Maps sidebar → leftSidebar, header → header, footer → footer
  - Only wires when zone is empty and component exists
- Extend validator to detect orphaned components with auto-wiring support
  - Warns about components defined but never referenced
  - Excludes auto-wireable components for FlexibleLayout templates
- Fixes composition model fracture between component registry and zone system
- Maintains backward compatibility and import time <200ms

Co-authored-by: Mervin Praison <[email protected]>

* fix: consolidate CompositionResolver and fix validator bugs (addresses Gemini review feedback)

- Remove redundant _resolve_composition_wiring method
- Enhance _apply_composition_resolver to initialize template.zones when None
- Fix validator bug: compare widget types to component types, not names
- Fix test_no_auto_bridge_for_non_flexible_layout by adding dummy FlexibleLayout

Co-authored-by: Mervin Praison <[email protected]>

---------

Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
Co-authored-by: Mervin Praison <[email protected]>
Co-authored-by: Mervin Praison <[email protected]>
Co-authored-by: MervinPraison <[email protected]>
Co-authored-by: Cursor <[email protected]>
2026-06-15 15:47:33 +01:00
c752fe0f65 fix: tool capability honesty metadata (fixes #69) (#94)
* feat: add tool capability honesty layer for stub vs live tools (fixes #69)

- Add ToolCapability enum (STUB/LIVE) to classify tool behavior
- Mark web_search as STUB with upgrade_env for live data option
- Add calculate tool with capability metadata and eval warning
- Update API responses to include capability and upgrade_env fields
- Add frontend UI indicators: warning badges and expanded disclaimers
- Create get_stub_tool_disclaimer() for system prompt integration
- Maintain backward compatibility with existing tool definitions

Co-authored-by: Mervin Praison <[email protected]>

* fix: add defensive check to get_stub_tool_disclaimer

Address Gemini reviewer feedback by adding early return when
enabled_tools is None or empty, preventing potential runtime errors.

Co-authored-by: Mervin Praison <[email protected]>

---------

Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
Co-authored-by: Mervin Praison <[email protected]>
Co-authored-by: Mervin Praison <[email protected]>
2026-06-15 15:46:20 +01:00
c4384fd818 fix: thread-safe stream bridge for agent streaming (fixes #68) (#93)
* feat: add StreamBridge for thread-safe async streaming (fixes #68)

- Create StreamBridge abstraction in examples/_shared/ with thread-safe async-sync bridge
- Fix examples 07, 08, 13 to use call_soon_threadsafe instead of unsafe put_nowait
- Fix example 07 lifecycle ordering bug by concurrent event draining
- Add comprehensive unit tests for StreamBridge functionality
- All examples now use consistent thread-safe streaming pattern

Co-authored-by: Mervin Praison <[email protected]>

* fix: resolve thread-safety and performance issues in StreamBridge

- Initialize asyncio.Queue in __init__ to ensure thread-safe creation
- Remove race condition by eliminating lazy initialization
- Fix 1.0-second performance delay in provider example using sentinel pattern
- Remove unused imports and StreamBridge instance in provider
- Update tests to match new initialization behavior
- All tests pass and linting clean

Co-authored-by: Mervin Praison <[email protected]>

---------

Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
Co-authored-by: Mervin Praison <[email protected]>
2026-06-15 15:46:15 +01:00
c8600029b3 feat: honest rename of blocks example to shadcn-deps (fixes #65) (#91)
Remove misleading 'Component Blocks Showcase' claims and orphaned /blocks/** route.
This example demonstrates dependency installation only, not component showcases.

- Renamed to 'shadcn Dependencies Installation Demo'
- Removed orphaned /blocks/** route
- Updated documentation to reflect actual functionality
- Example now honestly describes what it does: auto-downloads 22 shadcn components

Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
Co-authored-by: Mervin Praison <[email protected]>
2026-06-15 15:46:10 +01:00
f56cbf6243 fix: compile enterprise config fields to frontend (fixes #64) (#90)
* feat: implement ConfigFeatureRegistry to fix schema-runtime gap

- Add ConfigFeatureRegistry to track implementation status of config fields
- Enhance validation with --strict flag warning about experimental fields
- Update compiler to emit seo/a11y configs to ui-config.json
- Add logoImage support in Header component
- SEO tags generation now uses configured title template and OG image
- Runtime warnings for experimental fields (i18n)
- Maintains backward compatibility with existing configs

Fixes #64

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

Co-authored-by: Mervin Praison <[email protected]>

* feat: fix multi-layer config contract - emit enterprise fields to frontend (fixes #64)

- Update compiler to emit seo, i18n, a11y, logo fields to ui-config.json
- Add TypeScript interfaces for enterprise config fields
- Enhance Header component to support logo configuration (light/dark variants)
- Add SkipLink component for accessibility with conditional rendering
- Enhance SEO meta tag generation with titleTemplate and Twitter config
- Add strict validation mode for experimental features
- All fields now flow properly: YAML → Pydantic → ui-config → Frontend

Co-authored-by: Mervin Praison <[email protected]>

* fix: address critical architecture and accessibility issues

- Fix double-templating bug in SEO title generation (compiler.py:581)
- Improve skip link accessibility by targeting Content component only
- Add logo fallback logic for missing theme variants
- Simplify title template parsing with sequential replace
- Correct CLI flag reference in validation error messages
- Exclude metadata fields from unimplemented feature validation

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

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

---------

Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
Co-authored-by: Mervin Praison <[email protected]>
Co-authored-by: Mervin Praison <[email protected]>
Co-authored-by: Claude <[email protected]>
2026-06-15 15:46:04 +01:00
4fe69554fc fix: channel config secret classification (fixes #58) (#86)
* feat: implement channel config secret security validation (fixes #58)

- Add secret field detection to prevent inline API keys/passwords in channel configs
- Implement config validation that rejects secrets, requires env:VAR_NAME references
- Add response redaction to hide secrets from GET endpoints
- Update examples 20, 22 and seed data to use secure env: pattern
- Covers major secret types: api_key, bot_token, app_password, etc.

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

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

* fix: resolve _ref keys in bot startup and add defensive validation

- Add _get_config_secret() helper to resolve bot_token_ref, api_key_ref, app_token_ref
- Update _start_channel_bot() to use new helper for token resolution
- Add defensive input validation to _validate_config_security() and _redact_config_secrets()
- Fix defensive dictionary access in API endpoints (_list, _get)
- Update _resolve_slack_app_token() and test endpoint to use new helper

Fixes critical issue where bots configured with _ref keys could not start.

Co-authored-by: Mervin Praison <[email protected]>

---------

Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
Co-authored-by: Claude <[email protected]>
Co-authored-by: Mervin Praison <[email protected]>
2026-06-15 15:45:58 +01:00
9546cefbde fix: dedupe example 18 full-chat curriculum (fixes #57) (#85)
* feat: implement set_pages() in example 18 to fix curriculum duplication (fixes #57)

- Add aiui.set_pages(["chat", "sessions", "agents", "usage", "config"]) call
- This implements the page curation functionality promised in the docstring
- Resolves pedagogical architecture issue where example 18 was duplicate of 17
- Now example 18 genuinely teaches aiui.set_pages() API as advertised

Co-authored-by: Mervin Praison <[email protected]>

* feat: implement set_pages() in example 18 to demonstrate page curation

- Add aiui.set_pages() call to show only essential pages (chat, sessions, agents, usage, config)
- Update docstring to clarify difference from example 17 (all pages vs curated pages)
- Fix import ordering and remove unused imports per ruff
- Resolves curriculum duplication where 18 was identical to 17 but claimed different functionality

Fixes #57

Co-authored-by: Mervin Praison <[email protected]>

---------

Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
Co-authored-by: Mervin Praison <[email protected]>
Co-authored-by: Mervin Praison <[email protected]>
2026-06-15 15:45:53 +01:00
6a8c634031 fix: wire demo seed data into feature showcase (fixes #55) (#83)
* feat: restore demo data seeding in examples 10-12 (fixes #55)

- Update seed_demo_data() to delegate to seed_via_api() using TestClient
- Replaces no-op stub with working implementation
- Ensures examples show populated dashboards on startup
- Uses HTTP API as stable integration surface per architectural principle

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

Co-authored-by: Mervin Praison <[email protected]>

* feat: restore demo data seeding in examples 10-12 (fixes #55)

- Modified seed_demo_data() to call seed_via_api() with TestClient
- Preserves cold import performance with lazy imports (167ms < 200ms)
- Examples now show populated dashboards instead of empty tables
- Maintains backend compatibility via API-based seeding approach

Co-authored-by: Mervin Praison <[email protected]>

* fix: use TestClient as context manager for proper lifespan handling

Co-authored-by: Mervin Praison <[email protected]>

---------

Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
Co-authored-by: Mervin Praison <[email protected]>
Co-authored-by: Mervin Praison <[email protected]>
2026-06-15 15:45:47 +01:00
4df8c29f1d docs: sync examples/README.md with on-disk examples (fixes #62) (#80)
* docs: sync examples/README.md with on-disk Python and YAML examples (fixes #62)

Index all 35 Python apps, 13 YAML examples, and real site template folder
names. Note duplicate numeric prefixes and link to docs/examples guides.

Co-authored-by: Cursor <[email protected]>

* docs: fix playground run command to include --style agents flag

Fixes the run command for yaml/12-playground to match the docstring
in the YAML file itself, which specifies --style agents is required.

Co-authored-by: Mervin Praison <[email protected]>

---------

Co-authored-by: MervinPraison <[email protected]>
Co-authored-by: Cursor <[email protected]>
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
Co-authored-by: Mervin Praison <[email protected]>
2026-06-15 15:45:42 +01:00
e53df0ca78 fix: default python app.py examples to 127.0.0.1 (fixes #66) (#79)
* fix: default python app.py examples to 127.0.0.1 (fixes #66)

Bind loopback by default in 11 examples that call uvicorn or AIUIGateway
directly. HOST env var overrides for Docker/LAN demos.

Co-authored-by: Cursor <[email protected]>

* fix: make host variable consistent across all examples

Move HOST env var from global scope to local scope in main block
for consistency with other 10 examples.

Co-authored-by: Mervin Praison <[email protected]>

---------

Co-authored-by: MervinPraison <[email protected]>
Co-authored-by: Cursor <[email protected]>
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
Co-authored-by: Mervin Praison <[email protected]>
2026-06-15 15:45:36 +01:00
3d9c8da13d docs: update extensibility README for set_custom_js API (fixes #72) (#77)
Remove stale DevTools workaround; document aiui.set_custom_js() loader
already used in app.py.

Co-authored-by: MervinPraison <[email protected]>
Co-authored-by: Cursor <[email protected]>
2026-06-15 15:45:31 +01:00
b46f4450a0 fix(ci): add workflow permissions for PR comment triggers (#99)
GITHUB_TOKEN needs issues:write at workflow level for workflow_dispatch
and @claude comment posting.

Co-authored-by: MervinPraison <[email protected]>
Co-authored-by: Cursor <[email protected]>
2026-06-15 15:10:11 +01:00
f4ec043989 fix(ci): enable PR auto-Claude final review chain (parity with PraisonAI) (#98)
- Fallback GH_TOKEN to github.token when user PAT missing
- Fix pull_request handler using wrong context.issue.number
- Add bot PR path: CodeRabbit → Claude directly (skip Copilot)
- Add workflow_dispatch to manually trigger Claude on any PR

Co-authored-by: MervinPraison <[email protected]>
Co-authored-by: Cursor <[email protected]>
2026-06-15 15:07:35 +01:00
df02e0c15b fix(ci): dedupe Claude workflow when label and @claude comment both fire (#82)
Skip issue_comment trigger if claude label was added within 3 minutes.
Add per-issue concurrency to cancel overlapping runs.

Co-authored-by: MervinPraison <[email protected]>
Co-authored-by: Cursor <[email protected]>
2026-06-15 13:44:08 +01:00
66acd35496 chore(ci): auto-trigger Claude on owner issue open (parity with PraisonAI) (#81)
* chore(ci): auto-trigger Claude on owner issue open (parity with PraisonAI)

Mirror praisonai-package claude.yml: triage labels on open, auto-add claude
label for owner/collaborator issues, GitHub App token, fork PR handling.

Co-authored-by: Cursor <[email protected]>

* chore(ci): add trigger workflow to sync secrets from PraisonAI

Co-authored-by: Cursor <[email protected]>

* fix(ci): fallback to GITHUB_TOKEN when Claude app secrets missing

Auto-triage works without cross-repo secret copy. Sync workflow documents
manual fallback when SECRETS_ADMIN_PAT is unavailable.

Co-authored-by: Cursor <[email protected]>

---------

Co-authored-by: MervinPraison <[email protected]>
Co-authored-by: Cursor <[email protected]>
2026-06-15 13:34:39 +01:00
MervinPraison 8a483a62dd chore: update dependencies and enhance CI workflow
- Updated `praisonaiagents` and `praisonai` versions in `pyproject.toml`.
- Added integration smoke tests to the CI workflow for better backend validation.
- Improved documentation on platform integration and SDK gaps.
- Enhanced the UI for issues and sessions with new toolbar components.
- Refactored backend integration checks and tool resolution logic for better maintainability.
2026-06-15 13:22:27 +01:00
MervinPraisonandCursor d418dcf443 refactor: harden input validation and access controls
Co-authored-by: Cursor <[email protected]>
2026-06-13 13:44:05 +01:00
Mervin Praison c467643c15 Merge pull request #53 from MervinPraison/feat/l1-integration-backends
feat: L1 backend integration for PraisonAI package
2026-05-21 11:59:15 +01:00
MervinPraison 8caa8d51d0 test: align unit tests with surface decorators, dashboard modules, MCP session context.
Update regression allowlist and assertions after integration branch API changes.
2026-05-21 11:58:00 +01:00
MervinPraison 541ada0399 chore: fix ruff lint errors for CI.
Remove unused imports and sort import blocks flagged by ruff.
2026-05-21 11:56:23 +01:00
MervinPraison fb28f2a2ee test(jobs): read jobs config from module after set_jobs_backend.
Imported dict binding was stale after global reassignment in set_jobs_api.
2026-05-21 11:55:10 +01:00
MervinPraison ac0a4f32a8 test(jobs): reset jobs API config before backend default assertion.
Avoid cross-test pollution when other suites call set_jobs_api first.
2026-05-21 11:52:29 +01:00
MervinPraison 3a0fa72c3d Merge origin/main into feat/l1-integration-backends and fix review issues.
Resolve pyproject/uv.lock version conflicts, merge package integration docs,
fix a2ui-mapper JS syntax, run workflows in thread pool, and use AsyncContext
for video agent tools when called from a running event loop.
2026-05-21 11:51:16 +01:00
MervinPraison f38aa27345 feat(jobs): add configurable jobs API and platform board examples.
Expose set_jobs_api/set_jobs_backend for dashboard jobs view routing and add platform-board examples with tests.
2026-05-21 11:45:45 +01:00
MervinPraison 39615089ba refactor(dashboard): slim view modules and add package alignment tests.
Delegate heavy dashboard views to shared modules, register shell header branding slot, and add tests for auth/api views and jobs UI config.
2026-05-21 11:45:36 +01:00
MervinPraison 77b67271ec feat(docs): add PraisonAI package integration documentation
This commit introduces detailed documentation on how to integrate
the PraisonAI package with the Agent runtime, gateway, and CLI.
It outlines deployment options, wiring checklists, API surfaces,
and plugin systems to facilitate user understanding and usage.

Additionally, updates the version in `pyproject.toml` and
`.gitignore` to include database files and modifies the lock file.
2026-05-21 11:44:05 +01:00
MervinPraison 7099e9f2ce u 2026-05-21 11:43:26 +01:00
MervinPraison 3f02c32740 feat(docs): add image preview feature for chat
Introduced documentation for a new feature allowing inline media rendering
of agent-generated images during chat. This update includes architecture
details, data flow paths, and examples for seamless integration with
existing backends. Updated related documentation to link to new feature.
2026-05-20 20:38:41 +01:00
MervinPraison a9dfc97e8c feat(chat): support inline media in chat messages
- Added support for inline media (images, videos, files) in chat messages
- Updated `ChatArea`, `ChatMessages`, and `ToolCallDisplay` components to render media
- Introduced `ImagePreviewProvider` example demonstrating dynamic image rendering
- Enhanced documentation on provider media capabilities
- Updated integration and unit tests to cover new media functionality
2026-05-20 20:03:01 +01:00
MervinPraison 5429a287e7 feat(frontend, docs): add A2UI surfaces and chat-canvas integration
- Implemented A2UI surfaces for agent-driven UI in chat/canvas.
- Enhanced documentation for architecture and agent UI features.
- Introduced new example demonstrating A2UI canvas integration.
- Updated various frontend components to support new features.
- Improved tests for A2UI and related functionalities.
2026-05-20 15:55:45 +01:00
MervinPraison a4167b1477 feat(video): add Video Studio feature with integration and examples
Introduced Video Studio integration with PraisonAI Video engine.
Added documentation, example app, and multiple backend routes for project management, scene processing, and rendering.
Updated version in pyproject.toml for release.
2026-05-20 12:11:11 +01:00
MervinPraison 0923aac53a feat(docs): add documentation for modular dashboard shell feature
This commit introduces comprehensive documentation for the Agent UI host, detailing the architecture, quick start guide, extension API, layout components, optional dashboard modules, and third-party plugin development. Additionally, it includes a minimal example of an external agent hosted on the PraisonAIUI dashboard shell.
2026-05-19 21:27:40 +01:00
Mervin PraisonandMervinPraison b589c6fce2 refactor(api): PraisonAI-native names for discovery, citations, reasoning types (#52)
Rename the six public dataclasses shipped in v0.3.113 so none of them are
exact-name lifts from an external framework.  Names are now PraisonAI-unique,
noun-first, non-developer-friendly, and pair with the existing ``*Prompt`` /
``ThinkingSteps`` PraisonAI style.

## Rename table

| v0.3.113 (lifted) | v0.3.114 (PraisonAI-native) |
|-------------------|-----------------------------|
| ModelInfo         | **ModelCard**               |
| AgentDetails      | **AgentCard**               |
| TeamDetails       | **TeamCard**                |
| Reference         | **SourceChunk**             |
| ReferenceData     | **SourceBundle**            |
| ReasoningStep     | **ThoughtStep**             |
| `references_event()`     | **`source_bundle_event()`** |
| `reasoning_step_event()` | **`thought_step_event()`**  |

## What stays the same

- **Wire format is unchanged.**  The SSE event ``type`` string is still
  ``"references"``, and the JSON payload still uses the keys ``query``,
  ``references``, ``time_ms`` so third-party chat frontends see the exact
  shape they expect.
- ``RunEvent`` and ``RunEventType`` keep their existing names — these
  pre-existed in PraisonAIUI before the competitor audit and are not lifts.
- ``RunEventType.REFERENCES = "references"`` enum variant is unchanged.
- All REST routes added in v0.3.113 are unchanged (``/teams``, etc.).

## Why a breaking rename (no aliases)

Keeping the lifted names even as silent aliases would defeat the stated
goal ("names unique for PraisonAI").  v0.3.113 was released <1 h before
this rename, so no external consumer can have taken a dependency yet.

## Tests

- Every existing test in ``tests/unit/test_protocol_parity.py`` updated
  to the new names — **all 15 pass**.
- **New regression guard** ``test_no_competitor_lifted_names_exported``
  fails loudly if any of the six lifted names ever reappear on the
  public module, preventing accidental reintroduction.

Full suite: **955 pass**, 4 skipped, 8 xfailed, 1 xpassed.  Ruff clean.

## Version
Bumps to 0.3.114.

Co-authored-by: MervinPraison <[email protected]>
2026-04-23 00:41:32 +01:00
348 changed files with 26640 additions and 4550 deletions

No files matched your search

+17 -1
View File
@@ -138,7 +138,23 @@ Final rule: conclude only when evidence shows missing = 0.
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
PUBLISH WORKFLOW (reference)
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
Step 1 — Publish praisonaiagents (Core SDK):
GitHub Actions (recommended):
GitHub → Actions → "PyPI Release" → Run workflow → bump: patch
Requires repo secrets: PYPI_TOKEN, GH_TOKEN
Requires GitHub Environment "pypi" with required reviewers (Settings → Environments)
Manual — Publish aiui (PraisonAIUI):
cd /Users/praison/PraisonAIUI
# Bump version in pyproject.toml and src/praisonaiui/__version__.py
rm -rf dist && uv lock && uv build && uv publish --token "$PYPI_TOKEN"
git add pyproject.toml uv.lock src/praisonaiui/__version__.py
git commit -m "Release vX.Y.Z" && git tag vX.Y.Z && git push && git push --tags
gh release create vX.Y.Z --title "PraisonAIUI vX.Y.Z" --notes "Release vX.Y.Z" --latest
PyPI package name: aiui
Verify: pip index versions aiui | head -1
PraisonAI stack (separate repo — publish SDK before wrapper):
cd /Users/praison/praisonai-package/src/praisonai-agents
praisonai publish pypi
# Uses uv internally: uv lock → uv build → uv publish
@@ -0,0 +1,290 @@
name: "Claude Code Action"
description: "Run Claude Code in GitHub Actions workflows"
inputs:
anthropic_api_key:
description: "Anthropic API key"
required: false
claude_code_oauth_token:
description: "Claude Code OAuth Token for Claude Pro"
required: false
github_token:
description: "GitHub token for Claude to operate with"
required: false
default: ${{ github.token }}
trigger_phrase:
description: "The trigger phrase to look for in comments, issue/PR bodies, and issue titles"
required: false
default: "@claude"
assignee_trigger:
description: "The assignee username that triggers the action (e.g. @claude). Only used for issue assignment"
required: false
max_turns:
description: "Maximum number of conversation turns Claude can take"
required: false
timeout_minutes:
description: "Timeout in minutes for execution"
required: false
default: "30"
model:
description: "Model to use (provider-specific format required for Bedrock/Vertex)"
required: false
use_bedrock:
description: "Use Amazon Bedrock with OIDC authentication instead of direct Anthropic API"
required: false
default: "false"
use_vertex:
description: "Use Google Vertex AI with OIDC authentication instead of direct Anthropic API"
required: false
default: "false"
allowed_tools:
description: "Additional tools for Claude to use (the base GitHub tools will always be included)"
required: false
default: ""
disallowed_tools:
description: "Tools that Claude should never use"
required: false
default: ""
custom_instructions:
description: "Additional custom instructions to include in the prompt for Claude"
required: false
default: ""
mcp_config:
description: "Additional MCP configuration (JSON string) that merges with the built-in GitHub MCP servers"
required: false
default: ""
claude_env:
description: "Custom environment variables to pass to Claude Code execution (YAML format)"
required: false
default: ""
direct_prompt:
description: "Direct prompt for Claude to execute automatically without needing a trigger (for automated workflows)"
required: false
runs:
using: "composite"
steps:
- name: Install Claude Code
shell: bash
run: npm install -g @anthropic-ai/claude-code
- name: Install GitHub MCP Server
shell: bash
run: |
claude mcp add-json github '{
"command": "docker",
"args": [
"run",
"-i",
"--rm",
"-e",
"GITHUB_PERSONAL_ACCESS_TOKEN",
"ghcr.io/github/github-mcp-server:sha-ff3036d"
],
"env": {
"GITHUB_PERSONAL_ACCESS_TOKEN": "${{ inputs.github_token }}"
}
}'
- name: Extract GitHub Context and Create Prompt
shell: bash
id: prepare_context
env:
# Pass user-controlled inputs via environment variables to prevent script injection (GHSL-2025-093)
EVENT_COMMENT_BODY: ${{ github.event.comment.body }}
EVENT_ISSUE_TITLE: ${{ github.event.issue.title }}
EVENT_ISSUE_BODY: ${{ github.event.issue.body }}
EVENT_ISSUE_NUMBER: ${{ github.event.issue.number }}
EVENT_PR_TITLE: ${{ github.event.pull_request.title }}
EVENT_PR_NUMBER: ${{ github.event.pull_request.number }}
EVENT_REVIEW_BODY: ${{ github.event.review.body }}
EVENT_ASSIGNEE_LOGIN: ${{ github.event.assignee.login }}
INPUT_TRIGGER_PHRASE: ${{ inputs.trigger_phrase }}
INPUT_ASSIGNEE_TRIGGER: ${{ inputs.assignee_trigger }}
INPUT_DIRECT_PROMPT: ${{ inputs.direct_prompt }}
EVENT_ACTION: ${{ github.event.action }}
EVENT_NAME: ${{ github.event_name }}
REPO_NAME: ${{ github.repository }}
run: |
echo "🔍 Extracting GitHub context from event: $EVENT_NAME"
# Function to check for trigger phrase
check_trigger() {
local text="$1"
local trigger="$INPUT_TRIGGER_PHRASE"
if [[ "$text" == *"$trigger"* ]]; then
return 0
fi
return 1
}
# Extract context based on event type
TRIGGER_FOUND="false"
USER_REQUEST=""
CONTEXT_INFO=""
case "$EVENT_NAME" in
"issue_comment")
COMMENT_BODY="$EVENT_COMMENT_BODY"
ISSUE_TITLE="$EVENT_ISSUE_TITLE"
ISSUE_NUMBER="$EVENT_ISSUE_NUMBER"
if check_trigger "$COMMENT_BODY"; then
TRIGGER_FOUND="true"
USER_REQUEST="$COMMENT_BODY"
CONTEXT_INFO="Issue Comment on #$ISSUE_NUMBER: $ISSUE_TITLE"
fi
;;
"pull_request_review_comment")
COMMENT_BODY="$EVENT_COMMENT_BODY"
PR_TITLE="$EVENT_PR_TITLE"
PR_NUMBER="$EVENT_PR_NUMBER"
if check_trigger "$COMMENT_BODY"; then
TRIGGER_FOUND="true"
USER_REQUEST="$COMMENT_BODY"
CONTEXT_INFO="PR Comment on #$PR_NUMBER: $PR_TITLE"
fi
;;
"pull_request_review")
REVIEW_BODY="$EVENT_REVIEW_BODY"
PR_TITLE="$EVENT_PR_TITLE"
PR_NUMBER="$EVENT_PR_NUMBER"
if check_trigger "$REVIEW_BODY"; then
TRIGGER_FOUND="true"
USER_REQUEST="$REVIEW_BODY"
CONTEXT_INFO="PR Review on #$PR_NUMBER: $PR_TITLE"
fi
;;
"issues")
ISSUE_BODY="$EVENT_ISSUE_BODY"
ISSUE_TITLE="$EVENT_ISSUE_TITLE"
ISSUE_NUMBER="$EVENT_ISSUE_NUMBER"
if check_trigger "$ISSUE_TITLE" || check_trigger "$ISSUE_BODY"; then
TRIGGER_FOUND="true"
USER_REQUEST="$ISSUE_BODY"
CONTEXT_INFO="Issue #$ISSUE_NUMBER: $ISSUE_TITLE"
elif [[ "$EVENT_ACTION" == "assigned" && -n "$INPUT_ASSIGNEE_TRIGGER" ]]; then
ASSIGNEE="$EVENT_ASSIGNEE_LOGIN"
if [[ "$ASSIGNEE" == "$INPUT_ASSIGNEE_TRIGGER" ]]; then
TRIGGER_FOUND="true"
USER_REQUEST="$ISSUE_BODY"
CONTEXT_INFO="Issue #$ISSUE_NUMBER assigned to $ASSIGNEE: $ISSUE_TITLE"
fi
fi
;;
esac
# Check for direct prompt override
if [[ -n "$INPUT_DIRECT_PROMPT" ]]; then
TRIGGER_FOUND="true"
USER_REQUEST="$INPUT_DIRECT_PROMPT"
CONTEXT_INFO="Automated GitHub workflow"
fi
if [[ "$TRIGGER_FOUND" != "true" ]]; then
echo "❌ No trigger phrase found or direct prompt provided. Exiting gracefully."
echo "SKIP_EXECUTION=true" >> $GITHUB_ENV
exit 0
fi
echo "✅ Trigger found! Context: $CONTEXT_INFO"
# Create comprehensive prompt
mkdir -p /tmp/claude-action
cat > /tmp/claude-action/github-context-prompt.txt << EOF
You are Claude Code, an AI assistant helping with GitHub workflows and code.
Repository: $REPO_NAME
Context: $CONTEXT_INFO
Event: $EVENT_NAME
User Request:
$USER_REQUEST
Please analyze the request and provide helpful assistance.
CRITICAL INSTRUCTION: If your task involves modifying codebase files to add a feature or fix a bug:
STEP 0 — SETUP GIT IDENTITY:
git config user.name "MervinPraison"
git config user.email "[email protected]"
STEP 1 — READ GUIDELINES:
MANDATORY: Before writing any code, you MUST carefully read "src/praisonai-agents/AGENTS.md" to understand this repository's extremely specific Architecture, Protocol-First design rules, and Agent-Centric Philosophy.
STEP 2 — ARCHITECTURE VALIDATION & ROUTING (MANDATORY before writing code):
Before implementing anything, answer these questions:
- CORE vs WRAPPER vs TOOLS ROUTING:
1. Core SDK (praisonaiagents/): Only core protocols, base classes, decorators. No heavy implementations.
2. Wrapper (praisonai/): CLI, heavy implementations, optional dependencies.
3. Tools (PraisonAI-Tools): Specific integrations, external tools (e.g. SurrealDB, Slack), and community extensions MUST go to the `MervinPraison/PraisonAI-Tools` repository. If the feature is a tool, clone `https://github.com/MervinPraison/PraisonAI-Tools`, implement it there, and create the PR using `gh pr create -R MervinPraison/PraisonAI-Tools`.
- Does it duplicate existing functionality? Check if Agent already supports this via existing params (reflection, planning, tools, hooks, memory).
- Does it inherit from Agent properly? New agent types MUST inherit Agent, not wrap it with composition.
- Does it add new dependencies? Only optional deps allowed, must be lazy-imported.
- Will agent.py grow larger? If the change adds >50 lines to agent.py, find a way to extract instead.
- Is there a name collision with existing exports in __init__.py?
If ANY of these conceptual checks fail (excluding routing), add a comment to the issue explaining why and close it. Do NOT create a PR.
STEP 3 — IMPLEMENT:
- Create a new git branch appropriately named (e.g. \`feature/issue-<number>\`)
- Follow protocol-driven design: protocols in core SDK, heavy implementations in wrapper
- Keep changes small and backward-compatible
STEP 4 — TEST:
- Run: cd src/praisonai-agents && PYTHONPATH=. python -m pytest tests/ -x -q --timeout=30
- Ensure no regressions
STEP 5 — CREATE PR:
- Commit with descriptive message, push the branch
- Use the GitHub CLI (\`gh pr create --title "..." --body "Fixes #<number>"\`) to open a Pull Request
CRITICAL: You MUST create the PR automatically using \`gh pr create\`. Do NOT just provide a link.
Respond naturally and helpfully to the user's request using the available tools.
EOF
echo "PROMPT_FILE=/tmp/claude-action/github-context-prompt.txt" >> $GITHUB_ENV
echo "SKIP_EXECUTION=false" >> $GITHUB_ENV
- name: Run Claude Code
if: env.SKIP_EXECUTION != 'true'
shell: bash
run: |
echo "🚀 Running Claude Code with GitHub context..."
# Build command arguments
CMD_ARGS=("-p" "--verbose" "--output-format" "stream-json")
# Add max turns if specified
if [[ -n "${{ inputs.max_turns }}" ]]; then
CMD_ARGS+=("--max-turns" "${{ inputs.max_turns }}")
fi
# Add allowed tools (include GitHub tools by default)
TOOLS="mcp__github__get_issue,mcp__github__get_issue_comments,mcp__github__update_issue,mcp__github__search_issues,mcp__github__list_issues,mcp__github__create_comment,Read,Write,Edit,Bash"
if [[ -n "${{ inputs.allowed_tools }}" ]]; then
TOOLS="$TOOLS,${{ inputs.allowed_tools }}"
fi
CMD_ARGS+=("--allowedTools" "$TOOLS")
# Add disallowed tools
if [[ -n "${{ inputs.disallowed_tools }}" ]]; then
CMD_ARGS+=("--disallowedTools" "${{ inputs.disallowed_tools }}")
fi
echo "📝 Executing Claude Code with prompt from file..."
# Execute Claude Code with timeout, using stdin for the prompt
TIMEOUT_SECONDS=$((${{ inputs.timeout_minutes }} * 60))
timeout $TIMEOUT_SECONDS claude "${CMD_ARGS[@]}" < "${{ env.PROMPT_FILE }}"
echo "✅ Claude Code execution completed"
env:
ANTHROPIC_API_KEY: ${{ inputs.anthropic_api_key }}
CLAUDE_CODE_OAUTH_TOKEN: ${{ inputs.claude_code_oauth_token }}
GITHUB_TOKEN: ${{ inputs.github_token }}
@@ -0,0 +1,91 @@
name: "Claude Issue Triage Action"
description: "Automatically triage GitHub issues using Claude Code"
inputs:
timeout_minutes:
description: "Timeout in minutes for execution"
required: false
default: "5"
claude_code_oauth_token:
description: "Claude Code OAuth token for Pro subscription access"
required: true
github_token:
description: "GitHub token with repo and issues permissions"
required: true
runs:
using: "composite"
steps:
- name: Checkout repository code
uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Create prompt
id: generate_prompt
shell: bash
run: |
mkdir -p /tmp/claude-prompts
cat > /tmp/claude-prompts/claude-issue-triage-prompt.txt << 'EOF'
You're an issue triage assistant for GitHub issues. Your task is to analyze the issue and select appropriate labels from the provided list.
IMPORTANT: Don't post any comments or messages to the issue. Your only action should be to apply labels.
Issue Information:
- REPO: ${{ github.repository }}
- ISSUE_NUMBER: ${{ github.event.issue.number }}
TASK OVERVIEW:
1. First, fetch the list of labels available in this repository by running: `gh label list`. Run exactly this command with nothing else.
2. Next, use the GitHub tools to get context about the issue:
- You have access to these tools:
- mcp__github__get_issue: Use this to retrieve the current issue's details including title, description, and existing labels
- mcp__github__get_issue_comments: Use this to read any discussion or additional context provided in the comments
- mcp__github__update_issue: Use this to apply labels to the issue (do not use this for commenting)
- mcp__github__search_issues: Use this to find similar issues that might provide context for proper categorization and to identify potential duplicate issues
- mcp__github__list_issues: Use this to understand patterns in how other issues are labeled
- Start by using mcp__github__get_issue to get the issue details
3. Analyze the issue content, considering:
- The issue title and description
- The type of issue (bug report, feature request, question, etc.)
- Technical areas mentioned
- Severity or priority indicators
- User impact
- Components affected
4. Select appropriate labels from the available labels list provided above:
- Choose labels that accurately reflect the issue's nature
- Be specific but comprehensive
- Select priority labels if you can determine urgency (high-priority, med-priority, or low-priority)
- Consider platform labels (android, ios) if applicable
- If you find similar issues using mcp__github__search_issues, consider using a "duplicate" label if appropriate. Only do so if the issue is a duplicate of another OPEN issue.
5. Apply the selected labels:
- Use mcp__github__update_issue to apply your selected labels
- DO NOT post any comments explaining your decision
- DO NOT communicate directly with users
- If no labels are clearly applicable, do not apply any labels
IMPORTANT GUIDELINES:
- Be thorough in your analysis
- Only select labels from the provided list above
- DO NOT post any comments to the issue
- Your ONLY action should be to apply labels using mcp__github__update_issue
- It's okay to not add any labels if none are clearly applicable
EOF
echo "PROMPT<<DELIMITER" >> $GITHUB_OUTPUT
cat /tmp/claude-prompts/claude-issue-triage-prompt.txt >> $GITHUB_OUTPUT
echo "DELIMITER" >> $GITHUB_OUTPUT
- name: Run Claude Code
uses: ./.github/actions/claude-code-action
with:
direct_prompt: ${{ steps.generate_prompt.outputs.PROMPT }}
allowed_tools: "Bash(gh label list),mcp__github__get_issue,mcp__github__get_issue_comments,mcp__github__update_issue,mcp__github__search_issues,mcp__github__list_issues"
timeout_minutes: ${{ inputs.timeout_minutes }}
claude_code_oauth_token: ${{ inputs.claude_code_oauth_token }}
github_token: ${{ inputs.github_token }}
+153
View File
@@ -0,0 +1,153 @@
/**
* Idempotent CodeRabbit/Qodo kick for bot-opened PRs.
* @see .github/workflows/auto-pr-comment.yml, claude.yml, bot-pr-recovery.yml
*/
const KICK_AUTHORS = new Set(['MervinPraison', 'github-actions[bot]']);
const BOT_PR_AUTHORS = new Set(['praisonai-triage-agent[bot]', 'github-actions[bot]']);
async function listAllComments(github, owner, repo, issueNumber) {
if (typeof github.paginate === 'function') {
return github.paginate(github.rest.issues.listComments, {
owner,
repo,
issue_number: issueNumber,
per_page: 100,
});
}
const { data } = await github.rest.issues.listComments({
owner,
repo,
issue_number: issueNumber,
per_page: 100,
});
return data;
}
function kickAuthored(comment, marker) {
return (
KICK_AUTHORS.has(comment.user?.login) &&
(comment.body || '').includes(marker)
);
}
function coderabbitKickPosted(comments) {
return comments.some((c) => kickAuthored(c, '@coderabbitai review'));
}
function qodoKickPosted(comments) {
return comments.some((c) => kickAuthored(c, '/review'));
}
function chainKickPosted(comments) {
return coderabbitKickPosted(comments) && qodoKickPosted(comments);
}
function isBotOpenedPr(pr) {
if (BOT_PR_AUTHORS.has(pr.user?.login)) return true;
return pr.user?.type === 'Bot';
}
async function kickReviewChain(github, owner, repo, prNumber, core, preFetchedComments = null) {
const comments = preFetchedComments || await listAllComments(github, owner, repo, prNumber);
const needCoderabbit = !coderabbitKickPosted(comments);
const needQodo = !qodoKickPosted(comments);
if (!needCoderabbit && !needQodo) {
core?.info?.(`Review chain already kicked on PR #${prNumber}`);
return { kicked: false, reason: 'already_kicked' };
}
if (needCoderabbit) {
await github.rest.issues.createComment({
owner,
repo,
issue_number: prNumber,
body: '@coderabbitai review',
});
}
if (needQodo) {
await github.rest.issues.createComment({
owner,
repo,
issue_number: prNumber,
body: '/review',
});
}
core?.info?.(`Kicked review chain for PR #${prNumber} (coderabbit=${needCoderabbit}, qodo=${needQodo})`);
return { kicked: true, coderabbit: needCoderabbit, qodo: needQodo };
}
async function findOpenPrForIssue(github, owner, repo, issueNumber) {
const prefix = `claude/issue-${issueNumber}-`;
const { data: prs } = await github.rest.pulls.list({
owner,
repo,
state: 'open',
sort: 'created',
direction: 'desc',
per_page: 30,
});
return (
prs.find(
(p) => (p.head?.ref || '').startsWith(prefix) && isBotOpenedPr(p)
) || null
);
}
async function kickReviewChainForIssue(github, owner, repo, issueNumber, core) {
const pr = await findOpenPrForIssue(github, owner, repo, issueNumber);
if (!pr) {
core?.info?.(`No open PR for issue #${issueNumber}, skipping review kick`);
return { kicked: false, reason: 'no_pr' };
}
const result = await kickReviewChain(github, owner, repo, pr.number, core);
return { ...result, prNumber: pr.number };
}
async function recoverStalledBotPrs(github, owner, repo, options, core) {
const { prNumber = null, minAgeMs = 10 * 60 * 1000, maxRecover = 10 } = options || {};
let prs;
if (prNumber) {
const { data } = await github.rest.pulls.get({ owner, repo, pull_number: prNumber });
prs = [data];
} else if (typeof github.paginate === 'function') {
prs = await github.paginate(github.rest.pulls.list, {
owner,
repo,
state: 'open',
per_page: 100,
});
} else {
const { data } = await github.rest.pulls.list({ owner, repo, state: 'open', per_page: 100 });
prs = data;
}
const cutoff = Date.now() - minAgeMs;
let recovered = 0;
for (const pr of prs) {
if (recovered >= maxRecover) break;
if (!isBotOpenedPr(pr)) continue;
if (!prNumber && new Date(pr.created_at).getTime() > cutoff) continue;
const comments = await listAllComments(github, owner, repo, pr.number);
if (chainKickPosted(comments)) continue;
await kickReviewChain(github, owner, repo, pr.number, core, comments);
recovered += 1;
}
core?.info?.(`Recovery complete (${recovered} PR(s) kicked)`);
return recovered;
}
module.exports = {
KICK_AUTHORS,
BOT_PR_AUTHORS,
listAllComments,
chainKickPosted,
coderabbitKickPosted,
qodoKickPosted,
isBotOpenedPr,
kickReviewChain,
findOpenPrForIssue,
kickReviewChainForIssue,
recoverStalledBotPrs,
};
@@ -0,0 +1,35 @@
#!/usr/bin/env node
/**
* Run: node .github/scripts/ci-failure-claude-selftest.js
*/
const ciFix = require('./ci-failure-claude.js');
const mergeGate = require('./merge-gate.js');
const config = require('./gate-config.js');
let failed = 0;
function assert(name, cond) {
if (!cond) {
console.error('FAIL:', name);
failed += 1;
} else {
console.log('ok:', name);
}
}
const LOG = `
python UNKNOWN STEP FAILED (0.0100s) tests/unit/test_example.py::test_foo - AssertionError: bar
python UNKNOWN STEP ##[error]Process completed with exit code 1.
`;
const parsed = ciFix.parsePytestFailures(LOG);
assert('parses pytest failure', parsed.length === 1);
assert('uses CI workflow list', config.ciFailureWorkflowRuns.includes('CI'));
const comment = ciFix.buildCiFixComment({
headSha: 'abc1234567890abcdef1234567890abcdef12',
failedChecks: [{ name: 'python', workflow: 'CI', html_url: 'https://example.com/job/1' }],
failureSummaries: [{ jobName: 'python', failures: parsed }],
});
assert('comment mentions product guardrails', comment.includes('Product guardrails'));
process.exit(failed ? 1 : 0);
+294
View File
@@ -0,0 +1,294 @@
/**
* Post @claude comments with CI failure details for internal PRs.
* @see .github/workflows/ci-failure-claude.yml
*/
const mergeGate = require('./merge-gate.js');
const config = require('./gate-config.js');
const CI_FIX_LABEL = 'claude-ci-fix-pending';
const COOLDOWN_MS = 12 * 60 * 60 * 1000;
const AUTO_ACTORS = mergeGate.AUTO_ACTORS;
const MAX_FAILURES = 15;
const MAX_JOBS_TO_FETCH = 5;
function shortSha(headSha) {
return (headSha || '').slice(0, 8);
}
function ciFixShaMarker(headSha) {
return `ci failed on head \`${shortSha(headSha)}`.toLowerCase();
}
function isCiFixComment(comment) {
const body = (comment.body || '').toLowerCase();
return body.includes('@claude') && body.includes('ci failed on head');
}
function hasCiFixCommentForSha(comments, headSha) {
const marker = shortSha(headSha).toLowerCase();
return comments.some((c) => {
if (!AUTO_ACTORS.includes(c.user.login)) return false;
if (!isCiFixComment(c)) return false;
return (c.body || '').toLowerCase().includes(marker);
});
}
function hasRecentCiFixComment(comments, headSha) {
const cutoff = Date.now() - COOLDOWN_MS;
const marker = shortSha(headSha).toLowerCase();
return comments.some((c) => {
if (!AUTO_ACTORS.includes(c.user.login)) return false;
if (!isCiFixComment(c)) return false;
const body = (c.body || '').toLowerCase();
if (body.includes(marker)) return true;
return new Date(c.created_at).getTime() > cutoff;
});
}
function shouldSkipCiFix({ comments, headSha, labels, hasFinal, claudeInProgress, failedChecks }) {
if (!failedChecks.length) return { skip: true, reason: 'no failed checks' };
if (labels.includes(CI_FIX_LABEL)) return { skip: true, reason: 'ci fix pending' };
if (!hasFinal) return { skip: true, reason: 'awaiting final claude review trigger' };
if (claudeInProgress) return { skip: true, reason: 'claude in progress' };
if (hasCiFixCommentForSha(comments, headSha)) {
return { skip: true, reason: 'already commented for this sha' };
}
if (hasRecentCiFixComment(comments, headSha)) {
return { skip: true, reason: 'recent ci fix comment cooldown' };
}
return { skip: false };
}
function parsePytestFailures(logText) {
if (!logText) return [];
const lines = logText.split('\n');
const failures = [];
const seen = new Set();
for (let i = lines.length - 1; i >= 0 && failures.length < MAX_FAILURES; i -= 1) {
const line = lines[i].trim();
if (!line) continue;
const pytestMatch = line.match(
/FAILED\s*(?:\([^)]+\))?\s+(tests\/[^\s]+(?:::[^\s-]+)*)\s*-\s*(.+)/i
);
if (pytestMatch) {
const testId = pytestMatch[1];
if (!seen.has(testId)) {
seen.add(testId);
failures.push({ testId, error: pytestMatch[2].trim() });
}
continue;
}
const errorMatch = line.match(/##\[error\](.+)/);
if (errorMatch && /tests\//.test(errorMatch[1])) {
const msg = errorMatch[1].trim();
if (!seen.has(msg)) {
seen.add(msg);
failures.push({ testId: msg, error: msg });
}
}
}
return failures;
}
async function fetchJobFailureSummary(github, owner, repo, jobId, jobName) {
try {
const response = await github.rest.actions.downloadJobLogsForWorkflowRun({
owner,
repo,
job_id: jobId,
});
let logText = typeof response.data === 'string' ? response.data : String(response.data || '');
if (logText.length > 100000) {
logText = logText.slice(-100000);
}
return { jobName, jobId, failures: parsePytestFailures(logText) };
} catch (err) {
return { jobName, jobId, failures: [], error: err.message };
}
}
function mapFailedChecks(runs) {
return runs.map((run) => ({
name: run.name,
id: run.id,
html_url: run.html_url || run.details_url,
workflow: run.app?.slug || run.check_suite?.app?.slug || run.name,
}));
}
function buildCiFixComment({ headSha, failedChecks, failureSummaries }) {
const parts = [
`@claude CI failed on HEAD \`${shortSha(headSha)}\`. Please fix the failures below and push to this branch.`,
'',
'## Failed checks',
];
for (const check of failedChecks) {
parts.push(`- **${check.workflow || check.name}** / \`${check.name}\` — ${check.html_url}`);
}
parts.push('', '## Failures (extracted)');
let idx = 0;
for (const summary of failureSummaries) {
for (const failure of summary.failures) {
idx += 1;
if (idx > MAX_FAILURES) break;
parts.push(`${idx}. \`${failure.testId}\` — \`${failure.error}\``);
parts.push(` - Job: \`${summary.jobName}\``);
}
if (idx >= MAX_FAILURES) break;
}
if (idx === 0) {
parts.push('_(Could not extract pytest details — see job logs above.)_');
}
const exampleTest = failureSummaries.find((s) => s.failures.length)?.failures[0]?.testId;
parts.push(
'',
'## Critical review first',
'Before changing code or tests, decide **which side is wrong**:',
'1. **Legitimate feature change** — the PR intent is correct but implementation or tests need updating. Preserve product guarantees; update tests only when behaviour intentionally changed and document why.',
'2. **Regression / bug in this PR** — the failure exposes a real breakage introduced here. Fix the implementation; **do not weaken, skip, or delete tests** just to go green.',
'3. **Pre-existing flake or unrelated failure** — say so explicitly; prefer fixing the root cause over masking it.',
'',
'**Product guardrails (AGENTS.md):**',
'- Do not disturb PraisonAIUI contracts or frontend/backend layering to accommodate scope creep.',
'- Tests must continue to guard backward compatibility and UX behaviour — passing CI by lowering test standards is not acceptable.',
'- If the feature does not genuinely add product value, recommend reverting or narrowing scope instead of patching around failures.',
'',
'## What to do',
'1. State your verdict: **legitimate fix**, **regression fix**, or **needs human review** — and why (1–3 sentences).',
'2. Fix root cause with **minimal changes**; never bloat the Agent class with extra params.',
exampleTest
? `3. Run failing tests locally, e.g. \`pytest ${exampleTest} -q\`, plus any related tests touched by the PR.`
: '3. Run failing tests locally with targeted pytest, plus any related tests touched by the PR.',
'4. Push to this branch and comment: files changed, review verdict, and why tests still protect product behaviour.',
);
return parts.join('\n');
}
async function maybeClearCiFixLabel(github, owner, repo, prNumber, labels, headSha, core) {
if (!labels.includes(CI_FIX_LABEL)) return false;
const ciGreen = await mergeGate.allChecksGreenOnSha(github, owner, repo, headSha, core);
if (!ciGreen) return false;
try {
await github.rest.issues.removeLabel({
owner,
repo,
issue_number: prNumber,
name: CI_FIX_LABEL,
});
core?.info?.(`Removed ${CI_FIX_LABEL} from PR #${prNumber}`);
} catch (err) {
if (err.status !== 404) throw err;
}
return true;
}
async function maybeTriggerCiFixClaude(github, owner, repo, prNumber, core, opts = {}) {
const baseRepo = `${owner}/${repo}`;
const ctx = await mergeGate.loadPrContext(github, owner, repo, prNumber);
const pr = ctx.pr;
if (pr.state !== 'open') return { skipped: true, reason: 'not open' };
if (pr.draft) return { skipped: true, reason: 'draft' };
const headRepo = pr.head.repo?.full_name;
if (headRepo && headRepo !== baseRepo && !pr.maintainer_can_modify) {
return { skipped: true, reason: 'fork without maintainer edits' };
}
const headSha = pr.head.sha;
const labels = ctx.labels;
if (await maybeClearCiFixLabel(github, owner, repo, prNumber, labels, headSha, core)) {
return { skipped: true, reason: 'ci green, label cleared' };
}
const ciGreen = await mergeGate.allChecksGreenOnSha(github, owner, repo, headSha, core);
if (ciGreen) return { skipped: true, reason: 'ci green' };
const runs = await mergeGate.listChecksOnSha(github, owner, repo, headSha);
const failedRuns = mergeGate.listFailedChecksOnSha(runs);
if (failedRuns.length === 0) {
return { skipped: true, reason: 'no failed checks yet (may be pending)' };
}
const hasFinal = mergeGate.hasFinalClaudeReviewTrigger(ctx.comments);
const claudeInProgress = await mergeGate.hasInProgressClaudeAssistant(
github, owner, repo, prNumber
);
const skip = shouldSkipCiFix({
comments: ctx.comments,
headSha,
labels,
hasFinal,
claudeInProgress,
failedChecks: failedRuns,
});
if (skip.skip) return { skipped: true, reason: skip.reason };
const failedChecks = mapFailedChecks(failedRuns);
const failureSummaries = [];
for (const check of failedChecks.slice(0, MAX_JOBS_TO_FETCH)) {
failureSummaries.push(
await fetchJobFailureSummary(github, owner, repo, check.id, check.name)
);
}
const body = buildCiFixComment({ headSha, failedChecks, failureSummaries });
await github.rest.issues.addLabels({
owner,
repo,
issue_number: prNumber,
labels: [CI_FIX_LABEL],
});
await github.rest.issues.createComment({
owner,
repo,
issue_number: prNumber,
body,
});
core?.info?.(`Posted CI fix @claude on PR #${prNumber} (${shortSha(headSha)})`);
return { triggered: true, headSha: shortSha(headSha) };
}
async function processWorkflowRunFailure(github, owner, repo, workflowRun, core) {
if (workflowRun.conclusion !== 'failure') {
return { skipped: true, reason: `conclusion=${workflowRun.conclusion}` };
}
const allowed = config.ciFailureWorkflowRuns;
if (!allowed.includes(workflowRun.name)) {
return { skipped: true, reason: `workflow ${workflowRun.name}` };
}
const prNumber = await mergeGate.resolvePrNumberFromWorkflowRun(
github, owner, repo, workflowRun
);
if (!prNumber) return { skipped: true, reason: 'no linked PR' };
return maybeTriggerCiFixClaude(github, owner, repo, prNumber, core);
}
module.exports = {
CI_FIX_LABEL,
COOLDOWN_MS,
ciFixShaMarker,
isCiFixComment,
hasCiFixCommentForSha,
hasRecentCiFixComment,
shouldSkipCiFix,
parsePytestFailures,
fetchJobFailureSummary,
buildCiFixComment,
maybeClearCiFixLabel,
maybeTriggerCiFixClaude,
processWorkflowRunFailure,
};
+32
View File
@@ -0,0 +1,32 @@
/**
* PraisonAIUI gate configuration — imported by merge-gate, release-gate, ci-failure-claude.
*/
module.exports = {
repoFullName: 'MervinPraison/PraisonAIUI',
productPathPrefixes: ['src/praisonaiui/', 'src/frontend/', 'tests/'],
sensitivePathPatterns: [
/^\.github\/workflows\//,
/^pyproject\.toml$/,
/credentials\.json$/i,
],
requiredCheckPatterns: [/^ci$/i, /python/i, /test/i, /lint/i, /ruff/i],
ciWorkflowFile: 'ci.yml',
ciWorkflowName: 'CI',
mergeGateWorkflowRuns: ['CI', 'Claude Assistant'],
ciFailureWorkflowRuns: ['CI'],
pypiPackageName: 'aiui',
packagePaths: ['src/praisonaiui', 'src/frontend', 'pyproject.toml'],
finalClaudeScope:
'SCOPE: Focus ONLY on PraisonAIUI (src/praisonaiui, src/frontend, tests, docs). '
+ 'Do NOT expand into praisonaiagents or the monorepo unless the PR explicitly requires it.',
finalClaudeProductValue:
'4. Product value: review whether the change genuinely adds PraisonAIUI UX value and correct '
+ 'layering (UI/frontend here; core agent logic belongs in praisonaiagents). Reject scope creep.',
agentPyChecks: false,
reviewBotLogins: [
'coderabbitai[bot]',
'qodo-code-review[bot]',
'greptile-apps[bot]',
],
};
+234
View File
@@ -0,0 +1,234 @@
#!/usr/bin/env node
/**
* Local self-test for merge-gate.js heuristics (no GitHub API).
* Run: node .github/scripts/merge-gate-selftest.js
*/
const mg = require('./merge-gate.js');
let failed = 0;
function assert(name, cond) {
if (!cond) {
console.error('FAIL:', name);
failed++;
} else {
console.log('ok:', name);
}
}
// Stale FINAL: push after FINAL, no @claude since head
const finals = [
{ user: { login: 'github-actions[bot]' }, body: '@claude FINAL architecture reviewer', created_at: '2026-06-12T08:00:00Z' },
];
assert('stale when head after final', mg.isStaleFinalAfterPush(finals, '2026-06-12T09:00:00Z'));
const withRecovery = [
...finals,
{ user: { login: 'github-actions[bot]' }, body: '@claude FINAL architecture reviewer', created_at: '2026-06-12T09:30:00Z' },
];
assert('not stale when @claude after head', !mg.isStaleFinalAfterPush(withRecovery, '2026-06-12T09:00:00Z'));
const withClaudeReply = [
...finals,
{
user: { login: 'praisonai-triage-agent[bot]' },
body: "**Claude finished @MervinPraison's task** —— [View job](https://github.com/)",
created_at: '2026-06-12T08:30:00Z',
},
];
assert('not stale when Claude replied after FINAL', !mg.isStaleFinalAfterPush(withClaudeReply, '2026-06-12T09:00:00Z'));
assert('claude final reply detected', mg.isClaudeFinalReplyComment(withClaudeReply[1]));
assert('cancelled detect-and-trigger does not block', mg.OPTIONAL_CANCELLED_CHECKS.has('detect-and-trigger'));
// Stale-FINAL recovery guards (PR #2560 push loop)
const nowMs = Date.now();
const iso = (ms) => new Date(ms).toISOString();
const firstFinal = {
user: { login: 'MervinPraison' },
body: '@claude You are the FINAL architecture reviewer.',
created_at: iso(nowMs - 20 * 60 * 1000),
};
const pushSoonAfter = mg.shouldSkipStaleFinalRecovery(
[firstFinal],
iso(nowMs - 16 * 60 * 1000)
);
assert('debounce push soon after FINAL', pushSoonAfter.skip && pushSoonAfter.reason.includes('soon after'));
const secondFinal = {
user: { login: 'MervinPraison' },
body: '@claude You are the FINAL architecture reviewer.',
created_at: iso(nowMs - 20 * 60 * 1000),
};
const capped = mg.shouldSkipStaleFinalRecovery(
[firstFinal, secondFinal],
iso(nowMs - 2 * 60 * 1000)
);
assert('hourly cap blocks third FINAL in window', capped.skip && capped.reason.includes('capped'));
const botPush = mg.shouldSkipStaleFinalRecovery(
[firstFinal],
iso(nowMs - 16 * 60 * 1000),
'praisonai-triage-agent[bot]'
);
assert('skip when automation pushed head', botPush.skip && botPush.reason.includes('automation'));
assert('claude automation login', mg.isClaudeAutomationLogin('praisonai-triage-agent[bot]'));
// Bot CHANGES_REQUESTED then APPROVE
const reviews = [
{ user: { login: 'coderabbit[bot]', type: 'Bot' }, state: 'CHANGES_REQUESTED', submitted_at: '2026-06-12T08:00:00Z' },
{ user: { login: 'coderabbit[bot]', type: 'Bot' }, state: 'APPROVED', submitted_at: '2026-06-12T09:00:00Z' },
];
assert('bot approve clears CR', !mg.hasAnyChangesRequested(reviews));
assert('human CR blocks', mg.hasAnyChangesRequested([
{ user: { login: 'MervinPraison', type: 'User' }, state: 'CHANGES_REQUESTED', submitted_at: '2026-06-12T09:00:00Z' },
]));
// Verdict after HEAD
const verdictComments = [
{ body: 'MERGE_GATE_VERDICT: APPROVE', created_at: '2026-06-12T08:00:00Z' },
];
assert('verdict before head rejected', mg.findMergeGateVerdict(verdictComments, null, '2026-06-12T09:00:00Z') === null);
assert('verdict after head accepted', mg.findMergeGateVerdict(
[{ body: 'MERGE_GATE_VERDICT: APPROVE', created_at: '2026-06-12T10:00:00Z' }],
null,
'2026-06-12T09:00:00Z'
) === 'APPROVE');
const fallbackApprove = [
{
body: 'MERGE_GATE_VERDICT: APPROVE\n\nAutomated fallback — Claude assess did not post a verdict comment.',
created_at: '2026-06-12T10:00:00Z',
},
];
assert(
'automated fallback APPROVE ignored when Opus required',
mg.findMergeGateVerdict(fallbackApprove, null, '2026-06-12T09:00:00Z', { excludeAutomatedFallback: true }) === null
);
assert(
'automated fallback APPROVE still visible without Opus-only filter',
mg.findMergeGateVerdict(fallbackApprove, null, '2026-06-12T09:00:00Z') === 'APPROVE'
);
const fallbackBlockNewFormat = [
{
body: 'MERGE_GATE_VERDICT: BLOCK\n\nAutomated fallback — Opus merge gate assessment did not complete.',
created_at: '2026-06-12T10:00:00Z',
},
];
assert(
'new-format fallback BLOCK ignored when Opus required',
mg.findMergeGateVerdict(fallbackBlockNewFormat, null, '2026-06-12T09:00:00Z', { excludeAutomatedFallback: true }) === null
);
assert(
'new-format fallback BLOCK detected by marker helper',
mg.isAutomatedFallbackVerdict(fallbackBlockNewFormat[0].body)
);
assert(
'findMergeGateVerdict null-safe with explicit null options',
mg.findMergeGateVerdict(fallbackApprove, null, '2026-06-12T09:00:00Z', null) === 'APPROVE'
);
const noise = [{ user: { login: 'MervinPraison' }, body: '**Merge gate scan** — wait for `@claude`', created_at: new Date().toISOString() }];
assert('diagnostic comment not a trigger', !mg.hasRecentClaudeTrigger(noise, 35));
// Cooldown skip requires an actual verdict on HEAD, not just a FINAL trigger comment
const recentFinalOnHead = [
{
user: { login: 'MervinPraison' },
body: '@claude You are the FINAL architecture reviewer.',
created_at: '2026-06-27T10:00:00Z',
},
];
assert(
'final trigger alone does not count as verdict on head',
mg.findMergeGateVerdict(recentFinalOnHead, null, '2026-06-27T09:55:00Z') === null
);
const recentVerdictOnHead = [
...recentFinalOnHead,
{
user: { login: 'github-actions[bot]' },
body: 'MERGE_GATE_VERDICT: APPROVE',
created_at: '2026-06-27T10:05:00Z',
},
];
assert(
'verdict on head skips cooldown gate',
mg.findMergeGateVerdict(recentVerdictOnHead, null, '2026-06-27T09:55:00Z') === 'APPROVE'
);
// Sensitive + secrets
assert('workflow path sensitive', mg.sensitivePathReasons([{ filename: '.github/workflows/foo.yml' }]).length === 1);
assert('ci-only label exempts workflows', mg.sensitivePathReasons(
[{ filename: '.github/workflows/foo.yml' }],
[mg.WORKFLOW_ONLY_LABEL]
).length === 0);
assert('ci-only label not exempt mixed changes', mg.sensitivePathReasons(
[{ filename: '.github/workflows/foo.yml' }, { filename: 'src/praisonaiui/foo.py' }],
[mg.WORKFLOW_ONLY_LABEL]
).length === 1);
assert('secret in patch', mg.secretScanReasons([{ filename: 'x.py', patch: '+key = "ghp_abcdefghijklmnopqrstuvwxyz1234567890"' }]).length === 1);
// Claude run scoping — other PR branches must not block
assert('other branch claude does not block', !mg.hasBlockingClaudeRunForPr(
[{ event: 'issue_comment', head_branch: 'other-branch' }],
'my-branch'
));
assert('same branch claude blocks', mg.hasBlockingClaudeRunForPr(
[{ event: 'issue_comment', head_branch: 'my-branch' }],
'my-branch'
));
assert('issues event never blocks', !mg.hasBlockingClaudeRunForPr(
[{ event: 'issues', head_branch: 'my-branch' }],
'my-branch'
));
// Conflict rebase clears after bot completion + FINAL on HEAD (within 12h cooldown)
const conflictNowMs = Date.now();
const conflictIso = (offsetMs) => new Date(conflictNowMs + offsetMs).toISOString();
const conflictTrigger = {
user: { login: 'MervinPraison' },
body: '@claude this PR has merge conflicts with main. Please rebase',
created_at: conflictIso(-30 * 60 * 1000),
};
const rebaseDone = {
user: { login: 'praisonai-triage-agent[bot]' },
body: 'Rebase complete — PR #2308 onto latest main',
created_at: conflictIso(-29 * 60 * 1000),
};
const finalAfterRebase = {
user: { login: 'MervinPraison' },
body: '@claude You are the FINAL architecture reviewer.',
created_at: conflictIso(-20 * 60 * 1000),
};
const headAfterRebase = conflictIso(-21 * 60 * 1000);
const rebaseComments = [conflictTrigger, rebaseDone, finalAfterRebase];
assert('conflict blocks before rebase done', mg.hasRecentConflictComment([conflictTrigger], headAfterRebase));
assert('conflict clears after rebase + FINAL on HEAD', !mg.hasRecentConflictComment(rebaseComments, headAfterRebase));
assert('conflict still blocks without FINAL on HEAD', mg.hasRecentConflictComment(
[conflictTrigger, rebaseDone],
headAfterRebase
));
// Tests heuristic
assert('product code without tests', mg.missingTestsReason([{ filename: 'src/praisonaiui/a/b.py', additions: 3 }]) !== null);
assert('product code with tests ok', mg.missingTestsReason([
{ filename: 'src/praisonaiui/a/b.py', additions: 3 },
{ filename: 'tests/test_x.py', additions: 10 },
]) === null);
// PR size
assert('large PR blocked', mg.prSizeReasons([{ additions: 900 }]).length > 0);
assert('internal PR link accepted', mg.isInternalPullRequestLink(
{ base: { repo: { full_name: 'MervinPraison/PraisonAIUI' } } },
'MervinPraison',
'PraisonAIUI'
));
assert('fork sync PR link rejected', !mg.isInternalPullRequestLink(
{ number: 21, base: { repo: { full_name: 'Milkmange/PraisonAIUI' } } },
'MervinPraison',
'PraisonAIUI'
));
process.exit(failed ? 1 : 0);
+900
View File
@@ -0,0 +1,900 @@
/**
* Shared merge-gate helpers for claude-merge-gate.yml
* @see .github/workflows/claude-merge-gate.yml
*/
const config = require('./gate-config.js');
const CLAUDE_TRIGGER_LOGINS = ['MervinPraison', 'github-actions[bot]'];
const AUTO_ACTORS = CLAUDE_TRIGGER_LOGINS;
const CONFLICT_COOLDOWN_MS = 12 * 60 * 60 * 1000;
const CLAUDE_ACTIVE_MS = 35 * 60 * 1000;
const STALE_FINAL_RECOVERY_WINDOW_MS = 60 * 60 * 1000;
const STALE_FINAL_MAX_PER_WINDOW = 2;
const PUSH_AFTER_FINAL_DEBOUNCE_MS = 15 * 60 * 1000;
const ALLOWED_MERGE_STATES = new Set(['CLEAN', 'UNSTABLE']);
const AGENT_PY_MAX_AUTO_LINES = 100;
const PR_MAX_AUTO_ADDITIONS = 800;
const PR_MAX_AUTO_FILES = 30;
const MANUAL_ONLY_LABELS = new Set(['security', 'breaking-change', 'needs-manual-review', 'release']);
const WORKFLOW_ONLY_LABEL = 'merge-gate-ci-only';
const CI_ONLY_PATH_PREFIXES = ['.github/workflows/', '.github/actions/', '.github/scripts/merge-gate'];
const SDK_PATH_PREFIXES = config.productPathPrefixes;
const SENSITIVE_PATH_PATTERNS = config.sensitivePathPatterns;
const REQUIRED_SDK_CHECK_PATTERNS = config.requiredCheckPatterns;
const SECRET_PATTERNS = [
/sk-[a-zA-Z0-9]{20,}/,
/AKIA[0-9A-Z]{16}/,
/ghp_[a-zA-Z0-9]{36,}/,
/github_pat_/,
/Bearer eyJ[a-zA-Z0-9_-]+\.[a-zA-Z0-9_-]+/,
/-----BEGIN (RSA |EC |OPENSSH )?PRIVATE KEY-----/,
];
const BLOCK_LABELS = new Set([
'claude-conflict-pending',
'claude-merge-gate-active',
'no-auto-merge',
'auto-merged-by-gate',
]);
const MERGE_READY_LABEL = 'pipeline/merge-ready';
/** Superseded concurrency runs; must not block merge when real tests passed. */
const OPTIONAL_CANCELLED_CHECKS = new Set(['detect-and-trigger']);
const BOT_REVIEWER_PATTERNS = [
'coderabbit',
'qodo',
'gemini',
'copilot',
'greptile',
];
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
function isFinalClaudeTriggerComment(c) {
const body = (c.body || '').toLowerCase();
if (!AUTO_ACTORS.includes(c.user.login)) return false;
if (!body.includes('@claude')) return false;
if (body.includes('merge conflict')) return false;
return body.includes('final architecture reviewer') || body.includes('lead engineer');
}
function isClaudeTriggerNoise(c) {
const body = c.body || '';
if (body.includes('Merge gate scan')) return true;
if (body.includes('MERGE_GATE_VERDICT')) return true;
if (body.includes('Merged by **Claude PR merge gate**')) return true;
return false;
}
function isClaudeFinalReplyComment(c) {
const login = (c.user?.login || '').toLowerCase();
if (!login.includes('praisonai-triage')) return false;
return (c.body || '').includes('Claude finished');
}
function hasRecentClaudeTrigger(comments, minutes = 35) {
const cutoff = Date.now() - minutes * 60 * 1000;
return comments.some((c) => {
if (!CLAUDE_TRIGGER_LOGINS.includes(c.user.login)) return false;
if (isClaudeTriggerNoise(c)) return false;
if (!(c.body || '').includes('@claude')) return false;
return new Date(c.created_at).getTime() > cutoff;
});
}
function isConflictRebaseTriggerComment(c) {
if (!AUTO_ACTORS.includes(c.user.login)) return false;
const body = (c.body || '').toLowerCase();
return body.includes('@claude') && body.includes('merge conflict');
}
function isConflictRebaseCompletionComment(c) {
const login = (c.user?.login || '').toLowerCase();
if (!login.includes('praisonai-triage') && !login.includes('github-actions')) return false;
const body = (c.body || '').toLowerCase();
return (
body.includes('rebase complete') ||
body.includes('rebase onto') ||
(body.includes('conflict') && body.includes('resolved'))
);
}
function conflictRebaseQuiescent(comments, headPushedAt) {
const conflictTriggers = comments.filter(isConflictRebaseTriggerComment);
if (conflictTriggers.length === 0) return true;
const latestConflict = conflictTriggers.reduce((a, b) =>
new Date(a.created_at) > new Date(b.created_at) ? a : b
);
const conflictTime = new Date(latestConflict.created_at).getTime();
const rebaseDone = comments.some(
(c) =>
new Date(c.created_at).getTime() > conflictTime && isConflictRebaseCompletionComment(c)
);
if (!rebaseDone) return false;
return finalClaudeCompletedOnSha(comments, headPushedAt);
}
function hasRecentConflictComment(comments, headPushedAt = null) {
const cutoff = Date.now() - CONFLICT_COOLDOWN_MS;
const hasRecentTrigger = comments.some((c) => {
if (!isConflictRebaseTriggerComment(c)) return false;
return new Date(c.created_at).getTime() > cutoff;
});
if (!hasRecentTrigger) return false;
if (headPushedAt && conflictRebaseQuiescent(comments, headPushedAt)) {
return false;
}
return true;
}
function isBotReviewer(login, userType) {
const lower = (login || '').toLowerCase();
if (userType === 'Bot') return true;
if (lower.endsWith('[bot]')) return true;
return ['coderabbit', 'qodo', 'gemini', 'copilot', 'greptile'].some((p) => lower.includes(p));
}
function latestReviewsByUser(reviews) {
const latestByUser = new Map();
for (const r of reviews) {
const login = r.user?.login;
if (!login) continue;
const prev = latestByUser.get(login);
if (!prev || new Date(r.submitted_at) > new Date(prev.submitted_at)) {
latestByUser.set(login, r);
}
}
return latestByUser;
}
function hasHumanChangesRequested(reviews) {
for (const [login, review] of latestReviewsByUser(reviews)) {
if (review.state !== 'CHANGES_REQUESTED') continue;
if (!isBotReviewer(login, review.user?.type)) return true;
}
return false;
}
function hasAnyChangesRequested(reviews) {
for (const [, review] of latestReviewsByUser(reviews)) {
if (review.state === 'CHANGES_REQUESTED') return true;
}
return false;
}
function hasFinalClaudeReviewTrigger(comments) {
return comments.some(isFinalClaudeTriggerComment);
}
function isStaleFinalAfterPush(comments, headPushedAt) {
if (!headPushedAt) return false;
const headTime = new Date(headPushedAt).getTime();
const finals = comments.filter(isFinalClaudeTriggerComment);
if (finals.length === 0) return true;
const latestFinal = finals.reduce((a, b) =>
new Date(a.created_at) > new Date(b.created_at) ? a : b
);
const finalTime = new Date(latestFinal.created_at).getTime();
if (headTime <= finalTime + 60000) return false;
const claudeRepliedAfterFinal = comments.some((c) => {
if (!isClaudeFinalReplyComment(c)) return false;
return new Date(c.created_at).getTime() >= finalTime - 60000;
});
if (claudeRepliedAfterFinal) return false;
const claudeSinceHead = comments.some((c) => {
if (!CLAUDE_TRIGGER_LOGINS.includes(c.user.login)) return false;
if (isClaudeTriggerNoise(c)) return false;
if (!(c.body || '').includes('@claude')) return false;
return new Date(c.created_at).getTime() >= headTime - 60000;
});
return !claudeSinceHead;
}
function needsStaleFinalRecovery(comments, headPushedAt) {
return (
hasFinalClaudeReviewTrigger(comments) &&
isStaleFinalAfterPush(comments, headPushedAt)
);
}
function shouldSkipFinalRecovery(comments, headPushedAt) {
const isStale = isStaleFinalAfterPush(comments, headPushedAt);
if (isStale) return false;
return hasRecentClaudeTrigger(comments, 35);
}
function countFinalTriggersSince(comments, sinceMs) {
return comments.filter(
(c) => isFinalClaudeTriggerComment(c) && new Date(c.created_at).getTime() > sinceMs
).length;
}
function isClaudeAutomationLogin(login) {
const lower = (login || '').toLowerCase();
return lower.includes('praisonai-triage') || lower === 'github-actions[bot]';
}
function isPushSoonAfterLatestFinal(comments, headPushedAt) {
if (!headPushedAt) return false;
const finals = comments.filter(isFinalClaudeTriggerComment);
if (finals.length === 0) return false;
const latestFinal = finals.reduce((a, b) =>
new Date(a.created_at) > new Date(b.created_at) ? a : b
);
const finalTime = new Date(latestFinal.created_at).getTime();
const headTime = new Date(headPushedAt).getTime();
if (headTime <= finalTime) return false;
return headTime - finalTime < PUSH_AFTER_FINAL_DEBOUNCE_MS;
}
/** Returns { skip: true, reason } when stale-FINAL recovery should not post. */
function shouldSkipStaleFinalRecovery(comments, headPushedAt, headPusherLogin = null) {
if (!isStaleFinalAfterPush(comments, headPushedAt)) {
return { skip: true, reason: 'not stale' };
}
if (headPusherLogin && isClaudeAutomationLogin(headPusherLogin)) {
return { skip: true, reason: 'head pushed by Claude automation' };
}
if (isPushSoonAfterLatestFinal(comments, headPushedAt)) {
return {
skip: true,
reason: 'head pushed soon after FINAL (wait for CI / batched fixes)',
};
}
const windowStart = Date.now() - STALE_FINAL_RECOVERY_WINDOW_MS;
const finalsInWindow = countFinalTriggersSince(comments, windowStart);
if (finalsInWindow >= STALE_FINAL_MAX_PER_WINDOW) {
return {
skip: true,
reason: `stale-FINAL capped (${finalsInWindow} FINAL triggers in last hour)`,
};
}
return { skip: false, reason: '' };
}
function finalClaudeCompletedOnSha(comments, headPushedAt) {
if (!hasFinalClaudeReviewTrigger(comments)) return false;
if (isStaleFinalAfterPush(comments, headPushedAt)) return false;
return true;
}
const FINAL_CLAUDE_REVIEW_BODY =
`@claude You are the FINAL architecture reviewer. If the branch is under ${config.repoFullName} (not a fork), you are able to make modifications to this branch and push directly. ${config.finalClaudeScope} Read ALL comments above from Gemini, Qodo, CodeRabbit, and Copilot carefully before responding.\n\n**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)\n${config.finalClaudeProductValue}\n\n**Phase 2: FIX Valid Issues**\n5. For any VALID bugs or architectural flaws found by Gemini, CodeRabbit, Qodo, Copilot, or any other reviewer: implement the fix\n6. Push all code fixes directly to THIS branch (do NOT create a new PR)\n7. Comment a summary of exact files modified and what you skipped\n\n**Phase 3: Final Verdict**\n8. If all issues are resolved, approve the PR / close the Issue\n9. If blocking issues remain, request changes / leave clear action items`;
async function getMergeState(github, owner, repo, prNumber) {
const query = `
query($owner: String!, $repo: String!, $number: Int!) {
repository(owner: $owner, name: $repo) {
pullRequest(number: $number) {
mergeStateStatus
maintainerCanModify
isDraft
headRefOid
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,
headSha: prGql.headRefOid,
maintainerCanModify: prGql.maintainerCanModify === true,
};
}
if (attempt < 2) await sleep(10000);
}
const { data: pr } = await github.rest.pulls.get({ owner, repo, pull_number: prNumber });
return {
status: (pr.mergeable_state || '').toUpperCase(),
isDraft: pr.draft,
headRepo: pr.head.repo?.full_name,
headSha: pr.head.sha,
maintainerCanModify: pr.maintainer_can_modify === true,
};
}
async function listChecksOnSha(github, owner, repo, sha) {
const { data } = await github.rest.checks.listForRef({
owner,
repo,
ref: sha,
per_page: 100,
});
return (data.check_runs || []).filter((r) => r.head_sha === sha);
}
function listFailedChecksOnSha(runs) {
return (runs || []).filter((run) => {
if (run.status !== 'completed') return false;
return run.conclusion === 'failure';
});
}
async function allChecksGreenOnSha(github, owner, repo, sha, core) {
const runs = await listChecksOnSha(github, owner, repo, sha);
if (runs.length === 0) {
core?.info?.(`No check runs on ${sha.slice(0, 7)} — allowing (e.g. docs-only PR)`);
return true;
}
for (const run of runs) {
if (run.status !== 'completed') {
core?.info?.(`Check pending: ${run.name} (${run.status})`);
return false;
}
const ok =
['success', 'neutral', 'skipped'].includes(run.conclusion) ||
(run.conclusion === 'cancelled' && OPTIONAL_CANCELLED_CHECKS.has(run.name));
if (!ok) {
core?.info?.(`Check failed: ${run.name} (${run.conclusion})`);
return false;
}
}
return true;
}
function claudeRunBlocksPr(run, headRef) {
if (!run || run.event === 'issues') return false;
if (!headRef) return true;
return run.head_branch === headRef;
}
function hasBlockingClaudeRunForPr(runs, headRef) {
return (runs || []).some((r) => claudeRunBlocksPr(r, headRef));
}
async function hasInProgressClaudeAssistant(github, owner, repo, prNumber = null) {
try {
const { data } = await github.rest.actions.listWorkflowRuns({
owner,
repo,
workflow_id: 'claude.yml',
status: 'in_progress',
per_page: 20,
});
const runs = (data.workflow_runs || []).filter((r) => r.event !== 'issues');
if (runs.length === 0) return false;
if (prNumber == null) return runs.length > 0;
const { data: pr } = await github.rest.pulls.get({ owner, repo, pull_number: prNumber });
const headRef = pr.head.ref;
return hasBlockingClaudeRunForPr(runs, headRef);
} catch {
return false;
}
}
function isCiOnlyChange(files) {
if (!files.length) return false;
return files.every((f) => CI_ONLY_PATH_PREFIXES.some((p) => f.filename.startsWith(p)));
}
function isInternalPullRequestLink(link, owner, repo) {
if (link?.base?.repo?.full_name === `${owner}/${repo}`) return true;
const baseUrl = link?.base?.repo?.url || '';
return baseUrl.endsWith(`/repos/${owner}/${repo}`);
}
async function resolvePrNumberFromLinkedPullRequests(github, owner, repo, linked) {
const repoFull = `${owner}/${repo}`;
const internal = (linked || []).filter((l) => isInternalPullRequestLink(l, owner, repo));
for (const link of internal) {
if (!link.number) continue;
try {
const { data } = await github.rest.pulls.get({
owner,
repo,
pull_number: link.number,
});
if (data.state === 'open' && data.base?.repo?.full_name === repoFull) {
return data.number;
}
} catch (err) {
if (err.status !== 404) throw err;
}
}
return null;
}
async function resolvePrNumberFromHeadBranch(github, owner, repo, branch) {
if (!branch || branch === 'main' || branch === 'master') return null;
const { data } = await github.rest.pulls.list({
owner,
repo,
state: 'open',
head: `${owner}:${branch}`,
per_page: 1,
});
return data[0]?.number || null;
}
async function resolvePrNumberFromWorkflowRun(github, owner, repo, workflowRun) {
const fromLinked = await resolvePrNumberFromLinkedPullRequests(
github, owner, repo, workflowRun.pull_requests
);
if (fromLinked) return fromLinked;
const fromBranch = await resolvePrNumberFromHeadBranch(
github, owner, repo, workflowRun.head_branch
);
if (fromBranch) return fromBranch;
return null;
}
async function listPullFiles(github, owner, repo, prNumber) {
if (typeof github.paginate === 'function') {
return github.paginate(github.rest.pulls.listFiles, {
owner,
repo,
pull_number: prNumber,
per_page: 100,
});
}
const { data } = await github.rest.pulls.listFiles({
owner,
repo,
pull_number: prNumber,
per_page: 100,
});
return data;
}
function countNewAgentParams(patch) {
if (!patch) return 0;
return patch
.split('\n')
.filter((l) => l.startsWith('+') && !l.startsWith('+++'))
.filter((l) => {
const t = l.slice(1).trim();
if (!t || t.startsWith('#') || t.startsWith('"""') || t.startsWith("'''")) return false;
if (/^(def|class|@|return\b|if\b|elif\b|else\b|for\b|while\b)/.test(t)) return false;
if (/^\w+\s*[:=]/.test(t) && !t.startsWith('self.')) return true;
return false;
}).length;
}
function getAgentPyChangeFromFiles(files) {
const agentFile = files.find((f) => f.filename.endsWith('praisonaiagents/agent/agent.py'));
if (!agentFile) {
return { touched: false, additions: 0, newParams: 0 };
}
return {
touched: true,
additions: agentFile.additions || 0,
newParams: countNewAgentParams(agentFile.patch || ''),
};
}
async function getAgentPyChange(github, owner, repo, prNumber) {
const files = await listPullFiles(github, owner, repo, prNumber);
return getAgentPyChangeFromFiles(files);
}
function touchesSdk(files) {
return files.some((f) => SDK_PATH_PREFIXES.some((p) => f.filename.startsWith(p)));
}
function hasManualOnlyLabel(labels) {
return labels.some((l) => MANUAL_ONLY_LABELS.has(l));
}
function sensitivePathReasons(files, labels = []) {
if (labels.includes(WORKFLOW_ONLY_LABEL) && isCiOnlyChange(files)) {
return [];
}
const reasons = [];
for (const f of files) {
if (SENSITIVE_PATH_PATTERNS.some((p) => p.test(f.filename))) {
reasons.push(`sensitive path: ${f.filename}`);
break;
}
}
return reasons;
}
function prSizeReasons(files) {
const reasons = [];
const totalAdditions = files.reduce((sum, f) => sum + (f.additions || 0), 0);
if (totalAdditions > PR_MAX_AUTO_ADDITIONS) {
reasons.push(`PR +${totalAdditions} lines (>${PR_MAX_AUTO_ADDITIONS}) requires manual review`);
}
if (files.length > PR_MAX_AUTO_FILES) {
reasons.push(`${files.length} files changed (>${PR_MAX_AUTO_FILES}) requires manual review`);
}
return reasons;
}
function missingTestsReason(files) {
const productAdds = files.filter(
(f) =>
SDK_PATH_PREFIXES.some((p) => f.filename.startsWith(p)) &&
f.filename.startsWith('src/praisonaiui/') &&
f.filename.endsWith('.py') &&
!f.filename.endsWith('__init__.py') &&
(f.additions || 0) > 0
);
if (productAdds.length === 0) return null;
const hasTestChange = files.some(
(f) => /\/tests?\//.test(f.filename) || /test_.*\.py$/.test(f.filename) || /_test\.py$/.test(f.filename)
);
if (!hasTestChange) return 'Product code added without test file changes — requires manual review';
return null;
}
function secretScanReasons(files) {
for (const f of files) {
if (/\/tests?\//.test(f.filename) || /test_.*\.py$/.test(f.filename)) continue;
const patch = f.patch || '';
if (!patch) continue;
for (const pattern of SECRET_PATTERNS) {
if (pattern.test(patch)) {
return [`possible secret in diff (${f.filename}) — requires manual review`];
}
}
}
return [];
}
async function sdkTestChecksReason(github, owner, repo, sha, files, core) {
if (!touchesSdk(files)) return null;
const { data } = await github.rest.checks.listForRef({
owner,
repo,
ref: sha,
per_page: 100,
});
const runs = (data.check_runs || []).filter((r) => r.head_sha === sha);
if (runs.length === 0) {
return 'SDK code changed but no CI checks on HEAD';
}
const testRuns = runs.filter((r) => REQUIRED_SDK_CHECK_PATTERNS.some((p) => p.test(r.name || '')));
if (testRuns.length === 0) {
return 'SDK code changed but no test check runs on HEAD';
}
core?.info?.(`SDK test checks on HEAD: ${testRuns.map((r) => r.name).join(', ')}`);
return null;
}
function manualReviewReasonForAgentPy(agentChange) {
if (!agentChange.touched) return null;
if (agentChange.additions > AGENT_PY_MAX_AUTO_LINES) {
return `agent.py +${agentChange.additions} lines (>${AGENT_PY_MAX_AUTO_LINES}) requires manual review`;
}
if (agentChange.newParams > 0) {
return `agent.py adds ${agentChange.newParams} new Agent param(s) — requires manual review`;
}
return null;
}
async function listAllComments(github, owner, repo, issueNumber) {
if (typeof github.paginate === 'function') {
return github.paginate(github.rest.issues.listComments, {
owner,
repo,
issue_number: issueNumber,
per_page: 100,
});
}
const { data } = await github.rest.issues.listComments({
owner,
repo,
issue_number: issueNumber,
per_page: 100,
});
return data;
}
async function getHeadCommitDate(github, owner, repo, prNumber) {
try {
let commits;
if (typeof github.paginate === 'function') {
commits = await github.paginate(github.rest.pulls.listCommits, {
owner,
repo,
pull_number: prNumber,
per_page: 100,
});
} else {
const { data } = await github.rest.pulls.listCommits({
owner,
repo,
pull_number: prNumber,
per_page: 100,
});
commits = data;
}
const last = commits[commits.length - 1];
return last?.commit?.committer?.date || last?.commit?.author?.date || null;
} catch {
return null;
}
}
async function loadPrContext(github, owner, repo, prNumber) {
const { data: pr } = await github.rest.pulls.get({ owner, repo, pull_number: prNumber });
const { data: issue } = await github.rest.issues.get({ owner, repo, issue_number: prNumber });
const comments = await listAllComments(github, owner, repo, prNumber);
const { data: reviews } = await github.rest.pulls.listReviews({
owner,
repo,
pull_number: prNumber,
per_page: 100,
});
const mergeState = await getMergeState(github, owner, repo, prNumber);
const headSha = mergeState.headSha || pr.head.sha;
const headCommitDate = await getHeadCommitDate(github, owner, repo, prNumber);
const headPushedAt = headCommitDate || pr.updated_at;
return {
pr,
issue,
comments,
reviews,
mergeState,
headSha,
headPushedAt,
labels: issue.labels.map((l) => l.name),
baseRepo: `${owner}/${repo}`,
};
}
async function evaluatePipelineQuiescent(github, owner, repo, prNumber, core, options = {}) {
const {
forMergeStep = false,
skipGlobalClaudeRunCheck = false,
skipRecentClaudeCooldown = false,
} = options;
const ctx = await loadPrContext(github, owner, repo, prNumber);
const reasons = [];
if (ctx.pr.draft) reasons.push('draft');
if (ctx.pr.state !== 'open') reasons.push('not open');
if (ctx.labels.includes('auto-merged-by-gate')) reasons.push('already merged by gate');
if (ctx.labels.includes('no-auto-merge')) reasons.push('no-auto-merge label');
if (ctx.labels.includes('claude-conflict-pending')) reasons.push('claude-conflict-pending');
if (!forMergeStep && ctx.labels.includes('claude-merge-gate-active')) {
reasons.push('claude-merge-gate-active');
}
const { status, headRepo, maintainerCanModify } = ctx.mergeState;
if (!ALLOWED_MERGE_STATES.has(status)) reasons.push(`mergeState=${status}`);
if (headRepo && headRepo !== ctx.baseRepo) {
reasons.push('fork PR');
}
if (hasRecentConflictComment(ctx.comments, ctx.headPushedAt)) {
reasons.push('recent merge-conflict @claude');
}
if (!skipRecentClaudeCooldown && hasRecentClaudeTrigger(ctx.comments, 35)) {
const verdictOnHead = findMergeGateVerdict(ctx.comments, null, ctx.headPushedAt) !== null;
if (!verdictOnHead) reasons.push('recent @claude within 35min');
}
if (!skipGlobalClaudeRunCheck && (await hasInProgressClaudeAssistant(github, owner, repo, prNumber))) {
reasons.push('claude.yml in progress');
}
const checksOk = await allChecksGreenOnSha(github, owner, repo, ctx.headSha, core);
if (!checksOk) reasons.push('CI not green on HEAD');
if (!finalClaudeCompletedOnSha(ctx.comments, ctx.headPushedAt)) {
if (!hasFinalClaudeReviewTrigger(ctx.comments)) {
reasons.push('no FINAL Claude review trigger');
} else if (isStaleFinalAfterPush(ctx.comments, ctx.headPushedAt)) {
reasons.push('stale FINAL after new commits (needs @claude re-review)');
} else {
reasons.push('FINAL Claude not complete on HEAD');
}
}
if (hasAnyChangesRequested(ctx.reviews)) reasons.push('CHANGES_REQUESTED review');
if (hasManualOnlyLabel(ctx.labels)) {
const manualLabel = ctx.labels.find((l) => MANUAL_ONLY_LABELS.has(l));
reasons.push(`manual-only label: ${manualLabel}`);
}
const pullFiles = await listPullFiles(github, owner, repo, prNumber);
if (config.agentPyChecks) {
const agentChange = getAgentPyChangeFromFiles(pullFiles);
const agentManual = manualReviewReasonForAgentPy(agentChange);
if (agentManual) reasons.push(agentManual);
}
reasons.push(...sensitivePathReasons(pullFiles, ctx.labels));
reasons.push(...prSizeReasons(pullFiles));
reasons.push(...secretScanReasons(pullFiles));
const testsReason = missingTestsReason(pullFiles);
if (testsReason) reasons.push(testsReason);
const sdkCheckReason = await sdkTestChecksReason(github, owner, repo, ctx.headSha, pullFiles, core);
if (sdkCheckReason) reasons.push(sdkCheckReason);
if (ctx.pr.mergeable === false) reasons.push('not mergeable');
const ready = reasons.length === 0;
return {
ready,
reasons,
headSha: ctx.headSha,
prNumber,
};
}
async function listPrNumbersForMergeGateScan(github, owner, repo, core) {
let mergeReadyIssues;
if (typeof github.paginate === 'function') {
mergeReadyIssues = await github.paginate(github.rest.issues.listForRepo, {
owner,
repo,
state: 'open',
labels: MERGE_READY_LABEL,
per_page: 100,
});
} else {
({ data: mergeReadyIssues } = await github.rest.issues.listForRepo({
owner,
repo,
state: 'open',
labels: MERGE_READY_LABEL,
per_page: 100,
}));
}
const mergeReady = mergeReadyIssues
.filter((issue) => issue.pull_request)
.map((issue) => issue.number)
.sort((a, b) => a - b);
if (mergeReady.length > 0) {
core?.info?.(
`Merge gate scan: ${mergeReady.length} ${MERGE_READY_LABEL} PR(s) (skipping unlabelled open PRs)`
);
return mergeReady;
}
core?.info?.(`No ${MERGE_READY_LABEL} PRs — scanning oldest open PRs`);
const prs =
typeof github.paginate === 'function'
? await github.paginate(github.rest.pulls.list, {
owner,
repo,
state: 'open',
per_page: 100,
sort: 'created',
direction: 'asc',
})
: (await github.rest.pulls.list({
owner,
repo,
state: 'open',
per_page: 100,
sort: 'created',
direction: 'asc',
})).data;
return prs.map((pr) => pr.number);
}
async function selectMergeGateCandidates(github, owner, repo, prNumbers, maxCandidates, core) {
const readyList = [];
const skipped = [];
for (const num of prNumbers) {
if (readyList.length >= maxCandidates) {
core?.info?.(`Found ${maxCandidates} candidate(s) — skipping remaining PR scans`);
break;
}
const result = await evaluatePipelineQuiescent(github, owner, repo, num, core);
if (result.ready) {
readyList.push({ pr_number: num, head_sha: result.headSha });
} else {
skipped.push({ pr: num, reasons: result.reasons });
}
}
readyList.sort((a, b) => a.pr_number - b.pr_number);
return {
candidates: readyList.slice(0, maxCandidates),
skipped,
};
}
const AUTOMATED_FALLBACK_MARKER = 'Automated fallback —';
function isAutomatedFallbackVerdict(body) {
return (body || '').includes(AUTOMATED_FALLBACK_MARKER);
}
function findMergeGateVerdict(comments, minCreatedAt = null, headPushedAt = null, options = {}) {
const { excludeAutomatedFallback = false } = options || {};
const minTime = minCreatedAt ? new Date(minCreatedAt).getTime() : 0;
const headTime = headPushedAt ? new Date(headPushedAt).getTime() - 60000 : 0;
const gateComments = comments
.filter((c) => {
if (!(c.body || '').includes('MERGE_GATE_VERDICT:')) return false;
if (excludeAutomatedFallback && isAutomatedFallbackVerdict(c.body)) return false;
const created = new Date(c.created_at).getTime();
if (minTime && created < minTime) return false;
if (headTime && created < headTime) return false;
return true;
})
.sort((a, b) => new Date(b.created_at) - new Date(a.created_at));
if (gateComments.length === 0) return null;
const body = gateComments[0].body || '';
if (body.includes('MERGE_GATE_VERDICT: APPROVE')) return 'APPROVE';
if (body.includes('MERGE_GATE_VERDICT: BLOCK')) return 'BLOCK';
return null;
}
module.exports = {
CLAUDE_TRIGGER_LOGINS,
AUTO_ACTORS,
isFinalClaudeTriggerComment,
isClaudeTriggerNoise,
isClaudeFinalReplyComment,
hasRecentClaudeTrigger,
hasRecentConflictComment,
isConflictRebaseTriggerComment,
isConflictRebaseCompletionComment,
conflictRebaseQuiescent,
hasHumanChangesRequested,
hasAnyChangesRequested,
hasFinalClaudeReviewTrigger,
finalClaudeCompletedOnSha,
getMergeState,
OPTIONAL_CANCELLED_CHECKS,
listChecksOnSha,
listFailedChecksOnSha,
allChecksGreenOnSha,
hasInProgressClaudeAssistant,
claudeRunBlocksPr,
hasBlockingClaudeRunForPr,
isCiOnlyChange,
isInternalPullRequestLink,
resolvePrNumberFromLinkedPullRequests,
resolvePrNumberFromHeadBranch,
resolvePrNumberFromWorkflowRun,
WORKFLOW_ONLY_LABEL,
getAgentPyChange,
getAgentPyChangeFromFiles,
manualReviewReasonForAgentPy,
countNewAgentParams,
listPullFiles,
touchesSdk,
hasManualOnlyLabel,
sensitivePathReasons,
prSizeReasons,
missingTestsReason,
secretScanReasons,
sdkTestChecksReason,
listAllComments,
getHeadCommitDate,
isStaleFinalAfterPush,
needsStaleFinalRecovery,
shouldSkipFinalRecovery,
shouldSkipStaleFinalRecovery,
isClaudeAutomationLogin,
isPushSoonAfterLatestFinal,
countFinalTriggersSince,
STALE_FINAL_RECOVERY_WINDOW_MS,
STALE_FINAL_MAX_PER_WINDOW,
FINAL_CLAUDE_REVIEW_BODY,
loadPrContext,
evaluatePipelineQuiescent,
MERGE_READY_LABEL,
listPrNumbersForMergeGateScan,
selectMergeGateCandidates,
isAutomatedFallbackVerdict,
AUTOMATED_FALLBACK_MARKER,
findMergeGateVerdict,
};
@@ -0,0 +1,82 @@
#!/usr/bin/env node
/**
* Run: node .github/scripts/pipeline-status-selftest.js
*/
const ps = require('./pipeline-status.js');
const mg = require('./merge-gate.js');
const config = require('./gate-config.js');
let failed = 0;
function assert(name, cond) {
if (!cond) {
console.error('FAIL:', name);
failed++;
} else {
console.log('ok:', name);
}
}
const kickComments = [
{ user: { login: 'MervinPraison' }, body: '@coderabbitai review' },
{ user: { login: 'MervinPraison' }, body: '/review' },
];
const finalComment = {
user: { login: 'MervinPraison' },
body: '@claude You are the FINAL architecture reviewer.',
created_at: '2026-06-26T10:00:00Z',
};
assert(
'reviews pending when not kicked',
ps.deriveStage([], { ready: false, reasons: ['no FINAL Claude review trigger'] })
=== 'pipeline/reviews-pending'
);
assert(
'final pending after kick',
ps.deriveStage(kickComments, { ready: false, reasons: ['no FINAL Claude review trigger'] })
=== 'pipeline/final-claude-pending'
);
assert(
'awaiting merge gate after bot review comment',
ps.deriveStage(
[
{ user: { login: 'coderabbitai[bot]' }, body: 'review summary' },
finalComment,
],
{ ready: false, reasons: ['recent @claude within 35min'] }
) === 'pipeline/awaiting-merge-gate'
);
assert('review bot logins configured', config.reviewBotLogins.includes('coderabbitai[bot]'));
assert(
'merge ready',
ps.deriveStage([...kickComments, finalComment], { ready: true, reasons: [] })
=== 'pipeline/merge-ready'
);
const blockers = ps.deriveBlockerLabels({
ready: false,
reasons: ['CI not green on HEAD', 'SDK code added without test file changes — requires manual review'],
});
assert('maps ci blocker', blockers.includes('pipeline/blocked:ci'));
assert('maps manual blocker', blockers.includes('pipeline/blocked:manual-review'));
assert(
'internal link matches upstream base',
mg.isInternalPullRequestLink(
{ base: { repo: { full_name: 'MervinPraison/PraisonAI' } } },
'MervinPraison',
'PraisonAI'
)
);
assert(
'fork sync link rejected',
!mg.isInternalPullRequestLink(
{ number: 21, base: { repo: { full_name: 'Milkmange/PraisonAI' } } },
'MervinPraison',
'PraisonAI'
)
);
process.exit(failed ? 1 : 0);
+264
View File
@@ -0,0 +1,264 @@
/**
* Sync pipeline stage/blocker labels on open PRs.
* @see .github/workflows/pipeline-status-sync.yml
*/
const mergeGate = require('./merge-gate.js');
const chain = require('./bot-pr-review-chain.js');
const ciFix = require('./ci-failure-claude.js');
const config = require('./gate-config.js');
const PIPELINE_PREFIX = 'pipeline/';
const STAGE_LABELS = [
'pipeline/reviews-pending',
'pipeline/final-claude-pending',
'pipeline/awaiting-merge-gate',
'pipeline/merge-ready',
'pipeline/merged',
];
const BLOCKER_LABELS = [
'pipeline/blocked:ci',
'pipeline/blocked:conflict',
'pipeline/blocked:manual-review',
'pipeline/blocked:cooldown',
'pipeline/blocked:stale-final',
'pipeline/blocked:no-final',
];
const ALL_PIPELINE_LABELS = [...STAGE_LABELS, ...BLOCKER_LABELS];
const LABEL_SPECS = [
{ name: 'pipeline/reviews-pending', color: 'fbca04', description: 'Waiting for CodeRabbit/Qodo/Copilot reviews' },
{ name: 'pipeline/final-claude-pending', color: 'd4c5f9', description: 'Reviews done; waiting for FINAL @claude' },
{ name: 'pipeline/awaiting-merge-gate', color: 'c5def5', description: 'FINAL done; waiting for merge gate / CI' },
{ name: 'pipeline/merge-ready', color: '0e8a16', description: 'Eligible for merge gate auto-merge' },
{ name: 'pipeline/merged', color: '6f42c1', description: 'Merged via pipeline' },
{ name: 'pipeline/blocked:ci', color: 'd93f0b', description: 'Blocked: CI not green on HEAD' },
{ name: 'pipeline/blocked:conflict', color: 'b60205', description: 'Blocked: merge conflict or rebase pending' },
{ name: 'pipeline/blocked:manual-review', color: 'e99695', description: 'Blocked: requires manual review' },
{ name: 'pipeline/blocked:cooldown', color: 'fef2c0', description: 'Blocked: post-push or @claude cooldown' },
{ name: 'pipeline/blocked:stale-final', color: 'f9d0c4', description: 'Blocked: FINAL stale after new commits' },
{ name: 'pipeline/blocked:no-final', color: 'ededed', description: 'Blocked: no FINAL @claude trigger yet' },
];
function reviewChainStarted(comments) {
if (chain.chainKickPosted(comments)) return true;
return comments.some((c) => config.reviewBotLogins.includes(c.user?.login));
}
function deriveStage(comments, evalResult) {
if (evalResult.ready) return 'pipeline/merge-ready';
if (!reviewChainStarted(comments)) return 'pipeline/reviews-pending';
if (!mergeGate.hasFinalClaudeReviewTrigger(comments)) return 'pipeline/final-claude-pending';
return 'pipeline/awaiting-merge-gate';
}
function reasonToBlockerLabel(reason) {
const r = (reason || '').toLowerCase();
if (r.includes('ci not green') || r.includes('sdk code changed but no ci')) return 'pipeline/blocked:ci';
if (
r.includes('conflict') ||
r.includes('mergestate=dirty') ||
r.includes('not mergeable')
) return 'pipeline/blocked:conflict';
if (
r.includes('manual') ||
r.includes('requires manual') ||
r.includes('sensitive path') ||
r.includes('no-auto-merge') ||
r.includes('manual-only label') ||
r.includes('without test') ||
r.includes('product code') ||
r.includes('files changed') ||
r.includes('possible secret')
) return 'pipeline/blocked:manual-review';
if (r.includes('recent @claude') || r.includes('post-push buffer')) return 'pipeline/blocked:cooldown';
if (r.includes('stale final')) return 'pipeline/blocked:stale-final';
if (r.includes('no final claude')) return 'pipeline/blocked:no-final';
if (r.includes('final claude not complete')) return 'pipeline/blocked:stale-final';
if (r.includes('claude.yml in progress') || r.includes('claude-merge-gate-active')) {
return null;
}
return null;
}
function deriveBlockerLabels(evalResult) {
if (evalResult.ready) return [];
const labels = new Set();
for (const reason of evalResult.reasons || []) {
const mapped = reasonToBlockerLabel(reason);
if (mapped && mapped.startsWith('pipeline/blocked:')) labels.add(mapped);
}
return [...labels];
}
function computePipelineLabels(comments, evalResult) {
const stage = deriveStage(comments, evalResult);
const blockers = stage === 'pipeline/merge-ready' ? [] : deriveBlockerLabels(evalResult);
return { stage, blockers, all: [stage, ...blockers] };
}
async function ensurePipelineLabels(github, owner, repo, core) {
let existing;
if (typeof github.paginate === 'function') {
existing = await github.paginate(github.rest.issues.listLabelsForRepo, {
owner,
repo,
per_page: 100,
});
} else {
({ data: existing } = await github.rest.issues.listLabelsForRepo({
owner,
repo,
per_page: 100,
}));
}
const names = new Set(existing.map((l) => l.name));
for (const spec of LABEL_SPECS) {
if (names.has(spec.name)) continue;
try {
await github.rest.issues.createLabel({
owner,
repo,
name: spec.name,
color: spec.color,
description: spec.description,
});
core?.info?.(`Created label ${spec.name}`);
} catch (err) {
if (err.status !== 422) throw err;
core?.info?.(`Label ${spec.name} already exists`);
}
}
}
async function syncPipelineLabels(github, owner, repo, prNumber, core) {
const ctx = await mergeGate.loadPrContext(github, owner, repo, prNumber);
if (ctx.pr.state !== 'open') return { synced: false, reason: 'not_open' };
const evalResult = await mergeGate.evaluatePipelineQuiescent(
github, owner, repo, prNumber, core
);
const { stage, blockers, all } = computePipelineLabels(ctx.comments, evalResult);
const current = ctx.labels.filter((l) => l.startsWith(PIPELINE_PREFIX));
const desired = new Set(all);
const toRemove = current.filter((l) => !desired.has(l) && l !== 'pipeline/merged');
const toAdd = all.filter((l) => !current.includes(l));
for (const name of toRemove) {
try {
await github.rest.issues.removeLabel({ owner, repo, issue_number: prNumber, name });
} catch (err) {
if (err.status !== 404) throw err;
}
}
if (toAdd.length) {
await github.rest.issues.addLabels({
owner,
repo,
issue_number: prNumber,
labels: toAdd,
});
}
core?.info?.(`PR #${prNumber}: stage=${stage} blockers=[${blockers.join(', ')}]`);
const ciNotGreen = (evalResult.reasons || []).some((r) =>
r.toLowerCase().includes('ci not green')
);
if (ciNotGreen || blockers.includes('pipeline/blocked:ci')) {
await ciFix.maybeTriggerCiFixClaude(github, owner, repo, prNumber, core);
} else {
await ciFix.maybeClearCiFixLabel(
github, owner, repo, prNumber, ctx.labels, ctx.headSha, core
);
}
return {
synced: true,
stage,
blockers,
ready: evalResult.ready,
reasons: evalResult.reasons,
labels: ctx.labels,
createdAt: new Date(ctx.pr.created_at).getTime(),
};
}
async function dispatchMergeGateForOldestReady(github, owner, repo, readyCandidates, core) {
if (!readyCandidates.length) return 0;
readyCandidates.sort((a, b) => a.createdAt - b.createdAt);
for (const cand of readyCandidates) {
if ((cand.labels || []).includes('claude-merge-gate-active')) {
core?.info?.(`Skip dispatch PR #${cand.prNumber}: merge gate already active`);
continue;
}
await github.rest.repos.createDispatchEvent({
owner,
repo,
event_type: 'claude-merge-gate',
client_payload: { pr_number: cand.prNumber },
});
core?.info?.(`Dispatched merge gate for ready PR #${cand.prNumber}`);
return cand.prNumber;
}
return 0;
}
async function syncOpenPullRequests(github, owner, repo, options, core) {
const { maxPrs = 20, dispatchMergeGate = true } = options || {};
await ensurePipelineLabels(github, owner, repo, core);
let prs;
if (maxPrs <= 100) {
({ data: prs } = await github.rest.pulls.list({
owner,
repo,
state: 'open',
per_page: 100,
}));
} else {
prs = await github.paginate(github.rest.pulls.list, {
owner,
repo,
state: 'open',
per_page: 100,
});
}
let synced = 0;
const readyCandidates = [];
for (const pr of prs) {
if (synced >= maxPrs) break;
if (pr.draft) continue;
if (pr.head?.repo?.full_name && pr.head.repo.full_name !== `${owner}/${repo}`) continue;
const result = await syncPipelineLabels(github, owner, repo, pr.number, core);
if (result.ready) {
readyCandidates.push({
prNumber: pr.number,
createdAt: result.createdAt || new Date(pr.created_at).getTime(),
labels: result.labels || [],
});
}
synced += 1;
}
let dispatched = 0;
if (dispatchMergeGate && readyCandidates.length) {
dispatched = await dispatchMergeGateForOldestReady(
github, owner, repo, readyCandidates, core
);
}
core?.info?.(`Pipeline label sync complete (${synced} PR(s), dispatched=${dispatched || 'none'})`);
return synced;
}
module.exports = {
STAGE_LABELS,
BLOCKER_LABELS,
ALL_PIPELINE_LABELS,
deriveStage,
deriveBlockerLabels,
computePipelineLabels,
ensurePipelineLabels,
syncPipelineLabels,
syncOpenPullRequests,
dispatchMergeGateForOldestReady,
};
+300
View File
@@ -0,0 +1,300 @@
/**
* PR review pipeline: CodeRabbit / Greptile (+ optional Qodo/Gemini) → Claude (FINAL).
* Copilot step optional — set REVIEW_CHAIN_SKIP_COPILOT=1 or skipCopilot: true to bypass.
* @see .github/workflows/auto-pr-comment.yml
*/
function isSkipCopilot(options = {}) {
if (options.skipCopilot === true) return true;
if (options.skipCopilot === false) return false;
return process.env.REVIEW_CHAIN_SKIP_COPILOT === '1';
}
const COPILOT_TRIGGER_LOGINS = new Set(['MervinPraison', 'github-actions[bot]']);
const CLAUDE_TRIGGER_LOGINS = new Set(['MervinPraison', 'github-actions[bot]']);
const REQUIRED_PRIOR = ['coderabbit', 'greptile'];
const OPTIONAL_PRIOR = ['qodo', 'gemini'];
const OPTIONAL_PRIOR_WAIT_MS = 20 * 60 * 1000;
function loginOf(item) {
return (item?.user?.login || '').toLowerCase();
}
function bodyOf(item) {
return item?.body || '';
}
function hasCoderabbitSummary(comments) {
return comments.some(
(c) =>
loginOf(c).includes('coderabbit') &&
bodyOf(c).toLowerCase().includes('summarize by coderabbit')
);
}
function hasGreptileReview(comments) {
return comments.some((c) => {
if (!loginOf(c).includes('greptile')) return false;
const body = bodyOf(c);
return body.includes('Greptile Summary') || body.includes('<h3>Greptile Summary</h3>') || body.length > 150;
});
}
function hasQodoReview(comments, reviews = []) {
if (comments.some((c) => loginOf(c).includes('qodo'))) return true;
return reviews.some((r) => loginOf(r).includes('qodo'));
}
function hasGeminiReview(comments, reviews = []) {
if (reviews.some((r) => loginOf(r).includes('gemini'))) return true;
return comments.some((c) => {
if (loginOf(c).includes('gemini')) return true;
const body = bodyOf(c);
return (
body.includes('Review completed by Gemini CLI') ||
body.includes('The fix is ready for review. Please test')
);
});
}
const PRIOR_CHECKS = {
coderabbit: (comments) => hasCoderabbitSummary(comments),
greptile: (comments) => hasGreptileReview(comments),
qodo: (comments, reviews) => hasQodoReview(comments, reviews),
gemini: (comments, reviews) => hasGeminiReview(comments, reviews),
};
function priorReviewerStatus(comments, reviews = []) {
const status = {};
for (const [id, check] of Object.entries(PRIOR_CHECKS)) {
status[id] = check(comments, reviews);
}
return status;
}
function priorReviewersReady(comments, reviews = [], options = {}) {
const waitMs = options.optionalWaitMs ?? OPTIONAL_PRIOR_WAIT_MS;
const prCreatedAt = options.prCreatedAt || null;
const status = priorReviewerStatus(comments, reviews);
const missingRequired = REQUIRED_PRIOR.filter((id) => !status[id]);
if (missingRequired.length) {
return { ready: false, reason: `waiting for ${missingRequired.join(', ')}`, status };
}
const missingOptional = OPTIONAL_PRIOR.filter((id) => !status[id]);
if (missingOptional.length && prCreatedAt) {
const age = Date.now() - new Date(prCreatedAt).getTime();
if (age < waitMs) {
return {
ready: false,
reason: `waiting for optional ${missingOptional.join(', ')} (${Math.round((waitMs - age) / 60000)}m left)`,
status,
};
}
}
return { ready: true, reason: '', status };
}
function copilotTriggerComment(comments) {
return comments.find(
(c) =>
bodyOf(c).includes('@copilot') &&
COPILOT_TRIGGER_LOGINS.has(c.user?.login) &&
!bodyOf(c).toLowerCase().includes('@claude')
);
}
function copilotTriggered(comments) {
return Boolean(copilotTriggerComment(comments));
}
function copilotReviewReady(comments, reviews = []) {
const trigger = copilotTriggerComment(comments);
if (!trigger) {
return { ready: false, reason: 'copilot not triggered yet' };
}
const triggerTime = new Date(trigger.created_at).getTime();
const copilotComment = comments.some((c) => {
if (c === trigger) return false;
if (new Date(c.created_at).getTime() < triggerTime) return false;
const login = loginOf(c);
return (
login.includes('copilot') ||
c.user?.login === 'Copilot' ||
c.user?.login === 'copilot-swe-agent'
);
});
const copilotReview = reviews.some((r) => {
if (new Date(r.submitted_at).getTime() < triggerTime) return false;
return loginOf(r).includes('copilot');
});
if (copilotComment || copilotReview) {
return { ready: true, reason: '' };
}
return { ready: false, reason: 'awaiting copilot review' };
}
function isFinalClaudeTriggerComment(c) {
const body = bodyOf(c).toLowerCase();
if (!CLAUDE_TRIGGER_LOGINS.has(c.user?.login)) return false;
if (!body.includes('@claude')) return false;
if (body.includes('merge conflict')) return false;
return body.includes('final architecture reviewer') || body.includes('lead engineer');
}
function claudeFinalAlreadyTriggered(comments) {
return comments.some(isFinalClaudeTriggerComment);
}
function claudeFinalReady(comments, reviews = [], options = {}) {
if (!options.allowStaleRepost && claudeFinalAlreadyTriggered(comments)) {
return { ready: false, reason: 'claude FINAL already triggered', already: true };
}
const prior = priorReviewersReady(comments, reviews, options);
if (!prior.ready) {
return { ready: false, reason: prior.reason };
}
if (isSkipCopilot(options)) {
return { ready: true, reason: 'copilot skipped', copilotSkipped: true };
}
const copilot = copilotReviewReady(comments, reviews);
if (copilot.ready) {
return { ready: true, reason: '' };
}
if (options.allowCopilotTimeout && copilotTriggered(comments)) {
return { ready: true, reason: 'copilot timeout fallback', copilotSkipped: true };
}
return { ready: false, reason: copilot.reason };
}
/** Post @copilot when prior reviewers are ready, then @claude FINAL when Copilot done (or timeout). */
async function advanceReviewChain(github, owner, repo, prNumber, finalBody, core, options = {}) {
const { data: pr } = await github.rest.pulls.get({ owner, repo, pull_number: prNumber });
const chainOpts = {
prCreatedAt: pr.created_at,
optionalWaitMs: options.optionalWaitMs ?? 0,
};
const copilot = options.skipCopilot
? { triggered: false, reason: 'skipped' }
: await maybeTriggerCopilot(github, owner, repo, prNumber, core, chainOpts);
const claude = await maybeTriggerClaudeFinal(
github, owner, repo, prNumber, finalBody, core,
{ ...options, ...chainOpts }
);
return { copilot, claude };
}
async function maybeTriggerClaudeFinal(github, owner, repo, prNumber, finalBody, core, options = {}) {
const { data: pr } = await github.rest.pulls.get({ owner, repo, pull_number: prNumber });
const { comments, reviews } = await listCommentsAndReviews(github, owner, repo, prNumber);
const gate = claudeFinalReady(comments, reviews, {
prCreatedAt: pr.created_at,
optionalWaitMs: options.optionalWaitMs,
allowCopilotTimeout: options.allowCopilotTimeout !== false,
allowStaleRepost: options.allowStaleRepost === true,
skipCopilot: options.skipCopilot,
});
if (!gate.ready) {
core?.info?.(`PR #${prNumber}: skip Claude FINAL — ${gate.reason}`);
return { posted: false, reason: gate.reason };
}
await github.rest.issues.createComment({
owner,
repo,
issue_number: prNumber,
body: finalBody,
});
const note = gate.copilotSkipped ? ' (Copilot timeout fallback)' : '';
core?.info?.(`Posted Claude FINAL on PR #${prNumber}${note}`);
return { posted: true, reason: '' };
}
const COPILOT_REVIEW_BODY =
'@copilot Do a thorough review of this PR. Read ALL existing reviewer comments above from Qodo, CodeRabbit, Gemini, and Greptile 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, Gemini, or Greptile flagged valid issues, include them in your review\n8. Suggest specific improvements with code examples where possible';
async function listCommentsAndReviews(github, owner, repo, prNumber) {
let comments;
if (typeof github.paginate === 'function') {
comments = await github.paginate(github.rest.issues.listComments, {
owner,
repo,
issue_number: prNumber,
per_page: 100,
});
} else {
const { data } = await github.rest.issues.listComments({
owner,
repo,
issue_number: prNumber,
per_page: 100,
});
comments = data;
}
const { data: reviews } = await github.rest.pulls.listReviews({
owner,
repo,
pull_number: prNumber,
per_page: 100,
});
return { comments, reviews };
}
async function maybeTriggerCopilot(github, owner, repo, prNumber, core, options = {}) {
const { comments, reviews } = await listCommentsAndReviews(github, owner, repo, prNumber);
if (copilotTriggered(comments)) {
core?.info?.(`Copilot already triggered on PR #${prNumber}`);
return { triggered: false, reason: 'already_triggered' };
}
const prior = priorReviewersReady(comments, reviews, options);
if (!prior.ready) {
core?.info?.(`PR #${prNumber}: not ready for Copilot — ${prior.reason}`);
return { triggered: false, reason: prior.reason };
}
await github.rest.issues.createComment({
owner,
repo,
issue_number: prNumber,
body: COPILOT_REVIEW_BODY,
});
core?.info?.(`Posted @copilot on PR #${prNumber} after prior reviewers`);
return { triggered: true };
}
async function pollCopilotResponse(github, owner, repo, prNumber, options = {}) {
const maxAttempts = options.maxAttempts ?? 20;
const delayMs = options.delayMs ?? 30000;
for (let i = 0; i < maxAttempts; i += 1) {
const { comments, reviews } = await listCommentsAndReviews(github, owner, repo, prNumber);
const copilot = copilotReviewReady(comments, reviews);
if (copilot.ready) {
return { ready: true, reason: '' };
}
if (i < maxAttempts - 1) {
await new Promise((resolve) => setTimeout(resolve, delayMs));
}
}
return { ready: false, reason: 'copilot timeout' };
}
module.exports = {
isSkipCopilot,
REQUIRED_PRIOR,
OPTIONAL_PRIOR,
COPILOT_REVIEW_BODY,
priorReviewerStatus,
priorReviewersReady,
copilotTriggered,
copilotReviewReady,
claudeFinalReady,
claudeFinalAlreadyTriggered,
isFinalClaudeTriggerComment,
maybeTriggerCopilot,
maybeTriggerClaudeFinal,
advanceReviewChain,
pollCopilotResponse,
hasGeminiReview,
listCommentsAndReviews,
};
+32
View File
@@ -0,0 +1,32 @@
#!/usr/bin/env node
/**
* Run: node .github/scripts/release-gate-selftest.js
*/
const rg = require('./release-gate.js');
const config = require('./gate-config.js');
let failed = 0;
function assert(name, cond) {
if (!cond) {
console.error('FAIL:', name);
failed += 1;
} else {
console.log('ok:', name);
}
}
assert('package name is aiui', config.pypiPackageName === 'aiui');
assert('bumpPatch works', rg.bumpPatch('0.3.127') === '0.3.128');
try {
const versions = rg.readVersionsFromTree(process.cwd());
assert('reads current version', /^\d+\.\d+\.\d+$/.test(versions.current));
assert('computes target patch', versions.target === rg.bumpPatch(versions.current));
} catch (err) {
console.error('FAIL: readVersionsFromTree', err.message);
failed += 1;
}
assert('PACKAGE_PATHS includes pyproject', rg.PACKAGE_PATHS.includes('pyproject.toml'));
process.exit(failed ? 1 : 0);
+205
View File
@@ -0,0 +1,205 @@
/**
* Release gate preflight — path changes, CI SHA, dedupe, PyPI version checks (PraisonAIUI).
*/
const https = require('https');
const config = require('./gate-config.js');
const PACKAGE_PATHS = config.packagePaths;
const ACTIVE_RELEASE_STATUSES = new Set([
'queued', 'in_progress', 'waiting', 'pending', 'requested',
]);
function bumpPatch(version) {
const parts = version.split('.');
if (parts.length !== 3) throw new Error(`Invalid version: ${version}`);
return `${parts[0]}.${parts[1]}.${Number(parts[2]) + 1}`;
}
function readVersionsFromTree(root = '.') {
const fs = require('fs');
const path = require('path');
const toml = fs.readFileSync(path.join(root, 'pyproject.toml'), 'utf8');
const match = toml.match(/^version\s*=\s*"([^"]+)"/m);
if (!match) throw new Error('Could not read aiui version from pyproject.toml');
const current = match[1];
const target = bumpPatch(current);
return { current, target, packageName: config.pypiPackageName };
}
function pypiVersionExists(packageName, version) {
return new Promise((resolve) => {
const req = https.get(
`https://pypi.org/pypi/${packageName}/${version}/json`,
{ timeout: 15000 },
(res) => {
res.resume();
resolve(res.statusCode === 200);
}
);
req.on('error', () => resolve(false));
req.on('timeout', () => {
req.destroy();
resolve(false);
});
});
}
async function hasActiveReleaseRun(github, owner, repo) {
const runs = await github.rest.actions.listWorkflowRuns({
owner,
repo,
workflow_id: 'pypi-release.yml',
per_page: 20,
});
return runs.data.workflow_runs.some(
(r) => ACTIVE_RELEASE_STATUSES.has(r.status) && !r.conclusion
);
}
function utcDayStart(now = new Date()) {
return new Date(Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), now.getUTCDate()));
}
async function hasSuccessfulReleaseToday(github, owner, repo, now = new Date()) {
const dayStart = utcDayStart(now);
const runs = await github.rest.actions.listWorkflowRuns({
owner,
repo,
workflow_id: 'pypi-release.yml',
status: 'completed',
per_page: 30,
});
return runs.data.workflow_runs.some(
(r) => r.conclusion === 'success' && new Date(r.created_at) >= dayStart
);
}
async function lastGreenCiSha(github, owner, repo) {
const runs = await github.rest.actions.listWorkflowRuns({
owner,
repo,
workflow_id: config.ciWorkflowFile,
branch: 'main',
status: 'completed',
per_page: 30,
});
const hit = runs.data.workflow_runs.find((r) => r.conclusion === 'success');
return hit ? hit.head_sha : '';
}
async function evaluateReleasePreflight(github, owner, repo, options, core) {
const {
headSha,
isCiTrigger = false,
bump = 'patch',
} = options;
const reasons = [];
const out = {
ready: false,
reasons,
headSha: headSha || '',
lastTag: '',
targetVersion: '',
packageName: config.pypiPackageName,
};
if (bump !== 'patch') {
reasons.push('only patch auto-release supported');
return out;
}
if (await hasActiveReleaseRun(github, owner, repo)) {
reasons.push('PyPI Release already in progress or awaiting approval');
return out;
}
const referenceTime = options.now instanceof Date ? options.now : new Date();
if (await hasSuccessfulReleaseToday(github, owner, repo, referenceTime)) {
const day = referenceTime.toISOString().slice(0, 10);
reasons.push(`already released today (UTC ${day}); max one patch release per day`);
return out;
}
let versions;
try {
versions = readVersionsFromTree();
} catch (err) {
reasons.push(err.message);
return out;
}
out.targetVersion = versions.target;
const onPypi = await pypiVersionExists(config.pypiPackageName, versions.target);
if (onPypi) {
reasons.push(`already published: ${config.pypiPackageName}==${versions.target}`);
return out;
}
const { execSync } = require('child_process');
let lastTag = '';
try {
lastTag = execSync('git describe --tags --match "v*" --abbrev=0', { encoding: 'utf8' }).trim();
} catch {
reasons.push('no v* tag found');
return out;
}
out.lastTag = lastTag;
let changed = '';
try {
changed = execSync(
`git diff --name-only ${lastTag} HEAD -- ${PACKAGE_PATHS.join(' ')}`,
{ encoding: 'utf8' }
).trim();
} catch {
reasons.push('git diff failed');
return out;
}
if (!changed) {
reasons.push(`no changes in ${PACKAGE_PATHS.join(' or ')} since ${lastTag}`);
return out;
}
if (isCiTrigger) {
let mainSha = '';
try {
mainSha = execSync('git rev-parse origin/main', { encoding: 'utf8' }).trim();
} catch {
reasons.push('could not resolve origin/main');
return out;
}
if (headSha !== mainSha) {
reasons.push(`superseded: green SHA ${headSha.slice(0, 7)} != main ${mainSha.slice(0, 7)}`);
return out;
}
out.headSha = headSha;
} else {
const evalSha = execSync('git rev-parse HEAD', { encoding: 'utf8' }).trim();
out.headSha = evalSha;
const greenSha = await lastGreenCiSha(github, owner, repo);
if (greenSha !== evalSha) {
reasons.push(`CI not green on HEAD (last green: ${greenSha ? greenSha.slice(0, 7) : 'none'})`);
return out;
}
}
out.ready = true;
out.reasons = ['ready'];
if (core) core.info(`Release preflight passed for ${out.headSha || headSha}`);
return out;
}
module.exports = {
bumpPatch,
readVersionsFromTree,
pypiVersionExists,
hasSuccessfulReleaseToday,
utcDayStart,
evaluateReleasePreflight,
lastGreenCiSha,
ACTIVE_RELEASE_STATUSES,
PACKAGE_PATHS,
};
+24 -16
View File
@@ -1,8 +1,7 @@
name: Auto Issue Comment
# Optional: automatically escalate every new issue to Claude for a full
# investigation + draft fix. Disabled by default (only workflow_dispatch).
# To enable auto-triage on every new issue, uncomment the `issues:` trigger.
# Automatically escalate every new issue to Claude for a full investigation
# + draft fix. Manual re-trigger: workflow_dispatch with issue_number.
#
# Note: `@claude` mentions only trigger claude.yml when the commenter is a
# HUMAN user (or github-actions[bot] via PAT). This workflow posts the
@@ -14,9 +13,9 @@ on:
issue_number:
description: "Issue number to triage with Claude"
required: true
type: number
# issues:
# types: [opened]
type: string
issues:
types: [opened]
jobs:
# Escalate an issue to full Claude implementation by adding the `claude`
@@ -29,6 +28,7 @@ jobs:
runs-on: ubuntu-latest
permissions:
issues: write
actions: write
steps:
- name: Resolve issue number
id: resolve
@@ -52,18 +52,17 @@ jobs:
});
const labels = (issue.labels || []).map(l => typeof l === 'string' ? l : l.name);
if (labels.includes('claude')) {
console.log(`Issue #${issueNumber} already has 'claude' label — skipping.`);
return;
if (!labels.includes('claude')) {
await github.rest.issues.addLabels({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: issueNumber,
labels: ['claude'],
});
} else {
console.log(`Issue #${issueNumber} already has 'claude' label.`);
}
await github.rest.issues.addLabels({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: issueNumber,
labels: ['claude'],
});
const body = [
'🤖 Auto-triaging with Claude.',
'',
@@ -84,3 +83,12 @@ jobs:
issue_number: issueNumber,
body,
});
await github.rest.actions.createWorkflowDispatch({
owner: context.repo.owner,
repo: context.repo.repo,
workflow_id: 'claude.yml',
ref: 'main',
inputs: { issue_number: String(issueNumber) },
});
console.log(`Dispatched Claude Assistant for issue #${issueNumber}`);
+346 -62
View File
@@ -15,12 +15,32 @@ on:
pull_request_review:
types: [submitted]
pull_request:
types: [opened]
types: [opened, synchronize]
pull_request_target:
types: [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
permissions:
contents: read
issues: write
pull-requests: write
env:
MAX_POLL_ATTEMPTS: 20
POLL_DELAY_MS: 30000
CLAUDE_TRIGGER_LOGINS: '["MervinPraison","github-actions[bot]"]'
# GH_TOKEN (user PAT) posts as MervinPraison for @copilot; fallback to github.token for @claude
GH_TOKEN: ${{ secrets.GH_TOKEN || github.token }}
CLAUDE_UI_SCOPE: 'SCOPE: Focus ONLY on PraisonAIUI (src/praisonaiui, src/frontend, tests, docs). Do not expand into praisonaiagents or the monorepo unless the issue explicitly requires it.'
CLAUDE_FINAL_PHASES_COMPACT: "**Phase 1: Review per AGENTS.md** (lazy imports, backward compat, import-time <200ms; SDK/product value gate — genuine UI value, correct UI vs praisonaiagents layering, no scope creep)\n**Phase 2: FIX valid issues** found by prior reviewers — push to THIS branch\n**Phase 3: Final verdict** — approve PR if ready, else request changes"
CLAUDE_FINAL_PHASES_DETAILED: "**Phase 1: Review per AGENTS.md**\n1. Protocol-driven: check heavy implementations vs core SDK\n2. Backward compatible: ensure zero feature regressions\n3. Performance: no hot-path regressions (lazy imports, import-time <200ms)\n4. SDK/product value: review whether the change genuinely adds value — stronger PraisonAIUI UX and correct layering (UI/frontend here; core agent logic belongs in praisonaiagents). Reject scope creep; request changes or recommend closing if value is unclear\n\n**Phase 2: FIX Valid Issues**\n5. For any VALID bugs or architectural flaws found by Gemini, CodeRabbit, Qodo, Copilot, or any other reviewer: implement the fix\n6. Push all code fixes directly to THIS branch (do NOT create a new PR)\n7. Comment a summary of exact files modified and what you skipped\n\n**Phase 3: Final Verdict**\n8. If all issues are resolved, approve the PR / close the Issue\n9. If blocking issues remain, request changes / leave clear action items"
jobs:
# ================================================================
@@ -43,7 +63,7 @@ jobs:
id: trigger
uses: actions/github-script@v7
with:
github-token: ${{ secrets.GH_TOKEN }}
github-token: ${{ env.GH_TOKEN }}
script: |
const comments = await github.rest.issues.listComments({
issue_number: context.issue.number,
@@ -86,7 +106,7 @@ jobs:
id: trigger
uses: actions/github-script@v7
with:
github-token: ${{ secrets.GH_TOKEN }}
github-token: ${{ env.GH_TOKEN }}
script: |
const comments = await github.rest.issues.listComments({
issue_number: context.issue.number,
@@ -134,12 +154,14 @@ jobs:
uses: actions/github-script@v7
env:
TRIGGER_LOGINS: ${{ env.CLAUDE_TRIGGER_LOGINS }}
PR_NUMBER: ${{ github.event.pull_request.number }}
with:
github-token: ${{ secrets.GH_TOKEN }}
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: context.issue.number,
issue_number: pr,
owner: context.repo.owner,
repo: context.repo.repo
});
@@ -148,23 +170,25 @@ jobs:
);
core.setOutput('already_triggered', alreadyPosted ? 'true' : 'false');
core.setOutput('comments_count', comments.data.length.toString());
// Collect all bot reviewer comments for context
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 =>
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: ${{ secrets.GH_TOKEN }}
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, per_page: 100});
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();
@@ -183,14 +207,14 @@ jobs:
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 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: Focus ONLY on PraisonAIUI (src/praisonaiui, tests, docs). Do not expand scope into praisonaiagents or the monorepo unless the issue explicitly requires it. ' + reviewSummary + '.\n\n' +
'Review Context: ' + comments.data.length + ' total comments, ' +
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' +
'Phase 1: Review per AGENTS.md principles (protocol-driven, backward compatible, no perf impact).\n' +
'Phase 2: FIX valid issues found by prior reviewers.\n' +
'Phase 3: Provide final verdict (approve or request changes).';
await github.rest.issues.createComment({issue_number: context.issue.number, owner: context.repo.owner, repo: context.repo.repo, body: claudeBody});
console.log('Claude fallback triggered on PR #' + context.issue.number + ' with ' + reviewSummary);
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
@@ -209,6 +233,10 @@ jobs:
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
@@ -217,7 +245,7 @@ jobs:
MAX_POLL_ATTEMPTS: ${{ env.MAX_POLL_ATTEMPTS }}
POLL_DELAY_MS: ${{ env.POLL_DELAY_MS }}
with:
github-token: ${{ secrets.GH_TOKEN }}
github-token: ${{ env.GH_TOKEN }}
script: |
const pr = parseInt(process.env.PR_NUMBER, 10);
const owner = context.repo.owner;
@@ -286,7 +314,7 @@ jobs:
PR_NUMBER: ${{ needs.copilot-after-coderabbit.outputs.pr_number }}
TRIGGER_LOGINS: ${{ env.CLAUDE_TRIGGER_LOGINS }}
with:
github-token: ${{ secrets.GH_TOKEN }}
github-token: ${{ env.GH_TOKEN }}
script: |
const pr = parseInt(process.env.PR_NUMBER, 10);
const triggerLogins = JSON.parse(process.env.TRIGGER_LOGINS);
@@ -306,17 +334,33 @@ jobs:
env:
PR_NUMBER: ${{ needs.copilot-after-coderabbit.outputs.pr_number }}
with:
github-token: ${{ secrets.GH_TOKEN }}
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: Focus ONLY on PraisonAIUI (src/praisonaiui, tests, docs). Do not expand scope into praisonaiagents or the monorepo unless the issue explicitly requires it. Read ALL comments above from Gemini, Qodo, CodeRabbit, and Copilot carefully before responding.\n\n**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\n\n**Phase 2: FIX Valid Issues**\n4. For any VALID bugs or architectural flaws found by Gemini, CodeRabbit, Qodo, Copilot, or any other reviewer: implement the fix\n5. Push all code fixes directly to THIS branch (do NOT create a new PR)\n6. Comment a summary of exact files modified and what you skipped\n\n**Phase 3: Final Verdict**\n7. If all issues are resolved, approve the PR / close the Issue\n8. If blocking issues remain, request changes / leave clear action items'
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)
# ================================================================
@@ -340,7 +384,7 @@ jobs:
MAX_POLL_ATTEMPTS: ${{ env.MAX_POLL_ATTEMPTS }}
POLL_DELAY_MS: ${{ env.POLL_DELAY_MS }}
with:
github-token: ${{ secrets.GH_TOKEN }}
github-token: ${{ env.GH_TOKEN }}
script: |
const pr = parseInt(process.env.PR_NUMBER, 10);
const owner = context.repo.owner;
@@ -390,7 +434,7 @@ jobs:
PR_NUMBER: ${{ needs.copilot-after-qodo.outputs.pr_number }}
TRIGGER_LOGINS: ${{ env.CLAUDE_TRIGGER_LOGINS }}
with:
github-token: ${{ secrets.GH_TOKEN }}
github-token: ${{ env.GH_TOKEN }}
script: |
const pr = parseInt(process.env.PR_NUMBER, 10);
const triggerLogins = JSON.parse(process.env.TRIGGER_LOGINS);
@@ -410,14 +454,16 @@ jobs:
env:
PR_NUMBER: ${{ needs.copilot-after-qodo.outputs.pr_number }}
with:
github-token: ${{ secrets.GH_TOKEN }}
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: Focus ONLY on PraisonAIUI (src/praisonaiui, tests, docs). Do not expand scope into praisonaiagents or the monorepo unless the issue explicitly requires it. Read ALL comments above from Gemini, Qodo, CodeRabbit, and Copilot carefully before responding.\n\n**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\n\n**Phase 2: FIX Valid Issues**\n4. For any VALID bugs or architectural flaws found by Gemini, CodeRabbit, Qodo, Copilot, or any other reviewer: implement the fix\n5. Push all code fixes directly to THIS branch (do NOT create a new PR)\n6. Comment a summary of exact files modified and what you skipped\n\n**Phase 3: Final Verdict**\n7. If all issues are resolved, approve the PR / close the Issue\n8. If blocking issues remain, request changes / leave clear action items'
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}`);
@@ -445,7 +491,7 @@ jobs:
env:
TRIGGER_LOGINS: ${{ env.CLAUDE_TRIGGER_LOGINS }}
with:
github-token: ${{ secrets.GH_TOKEN }}
github-token: ${{ env.GH_TOKEN }}
script: |
const triggerLogins = JSON.parse(process.env.TRIGGER_LOGINS);
const comments = await github.rest.issues.listComments({
@@ -462,19 +508,266 @@ jobs:
if: steps.check_claude.outputs.already_triggered != 'true'
uses: actions/github-script@v7
with:
github-token: ${{ secrets.GH_TOKEN }}
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: Focus ONLY on PraisonAIUI (src/praisonaiui, tests, docs). Do not expand scope into praisonaiagents or the monorepo unless the issue explicitly requires it. Read ALL analysis and reviews above carefully (Gemini, CodeRabbit, Qodo, Copilot, etc).\n\n**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\n\n**Phase 2: FIX Valid Issues**\n4. For any VALID bugs or architectural flaws found by Gemini, CodeRabbit, Qodo, Copilot, or any other reviewer: implement the fix\n5. Push all code fixes directly to THIS branch (do NOT create a new PR)\n6. Comment a summary of exact files modified and what you skipped\n\n**Phase 3: Final Verdict**\n7. If all issues are resolved, approve the PR / close the Issue\n8. If blocking issues remain, request changes / leave clear action items'
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
});
# ================================================================
# BOT PRs: CodeRabbit/Qodo skip bot-authored PRs by default.
# Trigger them explicitly. Do NOT trigger Copilot here — it will
# be triggered by copilot-after-coderabbit/qodo when they finish.
# Recovery: re-post FINAL @claude after push or when chain missed
# ================================================================
claude-review-recovery:
if: |
github.event_name == 'pull_request_target' &&
github.event.action == 'synchronize'
runs-on: ubuntu-latest
permissions:
issues: write
pull-requests: read
steps:
- uses: actions/checkout@v4
- name: Re-post FINAL Claude review when stale or missing
uses: actions/github-script@v7
with:
github-token: ${{ env.GH_TOKEN }}
script: |
const path = require('path');
const mergeGate = require(path.join(process.env.GITHUB_WORKSPACE, '.github/scripts/merge-gate.js'));
const chain = require(path.join(process.env.GITHUB_WORKSPACE, '.github/scripts/pr-review-chain.js'));
const pr = context.payload.pull_request.number;
const owner = context.repo.owner;
const repo = context.repo.repo;
const comments = await mergeGate.listAllComments(github, owner, repo, pr);
const headPushedAt = (await mergeGate.getHeadCommitDate(github, owner, repo, pr))
|| context.payload.pull_request.updated_at;
const headPusher = context.payload.sender?.login || null;
const hasFinal = mergeGate.hasFinalClaudeReviewTrigger(comments);
const isStale = mergeGate.isStaleFinalAfterPush(comments, headPushedAt);
if (mergeGate.shouldSkipFinalRecovery(comments, headPushedAt)) {
core.info('Recent @claude within 35min and FINAL not stale — skip recovery');
return;
}
if (await mergeGate.hasInProgressClaudeAssistant(github, owner, repo, pr)) {
core.info('Claude workflow in progress — skip recovery');
return;
}
const finalBody = mergeGate.FINAL_CLAUDE_REVIEW_BODY;
const staleOpts = { allowStaleRepost: true, skipCopilot: true, optionalWaitMs: 0 };
const missingOpts = { allowCopilotTimeout: true, optionalWaitMs: 0 };
if (hasFinal && isStale) {
const gate = mergeGate.shouldSkipStaleFinalRecovery(comments, headPushedAt, headPusher);
if (gate.skip) {
core.info(`Skip stale-FINAL recovery: ${gate.reason}`);
return;
}
const result = await chain.maybeTriggerClaudeFinal(
github, owner, repo, pr, finalBody, core, staleOpts
);
if (result.posted) core.info(`Posted stale-FINAL recovery @claude on PR #${pr}`);
return;
}
if (hasFinal) {
core.info('FINAL Claude review current on HEAD — skip recovery');
return;
}
const result = await chain.maybeTriggerClaudeFinal(
github, owner, repo, pr, finalBody, core, missingOpts
);
if (result.posted) core.info(`Posted missing FINAL Claude review on PR #${pr}`);
- name: Sync pipeline status labels
uses: actions/github-script@v7
with:
github-token: ${{ env.GH_TOKEN }}
script: |
const path = require('path');
const ps = require(path.join(process.env.GITHUB_WORKSPACE, '.github/scripts/pipeline-status.js'));
const pr = context.payload.pull_request.number;
await ps.ensurePipelineLabels(github, context.repo.owner, context.repo.repo, core);
await ps.syncPipelineLabels(github, context.repo.owner, context.repo.repo, pr, core);
# Scheduled + manual: post missing or stale FINAL @claude on all open PRs
post-missing-claude-final:
if: |
github.event_name == 'schedule' ||
(github.event_name == 'workflow_dispatch' && !inputs.pr_number)
runs-on: ubuntu-latest
permissions:
issues: write
pull-requests: read
steps:
- uses: actions/checkout@v4
- name: Post missing/stale FINAL @claude on open PRs
uses: actions/github-script@v7
with:
github-token: ${{ env.GH_TOKEN }}
script: |
const path = require('path');
const mergeGate = require(path.join(process.env.GITHUB_WORKSPACE, '.github/scripts/merge-gate.js'));
const chain = require(path.join(process.env.GITHUB_WORKSPACE, '.github/scripts/pr-review-chain.js'));
const ps = require(path.join(process.env.GITHUB_WORKSPACE, '.github/scripts/pipeline-status.js'));
const owner = context.repo.owner;
const repo = context.repo.repo;
const prs = await github.paginate(github.rest.pulls.list, {
owner, repo, state: 'open', per_page: 100,
});
let posted = 0;
for (const pr of prs) {
if (pr.draft) continue;
const comments = await mergeGate.listAllComments(github, owner, repo, pr.number);
const headPushedAt = (await mergeGate.getHeadCommitDate(github, owner, repo, pr.number)) || pr.updated_at;
const hasFinal = mergeGate.hasFinalClaudeReviewTrigger(comments);
const isStale = mergeGate.isStaleFinalAfterPush(comments, headPushedAt);
if (hasFinal && !isStale) continue;
if (mergeGate.hasRecentClaudeTrigger(comments, 10)) {
core.info(`PR #${pr.number}: @claude within 10min — skip`);
continue;
}
if (await mergeGate.hasInProgressClaudeAssistant(github, owner, repo, pr.number)) {
core.info(`PR #${pr.number}: Claude in progress — skip`);
continue;
}
if (hasFinal && isStale) {
const gate = mergeGate.shouldSkipStaleFinalRecovery(comments, headPushedAt);
if (gate.skip) {
core.info(`PR #${pr.number}: skip stale — ${gate.reason}`);
continue;
}
}
const opts = hasFinal && isStale
? { allowStaleRepost: true, skipCopilot: true, optionalWaitMs: 0 }
: { allowCopilotTimeout: true, optionalWaitMs: 0 };
const result = await chain.maybeTriggerClaudeFinal(
github, owner, repo, pr.number,
mergeGate.FINAL_CLAUDE_REVIEW_BODY, core, opts
);
if (result.posted) {
posted++;
core.info(`Posted Claude FINAL on PR #${pr.number}`);
}
await ps.syncPipelineLabels(github, owner, repo, pr.number, core);
if (posted >= 10) break;
}
core.info(`Missing/stale Claude FINAL sweep complete (${posted} posted)`);
# ================================================================
# BOT PRs: CodeRabbit → Claude directly (skip Copilot when no user PAT)
# Copilot ignores github-actions[bot] comments; bot PRs use this path.
# ================================================================
claude-after-coderabbit-bot-pr:
if: |
github.event_name == 'issue_comment' &&
github.event.issue.pull_request &&
github.event.comment.user.login == 'coderabbitai[bot]' &&
contains(github.event.comment.body, 'summarize by coderabbit')
runs-on: ubuntu-latest
permissions:
issues: write
pull-requests: read
steps:
- name: Trigger Claude final review on bot PR
uses: actions/github-script@v7
env:
TRIGGER_LOGINS: ${{ env.CLAUDE_TRIGGER_LOGINS }}
with:
github-token: ${{ env.GH_TOKEN }}
script: |
const pr = context.issue.number;
const { data: pull } = await github.rest.pulls.get({
owner: context.repo.owner,
repo: context.repo.repo,
pull_number: pr,
});
const author = pull.user.login;
const isBot = author === 'github-actions[bot]' ||
author === 'praisonai-triage-agent[bot]' ||
pull.user.type === 'Bot';
if (!isBot) {
console.log('Human PR — Copilot chain handles Claude trigger');
return;
}
const triggerLogins = JSON.parse(process.env.TRIGGER_LOGINS);
const comments = await github.rest.issues.listComments({
issue_number: pr,
owner: context.repo.owner,
repo: context.repo.repo,
per_page: 100,
});
const alreadyPosted = comments.data.some(c =>
c.body.includes('@claude') && triggerLogins.includes(c.user.login)
);
if (alreadyPosted) {
console.log('Claude already triggered on PR #' + pr);
return;
}
await github.rest.issues.createComment({
issue_number: pr,
owner: context.repo.owner,
repo: context.repo.repo,
body: '@claude You are the FINAL architecture reviewer on this bot-authored PR. Push fixes directly to THIS branch (do NOT open a new PR). ' + process.env.CLAUDE_UI_SCOPE + ' Read ALL CodeRabbit/Qodo comments above.\n\n' + process.env.CLAUDE_FINAL_PHASES_COMPACT
});
console.log('Claude final review triggered on bot PR #' + pr);
# ================================================================
# Manual: trigger Claude final review on any PR
# ================================================================
claude-manual-dispatch:
if: github.event_name == 'workflow_dispatch' && inputs.pr_number
runs-on: ubuntu-latest
permissions:
issues: write
steps:
- name: Trigger Claude final review
uses: actions/github-script@v7
env:
PR_NUMBER: ${{ inputs.pr_number }}
TRIGGER_LOGINS: ${{ env.CLAUDE_TRIGGER_LOGINS }}
with:
github-token: ${{ env.GH_TOKEN }}
script: |
const pr = parseInt(process.env.PR_NUMBER, 10);
const triggerLogins = JSON.parse(process.env.TRIGGER_LOGINS);
const comments = await github.rest.issues.listComments({
issue_number: pr,
owner: context.repo.owner,
repo: context.repo.repo,
per_page: 100,
});
const alreadyPosted = comments.data.some(c =>
c.body.includes('@claude') && triggerLogins.includes(c.user.login)
);
if (alreadyPosted) {
console.log('Claude already triggered on PR #' + pr);
return;
}
await github.rest.issues.createComment({
issue_number: pr,
owner: context.repo.owner,
repo: context.repo.repo,
body: '@claude You are the FINAL architecture reviewer. If the branch is under MervinPraison/PraisonAIUI (not a fork), you are able to make modifications to this branch and push directly. ' + process.env.CLAUDE_UI_SCOPE + ' Read all prior reviewer comments.\n\n' + process.env.CLAUDE_FINAL_PHASES_COMPACT
});
console.log('Claude manually triggered on PR #' + pr);
# ================================================================
# BOT PRs: kick CodeRabbit + Qodo
# ================================================================
bot-pr-trigger-reviews:
if: |
@@ -487,39 +780,30 @@ jobs:
)
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: ${{ secrets.GH_TOKEN }}
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;
const owner = context.repo.owner;
const repo = context.repo.repo;
await chain.kickReviewChain(github, context.repo.owner, context.repo.repo, pr, core);
// 1. Trigger CodeRabbit review
await github.rest.issues.createComment({
issue_number: pr, owner, repo,
body: '@coderabbitai review'
});
console.log(`Triggered CodeRabbit for bot PR #${pr}`);
// 2. Trigger Qodo review
await github.rest.issues.createComment({
issue_number: pr, owner, repo,
body: '/review'
});
console.log(`Triggered Qodo for bot PR #${pr}`);
// 3. Trigger Gemini review (commented out)
// await github.rest.issues.createComment({
// issue_number: pr, owner, repo,
// body: '@gemini review this PR'
// });
// console.log(`Triggered Gemini for bot PR #${pr}`);
// NOTE: @copilot is NOT triggered here.
// It will be triggered by copilot-after-coderabbit or copilot-after-qodo
// once those bots finish, ensuring Copilot always reviews LAST.
console.log('Copilot will be triggered after CodeRabbit/Qodo complete.');
- 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);
+102
View File
@@ -0,0 +1,102 @@
name: Auto CI Failure → Claude
# Detects internal PRs with failing CI and posts @claude with extracted failure details.
on:
workflow_run:
workflows: [CI]
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:
- cron: '15 */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
actions: read
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();
+5 -2
View File
@@ -8,6 +8,7 @@ on:
jobs:
python:
name: python
runs-on: ubuntu-latest
strategy:
matrix:
@@ -28,10 +29,12 @@ jobs:
run: ruff check src/praisonaiui
- name: Test with pytest
# Integration tests are environment-sensitive (LLM API keys,
# long-running flows). Unit tests are the contract CI guards.
# Unit tests are the primary CI contract; integration smoke validates backends.
run: pytest tests/unit -v --no-cov --timeout=60
- name: Integration smoke tests
run: pytest tests/integration/test_agentic_roundtrip.py tests/test_feature_sdk_backends.py -q --no-cov --timeout=120
docs:
runs-on: ubuntu-latest
if: github.ref == 'refs/heads/main'
+538
View File
@@ -0,0 +1,538 @@
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
workflow_run:
workflows:
- Claude Assistant
- CI
types:
- completed
repository_dispatch:
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
issues: read
actions: read
jobs:
auto-dispatch:
if: github.event_name == 'workflow_run'
runs-on: ubuntu-latest
permissions:
contents: read
pull-requests: read
issues: read
actions: write
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: 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 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;
}
const prNumber = await mergeGate.resolvePrNumberFromWorkflowRun(github, owner, repo, wr);
if (!prNumber) {
core.info('Skip dispatch: could not resolve PR from workflow run');
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;
}
await github.rest.repos.createDispatchEvent({
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:
if: github.event_name != 'workflow_run'
runs-on: ubuntu-latest
permissions:
contents: read
pull-requests: read
issues: write
actions: read
id-token: write
outputs:
matrix: ${{ steps.scan.outputs.matrix }}
has_candidates: ${{ steps.scan.outputs.has_candidates }}
steps:
- uses: actions/checkout@v4
- name: Generate GitHub App token
id: app-token
uses: actions/create-github-app-token@v1
with:
app-id: ${{ secrets.CLAUDE_APP_ID }}
private-key: ${{ secrets.CLAUDE_APP_PRIVATE_KEY }}
owner: ${{ github.repository_owner }}
- name: Scan open PRs for merge gate eligibility
id: scan
uses: actions/github-script@v7
with:
github-token: ${{ steps.app-token.outputs.token }}
script: |
const path = require('path');
const mergeGate = require(path.join(process.env.GITHUB_WORKSPACE, '.github/scripts/merge-gate.js'));
const ps = require(path.join(process.env.GITHUB_WORKSPACE, '.github/scripts/pipeline-status.js'));
const owner = context.repo.owner;
const repo = context.repo.repo;
const maxCandidates = parseInt(process.env.MAX_CANDIDATES || '5', 10);
const inputPr = String(process.env.DISPATCH_PR_NUMBER || '').trim();
const candidates = [];
const skipped = [];
if (inputPr) {
const n = parseInt(inputPr, 10);
if (Number.isNaN(n)) {
throw new Error(`Invalid pr_number input: ${inputPr}`);
}
const result = await mergeGate.evaluatePipelineQuiescent(github, owner, repo, n, core);
if (result.ready) {
candidates.push({ pr_number: n, head_sha: result.headSha });
} else {
skipped.push({ pr: n, reasons: result.reasons });
}
} else {
const prNumbers = await mergeGate.listPrNumbersForMergeGateScan(
github, owner, repo, core
);
const selected = await mergeGate.selectMergeGateCandidates(
github, owner, repo, prNumbers, maxCandidates, core
);
candidates.push(...selected.candidates);
skipped.push(...selected.skipped);
}
core.info('Skipped: ' + JSON.stringify(skipped));
core.setOutput('matrix', JSON.stringify({ include: candidates }));
core.setOutput('has_candidates', candidates.length > 0 ? 'true' : 'false');
core.summary.addRaw('## Merge gate scan\n');
core.summary.addRaw(`Candidates: ${candidates.length}\n`);
core.summary.addRaw('```json\n' + JSON.stringify({ candidates, skipped }, null, 2) + '\n```');
await core.summary.write();
if (inputPr && skipped.length === 1 && skipped[0].pr === parseInt(inputPr, 10)) {
const reasons = skipped[0].reasons;
const body = [
'**Merge gate scan** — not eligible for auto-merge.',
'',
...reasons.map((r) => `- \`${r}\``),
'',
'**Actions:** wait for CI and the Claude review chain, or add label `needs-manual-review` and merge manually.',
'**Opt out:** label `no-auto-merge`.',
].join('\n');
await github.rest.issues.createComment({
owner, repo, issue_number: skipped[0].pr, body,
});
} else if (inputPr && candidates.length === 1) {
await github.rest.issues.createComment({
owner, repo, issue_number: candidates[0].pr_number,
body: '**Merge gate scan** — eligible for assessment. Claude merge gate will assess and may auto-merge if `MERGE_GATE_VERDICT: APPROVE`.',
});
}
await ps.syncOpenPullRequests(github, owner, repo, { maxPrs: 10 }, core);
claude-assess:
needs: scan-candidates
if: needs.scan-candidates.outputs.has_candidates == 'true'
runs-on: ubuntu-latest
timeout-minutes: 20
strategy:
fail-fast: false
matrix: ${{ fromJson(needs.scan-candidates.outputs.matrix) }}
permissions:
contents: read
pull-requests: read
issues: write
actions: read
id-token: write
steps:
- name: Generate GitHub App Token
id: app-token
uses: actions/create-github-app-token@v1
with:
app-id: ${{ secrets.CLAUDE_APP_ID }}
private-key: ${{ secrets.CLAUDE_APP_PRIVATE_KEY }}
owner: ${{ github.repository_owner }}
- uses: actions/checkout@v4
with:
fetch-depth: 0
token: ${{ steps.app-token.outputs.token }}
- name: Mark merge gate active
id: mark_active
uses: actions/github-script@v7
with:
github-token: ${{ steps.app-token.outputs.token }}
script: |
core.setOutput('started_at', new Date().toISOString());
await github.rest.issues.addLabels({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: ${{ matrix.pr_number }},
labels: ['claude-merge-gate-active'],
});
- name: Stash merge gate helpers
run: |
# Keep scripts together so require('./gate-config.js') works from /tmp
mkdir -p /tmp/merge-gate-scripts /tmp/gh-actions
cp .github/scripts/merge-gate.js .github/scripts/gate-config.js /tmp/merge-gate-scripts/
cp -R .github/actions/claude-code-action /tmp/gh-actions/
- name: Fetch PR branch
env:
PR_NUMBER: ${{ matrix.pr_number }}
run: |
git fetch origin "pull/${PR_NUMBER}/head:pr-${PR_NUMBER}"
git checkout "pr-${PR_NUMBER}"
mkdir -p .github/actions
rm -rf .github/actions/claude-code-action
cp -R /tmp/gh-actions/claude-code-action .github/actions/
- name: Claude merge gate assessment (read-only)
id: assess
uses: ./.github/actions/claude-code-action
env:
GH_TOKEN: ${{ steps.app-token.outputs.token }}
CLAUDE_CODE_SUBAGENT_MODEL: inherit
ANTHROPIC_DEFAULT_SONNET_MODEL: claude-sonnet-4-6
with:
claude_code_oauth_token: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }}
github_token: ${{ steps.app-token.outputs.token }}
model: claude-opus-4-8
trigger_phrase: '@claude-merge-gate-internal'
direct_prompt: |
You are the MERGE GATE reviewer for PraisonAI PR #${{ matrix.pr_number }}.
READ-ONLY: do not modify code, commit, push, rebase, or create branches.
CRITICAL OUTPUT RULE:
Post exactly one PR comment via GitHub MCP containing either:
MERGE_GATE_VERDICT: APPROVE
or
MERGE_GATE_VERDICT: BLOCK
followed by a short rationale on the next lines.
NEVER include @claude in any comment. NEVER mention merge conflict resolution.
Review checklist:
1. Read AGENTS.md and the PR diff on the current branch.
2. Read all PR comments and reviews (FINAL architecture reviewer / Lead Engineer must have run).
3. Confirm SDK value gate: change strengthens praisonaiagents (no scope creep).
4. Do not bloat the Agent class with additional params — only if absolutely required; we already support many params.
5. If agent.py adds >100 lines or new Agent __init__ params, respond MERGE_GATE_VERDICT: BLOCK (manual review required).
6. BLOCK if labels security/breaking-change/needs-manual-review/release, sensitive paths (.github/workflows, auth, pyproject.toml), secrets in diff, PR >800 lines or >30 files, or SDK changes without tests. Label `merge-gate-ci-only` exempts CI-only changes under `.github/workflows/`, `.github/actions/`, or merge-gate scripts.
7. Confirm no CHANGES_REQUESTED reviews and CI green on HEAD ${{ matrix.head_sha }}.
8. If ready to merge: MERGE_GATE_VERDICT: APPROVE. Otherwise: MERGE_GATE_VERDICT: BLOCK with reasons.
Do not approve or merge via GitHub UI/API — only post the verdict comment.
disallowed_tools: |
Bash
Edit
Write
Replace
allowed_tools: |
View
GlobTool
GrepTool
Read
timeout_minutes: 15
- name: Post fallback BLOCK if Opus did not comment
id: fallback_verdict
if: steps.assess.outcome != 'success'
uses: actions/github-script@v7
with:
github-token: ${{ steps.app-token.outputs.token }}
script: |
const mergeGate = require('/tmp/merge-gate-scripts/merge-gate.js');
const owner = context.repo.owner;
const repo = context.repo.repo;
const prNumber = ${{ matrix.pr_number }};
const ctx = await mergeGate.loadPrContext(github, owner, repo, prNumber);
const minCreatedAt = new Date(Date.now() - 25 * 60 * 1000).toISOString();
const existing = mergeGate.findMergeGateVerdict(
ctx.comments, minCreatedAt, ctx.headPushedAt, { excludeAutomatedFallback: true }
);
if (existing) {
core.info(`Opus verdict already posted: ${existing}`);
return;
}
const check = await mergeGate.evaluatePipelineQuiescent(
github, owner, repo, prNumber, core, { forMergeStep: true }
);
const lines = [
'MERGE_GATE_VERDICT: BLOCK',
'',
'Automated fallback — Opus merge gate assessment did not complete.',
];
if (check.ready) {
lines.push('Deterministic gates passed, but Opus MERGE_GATE_VERDICT is required before merge.');
} else {
lines.push('Blockers:', ...check.reasons.map((r) => `- ${r}`));
}
await github.rest.issues.createComment({
owner, repo, issue_number: prNumber,
body: lines.join('\n'),
});
core.info('Posted fallback BLOCK — Opus verdict required');
- name: Require Opus MERGE_GATE_VERDICT
uses: actions/github-script@v7
with:
github-token: ${{ steps.app-token.outputs.token }}
script: |
const mergeGate = require('/tmp/merge-gate-scripts/merge-gate.js');
const comments = await mergeGate.listAllComments(
github, context.repo.owner, context.repo.repo, ${{ matrix.pr_number }}
);
const ctx = await mergeGate.loadPrContext(
github, context.repo.owner, context.repo.repo, ${{ matrix.pr_number }}
);
const minCreatedAt = new Date(Date.now() - 25 * 60 * 1000).toISOString();
const verdict = mergeGate.findMergeGateVerdict(
comments, minCreatedAt, ctx.headPushedAt, { excludeAutomatedFallback: true }
);
if (!verdict) {
core.setFailed(
'Opus merge gate assessment must post MERGE_GATE_VERDICT on this PR (automated fallback APPROVE is not accepted).'
);
return;
}
core.info(`Opus merge gate verdict: ${verdict}`);
core.setOutput('verdict', verdict);
- name: Remove merge gate active label
if: always()
uses: actions/github-script@v7
with:
github-token: ${{ steps.app-token.outputs.token }}
script: |
const prNumber = ${{ matrix.pr_number }};
try {
await github.rest.issues.removeLabel({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: prNumber,
name: 'claude-merge-gate-active',
});
core.info(`Removed claude-merge-gate-active from PR #${prNumber}`);
} catch (e) {
core.info(`Label claude-merge-gate-active already removed or missing on PR #${prNumber}`);
}
- name: Fail job if assessment failed
if: steps.assess.outcome == 'failure'
run: exit 1
merge-only:
needs: [scan-candidates, claude-assess]
if: |
always() &&
needs.scan-candidates.outputs.has_candidates == 'true' &&
needs.claude-assess.result == 'success'
runs-on: ubuntu-latest
strategy:
fail-fast: false
matrix: ${{ fromJson(needs.scan-candidates.outputs.matrix) }}
permissions:
contents: write
pull-requests: write
issues: write
actions: read
id-token: write
steps:
- uses: actions/checkout@v4
- name: Generate GitHub App token
id: app-token
uses: actions/create-github-app-token@v1
with:
app-id: ${{ secrets.CLAUDE_APP_ID }}
private-key: ${{ secrets.CLAUDE_APP_PRIVATE_KEY }}
owner: ${{ github.repository_owner }}
- name: Re-check gates and merge
uses: actions/github-script@v7
env:
MERGE_METHOD: ${{ github.event.inputs.merge_method || 'merge' }}
SCAN_HEAD_SHA: ${{ matrix.head_sha }}
with:
github-token: ${{ steps.app-token.outputs.token }}
script: |
const path = require('path');
const mergeGate = require(path.join(process.env.GITHUB_WORKSPACE, '.github/scripts/merge-gate.js'));
const owner = context.repo.owner;
const repo = context.repo.repo;
const prNumber = ${{ matrix.pr_number }};
const scanHeadSha = process.env.SCAN_HEAD_SHA;
const removeLabels = async () => {
try {
await github.rest.issues.removeLabel({
owner, repo, issue_number: prNumber, name: 'claude-merge-gate-active',
});
} catch (e) {
core.info('Label claude-merge-gate-active already removed or missing');
}
};
try {
const ctx = await mergeGate.loadPrContext(github, owner, repo, prNumber);
const comments = ctx.comments;
const verdictMin = new Date(Date.now() - 25 * 60 * 1000).toISOString();
const verdict = mergeGate.findMergeGateVerdict(
comments, verdictMin, ctx.headPushedAt, { excludeAutomatedFallback: true }
);
if (verdict !== 'APPROVE') {
core.info(`Skip merge: verdict=${verdict || 'NONE'}`);
await removeLabels();
return;
}
const check = await mergeGate.evaluatePipelineQuiescent(
github, owner, repo, prNumber, core, { forMergeStep: true }
);
if (!check.ready) {
core.info(`Skip merge: ${check.reasons.join(', ')}`);
await removeLabels();
return;
}
if (check.headSha !== scanHeadSha) {
core.info(`Skip merge: HEAD moved ${scanHeadSha.slice(0, 7)} → ${check.headSha.slice(0, 7)}`);
await removeLabels();
return;
}
const method = process.env.MERGE_METHOD || 'merge';
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
let merged = false;
for (let attempt = 1; attempt <= 3; attempt++) {
try {
await github.rest.pulls.merge({
owner, repo, pull_number: prNumber, merge_method: method,
});
merged = true;
break;
} catch (e) {
const msg = (e.message || '').toLowerCase();
if (attempt < 3 && msg.includes('base branch was modified')) {
core.info(`Merge attempt ${attempt} failed (base moved), retrying in 8s...`);
await sleep(8000);
const recheck = await mergeGate.evaluatePipelineQuiescent(
github, owner, repo, prNumber, core, { forMergeStep: true }
);
if (!recheck.ready) {
core.info(`Skip merge retry: ${recheck.reasons.join(', ')}`);
await removeLabels();
return;
}
continue;
}
throw e;
}
}
if (!merged) {
core.info('Skip merge: exhausted retries');
await removeLabels();
return;
}
await github.rest.issues.addLabels({
owner, repo, issue_number: prNumber, labels: ['auto-merged-by-gate'],
});
await github.rest.issues.createComment({
owner, repo, issue_number: prNumber,
body: [
'Merged by **Claude PR merge gate** (`claude-merge-gate.yml`).',
`Verdict: MERGE_GATE_VERDICT: APPROVE`,
`SHA: \`${check.headSha.slice(0, 7)}\``,
`Method: ${method}`,
].join('\n'),
});
core.info(`Merged PR #${prNumber}`);
} finally {
await removeLabels();
}
+331 -79
View File
@@ -1,13 +1,16 @@
name: Claude Assistant
# Triggers:
# - issues:opened → lightweight triage (welcome comment + label)
# - 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.
@@ -21,20 +24,17 @@ on:
types: [opened, labeled, assigned]
pull_request_review:
types: [submitted]
permissions:
contents: write
pull-requests: write
issues: write
actions: read
id-token: write
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 from external users)
# Posts a welcome comment. Does NOT execute Claude Code.
# To escalate to full implementation, a maintainer adds the `claude` label
# or mentions @claude in a comment — which triggers Job 2.
# 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: |
@@ -46,87 +46,357 @@ jobs:
issues: write
contents: read
steps:
- name: Post welcome comment
- 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: ${{ secrets.GITHUB_TOKEN }}
github-token: ${{ steps.github-token.outputs.token }}
script: |
const body = [
'👋 Thanks for opening an issue in **PraisonAIUI**.',
'',
'A maintainer will triage this shortly. If you would like Claude to investigate and draft a fix, a maintainer can:',
'',
'- Apply the `claude` label to this issue, **or**',
'- Mention `@claude` in a comment with any additional instructions.',
'',
'Please make sure your issue includes:',
'',
'1. A clear description of the behaviour you expect vs what you see.',
'2. Minimal reproduction steps (code snippet or `aiui.template.yaml`).',
'3. Version: `python -c "import praisonaiui; print(praisonaiui.__version__)"`.',
'4. Any relevant log output.',
'',
'See [AGENTS.md](https://github.com/MervinPraison/PraisonAIUI/blob/main/AGENTS.md) for project conventions.'
].join('\n');
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
);
await github.rest.issues.createComment({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: context.issue.number,
body
});
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
# Runs when:
# - A comment containing `@claude` is posted on an issue or PR
# - The `claude` label is added to an issue
# - The issue is assigned to a Claude bot
# - A PR review or review-comment contains `@claude`
# 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-response:
env:
ISSUE_NUMBER_RESOLVED: ${{ github.event.issue.number || github.event.pull_request.number }}
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]' &&
(
(github.event_name == 'issue_comment' && contains(github.event.comment.body, '@claude')) ||
(github.event_name == 'pull_request_review_comment' && contains(github.event.comment.body, '@claude')) ||
(github.event_name == 'pull_request_review' && contains(github.event.review.body, '@claude')) ||
(github.event_name == 'issues' && github.event.action == 'labeled' && contains(toJSON(github.event.label), 'claude')) ||
(github.event_name == 'issues' && github.event.action == 'assigned')
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
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: ${{ secrets.GITHUB_TOKEN }}
ref: ${{ github.event.issue.pull_request && format('refs/pull/{0}/head', github.event.issue.number) || github.event.repository.default_branch }}
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: Run Claude Code Action
- 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: ${{ secrets.GITHUB_TOKEN }}
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: ${{ secrets.GITHUB_TOKEN }}
github_token: ${{ steps.github-token.outputs.token }}
model: claude-opus-4-8
trigger_phrase: "@claude"
label_trigger: "claude"
allowed_bots: "praisonai-triage-agent[bot]"
timeout_minutes: "30"
direct_prompt: |
${{ steps.check_fork.outputs.is_fork == 'true' && 'CRITICAL: FORK PR — comment only, do not push.\n\n' || 'PR on MervinPraison/PraisonAIUI — SAME REPO, NOT A FORK. You HAVE push permission to this branch. NEVER say this is a fork.\n\n' }}
${{ steps.check_fork.outputs.is_fork != 'true' && steps.check_fork.outputs.merge_state == 'DIRTY' && format('PRIORITY — RESOLVE MERGE CONFLICTS (branch: {0}, PR #{1}):\n- DO NOT create a new branch or PR\n- git fetch origin main && git rebase origin/main\n- Resolve conflicts: keep this PR intent, merge in main newer logic\n- git add -A && git rebase --continue (repeat until done)\n- git push --force-with-lease origin {0}\n- Run targeted pytest/ruff on changed paths\n- Comment files resolved and what you skipped\n- Only after conflicts are resolved, proceed with review phases below if still needed\n\n', steps.check_fork.outputs.pr_branch, steps.check_fork.outputs.pr_number) || '' }}
${{ steps.check_fork.outputs.merge_state != 'DIRTY' && format('CONTEXT: PR #{0} on branch {1}. merge_state={2}.\n\n', steps.check_fork.outputs.pr_number, steps.check_fork.outputs.pr_branch, steps.check_fork.outputs.merge_state) || '' }}
You are the Lead Engineer doing FINAL review on this open PR in MervinPraison/PraisonAIUI.
Read ALL reviewer comments (CodeRabbit, Gemini, Qodo, Copilot, human). Follow AGENTS.md.
SCOPE: src/praisonaiui, src/frontend, tests, docs only.
**Phase 1 — Review** architecture, security, tests, backward compat, import-time budget.
**Phase 2 — FIX (mandatory)** implement every VALID issue on THIS branch. Push commits before Phase 3.
Do NOT ask the author to fix. Do NOT open a new PR. Do NOT claim fork restrictions unless is_fork is true.
**Phase 3 — Verdict** approve only after fixes are pushed and tests pass; else request changes with specifics.
git config --global user.name "MervinPraison"
git config --global user.email "[email protected]"
gh auth setup-git
git status
git branch --show-current
allowed_tools: |
Bash(git:*)
Bash(python:*)
Bash(pip:*)
Bash(pytest:*)
Bash(ruff:*)
Bash(gh:*)
Bash(npm:*)
View
GlobTool
GrepTool
BatchTool
Edit
Replace
- name: Run Claude Code Action
if: steps.check_fork.outputs.is_pr_review != 'true'
uses: anthropics/claude-code-action@beta
env:
GH_TOKEN: ${{ steps.github-token.outputs.token }}
with:
allowed_bots: 'praisonai-triage-agent[bot]'
claude_code_oauth_token: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }}
github_token: ${{ steps.github-token.outputs.token }}
model: claude-opus-4-8
mode: ${{ github.event_name == 'workflow_dispatch' && 'agent' || 'tag' }}
trigger_phrase: "@claude"
label_trigger: "claude"
timeout_minutes: "30"
direct_prompt: |
${{ steps.check_fork.outputs.is_fork == 'true' && 'CRITICAL: THIS IS A PULL REQUEST FROM A FORK. YOU DO NOT HAVE PUSH PERMISSIONS TO THIS REPOSITORY. ONLY READ FILES, VALIDATE CODE, AND PROVIDE YOUR COMMENTS/FEEDBACK DIRECTLY IN THIS PR VIA COMMENTS. DO NOT ATTEMPT TO PUSH OR CREATE PULL REQUESTS.\n\n' || 'NOTE: The branch is under MervinPraison/PraisonAIUI (not a fork). You are able to make modifications and push directly to this branch.\n\n' }}
You are implementing changes in **MervinPraison/PraisonAIUI** (YAML-driven server-driven UI with agentic chat).
Follow `AGENTS.md` at the repository root. If a conflict arises between this prompt and `AGENTS.md`, `AGENTS.md` wins.
═══════════════════════════════════════════════════════════════
STEP 0 — GIT & GITHUB CLI
═══════════════════════════════════════════════════════════════
git config --global user.name "github-actions[bot]"
git config --global user.email "41898282+github-actions[bot]@users.noreply.github.com"
git config --global user.name "MervinPraison"
git config --global user.email "454862+MervinPraison@users.noreply.github.com"
gh auth setup-git
═══════════════════════════════════════════════════════════════
@@ -204,14 +474,6 @@ jobs:
# 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}'"
# 4. Real agentic end-to-end test (write this into tests/integration/
# if your feature changes the agent-facing API):
# from praisonaiagents import Agent
# import praisonaiui as aiui
# agent = Agent(name="demo", instructions="You are a helpful assistant")
# result = agent.start("Say hello in one sentence")
# print(result)
If anything fails, fix the root cause (never skip, xfail, or delete the test).
═══════════════════════════════════════════════════════════════
@@ -233,10 +495,6 @@ jobs:
<what changed in one paragraph>
## Before / After
<copy-pasteable snippet per public API change>
## Acceptance criteria
<copy from the issue; tick boxes only with commit SHA + file path evidence>
@@ -246,12 +504,6 @@ jobs:
\`\`\`
<paste pytest output>
\`\`\`
## Import-time proof
\`\`\`
<paste the python -c import-time output>
\`\`\`
"
fi
+183
View File
@@ -0,0 +1,183 @@
name: Auto Merge Conflict → Claude
# Detects internal PRs with mergeStateStatus=DIRTY and posts @claude to trigger claude.yml.
on:
push:
branches: [main]
pull_request:
types: [opened, synchronize, reopened]
workflow_dispatch:
schedule:
- cron: '0 */6 * * *'
concurrency:
group: merge-conflict-scan-${{ github.repository }}
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: write
issues: write
jobs:
detect-and-trigger:
runs-on: ubuntu-latest
steps:
- name: Detect conflicting PRs and trigger Claude
uses: actions/github-script@v7
with:
github-token: ${{ env.GH_TOKEN }}
script: |
const owner = context.repo.owner;
const repo = context.repo.repo;
const baseRepo = `${owner}/${repo}`;
const COOLDOWN_MS = 12 * 60 * 60 * 1000;
const AUTO_ACTORS = ['github-actions[bot]', 'MervinPraison'];
const LABEL = 'claude-conflict-pending';
const COMMENT_BODY = [
'@claude this PR has merge conflicts with `main`.',
'Please rebase onto latest `main`, resolve conflicts (keep this PR\'s intent, merge in newer main logic),',
'run targeted tests, and force-push with `--force-with-lease`.',
'Comment which files you resolved.'
].join(' ');
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
async function getMergeState(prNumber) {
const query = `
query($owner: String!, $repo: String!, $number: Int!) {
repository(owner: $owner, name: $repo) {
pullRequest(number: $number) {
mergeStateStatus
maintainerCanModify
isDraft
headRef { repository { nameWithOwner } }
}
}
}
`;
for (let attempt = 0; attempt < 3; attempt++) {
const result = await github.graphql(query, { owner, repo, number: prNumber });
const prGql = result.repository.pullRequest;
const status = (prGql?.mergeStateStatus || '').toUpperCase();
if (status && status !== 'UNKNOWN') {
return {
status,
isDraft: prGql.isDraft,
headRepo: prGql.headRef?.repository?.nameWithOwner,
maintainerCanModify: prGql.maintainerCanModify === true,
};
}
if (attempt < 2) await sleep(10000);
}
const pr = await github.rest.pulls.get({ owner, repo, pull_number: prNumber });
return {
status: (pr.data.mergeable_state || '').toUpperCase(),
isDraft: pr.data.draft,
headRepo: pr.data.head.repo?.full_name,
maintainerCanModify: pr.data.maintainer_can_modify === true,
};
}
function hasFinalClaudeReviewTrigger(comments) {
return comments.some((c) => {
const body = (c.body || '').toLowerCase();
if (!AUTO_ACTORS.includes(c.user.login)) return false;
if (!body.includes('@claude')) return false;
if (body.includes('merge conflict')) return false;
return body.includes('final architecture reviewer') || body.includes('lead engineer');
});
}
function hasRecentAutoComment(comments) {
const cutoff = Date.now() - COOLDOWN_MS;
return comments.some((c) => {
if (!AUTO_ACTORS.includes(c.user.login)) return false;
const body = (c.body || '').toLowerCase();
if (!body.includes('@claude') || !body.includes('merge conflict')) return false;
return new Date(c.created_at).getTime() > cutoff;
});
}
async function processPR(prNumber) {
const { status, isDraft, headRepo, maintainerCanModify } = await getMergeState(prNumber);
core.info(`PR #${prNumber}: mergeState=${status}, head=${headRepo}, draft=${isDraft}, maintainerCanModify=${maintainerCanModify}`);
const { data: issue } = await github.rest.issues.get({
owner,
repo,
issue_number: prNumber,
});
const labels = issue.labels.map((l) => l.name);
if (status !== 'DIRTY' && labels.includes(LABEL)) {
await github.rest.issues.removeLabel({
owner,
repo,
issue_number: prNumber,
name: LABEL,
});
core.info(`Removed ${LABEL} from PR #${prNumber}`);
}
if (status !== 'DIRTY') return { skipped: true, reason: 'not dirty' };
if (isDraft) return { skipped: true, reason: 'draft' };
if (headRepo && headRepo !== baseRepo && !maintainerCanModify) {
return { skipped: true, reason: 'fork without maintainer edits' };
}
if (labels.includes(LABEL)) {
return { skipped: true, reason: 'conflict resolution pending' };
}
const { data: comments } = await github.rest.issues.listComments({
owner,
repo,
issue_number: prNumber,
per_page: 100,
});
if (hasRecentAutoComment(comments)) {
return { skipped: true, reason: 'recent auto comment' };
}
if (!hasFinalClaudeReviewTrigger(comments)) {
return { skipped: true, reason: 'awaiting final claude review trigger' };
}
await github.rest.issues.addLabels({
owner,
repo,
issue_number: prNumber,
labels: [LABEL],
});
await github.rest.issues.createComment({
owner,
repo,
issue_number: prNumber,
body: COMMENT_BODY,
});
return { triggered: true };
}
let prNumbers = [];
if (context.eventName === 'pull_request') {
prNumbers = [context.payload.pull_request.number];
} else {
const prs = await github.paginate(github.rest.pulls.list, {
owner,
repo,
state: 'open',
per_page: 100,
});
prNumbers = prs.map((p) => p.number);
}
const results = [];
for (const num of prNumbers) {
results.push({ pr: num, ...(await processPR(num)) });
}
core.summary.addRaw('```json\n' + JSON.stringify(results, null, 2) + '\n```');
await core.summary.write();
+129
View File
@@ -0,0 +1,129 @@
name: Nightly Release Gate
# Preflight before PyPI Release (patch bump).
# Primary: CI success on main (workflow_run).
# Backstop: nightly cron at 00:00 UTC.
on:
schedule:
- cron: '0 0 * * *'
workflow_dispatch:
inputs:
dry_run:
description: 'Run preflight only; do not dispatch pypi-release.yml'
required: false
default: false
type: boolean
workflow_run:
workflows:
- CI
types:
- completed
branches:
- main
permissions:
contents: read
actions: write
concurrency:
group: nightly-release-gate
cancel-in-progress: false
jobs:
preflight:
if: |
github.event_name == 'workflow_dispatch' ||
github.event_name == 'schedule' ||
(
github.event_name == 'workflow_run' &&
github.event.workflow_run.conclusion == 'success' &&
github.event.workflow_run.head_branch == 'main' &&
github.event.workflow_run.event == 'push'
)
runs-on: ubuntu-latest
env:
GH_TOKEN: ${{ secrets.GH_TOKEN }}
steps:
- name: Resolve trigger context
id: ctx
run: |
if [ "${{ github.event_name }}" = "workflow_run" ]; then
echo "checkout_sha=${{ github.event.workflow_run.head_sha }}" >> "$GITHUB_OUTPUT"
echo "source=ci" >> "$GITHUB_OUTPUT"
echo "is_ci=true" >> "$GITHUB_OUTPUT"
else
echo "checkout_sha=main" >> "$GITHUB_OUTPUT"
echo "source=nightly" >> "$GITHUB_OUTPUT"
echo "is_ci=false" >> "$GITHUB_OUTPUT"
fi
- name: Checkout
uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
with:
ref: ${{ steps.ctx.outputs.checkout_sha }}
fetch-depth: 0
- name: Fetch main and tags
run: git fetch --tags origin main
- name: Preflight — path changes, CI SHA, dedupe
id: preflight
uses: actions/github-script@v7
env:
HEAD_SHA: ${{ steps.ctx.outputs.checkout_sha }}
IS_CI: ${{ steps.ctx.outputs.is_ci }}
with:
github-token: ${{ secrets.GH_TOKEN }}
script: |
const path = require('path');
const rg = require(path.join(process.env.GITHUB_WORKSPACE, '.github/scripts/release-gate.js'));
const owner = context.repo.owner;
const repo = context.repo.repo;
const headSha = process.env.HEAD_SHA;
const isCi = process.env.IS_CI === 'true';
const result = await rg.evaluateReleasePreflight(
github, owner, repo,
{ headSha, isCiTrigger: isCi, bump: 'patch' },
core
);
core.setOutput('should_release', result.ready ? 'true' : 'false');
core.setOutput('reason', result.reasons.join('; '));
core.setOutput('head_sha', result.headSha || headSha);
core.setOutput('last_tag', result.lastTag || '');
core.setOutput('target_version', result.targetVersion || '');
core.summary.addRaw('## Release gate preflight\n');
core.summary.addRaw(`Ready: ${result.ready}\n`);
core.summary.addRaw(`Reason: ${result.reasons.join('; ')}\n`);
if (result.targetVersion) {
core.summary.addRaw(`Target: aiui==${result.targetVersion}\n`);
}
await core.summary.write();
if (!result.ready) {
core.info(`Skip release: ${result.reasons.join('; ')}`);
}
- name: Dry run summary
if: inputs.dry_run == true
run: |
echo "Dry run — preflight only, no pypi-release dispatch."
echo "should_release=${{ steps.preflight.outputs.should_release }}"
echo "reason=${{ steps.preflight.outputs.reason }}"
- name: Dispatch PyPI Release (patch)
if: steps.preflight.outputs.should_release == 'true' && inputs.dry_run != true
run: |
set -euo pipefail
gh workflow run pypi-release.yml \
--repo "${GITHUB_REPOSITORY}" \
--ref main \
-f bump=patch \
-f source=${{ steps.ctx.outputs.source }} \
-f trigger_sha=${{ steps.preflight.outputs.head_sha }} \
-f dry_run=false
echo "Dispatched pypi-release.yml (patch, source=${{ steps.ctx.outputs.source }})"
echo "Uses pypi-auto environment for ci/nightly patch releases."
@@ -0,0 +1,42 @@
name: Pipeline Status Sync
# Sync pipeline/* stage and blocker labels on open internal PRs.
on:
schedule:
- cron: '*/10 * * * *'
workflow_dispatch:
permissions:
pull-requests: read
issues: write
actions: write
id-token: write
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
);
+232
View File
@@ -0,0 +1,232 @@
name: PyPI Release
# Requires GitHub Environment "pypi" (manual) or "pypi-auto" (ci/nightly patch).
# Secrets: PYPI_TOKEN (or PYPI_API_TOKEN), GH_TOKEN
on:
workflow_dispatch:
inputs:
bump:
description: 'Version bump type for aiui'
required: true
default: patch
type: choice
options:
- patch
- minor
- major
dry_run:
description: 'Preview versions and steps without publishing or pushing'
required: false
default: false
type: boolean
version:
description: 'Override version (empty = auto bump from pyproject.toml)'
required: false
default: ''
type: string
source:
description: 'Trigger source (audit trail)'
required: false
default: manual
type: string
trigger_sha:
description: 'SHA that triggered this release'
required: false
default: ''
type: string
permissions:
contents: write
concurrency:
group: pypi-release
cancel-in-progress: false
jobs:
release:
if: github.ref == 'refs/heads/main'
runs-on: ubuntu-latest
environment: >-
${{
(inputs.source == 'ci' || inputs.source == 'nightly') &&
inputs.bump == 'patch' &&
'pypi-auto' || 'pypi'
}}
env:
UV_PUBLISH_TOKEN: ${{ secrets.PYPI_TOKEN || secrets.PYPI_API_TOKEN }}
GH_TOKEN: ${{ secrets.GH_TOKEN }}
PACKAGE: aiui
steps:
- name: Release audit
run: |
echo "Source: ${{ inputs.source }}"
echo "Trigger SHA: ${{ inputs.trigger_sha }}"
echo "Bump: ${{ inputs.bump }}"
echo "Environment: ${{ (inputs.source == 'ci' || inputs.source == 'nightly') && inputs.bump == 'patch' && 'pypi-auto' || 'pypi' }}"
- name: Checkout main
uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
with:
ref: main
fetch-depth: 0
token: ${{ secrets.GH_TOKEN }}
- name: Set up Python
uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5
with:
python-version: '3.11'
- name: Set up uv
uses: astral-sh/setup-uv@38f3f104447c67c051c4a08e39b64a148898af3a # v4
- name: Configure git
run: |
git config user.name "MervinPraison"
git config user.email "[email protected]"
gh auth setup-git
- name: Compute release version
id: versions
env:
BUMP: ${{ inputs.bump }}
VERSION_OVERRIDE: ${{ inputs.version }}
run: |
python <<'PY'
import os
import re
import sys
from pathlib import Path
SEMVER = re.compile(r"^\d+\.\d+\.\d+$")
bump = os.environ["BUMP"]
override = os.environ.get("VERSION_OVERRIDE", "").strip()
def bump_version(version: str) -> str:
parts = version.split(".")
if len(parts) != 3:
print(f"Invalid version: {version}", file=sys.stderr)
sys.exit(1)
maj, min_, patch = (int(parts[0]), int(parts[1]), int(parts[2]))
if bump == "major":
return f"{maj + 1}.0.0"
if bump == "minor":
return f"{maj}.{min_ + 1}.0"
return f"{maj}.{min_}.{patch + 1}"
pyproject = Path("pyproject.toml")
content = pyproject.read_text()
match = re.search(r'^version\s*=\s*"([^"]+)"', content, re.MULTILINE)
if not match:
print("Could not read version from pyproject.toml", file=sys.stderr)
sys.exit(1)
current = match.group(1)
if not SEMVER.match(current):
print(f"Invalid current version: {current}", file=sys.stderr)
sys.exit(1)
if override:
if not SEMVER.match(override):
print(f"Invalid version override: {override}", file=sys.stderr)
sys.exit(1)
new_version = override
else:
new_version = bump_version(current)
with open(os.environ["GITHUB_OUTPUT"], "a", encoding="utf-8") as fh:
fh.write(f"current_version={current}\n")
fh.write(f"new_version={new_version}\n")
print(f"aiui: {current} -> {new_version}")
PY
- name: Dry run summary
if: inputs.dry_run
run: |
echo "Dry run — no publish, commit, or push."
echo "Package: ${{ env.PACKAGE }}"
echo "Version: ${{ steps.versions.outputs.current_version }} -> ${{ steps.versions.outputs.new_version }}"
echo "Would: bump pyproject.toml + __version__.py, uv publish, commit, tag, GitHub release"
- name: Bump version files
if: inputs.dry_run != true
env:
CURRENT: ${{ steps.versions.outputs.current_version }}
NEW_VERSION: ${{ steps.versions.outputs.new_version }}
run: |
set -euo pipefail
python <<'PY'
import os
import re
from pathlib import Path
current = os.environ["CURRENT"]
new = os.environ["NEW_VERSION"]
pyproject = Path("pyproject.toml")
content = pyproject.read_text()
pyproject.write_text(content.replace(f'version = "{current}"', f'version = "{new}"', 1))
version_py = Path("src/praisonaiui/__version__.py")
vcontent = version_py.read_text()
version_py.write_text(re.sub(
r'__version__ = "[^"]+"',
f'__version__ = "{new}"',
vcontent,
count=1,
))
PY
- name: Publish aiui to PyPI
if: inputs.dry_run != true
run: |
set -euo pipefail
rm -rf dist
uv lock
uv build
uv publish
- name: Wait for aiui on PyPI
if: inputs.dry_run != true
run: |
set -euo pipefail
VERSION="${{ steps.versions.outputs.new_version }}"
MAX_WAIT=600
INTERVAL=30
START=$(date +%s)
while true; do
if curl -fsSL "https://pypi.org/pypi/${PACKAGE}/${VERSION}/json" >/dev/null; then
echo "✅ ${PACKAGE}==${VERSION} is available on PyPI"
exit 0
fi
ELAPSED=$(( $(date +%s) - START ))
if [ "$ELAPSED" -ge "$MAX_WAIT" ]; then
echo "❌ Timeout waiting for ${PACKAGE}==${VERSION} on PyPI"
exit 1
fi
echo "⏳ Waiting for ${PACKAGE}==${VERSION} ($(( MAX_WAIT - ELAPSED ))s remaining...)"
sleep "$INTERVAL"
done
- name: Commit, tag, and release
if: inputs.dry_run != true
env:
VERSION: ${{ steps.versions.outputs.new_version }}
run: |
set -euo pipefail
TAG="v${VERSION}"
git add pyproject.toml uv.lock src/praisonaiui/__version__.py
git diff --cached --quiet && { echo "No version files to commit"; exit 0; }
git commit -m "Release ${TAG}"
git tag -f "${TAG}"
git pull --rebase origin main
git tag -f "${TAG}"
git push origin main
git push --tags -f
gh release create "${TAG}" \
--title "PraisonAIUI ${TAG}" \
--notes "Release ${TAG}" \
--latest
- name: Verify release
if: inputs.dry_run != true
run: echo "✅ ${{ env.PACKAGE }}==${{ steps.versions.outputs.new_version }}"
@@ -0,0 +1,33 @@
name: Sync Claude Secrets from PraisonAI
# GitHub does not allow reading secret values back via API.
# This workflow triggers the sync job in MervinPraison/PraisonAI, which copies:
# CLAUDE_APP_ID, CLAUDE_APP_PRIVATE_KEY, CLAUDE_CODE_OAUTH_TOKEN, GH_TOKEN
#
# Requires SECRETS_ADMIN_PAT in PraisonAI (repo Settings → Secrets).
# If sync fails, copy secrets manually:
# PraisonAI → Settings → Secrets → Actions
# PraisonAIUI → Settings → Secrets → Actions (same names)
on:
workflow_dispatch:
permissions:
contents: read
jobs:
trigger-sync:
runs-on: ubuntu-latest
steps:
- name: Trigger PraisonAI secret sync
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
gh workflow run sync-secrets-to-aiui.yml \
--repo MervinPraison/PraisonAI \
--ref main \
-f target_repo=MervinPraison/PraisonAIUI
echo "Triggered: https://github.com/MervinPraison/PraisonAI/actions/workflows/sync-secrets-to-aiui.yml"
echo ""
echo "If sync fails (401/403), refresh SECRETS_ADMIN_PAT in PraisonAI,"
echo "or copy CLAUDE_APP_ID + CLAUDE_APP_PRIVATE_KEY + GH_TOKEN manually."
+2 -1
View File
@@ -55,4 +55,5 @@ aiui-output/
_site
.praisonai
tmp/
tmp/
*.db
+17 -1
View File
@@ -138,7 +138,23 @@ Final rule: conclude only when evidence shows missing = 0.
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
PUBLISH WORKFLOW (reference)
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
Step 1 — Publish praisonaiagents (Core SDK):
GitHub Actions (recommended):
GitHub → Actions → "PyPI Release" → Run workflow → bump: patch
Requires repo secrets: PYPI_TOKEN, GH_TOKEN
Requires GitHub Environment "pypi" with required reviewers (Settings → Environments)
Manual — Publish aiui (PraisonAIUI):
cd /Users/praison/PraisonAIUI
# Bump version in pyproject.toml and src/praisonaiui/__version__.py
rm -rf dist && uv lock && uv build && uv publish --token "$PYPI_TOKEN"
git add pyproject.toml uv.lock src/praisonaiui/__version__.py
git commit -m "Release vX.Y.Z" && git tag vX.Y.Z && git push && git push --tags
gh release create vX.Y.Z --title "PraisonAIUI vX.Y.Z" --notes "Release vX.Y.Z" --latest
PyPI package name: aiui
Verify: pip index versions aiui | head -1
PraisonAI stack (separate repo — publish SDK before wrapper):
cd /Users/praison/praisonai-package/src/praisonai-agents
praisonai publish pypi
# Uses uv internally: uv lock → uv build → uv publish
+6 -3
View File
@@ -111,9 +111,12 @@ YAML config → Pydantic models → Compiler → JSON manifests → React SPA
### Frontend Build
1. Source in `src/frontend/src/` (React 19 + TypeScript + Vite)
2. Build: `cd src/frontend && npm run build`
3. Output: `src/frontend/dist/`
4. Sync to templates: copy `dist/{index.html,assets/,icon.svg}` → `src/praisonaiui/templates/frontend/`
5. Compiler copies templates to output dir during `aiui build`
3. Optional A2UI renderer (full agent-driven UI catalog): `npm install @a2ui/react @a2ui/web_core`
4. Output: `src/frontend/dist/`
5. Sync to templates: copy `dist/{index.html,assets/,icon.svg}` → `src/praisonaiui/templates/frontend/`
6. Compiler copies templates to output dir during `aiui build`
Chat inline media (agent-generated images): `RunEventType.MESSAGE_ELEMENT` — see `docs/features/image-preview-chat.md`
## Gotchas
+79
View File
@@ -0,0 +1,79 @@
# Bootstrap Architecture
PraisonAIUI provides a unified bootstrap entrypoint through `aiui run app.py` that automatically detects and handles different application patterns.
## Supported Patterns
### 1. Chat Applications
```python
import praisonaiui as aiui
@aiui.reply
async def on_message(message: str):
await aiui.say(f"You said: {message}")
```
**Run**: `aiui run app.py`
### 2. Dashboard Applications
```python
from praisonaiui.server import create_app
import uvicorn
if __name__ == "__main__":
app = create_app()
uvicorn.run(app, host="0.0.0.0", port=8082)
```
**Run**: `aiui run app.py` (automatically detects `create_app()` pattern)
### 3. Gateway Applications
```python
import asyncio
from praisonaiui.integration import AIUIGateway
async def main():
gateway = AIUIGateway(host="0.0.0.0", port=8083)
# ... register agents
await gateway.start()
if __name__ == "__main__":
asyncio.run(main())
```
**Run**: `aiui run app.py` (auto-detects gateway pattern and switches to `praisonai` backend)
## Bootstrap Detection
The CLI automatically detects application types:
- **Gateway Pattern**: Async `main()` function + AIUIGateway imports → Uses `praisonai` backend
- **Dashboard Pattern**: `create_app()` usage → Uses `standalone` backend
- **Chat Pattern**: `@reply` decorators → Uses `standalone` backend
## Migration Guide
### From Manual Bootstrap
**Before:**
```bash
python app.py
```
**After:**
```bash
aiui run app.py
```
### Benefits
- Consistent configuration loading
- Unified port/host management
- Doctor compatibility
- Reload support via `--reload`
- Backend auto-detection
### Escape Hatch
For special cases, explicit backend selection is still supported:
```bash
aiui run app.py --backend praisonai # Force gateway backend
aiui run app.py --backend standalone # Force standalone backend
```
+136
View File
@@ -0,0 +1,136 @@
# Health API Reference
The AIUI health API provides endpoints to monitor server and provider health status.
## Health Status Values
The following status values are used throughout the health API:
| Status | Meaning | Color Code |
|--------|---------|------------|
| `"ok"` | Service is functioning normally | Green |
| `"healthy"` | Service is functioning normally (legacy) | Green |
| `"degraded"` | Service is partially functional | Yellow |
| `"error"` | Service has errors | Red |
| `"unknown"` | Status cannot be determined | Yellow |
> **Note:** Both `"ok"` and `"healthy"` indicate successful operation. The system accepts either value for backward compatibility.
## Endpoints
### GET /health
Returns the overall server health status.
**Response:**
```json
{
"status": "ok",
"timestamp": "2026-06-23T10:00:00Z",
"integrated": true,
"sdk_gaps": []
}
```
### GET /api/provider
Returns information about the active AI provider.
**Response:**
```json
{
"name": "PraisonAIProvider",
"module": "praisonaiui.providers",
"status": "ok",
"agents": []
}
```
### GET /api/provider/health
Returns detailed JSON health information from the provider gateway. Always
responds with `Content-Type: application/json`. The base fields from the
provider's `health()` payload are merged with the normalised fields below.
**Response:**
```json
{
"status": "ok",
"provider": "PraisonAIProvider",
"type": "PraisonAIProvider",
"agents": 5,
"detail": "ok"
}
```
| Field | Type | Description |
|-------|------|-------------|
| `status` | string | One of the health status values above. |
| `type` | string | Provider type (from `provider` field, falls back to class name). |
| `agents` | integer | Count of agents reported by the provider. |
| `detail` | string | Human-readable detail; `"ok"` for healthy providers. |
> **Note:** Any unmatched `/api/*` path returns a JSON `404` (never the SPA
> HTML shell), so API clients can rely on parsing the response body.
## CLI Commands
### health-check
Check server health status:
```bash
aiui health-check --server http://127.0.0.1:8000
```
The command displays:
- Green panel for `"ok"` or `"healthy"` status
- Yellow panel for other statuses
- Red error message if server is unreachable
### doctor
Run comprehensive diagnostics:
```bash
aiui doctor --server http://127.0.0.1:8000
```
The doctor command checks:
1. Server Health
2. Provider Status
3. Gateway Status
4. Features Loaded
5. Config Store
6. Datastore
7. Channels
### provider status
Check provider health:
```bash
aiui provider status --server http://127.0.0.1:8000
```
## Python API
```python
from praisonaiui.health_utils import is_success_status
# Check if a status indicates success
if is_success_status("ok"):
print("Service is healthy")
# Available success statuses
from praisonaiui.health_utils import SUCCESS_STATUSES
# frozenset({'ok', 'healthy'})
```
## JavaScript/Dashboard
The dashboard automatically accepts both `"ok"` and `"healthy"` as success statuses:
```javascript
const ok = d && (d.status === 'ok' || d.status === 'healthy');
```
+32
View File
@@ -227,6 +227,25 @@ aiui.set_dashboard(page_header=False) # Hide page titles
---
### `aiui.set_chat_preview(enabled, *, surface_id, width)`
Configure the **chat + canvas** split preview (`chat-canvas` page). The vanilla `chat` page is unchanged.
| Parameter | Type | Default | Description |
|-----------|------|---------|-------------|
| `enabled` | `bool` | `True` | Expose preview settings in `/ui-config.json` |
| `surface_id` | `str` | `"main"` | Default A2UI surface id for the preview panel |
| `width` | `str` | `"40%"` | CSS width of the preview column (e.g. `"38%"`, `"420px"`) |
```python
aiui.set_chat_preview(enabled=True, surface_id="main", width="38%")
aiui.set_pages(["chat-canvas", "chat", "canvas"])
```
See [A2UI canvas](../features/a2ui-canvas.md).
---
### `aiui.register_theme(name, variables)`
Register a custom theme preset. The theme becomes available in the theme picker UI and via `/api/theme`.
@@ -559,6 +578,19 @@ async def on_resume(session_id):
---
### `@aiui.surface_action(surface_id)`
Handle user actions from an A2UI surface (button clicks, form submits).
```python
@aiui.surface_action("main")
async def on_surface_action(action: dict):
# action includes component_id, action name, data, session_id
return {"status": "ok"}
```
---
## Message Functions
Async functions for sending messages to the user. Call inside callback handlers.
+9 -6
View File
@@ -16,7 +16,8 @@ graph TD
GW["WebSocketGateway<br/>REST + WebSocket<br/>Bot management"]
end
CHAT -->|"AG-UI protocol<br/>(SSE events)"| AGUI
CHAT -->|"WebSocket / SSE<br/>(RunEvent protocol)"| GW
CHAT -.->|"AG-UI (optional)<br/>POST /agui"| AGUI
DASH -->|"REST API<br/>(/api/channels, /api/nodes)"| GW
```
@@ -110,7 +111,7 @@ sequenceDiagram
## Using Both Together
`AIUIGateway.start()` in `integration.py` mounts both layers on the same server:
`AIUIGateway.start()` in `integration.py` mounts the gateway and dashboard REST API on one server. Chat uses the built-in WebSocket/SSE `RunEvent` protocol; AG-UI is mounted separately via `AguiFeature` when enabled:
```python
from praisonaiui.integration import AIUIGateway
@@ -121,10 +122,12 @@ agent = Agent(name="assistant", instructions="You are helpful.")
gateway.register_agent(agent)
await gateway.start()
# Chat: POST /agui → AG-UI streaming (via AGUI class)
# Admin: GET /api/channels → Gateway REST API
# Admin: GET /api/nodes → Gateway REST API
# WS: /ws → WebSocket for real-time updates
# Chat: POST /run, WS /api/chat/ws → RunEvent streaming (built-in)
# AG-UI: POST /agui, GET /agui/status → CopilotKit-compatible SSE (AguiFeature)
# Admin: GET /api/channels → Gateway REST API
# Admin: GET /api/nodes → Gateway REST API
# WS: /ws → Legacy gateway WebSocket
# A2UI: GET/POST /api/surfaces/* → Agent-driven surfaces (SurfaceFeature)
```
## Summary
+30 -1
View File
@@ -108,6 +108,34 @@ class EchoProvider(BaseProvider):
yield RunEvent(type=RunEventType.RUN_COMPLETED, content=f"Echo: {message}")
```
### Inline media (images, video, files)
Any provider can attach rich media to chat messages using ``MESSAGE_ELEMENT``
events. The UI renders them inline and persists them in session history.
```python
class ImageProvider(BaseProvider):
async def run(self, message, *, session_id=None, agent_name=None, **kw):
yield RunEvent(type=RunEventType.RUN_STARTED)
# ... call your image API (OpenAI, Stability, custom, etc.) ...
image_url = "https://example.com/generated.png"
yield BaseProvider.message_element_event({
"type": "image",
"url": image_url,
"alt": "Generated image",
})
yield RunEvent(type=RunEventType.RUN_COMPLETED, content="Here is your image.")
```
Tool results from PraisonAI agents are also detected automatically when they
match OpenAI image shapes (``{"data": [{"url": "..."}]}``) or explicit element
dicts — no provider code required.
Callback handlers can use ``Message.add_image()`` or ``await aiui.image(url)``
and the default ``PraisonAIProvider`` maps those to ``MESSAGE_ELEMENT`` events.
Full architecture, data flows, and tests: [Image Preview in Chat](../features/image-preview-chat.md).
### With Tool Calls and Reasoning
```python
@@ -155,7 +183,7 @@ Every event has a `type` field and optional payload fields:
| `step` | `str` | `REASONING_STEP` |
| `error` | `str` | `RUN_ERROR` |
| `agent_name` | `str` | Multi-agent events |
| `extra_data` | `dict` | Custom payload |
| `extra_data` | `dict` | Custom payload (includes `element` for `message_element`) |
### Event Types (27 total)
@@ -178,6 +206,7 @@ Every event has a `type` field and optional payload fields:
| `updating_memory` | Memory being updated |
| `run_paused` | Run paused (e.g. waiting for user) |
| `run_continued` | Run resumed |
| `message_element` | Inline media element (image, video, file, etc.) — see [Image Preview in Chat](../features/image-preview-chat.md) |
=== "Team Events"
+128
View File
@@ -0,0 +1,128 @@
# A2UI canvas — agent-driven UI
PraisonAIUI hosts **A2UI v0.9** surfaces: agents push structured UI messages to a live canvas while users chat in the same window or on a full-page workspace.
For dashboard extension patterns (views, plugins, layout JSON), see [Agent UI host](agent-ui-host.md).
## Quick start
```python
import praisonaiui as aiui
aiui.set_style("dashboard")
aiui.set_chat_preview(enabled=True, surface_id="main", width="38%")
aiui.set_pages(["chat-canvas", "chat", "canvas"])
@aiui.page("agent-canvas", title="Agent Canvas", icon="🖼️")
async def agent_canvas():
return {"_surface": {"id": "main", "messages": []}}
@aiui.surface_action("main")
async def on_surface_action(action: dict):
return {"received": action}
```
Run the full example: [`examples/python/30-a2ui-canvas/app.py`](../../examples/python/30-a2ui-canvas/app.py).
## Dashboard pages
| Page id | Route | Purpose |
|---------|-------|---------|
| `chat-canvas` | `/chat-canvas` | Split view — chat left, live A2UI preview right |
| `chat` | `/chat` | Vanilla chat (unchanged) |
| `canvas` | `/canvas` | Full-page surface workspace |
Enable the split preview with [`set_chat_preview`](../api/python.md#set_chat_previewenabled-surface_id-width). Settings appear in `/ui-config.json` as `chat.preview: { enabled, surfaceId, width }`.
## Agent integration
Agents should call **`send_a2ui_messages`** (from `praisonaiagents.tools.a2ui_tools`) with A2UI message dicts:
```python
send_a2ui_messages(messages=[
{"createSurface": {"surfaceId": "main", "catalogId": "basic"}},
{"updateComponents": {"components": [
{"component": "Text", "text": {"literal": "Hello"}},
{"component": "Button", "text": {"literal": "Submit"},
"action": {"name": "handleSubmit"}},
]}},
])
```
Wrap the tool with **`coerce_a2ui_tool_messages`** so common LLM argument shapes still work:
```python
from praisonaiui.a2ui_utils import coerce_a2ui_tool_messages
@tool
def send_a2ui_messages(messages):
coerced = coerce_a2ui_tool_messages(messages, surface_id="main")
return sdk_send_a2ui(messages=coerced)
```
`coerce_a2ui_tool_messages` accepts:
- A JSON string or list of message dicts
- A dict with a `messages` key
- A bare `components` list (wrapped in `updateComponents`)
- A single component dict (wrapped in `updateComponents`)
- Missing `createSurface` (inserted automatically for the given `surface_id`)
Tool results are detected by [`a2ui_utils`](../../src/praisonaiui/a2ui_utils.py) and ingested into the surface store; connected clients receive WebSocket `a2ui_surface` events.
## HTTP API
| Method | Path | Behaviour |
|--------|------|-----------|
| `GET` | `/api/surfaces` | List surfaces and message counts |
| `GET` | `/api/surfaces/{id}` | Surface state; **200 with `{ messages: [] }`** if not created yet (empty canvas is normal) |
| `POST` | `/api/surfaces/{id}/messages` | Append or replace messages (`replace: true` in body) |
| `POST` | `/api/surfaces/{id}/actions` | User action from a button/component |
| `DELETE` | `/api/surfaces/{id}` | Clear surface |
Realtime updates use the existing dashboard WebSocket (`a2ui_surface` payload).
## CLI
Manage surfaces against a running server (`--server` / `AIUI_SERVER`, default `http://127.0.0.1:8000`):
```bash
aiui surface list
aiui surface get main
aiui surface get main --json
aiui surface push main --file messages.json
aiui surface push main --file messages.json --replace
aiui surface clear main
aiui surface status
```
`messages.json` must contain `{ "messages": [ ... ] }`.
## Frontend modules
Shared dashboard JS (lazy-loaded with `chat-canvas`):
| Module | Role |
|--------|------|
| `surface-utils.js` | Load surface, WebSocket sync, post actions |
| `canvas-preview.js` | Right-hand preview panel |
| `a2ui-mapper.js` | Default DOM mapper (Text, Button, Column, Row, Card, …) |
| `plugins/views/chat-canvas.js` | Composes chat + preview without modifying `chat.js` |
Override rendering: `window.aiui.registerSurfaceRenderer("main", fn)`.
## Troubleshooting
| Symptom | Likely cause | Fix |
|---------|--------------|-----|
| Chat works but canvas stays empty | Agent did not call `send_a2ui_messages` | Strengthen instructions; wrap tool with `coerce_a2ui_tool_messages` |
| Tool error / invalid JSON | LLM passed wrong shape | Use coercion wrapper; include schema prompt from `praisonaiagents.ui.a2ui.adapter` |
| Preview shows raw JSON | Component type not in `a2ui-mapper` | Extend mapper or use supported components |
| `GET /api/surfaces/main` empty | Normal before first push | Push via agent or `aiui surface push` |
| Button click does nothing | No `@aiui.surface_action` handler | Register handler for that `surface_id` |
## Related
- [Agent UI host](agent-ui-host.md) — `registerSurfaceRenderer`, layout tiers
- [Python API — `set_chat_preview`](../api/python.md#set_chat_previewenabled-surface_id-width)
- [Python API — `@aiui.surface_action`](../api/python.md#aiuisurface_actionsurface_id)
+223
View File
@@ -0,0 +1,223 @@
# Agent UI host — modular dashboard shell
PraisonAIUI can host any AI agent backend behind a **modular dashboard shell**: pages, views, layout components, optional plugins, and branding — without forking the frontend.
## Architecture
```text
YAML / Python config → server (pages API, plugins.json) → dashboard.js shell
↓
registerView / BUILTIN_VIEWS / layout JSON / registerComponent
↓
renderComponent() + theme (--db-*)
```
**Extension priority** (first match wins):
1. `window.aiui.registerView(pageId, …)`
2. Dashboard manifest plugins (`dashboard-plugins/*/manifest.json`)
3. Built-in view modules (`plugins/views/*.js` via `BUILTIN_VIEWS`)
4. Server `aiui.layout()` JSON from `@aiui.page()`
5. `window.aiui.registerComponent(type, …)` inside `renderComponent`
6. Generic JSON fallback viewer
## Video Studio
For YAML scene authoring with an external PraisonAI Video engine, see [Video Studio](video-studio.md).
## Quick start (external agent)
```python
import praisonaiui as aiui
from praisonaiui.server import create_app
aiui.set_style("dashboard")
aiui.set_branding(title="Acme Agents", logo="A")
aiui.set_pages(["chat", "tasks", "sessions", "config"])
aiui.set_dashboard(modules=["jobs"], sidebar=True, page_header=True)
@aiui.page("tasks", title="Tasks", icon="📋", group="Work")
async def tasks_page():
return aiui.layout([
aiui.board(columns=[
{"id": "todo", "title": "Todo", "cards": [aiui.card("Review PR", footer="agent-a")]},
{"id": "done", "title": "Done", "cards": [aiui.card("Ship feature")]},
]),
])
app = create_app()
```
See [`examples/python/external-agent-dashboard/app.py`](../../examples/python/external-agent-dashboard/app.py).
## `window.aiui` extension API
| API | Purpose |
|-----|---------|
| `version` | Shell protocol version (`'1'`) |
| `registerView(pageId, renderFn, cleanup?)` | Replace or add a full page renderer |
| `registerComponent(type, renderFn)` | Override a layout component type |
| `registerSurfaceRenderer(surfaceId, renderFn)` | Custom A2UI surface renderer |
| `selectPage(pageId)` | Navigate programmatically (e.g. open full canvas) |
| `registerSlot(name, renderFn)` | Inject DOM into named slots |
| `sdk.fetchJSON(url, opts?)` | Authenticated fetch helper |
| `sdk.el(tag, attrs?, children?)` | Small DOM builder |
| `sdk.themeVar('--db-*', fallback?)` | Read theme CSS variables |
| `sdk.createBoard(root, { fetch, pollMs? })` | Live board with optional polling |
### Slots
| Slot id | Where it renders |
|---------|------------------|
| `shell:sidebar:footer` | Bottom of sidebar (default: health hint) |
| `page:{pageId}:toolbar` | Right side of page header |
Register only what you need; omit slots you do not use.
## Layout components (Python → JS)
Define pages with `@aiui.page()` returning `aiui.layout([...])`. Types are implemented once in [`ui.py`](../../src/praisonaiui/ui.py) and [`dashboard.js`](../../src/praisonaiui/templates/frontend/plugins/dashboard.js).
**Kanban / board:** use `aiui.board(columns=[...])`. Columns contain `id`, `title`, and `cards` (from `aiui.card()` or dicts). The shell renders via `renderBoard`, composing existing card renderers (DRY).
## A2UI surfaces (agent-driven UI)
Three tiers work together:
| Tier | Mechanism | Use case |
|------|-----------|----------|
| Dashboard | `aiui.layout()` → `_components` | Python-authored pages |
| A2UI | `send_a2ui_messages` → `SurfaceHost` | Agent-generated UI in chat/canvas |
| AG-UI | `POST /agui` SSE | CopilotKit / external clients |
Register an empty canvas page:
```python
@aiui.page("agent-canvas", title="Agent Canvas", icon="🖼️")
async def agent_canvas():
return {"_surface": {"id": "main", "messages": []}}
```
Or use `@aiui.surface_action("main")` for button callbacks.
**Full guide:** [A2UI canvas](a2ui-canvas.md) — chat-canvas split view, `send_a2ui_messages`, HTTP/CLI, `coerce_a2ui_tool_messages`, troubleshooting. Example: [`examples/python/30-a2ui-canvas/`](../../examples/python/30-a2ui-canvas/app.py).
## Optional dashboard modules
Load heavy built-ins only when listed:
```python
aiui.set_dashboard(modules=["jobs", "auth", "api"])
```
```yaml
dashboard:
modules: [jobs]
```
This adds `jobs.js`, `auth.js`, or `api.js` to the plugin chain and maps them in `BUILTIN_VIEWS`. `feedback` is always available as a built-in view when the page is enabled.
## Third-party dashboard plugins
Place folders under:
- `~/.praisonai/dashboard-plugins/{name}/`
- Or `src/praisonaiui/templates/frontend/dashboard-plugins/{name}/` (shipped examples)
Each plugin needs `manifest.json` + `index.js` (optional `styles.css`):
```json
{
"name": "my-board",
"label": "My board",
"icon": "📋",
"tab": { "path": "/my-board" },
"entry": "index.js"
}
```
The server exposes:
- `GET /api/dashboard/plugins` — manifest list
- `GET /dashboard-plugins/{name}/{path}` — static assets
Plugins register views in `index.js`:
```javascript
window.aiui.registerView('my-board', async (container) => {
window.aiui.sdk.createBoard(container, {
fetch: async () => ({ columns: [...] }),
});
});
```
Sample: [`sample-board`](../../src/praisonaiui/templates/frontend/dashboard-plugins/sample-board/).
## Agent plugins vs dashboard plugins
| System | Location | Purpose |
|--------|----------|---------|
| Agent plugins | `~/.praisonai/plugins/` | Python agent extensions |
| Dashboard plugins | `~/.praisonai/dashboard-plugins/` | JS UI tabs |
See [PraisonAI package integration](praisonai-package-integration.md) for backend wiring, jobs API, and deployment.
## Granular control — opt-in / opt-out
| What you control | How | Example |
|------------------|-----|---------|
| UI mode | `aiui.set_style(...)` | `"dashboard"`, `"chat"`, `"docs"` |
| Sidebar pages | `aiui.set_pages([ids])` whitelist | `["chat", "sessions"]` |
| Hide built-ins | YAML `pages.disabled: [...]` | Blacklist in server |
| Sidebar on/off | `set_dashboard(sidebar=False)` | Chat-only full width |
| Page title bar | `set_dashboard(page_header=False)` | Cleaner surface |
| Optional modules | `set_dashboard(modules=[...])` | `["jobs"]` only |
| Third-party tabs | Install chosen `dashboard-plugins/` folders | Manifest per plugin |
| Shell chrome | `registerSlot` | Header/footer widgets |
| Look only | `set_theme`, `set_branding`, `set_custom_css` | White-label |
| Replace one page | `registerView(pageId, ...)` | Override built-in |
| Board UI | `aiui.board()` or plugin | Not in default nav |
**Note:** `@aiui.page()` custom pages are **not** filtered by `set_pages()` — only built-in dashboard pages are. Remove custom pages from code to hide them.
### Example profiles
```python
# Chat-only
aiui.set_style("dashboard")
aiui.set_pages(["chat"])
aiui.set_dashboard(sidebar=False, page_header=False)
# Ops console — no chat
aiui.set_pages(["sessions", "agents", "logs", "config", "usage", "approvals"])
```
```yaml
style: dashboard
pages:
enabled: [chat, agents, memory, sessions]
dashboard:
sidebar: true
pageHeader: true
modules: [jobs]
plugin_dirs: [/opt/my-agent/plugins]
```
CLI: `aiui pages ids` lists built-in page ids for whitelists.
## DRY rules for contributors
1. Add new layout types in **`ui.py`** and one `case` in **`dashboard.js`**.
2. Page-specific UI belongs in **`plugins/views/*.js`**, delegating to shared modules (`jobs.js`, etc.) via thin wrappers.
3. Reuse **`_helpers.js`** (`pageToolbar`, `filterChips`, `searchInput`, `modalShell`, `toast`).
4. Boards compose **`card`** / **`columns`** — do not duplicate card markup in plugins.
## Related files
| File | Role |
|------|------|
| [`praisonai-package-integration.md`](praisonai-package-integration.md) | Praison backend wiring |
| [`server.py`](../../src/praisonaiui/server.py) | Pages API, `set_dashboard`, plugin chain |
| [`dashboard_plugins.py`](../../src/praisonaiui/dashboard_plugins.py) | Manifest discovery and static routes |
| [`dashboard.js`](../../src/praisonaiui/templates/frontend/plugins/dashboard.js) | Shell, registries, renderers |
| [`schema/models.py`](../../src/praisonaiui/schema/models.py) | `DashboardConfig.modules`, `plugin_dirs` |
+25
View File
@@ -0,0 +1,25 @@
# Backend Integration Contract
PraisonAIUI supports a four-layer integration model so standalone mode and SDK-integrated mode can share the same feature APIs.
## Four Layers
1. **Feature API layer**
Endpoints live in feature modules and expose stable HTTP/CLI behaviour.
2. **Feature manager layer**
Each feature manager keeps local fallback logic for standalone operation.
3. **Backend factory layer**
`praisonaiui.backends` provides optional injected factories (`get_workflow_runner()`, `get_hooks_lister()`, `get_usage_query()`, etc.).
4. **SDK/runtime layer**
PraisonAI or praisonaiagents implementations are used when injected or importable.
## Injection Contract
- Injected backends are optional and must be callable.
- Features must keep local fallbacks when injected backends are absent or fail.
- Backend call signatures should remain minimal and dict/list based.
- Integration should not introduce hard imports in feature modules when a fallback already exists.
## Operational Rule
When integrated, wrappers should inject backend callables at startup; when standalone, features should run without any backend injection.
+2 -2
View File
@@ -129,7 +129,7 @@
| Android app | 🔴 | 🟢 | |
| Voice Wake (wake word) | 🔴 | 🟢 | macOS + iOS |
| Talk Mode (voice chat) | 🔴 | 🟢 | ElevenLabs + system TTS |
| Canvas (visual workspace) | 🔴 | 🟢 A2UI | Agent-driven UI |
| Canvas (visual workspace) | 🟡 A2UI surfaces | 🟢 A2UI | Agent-driven UI via `send_a2ui_messages` |
| Device pairing | 🔴 | 🟢 Bonjour | iOS/Android → Gateway |
| PWA support | 🔴 | 🔴 | Neither has PWA |
| i18n / localization | 🔴 | 🔴 | Neither has i18n |
@@ -187,7 +187,7 @@
| **Skills marketplace** | 13,000+ vs 0 | 🔴 Critical — massive ecosystem moat |
| **Native apps** | macOS/iOS/Android vs web-only | 🟡 High — device presence |
| **Voice** | Wake word + Talk Mode vs none | 🟡 High — hands-free interaction |
| **Canvas (A2UI)** | Agent-driven visual workspace vs none | 🟡 Medium |
| **Canvas (A2UI)** | Agent-driven visual workspace vs partial parity | 🟡 Medium |
| **Agent-to-agent** | Sessions-based messaging vs none | 🟡 Medium |
### Strategic Positioning
+39 -9
View File
@@ -49,7 +49,7 @@ services:
volumes:
- aiui-data:/data
healthcheck:
test: ["CMD", "curl", "-f", "http://localhost:8082/health"]
test: ["CMD", "curl", "-f", "http://localhost:8082/health/live"]
interval: 30s
timeout: 5s
retries: 3
@@ -81,7 +81,7 @@ curl -H "Authorization: Bearer <api-key>" http://localhost:8082/api/config
```
**Exempt paths** (always accessible without auth):
- `/health`, `/api/health` — health checks
- `/health`, `/health/live`, `/health/ready`, `/api/health` — health checks
- `/api/auth/login`, `/login` — login endpoints
- `/api/protocol`, `/api/protocol/negotiate` — protocol discovery
@@ -146,8 +146,36 @@ This affects:
### Health Endpoint
```bash
# Fast liveness probe (<500ms) - use for load balancers and K8s livenessProbe
curl http://localhost:8082/health/live
# Returns: {"status": "ok", "timestamp": "2024-..."}
# Readiness probe with parallel feature checks - use for K8s readinessProbe
curl http://localhost:8082/health/ready
# Deep diagnostics (parallel feature checks, deep by default)
curl http://localhost:8082/health
# Returns: {"status": "healthy", "timestamp": "2024-..."}
# Pass ?deep=false for an immediate liveness response
```
### Kubernetes Probes
Use the fast liveness endpoint for probes to avoid timeouts:
```yaml
livenessProbe:
httpGet:
path: /health/live
port: 8082
initialDelaySeconds: 10
periodSeconds: 30
timeoutSeconds: 1
readinessProbe:
httpGet:
path: /health/ready
port: 8082
initialDelaySeconds: 5
periodSeconds: 10
```
### Docker HEALTHCHECK
@@ -156,17 +184,17 @@ The Dockerfile includes a built-in health check:
```dockerfile
HEALTHCHECK --interval=30s --timeout=5s --start-period=10s --retries=3 \
CMD curl -f http://localhost:${AIUI_PORT}/health || exit 1
CMD curl -f http://localhost:${AIUI_PORT}/health/live || exit 1
```
### CLI Health Commands
```bash
# Quick health check
aiui health --server http://localhost:8082
aiui health-check --server http://localhost:8082
# Detailed per-feature health
aiui health --server http://localhost:8082 --detailed
# Detailed per-feature health
aiui health-check --server http://localhost:8082 --detailed
# Full diagnostic (7 checks)
aiui doctor --server http://localhost:8082
@@ -199,7 +227,7 @@ curl -H "X-API-Key: <key>" http://localhost:8082/api/config
- **Internal networks**: Use `AUTH_ENFORCE=false` if behind a trusted reverse proxy
- **Public exposure**: Always set `AUTH_ENFORCE=true` and use HTTPS
- **Load balancers**: Health check path is `/health` (exempt from auth)
- **Load balancers**: Health check path is `/health/live` (fast, exempt from auth)
## Managed Hosting
@@ -209,7 +237,9 @@ Hosting platforms should expose these endpoints:
| Endpoint | Purpose |
|----------|---------|
| `/health` | Health check (returns `{"status": "healthy"}`) |
| `/health/live` | Fast liveness probe (returns `{"status": "ok"}`) |
| `/health/ready` | Readiness probe (parallel feature checks) |
| `/health` | Deep health (parallel feature checks; `?deep=false` for liveness) |
| `/api/protocol` | Protocol version and capabilities |
| `/api/protocol/negotiate` | Capability negotiation |
| `/api/features` | List of registered features |
+171
View File
@@ -0,0 +1,171 @@
# Image Preview in Chat
Protocol-driven inline media for agent-generated images (and other element types) during chat streaming. Any backend that implements `BaseProvider` can emit images without PraisonAI-specific code in chat or the frontend.
## Problem This Solves
Before this feature, PraisonAIUI could **render** images (`Message.add_image`, `ElementRenderer`) but the **wire protocol dropped media** during agent runs. ImageAgent / tool results appeared as JSON in the tool-call panel instead of inline `<img>` previews.
## Architecture
Modular layers — same pattern as [A2UI surfaces](./agent-ui-host.md):
```mermaid
sequenceDiagram
participant Backend as Provider_or_Tool
participant Detect as media_utils.py
participant Protocol as RunEvent_MESSAGE_ELEMENT
participant Chat as chat.py
participant UI as chat.js_React
Backend->>Detect: tool result or callback payload
Detect->>Protocol: normalised element dict
Protocol->>Chat: broadcast message_element
Chat->>UI: WebSocket / SSE frame
UI->>UI: appendMediaElement / ElementRenderer
Chat->>Chat: persist elements in session history
```
### Layer responsibilities
| Layer | Module | Backend-agnostic? |
|-------|--------|-------------------|
| **Detection** | [`media_utils.py`](../../src/praisonaiui/media_utils.py) | Yes — normalises OpenAI, URL, b64, element dicts |
| **Protocol** | [`provider.py`](../../src/praisonaiui/provider.py) — `RunEventType.MESSAGE_ELEMENT` | Yes |
| **Adapter** | [`providers/__init__.py`](../../src/praisonaiui/providers/__init__.py) — PraisonAI bridge only | No — maps SDK/callback queue → protocol |
| **Transport** | [`chat.py`](../../src/praisonaiui/features/chat.py) — broadcast + persist | Yes |
| **Storage** | [`attachments.py`](../../src/praisonaiui/features/attachments.py) — `GET /api/chat/media/{id}` | Yes — reuses attachment store for b64 |
| **UI** | [`chat.js`](../../src/praisonaiui/templates/frontend/plugins/views/chat.js), [`ElementRenderer`](../../src/frontend/src/chat/MultimediaElements.tsx) | Yes — renders `{type, url, alt}` |
Detection and UI do **not** import PraisonAI. Only the provider adapter knows ImageAgent / OpenAI `{data: [{url}]}` shapes.
## Wire protocol
### Event type
```
type: "message_element"
element: { "type": "image", "url": "...", "alt": "..." }
session_id: "..."
run_id: "..."
```
Defined in Python as `RunEventType.MESSAGE_ELEMENT` and mirrored in [`types.ts`](../../src/frontend/src/types.ts).
### Helper (any provider)
```python
from praisonaiui.provider import BaseProvider, RunEvent, RunEventType
yield BaseProvider.message_element_event({
"type": "image",
"url": "https://example.com/out.png",
"alt": "Generated image",
})
```
Element shape reuses the existing [message element schema](./message-elements.md) (`image`, `pdf`, `video`, `audio`, `file`, `code`).
## Data flow paths
### 1. Custom provider (swappable backend)
```python
import praisonaiui as aiui
from praisonaiui.provider import BaseProvider, RunEvent, RunEventType
class MyImageProvider(BaseProvider):
async def run(self, message, *, session_id=None, agent_name=None, **kw):
yield RunEvent(type=RunEventType.RUN_STARTED)
# Call any image API here (OpenAI, Stability, local, etc.)
yield BaseProvider.message_element_event({
"type": "image",
"url": image_url,
"alt": "Generated image",
})
yield RunEvent(type=RunEventType.RUN_COMPLETED, content="Here is your image.")
aiui.set_provider(MyImageProvider())
```
Example app: [`examples/python/30-image-preview/app.py`](../../examples/python/30-image-preview/app.py).
### 2. Callback handler (`@aiui.reply`)
```python
import praisonaiui as aiui
from praisonaiui import Message
@aiui.reply
async def on_message(msg):
m = Message(content="Here is your image:")
m.add_image("https://example.com/chart.png", alt="Chart")
await m.send()
```
`PraisonAIProvider` maps queue events (`image`, `message` + `elements`) → `MESSAGE_ELEMENT`.
### 3. Tool results (automatic detection)
When a tool completes, `tool_completed_extra()` in [`a2ui_utils.py`](../../src/praisonaiui/a2ui_utils.py) calls `build_media_extra()` from `media_utils`. Supported shapes:
| Input shape | Example |
|-------------|---------|
| OpenAI / ImageAgent | `{"data": [{"url": "...", "b64_json": "...", "revised_prompt": "..."}]}` |
| Explicit element | `{"type": "image", "url": "..."}` |
| Plain URL string | `"https://cdn.example.com/out.png"` |
| Data URL | `"data:image/png;base64,..."` |
| Nested elements | `{"elements": [{"type": "video", "url": "..."}]}` |
`chat.py` broadcasts `message_element` for each detected element and attaches `elements` to the persisted assistant message.
### 4. ImageAgent direct mode
If the registered agent's `chat()` returns an OpenAI-shaped dict, `PraisonAIProvider._run_direct_mode` runs `extract_media_elements()` and emits `MESSAGE_ELEMENT` events before `RUN_COMPLETED`.
## Base64 storage
Large `b64_json` payloads are **not** stored inline in message history. They are decoded and saved via `AttachmentManager`, then served at:
```
GET /api/chat/media/{attachment_id}
```
If storage fails, the system falls back to a `data:image/png;base64,...` URL.
## Frontend rendering
| Surface | Handler |
|---------|---------|
| Dashboard chat (template) | `chat.js` — `case 'message_element': appendMediaElement()` |
| React chat | `streamingStore.ts` — `pendingElements` + `ElementRenderer` |
| Tool call panel | Thumbnail from `toolCall.elements` when expanded |
| Session reload | `elements` on messages from `/api/chat/history` and `/sessions/{id}/runs` |
See also [Message Elements](./message-elements.md) for element properties and display modes.
## Deduplication
`_collect_element()` in `chat.py` dedupes by URL so the same image is not stored twice when both a `MESSAGE_ELEMENT` event and a tool completion carry the same URL.
## Out of scope
- **REST image generation** (`POST /v1/images/generations`) — separate OpenAI-compat endpoint; not wired into chat streaming
- **Canvas / A2UI surfaces** — separate feature for agent-driven UI catalogs
- **Auto-registering ImageAgent** — agents are configured explicitly by the user
## Tests
```bash
pytest tests/unit/test_media_utils.py \
tests/integration/test_chat_image_preview.py -v -o addopts=
```
Coverage includes: detection normalisation, b64 fallback, provider callback bridge, `_run_and_broadcast` persistence, media serve route, ImageAgent dict responses, URL dedup.
## Related docs
- [Providers](../concepts/providers.md) — provider protocol and inline media snippet
- [Message Elements](./message-elements.md) — element types and Python API
- [Attachments](./attachments.md) — user file uploads (separate from generated media serve path)
- [Gateway Chat](./gateway-chat.md) — WebSocket event flow
+7 -1
View File
@@ -172,4 +172,10 @@ The new element system maintains backward compatibility with existing message pr
- `message.files` (still supported)
- New `message.elements` takes precedence when both are present
This ensures existing code continues to work while providing a migration path to the new standardized element system.
This ensures existing code continues to work while providing a migration path to the new standardized element system.
## Agent-generated images (streaming)
When an agent or tool **creates** an image during a chat run, the UI receives `message_element` events over WebSocket/SSE and renders them inline. This uses the same element dict shape as above but is driven by the [RunEvent protocol](../concepts/providers.md), not only `Message.send()`.
See **[Image Preview in Chat](./image-preview-chat.md)** for the full architecture: detection (`media_utils`), provider bridge, persistence, and swappable backends.
@@ -0,0 +1,201 @@
# PraisonAI package integration
How **PraisonAIUI** (`aiui`) connects to **`praisonai-package`** — the agent runtime, gateway, and CLI. For generic UI hosting see [Agent UI host](agent-ui-host.md).
## Division of labour
| Layer | Repo | Role |
|-------|------|------|
| Agent runtime | `praisonai-agents` | `Agent`, tools, memory, streaming |
| Gateway, jobs, CLI | `praisonai` | WebSocket `/ws`, `/api/v1/runs`, `praisonai claw` |
| Dashboard UI | **PraisonAIUI** | `dashboard.js`, `@aiui.page`, layout protocol |
| Work tracking | `praisonai-platform` | Issues API (optional board data source) |
Canonical app: `praisonai-package` → `src/praisonai/praisonai/claw/default_app.py`.
## Deployment shapes
### 1. Single process — `praisonai claw` (recommended)
```bash
praisonai claw # → aiui run claw/default_app.py, default :8082
```
```text
Browser → create_app() (same process)
→ PraisonAISessionDataStore (sessions)
→ PraisonAIProvider (@aiui.reply / agents)
→ Built-in dashboard views
```
Use when: local dev, full dashboard, agents registered in Python.
### 2. Unified — `AIUIGateway`
```python
from praisonaiui.integration import AIUIGateway
gateway = AIUIGateway(port=8080)
gateway.register_agent(agent)
await gateway.start() # create_app() + WebSocket /ws
```
Use when: you need **WebSocket gateway protocol** and dashboard on **one port**.
### 3. Split — gateway + UI
| Service | Default port | Endpoints |
|---------|--------------|-----------|
| `praisonai gateway start` | 8765 | `ws://…/ws`, `/health`, `/info` |
| `praisonai claw` or `aiui run` | 8082 | Dashboard, `/run` SSE, `/api/*` |
Use when: gateway runs headless; UI is a separate process. Point chat at gateway via provider config or run `AIUIGateway` instead.
## Wiring checklist
```python
import praisonaiui as aiui
from praisonai.ui._aiui_datastore import PraisonAISessionDataStore
aiui.set_datastore(PraisonAISessionDataStore())
aiui.set_style("dashboard")
aiui.set_pages(["chat", "agents", "sessions", "config"])
aiui.set_dashboard(modules=["jobs"], sidebar=True, page_header=True)
# Optional: point jobs UI at package jobs server paths
aiui.set_jobs_api(api_base="/api/v1/runs", backend="praisonai")
```
Or via host bootstrap:
```python
from praisonai.integration.host_app import configure_host, create_host_app
configure_host(pages=[...], modules=["jobs"], agents=[...])
app = create_host_app()
```
## API surface
### Chat and agents (dashboard)
| Endpoint | Purpose |
|----------|---------|
| `POST /run` | Stream agent run (SSE) |
| `GET /sessions`, `POST /sessions` | Session CRUD |
| `GET /agents`, `/api/agents/definitions` | Agent list / CRUD |
| `GET /api/health` | Sidebar health slot |
### Async jobs — two paths
| Backend | Base path | When |
|---------|-----------|------|
| **aiui** `JobsFeature` | `/api/jobs` | Default; in-process job store |
| **praisonai** jobs server | `/api/v1/runs` | Standalone `praisonai jobs` / FastAPI router |
Configure the dashboard jobs view:
```python
aiui.set_jobs_api(api_base="/api/jobs", backend="aiui") # default
aiui.set_jobs_api(api_base="/api/v1/runs", backend="praisonai")
```
`/ui-config.json` exposes `jobs: { apiBase, backend }` for `jobs.js`.
**Response shapes:** both return `{ jobs, total }`. Praison uses `job_id`; aiui uses `id`. The jobs view normalises either field.
| Action | aiui | praisonai |
|--------|------|-----------|
| List | `GET /api/jobs` | `GET /api/v1/runs` |
| Detail | `GET /api/jobs/{id}` | `GET /api/v1/runs/{id}` |
| Cancel | `POST /api/jobs/{id}/cancel` | `POST /api/v1/runs/{id}/cancel` |
| Stream | `GET /api/jobs/{id}/stream` | `GET /api/v1/runs/{id}/stream` |
| Delete | `DELETE /api/jobs/{id}` | Not on package router |
**Split jobs server (optional):** proxy aiui `/api/jobs/*` to an external jobs host:
```python
aiui.set_jobs_proxy("http://127.0.0.1:9000") # forwards to /api/v1/runs/*
aiui.set_jobs_api(api_base="/api/jobs", backend="aiui")
```
Use when the dashboard and jobs server run on different ports. Prefer `set_jobs_backend("praisonai")` when the browser can call `/api/v1/runs` on the same origin.
### REST invoke (automation)
| Path | Use |
|------|-----|
| `POST /api/v1/agents/{id}/invoke` | Sync invoke (n8n, scripts) — **package** `agent_invoke.py` |
| `POST /run` | Streaming chat — **dashboard** chat page |
Same agents if registered on both surfaces; different protocols.
### Discovery (package serve)
| Endpoint | Purpose |
|----------|---------|
| `GET /__praisonai__/discovery` | Serve-mode capability discovery |
| `GET /health`, `GET /info` | Gateway liveness and metadata |
| `GET /api/gateway/status` | aiui gateway feature status |
See **Feature Explorer** page in dashboard for live probes.
## Parallel UI protocols (not dashboard replacements)
Under `praisonaiagents/ui/`:
| Protocol | Purpose |
|----------|---------|
| **A2A** | Agent cards, `/.well-known/agent.json`, external agent hosts |
| **AG-UI** | CopilotKit-style event stream (`POST /agui`) |
| **A2UI** | Declarative surfaces in chat/canvas |
Dashboard shell remains the ops console for `praisonai claw`. Use A2A/AG-UI/A2UI when embedding in other products.
## Two plugin systems
| System | Location | Purpose |
|--------|----------|---------|
| **Agent plugins** | `~/.praisonai/plugins/` | Python agent extensions (`praisonaiagents.plugins`) |
| **Dashboard plugins** | `~/.praisonai/dashboard-plugins/` | JS tabs + `manifest.json` for aiui shell |
Do not mix folders. Agent plugins extend runtime; dashboard plugins extend UI tabs only.
## Optional modules
```python
aiui.set_dashboard(modules=["jobs", "auth", "api"])
```
Loads `jobs.js`, `auth.js`, `api.js` into the plugin chain and maps them in `BUILTIN_VIEWS`. Enable only what your deployment exposes.
## Board / work tracking
Neither repo ships Hermes-style Kanban. Options:
1. **`aiui.board()`** — columns from `@aiui.page` handler (jobs by status, custom data).
2. **`praisonai-platform` issues** — map issue `status` → board columns (see `examples/python/platform-board/`). Set `PRAISONAI_PLATFORM_URL` and `PRAISONAI_PLATFORM_WORKSPACE_ID` (default workspace `default`); issues are fetched from `GET /api/v1/workspaces/{workspace_id}/issues`.
3. **`dashboard-plugins`** — rich JS via `registerView` + `sdk.createBoard`.
Advanced board UX (drag-drop, WebSocket sync) is **deferred** — use polling via `sdk.createBoard({ pollMs })` or a custom plugin.
## Smoke checklist
After `praisonai claw` or `aiui run` with package bridges:
- [ ] `GET /api/health` → sidebar footer shows healthy
- [ ] Chat page sends message, streams reply
- [ ] Sessions page lists `~/.praisonai/sessions/` data
- [ ] Agents page CRUD (if agents registered)
- [ ] Jobs page (if `modules` includes `jobs` and jobs API configured)
- [ ] Explorer probes `/health`, `/ui-config.json`, `/api/dashboard/plugins`
- [ ] Auth/API pages (if `modules` includes `auth` / `api`)
## Related
- [Agent UI host](agent-ui-host.md) — extension API, opt-in matrix
- [Backend integration](backend-integration.md) — L1 backend injection
- [Standalone vs integrated](standalone-vs-integrated.md) — SDK gaps and fallbacks
- [PraisonAIUI repository](https://github.com/MervinPraison/PraisonAIUI)
- [`integration.py`](../../src/praisonaiui/integration.py) — `AIUIGateway`
- [`examples/python/praisonai-claw-board/`](../../examples/python/praisonai-claw-board/app.py) — board from jobs
- [`examples/python/platform-board/`](../../examples/python/platform-board/app.py) — board from platform issues
+53
View File
@@ -0,0 +1,53 @@
# Standalone vs Integrated
This page summarises the expected install and runtime shape for PraisonAIUI in both modes.
## Install Matrix
| Mode | Package install | Runtime dependency | Behaviour |
| --- | --- | --- | --- |
| Standalone UI | `pip install praisonaiui` | Optional SDK packages absent | Uses built-in local managers and fallbacks |
| Integrated (PraisonAI) | `pip install praisonaiui praisonai praisonaiagents` | SDK and wrapper available | Uses injected backends and SDK-aware managers |
## Backend injection (integrated mode)
When hosted via `praisonai.integration.host_app.configure_host()`, the wrapper calls `praisonaiui.backends.set_backend()` for:
| Backend key | Feature area |
| --- | --- |
| `hooks` | Hooks dashboard |
| `workflows` | Workflow runs |
| `usage_query` / `usage_sink` | Usage analytics |
| `approvals_pending` / `approvals_policies` | HITL approvals |
| `jobs_store` / `jobs_executor` | Async jobs (optional) |
| `channel_bot` | Channel bots (optional) |
| `tool_resolver` | Agent tool name resolution (optional) |
Detect integrated mode in code: `praisonaiui.backends.is_integrated_mode()`.
## Documented SDK gaps (local fallback)
These features work standalone with in-memory stores. Health responses include `sdk_gap: true`:
| Feature | Gap | Standalone behaviour |
| --- | --- | --- |
| **nodes** | No cluster API in praisonaiagents | In-memory node registry |
| **auth** | No SDK auth module | In-memory API keys / sessions |
| **channels** | Bot runtime via praisonai wrapper | In-memory channel registry; bots need `praisonai[bot]` |
| **jobs** | Mock success only when standalone and agent missing | Integrated mode fails job instead of fake success |
| **realtime** | WebRTC needs `OPENAI_API_KEY` | Mock stream standalone; degraded error when integrated without key |
| **media / code** | SDK paths optional | `status: simulated` standalone; `status: degraded` integrated on failure |
View gaps in the dashboard sidebar footer (`GET /api/health` → `sdk_gaps` array).
## Practical Differences
- **Session/data handling**: standalone uses UI datastore fallback; integrated aligns with SDK-backed stores.
- **Feature backends**: standalone keeps in-memory/mock behaviour; integrated routes through injected factories.
- **Lifecycle hooks**: integrated mode can subscribe to SDK hooks (handoff, MCP lifecycle, tracing/eval stores).
## Recommendation
Use standalone mode for local UI-only workflows. Use integrated mode when you need shared runtime state with PraisonAI agents, tools, tracing, approvals, and schedules.
See also [backend-integration.md](backend-integration.md) and [praisonai-package-integration.md](praisonai-package-integration.md).
+114
View File
@@ -0,0 +1,114 @@
# Video Studio
The Video Studio integrates [PraisonAI Video](https://github.com/MervinPraison/praisonai-video) as an external Node render engine. PraisonAIUI provides the dashboard shell, project files, YAML editor, job UX, and HTTP proxying. The video engine handles parse, compile, lint, and MP4 export. **Agents live in PraisonAI (Chat + tools), not inside the renderer.**
## Prerequisites
1. Install PraisonAI Video CLI (`praisonai-video`) or run from the monorepo.
2. Start the engine sidecar (recommended):
```bash
praisonai-video serve --port 3921
```
3. Run the example app:
```bash
aiui run examples/python/video-studio/app.py
```
Open the **Video** tab, create a project, edit `scene.yaml`, and use Lint / Render.
For **Chat** to edit scenes, install `praisonaiagents` and set `OPENAI_API_KEY`. Optional: `VIDEO_STUDIO_PROJECT_ID` to target a specific project.
## Configuration
| Variable | Default | Purpose |
|----------|---------|---------|
| `VIDEO_ENGINE_URL` | `http://127.0.0.1:3921` | HTTP API base URL |
| `VIDEO_ENGINE_TOKEN` | (none) | Optional Bearer token forwarded to the engine |
| `PRAISONAI_PROJECTS_DIR` | `~/.praisonai/projects` | On-disk project roots |
| `PRAISONAI_VIDEO_CLI` | (auto-detect) | Path to `praisonai-video` or `dist/cli.js` for subprocess fallback |
| `VIDEO_STUDIO_PROJECT_ID` | (auto) | Active project for agent tools when not passed explicitly |
| `PRAISONAI_MODEL` | `gpt-4o-mini` | LLM for Video Editor agent in the example app |
Python API:
```python
from praisonaiui.video_config import set_video_engine
set_video_engine(url="http://127.0.0.1:3921", projects_dir="~/my-videos")
```
## Projects
Each project is a directory containing:
- `scene.yaml` — scene definition
- `scene.visual-test.yaml` — optional visual regression spec
- `.praisonai/project.json` — metadata
- `exports/` — rendered MP4 artefacts
## API routes
All routes are under `/api/video/`:
- `GET /health` — sidecar status
- `POST /lint`, `POST /compile` — scene validation
- `GET /preview-url`, `POST /preview/start` — preview iframe URL (CLI; dashboard preview hidden)
- `POST /render` — async render job (`type: video_render`); optional `backend` (`playwright` | `remotion`) overrides `scene.yaml` `render.backend`
- `GET /jobs/{id}` — job status and download URL
- `GET|POST /projects` — list and create projects
- `GET /projects/{id}/studio-refresh` — poll after agent tool updates (`refresh: true` once)
When the HTTP sidecar is unavailable, lint and render fall back to subprocess calls to `praisonai-video`.
## Dashboard plugin
The UI lives in `dashboard-plugins/video-studio/` (built-in) or `~/.praisonai/dashboard-plugins/video-studio/` for overrides. It uses `window.aiui.sdk.fetchJSON`.
**Frame-by-frame composition preview is hidden** in the dashboard (`SHOW_COMPOSITION_PREVIEW = false`) until serve preview + seek are reliable. Use **Render MP4** and the **Your video** player for output. Set the flag to `true` in `index.js` to re-enable the optional iframe scrubber.
After agent tool writes, the editor reloads via `video-studio:refresh` (custom event) or polling `studio-refresh`.
## Agent workflow (PraisonAI-first)
1. Open **Chat** with the Video Editor agent (example app registers tools when `praisonaiagents` is installed).
2. Agent calls `video_get_scene` / `video_update_scene` / `video_lint_scene` / `video_render_project` (same outcomes as UI buttons).
3. Video Studio tab picks up changes automatically.
Tools are defined in `praisonaiui/video_agent_tools.py`. For Cursor/Codex outside the UI, use the skill at `skills/praisonai-video/SKILL.md` in the PraisonAI Video repo.
### Optional render backends
Default is **Playwright** (no `render:` block in `scene.yaml`). Use Remotion only when required:
```yaml
render:
backend: remotion # needs Remotion licence + remotion-root export; lint will error on default YAML export
```
`video_render_project(backend="playwright")` overrides YAML for one agent run. Hyperframes is deferred.
## CLI parity
The text box edits the same `scene.yaml` the CLI uses. Equivalent commands (from the video repo):
```bash
praisonai-video project create "My video"
praisonai-video project lint <project-id>
praisonai-video project preview <project-id>
praisonai-video project test <project-id>
praisonai-video project render <project-id>
praisonai-video project reset <project-id>
```
Lint editor text without saving: `praisonai-video lint --stdin < scene.yaml`
See `docs/integrations/praisonaiui.md` in the PraisonAI Video repo for the full mapping table.
## Related
- [Agent UI host](agent-ui-host.md) — extension patterns (`registerView`, plugins, `@aiui.page`)
- PraisonAI Video integration doc: `docs/integrations/praisonaiui.md` in the video repo
- PraisonAI Video skill: `skills/praisonai-video/SKILL.md` — YAML-first invariants for agents and CI
+5 -2
View File
@@ -276,10 +276,13 @@ aiui features list # List all registered features
aiui features status # Feature health summary
```
### `aiui health`
### `aiui health-check`
Check server health. Can also be invoked as `aiui health` for convenience.
```bash
aiui health [--detailed] # Check server health
aiui health-check [--detailed] # Check server health
aiui health [--detailed] # Alias for health-check
```
### `aiui session-ext`
+1 -1
View File
@@ -101,6 +101,6 @@ If something isn't working:
```bash
aiui doctor # Check server health
aiui health --detailed # Detailed health check
aiui health-check --detailed # Detailed health check (alias: aiui health)
aiui test all # Run all integration tests
```
+209
View File
@@ -0,0 +1,209 @@
# Health Endpoints Guide
## Overview
PraisonAIUI provides multiple health endpoints optimized for different use cases:
- **`/health/live`** - Fast liveness check (<500ms)
- **`/health/ready`** - Readiness check with optional deep checks
- **`/health`** - Legacy endpoint (backward compatibility)
## Endpoints
### Liveness Check: `/health/live`
Fast endpoint to verify the server is running. Returns immediately with minimal processing.
**Use for:**
- Load balancer health probes
- Kubernetes liveness probes
- High-frequency monitoring
**Response time:** <500ms (typically <100ms)
**Example:**
```bash
curl http://localhost:8082/health/live
```
**Response:**
```json
{
"status": "ok",
"timestamp": "2024-01-20T10:30:00.000Z"
}
```
### Readiness Check: `/health/ready`
Verifies the server is ready to handle requests. Performs parallel health checks on all features with timeout protection.
**Use for:**
- Kubernetes readiness probes
- Pre-deployment validation
- Detailed diagnostics
**Query parameters:**
- `?deep=false` - Skip feature checks for faster response
**Response time:**
- With deep checks: <1s (parallel execution)
- Without deep checks: <200ms
**Example:**
```bash
# Full readiness check
curl http://localhost:8082/health/ready
# Basic readiness (no feature checks)
curl http://localhost:8082/health/ready?deep=false
```
**Response with deep checks:**
```json
{
"status": "ok",
"timestamp": "2024-01-20T10:30:00.000Z",
"provider": {
"name": "PraisonAIProvider",
"status": "ok"
},
"features": {
"agents": {"status": "ok", "feature": "agents"},
"chat": {"status": "ok", "feature": "chat"},
"memory": {"healthy": false, "detail": "timeout"}
}
}
```
### API Health: `/api/health`
Cached version of readiness check for dashboard polling. 30-second TTL to reduce load.
**Use for:**
- Dashboard health indicators
- Frequent client polling
- Status pages
**Response:** Same as `/health/ready` but cached for 30 seconds
## Deployment Configurations
### Kubernetes
```yaml
apiVersion: apps/v1
kind: Deployment
metadata:
name: praisonaiui
spec:
template:
spec:
containers:
- name: praisonaiui
image: praisonaiui:latest
livenessProbe:
httpGet:
path: /health/live
port: 8082
initialDelaySeconds: 10
periodSeconds: 10
timeoutSeconds: 2
readinessProbe:
httpGet:
path: /health/ready?deep=false
port: 8082
initialDelaySeconds: 5
periodSeconds: 5
timeoutSeconds: 3
```
### AWS ALB/ELB
```terraform
resource "aws_lb_target_group" "praisonaiui" {
health_check {
enabled = true
path = "/health/live"
interval = 30
timeout = 5
healthy_threshold = 2
unhealthy_threshold = 2
}
}
```
### Docker Compose
```yaml
services:
praisonaiui:
image: praisonaiui:latest
healthcheck:
test: ["CMD", "curl", "-f", "http://localhost:8082/health/live"]
interval: 30s
timeout: 5s
retries: 3
start_period: 10s
```
### Nginx
```nginx
upstream praisonaiui {
server backend1:8082 max_fails=2 fail_timeout=5s;
server backend2:8082 max_fails=2 fail_timeout=5s;
# Health check configuration
check interval=5000 rise=2 fall=3 timeout=2000 type=http;
check_http_send "GET /health/live HTTP/1.0\r\n\r\n";
check_http_expect_alive http_2xx;
}
```
## Migration Guide
If you're upgrading from a version using only `/health`:
1. **Update monitoring:** Change health checks to use `/health/live` for liveness
2. **Update readiness:** Use `/health/ready?deep=false` for readiness probes
3. **Update dashboards:** Point status indicators to `/api/health` (cached)
4. **Backward compatibility:** The `/health` endpoint remains available but may be slow
## Performance Characteristics
| Endpoint | Cold Start | Warm (p50) | Warm (p95) | Use Case |
|----------|------------|------------|------------|----------|
| `/health/live` | <100ms | <20ms | <50ms | Liveness probes |
| `/health/ready?deep=false` | <200ms | <50ms | <100ms | Basic readiness |
| `/health/ready` | <1s | <500ms | <800ms | Full diagnostics |
| `/api/health` (cached) | <1s* | <20ms | <50ms | Dashboard polling |
*First request only, then cached for 30s
## Troubleshooting
### Slow `/health` responses
If you're seeing slow responses on the legacy `/health` endpoint:
- Switch to `/health/live` for fast checks
- Use `/health/ready?deep=false` if you don't need feature health
- Enable caching with `/api/health` for frequent polling
### Feature timeouts
Features that timeout in `/health/ready` will show:
```json
{
"feature_name": {
"healthy": false,
"detail": "timeout"
}
}
```
This indicates the feature took longer than 500ms to respond. The server is still operational, but that specific feature may be experiencing issues.
### High latency under load
Under high load, use `/health/live` exclusively for monitoring. It has minimal overhead and won't impact performance.
+45 -23
View File
@@ -2,6 +2,12 @@
> Each example teaches exactly **one new concept** — no overlap.
See also: [`docs/examples/basic.md`](../docs/examples/basic.md) and [`docs/examples/advanced.md`](../docs/examples/advanced.md).
**Note:** Some folders share numeric prefixes (`02-*`, `19-*`) — treat the path name as the canonical ID, not the number alone.
---
## Python Track
| # | Name | Unique Concept | Run |
@@ -15,16 +21,32 @@
| 07 | [provider-praisonai](python/07-provider-praisonai/) | Custom `BaseProvider` subclass, `RunEvent` protocol | `aiui run app.py` |
| 08 | [streaming](python/08-streaming/) | PraisonAI Agent streaming via `stream_emitter` | `aiui run app.py` |
| 09 | [widget](python/09-widget/) | Copilot/sidebar mode | `aiui run app.py` |
| 10 | [feature-showcase](python/10-feature-showcase/) | All protocol features seeded, `create_app()` | `python app.py` |
| 11 | [ui-integration](python/11-ui-integration/) | Gradio ASGI mount, Streamlit iframe, REST embedding | `python app.py` |
| 12 | [agent-dashboard](python/12-agent-dashboard/) | OpenClaw-style rendered HTML admin panel | `python app.py` |
| 10 | [feature-showcase](python/10-feature-showcase/) | All protocol features seeded, `create_app()` | `aiui run app.py` |
| 11 | [ui-integration](python/11-ui-integration/) | Gradio ASGI mount, Streamlit iframe, REST embedding | `aiui run app.py` |
| 12 | ~~agent-dashboard~~ → Use [dashboard-test](python/15-dashboard-test/) | ~~Removed in v0.5.0 (XSS vulnerability)~~ | See example 15 |
| 13 | [real-dashboard](python/13-real-dashboard/) | Real PraisonAI Agent + native dashboard + live metrics | `aiui run app.py --style dashboard` |
| 19 | [email-bot](python/19-email-bot/) | `EmailBot` IMAP/SMTP + agent replies + subject commands | `python app.py` |
| 20 | [email-channel](python/20-email-channel/) | Email as AIUI dashboard channel via `/api/channels` | `python app.py` |
| 21 | [agentmail-bot](python/21-agentmail-bot/) | `AgentMailBot` API-first email + programmatic inbox lifecycle | `python app.py` |
| 22 | [agentmail-channel](python/22-agentmail-channel/) | AgentMail as AIUI dashboard channel via `/api/channels` | `python app.py` |
| 25 | [clean-chat](python/25-clean-chat/) | `set_dashboard(sidebar=False)` — dashboard chat without left nav | `aiui run app.py` |
| 26 | [custom-theme-chat](python/26-custom-theme-chat/) | `register_theme()` — dynamic custom theme (teal/cyan) | `aiui run app.py` |
| 14 | [all-features](python/14-all-features/) | Integration test suite for all 16 feature APIs | `python app.py` |
| 15 | [dashboard-test](python/15-dashboard-test/) | Protocol-first page registration, view registry | `python app.py` |
| 16 | [gateway-integration](python/16-gateway-integration/) | `AIUIGateway` — real agent execution + WebSocket streaming | `python app.py` |
| 17 | [three-column-demo](python/17-three-column-demo/) | Three-column feature explorer with gateway | `python app.py` |
| 18 | [full-chat](python/18-full-chat/) | `aiui.set_pages()` page curation + custom explorer page | `python app.py` |
| 19 | [components-showcase](python/19-components-showcase/) | All 54 UI component types in one dashboard | `aiui run app.py --style dashboard` |
| 20 | [email-bot](python/20-email-bot/) | `EmailBot` IMAP/SMTP + agent replies + subject commands | `python app.py` |
| 21 | [email-channel](python/21-email-channel/) | Email as AIUI dashboard channel via `/api/channels` | `aiui run app.py` |
| 22 | [agentmail-bot](python/22-agentmail-bot/) | `AgentMailBot` API-first email + programmatic inbox lifecycle | `python app.py` |
| 23 | [agentmail-channel](python/23-agentmail-channel/) | AgentMail as AIUI dashboard channel via `/api/channels` | `aiui run app.py` |
| 24 | [e2e-components-test](python/24-e2e-components-test/) | Components, docs, and chat rendering in one app | `python app.py` |
| 25 | [custom-design](python/25-custom-design/) | Every chat design knob (`set_theme`, branding, layout) | `aiui run app.py` |
| 26 | [clean-chat](python/26-clean-chat/) | `set_dashboard(sidebar=False)` — dashboard chat without left nav | `aiui run app.py` |
| 27 | [custom-theme-chat](python/27-custom-theme-chat/) | `register_theme()` — dynamic custom theme (teal/cyan) | `aiui run app.py` |
| 28 | [multica-style](python/28-multica-style/) | Multica-inspired UI features (glass, gradients, motion) | `python app.py` |
| 29 | [full-extensibility](python/29-full-extensibility/) | All extension points: pages, forms, `set_custom_js`, plugins | `python app.py` |
| 30 | [a2ui-canvas](python/30-a2ui-canvas/) | A2UI canvas + `send_a2ui_messages` integration | `python app.py` |
| 31 | [image-preview](python/31-image-preview/) | `ImageAgent` inline chat image preview via `MESSAGE_ELEMENT` | `aiui run app.py` |
| — | [external-agent-dashboard](python/external-agent-dashboard/) | Minimal external agent on dashboard shell | `aiui run app.py` |
| — | [platform-board](python/platform-board/) | Kanban board from PraisonAI Platform issues API | `aiui run app.py` |
| — | [praisonai-claw-board](python/praisonai-claw-board/) | Kanban board from `aiui` jobs store | `aiui run app.py` |
| — | [video-studio](python/video-studio/) | Video Studio — YAML scenes via Video engine sidecar | `aiui run app.py` |
---
@@ -33,22 +55,22 @@
| # | Name | Shows | Run |
|---|---|---|---|
| 01 | [chat](yaml/01-chat/) | Zero-code YAML chat agent | `aiui run chat.yaml` |
| 02 | [clean-chat](yaml/02-clean-chat/) | `dashboard.sidebar: false` — dashboard chat without left nav (YAML parity) | `aiui run clean-chat.yaml` |
| 02 | [clean-chat](yaml/02-clean-chat/) | `dashboard.sidebar: false` — dashboard chat without left nav | `aiui run clean-chat.yaml` |
| 02 | [profiles](yaml/02-profiles/) | YAML agent profiles / persona switching | `aiui run chat.yaml` |
| 03 | [tools](yaml/03-tools/) | Built-in YAML tool registration | `aiui run chat.yaml` |
| 04 | [features](yaml/04-features/) | Feature flags and dashboard options in YAML | `aiui run chat.yaml` |
| 12 | [playground](yaml/12-playground/) | YAML agent playground with multiple profiles | `aiui run chat.yaml --style agents` |
---
## YAML Track — Site Templates
| # | Name | Shows |
|---|---|---|
| 01 | minimal | 5-line YAML → live docs site |
| 02 | with-sidebar | `DocsSidebar` slot, directory-based navigation |
| 03 | with-toc | `Toc` slot, right-column table of contents |
| 04 | multi-section | Multiple content directories and grouped routes |
| 05 | custom-homepage | `Hero` + `FeatureList` slot components |
| 06 | fullstack | Header/Footer refs, CTA buttons, dark mode |
| 07 | seo-config | `<meta>` tags, `<title>` per-page, Open Graph |
| 08 | api-reference | `ApiSidebar`, route-level slot overrides |
| 09 | typescript-custom | Custom TypeScript component registration |
| 10 | i18n-ready | Multi-language content directories |
| 11 | monorepo | Workspaces, shared components, per-package docs |
| # | Name | Shows | Run |
|---|---|---|---|
| 05 | [minimal](yaml/05-minimal/) | 5-line YAML → live docs site | `aiui build --config aiui.template.yaml` |
| 06 | [blocks](yaml/06-blocks/) | shadcn component dependency installation | `aiui build --config aiui.template.yaml` |
| 07 | [colorful](yaml/07-colorful/) | Colourful marketing theme with hero zones | `aiui build --config aiui.template.yaml` |
| 08 | [corporate](yaml/08-corporate/) | Corporate docs with SEO, i18n, and a11y fields | `aiui build --config aiui.template.yaml` |
| 09 | [dark-modern](yaml/09-dark-modern/) | Dark-mode modern docs layout | `aiui build --config aiui.template.yaml` |
| 10 | [developer-portal](yaml/10-developer-portal/) | API reference sidebar and route overrides | `aiui build --config aiui.template.yaml` |
| 11 | [full-featured](yaml/11-full-featured/) | Full enterprise config (SEO, i18n, a11y, search) | `aiui build --config aiui.template.yaml` |
+12 -12
View File
@@ -40,8 +40,10 @@ def _get_client():
_client = AsyncOpenAI(api_key=api_key)
return _client
# Keep per-session context in memory (DataStore handles persistence)
_contexts: dict[str, list[dict]] = {}
# Use SessionGovernor for bounded session memory management
from praisonaiui.session_governor import SessionGovernor
_governor = SessionGovernor(max_turns=20, max_sessions=100)
@aiui.starters
@@ -66,20 +68,18 @@ async def on_message(message: str):
"""Send the message to OpenAI and stream the response."""
session_id = message.session_id if hasattr(message, "session_id") else "default"
# Build conversation context
if session_id not in _contexts:
_contexts[session_id] = [
{"role": "system", "content": "You are a helpful, concise assistant."}
]
_contexts[session_id].append({"role": "user", "content": str(message)})
# Add user message to bounded session context
_governor.add_message(session_id, {"role": "user", "content": str(message)})
await aiui.think("Thinking...")
# Get bounded context for LLM
messages = _governor.get_context(session_id)
# Stream from OpenAI
stream = await _get_client().chat.completions.create(
model="gpt-4o-mini",
messages=_contexts[session_id],
messages=messages,
stream=True,
)
@@ -90,8 +90,8 @@ async def on_message(message: str):
full_response += delta.content
await aiui.stream_token(delta.content)
# Save assistant response to context
_contexts[session_id].append({"role": "assistant", "content": full_response})
# Save assistant response to bounded context
_governor.add_message(session_id, {"role": "assistant", "content": full_response})
# Feedback buttons
await aiui.action_buttons([
@@ -15,8 +15,20 @@ Run:
"""
import asyncio
import sys
import praisonaiui as aiui
# Support --datastore json flag as documented
if "--datastore" in sys.argv:
try:
idx = sys.argv.index("--datastore")
if idx + 1 < len(sys.argv) and sys.argv[idx + 1] == "json":
from praisonaiui.datastore import JSONFileDataStore
aiui.set_datastore(JSONFileDataStore())
except (IndexError, ImportError):
pass
# Lazy agent — only created when first message is sent
_agent = None
+66 -31
View File
@@ -17,10 +17,10 @@ Run:
"""
import asyncio
import praisonaiui as aiui
from praisonaiui.provider import BaseProvider, RunEvent, RunEventType
# === Option 1: Provider-based (Direct Mode) ===
# Uses PraisonAI Agent directly with streaming bridge
@@ -65,13 +65,11 @@ class FullFeaturedProvider(BaseProvider):
def calculate(expression: str) -> str:
"""Evaluate a math expression safely."""
from praisonaiui.math_eval import eval_math_expression
try:
# Safe evaluation of basic math
allowed = set("0123456789+-*/.() ")
if all(c in allowed for c in expression):
result = eval(expression)
return f"Result: {result}"
return "Error: Only basic math operations allowed"
result = eval_math_expression(expression)
return f"Result: {result}"
except Exception as e:
return f"Error: {e}"
@@ -89,7 +87,8 @@ class FullFeaturedProvider(BaseProvider):
yield RunEvent(type=RunEventType.REASONING_STARTED)
yield RunEvent(type=RunEventType.REASONING_STEP, step="Analyzing the request...")
# Set up streaming bridge
# Get the running event loop for thread-safe queue operations
loop = asyncio.get_running_loop()
event_queue = asyncio.Queue()
_has_streaming = False
@@ -99,27 +98,32 @@ class FullFeaturedProvider(BaseProvider):
def _on_stream(evt):
if evt.type == SET.DELTA_TEXT and evt.content:
if evt.is_reasoning:
event_queue.put_nowait(RunEvent(
run_event = RunEvent(
type=RunEventType.REASONING_STEP,
step=evt.content,
))
)
# Use thread-safe queue operation
loop.call_soon_threadsafe(event_queue.put_nowait, run_event)
else:
event_queue.put_nowait(RunEvent(
run_event = RunEvent(
type=RunEventType.RUN_CONTENT,
token=evt.content,
))
)
loop.call_soon_threadsafe(event_queue.put_nowait, run_event)
elif evt.type == SET.DELTA_TOOL_CALL and evt.tool_call:
event_queue.put_nowait(RunEvent(
run_event = RunEvent(
type=RunEventType.TOOL_CALL_STARTED,
name=evt.tool_call.get("name"),
args=evt.tool_call.get("arguments"),
))
)
loop.call_soon_threadsafe(event_queue.put_nowait, run_event)
elif evt.type == SET.TOOL_CALL_END and evt.tool_call:
event_queue.put_nowait(RunEvent(
run_event = RunEvent(
type=RunEventType.TOOL_CALL_COMPLETED,
name=evt.tool_call.get("name"),
result=evt.tool_call.get("result"),
))
)
loop.call_soon_threadsafe(event_queue.put_nowait, run_event)
agent.stream_emitter.add_callback(_on_stream)
agent.stream_emitter.enable_metrics()
@@ -129,26 +133,57 @@ class FullFeaturedProvider(BaseProvider):
yield RunEvent(type=RunEventType.REASONING_COMPLETED)
# Run the agent
try:
response = await asyncio.to_thread(agent.chat, message)
full_response = str(response)
except Exception as exc:
yield RunEvent(type=RunEventType.RUN_ERROR, error=str(exc))
return
finally:
if _has_streaming:
# Run the agent concurrently with event draining
if _has_streaming:
async def _drain_events():
"""Drain events concurrently with agent execution."""
while True:
event = await event_queue.get()
if event is None:
break
yield event
chat_task = asyncio.create_task(asyncio.to_thread(agent.chat, message))
async def _signal_end():
try:
await chat_task
finally:
await event_queue.put(None)
signal_task = asyncio.create_task(_signal_end())
try:
# Drain events while agent is running
async for event in _drain_events():
yield event
# Wait for agent completion
response = await chat_task
full_response = str(response)
except Exception as exc:
yield RunEvent(type=RunEventType.RUN_ERROR, error=str(exc))
return
finally:
try:
agent.stream_emitter.remove_callback(_on_stream)
except Exception:
pass
# Drain streamed events
while not event_queue.empty():
# Ensure signal task is cleaned up
if not signal_task.done():
signal_task.cancel()
try:
await signal_task
except asyncio.CancelledError:
pass
else:
# Fallback without streaming
try:
yield event_queue.get_nowait()
except asyncio.QueueEmpty:
break
response = await asyncio.to_thread(agent.chat, message)
full_response = str(response)
except Exception as exc:
yield RunEvent(type=RunEventType.RUN_ERROR, error=str(exc))
return
# If no tokens were streamed, emit the full response
if event_queue.empty() and not _has_streaming:
+17 -27
View File
@@ -10,14 +10,17 @@ Run:
aiui run app.py
"""
import os
import asyncio
import os
import sys
import praisonaiui as aiui
sys.path.insert(0, os.path.join(os.path.dirname(__file__), '..', '_shared'))
from stream_bridge import StreamBridge
try:
from praisonaiagents import Agent
from praisonaiagents.streaming.events import StreamEventType
except ImportError:
raise ImportError(
"praisonaiagents package required. Install with: pip install praisonaiagents"
@@ -70,36 +73,22 @@ async def on_message(message: str):
await aiui.think("Thinking...")
# Set up a streaming callback that forwards tokens to PraisonAIUI
token_queue = asyncio.Queue()
# Set up thread-safe streaming bridge
bridge = StreamBridge()
def on_stream_event(event):
"""Callback invoked by the Agent's stream_emitter for each token."""
if event.type == StreamEventType.DELTA_TEXT and event.content:
token_queue.put_nowait(event.content)
elif event.type == StreamEventType.FIRST_TOKEN and event.content:
token_queue.put_nowait(event.content)
elif event.type == StreamEventType.STREAM_END:
token_queue.put_nowait(None) # Sentinel
# Register callback
agent.stream_emitter.add_callback(on_stream_event)
# Register callback using the bridge
callback = bridge.emitter_callback()
agent.stream_emitter.add_callback(callback)
agent.stream_emitter.enable_metrics()
try:
# Run agent.chat in a thread (it's synchronous)
chat_task = asyncio.get_event_loop().run_in_executor(
None, lambda: agent.chat(str(message), stream=True)
# Run agent.chat concurrently with token consumption
chat_task = asyncio.create_task(
asyncio.to_thread(lambda: agent.chat(str(message), stream=True))
)
# Drain tokens as they arrive and stream to UI
while True:
try:
token = await asyncio.wait_for(token_queue.get(), timeout=60.0)
except asyncio.TimeoutError:
break
if token is None: # Sentinel from STREAM_END
break
# Consume tokens as they arrive and stream to UI
async for token in bridge.consume():
await aiui.stream_token(token)
# Wait for the chat to fully complete
@@ -107,7 +96,8 @@ async def on_message(message: str):
finally:
# Clean up callback
agent.stream_emitter.remove_callback(on_stream_event)
agent.stream_emitter.remove_callback(callback)
bridge.cancel()
@aiui.cancel
+7 -4
View File
@@ -11,15 +11,17 @@ Demonstrates:
Run:
pip install praisonaiui
python app.py
aiui run app.py
# Dashboard at http://localhost:8082
# Set HOST=0.0.0.0 for Docker/LAN demos (defaults to 127.0.0.1)
"""
import sys
import os
import sys
import uvicorn
from praisonaiui.server import create_app
import uvicorn
# Use shared seed data helper
sys.path.insert(0, os.path.join(os.path.dirname(__file__), ".."))
@@ -31,4 +33,5 @@ if __name__ == "__main__":
app = create_app()
print("✅ Full Dashboard at http://localhost:8082")
print(" API: http://localhost:8082/api/features")
uvicorn.run(app, host="0.0.0.0", port=8082, log_level="info")
host = os.getenv("HOST", "127.0.0.1")
uvicorn.run(app, host=host, port=8082, log_level="info")
+2 -1
View File
@@ -171,7 +171,8 @@ def run_standalone():
aiui_app.routes.insert(0, Route("/", landing, methods=["GET"]))
print("✅ UI Integration Demo at http://localhost:8082")
uvicorn.run(aiui_app, host="0.0.0.0", port=8082, log_level="info")
host = os.getenv("HOST", "127.0.0.1")
uvicorn.run(aiui_app, host=host, port=8082, log_level="info")
if __name__ == "__main__":
-493
View File
@@ -1,493 +0,0 @@
"""Agent Dashboard — OpenClaw/agent-ui style admin panel.
What's New (vs full-dashboard/):
• Rendered HTML dashboard — not just JSON APIs
• Sidebar navigation with live page switching
• Status badges, toggle buttons, real-time data
• Glassmorphism dark theme
• All data from PraisonAIUI feature APIs
Run:
PYTHONPATH=src python app.py
# Dashboard at http://localhost:8082
"""
import os
import sys
import uvicorn
from starlette.responses import HTMLResponse
from starlette.routing import Route
from praisonaiui.server import create_app
# Use shared seed data helper
sys.path.insert(0, os.path.join(os.path.dirname(__file__), ".."))
from _shared.seed_data import seed_demo_data
# ── HTML Template ────────────────────────────────────────────────────
DASHBOARD_HTML = """<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Agent Dashboard — PraisonAIUI</title>
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700&display=swap" rel="stylesheet">
<style>
:root {
--bg-primary: #0a0a12;
--bg-card: #12121f;
--bg-hover: #1a1a2e;
--border: #2a2a4a;
--text-primary: #e0e0e8;
--text-secondary: #94a3b8;
--accent-purple: #7c3aed;
--accent-cyan: #06b6d4;
--accent-green: #22c55e;
--accent-amber: #f59e0b;
--accent-red: #ef4444;
}
* { margin: 0; padding: 0; box-sizing: border-box; }
body { font-family: 'Inter', -apple-system, sans-serif; background: var(--bg-primary);
color: var(--text-primary); display: flex; min-height: 100vh; }
/* Sidebar */
.sidebar { width: 240px; background: var(--bg-card); border-right: 1px solid var(--border);
display: flex; flex-direction: column; position: fixed; height: 100vh; z-index: 10; }
.sidebar-header { padding: 1.25rem; border-bottom: 1px solid var(--border); }
.sidebar-header h1 { font-size: 1.1rem; font-weight: 700;
background: linear-gradient(90deg, var(--accent-purple), var(--accent-cyan));
-webkit-background-clip: text; -webkit-text-fill-color: transparent; }
.sidebar-header p { font-size: 0.7rem; color: var(--text-secondary); margin-top: 2px; }
.nav-group { padding: 0.75rem 0; }
.nav-group-label { font-size: 0.65rem; color: var(--text-secondary); text-transform: uppercase;
letter-spacing: 0.1em; padding: 0 1rem; margin-bottom: 0.25rem; font-weight: 600; }
.nav-item { display: flex; align-items: center; gap: 0.6rem; padding: 0.5rem 1rem;
cursor: pointer; transition: all 0.2s; font-size: 0.85rem; color: var(--text-secondary);
border-left: 3px solid transparent; }
.nav-item:hover { background: var(--bg-hover); color: var(--text-primary); }
.nav-item.active { background: var(--bg-hover); color: var(--accent-purple);
border-left-color: var(--accent-purple); font-weight: 500; }
.nav-icon { font-size: 1rem; width: 1.5rem; text-align: center; }
.nav-badge { margin-left: auto; background: var(--accent-purple); color: white;
font-size: 0.65rem; padding: 1px 6px; border-radius: 10px; }
/* Main content */
.main { margin-left: 240px; flex: 1; padding: 1.5rem; }
.page-header { margin-bottom: 1.5rem; }
.page-header h2 { font-size: 1.5rem; font-weight: 600; }
.page-header p { color: var(--text-secondary); font-size: 0.9rem; margin-top: 0.25rem; }
/* Cards grid */
.stats-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
gap: 1rem; margin-bottom: 1.5rem; }
.stat-card { background: var(--bg-card); border: 1px solid var(--border); border-radius: 10px;
padding: 1.25rem; transition: border-color 0.3s; }
.stat-card:hover { border-color: var(--accent-purple); }
.stat-label { font-size: 0.75rem; color: var(--text-secondary); text-transform: uppercase;
letter-spacing: 0.05em; }
.stat-value { font-size: 1.75rem; font-weight: 700; margin-top: 0.25rem;
background: linear-gradient(90deg, var(--accent-purple), var(--accent-cyan));
-webkit-background-clip: text; -webkit-text-fill-color: transparent; }
.stat-sub { font-size: 0.75rem; color: var(--text-secondary); margin-top: 0.25rem; }
/* Table */
.data-table { width: 100%; background: var(--bg-card); border: 1px solid var(--border);
border-radius: 10px; overflow: hidden; }
.data-table th { text-align: left; padding: 0.75rem 1rem; font-size: 0.75rem;
text-transform: uppercase; letter-spacing: 0.05em; color: var(--text-secondary);
background: var(--bg-hover); border-bottom: 1px solid var(--border); font-weight: 600; }
.data-table td { padding: 0.75rem 1rem; font-size: 0.85rem; border-bottom: 1px solid var(--border); }
.data-table tr:last-child td { border-bottom: none; }
.data-table tr:hover td { background: rgba(124, 58, 237, 0.05); }
/* Badges */
.badge { padding: 2px 8px; border-radius: 4px; font-size: 0.7rem; font-weight: 600; }
.badge-online { background: rgba(34, 197, 94, 0.15); color: var(--accent-green); }
.badge-offline { background: rgba(239, 68, 68, 0.15); color: var(--accent-red); }
.badge-enabled { background: rgba(34, 197, 94, 0.15); color: var(--accent-green); }
.badge-disabled { background: rgba(148, 163, 184, 0.15); color: var(--text-secondary); }
.badge-running { background: rgba(6, 182, 212, 0.15); color: var(--accent-cyan); }
.badge-platform { background: rgba(124, 58, 237, 0.1); color: var(--accent-purple); }
/* Toggle switch */
.toggle { position: relative; width: 36px; height: 20px; cursor: pointer; }
.toggle input { display: none; }
.toggle-slider { position: absolute; inset: 0; background: #333; border-radius: 10px;
transition: 0.3s; }
.toggle-slider::before { content: ''; position: absolute; left: 2px; top: 2px; width: 16px;
height: 16px; background: white; border-radius: 50%; transition: 0.3s; }
.toggle input:checked + .toggle-slider { background: var(--accent-purple); }
.toggle input:checked + .toggle-slider::before { transform: translateX(16px); }
/* Section */
.section { margin-bottom: 1.5rem; }
.section-title { font-size: 0.85rem; color: var(--text-secondary); font-weight: 600;
text-transform: uppercase; letter-spacing: 0.05em; margin-bottom: 0.75rem;
display: flex; align-items: center; gap: 0.5rem; }
.section-count { background: var(--accent-purple); color: white; font-size: 0.65rem;
padding: 1px 6px; border-radius: 10px; }
/* Page display */
.page { display: none; }
.page.active { display: block; }
/* Animations */
@keyframes fadeIn { from { opacity: 0; transform: translateY(8px); }
to { opacity: 1; transform: translateY(0); } }
.page.active { animation: fadeIn 0.3s ease; }
/* Feature list */
.feature-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(280px, 1fr));
gap: 1rem; }
.feature-card { background: var(--bg-card); border: 1px solid var(--border); border-radius: 10px;
padding: 1.25rem; transition: all 0.3s; }
.feature-card:hover { border-color: var(--accent-purple); transform: translateY(-2px); }
.feature-name { font-weight: 600; margin-bottom: 0.25rem; }
.feature-desc { font-size: 0.8rem; color: var(--text-secondary); }
.feature-routes { margin-top: 0.75rem; font-size: 0.75rem; color: var(--text-secondary); }
.feature-route { font-family: monospace; font-size: 0.7rem; color: var(--accent-cyan);
display: block; margin-top: 2px; }
/* Empty state */
.empty { text-align: center; padding: 3rem; color: var(--text-secondary); }
.empty-icon { font-size: 3rem; margin-bottom: 1rem; }
</style>
</head>
<body>
<aside class="sidebar">
<div class="sidebar-header">
<h1>🤖 Agent Dashboard</h1>
<p>PraisonAIUI Admin</p>
</div>
<nav>
<div class="nav-group">
<div class="nav-group-label">Overview</div>
<div class="nav-item active" data-page="overview">
<span class="nav-icon">📊</span> Overview
</div>
<div class="nav-item" data-page="features">
<span class="nav-icon">⚡</span> Features
<span class="nav-badge" id="nav-feature-count">—</span>
</div>
</div>
<div class="nav-group">
<div class="nav-group-label">Messaging</div>
<div class="nav-item" data-page="channels">
<span class="nav-icon">📡</span> Channels
<span class="nav-badge" id="nav-channel-count">—</span>
</div>
</div>
<div class="nav-group">
<div class="nav-group-label">Infrastructure</div>
<div class="nav-item" data-page="nodes">
<span class="nav-icon">🖥️</span> Nodes
<span class="nav-badge" id="nav-node-count">—</span>
</div>
<div class="nav-item" data-page="instances">
<span class="nav-icon">📻</span> Instances
<span class="nav-badge" id="nav-instance-count">—</span>
</div>
</div>
<div class="nav-group">
<div class="nav-group-label">Automation</div>
<div class="nav-item" data-page="schedules">
<span class="nav-icon">⏰</span> Schedules
<span class="nav-badge" id="nav-schedule-count">—</span>
</div>
<div class="nav-item" data-page="skills">
<span class="nav-icon">🧩</span> Skills
<span class="nav-badge" id="nav-skill-count">—</span>
</div>
</div>
</nav>
</aside>
<main class="main">
<!-- OVERVIEW PAGE -->
<div class="page active" id="page-overview">
<div class="page-header">
<h2>Overview</h2>
<p>Real-time system status at a glance</p>
</div>
<div class="stats-grid" id="overview-stats"></div>
</div>
<!-- FEATURES PAGE -->
<div class="page" id="page-features">
<div class="page-header">
<h2>Features</h2>
<p>All registered protocol feature modules</p>
</div>
<div class="feature-grid" id="features-grid"></div>
</div>
<!-- CHANNELS PAGE -->
<div class="page" id="page-channels">
<div class="page-header">
<h2>Channels</h2>
<p>Multi-platform messaging channels</p>
</div>
<div class="section">
<div class="section-title">Active Channels <span class="section-count" id="channel-count">0</span></div>
<table class="data-table" id="channels-table">
<thead><tr>
<th>Name</th><th>Platform</th><th>Status</th><th>Enabled</th><th>Last Activity</th>
</tr></thead>
<tbody></tbody>
</table>
</div>
</div>
<!-- NODES PAGE -->
<div class="page" id="page-nodes">
<div class="page-header">
<h2>Nodes</h2>
<p>Execution nodes and agent bindings</p>
</div>
<div class="section">
<div class="section-title">Registered Nodes <span class="section-count" id="node-count">0</span></div>
<table class="data-table" id="nodes-table">
<thead><tr>
<th>Name</th><th>Host</th><th>Platform</th><th>Status</th><th>Agents</th><th>Policy</th>
</tr></thead>
<tbody></tbody>
</table>
</div>
</div>
<!-- INSTANCES PAGE -->
<div class="page" id="page-instances">
<div class="page-header">
<h2>Instances</h2>
<p>Connected clients and workers</p>
</div>
<div class="section">
<div class="section-title">Connected Instances <span class="section-count" id="instance-count">0</span></div>
<table class="data-table" id="instances-table">
<thead><tr>
<th>ID</th><th>Host</th><th>Platform</th><th>Mode</th><th>Roles</th><th>Last Seen</th>
</tr></thead>
<tbody></tbody>
</table>
</div>
</div>
<!-- SCHEDULES PAGE -->
<div class="page" id="page-schedules">
<div class="page-header">
<h2>Schedules</h2>
<p>Cron jobs and scheduled tasks</p>
</div>
<div class="section">
<div class="section-title">Scheduled Jobs <span class="section-count" id="schedule-count">0</span></div>
<table class="data-table" id="schedules-table">
<thead><tr>
<th>Name</th><th>Schedule</th><th>Agent</th><th>Enabled</th><th>Runs</th><th>Last Run</th>
</tr></thead>
<tbody></tbody>
</table>
</div>
</div>
<!-- SKILLS PAGE -->
<div class="page" id="page-skills">
<div class="page-header">
<h2>Skills</h2>
<p>Registered agent capabilities</p>
</div>
<div class="section">
<div class="section-title">Agent Skills <span class="section-count" id="skill-count">0</span></div>
<table class="data-table" id="skills-table">
<thead><tr>
<th>Name</th><th>ID</th><th>Enabled</th><th>Config</th>
</tr></thead>
<tbody></tbody>
</table>
</div>
</div>
</main>
<script>
// ── Navigation ──────────────────────────────────────────────────────
document.querySelectorAll('.nav-item').forEach(item => {
item.addEventListener('click', () => {
document.querySelectorAll('.nav-item').forEach(i => i.classList.remove('active'));
document.querySelectorAll('.page').forEach(p => p.classList.remove('active'));
item.classList.add('active');
document.getElementById('page-' + item.dataset.page).classList.add('active');
});
});
// ── Data loading ────────────────────────────────────────────────────
async function fetchJSON(url) {
const r = await fetch(url);
return r.json();
}
function timeAgo(ts) {
if (!ts) return '—';
const s = Math.floor(Date.now() / 1000 - ts);
if (s < 60) return s + 's ago';
if (s < 3600) return Math.floor(s / 60) + 'm ago';
if (s < 86400) return Math.floor(s / 3600) + 'h ago';
return Math.floor(s / 86400) + 'd ago';
}
async function loadOverview() {
const [features, channels, nodes, instances, schedules, skills] = await Promise.all([
fetchJSON('/api/features'),
fetchJSON('/api/channels'),
fetchJSON('/api/nodes'),
fetchJSON('/api/instances'),
fetchJSON('/api/schedules'),
fetchJSON('/api/skills'),
]);
// Nav badges
document.getElementById('nav-feature-count').textContent = features.count;
document.getElementById('nav-channel-count').textContent = channels.count;
document.getElementById('nav-node-count').textContent = nodes.count;
document.getElementById('nav-instance-count').textContent = instances.count;
document.getElementById('nav-schedule-count').textContent = schedules.count;
document.getElementById('nav-skill-count').textContent = skills.count;
// Overview stats
const statsEl = document.getElementById('overview-stats');
const runningChannels = channels.channels.filter(c => c.running).length;
const onlineNodes = nodes.nodes.filter(n => n.status === 'online').length;
statsEl.innerHTML = `
<div class="stat-card">
<div class="stat-label">Features</div>
<div class="stat-value">${features.count}</div>
<div class="stat-sub">Protocol modules active</div>
</div>
<div class="stat-card">
<div class="stat-label">Channels</div>
<div class="stat-value">${channels.count}</div>
<div class="stat-sub">${runningChannels} running</div>
</div>
<div class="stat-card">
<div class="stat-label">Nodes</div>
<div class="stat-value">${nodes.count}</div>
<div class="stat-sub">${onlineNodes} online</div>
</div>
<div class="stat-card">
<div class="stat-label">Instances</div>
<div class="stat-value">${instances.count}</div>
<div class="stat-sub">Connected clients & workers</div>
</div>
<div class="stat-card">
<div class="stat-label">Schedules</div>
<div class="stat-value">${schedules.count}</div>
<div class="stat-sub">Cron jobs registered</div>
</div>
<div class="stat-card">
<div class="stat-label">Skills</div>
<div class="stat-value">${skills.count}</div>
<div class="stat-sub">Agent capabilities</div>
</div>
`;
// Features grid
const fg = document.getElementById('features-grid');
fg.innerHTML = features.features.map(f => `
<div class="feature-card">
<div class="feature-name">${f.name}</div>
<div class="feature-desc">${f.description}</div>
<div class="feature-routes">${f.routes.length} routes
${f.routes.slice(0, 3).map(r => '<span class="feature-route">' + r + '</span>').join('')}
${f.routes.length > 3 ? '<span class="feature-route">... +' + (f.routes.length - 3) + ' more</span>' : ''}
</div>
</div>
`).join('');
// Channels table
document.getElementById('channel-count').textContent = channels.count;
document.querySelector('#channels-table tbody').innerHTML = channels.channels.map(c => `
<tr>
<td><strong>${c.name}</strong></td>
<td><span class="badge badge-platform">${c.platform}</span></td>
<td>${c.running ? '<span class="badge badge-running">● running</span>' : '<span class="badge badge-offline">● stopped</span>'}</td>
<td>${c.enabled ? '<span class="badge badge-enabled">enabled</span>' : '<span class="badge badge-disabled">disabled</span>'}</td>
<td>${timeAgo(c.last_activity)}</td>
</tr>
`).join('');
// Nodes table
document.getElementById('node-count').textContent = nodes.count;
document.querySelector('#nodes-table tbody').innerHTML = nodes.nodes.map(n => `
<tr>
<td><strong>${n.name}</strong></td>
<td><code>${n.host}</code></td>
<td>${n.platform}</td>
<td>${n.status === 'online' ? '<span class="badge badge-online">● online</span>' : '<span class="badge badge-offline">● offline</span>'}</td>
<td>${(n.agents || []).map(a => '<span class="badge badge-platform">' + a + '</span> ').join('')}</td>
<td><span class="badge">${n.approval_policy}</span></td>
</tr>
`).join('');
// Instances table
document.getElementById('instance-count').textContent = instances.count;
document.querySelector('#instances-table tbody').innerHTML = instances.instances.map(i => `
<tr>
<td><code>${i.id}</code></td>
<td>${i.host}</td>
<td>${i.platform}</td>
<td><span class="badge badge-${i.mode === 'worker' ? 'running' : 'enabled'}">${i.mode}</span></td>
<td>${(i.roles || []).join(', ')}</td>
<td>${timeAgo(i.last_seen)}</td>
</tr>
`).join('');
// Schedules table
document.getElementById('schedule-count').textContent = schedules.count;
document.querySelector('#schedules-table tbody').innerHTML = schedules.schedules.map(s => `
<tr>
<td><strong>${s.name}</strong></td>
<td><code>${s.schedule}</code></td>
<td>${s.agent_id || '—'}</td>
<td>${s.enabled ? '<span class="badge badge-enabled">enabled</span>' : '<span class="badge badge-disabled">disabled</span>'}</td>
<td>${s.run_count || 0}</td>
<td>${timeAgo(s.last_run)}</td>
</tr>
`).join('');
// Skills table
document.getElementById('skill-count').textContent = skills.count;
document.querySelector('#skills-table tbody').innerHTML = skills.skills.map(s => `
<tr>
<td><strong>${s.name}</strong></td>
<td><code>${s.id}</code></td>
<td>${s.enabled ? '<span class="badge badge-enabled">enabled</span>' : '<span class="badge badge-disabled">disabled</span>'}</td>
<td><code>${JSON.stringify(s.config || {})}</code></td>
</tr>
`).join('');
}
// Auto-refresh every 10 seconds
loadOverview();
setInterval(loadOverview, 10000);
</script>
</body>
</html>"""
# ── Entry point ──────────────────────────────────────────────────────
seed_demo_data()
if __name__ == "__main__":
app = create_app()
# Add the dashboard HTML landing page
async def dashboard_landing(request):
return HTMLResponse(DASHBOARD_HTML)
app.routes.insert(0, Route("/", dashboard_landing, methods=["GET"]))
print("✅ Agent Dashboard at http://localhost:8082")
print(" JSON API: http://localhost:8082/api/features")
uvicorn.run(app, host="0.0.0.0", port=8082, log_level="info")
+18 -24
View File
@@ -1,6 +1,6 @@
"""Real Agent Dashboard — PraisonAI agents + PraisonAIUI dashboard.
Unlike 12-agent-dashboard (mock data), this runs REAL PraisonAI agents.
This example uses secure component-based rendering with REAL PraisonAI agents.
Uses PraisonAIUI's native dashboard with @reply, @page, register_agent()
— all backed by actual LLM calls.
@@ -18,15 +18,18 @@ Run:
aiui run app.py --style dashboard
"""
import os
import time
import asyncio
import os
import sys
import time
import praisonaiui as aiui
sys.path.insert(0, os.path.join(os.path.dirname(__file__), '..', '_shared'))
from stream_bridge import StreamBridge
try:
from praisonaiagents import Agent
from praisonaiagents.streaming.events import StreamEventType
except ImportError:
raise ImportError(
"praisonaiagents required: pip install praisonaiagents"
@@ -102,31 +105,21 @@ async def on_message(message: str):
await aiui.think("Agent thinking...")
start = time.time()
token_queue = asyncio.Queue()
def on_stream(event):
if event.type == StreamEventType.DELTA_TEXT and event.content:
token_queue.put_nowait(event.content)
elif event.type == StreamEventType.FIRST_TOKEN and event.content:
token_queue.put_nowait(event.content)
elif event.type == StreamEventType.STREAM_END:
token_queue.put_nowait(None)
agent.stream_emitter.add_callback(on_stream)
# Set up thread-safe streaming bridge
bridge = StreamBridge()
callback = bridge.emitter_callback()
agent.stream_emitter.add_callback(callback)
full = []
try:
task = asyncio.get_event_loop().run_in_executor(
None, lambda: agent.chat(str(message), stream=True)
# Run agent.chat concurrently with token consumption
task = asyncio.create_task(
asyncio.to_thread(lambda: agent.chat(str(message), stream=True))
)
while True:
try:
tok = await asyncio.wait_for(token_queue.get(), timeout=60)
except asyncio.TimeoutError:
break
if tok is None:
break
# Consume tokens as they arrive and stream to UI
async for tok in bridge.consume():
full.append(tok)
await aiui.stream_token(tok)
@@ -139,7 +132,8 @@ async def on_message(message: str):
await aiui.say(f"❌ Error: {e}")
finally:
agent.stream_emitter.remove_callback(on_stream)
agent.stream_emitter.remove_callback(callback)
bridge.cancel()
# ── @page — live metrics from real calls ─────────────────────────────
+1 -8
View File
@@ -1,16 +1,9 @@
"""Example 14: All Features Demo — Comprehensive API test suite.
Demonstrates ALL 16 PraisonAIUI features with real API calls.
Run: PYTHONPATH=src python examples/python/14-all-features/app.py
Run: aiui run app.py
"""
import sys
import os
# Add paths for imports
sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..", "..", "..", "src"))
sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..", "..", ".."))
from starlette.testclient import TestClient
from praisonaiui.server import create_app
+3 -1
View File
@@ -85,5 +85,7 @@ async def metrics_page():
app = create_app()
if __name__ == "__main__":
import os
import uvicorn
uvicorn.run(app, host="0.0.0.0", port=8082)
host = os.getenv("HOST", "127.0.0.1")
uvicorn.run(app, host=host, port=8082)
@@ -154,13 +154,14 @@ def _register_agents_in_dashboard():
# ── Main ────────────────────────────────────────────────────
async def main():
host = os.getenv("HOST", "127.0.0.1")
if GATEWAY_OK:
print("🚀 Starting with Gateway mode (full execution + streaming)")
print()
# Create the gateway
gateway = AIUIGateway(
host="0.0.0.0",
host=host,
port=8083,
)
@@ -195,7 +196,7 @@ async def main():
_register_agents_in_dashboard()
app = create_app()
config = uvicorn.Config(app, host="0.0.0.0", port=8083, log_level="info")
config = uvicorn.Config(app, host=host, port=8083, log_level="info")
server = uvicorn.Server(config)
await server.serve()
+3 -2
View File
@@ -122,12 +122,13 @@ def _register_agents_in_dashboard():
async def main():
port = int(os.getenv("PORT", "8082"))
host = os.getenv("HOST", "127.0.0.1")
if GATEWAY_OK:
print("🚀 Starting with Gateway mode (full execution + streaming)")
print()
gateway = AIUIGateway(host="0.0.0.0", port=port)
gateway = AIUIGateway(host=host, port=port)
# Register agents with gateway for real execution
for agent_def in AGENTS:
@@ -165,7 +166,7 @@ async def main():
print(f" Note: POST endpoints may fail without gateway")
print()
config = uvicorn.Config(app, host="0.0.0.0", port=port, log_level="info")
config = uvicorn.Config(app, host=host, port=port, log_level="info")
server = uvicorn.Server(config)
await server.serve()
+22 -8
View File
@@ -1,10 +1,13 @@
"""PraisonAIUI — Full Chat Example (with Gateway + Page Config).
Dashboard with curated sidebar pages and a custom Feature Explorer:
- Uses ``aiui.set_pages()`` to show only essential built-in pages
Clean chat-focused dashboard with curated sidebar pages:
- Uses ``aiui.set_pages()`` to show only essential pages (chat, sessions, agents, usage, config)
- Hides debug/admin pages for a streamlined experience
- Adds a custom "Feature Explorer" page via ``@aiui.page()``
- Registers three agents: Researcher, Writer, Coder
Compare with 17-three-column-demo which shows ALL available pages.
Modes:
1. Gateway mode (recommended) — starts real agent execution + WebSocket
Requires: pip install praisonai praisonaiagents
@@ -22,14 +25,14 @@ Usage:
import asyncio
import os
import sys
import praisonaiui as aiui
# ── Check gateway availability ──────────────────────────────
try:
from praisonaiui.integration import AIUIGateway
from praisonaiagents import Agent
from praisonaiui.integration import AIUIGateway
GATEWAY_OK = True
except ImportError as e:
print(f"⚠️ Gateway dependencies not found: {e}")
@@ -39,6 +42,15 @@ except ImportError as e:
aiui.set_style("dashboard")
# ── Page curation ───────────────────────────────────────────
aiui.set_pages([
"chat", # Main chat interface
"sessions", # Session management
"agents", # Agent configuration
"usage", # Usage metrics
"config", # Configuration settings
])
# ── Agent definitions ───────────────────────────────────────
AGENTS = [
@@ -122,12 +134,13 @@ def _register_agents_in_dashboard():
async def main():
port = int(os.getenv("PORT", "8082"))
host = os.getenv("HOST", "127.0.0.1")
if GATEWAY_OK:
print("🚀 Starting with Gateway mode (full execution + streaming)")
print()
gateway = AIUIGateway(host="0.0.0.0", port=port)
gateway = AIUIGateway(host=host, port=port)
# Register agents with gateway for real execution
for agent_def in AGENTS:
@@ -153,19 +166,20 @@ async def main():
else:
# Standalone: REST APIs only, no agent execution
print("📦 Starting in standalone mode (REST APIs only, no agent execution)")
from praisonaiui.server import create_app
import uvicorn
from praisonaiui.server import create_app
_register_agents_in_dashboard()
app = create_app()
print()
print(f" Dashboard: http://localhost:{port}")
print(f" Explorer: http://localhost:{port} → 🔬 Feature Explorer")
print(f" Note: POST endpoints may fail without gateway")
print(" Note: POST endpoints may fail without gateway")
print()
config = uvicorn.Config(app, host="0.0.0.0", port=port, log_level="info")
config = uvicorn.Config(app, host=host, port=port, log_level="info")
server = uvicorn.Server(config)
await server.serve()
File renamed without changes.
@@ -31,7 +31,7 @@ Requires:
export EMAIL_APP_PASSWORD=your_app_password
Run:
python app.py
aiui run app.py
# Dashboard at http://localhost:8084
"""
@@ -85,6 +85,7 @@ def seed_email_channel():
"email_address": os.getenv("EMAIL_ADDRESS", "[email protected]"),
"imap_server": os.getenv("EMAIL_IMAP_SERVER", "imap.gmail.com"),
"smtp_server": os.getenv("EMAIL_SMTP_SERVER", "smtp.gmail.com"),
"app_password_ref": "env:EMAIL_APP_PASSWORD",
"polling_interval": 30,
},
})
@@ -182,4 +183,5 @@ if __name__ == "__main__":
print(f"✅ Dashboard at http://localhost:8084")
print(f" Channels: http://localhost:8084/api/channels")
print(f" Platforms: http://localhost:8084/api/channels/platforms")
uvicorn.run(app, host="0.0.0.0", port=8084, log_level="info")
host = os.getenv("HOST", "127.0.0.1")
uvicorn.run(app, host=host, port=8084, log_level="info")
@@ -80,7 +80,7 @@ def seed_agentmail_channel():
"name": "Agent Inbox",
"platform": "agentmail",
"config": {
"api_key": os.getenv("AGENTMAIL_API_KEY", ""),
"api_key_ref": "env:AGENTMAIL_API_KEY",
"domain": os.getenv("AGENTMAIL_DOMAIN", ""),
"inbox_id": os.getenv("AGENTMAIL_INBOX_ID", ""),
"polling_interval": 15,
@@ -182,4 +182,5 @@ if __name__ == "__main__":
print(f"✅ Dashboard at http://localhost:8084")
print(f" Channels: http://localhost:8084/api/channels")
print(f" Platforms: http://localhost:8084/api/channels/platforms")
uvicorn.run(app, host="0.0.0.0", port=8084, log_level="info")
host = os.getenv("HOST", "127.0.0.1")
uvicorn.run(app, host=host, port=8084, log_level="info")
@@ -227,5 +227,7 @@ async def on_welcome():
app = create_app()
if __name__ == "__main__":
import os
import uvicorn
uvicorn.run(app, host="0.0.0.0", port=8083)
host = os.getenv("HOST", "127.0.0.1")
uvicorn.run(app, host=host, port=8083)
@@ -19,6 +19,22 @@ import os
import praisonaiui as aiui
try:
from openai import AsyncOpenAI
except ImportError:
AsyncOpenAI = None
# Reuse client to prevent resource churn
_client = None
def _get_client():
global _client
if _client is None and AsyncOpenAI is not None:
api_key = os.getenv("OPENAI_API_KEY")
if api_key:
_client = AsyncOpenAI(api_key=api_key)
return _client
# ── Dashboard style, but no sidebar navigation ─────────────
aiui.set_style("dashboard")
aiui.set_dashboard(sidebar=False, page_header=False)
@@ -49,18 +65,14 @@ async def on_message(message: str):
"""Stream a response from OpenAI."""
await aiui.think("Thinking...")
try:
from openai import AsyncOpenAI
except ImportError:
await aiui.say("❌ Please install openai: `pip install openai`")
client = _get_client()
if client is None:
if AsyncOpenAI is None:
await aiui.say("❌ Please install openai: `pip install openai`")
else:
await aiui.say("❌ Please set OPENAI_API_KEY environment variable.")
return
api_key = os.getenv("OPENAI_API_KEY")
if not api_key:
await aiui.say("❌ Please set OPENAI_API_KEY environment variable.")
return
client = AsyncOpenAI(api_key=api_key)
stream = await client.chat.completions.create(
model=os.getenv("PRAISONAI_MODEL", "gpt-4o-mini"),
messages=[
@@ -18,6 +18,22 @@ import os
import praisonaiui as aiui
try:
from openai import AsyncOpenAI
except ImportError:
AsyncOpenAI = None
# Reuse client to prevent resource churn
_client = None
def _get_client():
global _client
if _client is None and AsyncOpenAI is not None:
api_key = os.getenv("OPENAI_API_KEY")
if api_key:
_client = AsyncOpenAI(api_key=api_key)
return _client
# ── Register custom teal/cyan theme ──────────────────────────────────
aiui.register_theme("teal-dark", {
"accent": "#14b8a6", # teal-500
@@ -58,18 +74,14 @@ async def on_message(message: str):
"""Stream a response from OpenAI."""
await aiui.think("Thinking...")
try:
from openai import AsyncOpenAI
except ImportError:
await aiui.say("❌ Please install openai: `pip install openai`")
client = _get_client()
if client is None:
if AsyncOpenAI is None:
await aiui.say("❌ Please install openai: `pip install openai`")
else:
await aiui.say("❌ Please set OPENAI_API_KEY environment variable.")
return
api_key = os.getenv("OPENAI_API_KEY")
if not api_key:
await aiui.say("❌ Please set OPENAI_API_KEY environment variable.")
return
client = AsyncOpenAI(api_key=api_key)
stream = await client.chat.completions.create(
model=os.getenv("PRAISONAI_MODEL", "gpt-4o-mini"),
messages=[
@@ -36,17 +36,14 @@ python app.py
## Testing the custom component + custom view
The `timeline` component and the `custom-view` page are client-side extensions.
Load `plugin.js` into the page:
`app.py` loads `plugin.js` automatically via `aiui.set_custom_js()` — no DevTools paste required.
1. Open http://localhost:8082 in the browser
2. Open DevTools → Console
3. Paste the full contents of `plugin.js` and press Enter
4. Navigate to **Custom Component** → the timeline now renders as a styled list
5. Navigate to **Client-Only View** → a JS-rendered clock appears
2. Navigate to **Custom Component** → the timeline renders as a styled list
3. Navigate to **Client-Only View** → a JS-rendered clock appears
> **Gap**: There is currently no Python API to inject client-side JS from
> `app.py`. A proposed `aiui.set_custom_js(path)` would close this gap —
> see the `docs/features/how-to-add-a-feature.md` guide.
> **Note:** `aiui.set_custom_js(path)` injects client-side JS from a file path at page load.
> See `docs/features/how-to-add-a-feature.md` for the extension API contract.
## Testing the form action
@@ -214,7 +214,9 @@ async def custom_view_fallback():
if __name__ == "__main__":
import os
import uvicorn
from praisonaiui.server import create_app
app = create_app()
uvicorn.run(app, host="0.0.0.0", port=8082)
host = os.getenv("HOST", "127.0.0.1")
uvicorn.run(app, host=host, port=8082)
@@ -27,14 +27,25 @@
'display:flex;gap:16px;align-items:center;padding:12px;' +
'background:var(--db-card-bg, #1f2937);border-radius:8px;' +
'border-left:4px solid var(--db-accent, #818cf8)';
row.innerHTML =
'<div style="font-size:24px">' + (ev.icon || '•') + '</div>' +
'<div style="flex:1">' +
'<div style="font-weight:600;color:var(--db-text, #fff)">' +
(ev.label || '') + '</div>' +
'<div style="font-size:12px;color:var(--db-text-muted, #94a3b8);' +
'margin-top:4px">' + (ev.time || '') + '</div>' +
'</div>';
const iconDiv = document.createElement('div');
iconDiv.style.cssText = 'font-size:24px';
iconDiv.textContent = ev.icon || '•';
const contentDiv = document.createElement('div');
contentDiv.style.cssText = 'flex:1';
const labelDiv = document.createElement('div');
labelDiv.style.cssText = 'font-weight:600;color:var(--db-text, #fff)';
labelDiv.textContent = ev.label || '';
const timeDiv = document.createElement('div');
timeDiv.style.cssText = 'font-size:12px;color:var(--db-text-muted, #94a3b8);margin-top:4px';
timeDiv.textContent = ev.time || '';
contentDiv.appendChild(labelDiv);
contentDiv.appendChild(timeDiv);
row.appendChild(iconDiv);
row.appendChild(contentDiv);
el.appendChild(row);
});
return el;
@@ -47,12 +58,38 @@
const box = document.createElement('div');
box.style.cssText = 'padding:24px;background:var(--db-card-bg, #1f2937);' +
'border-radius:12px;color:var(--db-text, #fff)';
box.innerHTML =
'<h2 style="margin:0 0 16px 0">⚡ Client-Only View</h2>' +
'<p>This entire page is rendered by JavaScript in the browser — ' +
'it does <b>not</b> call <code>/api/pages/custom-view/data</code>.</p>' +
'<p>Registered via <code>window.aiui.registerView(\'custom-view\', ...)</code>.</p>' +
'<p>Current time: <span id="aiui-clock"></span></p>';
const h2 = document.createElement('h2');
h2.style.cssText = 'margin:0 0 16px 0';
h2.textContent = '⚡ Client-Only View';
const p1 = document.createElement('p');
p1.textContent = 'This entire page is rendered by JavaScript in the browser — it does ';
const strongCode = document.createElement('strong');
strongCode.textContent = 'not';
p1.appendChild(strongCode);
p1.appendChild(document.createTextNode(' call '));
const code1 = document.createElement('code');
code1.textContent = '/api/pages/custom-view/data';
p1.appendChild(code1);
p1.appendChild(document.createTextNode('.'));
const p2 = document.createElement('p');
p2.textContent = 'Registered via ';
const code2 = document.createElement('code');
code2.textContent = 'window.aiui.registerView(\'custom-view\', ...)';
p2.appendChild(code2);
p2.appendChild(document.createTextNode('.'));
const p3 = document.createElement('p');
p3.textContent = 'Current time: ';
const clockSpan = document.createElement('span');
clockSpan.id = 'aiui-clock';
p3.appendChild(clockSpan);
box.appendChild(h2);
box.appendChild(p1);
box.appendChild(p2);
box.appendChild(p3);
container.appendChild(box);
const clock = box.querySelector('#aiui-clock');
const tick = function () { clock.textContent = new Date().toLocaleTimeString(); };
+88
View File
@@ -0,0 +1,88 @@
"""Example 30 — A2UI canvas and send_a2ui_messages integration.
Run:
python app.py
# Optional full Google A2UI renderer in React shell:
cd ../../../frontend && npm install @a2ui/react @a2ui/web_core && npm run build
"""
from __future__ import annotations
import praisonaiui as aiui
aiui.set_style("dashboard")
aiui.set_branding(title="A2UI Canvas Demo", logo="🖼️")
aiui.set_chat_preview(enabled=True, surface_id="main", width="38%")
aiui.set_pages(["chat-canvas", "chat", "canvas", "agent-canvas"])
@aiui.page("agent-canvas", title="Agent Canvas", icon="🖼️", group="A2UI")
async def agent_canvas_page():
return {"_surface": {"id": "main", "messages": []}}
@aiui.surface_action("main")
async def on_surface_action(action: dict):
return {"received": action}
def _build_assistant_agent():
from praisonaiagents import Agent
from praisonaiagents.tools.decorator import tool
from praisonaiagents.tools.a2ui_tools import send_a2ui_messages as sdk_send_a2ui
from praisonaiui.a2ui_utils import coerce_a2ui_tool_messages
@tool
def send_a2ui_messages(messages):
"""
Push A2UI v0.9 UI to the canvas. Always call this for buttons, layouts, forms.
Example:
send_a2ui_messages(messages=[
{"createSurface": {"surfaceId": "main", "catalogId": "basic"}},
{"updateComponents": {"components": [
{"component": "Text", "text": {"literal": "Mobile App Header"}},
{"component": "Button", "text": {"literal": "Submit"},
"action": {"name": "handleSubmit"}},
]}},
])
"""
coerced = coerce_a2ui_tool_messages(messages, surface_id="main")
return sdk_send_a2ui(messages=coerced)
role = (
"You are a UI builder. For ANY UI request you MUST call send_a2ui_messages "
"before replying — never claim UI was created without a successful tool call. "
"Use surface id 'main'. Prefer Column layouts for mobile app UIs."
)
try:
from praisonaiagents.ui.a2ui.adapter import generate_a2ui_system_prompt
instructions = generate_a2ui_system_prompt(
role_description=role,
workflow_description="Call send_a2ui_messages with valid A2UI JSON, then briefly confirm.",
ui_description="Dashboard canvas on surface main.",
)
except ImportError:
instructions = role
return Agent(name="assistant", instructions=instructions, tools=[send_a2ui_messages])
def create_app():
try:
agent = _build_assistant_agent()
aiui.register_agent("assistant", agent)
except ImportError:
pass
from praisonaiui.server import create_app as _create_app
return _create_app()
if __name__ == "__main__":
import uvicorn
uvicorn.run(create_app(), host="127.0.0.1", port=8099)
+51
View File
@@ -0,0 +1,51 @@
"""Image preview in chat — ImageAgent example.
Uses praisonaiagents ``ImageAgent`` with ``PraisonAIProvider`` so generated
images appear inline in chat via ``MESSAGE_ELEMENT`` events.
Requires:
pip install praisonaiagents litellm
export OPENAI_API_KEY=your-key
Optional:
export IMAGE_MODEL=gpt-image-2 # default; override if your project uses another image model
Run:
aiui run app.py
"""
import os
import praisonaiui as aiui
from praisonaiui.providers import PraisonAIProvider
try:
from praisonaiagents import ImageAgent
except ImportError as exc:
raise ImportError("Install praisonaiagents: pip install praisonaiagents") from exc
# Match other chat examples (25-clean-chat, 26-custom-theme-chat): dashboard shell
aiui.set_style("dashboard")
aiui.set_dashboard(sidebar=False, page_header=False)
aiui.set_branding(title="Image Studio", logo="🖼️")
aiui.set_theme(preset="blue", dark_mode=True, radius="lg")
aiui.set_pages(["chat"])
image_agent = ImageAgent(
name="Image Bot",
llm=os.environ.get("IMAGE_MODEL", "gpt-image-2"),
verbose=False,
)
# Legacy DALL-E params break newer OpenAI image endpoints
image_agent.image_config.response_format = None
image_agent.image_config.style = None
aiui.set_provider(PraisonAIProvider(agent=image_agent))
@aiui.starters
async def get_starters():
return [
{"label": "Sunset over mountains", "message": "A sunset over mountains", "icon": "🌄"},
{"label": "Robot portrait", "message": "A friendly robot portrait", "icon": "🤖"},
]
Binary file not shown.
+21 -12
View File
@@ -11,33 +11,42 @@ than direct store manipulation.
def seed_demo_data():
"""Populate feature stores with demo data.
This is a no-op placeholder. Use API calls in tests to seed data,
as feature stores may use different backends (file, DB, etc.).
Seeds data via HTTP API calls to ensure compatibility across
different backend implementations (file, DB, etc.).
"""
pass
try:
from starlette.testclient import TestClient
from praisonaiui.server import create_app
app = create_app()
with TestClient(app) as client:
seed_via_api(client)
except ImportError:
pass
def seed_via_api(client):
"""Seed demo data via API calls (recommended approach).
Args:
client: Starlette TestClient instance
"""
# Channels
client.post("/api/channels", json={
"name": "Discord #general", "platform": "discord",
"config": {"guild_id": "123456789"}
"config": {"guild_id": "123456789", "bot_token_ref": "env:DISCORD_BOT_TOKEN"}
})
client.post("/api/channels", json={
"name": "Telegram Support", "platform": "telegram",
"config": {"bot_token": "***masked***"}
"config": {"bot_token_ref": "env:TELEGRAM_BOT_TOKEN"}
})
client.post("/api/channels", json={
"name": "Support Email", "platform": "email",
"config": {"email_address": "[email protected]"}
"config": {"email_address": "[email protected]", "app_password_ref": "env:EMAIL_APP_PASSWORD"}
})
# Agents
client.post("/api/agents/definitions", json={
"name": "Summarizer",
@@ -49,21 +58,21 @@ def seed_via_api(client):
"instructions": "You are an expert programmer.",
"model": "gpt-4o"
})
# Schedules
client.post("/api/schedules", json={
"name": "Daily Report",
"schedule": {"kind": "every", "every_seconds": 86400},
"message": "Generate daily summary"
})
# Usage
client.post("/api/usage/track", json={
"model": "gpt-4o-mini",
"input_tokens": 150,
"output_tokens": 80
})
# Jobs
client.post("/api/jobs", json={
"prompt": "Test job",
+121
View File
@@ -0,0 +1,121 @@
"""StreamBridge — Thread-safe async-sync bridge for agent streaming.
Solves the concurrency architecture issue where PraisonAI agent execution
runs on worker threads while PraisonAIUI chat runs on asyncio. Token streaming
crosses this boundary via callbacks that must be thread-safe.
Usage:
bridge = StreamBridge()
# In agent callback (worker thread):
bridge.emit_token(token)
# In asyncio handler:
async for token in bridge.consume():
await aiui.stream_token(token)
"""
import asyncio
import threading
from typing import Any, AsyncIterator, Callable
class StreamBridge:
"""Thread-safe bridge for streaming tokens from worker threads to asyncio."""
def __init__(self, loop: asyncio.AbstractEventLoop | None = None):
"""Initialize the bridge.
Args:
loop: Event loop to use. If None, uses the current running loop.
"""
if loop is None:
try:
loop = asyncio.get_running_loop()
except RuntimeError:
raise RuntimeError("No running event loop found. StreamBridge must be initialized from an async context (or pass an explicit loop).")
self._loop = loop
self._queue = asyncio.Queue()
self._cancelled = False
self._lock = threading.Lock()
def emit_token(self, token: str) -> None:
"""Emit a token from a worker thread (thread-safe).
Args:
token: Token to emit, or None to signal end of stream.
"""
with self._lock:
if self._cancelled:
return
# Use call_soon_threadsafe for thread-safe queue operations
self._loop.call_soon_threadsafe(self._queue.put_nowait, token)
def emit_end(self) -> None:
"""Signal end of stream (thread-safe)."""
self.emit_token(None)
async def consume(self, timeout: float = 120.0) -> AsyncIterator[str]:
"""Consume tokens asynchronously.
Args:
timeout: Timeout in seconds for each token.
Yields:
Tokens as they arrive. Stops when None is received (end signal).
"""
while not self._cancelled:
try:
token = await asyncio.wait_for(self._queue.get(), timeout=timeout)
except asyncio.TimeoutError:
break
if token is None: # End of stream sentinel
break
yield token
def cancel(self) -> None:
"""Cancel the bridge and stop token emission (thread-safe)."""
with self._lock:
self._cancelled = True
# Signal any waiting consumers
self._loop.call_soon_threadsafe(self._queue.put_nowait, None)
def emitter_callback(self) -> Callable[[Any], None]:
"""Create a callback function for agent stream emitters.
Returns:
Callback function that can be passed to agent.stream_emitter.add_callback()
"""
def callback(event):
"""Handle stream events from PraisonAI agent."""
try:
from praisonaiagents.streaming.events import StreamEventType
if event.type == StreamEventType.DELTA_TEXT and event.content:
self.emit_token(event.content)
elif event.type == StreamEventType.FIRST_TOKEN and event.content:
self.emit_token(event.content)
elif event.type == StreamEventType.STREAM_END:
self.emit_end()
except (ImportError, AttributeError):
# Fallback for other event types or missing dependencies
if hasattr(event, 'content') and event.content:
self.emit_token(event.content)
return callback
def create_stream_bridge(loop: asyncio.AbstractEventLoop | None = None) -> StreamBridge:
"""Create a new StreamBridge instance.
Args:
loop: Event loop to use. If None, uses the current running loop.
Returns:
New StreamBridge instance.
"""
return StreamBridge(loop)
@@ -0,0 +1,37 @@
"""Minimal external agent hosted on PraisonAIUI dashboard shell."""
import praisonaiui as aiui
from praisonaiui.server import create_app
aiui.set_style("dashboard")
aiui.set_branding(title="Acme Agents", logo="A")
aiui.set_pages(["chat", "tasks", "sessions", "config"])
aiui.set_dashboard(modules=["jobs"], sidebar=True, page_header=True)
@aiui.page("tasks", title="Tasks", icon="📋", group="Work", order=5)
async def tasks_page():
return aiui.layout(
[
aiui.board(
columns=[
{
"id": "todo",
"title": "Todo",
"cards": [
aiui.card("Review PR", footer="agent-a"),
aiui.card("Write docs", footer="agent-b"),
],
},
{
"id": "done",
"title": "Done",
"cards": [aiui.card("Modular shell", footer="shipped")],
},
]
),
]
)
app = create_app()
+89
View File
@@ -0,0 +1,89 @@
"""Board from praisonai-platform issues (optional separate service)."""
import os
import httpx
import praisonaiui as aiui
from praisonaiui.server import create_app
PLATFORM = os.getenv("PRAISONAI_PLATFORM_URL", "http://127.0.0.1:8000")
WORKSPACE_ID = os.getenv("PRAISONAI_PLATFORM_WORKSPACE_ID", "default")
aiui.set_style("dashboard")
aiui.set_pages(["issues-board"])
aiui.set_dashboard(sidebar=True)
async def _fetch_issues() -> tuple[list, str | None]:
"""Return (issues, error_message). error_message is None on success."""
url = f"{PLATFORM.rstrip('/')}/api/v1/workspaces/{WORKSPACE_ID}/issues"
try:
async with httpx.AsyncClient(timeout=10.0) as client:
response = await client.get(url)
except httpx.ConnectError:
return [], f"Cannot reach platform at {PLATFORM}"
except httpx.TimeoutException:
return [], f"Platform request timed out ({PLATFORM})"
except Exception as exc:
return [], f"Platform request failed: {exc}"
if response.status_code in (401, 403):
return [], f"Platform auth failed (HTTP {response.status_code})"
if response.status_code != 200:
return [], f"Platform error (HTTP {response.status_code})"
try:
data = response.json()
except ValueError:
return [], "Platform returned invalid JSON"
if isinstance(data, list):
issues = data
elif isinstance(data, dict):
issues = data.get("issues") or data.get("items") or []
else:
issues = []
return issues, None
@aiui.page("issues-board", title="Issues", icon="📌", group="Work")
async def issues_board():
issues, error = await _fetch_issues()
if error:
return aiui.layout([
aiui.text(f"Platform: {PLATFORM} · workspace: {WORKSPACE_ID}"),
aiui.card("Platform unavailable", footer=error),
])
by: dict[str, list] = {}
for issue in issues:
status = (issue.get("status") or "backlog").lower().replace(" ", "_")
by.setdefault(status, []).append(issue)
columns = []
for status_id in ("backlog", "in_progress", "done"):
cards = [aiui.card(i.get("title", "Issue"), footer=status_id) for i in by.get(status_id, [])]
if not cards:
cards = [aiui.card("(no issues)")]
columns.append({
"id": status_id,
"title": status_id.replace("_", " ").title(),
"cards": cards,
})
summary = f"{len(issues)} issue(s)" if issues else "No issues in workspace"
return aiui.layout([
aiui.text(f"Platform: {PLATFORM} · workspace: {WORKSPACE_ID} · {summary}"),
aiui.board(columns=columns),
])
app = create_app()
if __name__ == "__main__":
import uvicorn
host = os.getenv("HOST", "127.0.0.1")
port = int(os.getenv("PORT", "8082"))
uvicorn.run(app, host=host, port=port)
@@ -0,0 +1,36 @@
"""Board page from aiui jobs — status columns."""
import os
import praisonaiui as aiui
from praisonaiui.server import create_app
aiui.set_style("dashboard")
aiui.set_pages(["jobs-board"])
aiui.set_dashboard(modules=["jobs"], sidebar=True)
def _card(job: dict) -> dict:
title = (job.get("prompt") or job.get("id") or "Job")[:80]
return aiui.card(title, footer=job.get("status", ""))
@aiui.page("jobs-board", title="Jobs Board", icon="📋", group="Work")
async def jobs_board():
from praisonaiui.features.jobs import get_job_store
jobs = get_job_store().list_all()
cols = ["queued", "running", "succeeded", "failed"]
columns = []
for c in cols:
cards = [_card(j) for j in jobs if j.get("status") == c]
columns.append({"id": c, "title": c.title(), "cards": cards or [aiui.card("(empty)")]})
return aiui.layout([aiui.board(columns=columns)])
app = create_app()
if __name__ == "__main__":
import uvicorn
uvicorn.run(app, host="127.0.0.1", port=int(os.getenv("PORT", "8082")))
+55
View File
@@ -0,0 +1,55 @@
"""Video Studio example — YAML scenes via PraisonAI Video engine sidecar."""
import os
import praisonaiui as aiui
from praisonaiui.server import create_app
from praisonaiui.video_config import set_video_engine
aiui.set_style("dashboard")
aiui.set_branding(title="Video Studio", logo="🎬")
aiui.set_pages(["chat", "video-studio", "jobs", "config"])
aiui.set_dashboard(modules=["jobs", "video"], sidebar=True, page_header=True)
# Optional: override engine URL (default http://127.0.0.1:3921)
# set_video_engine(url="http://127.0.0.1:3921")
_VIDEO_AGENT_INSTRUCTIONS = """You edit PraisonAI Video projects via tools.
Rules:
- scene.yaml must use schemaVersion: 1 with composition and scene blocks.
- Always video_lint_scene after video_update_scene; fix errors before render.
- Use video_render_project only when lint is clean.
- Omit render: in scene.yaml unless the user needs remotion (licensed) or hyperframes (deferred).
- Default render path is Playwright; optional render.backend in scene.yaml.
- Default project: VIDEO_STUDIO_PROJECT_ID env, or the only listed project.
- Skill reference: praisonai-video repo skills/praisonai-video/SKILL.md (YAML-first invariants).
"""
try:
from praisonaiagents import Agent
from praisonaiui.providers import PraisonAIProvider
from praisonaiui.video_agent_tools import get_video_agent_tools
_video_agent = Agent(
name="Video Editor",
instructions=_VIDEO_AGENT_INSTRUCTIONS,
llm=os.environ.get("PRAISONAI_MODEL", "gpt-4o-mini"),
tools=get_video_agent_tools(),
reflection=False,
)
aiui.set_provider(PraisonAIProvider(agent=_video_agent))
except ImportError:
pass # Chat works without praisonaiagents; Video tab still uses REST APIs
@aiui.page("video-studio", title="Video", icon="🎬", group="Create", order=20)
async def video_studio_page():
return aiui.layout(
[
aiui.html_embed('<div id="video-studio-root"></div>'),
]
)
app = create_app()
+10 -1
View File
@@ -1,6 +1,6 @@
# YAML Chat with Profiles & Goodbye
#
# What's New (vs chat-yaml/):
# What's New (vs yaml/01-chat/):
# • profiles — selectable agent personas
# • goodbye — farewell message on disconnect
#
@@ -19,12 +19,21 @@ profiles:
- name: Code Expert
description: Code review, debugging, and generation
icon: 💻
instructions: |
You are an expert programmer. Write clean, well-commented code.
Always include example usage and best practices. Be technical and precise.
- name: Writer
description: Creative and technical writing
icon: ✍️
instructions: |
You are a creative writer. Use flowing prose, metaphors, and engaging
language. Make your responses vivid and expressive. Be artistic and imaginative.
- name: Data Analyst
description: Data analysis and visualization
icon: 📊
instructions: |
You are a data analyst. Always think in terms of data, metrics, and insights.
Use structured analysis, mention specific visualization types, and be analytical.
starters:
- label: "Review my code"
@@ -1,6 +1,6 @@
# Clean Chat — Dashboard chat without sidebar (YAML version)
#
# This is the YAML equivalent of examples/python/25-clean-chat/app.py
# This is the YAML equivalent of examples/python/26-clean-chat/app.py
# All config is protocol-driven through the same /ui-config.json endpoint.
#
# Run:
@@ -1,6 +1,6 @@
# YAML Chat with Tools & Model Selection
#
# What's New (vs chat-yaml-profiles/):
# What's New (vs yaml/02-profiles/):
# • model — specify which LLM to use
# • tools — give the agent built-in tool capabilities
#
@@ -1,6 +1,6 @@
# YAML Chat with All Protocol Features
#
# What's New (vs chat-yaml-tools/):
# What's New (vs yaml/04-tools/):
# • features: true — auto-registers all 8 protocol feature modules
# (approvals, schedules, memory, sessions, skills, hooks, workflows, config)
#
-31
View File
@@ -1,31 +0,0 @@
# Component Blocks Showcase
A complete showcase of all shadcn UI component blocks, inspired by [ui.shadcn.com/themes](https://ui.shadcn.com/themes).
## Dashboard Cards
Revenue cards, subscription stats, and analytics displays.
## Forms
Authentication forms, payment forms, and input variations.
## Calendar & Date
Date pickers, calendar widgets, and scheduling components.
## Charts
Area charts, bar charts, line graphs for data visualization.
## Tables
Data tables with sorting, filtering, and pagination.
## Navigation
Tabs, accordions, and dropdown menus for navigation patterns.
---
This example uses the auto-download feature to install all 22 components at build time.
@@ -1,8 +1,8 @@
schemaVersion: 1
site:
title: "Component Blocks Showcase"
description: "Showcase of all shadcn UI component blocks - similar to ui.shadcn.com/themes"
title: "shadcn Dependencies Installation"
description: "Demonstrates auto-download of shadcn UI components at build time"
ui: "shadcn"
theme:
preset: "zinc" # All 22 Tailwind colors available
@@ -46,7 +46,5 @@ templates:
main: { type: "DocContent" }
routes:
- match: "/blocks/**"
template: "blocks"
- match: "/docs/**"
template: "blocks"
+18
View File
@@ -0,0 +1,18 @@
# shadcn Dependencies Installation Demo
This example demonstrates the automatic installation of shadcn UI components at build time using the `dependencies.shadcn` configuration.
## How it works
When you build this project, PraisonAIUI will automatically download and install the following 22 shadcn components:
- **UI Components**: card, button, input, label, avatar, badge, progress, separator, switch, checkbox, radio-group, select, slider, tabs, accordion, dialog, dropdown-menu, popover, tooltip, table
- **Advanced Components**: calendar, chart
## What gets installed
The components are downloaded from the shadcn/ui registry and installed in your project's components directory. They become available for use in your custom components and templates.
## Note
This example demonstrates dependency installation only. For actual component showcases with live demos, see the shadcn documentation at [ui.shadcn.com](https://ui.shadcn.com).
@@ -1,6 +1,6 @@
# YAML Playground — Multi-agent tabbed UI, no Python needed
# YAML Agents Playground — Multi-agent tabbed UI, no Python needed
#
# What's New (vs chat-yaml-profiles/):
# What's New (vs yaml/02-profiles/):
# • style: agents — activates tabbed AgentUILayout (sidebar with Agents/History tabs)
# • Multiple agent profiles with different system prompts
# • Per-agent starter messages
@@ -25,15 +25,27 @@ profiles:
description: "A helpful general-purpose assistant"
icon: "🤖"
default: true
instructions: |
You are a helpful, concise assistant. Answer questions clearly
and directly. Provide balanced, well-rounded responses.
- name: "Code Expert"
description: "Specialized in writing and debugging code"
icon: "💻"
instructions: |
You are a senior software engineer. Focus on code quality,
best practices, and technical accuracy. Always include working examples.
- name: "Data Analyst"
description: "Analyzes data and provides insights"
icon: "📊"
instructions: |
You are a data scientist. Think analytically, suggest appropriate
visualizations, and provide data-driven insights. Be methodical and precise.
- name: "Creative Writer"
description: "Helps with creative writing and content"
icon: "✍️"
instructions: |
You are a creative writer. Use vivid language, storytelling techniques,
and engaging narrative structures. Be imaginative and expressive.
welcome: "👋 Welcome to the AI Playground! Select an agent from the sidebar to get started."
+8 -3
View File
@@ -1,6 +1,6 @@
[project]
name = "aiui"
version = "0.3.113"
version = "0.3.132"
description = "YAML-driven website generator - CLI and compiler"
readme = "README.md"
license = {text = "MIT"}
@@ -61,9 +61,9 @@ postgres = [
praisonai = [
# Core agents SDK with knowledge support
"praisonaiagents[knowledge,search,crawl]>=1.5.56",
"praisonaiagents[knowledge,search,crawl]>=1.6.46",
# PraisonAI wrapper (includes CLI, bots, gateway support)
"praisonai[bot,api,os]>=2.0.0",
"praisonai[bot,api,os]>=4.6.0",
# Gateway dependencies
"fastapi>=0.115.0",
"sse-starlette>=1.6.0",
@@ -114,6 +114,10 @@ teams = [
"botbuilder-schema>=4.0.0",
]
a2ui = [
"praisonaiagents[a2ui]>=1.6.38",
]
all = [
"aiui[memory]",
"aiui[knowledge]",
@@ -129,6 +133,7 @@ all = [
"aiui[slack]",
"aiui[discord]",
"aiui[teams]",
"aiui[a2ui]",
]
dev = [
+339
View File
@@ -0,0 +1,339 @@
#!/usr/bin/env python3
"""Apply modular shell patches to dashboard.js (one-shot)."""
from pathlib import Path
p = Path(__file__).resolve().parents[1] / "src/praisonaiui/templates/frontend/plugins/dashboard.js"
text = p.read_text()
# 1) Extend BUILTIN_VIEWS and registries after theme-picker line
old_views = " 'theme-picker': '/plugins/views/theme-picker.js',\n};"
new_views = """ 'theme-picker': '/plugins/views/theme-picker.js',
feedback: '/plugins/views/feedback.js',
jobs: '/plugins/views/jobs.js',
auth: '/plugins/views/auth.js',
api: '/plugins/views/api.js',
};
const SLOT_REGISTRY = {};
let _dashboardPluginManifests = [];
const aiuiSdk = {
version: '1',
async fetchJSON(url, options) {
const res = await fetch(url, options);
if (!res.ok) throw new Error(`${res.status}: ${await res.text()}`);
return res.json();
},
el(tag, attrs, children) {
const node = document.createElement(tag);
if (attrs) {
Object.entries(attrs).forEach(([k, v]) => {
if (k === 'className') node.className = v;
else if (k === 'text') node.textContent = v;
else if (k.startsWith('on') && typeof v === 'function') node.addEventListener(k.slice(2).toLowerCase(), v);
else node.setAttribute(k, v);
});
}
(children || []).forEach((c) => { if (c != null) node.appendChild(typeof c === 'string' ? document.createTextNode(c) : c); });
return node;
},
themeVar(name, fallback) {
const v = getComputedStyle(document.documentElement).getPropertyValue(name).trim();
return v || fallback || '';
},
createBoard(root, opts) {
let destroyed = false;
const wrap = document.createElement('motion.div');
wrap.className = 'aiui-board';
root.appendChild(wrap);
async function refresh() {
if (destroyed || !opts?.fetch) return;
try {
const data = await opts.fetch();
wrap.innerHTML = '';
if (data?._components) data._components.forEach((c) => wrap.appendChild(renderComponent(c)));
else if (data?.columns) wrap.appendChild(renderBoard({ type: 'board', columns: data.columns }));
} catch (e) { wrap.innerHTML = `<div class="db-alert db-alert-error">${e.message}</div>`; }
}
refresh();
const interval = opts?.pollMs ? setInterval(refresh, opts.pollMs) : null;
return { refresh, destroy() { destroyed = true; if (interval) clearInterval(interval); wrap.remove(); } };
},
};"""
if old_views not in text:
raise SystemExit("BUILTIN_VIEWS anchor not found")
text = text.replace(old_views, new_views.replace('motion.div', 'div'))
old_api = """window.aiui.components = COMPONENT_REGISTRY;
// ── Sidebar State"""
new_api = """window.aiui.version = '1';
window.aiui.components = COMPONENT_REGISTRY;
window.aiui.registerSlot = function (name, renderFn) { SLOT_REGISTRY[name] = renderFn; };
window.aiui.slots = SLOT_REGISTRY;
window.aiui.sdk = aiuiSdk;
// ── Sidebar State"""
text = text.replace(old_api, new_api)
# 2) CSS after .db-columns
css_anchor = " .db-columns { display: grid; gap: 16px; }\n .db-card {"
css_insert = """ .db-columns { display: grid; gap: 16px; }
.aiui-board-columns { display: flex; gap: 12px; align-items: start; overflow-x: auto; padding-bottom: 8px; }
.aiui-board-column { flex: 0 0 280px; display: flex; flex-direction: column; gap: 8px;
background: var(--db-card-bg); border: 1px solid var(--db-border); border-radius: var(--db-radius); padding: 10px; min-height: 120px; }
.aiui-board-column-title { font-size: 11px; font-weight: 600; text-transform: uppercase; letter-spacing: 0.06em; color: var(--db-text-dim); margin: 0 0 4px; }
.aiui-board-column-cards { display: flex; flex-direction: column; gap: 8px; }
.aiui-board-card .db-card { margin: 0; }
.db-slot-host { min-height: 0; }
.db-sidebar-footer { margin-top: auto; padding: 10px 14px; border-top: 1px solid var(--db-border); font-size: 11px; color: var(--db-text-dim); }
.db-mobile-header { display: none; position: fixed; top: 0; left: 0; right: 0; z-index: 40; min-height: 48px; align-items: center; gap: 8px; padding: 8px 12px; background: var(--db-sidebar-bg); border-bottom: 1px solid var(--db-border); }
.db-mobile-overlay { display: none; position: fixed; inset: 0; z-index: 35; background: rgba(0,0,0,0.5); }
@media (max-width: 1023px) {
.db-mobile-header { display: flex; }
.db-sidebar { position: fixed; z-index: 45; transform: translateX(-100%); transition: transform 0.2s ease; }
.db-sidebar.db-sidebar-open { transform: translateX(0); }
.db-mobile-overlay.db-visible { display: block; }
.db-main { padding-top: 48px; }
}
.db-card {"""
text = text.replace(css_anchor, css_insert)
# 3) Helpers before init
init_anchor = "async function init() {"
helpers = '''
function renderSlot(name, parent) {
const fn = SLOT_REGISTRY[name];
if (!fn || !parent) return;
const host = document.createElement('div');
host.className = 'db-slot-host';
host.dataset.slot = name;
try { const node = fn(); if (node) host.appendChild(node); } catch (e) { console.warn(`[AIUI] Slot '${name}':`, e); }
parent.appendChild(host);
}
async function loadDashboardPlugins() {
_dashboardPluginManifests = [];
try {
const res = await fetch('/api/dashboard/plugins');
if (!res.ok) return;
_dashboardPluginManifests = (await res.json()).plugins || [];
} catch (e) { console.warn('[AIUI] Dashboard plugins:', e); return; }
for (const manifest of _dashboardPluginManifests) {
if (manifest.css) {
const href = `/dashboard-plugins/${manifest.name}/${manifest.css}`;
if (!document.querySelector(`link[data-aiui-plugin-css="${manifest.name}"]`)) {
const link = document.createElement('link');
link.rel = 'stylesheet'; link.href = href; link.dataset.aiuiPluginCss = manifest.name;
document.head.appendChild(link);
}
}
if (manifest.entry && !document.querySelector(`script[data-aiui-plugin="${manifest.name}"]`)) {
await new Promise((resolve) => {
const script = document.createElement('script');
script.src = `/dashboard-plugins/${manifest.name}/${manifest.entry}`;
script.async = true; script.dataset.aiuiPlugin = manifest.name;
script.onload = script.onerror = () => resolve();
document.head.appendChild(script);
});
}
if (manifest.page && !pagesData.find((pg) => pg.id === manifest.page.id)) pagesData.push(manifest.page);
}
}
function setupMobileNav(root, sidebar) {
if (!sidebar) return;
let overlay = document.getElementById('db-mobile-overlay');
if (!overlay) {
overlay = document.createElement('div');
overlay.id = 'db-mobile-overlay'; overlay.className = 'db-mobile-overlay';
overlay.addEventListener('click', () => { sidebar.classList.remove('db-sidebar-open'); overlay.classList.remove('db-visible'); });
root.insertBefore(overlay, root.firstChild);
}
if (!document.getElementById('db-mobile-header')) {
const header = document.createElement('motion.div');
header.id = 'db-mobile-header'; header.className = 'db-mobile-header';
const menuBtn = document.createElement('button');
menuBtn.type = 'button'; menuBtn.textContent = '☰';
menuBtn.style.cssText = 'background:transparent;border:1px solid var(--db-border);color:var(--db-text);border-radius:6px;padding:6px 10px;cursor:pointer';
menuBtn.addEventListener('click', () => {
sidebar.classList.toggle('db-sidebar-open');
overlay.classList.toggle('db-visible', sidebar.classList.contains('db-sidebar-open'));
});
const title = document.createElement('span'); title.id = 'db-mobile-title'; title.style.fontWeight = '600';
header.appendChild(menuBtn); header.appendChild(title);
root.insertBefore(header, overlay.nextSibling);
}
}
'''
helpers = helpers.replace('motion.div', 'motion.div').replace("createElement('motion.div')", "createElement('motion.div')")
helpers = helpers.replace('motion.div', 'div')
text = text.replace(init_anchor, helpers + init_anchor)
# 4) init awaits buildDashboard
text = text.replace(
" // Build the dashboard (after theme is applied)\n buildDashboard();\n}",
" await buildDashboard();\n}",
)
# 5) buildDashboard plugins + mobile
text = text.replace(
" dashboardConfig = uiConfig.dashboard || {};\n } catch (e) {",
" dashboardConfig = uiConfig.dashboard || {};\n window.__aiuiDashboardModules = dashboardConfig.modules || [];\n await loadDashboardPlugins();\n } catch (e) {",
)
text = text.replace(
""" if (showSidebar) {
const sidebar = buildSidebar(pagesData);
root.appendChild(sidebar);
}
root.appendChild(main);""",
""" let sidebar = null;
if (showSidebar) {
sidebar = buildSidebar(pagesData);
const slotFooter = document.createElement('div');
slotFooter.className = 'db-sidebar-footer';
renderSlot('shell:sidebar:footer', slotFooter);
if (slotFooter.children.length) sidebar.appendChild(slotFooter);
root.appendChild(sidebar);
setupMobileNav(root, sidebar);
}
root.appendChild(main);
const shellHeader = document.createElement('div');
shellHeader.id = 'db-shell-header-slot';
renderSlot('shell:header', shellHeader);
if (shellHeader.children.length) root.insertBefore(shellHeader, main);""",
)
# 6) selectPage toolbar
old_sel = """ const headerHtml = showPageHeader ? `
<motion.div class="db-page-header">
<h1 class="db-page-title">${page.icon || ''} ${page.title}</h1>
${page.description ? `<p class="db-page-desc">${page.description}</p>` : ''}
</motion.div>
` : '';
main.innerHTML = `
${headerHtml}
<motion.div data-page="${pageId}" id="db-page-content"></motion.div>
`;
const container = document.querySelector(`[data-page="${pageId}"]`);"""
old_sel = old_sel.replace('motion.div', 'div')
new_sel = """ const toolbarSlotId = `page:${pageId}:toolbar`;
const headerHtml = showPageHeader ? `
<div class="db-page-header">
<div style="display:flex;align-items:flex-start;justify-content:space-between;gap:12px;flex-wrap:wrap">
<div>
<h1 class="db-page-title">${page.icon || ''} ${page.title}</h1>
${page.description ? `<p class="db-page-desc">${page.description}</p>` : ''}
</div>
<div id="db-page-toolbar-slot" class="db-slot-host" data-slot="${toolbarSlotId}"></div>
</div>
</div>
` : '';
main.innerHTML = `${headerHtml}<motion.div data-page="${pageId}" id="db-page-content"></motion.div>`;
if (showPageHeader) {
const toolbarHost = document.getElementById('db-page-toolbar-slot');
if (toolbarHost && SLOT_REGISTRY[toolbarSlotId]) {
try { const node = SLOT_REGISTRY[toolbarSlotId](); if (node) toolbarHost.appendChild(node); } catch (e) {}
}
}
const mobileTitle = document.getElementById('db-mobile-title');
if (mobileTitle) mobileTitle.textContent = page.title || '';
const container = document.querySelector(`[data-page="${pageId}"]`);"""
new_sel = new_sel.replace('motion.div', 'div')
if old_sel not in text:
old_sel2 = """ const headerHtml = showPageHeader ? `
<div class="db-page-header">
<h1 class="db-page-title">${page.icon || ''} ${page.title}</h1>
${page.description ? `<p class="db-page-desc">${page.description}</p>` : ''}
</motion.div>
` : '';
main.innerHTML = `
${headerHtml}
<motion.div data-page="${pageId}" id="db-page-content"></motion.div>
`;
const container = document.querySelector(`[data-page="${pageId}"]`);""".replace('motion.div','motion.div')
# use correct version
old_sel2 = """ const headerHtml = showPageHeader ? `
<div class="db-page-header">
<h1 class="db-page-title">${page.icon || ''} ${page.title}</h1>
${page.description ? `<p class="db-page-desc">${page.description}</p>` : ''}
</div>
` : '';
main.innerHTML = `
${headerHtml}
<div data-page="${pageId}" id="db-page-content"></div>
`;
const container = document.querySelector(`[data-page="${pageId}"]`);"""
text = text.replace(old_sel2, new_sel)
else:
text = text.replace(old_sel, new_sel)
# 7) board case + renderBoard
text = text.replace(
" case 'form_action': return renderFormAction(comp);\n default: {",
" case 'form_action': return renderFormAction(comp);\n case 'board': return renderBoard(comp);\n default: {",
)
if "function renderBoard" not in text:
text = text.replace(
"function renderCard(comp) {",
'''function renderBoard(comp) {
const wrap = document.createElement('div');
wrap.className = 'aiui-board';
const row = document.createElement('div');
row.className = 'aiui-board-columns';
(comp.columns || []).forEach((col) => {
const colEl = document.createElement('div');
colEl.className = 'aiui-board-column';
const title = document.createElement('div');
title.className = 'aiui-board-column-title';
title.textContent = col.title || col.id || 'Column';
colEl.appendChild(title);
const cardsWrap = document.createElement('div');
cardsWrap.className = 'aiui-board-column-cards';
(col.cards || []).forEach((cardComp) => {
const cardWrap = document.createElement('div');
cardWrap.className = 'aiui-board-card';
const normalized = cardComp?.type ? cardComp : {
type: 'card', title: cardComp.title || cardComp.name || 'Card',
value: cardComp.value, footer: cardComp.footer || cardComp.assignee || cardComp.status,
};
cardWrap.appendChild(renderComponent(normalized));
cardsWrap.appendChild(cardWrap);
});
colEl.appendChild(cardsWrap);
row.appendChild(colEl);
});
wrap.appendChild(row);
return wrap;
}
function renderCard(comp) {''',
)
# 8) default slot before export
if "registerSlot('shell:sidebar:footer'" not in text:
text = text.replace(
"export { init, onContentChange };",
"""window.aiui.registerSlot('shell:sidebar:footer', () => {
const el = document.createElement('div');
el.innerHTML = '<span class="db-health-dot" style="color:var(--db-text-dim)">●</span> <span class="db-health-label">Checking…</span>';
fetch('/api/health').then((r) => r.json()).then((d) => {
const ok = d && (d.status === 'ok' || d.status === 'healthy');
const dot = el.querySelector('.db-health-dot');
const label = el.querySelector('.db-health-label');
if (dot) dot.style.color = ok ? '#22c55e' : '#ef4444';
if (label) label.textContent = ok ? 'Connected' : 'Degraded';
}).catch(() => { const label = el.querySelector('.db-health-label'); if (label) label.textContent = 'Offline'; });
return el;
});
export { init, onContentChange };""",
)
p.write_text(text)
print("patched ok", len(text))
+185
View File
@@ -45,6 +45,8 @@
"tailwind-merge": "^3.4.0"
},
"devDependencies": {
"@a2ui/react": "^0.10.0",
"@a2ui/web_core": "^0.10.0",
"@eslint/js": "^9.39.1",
"@tailwindcss/vite": "^4.1.18",
"@types/node": "^24.10.1",
@@ -65,8 +67,87 @@
"vite-plugin-dts": "^4.5.4"
},
"peerDependencies": {
"@a2ui/react": ">=0.10.0",
"@a2ui/web_core": ">=0.10.0",
"react": ">=18.0.0",
"react-dom": ">=18.0.0"
},
"peerDependenciesMeta": {
"@a2ui/react": {
"optional": true
},
"@a2ui/web_core": {
"optional": true
}
}
},
"node_modules/@a2ui/markdown-it": {
"version": "0.0.4",
"resolved": "https://registry.npmjs.org/@a2ui/markdown-it/-/markdown-it-0.0.4.tgz",
"integrity": "sha512-uaYNywdiHDH4i//pHpyckFnApvOshiUNT2Xl8awceNjL1XAX/gqtki9GRUJQ26RE7id2+aPNgOy1W6mXNBxriQ==",
"dev": true,
"license": "Apache-2.0",
"dependencies": {
"dompurify": "^3.3.1",
"markdown-it": "^14.1.0"
},
"peerDependencies": {
"@a2ui/web_core": "^0.9.2"
}
},
"node_modules/@a2ui/react": {
"version": "0.10.0",
"resolved": "https://registry.npmjs.org/@a2ui/react/-/react-0.10.0.tgz",
"integrity": "sha512-LeyrHUv7lNkLqrxr+qM2XybCB5ok/8gm5fVcs2foPiPV0KN6XtZGDKk23Kt3oitRHJp7vFOeyDD9y+UYWh4Y3A==",
"dev": true,
"license": "Apache-2.0",
"dependencies": {
"@a2ui/markdown-it": "^0.0.4",
"@a2ui/web_core": "^0.10.0",
"clsx": "^2.1.0",
"markdown-it": "^14.0.0",
"zod": "^3.23.8"
},
"engines": {
"node": ">=18.0.0"
},
"peerDependencies": {
"react": "^18.0.0 || ^19.0.0",
"react-dom": "^18.0.0 || ^19.0.0",
"zod": "^3.23.8"
}
},
"node_modules/@a2ui/react/node_modules/zod": {
"version": "3.25.76",
"resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz",
"integrity": "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==",
"dev": true,
"license": "MIT",
"funding": {
"url": "https://github.com/sponsors/colinhacks"
}
},
"node_modules/@a2ui/web_core": {
"version": "0.10.0",
"resolved": "https://registry.npmjs.org/@a2ui/web_core/-/web_core-0.10.0.tgz",
"integrity": "sha512-pSieKxaB6aZWBL2LjZfKo4/1v4xMWylczpzte1ehuGH0kfNGf/iTd73PKsIKYx3b0FNWTbD8gAloOM4JCVqUig==",
"dev": true,
"license": "Apache-2.0",
"dependencies": {
"@preact/signals-core": "^1.13.0",
"date-fns": "^4.1.0",
"zod": "^3.25.76",
"zod-to-json-schema": "^3.25.1"
}
},
"node_modules/@a2ui/web_core/node_modules/zod": {
"version": "3.25.76",
"resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz",
"integrity": "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==",
"dev": true,
"license": "MIT",
"funding": {
"url": "https://github.com/sponsors/colinhacks"
}
},
"node_modules/@babel/code-frame": {
@@ -1276,6 +1357,17 @@
"dev": true,
"license": "MIT"
},
"node_modules/@preact/signals-core": {
"version": "1.14.2",
"resolved": "https://registry.npmjs.org/@preact/signals-core/-/signals-core-1.14.2.tgz",
"integrity": "sha512-RZHdBj9ZF4n40Rp4jS052EHHjBWf96P9oNdXPfhQTovCuWY9iQn3Gq+gOTJSgBO9A/JBuPfMOWsSX/lIU9Pc/A==",
"dev": true,
"license": "MIT",
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/preact"
}
},
"node_modules/@radix-ui/number": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/@radix-ui/number/-/number-1.1.1.tgz",
@@ -3751,6 +3843,14 @@
"@types/react": "*"
}
},
"node_modules/@types/trusted-types": {
"version": "2.0.7",
"resolved": "https://registry.npmjs.org/@types/trusted-types/-/trusted-types-2.0.7.tgz",
"integrity": "sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==",
"dev": true,
"license": "MIT",
"optional": true
},
"node_modules/@types/unist": {
"version": "3.0.3",
"resolved": "https://registry.npmjs.org/@types/unist/-/unist-3.0.3.tgz",
@@ -4872,6 +4972,16 @@
"csstype": "^3.0.2"
}
},
"node_modules/dompurify": {
"version": "3.4.5",
"resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.4.5.tgz",
"integrity": "sha512-OrwIBKsdNSVEeubdJ1HBv/wNENRM9ytAVCv7YXt//A3vPdVMNuACRqK9mXCGCBW2ln7BT/A4X0jXHo2Gu89miA==",
"dev": true,
"license": "(MPL-2.0 OR Apache-2.0)",
"optionalDependencies": {
"@types/trusted-types": "^2.0.7"
}
},
"node_modules/electron-to-chromium": {
"version": "1.5.307",
"resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.307.tgz",
@@ -6145,6 +6255,16 @@
"url": "https://opencollective.com/parcel"
}
},
"node_modules/linkify-it": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/linkify-it/-/linkify-it-5.0.0.tgz",
"integrity": "sha512-5aHCbzQRADcdP+ATqnDuhhJ/MRIqDkZX5pyjFHRRysS8vZ5AbqGEoFIb6pYHPZ+L/OC2Lc+xT8uHVVR5CAK/wQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"uc.micro": "^2.0.0"
}
},
"node_modules/local-pkg": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/local-pkg/-/local-pkg-1.1.2.tgz",
@@ -6257,6 +6377,37 @@
"@jridgewell/sourcemap-codec": "^1.5.5"
}
},
"node_modules/markdown-it": {
"version": "14.1.1",
"resolved": "https://registry.npmjs.org/markdown-it/-/markdown-it-14.1.1.tgz",
"integrity": "sha512-BuU2qnTti9YKgK5N+IeMubp14ZUKUUw7yeJbkjtosvHiP0AZ5c8IAgEMk79D0eC8F23r4Ac/q8cAIFdm2FtyoA==",
"dev": true,
"license": "MIT",
"dependencies": {
"argparse": "^2.0.1",
"entities": "^4.4.0",
"linkify-it": "^5.0.0",
"mdurl": "^2.0.0",
"punycode.js": "^2.3.1",
"uc.micro": "^2.1.0"
},
"bin": {
"markdown-it": "bin/markdown-it.mjs"
}
},
"node_modules/markdown-it/node_modules/entities": {
"version": "4.5.0",
"resolved": "https://registry.npmjs.org/entities/-/entities-4.5.0.tgz",
"integrity": "sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==",
"dev": true,
"license": "BSD-2-Clause",
"engines": {
"node": ">=0.12"
},
"funding": {
"url": "https://github.com/fb55/entities?sponsor=1"
}
},
"node_modules/markdown-table": {
"version": "3.0.4",
"resolved": "https://registry.npmjs.org/markdown-table/-/markdown-table-3.0.4.tgz",
@@ -6549,6 +6700,13 @@
"url": "https://opencollective.com/unified"
}
},
"node_modules/mdurl": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/mdurl/-/mdurl-2.0.0.tgz",
"integrity": "sha512-Lf+9+2r+Tdp5wXDXC4PcIBjTDtq4UKjCPMQhKIuzpJNW0b96kVqSwW0bT7FhRSfmAiFYgP+SCRvdrDozfh0U5w==",
"dev": true,
"license": "MIT"
},
"node_modules/micromark": {
"version": "4.0.2",
"resolved": "https://registry.npmjs.org/micromark/-/micromark-4.0.2.tgz",
@@ -7471,6 +7629,16 @@
"node": ">=6"
}
},
"node_modules/punycode.js": {
"version": "2.3.1",
"resolved": "https://registry.npmjs.org/punycode.js/-/punycode.js-2.3.1.tgz",
"integrity": "sha512-uxFIHU0YlHYhDQtV4R9J6a52SLx28BCjT+4ieh7IGbgwVJWO+km431c4yRlREUAsAmt/uMjQUyQHNEPf0M39CA==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=6"
}
},
"node_modules/quansync": {
"version": "0.2.11",
"resolved": "https://registry.npmjs.org/quansync/-/quansync-0.2.11.tgz",
@@ -8214,6 +8382,13 @@
"typescript": ">=4.8.4 <6.0.0"
}
},
"node_modules/uc.micro": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/uc.micro/-/uc.micro-2.1.0.tgz",
"integrity": "sha512-ARDJmphmdvUk6Glw7y9DQ2bFkKBHwQHLi2lsaH6PPmz/Ka9sFOBsBluozhDltWmnv9u/cF6Rt87znRTPV+yp/A==",
"dev": true,
"license": "MIT"
},
"node_modules/ufo": {
"version": "1.6.3",
"resolved": "https://registry.npmjs.org/ufo/-/ufo-1.6.3.tgz",
@@ -8639,6 +8814,16 @@
"url": "https://github.com/sponsors/colinhacks"
}
},
"node_modules/zod-to-json-schema": {
"version": "3.25.2",
"resolved": "https://registry.npmjs.org/zod-to-json-schema/-/zod-to-json-schema-3.25.2.tgz",
"integrity": "sha512-O/PgfnpT1xKSDeQYSCfRI5Gy3hPf91mKVDuYLUHZJMiDFptvP41MSnWofm8dnCm0256ZNfZIM7DSzuSMAFnjHA==",
"dev": true,
"license": "ISC",
"peerDependencies": {
"zod": "^3.25.28 || ^4"
}
},
"node_modules/zod-validation-error": {
"version": "4.0.2",
"resolved": "https://registry.npmjs.org/zod-validation-error/-/zod-validation-error-4.0.2.tgz",
+12
View File
@@ -48,9 +48,19 @@
"directory": "src/frontend"
},
"peerDependencies": {
"@a2ui/react": ">=0.10.0",
"@a2ui/web_core": ">=0.10.0",
"react": ">=18.0.0",
"react-dom": ">=18.0.0"
},
"peerDependenciesMeta": {
"@a2ui/react": {
"optional": true
},
"@a2ui/web_core": {
"optional": true
}
},
"dependencies": {
"@radix-ui/react-accordion": "^1.2.12",
"@radix-ui/react-avatar": "^1.1.11",
@@ -88,6 +98,8 @@
"tailwind-merge": "^3.4.0"
},
"devDependencies": {
"@a2ui/react": "^0.10.0",
"@a2ui/web_core": "^0.10.0",
"@eslint/js": "^9.39.1",
"@tailwindcss/vite": "^4.1.18",
"@types/node": "^24.10.1",
+170 -82
View File
@@ -9,6 +9,19 @@ import { ZoneWidgets } from './Widgets'
import { Toc } from './Toc'
import { Footer } from './Footer'
import { ChatLayout, AgentUILayout, CopilotWidget, PlaygroundLayout } from './layouts'
import { LocaleProvider } from './i18n'
function SkipLink({ enabled }: { enabled?: boolean }) {
if (!enabled) return null
return (
<a
href="#main-content"
className="sr-only focus:not-sr-only focus:absolute focus:top-4 focus:left-4 z-50 bg-primary text-primary-foreground px-4 py-2 rounded-md text-sm font-medium"
>
Skip to main content
</a>
)
}
export default function App() {
const [config, setConfig] = useState<UIConfig>({})
@@ -19,89 +32,48 @@ export default function App() {
const [selectedItem, setSelectedItem] = useState<NavItem | null>(null)
const [activeItemPath, setActiveItemPath] = useState<string>('')
useEffect(() => {
async function loadManifests() {
try {
const [configRes, navRes, routesRes] = await Promise.all([
fetch('/ui-config.json'),
fetch('/docs-nav.json'),
fetch('/route-manifest.json'),
])
if (!configRes.ok || !navRes.ok || !routesRes.ok) {
throw new Error('Failed to load manifests')
// Helper function to find nav item by path
const findNavItemByPath = (navData: DocsNav, path: string): NavItem | null => {
const findItem = (items: NavItem[]): NavItem | null => {
for (const item of items) {
if (item.path === path || item.path === path.replace(/^\//, '')) {
return item
}
const configData = await configRes.json()
const navData = await navRes.json()
setConfig(configData)
setNav(navData)
setRoutes(await routesRes.json())
// Apply theme from YAML config
const theme = configData.site?.theme
applyTheme(
theme?.preset || 'zinc',
theme?.darkMode !== false,
theme?.radius || 'md'
)
// Handle initial URL path for SPA routing
const currentPath = window.location.pathname
if (currentPath && currentPath !== '/') {
// Find matching nav item by path
const findItem = (items: NavItem[]): NavItem | null => {
for (const item of items) {
if (item.path === currentPath || item.path === currentPath.replace(/^\//, '')) {
return item
}
if (item.children) {
const found = findItem(item.children)
if (found) return found
}
}
return null
}
// Search all nav groups
if (navData.items) {
for (const group of navData.items) {
if (group.children) {
const found = findItem(group.children)
if (found) {
setSelectedItem(found)
setActiveItemPath(found.path || found.title)
break
}
}
}
}
if (item.children) {
const found = findItem(item.children)
if (found) return found
}
} catch (err) {
setError(err instanceof Error ? err.message : 'Unknown error')
} finally {
setLoading(false)
}
return null
}
loadManifests()
// Handle browser back/forward navigation
const handlePopState = () => {
const path = window.location.pathname
if (path === '/') {
setSelectedItem(null)
setActiveItemPath('')
// Search all nav groups
if (navData.items) {
for (const group of navData.items) {
if (group.children) {
const found = findItem(group.children)
if (found) return found
}
}
}
window.addEventListener('popstate', handlePopState)
return () => window.removeEventListener('popstate', handlePopState)
}, [])
return null
}
// Update SEO meta tags dynamically
const updateSEO = (title: string, path: string, description?: string) => {
// Update title
document.title = `${title} | ${config.site?.title || 'Documentation'}`
// Update title using SEO titleTemplate if available
const titleTemplate = config.seo?.titleTemplate || '%s | %s'
const siteName = config.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}`
}
// Update canonical URL
let canonical = document.querySelector('link[rel="canonical"]') as HTMLLinkElement
@@ -137,8 +109,101 @@ export default function App() {
document.head.appendChild(ogUrl)
}
ogUrl.content = window.location.origin + path
// Update Twitter meta tags if configured
if (config.seo?.twitter) {
Object.entries(config.seo.twitter).forEach(([key, value]) => {
let twitterMeta = document.querySelector(`meta[name="twitter:${key}"]`) as HTMLMetaElement
if (!twitterMeta) {
twitterMeta = document.createElement('meta')
twitterMeta.name = `twitter:${key}`
document.head.appendChild(twitterMeta)
}
twitterMeta.content = value
})
}
// Set default image if available
if (config.seo?.defaultImage) {
let ogImage = document.querySelector('meta[property="og:image"]') as HTMLMetaElement
if (!ogImage) {
ogImage = document.createElement('meta')
ogImage.setAttribute('property', 'og:image')
document.head.appendChild(ogImage)
}
ogImage.content = config.seo.defaultImage
}
}
useEffect(() => {
async function loadManifests() {
try {
const [configRes, navRes, routesRes] = await Promise.all([
fetch('/ui-config.json'),
fetch('/docs-nav.json'),
fetch('/route-manifest.json'),
])
if (!configRes.ok || !navRes.ok || !routesRes.ok) {
throw new Error('Failed to load manifests')
}
const configData = await configRes.json()
const navData = await navRes.json()
setConfig(configData)
setNav(navData)
setRoutes(await routesRes.json())
// Apply theme from YAML config
const theme = configData.site?.theme
applyTheme(
theme?.preset || 'zinc',
theme?.darkMode !== false,
theme?.radius || 'md'
)
// Handle initial URL path for SPA routing
const currentPath = window.location.pathname
if (currentPath && currentPath !== '/') {
const found = findNavItemByPath(navData, currentPath)
if (found) {
setSelectedItem(found)
setActiveItemPath(found.path || found.title)
}
}
} catch (err) {
setError(err instanceof Error ? err.message : 'Unknown error')
} finally {
setLoading(false)
}
}
loadManifests()
}, [])
// Handle browser back/forward navigation in a separate effect
useEffect(() => {
const handlePopState = () => {
const path = window.location.pathname
const found = findNavItemByPath(nav, path)
if (found) {
setSelectedItem(found)
setActiveItemPath(found.path || found.title)
// Use updateSEO function for consistency
updateSEO(found.title, path)
} else if (path === '/') {
setSelectedItem(null)
setActiveItemPath('')
// Reset title to site title
document.title = config.site?.title || 'Documentation'
}
}
window.addEventListener('popstate', handlePopState)
return () => window.removeEventListener('popstate', handlePopState)
}, [nav, config])
const handleItemClick = (item: NavItem) => {
setSelectedItem(item)
setActiveItemPath(item.path || item.title)
@@ -186,7 +251,7 @@ export default function App() {
return (
<div className="flex justify-center">
<div className="w-full max-w-4xl px-6">
{zones?.hero && <ZoneWidgets widgets={zones.hero} />}
{zones?.hero && zones.hero.length > 0 && <ZoneWidgets widgets={zones.hero} />}
<Content config={config} routes={routes} selectedItem={selectedItem} />
</div>
</div>
@@ -195,19 +260,28 @@ export default function App() {
// Full width content
return (
<div className="px-6">
{zones?.hero && <ZoneWidgets widgets={zones.hero} />}
{zones?.hero && zones.hero.length > 0 && <ZoneWidgets widgets={zones.hero} />}
<Content config={config} routes={routes} selectedItem={selectedItem} />
</div>
)
case 'FlexibleLayout':
// WordPress-style zones layout
case 'FlexibleLayout': {
// WordPress-style zones layout with navigation invariant
// When docs nav exists but no leftSidebar zone configured, render default nav
const hasExplicitLeftSidebar = (zones?.leftSidebar?.length ?? 0) > 0
const hasNavigation = (nav?.items?.length ?? 0) > 0
const shouldRenderNavigation = hasExplicitLeftSidebar || hasNavigation
return (
<div className="flex flex-col">
{zones?.hero && <ZoneWidgets widgets={zones.hero} />}
{zones?.hero && zones.hero.length > 0 && <ZoneWidgets widgets={zones.hero} />}
<div className="flex flex-1">
{zones?.leftSidebar && (
{shouldRenderNavigation && (
<aside className="w-64 border-r p-4 hidden md:block">
<ZoneWidgets widgets={zones.leftSidebar} />
{hasExplicitLeftSidebar ? (
<ZoneWidgets widgets={zones?.leftSidebar} />
) : (
<Sidebar nav={nav} activeItem={activeItemPath} onItemClick={handleItemClick} />
)}
</aside>
)}
<div className="flex-1">
@@ -215,13 +289,19 @@ export default function App() {
</div>
<Toc selectedItem={selectedItem} zones={zones} />
</div>
{zones?.bottomNav && (
{zones?.bottomNav && zones.bottomNav.length > 0 && (
<div className="border-t p-4 bg-muted/30">
<ZoneWidgets widgets={zones.bottomNav} />
</div>
)}
{zones?.footer && zones.footer.length > 0 && (
<div className="border-t p-4 bg-muted/50">
<ZoneWidgets widgets={zones.footer} />
</div>
)}
</div>
)
}
case 'ThreeColumnLayout':
default:
// Classic: Sidebar + Content + TOC with zones
@@ -271,6 +351,7 @@ export default function App() {
if (config.chat?.enabled) {
return (
<div className="min-h-screen bg-background text-foreground">
<SkipLink enabled={config.a11y?.skipToContent} />
<Header config={config} />
{renderLayout()}
<Footer config={config} />
@@ -280,6 +361,7 @@ export default function App() {
}
return (
<div className="min-h-screen bg-background text-foreground">
<SkipLink enabled={config.a11y?.skipToContent} />
<Header config={config} />
{renderLayout()}
<Footer config={config} />
@@ -293,6 +375,7 @@ export default function App() {
if (layoutMode && ['bottom-right', 'bottom-left', 'top-right', 'top-left'].includes(layoutMode)) {
return (
<div className="min-h-screen bg-background text-foreground">
<SkipLink enabled={config.a11y?.skipToContent} />
<Header config={config} />
{renderLayout()}
<Footer config={config} />
@@ -303,6 +386,7 @@ export default function App() {
}
return (
<div className="min-h-screen bg-background text-foreground">
<SkipLink enabled={config.a11y?.skipToContent} />
<Header config={config} />
{renderLayout()}
<Footer config={config} />
@@ -311,5 +395,9 @@ export default function App() {
}
}
return renderByStyle()
return (
<LocaleProvider config={config.i18n}>
{renderByStyle()}
</LocaleProvider>
)
}
Loaded 100 of 348 files, more files were not shown because too many files have changed in this diff. Show more