Compare commits

...
Author SHA1 Message Date
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
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
Mervin PraisonandMervinPraison 76d01dbd19 feat(protocol): REST team discovery, RAG references, rich reasoning steps (closes #48, #49, #50) (#51)
Three non-clashing, strictly-additive protocol enhancements so third-party
chat frontends can integrate with PraisonAIUI without PraisonAIUI-internal
knowledge.

## Issue #48 — REST discovery parity
### Before
```
GET /agents  → {"agents": [{"name": "...", "created_at": 123}]}
# no /teams anywhere
```
### After
```
GET /agents  → {"agents": [{"agent_id": "...", "name": "...",
                            "description": "...", "model": {...},
                            "storage": bool, "created_at": 123}]}
GET /teams   → {"teams": [...]}
GET /api/teams
POST /teams/{team_id}/runs         (SSE stream, mirrors /agents/{id}/runs)
POST /api/teams/{team_id}/runs
DELETE /teams/{team_id}/sessions/{session_id}
```
`BaseProvider.list_teams()` and `BaseProvider.run_team()` added with sane
defaults so every existing provider keeps working untouched.  New dataclasses
`ModelInfo`, `AgentDetails`, `TeamDetails` give contributors a typed schema
to implement against.

## Issue #49 — RAG references
### Before
No way for a RAG provider to emit source chunks; frontend has no reference
schema.
### After
- `RunEventType.REFERENCES = "references"` enum variant
- `Reference(name, content, chunk, chunk_size)` dataclass
- `ReferenceData(query, references, time_ms)` dataclass
- `BaseProvider.references_event(query, refs, time_ms)` one-line helper
- `RunEvent` gained optional `query`, `references`, `time_ms` fields that
  flow through `to_dict()` into the SSE wire format automatically.

## Issue #50 — Rich reasoning steps
### Before
`RunEvent.step` was a bare string; providers could only send step text.
### After
- `ReasoningStep(title, result, reasoning, action?, confidence?, next_action?)`
  dataclass
- `BaseProvider.reasoning_step_event(step)` helper that populates both the
  canonical wire fields (`step`, `action`, `confidence`, `next_action`) and a
  lossless `extra_data` dict for full-fidelity replay.
- `RunEvent` gained optional `action`, `confidence`, `next_action` fields.
  Old providers that only set `step=<text>` continue to work bit-for-bit.

## Evidence per claim
- `provider.py`: `+155 lines`, all additive (new dataclasses + enum variant +
  two optional-field extensions + three helper methods).  No existing field
  was removed or reordered; `RunEvent.to_dict()` keeps omitting `None`
  fields so the wire format is unchanged for callers that don't use the new
  fields.
- `server.py`: `+79 lines` (three new route handlers, one-line enrichment in
  `list_agents`), five new `Route(...)` registrations.  Every legacy field in
  `GET /agents` response is still present.
- `__init__.py`: six new symbols added to both the lazy-dispatch table and
  `__all__`.  No existing symbol changed.

## Tests
`tests/unit/test_protocol_parity.py` — **14 parametric tests**:
- `/agents` enrichment + legacy-field preservation
- `/teams`, `/api/teams`, `/teams/{id}/runs` (400 on bad JSON)
- `/teams/{team_id}/sessions/{session_id}` DELETE route exists
- `Reference`, `ReferenceData`, `ReasoningStep` dataclass round-trips
- `references_event` / `reasoning_step_event` helper payload shape
- Back-compat: old `RunEvent(step="text")` still serialises correctly
- `__all__` contains every new symbol

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

## Version
Bumps to 0.3.113.

Co-authored-by: MervinPraison <[email protected]>
2026-04-23 00:21:47 +01:00
Mervin PraisonandMervinPraison bf3721e484 feat(api): PraisonAIUI-native public names (non-developer-friendly) (closes #41) (#47)
Establishes a plain-English, noun-first public API surface that does not
lift names from unrelated frameworks.  Every original name remains
importable as a silent alias so no existing code breaks.

## Rationale
1. **Non-developer friendly** — a lay reader should understand what a
   symbol does from its name alone (``FilePrompt`` > ``AskFileMessage``,
   ``on_startup`` > ``on_app_startup``, ``on_mic_start`` > ``on_audio_start``).
2. **API-direction compliant** — new class aliases are *noun-first*,
   not verb-first, so they pass the regression fence added in PR #44
   (``tests/unit/test_api_direction.py``).
3. **No overlap with third-party frameworks** — the pre-rename audit
   showed ~25 public names that read as direct lifts.  These now have
   native replacements.

## Native aliases (new primary names)

| Native (new)          | Legacy (still supported)        |
|-----------------------|---------------------------------|
| TextPrompt            | AskUserMessage                  |
| FilePrompt            | AskFileMessage                  |
| ChoicePrompt          | AskActionMessage                |
| LocationPrompt        | AskElementMessage               |
| UIFunction            | CopilotFunction                 |
| UIFunctionParameter   | CopilotFunctionParameter        |
| ui_function           | copilot_function                |
| on_ui_function        | on_copilot_function_call        |
| call_ui_function      | call_copilot_function           |
| get_ui_function(s)    | get_copilot_function(s)         |
| Settings              | ChatSettings                    |
| on_settings_change    | on_settings_update              |
| on_oauth_login        | oauth_callback                  |
| on_header_login       | header_auth_callback            |
| on_password_login     | password_auth_callback          |
| on_shared_view        | on_shared_thread_view           |
| on_slack_reaction     | on_slack_reaction_added         |
| on_startup            | on_app_startup                  |
| on_shutdown           | on_app_shutdown                 |
| on_parent_message     | on_window_message               |
| send_to_parent        | send_window_message             |
| on_mic_start          | on_audio_start                  |
| on_mic_data           | on_audio_chunk                  |
| on_mic_stop           | on_audio_end                    |

Generic agent/Python vocabulary (``Message``, ``User``, ``Task``,
``Step``, ``Action``, ``ErrorMessage``, ``CustomElement``, ``sleep``,
``make_async``, ``run_sync``, ``Plotly``, ``Pyplot``, ``Dataframe``,
``instrument_openai``, ``on_mcp_connect``) is kept as-is — these are
industry-standard terms, not lifts.

## Tests
- ``tests/unit/test_native_naming.py`` — 26 new tests proving each
  new name resolves to the exact same object as its legacy counterpart
  and is exported in ``praisonaiui.__all__``.

Full suite: **940 pass**, 4 skipped, 8 xfailed, 1 xpassed.

## Version
Bumps to 0.3.112; fixes a pre-existing mismatch where ``pyproject.toml``
advertised 0.3.111 while ``__version__.py`` still said 0.3.110.

Co-authored-by: MervinPraison <[email protected]>
2026-04-22 14:05:36 +01:00
MervinPraison 5e8ce01c58 feat(version): bump version to 0.3.111
Updated version in pyproject.toml and uv.lock to reflect the new release.
2026-04-22 13:34:49 +01:00
52946ee0ac feat: add API direction regression fence (Phase 0) (#44)
* feat: add API direction regression fence (fixes #41 Phase 0)

Implements Phase 0 of epic #41 to prevent API direction regression:

- Prevents new Ask*Message classes beyond documented 4
- Blocks Message classes with verb prefixes (Ask/Do/Request/Show/Get/Set/Send/Create)
- Enforces on_*/get_* naming for new callback decorators
- Validates exported symbol consistency
- Uses data-driven allow-lists for easy maintenance

Tests pass on current main and successfully catch violations.
Supports framework-neutral verb-first API direction.

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

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

* style: fix ruff violations in test_api_direction.py

- Remove unused import (inspect)
- Fix trailing whitespace and blank lines
- Format code to comply with ruff standards

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-04-22 12:54:27 +01:00
2976fb9b4d feat(auth): URL query-parameter token middleware for share-by-URL dashboards (fixes #39) (#45)
Add TokenQueryMiddleware supporting Jupyter-style ?token=SECRET authentication:

- Query param → redirect + secure cookie flow to prevent token leaks
- Constant-time comparison using hmac.compare_digest for timing attack protection
- Environment variable integration (AIUI_URL_TOKEN or GATEWAY_AUTH_TOKEN fallback)
- Extended AuthConfig with url_token provider and configuration fields
- Comprehensive test coverage (19 tests covering all authentication scenarios)
- Import time verified under 200ms requirement
- Opt-in via environment variables (inert when not configured)

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

Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
Co-authored-by: Mervin Praison <[email protected]>
2026-04-22 12:51:06 +01:00
41ad9a6df1 fix: ruff --fix pass over audio/lifecycle/window_message test files (fixes #37) (#46)
- Fixed 307 linting errors (W293 trailing whitespace, F401 unused imports, W292 missing EOL)
- Formatted 2 files for consistency
- All 56 tests still pass
- Import time constraint maintained at 165.7ms (<200ms limit)

Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
Co-authored-by: Mervin Praison <[email protected]>
2026-04-22 12:51:02 +01:00
Mervin PraisonandMervinPraison dcf2f4da06 chore(ci): expand claude.yml + add auto-issue-comment.yml (#43)
- claude.yml: add issues:[opened,labeled,assigned] + pull_request_review_comment triggers
- claude.yml: add issue-triage job (welcome comment on new issues)
- claude.yml: expand direct_prompt with PraisonAIUI-specific routing (STEP 0-6):
  - architecture routing (core / features / UI builders / frontend / tests / docs)
  - external-repo routing (PraisonAI / PraisonAI-Tools / PraisonAIDocs)
  - conceptual checks (no duplicate exports, no class-first Ask*Message, lazy
    optional deps, real DeprecationWarning on deprecations)
  - import-time < 200ms + no heavy-dep leak invariant
  - PR body template (Summary, Before/After, SHA-cited checklist, test evidence,
    import-time proof)
  - STEP 6 reviewer-loop instructions (respond to CodeRabbit/Copilot/human
    review comments, push to same branch)
- auto-issue-comment.yml: optional workflow to escalate an issue to Claude by
  adding the 'claude' label (workflow_dispatch-only; issues:opened trigger
  commented out by default)

CodeRabbit + Copilot: no workflow file needed — both are GitHub Apps that
react to PRs automatically. The chain (CodeRabbit → Copilot → Claude) is
already orchestrated by auto-pr-comment.yml.

Co-authored-by: MervinPraison <[email protected]>
2026-04-22 06:12:18 +01:00
Mervin PraisonandMervinPraison a8bb34ec92 chore(ci): expand claude.yml + add auto-issue-comment.yml (#42)
- claude.yml: add issues:[opened,labeled,assigned] + pull_request_review_comment triggers
- claude.yml: add issue-triage job (welcome comment on new issues)
- claude.yml: expand direct_prompt with PraisonAIUI-specific routing (STEP 0-6):
  - architecture routing (core / features / UI builders / frontend / tests / docs)
  - external-repo routing (PraisonAI / PraisonAI-Tools / PraisonAIDocs)
  - conceptual checks (no duplicate exports, no class-first Ask*Message, lazy
    optional deps, real DeprecationWarning on deprecations)
  - import-time < 200ms + no heavy-dep leak invariant
  - PR body template (Summary, Before/After, SHA-cited checklist, test evidence,
    import-time proof)
  - STEP 6 reviewer-loop instructions (respond to CodeRabbit/Copilot/human
    review comments, push to same branch)
- auto-issue-comment.yml: optional workflow to escalate an issue to Claude by
  adding the 'claude' label (workflow_dispatch-only; issues:opened trigger
  commented out by default)

CodeRabbit + Copilot: no workflow file needed — both are GitHub Apps that
react to PRs automatically. The chain (CodeRabbit → Copilot → Claude) is
already orchestrated by auto-pr-comment.yml.

Co-authored-by: MervinPraison <[email protected]>
2026-04-22 06:09:33 +01:00
329 changed files with 21075 additions and 4719 deletions

No files matched your search

+94
View File
@@ -0,0 +1,94 @@
name: Auto Issue Comment
# 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
# comment as github-actions[bot], so claude.yml must allow that bot.
on:
workflow_dispatch:
inputs:
issue_number:
description: "Issue number to triage with Claude"
required: true
type: string
issues:
types: [opened]
jobs:
# Escalate an issue to full Claude implementation by adding the `claude`
# label. claude.yml listens on `issues:labeled` + label=='claude' and
# triggers Job 2 (claude-response) which does the actual work.
trigger-claude:
if: |
github.event_name == 'workflow_dispatch' ||
(github.event_name == 'issues' && !contains(toJSON(github.event.issue.labels), 'claude'))
runs-on: ubuntu-latest
permissions:
issues: write
actions: write
steps:
- name: Resolve issue number
id: resolve
run: |
if [ "${{ github.event_name }}" = "workflow_dispatch" ]; then
echo "number=${{ inputs.issue_number }}" >> "$GITHUB_OUTPUT"
else
echo "number=${{ github.event.issue.number }}" >> "$GITHUB_OUTPUT"
fi
- name: Add claude label + post instructions
uses: actions/github-script@v7
with:
github-token: ${{ secrets.GH_TOKEN || secrets.GITHUB_TOKEN }}
script: |
const issueNumber = Number('${{ steps.resolve.outputs.number }}');
const { data: issue } = await github.rest.issues.get({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: issueNumber,
});
const labels = (issue.labels || []).map(l => typeof l === 'string' ? l : l.name);
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.`);
}
const body = [
'🤖 Auto-triaging with Claude.',
'',
'Claude will:',
'',
'1. Read this issue and all comments.',
'2. Classify and route to the right area (`src/praisonaiui/`, `src/frontend/`, `docs/`, or external repo).',
'3. Implement a minimal fix per `AGENTS.md`.',
'4. Run `pytest` + the import-time invariant locally.',
'5. Open a draft PR linked to this issue.',
'',
'Follow-ups in this thread with `@claude` will re-trigger the loop.'
].join('\n');
await github.rest.issues.createComment({
owner: context.repo.owner,
repo: context.repo.repo,
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}`);
+144 -29
View File
@@ -16,11 +16,24 @@ on:
types: [submitted]
pull_request:
types: [opened]
workflow_dispatch:
inputs:
pr_number:
description: 'PR number to trigger Claude final review on'
required: true
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 }}
jobs:
# ================================================================
@@ -43,7 +56,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 +99,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 +147,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 +163,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 +200,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 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, src/frontend, tests, docs). 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);
'**Phase 1: Review per AGENTS.md** (lazy imports, backward compat, import-time <200ms)\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';
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
@@ -217,7 +234,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 +303,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,7 +323,7 @@ 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);
await github.rest.issues.createComment({
@@ -340,7 +357,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 +407,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,7 +427,7 @@ 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);
await github.rest.issues.createComment({
@@ -445,7 +462,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,7 +479,7 @@ 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: |
await github.rest.issues.createComment({
issue_number: context.issue.number,
@@ -492,7 +509,7 @@ jobs:
- name: Trigger CodeRabbit and Qodo reviews
uses: actions/github-script@v7
with:
github-token: ${{ secrets.GH_TOKEN }}
github-token: ${{ env.GH_TOKEN }}
script: |
const pr = context.payload.pull_request.number;
const owner = context.repo.owner;
@@ -522,4 +539,102 @@ jobs:
// 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.');
console.log('Copilot will be triggered after CodeRabbit/Qodo complete.');
# ================================================================
# 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). SCOPE: PraisonAIUI only (src/praisonaiui, src/frontend, tests, docs). Read ALL CodeRabbit/Qodo comments above.\n\n**Phase 1:** Review per AGENTS.md (lazy imports, backward compat, import-time <200ms)\n**Phase 2:** FIX valid reviewer issues on this branch\n**Phase 3:** Approve PR if ready, else request changes with clear action items'
});
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'
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. Push fixes directly to THIS branch. SCOPE: PraisonAIUI (src/praisonaiui, src/frontend, tests, docs). Read all prior reviewer comments.\n\n**Phase 1:** Review per AGENTS.md\n**Phase 2:** FIX valid issues on this branch\n**Phase 3:** Approve PR if ready, else request changes'
});
console.log('Claude manually triggered on PR #' + pr);
+4 -2
View File
@@ -28,10 +28,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'
+493 -43
View File
@@ -1,87 +1,537 @@
name: Claude Assistant
# Triggers:
# - issues:opened → lightweight triage (labels + auto-claude for owner)
# - issues:labeled → full implementation when `claude` label applied
# - issues:assigned → full implementation when issue assigned to Claude
# - issue_comment → full implementation when @claude mentioned (issues + PRs)
# - pull_request_review → full implementation when @claude mentioned in review
# - pull_request_review_comment → full implementation when @claude mentioned inline
#
# Escalate an issue with the `claude` label OR an @claude comment — not both.
# If both fire within 3 minutes, the comment trigger is skipped (dedupe job).
#
# Focus: CLAUDE only. Copilot and CodeRabbit are GitHub Apps that react to PRs
# automatically — no workflow file needed. auto-pr-comment.yml orchestrates the
# CodeRabbit → Copilot → Claude review chain for PRs.
on:
issue_comment:
types: [created]
permissions:
contents: write
pull-requests: write
issues: write
actions: read
id-token: write
pull_request_review_comment:
types: [created]
issues:
types: [opened, labeled, assigned]
pull_request_review:
types: [submitted]
workflow_dispatch:
inputs:
issue_number:
description: 'Issue number for Claude implementation'
required: true
type: string
jobs:
claude-response:
env:
ISSUE_NUMBER_RESOLVED: ${{ github.event.issue.number }}
# ================================================================
# Job 1 — Issue triage (runs on ANY new issue, including external users)
# Posts welcome comment + triage labels. Auto-adds `claude` for owner issues.
# ================================================================
issue-triage:
if: |
github.event_name == 'issue_comment' &&
contains(github.event.comment.body, '@claude') &&
github.event_name == 'issues' &&
github.event.action == 'opened' &&
!contains(toJSON(github.event.issue.labels), 'claude')
runs-on: ubuntu-latest
permissions:
issues: write
contents: read
steps:
- name: Generate GitHub App Token
id: app-token
continue-on-error: true
uses: actions/create-github-app-token@v1
with:
app-id: ${{ secrets.CLAUDE_APP_ID }}
private-key: ${{ secrets.CLAUDE_APP_PRIVATE_KEY }}
owner: ${{ github.repository_owner }}
- name: Resolve GitHub token
id: github-token
run: |
if [ -n "${{ steps.app-token.outputs.token }}" ]; then
echo "token=${{ steps.app-token.outputs.token }}" >> "$GITHUB_OUTPUT"
else
echo "token=${{ secrets.GITHUB_TOKEN }}" >> "$GITHUB_OUTPUT"
fi
- name: Triage and acknowledge issue
uses: actions/github-script@v7
with:
github-token: ${{ steps.github-token.outputs.token }}
script: |
const issue = context.payload.issue;
const title = (issue.title || '').toLowerCase();
const body = (issue.body || '').toLowerCase();
const content = title + ' ' + body;
const isOwner = ['OWNER', 'MEMBER', 'COLLABORATOR'].includes(
issue.author_association
);
const labels = [];
if (content.includes('feature request') || content.includes('[feature')) {
labels.push('enhancement');
} else if (content.includes('bug') || content.includes('error') || content.includes('crash') || content.includes('traceback')) {
labels.push('bug');
} else if (content.includes('question') || content.includes('how do i') || content.includes('how to')) {
labels.push('question');
}
if (content.includes('security') || content.includes('cve') || content.includes('vulnerability')) {
labels.push('security');
}
if (content.includes('performance') || content.includes('slow') || content.includes('memory leak')) {
labels.push('performance');
}
if (content.includes('documentation') || content.includes('docs')) {
labels.push('documentation');
}
if (content.includes('typescript') || content.includes('javascript') || content.includes('npm')) {
labels.push('javascript');
}
if (content.includes('frontend') || content.includes('react') || content.includes('vite')) {
labels.push('frontend');
}
if (content.includes('example') || content.includes('yaml')) {
labels.push('examples');
}
if (labels.length > 0) {
await github.rest.issues.addLabels({
issue_number: issue.number,
owner: context.repo.owner,
repo: context.repo.repo,
labels
});
}
if (!isOwner) {
await github.rest.issues.createComment({
issue_number: issue.number,
owner: context.repo.owner,
repo: context.repo.repo,
body: [
`👋 Thanks for opening this issue, @${issue.user.login}!`,
'',
'A maintainer will review this shortly. In the meantime:',
'- Make sure you\'ve included steps to reproduce (for bugs)',
'- Check [existing issues](https://github.com/MervinPraison/PraisonAIUI/issues) for duplicates',
'- Review [AGENTS.md](https://github.com/MervinPraison/PraisonAIUI/blob/main/AGENTS.md) for project conventions',
'',
'_A maintainer can trigger deeper analysis by commenting `@claude` on this issue._'
].join('\n')
});
}
if (isOwner) {
await github.rest.issues.addLabels({
issue_number: issue.number,
owner: context.repo.owner,
repo: context.repo.repo,
labels: ['claude']
});
}
# ================================================================
# Job 2 — Full Claude Code implementation
# Triggers on: labeled `claude`, @claude comments, assignments
#
# Dedup: label + @claude comment on the same issue fires two events.
# Skip the comment trigger when `claude` was labelled within 3 minutes.
# ================================================================
claude-dedupe:
if: |
github.event_name == 'workflow_dispatch' ||
(
github.event.action != 'opened' &&
(github.event.action != 'labeled' || github.event.label.name == 'claude') &&
(github.event_name != 'issue_comment' || contains(github.event.comment.body, '@claude')) &&
(
!contains(github.actor, '[bot]') ||
github.actor == 'github-actions[bot]' ||
github.actor == 'praisonai-triage-agent[bot]'
) &&
github.actor != 'dependabot[bot]' &&
github.actor != 'cursor[bot]' &&
github.actor != 'renovate[bot]'
)
runs-on: ubuntu-latest
outputs:
proceed: ${{ steps.check.outputs.proceed }}
permissions:
issues: read
pull-requests: read
steps:
- name: Skip duplicate comment after fresh claude label
id: check
uses: actions/github-script@v7
with:
script: |
if (context.eventName === 'workflow_dispatch') {
core.setOutput('proceed', 'true');
return;
}
if (context.eventName !== 'issue_comment' || context.payload.issue?.pull_request) {
core.setOutput('proceed', 'true');
return;
}
const issue = context.payload.issue;
const hasClaude = (issue.labels || []).some(l => l.name === 'claude');
if (!hasClaude) {
core.setOutput('proceed', 'true');
return;
}
const events = await github.rest.issues.listEvents({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: issue.number,
per_page: 30,
});
const windowMs = 3 * 60 * 1000;
const recentLabel = events.data.find(e =>
e.event === 'labeled' &&
e.label?.name === 'claude' &&
(Date.now() - new Date(e.created_at).getTime()) < windowMs
);
if (recentLabel) {
core.info('Skipping: claude label was added within 3 minutes — label trigger already handles this issue.');
core.setOutput('proceed', 'false');
return;
}
core.setOutput('proceed', 'true');
claude-response:
needs: claude-dedupe
env:
ISSUE_NUMBER_RESOLVED: ${{ github.event.issue.number || github.event.pull_request.number || inputs.issue_number }}
if: needs.claude-dedupe.outputs.proceed == 'true'
concurrency:
group: claude-${{ github.event.issue.number || github.event.pull_request.number || inputs.issue_number || github.run_id }}
cancel-in-progress: true
runs-on: ubuntu-latest
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: |
You are implementing changes in MervinPraison/PraisonAIUI (Server-Driven UI). Follow AGENTS.md in this repository.
${{ 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.
Primary scope: src/praisonaiui/, tests/, docs/. If the fix belongs in praisonaiagents, praisonai, or PraisonAI-Tools, route there and explain — do not implement SDK/tool code inside this repo unless it is AIUI integration 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.
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 "[email protected]"
gh auth setup-git
git status
git branch --show-current
allowed_tools: |
Bash(git:*)
Bash(python:*)
Bash(pip:*)
Bash(pytest:*)
Bash(ruff:*)
Bash(gh:*)
Bash(npm:*)
View
GlobTool
GrepTool
BatchTool
Edit
Replace
- name: Run Claude Code Action
if: steps.check_fork.outputs.is_pr_review != 'true'
uses: anthropics/claude-code-action@beta
env:
GH_TOKEN: ${{ steps.github-token.outputs.token }}
with:
allowed_bots: 'praisonai-triage-agent[bot]'
claude_code_oauth_token: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }}
github_token: ${{ steps.github-token.outputs.token }}
model: claude-opus-4-8
mode: ${{ github.event_name == 'workflow_dispatch' && 'agent' || 'tag' }}
trigger_phrase: "@claude"
label_trigger: "claude"
timeout_minutes: "30"
direct_prompt: |
${{ steps.check_fork.outputs.is_fork == 'true' && 'CRITICAL: THIS IS A PULL REQUEST FROM A FORK. YOU DO NOT HAVE PUSH PERMISSIONS TO THIS REPOSITORY. ONLY READ FILES, VALIDATE CODE, AND PROVIDE YOUR COMMENTS/FEEDBACK DIRECTLY IN THIS PR VIA COMMENTS. DO NOT ATTEMPT TO PUSH OR CREATE PULL REQUESTS.\n\n' || 'NOTE: The branch is under MervinPraison/PraisonAIUI (not a fork). You are able to make modifications and push directly to this branch.\n\n' }}
You are implementing changes in **MervinPraison/PraisonAIUI** (YAML-driven server-driven UI with agentic chat).
Follow `AGENTS.md` at the repository root. If a conflict arises between this prompt and `AGENTS.md`, `AGENTS.md` wins.
═══════════════════════════════════════════════════════════════
STEP 0 — GIT & GITHUB CLI
═══════════════════════════════════════════════════════════════
git config --global user.name "MervinPraison"
git config --global user.email "[email protected]"
gh auth setup-git
STEP 1 — ISSUE:
Read the issue and all comments. Implement what the issue describes with minimal, focused changes.
═══════════════════════════════════════════════════════════════
STEP 1 — READ GUIDELINES
═══════════════════════════════════════════════════════════════
Read `AGENTS.md` and any `docs/principles.md`. Key invariants:
• Cold import of `praisonaiui` must stay under 200 ms; module count must not grow.
• Heavy / optional deps (`mcp`, `langchain`, `llama_index`, `semantic_kernel`,
`slack_sdk`, `discord.py`, `botbuilder.core`, `openai`, `anthropic`, `mistralai`,
`google.generativeai`) must be **lazy-imported** — never eager.
• Public API is verb-first. New classes in `src/praisonaiui/message.py` ending
in `Message` with a verb prefix (Ask*, Do*, Request*, …) are forbidden —
use a module-level `async def` verb helper instead (see `prompt`, `error`).
• Deprecations must emit a runtime `warnings.warn(..., DeprecationWarning, stacklevel=2)`
on first access, not only in the docstring.
• Deterministic serialisation in anything reaching the wire (sorted keys, stable field
order). See existing `to_dict` patterns.
• Multi-agent + async safe: no global mutable state without a lock.
STEP 2 — BRANCH (single name; reuse in STEP 4):
BRANCH="claude/issue-${{ env.ISSUE_NUMBER_RESOLVED }}-$(date +%Y%m%d-%H%M%S)"
git checkout -b "$BRANCH"
═══════════════════════════════════════════════════════════════
STEP 2 — ARCHITECTURE VALIDATION & ROUTING (MANDATORY before any code)
═══════════════════════════════════════════════════════════════
Answer these questions inline in your response, then proceed only if you pass:
STEP 3 — TEST / VERIFY:
Run targeted pytest or smoke checks relevant to your edits (e.g. pytest tests/unit -q).
ROUTING:
1. **Core aiui** (`src/praisonaiui/`) — protocols, message types, server, features.
2. **Features** (`src/praisonaiui/features/`) — opt-in capabilities (MCP, platforms, lifecycle, audio, auth, sharing, etc.).
3. **UI builders** (`src/praisonaiui/ui.py`) — declarative component helpers.
4. **Frontend** (`src/frontend/`) — React components / stores / SSE handlers.
5. **Tests** (`tests/unit/`, `tests/integration/`) — new features require both smoke and real-agentic tests.
6. **Docs** (`docs/`) — canonical name per feature, migration table for renames.
STEP 4 — COMMIT, PUSH, OPEN PR (mandatory if you produced commits):
git add -A
git commit -m "feat: <short summary> (fixes #${{ env.ISSUE_NUMBER_RESOLVED }})"
git push -u origin "$BRANCH"
REPO="${{ github.repository }}"
COUNT=$(gh pr list --repo "$REPO" --head "$BRANCH" --json number --jq 'length')
if [ "$COUNT" -gt 0 ]; then
gh pr list --repo "$REPO" --head "$BRANCH" --json url --jq '.[0].url'
else
gh pr create --repo "$REPO" --base main --head "$BRANCH" --title "feat: <short title>" --body "Fixes #${{ env.ISSUE_NUMBER_RESOLVED }}"
fi
CRITICAL: Do not stop after `git push`. You MUST complete `gh pr create` (or print the existing PR URL from the step above) and post that URL on the issue. Never ask a human to open the PR manually.
EXTERNAL REPOS (do NOT edit from within this repo — route the work to the right place and stop):
• Core SDK: `MervinPraison/PraisonAI` (`praisonaiagents`, `praisonai`) — agent runtime.
• Tools: `MervinPraison/PraisonAI-Tools` — tool implementations.
• Docs site: `MervinPraison/PraisonAIDocs` — product docs.
If the requested change belongs in an external repo, comment on the issue explaining
where it should live and DO NOT open a PR in this repo.
CONCEPTUAL CHECKS (if ANY fails — add a comment, do NOT open a PR):
• Does it duplicate an existing public symbol in `src/praisonaiui/__init__.py`?
• Does it add new class-first message types (`class Ask*Message`, `class *Message`
with a verb in the name)? → Use a verb-first helper instead.
• Does it add a new hard dependency to `pyproject.toml` `[project] dependencies`?
→ Move to `[project.optional-dependencies]` and wrap imports lazily.
• Does it break backward compat (rename, signature change, removal)? → Add
a deprecation alias that emits `DeprecationWarning`; never hard-remove before the
documented removal version (e.g. `0.5.0`).
═══════════════════════════════════════════════════════════════
STEP 3 — IMPLEMENT
═══════════════════════════════════════════════════════════════
Single branch for the whole flow (reuse the same name in STEP 5):
BRANCH="claude/issue-${{ env.ISSUE_NUMBER_RESOLVED }}-$(date +%Y%m%d-%H%M%S)"
git checkout -b "$BRANCH"
Make minimal, focused changes:
• Stay within the files listed in the issue's "Scope" / "Files to change" section.
• Prefer extending existing modules over adding new ones.
• No emojis, no comments, no markdown except where the user asked for it.
• Python ≥ 3.10 style (`list[...]` not `List[...]`, `X | None` not `Optional[X]`).
═══════════════════════════════════════════════════════════════
STEP 4 — TEST / VERIFY
═══════════════════════════════════════════════════════════════
Run these targeted checks (NOT the full suite):
# 1. Lint on only the files you changed:
CHANGED=$(git diff --name-only origin/main...HEAD | grep -E '\.py$' | tr '\n' ' ')
[ -n "$CHANGED" ] && ruff check $CHANGED
# 2. Unit tests for the module(s) you touched:
pytest tests/unit/<your_new_test_file>.py -v --tb=short
# 3. Import-time invariant (MUST stay under 200 ms cold):
python -c "import time, sys; t=time.time(); import praisonaiui; dt=(time.time()-t)*1000; heavy=[m for m in sys.modules if any(h in m for h in ['langchain','llama_index','mcp','slack','discord','botbuilder','openai.','anthropic.','mistralai','google.generativeai'])]; print(f'{dt:.1f}ms modules={len(sys.modules)} leaked={heavy}'); assert heavy == [], f'Heavy deps leaked: {heavy}'"
If anything fails, fix the root cause (never skip, xfail, or delete the test).
═══════════════════════════════════════════════════════════════
STEP 5 — COMMIT, PUSH, OPEN PR (MANDATORY if you produced commits)
═══════════════════════════════════════════════════════════════
git add -A
git commit -m "feat: <short summary> (fixes #${{ env.ISSUE_NUMBER_RESOLVED }})"
git push -u origin "$BRANCH"
REPO="${{ github.repository }}"
COUNT=$(gh pr list --repo "$REPO" --head "$BRANCH" --json number --jq 'length')
if [ "$COUNT" -gt 0 ]; then
gh pr list --repo "$REPO" --head "$BRANCH" --json url --jq '.[0].url'
else
gh pr create --repo "$REPO" --base main --head "$BRANCH" \
--title "feat: <short title>" \
--body "Fixes #${{ env.ISSUE_NUMBER_RESOLVED }}
## Summary
<what changed in one paragraph>
## Acceptance criteria
<copy from the issue; tick boxes only with commit SHA + file path evidence>
## Test evidence
\`\`\`
<paste pytest output>
\`\`\`
"
fi
CRITICAL: Do NOT stop after `git push`. You MUST complete `gh pr create` (or print the
existing PR URL from the step above) and post that URL on the issue. Never ask a
human to open the PR manually.
═══════════════════════════════════════════════════════════════
STEP 6 — RESPOND TO REVIEW FEEDBACK (if this run was triggered by a review comment)
═══════════════════════════════════════════════════════════════
If you were invoked by a PR review comment, you are the Lead Engineer for this PR:
• Read ALL reviewer comments in chronological order (CodeRabbit → Copilot → human).
• Fix every VALID issue by pushing commits to THIS branch (do NOT open a new PR).
• Reply with a summary of which comments you addressed, which you disagreed with (and why).
• Never ship a fix that removes/weakens a test.
allowed_tools: |
Bash(git:*)
Bash(python:*)
Bash(pip:*)
Bash(pytest:*)
Bash(ruff:*)
Bash(gh:*)
Bash(npm:*)
Bash(npx:*)
Bash(python -m pytest:*)
Bash(python -m pip:*)
Bash(python -m ruff:*)
View
GlobTool
GrepTool
+164
View File
@@ -0,0 +1,164 @@
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: false
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 isMainPush = context.eventName === 'push';
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
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,
};
}
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,
};
}
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 } = await getMergeState(prNumber);
core.info(`PR #${prNumber}: mergeState=${status}, head=${headRepo}, draft=${isDraft}`);
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) return { skipped: true, reason: 'fork' };
const { data: comments } = await github.rest.issues.listComments({
owner,
repo,
issue_number: prNumber,
per_page: 100,
});
if (!isMainPush && hasRecentAutoComment(comments)) {
return { skipped: true, reason: 'recent auto comment' };
}
await github.rest.issues.createComment({
owner,
repo,
issue_number: prNumber,
body: COMMENT_BODY,
});
if (!labels.includes(LABEL)) {
await github.rest.issues.addLabels({
owner,
repo,
issue_number: prNumber,
labels: [LABEL],
});
}
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();
@@ -0,0 +1,32 @@
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
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
@@ -22,6 +22,7 @@ Deep Analysis → Gap Analysis → Critical Review → Plan → Create Issue →
- **No competitor names in public surfaces**: Never mention competitor product, company, or project names in GitHub issues, PRs, commit messages, release notes, docs, or any other public-facing content. Refer to capabilities generically (e.g., "self-improving agent pattern", "external agent framework").
- **Test before merge**: never merge unvalidated PRs. Always clone the branch locally and run the tests.
- **Minimal code change**: prefer upstream one-line fixes over downstream workarounds.
- **Autonomous polling — NEVER ask the user to "check now"**: after posting feedback to `@claude`, submitting a trigger comment, or any state that requires an external agent to push commits, the assistant itself waits ~10 minutes (`sleep 600`) and then re-checks. Do not stop and wait for the human to type "check now". Only surface control back to the human when a round is fully complete (PR merged or 3-round feedback cap hit) or a blocking decision is required (e.g. rebase strategy, conflicting feature intent).
---
@@ -536,6 +537,11 @@ sleep 600
# → return to Phase 12 (fetch new commits, re-run checks)
```
**The assistant runs this `sleep 600` itself — it does NOT pause the loop to ask the user "check now".** After the 10-minute wait completes, the assistant immediately re-fetches the PR, re-runs the test suite, and either merges, posts the next feedback round, or reports a final status. The human is only prompted when:
- The PR merges successfully (report + move to next issue).
- The 3-round feedback cap is hit (escalate).
- A design decision is needed that the assistant cannot make unilaterally (e.g. "should `allow` policy persist or not?").
Repeat Phases 12 → 13 → 14 until the mergeability gate in Phase 13 is fully green, or escalate to a human after 3 failed rounds.
### When the PR has merge conflicts
@@ -668,3 +674,4 @@ gh issue view $ISSUE_NUM --repo $REPO --json state
6. **Always verify** the issue auto-closed after merge.
7. **All `run_command` calls use `timeout`** to avoid hangs (user global rule).
8. **No proactive `.md` creation** beyond this workflow file (user global rule).
9. **Never ask the user "check now"**: between rounds the assistant runs `sleep 600` itself and keeps driving. Only return control to the human on merge, 3-round cap, or an irresolvable design decision.
+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
+3 -3
View File
@@ -163,10 +163,10 @@ HEALTHCHECK --interval=30s --timeout=5s --start-period=10s --retries=3 \
```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
+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.110"
version = "0.3.122"
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);
}
const shellHeader = document.createElement('div');
shellHeader.id = 'db-shell-header-slot';
renderSlot('shell:header', shellHeader);
if (shellHeader.children.length) root.insertBefore(shellHeader, main);
root.appendChild(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>
)
}
+1 -1
View File
@@ -82,7 +82,7 @@ export function Content({ config, routes, selectedItem }: ContentProps) {
if (selectedItem) {
return (
<main className="flex-1 p-8 max-w-3xl">
<main id="main-content" className="flex-1 p-8 max-w-3xl">
{loadingContent ? (
<div className="text-muted-foreground">Loading content...</div>
) : markdown ? (
+22 -5
View File
@@ -1,5 +1,6 @@
// Header component
import type { UIConfig } from './types'
import { LocaleSwitcher } from './i18n'
export function Header({ config }: { config: UIConfig }) {
// Resolve header component via template slot ref (e.g., header_main)
@@ -10,21 +11,36 @@ export function Header({ config }: { config: UIConfig }) {
: config.components?.header
const header = (headerComponent?.props || headerSlot?.props) as {
logoText?: string
logoImage?: string
links?: { label: string; href: string }[]
cta?: { label: string; href: string }
} | undefined
// Get logo configuration - support both header.logoImage and config.logo
const logoConfig = config.logo
const isDarkMode = document.documentElement.classList.contains('dark')
const logoSrc = header?.logoImage || (logoConfig ? (isDarkMode ? (logoConfig.dark || logoConfig.light) : (logoConfig.light || logoConfig.dark)) : null)
const logoHref = logoConfig?.href || '/'
return (
<header className="sticky top-0 z-50 border-b border-border/50 bg-background/80 backdrop-blur-xl supports-[backdrop-filter]:bg-background/60">
<div className="max-w-screen-2xl mx-auto flex h-16 items-center px-6 gap-8">
<a href="/" className="flex items-center gap-3 group cursor-pointer" onClick={(e) => {
<a href={logoHref} className="flex items-center gap-3 group cursor-pointer" onClick={(e) => {
e.preventDefault()
window.history.pushState({}, '', '/')
window.history.pushState({}, '', logoHref)
window.location.reload()
}}>
<div className="w-8 h-8 rounded-lg bg-gradient-to-br from-primary/80 to-primary flex items-center justify-center shadow-lg shadow-primary/20 group-hover:shadow-primary/40 transition-shadow">
<span className="text-primary-foreground text-xs font-bold">AI</span>
</div>
{logoSrc ? (
<img
src={logoSrc}
alt="Logo"
className="w-8 h-8 rounded-lg shadow-lg shadow-primary/20 group-hover:shadow-primary/40 transition-shadow object-contain"
/>
) : (
<div className="w-8 h-8 rounded-lg bg-gradient-to-br from-primary/80 to-primary flex items-center justify-center shadow-lg shadow-primary/20 group-hover:shadow-primary/40 transition-shadow">
<span className="text-primary-foreground text-xs font-bold">AI</span>
</div>
)}
<span className="font-semibold text-lg tracking-tight">{header?.logoText || config.site?.title || 'PraisonAIUI'}</span>
</a>
@@ -41,6 +57,7 @@ export function Header({ config }: { config: UIConfig }) {
</nav>
<div className="flex items-center gap-2">
<LocaleSwitcher />
{header?.cta && (
<a
href={header.cta.href}
+3
View File
@@ -55,6 +55,7 @@ export function ChatArea({ config, className = '', sessionId: externalSessionId,
content: run.content || run.message || '',
timestamp: run.timestamp || new Date().toISOString(),
...(run.toolCalls ? { toolCalls: run.toolCalls } : {}),
...(run.elements ? { elements: run.elements } : {}),
}))
setMessages(loaded)
}
@@ -71,6 +72,7 @@ export function ChatArea({ config, className = '', sessionId: externalSessionId,
currentResponse,
toolCalls,
thinkingSteps,
pendingElements,
sendMessage,
cancel,
} = useSSE({
@@ -152,6 +154,7 @@ export function ChatArea({ config, className = '', sessionId: externalSessionId,
currentResponse={currentResponse}
thinkingSteps={thinkingSteps}
toolCalls={toolCalls}
pendingElements={pendingElements}
isStreaming={isStreaming}
/>
)}
+36 -3
View File
@@ -13,6 +13,7 @@ interface ChatMessagesProps {
currentResponse: string
thinkingSteps: string[]
toolCalls: ToolCall[]
pendingElements?: (import('../types').MessageElementUnion | Record<string, unknown>)[]
isStreaming: boolean
sessionId?: string
}
@@ -22,6 +23,7 @@ export function ChatMessages({
currentResponse,
thinkingSteps,
toolCalls,
pendingElements = [],
isStreaming,
sessionId,
}: ChatMessagesProps) {
@@ -45,14 +47,27 @@ export function ChatMessages({
))}
</div>
)}
{currentResponse && (
{(currentResponse || pendingElements.length > 0) ? (
<div className="flex gap-3">
<Avatar role="assistant" />
<div className="flex-1 min-w-0">
<MarkdownContent content={currentResponse} />
<span className="inline-block w-2 h-4 bg-primary animate-pulse ml-0.5 rounded-sm" />
{currentResponse && (
<>
<MarkdownContent content={currentResponse} />
<span className="inline-block w-2 h-4 bg-primary animate-pulse ml-0.5 rounded-sm" />
</>
)}
{pendingElements.length > 0 && (
<div className={`space-y-3 ${currentResponse ? 'mt-3' : ''}`}>
{pendingElements.map((element, i) => (
<ElementRenderer key={i} element={element} className="w-full" />
))}
</div>
)}
</div>
</div>
) : (
<StreamLoadingBubble />
)}
</div>
)}
@@ -61,6 +76,24 @@ export function ChatMessages({
}
// -- Avatar ---
function StreamLoadingBubble() {
return (
<div className="flex gap-3" aria-live="polite" aria-label="Assistant is responding">
<Avatar role="assistant" />
<div className="flex-1 min-w-0">
<div className="inline-flex items-center gap-2 rounded-2xl bg-muted/60 px-4 py-3 text-sm text-muted-foreground">
<span className="inline-flex items-center gap-1" aria-hidden="true">
<span className="w-2 h-2 rounded-full bg-primary/70 animate-bounce [animation-delay:0ms]" />
<span className="w-2 h-2 rounded-full bg-primary/70 animate-bounce [animation-delay:150ms]" />
<span className="w-2 h-2 rounded-full bg-primary/70 animate-bounce [animation-delay:300ms]" />
</span>
<span>Generating response…</span>
</div>
</div>
</div>
)
}
function Avatar({ role }: { role: string }) {
if (role === 'user') {
return (
+13 -1
View File
@@ -285,7 +285,7 @@ export function CodeBlock({ code, language, className = '' }: CodeBlockProps) {
)
}
// Unified element renderer supporting all element types and display modes
import { SurfaceHost } from '../surfaces/SurfaceHost'
interface ElementRendererProps {
element: MessageElementUnion | Record<string, unknown>
className?: string
@@ -361,6 +361,18 @@ export function ElementRenderer({ element, className = '' }: ElementRendererProp
/>
)
}
case 'a2ui_surface': {
const el = element as Record<string, unknown>
const messages = (el.messages ?? []) as Record<string, unknown>[]
return (
<SurfaceHost
mode="auto"
messages={messages}
surfaceId={el.surface_id as string | undefined}
className={className}
/>
)
}
default:
// Fallback for legacy/unknown element types
if ('url' in element && typeof element.url === 'string') {
+45
View File
@@ -1,5 +1,7 @@
import { useState } from 'react'
import type { ToolCall } from '../types'
import { ElementRenderer } from './MultimediaElements'
import { SurfaceHost } from '../surfaces/SurfaceHost'
interface ToolCallDisplayProps {
toolCall: ToolCall
@@ -35,6 +37,11 @@ export function ToolCallDisplay({ toolCall }: ToolCallDisplayProps) {
<span className="font-medium truncate">
{toolCall.icon && <span className="mr-1">{toolCall.icon}</span>}
{displayLabel}
{toolCall.capability === 'stub' && (
<span className="ml-1 text-[10px] text-orange-600 bg-orange-100 px-1 py-0.5 rounded">
⚠️ DEMO
</span>
)}
</span>
{toolCall.error && (
@@ -57,6 +64,44 @@ export function ToolCallDisplay({ toolCall }: ToolCallDisplayProps) {
</button>
{expanded && (
<div className="p-2 border-t space-y-2">
{toolCall.capability === 'stub' && (
<div className="p-2 rounded bg-orange-50 border border-orange-200 text-orange-800 text-xs">
<div className="font-medium flex items-center gap-1">
⚠️ Simulated Tool
</div>
<div className="mt-1">
This tool returns demonstration data, not real results.
</div>
</div>
)}
{toolCall.warning && (
<div className="p-2 rounded bg-yellow-50 border border-yellow-200 text-yellow-800 text-xs">
<div className="font-medium">⚠️ Warning</div>
<div className="mt-1">{toolCall.warning}</div>
</div>
)}
{toolCall.elements?.length ? (
<div>
<span className="text-muted-foreground">Preview:</span>
<div className="mt-1 space-y-2">
{toolCall.elements.map((element, i) => (
<ElementRenderer key={i} element={element} className="max-w-sm" />
))}
</div>
</div>
) : null}
{toolCall.a2ui?.messages?.length ? (
<div>
<span className="text-muted-foreground">UI:</span>
<div className="mt-1">
<SurfaceHost
mode="auto"
messages={toolCall.a2ui.messages}
surfaceId={toolCall.a2ui.surface_id ?? toolCall.surface_id}
/>
</div>
</div>
) : null}
{toolCall.args && Object.keys(toolCall.args).length > 0 && (
<div>
<span className="text-muted-foreground">Arguments:</span>
@@ -0,0 +1,170 @@
import { useEffect, useState, useCallback } from 'react'
import {
CommandDialog,
CommandEmpty,
CommandInput,
CommandItem,
CommandList,
CommandGroup,
} from "@/components/ui/command"
interface Session {
id: string
title: string
created_at: string
updated_at: string
message_count: number
}
interface SessionSearchProps {
open: boolean
onOpenChange: (open: boolean) => void
onSessionSelect: (sessionId: string) => void
currentSessionId?: string | null
}
export function SessionSearch({ open, onOpenChange, onSessionSelect, currentSessionId }: SessionSearchProps) {
const [sessions, setSessions] = useState<Session[]>([])
const [loading, setLoading] = useState(false)
// Fetch sessions when dialog opens
useEffect(() => {
if (!open) return
let active = true
const fetchSessions = async () => {
try {
setLoading(true)
const res = await fetch('/sessions')
if (!res.ok) throw new Error(`HTTP ${res.status}`)
const data = await res.json()
if (active) {
setSessions(data.sessions || [])
}
} catch (err) {
console.error('Failed to fetch sessions:', err)
if (active) {
setSessions([])
}
} finally {
if (active) {
setLoading(false)
}
}
}
fetchSessions()
return () => {
active = false
}
}, [open])
const handleSelect = useCallback((sessionId: string) => {
onSessionSelect(sessionId)
onOpenChange(false)
}, [onSessionSelect, onOpenChange])
const formatDate = (iso: string) => {
try {
const date = new Date(iso)
const now = new Date()
const diff = now.getTime() - date.getTime()
const days = Math.floor(diff / (1000 * 60 * 60 * 24))
if (days === 0) return 'Today'
if (days === 1) return 'Yesterday'
if (days < 7) return `${days} days ago`
if (days < 30) return `${Math.floor(days / 7)} weeks ago`
if (days < 365) return `${Math.floor(days / 30)} months ago`
return `${Math.floor(days / 365)} years ago`
} catch {
return iso
}
}
// Group sessions by date
const today = new Date()
const yesterday = new Date(today)
yesterday.setDate(yesterday.getDate() - 1)
const lastWeek = new Date(today)
lastWeek.setDate(lastWeek.getDate() - 7)
const todayStr = today.toDateString()
const yesterdayStr = yesterday.toDateString()
const groupedSessions = sessions.reduce((groups, session) => {
const date = new Date(session.updated_at)
const dateStr = date.toDateString()
let group = 'Older'
if (dateStr === todayStr) {
group = 'Today'
} else if (dateStr === yesterdayStr) {
group = 'Yesterday'
} else if (date > lastWeek) {
group = 'This Week'
}
if (!groups[group]) groups[group] = []
groups[group].push(session)
return groups
}, {} as Record<string, Session[]>)
const groupOrder = ['Today', 'Yesterday', 'This Week', 'Older']
return (
<CommandDialog
open={open}
onOpenChange={onOpenChange}
title="Search Sessions"
description="Search for a session by title or ID"
>
<CommandInput
placeholder="Search sessions..."
disabled={loading}
/>
<CommandList>
{loading ? (
<div className="py-6 text-center text-sm text-muted-foreground">Loading sessions...</div>
) : sessions.length === 0 ? (
<CommandEmpty>No sessions found. Start a chat to create one.</CommandEmpty>
) : (
<>
<CommandEmpty>No sessions matching your search.</CommandEmpty>
{groupOrder.map((group) => {
const groupSessions = groupedSessions[group]
if (!groupSessions || groupSessions.length === 0) return null
return (
<CommandGroup key={group} heading={group}>
{groupSessions.map((session) => (
<CommandItem
key={session.id}
value={`${session.title} ${session.id}`}
onSelect={() => handleSelect(session.id)}
className="flex items-center justify-between"
>
<div className="flex flex-col">
<span className="font-medium">
{session.title || 'Untitled Session'}
</span>
<span className="text-xs text-muted-foreground">
{session.message_count} messages · {formatDate(session.updated_at)}
</span>
</div>
{currentSessionId === session.id && (
<span className="text-xs px-2 py-0.5 rounded bg-primary text-primary-foreground">
Current
</span>
)}
</CommandItem>
))}
</CommandGroup>
)
})}
</>
)}
</CommandList>
</CommandDialog>
)
}
+43 -1
View File
@@ -18,6 +18,7 @@ export interface SessionStreamState {
currentResponse: string
toolCalls: ToolCall[]
thinkingSteps: string[]
pendingElements: (import('../types').MessageElementUnion | Record<string, unknown>)[]
}
const defaultState = (): SessionStreamState => ({
@@ -25,6 +26,7 @@ const defaultState = (): SessionStreamState => ({
currentResponse: '',
toolCalls: [],
thinkingSteps: [],
pendingElements: [],
})
// ---------------------------------------------------------------------------
@@ -119,6 +121,7 @@ export function startStream(
currentResponse: '',
toolCalls: [],
thinkingSteps: [],
pendingElements: [],
})
// Reset tool call dedup map for this new stream
entry.toolCallMap = new Map()
@@ -264,6 +267,11 @@ function handleStoreEvent(
case 'tool_call_completed':
case 'team_tool_call_completed': {
const extra = event.extra_data as Record<string, unknown> | undefined
const a2uiRaw = (event.a2ui ?? extra?.a2ui) as Record<string, unknown>[] | undefined
const mediaEls = (event.elements ?? extra?.elements) as
| (import('../types').MessageElementUnion | Record<string, unknown>)[]
| undefined
const tc: ToolCall = {
name: event.name as string,
description: event.description as string | undefined,
@@ -274,10 +282,29 @@ function handleStoreEvent(
args: event.args as Record<string, unknown> | undefined,
formatted_result: event.formatted_result as string | undefined,
result: event.result,
surface_id: (event.surface_id ?? extra?.surface_id) as string | undefined,
a2ui: a2uiRaw?.length
? { messages: a2uiRaw, surface_id: (event.surface_id ?? extra?.surface_id) as string | undefined }
: undefined,
elements: mediaEls,
}
if (tc.tool_call_id) toolCallMap.set(tc.tool_call_id, tc)
const all = buildToolCallList(entry.state.toolCalls, tc)
update(sessionId, { toolCalls: all })
const pendingElements = mediaEls?.length
? [...entry.state.pendingElements, ...mediaEls]
: entry.state.pendingElements
update(sessionId, { toolCalls: all, pendingElements })
break
}
case 'message_element': {
const extra = event.extra_data as Record<string, unknown> | undefined
const el = (event.element ?? extra?.element) as Record<string, unknown> | undefined
if (el) {
update(sessionId, {
pendingElements: [...entry.state.pendingElements, el],
})
}
break
}
@@ -293,6 +320,21 @@ function handleStoreEvent(
break
}
case 'approval_required':
case 'approval_pending':
case 'run_paused': {
// Check if this is an approval event via extra_data
const extra = event.extra_data as Record<string, unknown> | undefined
if (extra?.type === 'approval_required') {
// Emit custom event for dashboard to handle
const approvalData = extra
window.dispatchEvent(new CustomEvent('aiui:approval-required', {
detail: approvalData
}))
}
break
}
case 'end':
case 'done':
case 'run_cancelled':
+2
View File
@@ -27,6 +27,7 @@ interface UseSSEReturn {
currentResponse: string
toolCalls: import('../types').ToolCall[]
thinkingSteps: string[]
pendingElements: (import('../types').MessageElementUnion | Record<string, unknown>)[]
}
export function useSSE(options: UseSSEOptions = {}): UseSSEReturn {
@@ -75,6 +76,7 @@ export function useSSE(options: UseSSEOptions = {}): UseSSEReturn {
currentResponse: snap.currentResponse,
toolCalls: snap.toolCalls,
thinkingSteps: snap.thinkingSteps,
pendingElements: snap.pendingElements,
sendMessage,
cancel,
}
@@ -0,0 +1,30 @@
import { useEffect, useState, useCallback } from 'react'
export function useSessionSearch(enabled: boolean = true) {
const [open, setOpen] = useState(false)
useEffect(() => {
if (!enabled) return
const handleKeyDown = (e: KeyboardEvent) => {
// Check for Ctrl+K (Windows/Linux) or Cmd+K (Mac)
if ((e.ctrlKey || e.metaKey) && e.key.toLowerCase() === 'k') {
e.preventDefault()
setOpen((prev) => !prev)
}
}
document.addEventListener('keydown', handleKeyDown)
return () => document.removeEventListener('keydown', handleKeyDown)
}, [enabled])
const onOpenChange = useCallback((open: boolean) => {
setOpen(open)
}, [])
return {
open,
setOpen,
onOpenChange,
}
}
+133
View File
@@ -0,0 +1,133 @@
import React, { createContext, useContext, useState, useEffect, useCallback } from 'react'
import type { I18nConfig } from '../types'
interface LocaleContextValue {
locale: string
locales: string[]
rtlLocales: string[]
setLocale: (locale: string) => void
t: (key: string, variables?: Record<string, string>) => string
isRTL: boolean
strings: Record<string, string>
loading: boolean
}
const LocaleContext = createContext<LocaleContextValue | null>(null)
export function useLocale() {
const context = useContext(LocaleContext)
if (!context) {
throw new Error('useLocale must be used within LocaleProvider')
}
return context
}
interface LocaleProviderProps {
config?: I18nConfig
children: React.ReactNode
}
export function LocaleProvider({ config, children }: LocaleProviderProps) {
const [locale, setLocaleState] = useState(config?.defaultLocale || 'en')
const [strings, setStrings] = useState<Record<string, string>>({})
const [loading, setLoading] = useState(false)
const locales = config?.locales || ['en']
const rtlLocales = config?.rtlLocales || []
const isRTL = rtlLocales.includes(locale)
// Apply RTL to document
useEffect(() => {
if (isRTL) {
document.documentElement.dir = 'rtl'
} else {
document.documentElement.dir = 'ltr'
}
}, [isRTL])
// Load strings from API when locale changes
useEffect(() => {
const abortController = new AbortController()
async function loadStrings() {
if (!config || locales.length <= 1) return
setLoading(true)
try {
const response = await fetch(`/api/i18n/strings/${locale}`, {
signal: abortController.signal
})
if (response.ok) {
const data = await response.json()
setStrings(data.strings || {})
}
} catch (error) {
// Ignore abort errors
if (error instanceof Error && error.name === 'AbortError') return
console.error('Failed to load translations:', error)
} finally {
setLoading(false)
}
}
loadStrings()
// Cleanup: abort the request if component unmounts or locale changes
return () => abortController.abort()
}, [locale, config, locales.length])
const setLocale = useCallback(async (newLocale: string) => {
// Validate locale format (e.g., 'en', 'en-US', 'zh-CN')
if (!/^[a-z]{2}(-[A-Z]{2})?$/.test(newLocale)) {
console.error('Invalid locale format:', newLocale)
return
}
if (!locales.includes(newLocale)) return
setLocaleState(newLocale)
// Update server-side locale
try {
await fetch('/api/i18n/locale', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ locale: newLocale })
})
} catch (error) {
console.error('Failed to update server locale:', error)
}
}, [locales])
const t = useCallback((key: string, variables?: Record<string, string>) => {
let text = strings[key] || key
// Apply variable substitution with proper escaping
if (variables) {
Object.entries(variables).forEach(([k, v]) => {
// Escape special regex characters in the key to prevent injection
const escapedKey = k.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
text = text.replace(new RegExp(`{${escapedKey}}`, 'g'), v)
})
}
return text
}, [strings])
const value: LocaleContextValue = {
locale,
locales,
rtlLocales,
setLocale,
t,
isRTL,
strings,
loading
}
return (
<LocaleContext.Provider value={value}>
{children}
</LocaleContext.Provider>
)
}
+25
View File
@@ -0,0 +1,25 @@
import { useLocale } from './LocaleProvider'
export function LocaleSwitcher() {
const { locale, locales, setLocale } = useLocale()
// Don't show switcher if only one locale
if (locales.length <= 1) {
return null
}
return (
<select
value={locale}
onChange={(e) => setLocale(e.target.value)}
className="px-3 py-1.5 rounded-md border border-border bg-background text-sm focus:outline-none focus:ring-2 focus:ring-primary/20"
aria-label="Language"
>
{locales.map((loc) => (
<option key={loc} value={loc}>
{loc.toUpperCase()}
</option>
))}
</select>
)
}
+2
View File
@@ -0,0 +1,2 @@
export { LocaleProvider, useLocale } from './LocaleProvider'
export { LocaleSwitcher } from './LocaleSwitcher'
+19 -1
View File
@@ -1,6 +1,8 @@
import { useCallback, useEffect, useState } from 'react'
import type { ChatConfig, ChatProfile } from '../types'
import { ChatArea, SessionManager } from '../chat'
import { SessionSearch } from '../components/SessionSearch'
import { useSessionSearch } from '../hooks/useSessionSearch'
interface AgentUILayoutProps {
config?: ChatConfig
@@ -13,6 +15,9 @@ export function AgentUILayout({ config, title }: AgentUILayoutProps) {
const [selectedProfile, setSelectedProfile] = useState<ChatProfile | undefined>(defaultProfile)
const [currentSessionId, setCurrentSessionId] = useState<string | null>(null)
const [activeTab, setActiveTab] = useState<'agents' | 'sessions'>('agents')
// Session search palette (Ctrl+K)
const { open: sessionSearchOpen, onOpenChange: onSessionSearchChange } = useSessionSearch()
// Fetch profiles dynamically from the API (same as ProfilePicker in ChatLayout)
useEffect(() => {
@@ -42,6 +47,14 @@ export function AgentUILayout({ config, title }: AgentUILayoutProps) {
return (
<div className="flex h-screen bg-background">
{/* Session search palette */}
<SessionSearch
open={sessionSearchOpen}
onOpenChange={onSessionSearchChange}
onSessionSelect={handleSessionSelect}
currentSessionId={currentSessionId}
/>
{/* Sidebar */}
<aside className="w-64 border-r flex flex-col">
<header className="border-b px-4 py-3">
@@ -142,7 +155,12 @@ export function AgentUILayout({ config, title }: AgentUILayoutProps) {
</header>
)}
<div className="flex-1 overflow-hidden">
<ChatArea config={config} className="h-full" />
<ChatArea
config={config}
className="h-full"
sessionId={currentSessionId}
onSessionChange={handleSessionSelect}
/>
</div>
</main>
</div>
+13
View File
@@ -2,6 +2,8 @@ import { useCallback, useState } from 'react'
import type { ChatConfig, LayoutConfig } from '../types'
import { ChatArea, SessionManager } from '../chat'
import { ProfilePicker } from '../chat/ProfilePicker'
import { SessionSearch } from '../components/SessionSearch'
import { useSessionSearch } from '../hooks/useSessionSearch'
interface ChatLayoutProps {
config?: ChatConfig
@@ -14,6 +16,9 @@ export function ChatLayout({ config, layout, title }: ChatLayoutProps) {
const [currentSessionId, setCurrentSessionId] = useState<string | null>(null)
const [showSessions, setShowSessions] = useState(true)
const [sessionListKey, setSessionListKey] = useState(0)
// Session search palette (Ctrl+K)
const { open: sessionSearchOpen, onOpenChange: onSessionSearchChange } = useSessionSearch(mode === 'fullscreen')
const handleSessionSelect = useCallback((sessionId: string) => {
setCurrentSessionId(sessionId)
@@ -33,6 +38,14 @@ export function ChatLayout({ config, layout, title }: ChatLayoutProps) {
if (mode === 'fullscreen') {
return (
<div className="flex h-screen bg-background">
{/* Session search palette */}
<SessionSearch
open={sessionSearchOpen}
onOpenChange={onSessionSearchChange}
onSessionSelect={handleSessionSelect}
currentSessionId={currentSessionId}
/>
{/* Session sidebar */}
{showSessions && config?.features?.history !== false && (
<aside className="w-64 border-r flex-shrink-0">
+18 -1
View File
@@ -4,6 +4,8 @@ import type { DashboardPageDef, DashboardTabGroup } from '../types/dashboard'
import { ChatArea, SessionManager } from '../chat'
import { ProfilePicker } from '../chat/ProfilePicker'
import { resolveView } from '../views'
import { SessionSearch } from '../components/SessionSearch'
import { useSessionSearch } from '../hooks/useSessionSearch'
interface DashboardLayoutProps {
config?: ChatConfig
@@ -39,6 +41,9 @@ export function DashboardLayout({ config, layout: _layout, title, logo }: Dashbo
// Protocol-driven: pages fetched from /api/pages
const [pages, setPages] = useState<DashboardPageDef[]>([])
const [tabGroups, setTabGroups] = useState<DashboardTabGroup[]>([])
// Session search palette (Ctrl+K)
const { open: sessionSearchOpen, onOpenChange: onSessionSearchChange } = useSessionSearch()
// Fetch registered pages from the backend protocol endpoint
useEffect(() => {
@@ -60,7 +65,11 @@ export function DashboardLayout({ config, layout: _layout, title, logo }: Dashbo
const handleSessionSelect = useCallback((sessionId: string) => {
setCurrentSessionId(sessionId)
}, [])
// If selecting a session, ensure we're on the chat tab
if (activeTab !== 'chat') {
setActiveTab('chat')
}
}, [activeTab])
const handleNewSession = useCallback(() => {
setCurrentSessionId(null)
@@ -124,6 +133,14 @@ export function DashboardLayout({ config, layout: _layout, title, logo }: Dashbo
return (
<div className="flex h-screen bg-background text-foreground">
{/* Session search palette */}
<SessionSearch
open={sessionSearchOpen}
onOpenChange={onSessionSearchChange}
onSessionSelect={handleSessionSelect}
currentSessionId={currentSessionId}
/>
{/* Sidebar navigation */}
<aside
className={`${navCollapsed ? 'w-14' : 'w-52'} flex-shrink-0 border-r bg-card flex flex-col transition-all duration-200`}
+168
View File
@@ -0,0 +1,168 @@
import { useEffect, useMemo, useState, type ComponentType } from 'react'
import { ComponentRenderer } from '../components/ComponentRenderer'
import { a2uiToComponents } from './a2uiToComponents'
import type { SurfaceHostProps } from './types'
interface A2uiRendererProps {
messages: Record<string, unknown>[]
surfaceId?: string
sessionId?: string
className?: string
}
type SurfaceEntry = {
id: string
surface: unknown
processor: unknown
A2uiSurface: ComponentType<{
surface: unknown
processor: unknown
onAction?: (action: Record<string, unknown>) => void
}>
}
export function A2uiRenderer({ messages, surfaceId, sessionId, className = '' }: A2uiRendererProps) {
const [surfaces, setSurfaces] = useState<SurfaceEntry[]>([])
const [error, setError] = useState<string | null>(null)
const [ready, setReady] = useState(false)
const messageKey = useMemo(() => JSON.stringify(messages), [messages])
useEffect(() => {
let cancelled = false
async function load() {
setError(null)
try {
const webCore = await import(/* @vite-ignore */ '@a2ui/web_core/v0_9')
const reactMod = await import(/* @vite-ignore */ '@a2ui/react/v0_9')
const { MessageProcessor } = webCore
const { A2uiSurface, basicCatalog } = reactMod
const processor = new MessageProcessor([basicCatalog])
processor.processMessages(messages)
const rawSurfaces = (processor as { getSurfaces?: () => unknown[] }).getSurfaces?.()
?? (processor as { surfaces?: unknown[] }).surfaces
?? []
const list: SurfaceEntry[] = []
if (Array.isArray(rawSurfaces) && rawSurfaces.length) {
for (const s of rawSurfaces) {
const id = typeof s === 'string' ? s : (s as { id?: string })?.id ?? 'main'
list.push({ id, surface: s, A2uiSurface, processor })
}
} else {
list.push({
id: surfaceId ?? 'main',
surface: surfaceId ?? 'main',
A2uiSurface,
processor,
})
}
if (!cancelled) {
setSurfaces(list)
setReady(true)
}
} catch (e) {
if (!cancelled) {
setError(
e instanceof Error
? e.message
: 'A2UI renderer unavailable — install @a2ui/react and @a2ui/web_core',
)
setReady(false)
}
}
}
if (messages?.length) {
load()
}
return () => {
cancelled = true
}
}, [messageKey, surfaceId, messages])
if (error) {
return (
<div className={`rounded-md border border-dashed p-3 text-xs text-muted-foreground ${className}`}>
<p className="font-medium mb-1">A2UI surface</p>
<p>{error}</p>
<pre className="mt-2 max-h-40 overflow-auto text-[10px] opacity-70">
{JSON.stringify(messages, null, 2)}
</pre>
</div>
)
}
if (!ready || !surfaces.length) {
return (
<div className={`rounded-md border bg-muted/30 p-3 text-xs text-muted-foreground ${className}`}>
Loading A2UI surface…
</div>
)
}
return (
<div className={`space-y-2 ${className}`}>
{surfaces.map((entry) => (
<entry.A2uiSurface
key={entry.id}
surface={entry.surface}
processor={entry.processor}
onAction={async (action) => {
const sid = surfaceId ?? entry.id
await fetch(`/api/surfaces/${sid}/action`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ ...action, session_id: sessionId }),
})
}}
/>
))}
</div>
)
}
export function SurfaceHost({
mode = 'auto',
messages = [],
components,
surfaceId,
sessionId,
className = '',
}: SurfaceHostProps) {
if (components?.length) {
return (
<div className={className}>
<ComponentRenderer components={components} />
</div>
)
}
if (mode === 'components' || !messages.length) {
return null
}
if (mode === 'auto') {
const mapped = a2uiToComponents(messages)
if (mapped?.length) {
return (
<div className={className}>
<ComponentRenderer components={mapped} />
</div>
)
}
}
return (
<A2uiRenderer
messages={messages}
surfaceId={surfaceId}
sessionId={sessionId}
className={className}
/>
)
}
+19
View File
@@ -0,0 +1,19 @@
/** Optional A2UI packages — installed via npm when using agent-driven surfaces. */
declare module '@a2ui/web_core/v0_9' {
export class MessageProcessor {
constructor(catalogs: unknown[])
processMessages(messages: unknown[]): void
getSurfaces?(): unknown[]
surfaces?: unknown[]
}
}
declare module '@a2ui/react/v0_9' {
import type { ComponentType } from 'react'
export const basicCatalog: unknown
export const A2uiSurface: ComponentType<{
surface: unknown
processor: unknown
onAction?: (action: Record<string, unknown>) => void
}>
}
@@ -0,0 +1,63 @@
import type { ComponentDict } from '../components/ComponentRenderer'
/** Partial A2UI → aiui component mapper (DRY fallback before Google renderer). */
function textFromNode(node: Record<string, unknown>): string {
const text = node.text
if (typeof text === 'string') return text
if (typeof text === 'object' && text !== null) {
const t = text as Record<string, unknown>
return String(t.literal ?? t.path ?? '')
}
return String(node.content ?? node.label ?? '')
}
function mapComponent(comp: Record<string, unknown>): ComponentDict | null {
const type = comp.component as string | undefined
if (!type) return null
switch (type) {
case 'Text':
return { type: 'text', content: textFromNode(comp) }
case 'Markdown':
return { type: 'markdown', content: textFromNode(comp) }
case 'Divider':
return { type: 'separator' }
case 'Image': {
const url = (comp.url as Record<string, unknown>)?.literal ?? comp.url
return { type: 'image', src: String(url ?? ''), alt: textFromNode(comp) }
}
case 'Button':
return {
type: 'button',
label: textFromNode(comp),
variant: 'default',
}
default:
return null
}
}
export function a2uiToComponents(messages: Record<string, unknown>[]): ComponentDict[] | null {
const out: ComponentDict[] = []
let sawUnmapped = false
for (const msg of messages) {
const update = msg.updateComponents as Record<string, unknown> | undefined
const components = (update?.components ?? update?.componentList) as Record<string, unknown>[] | undefined
if (!Array.isArray(components)) continue
for (const comp of components) {
const mapped = mapComponent(comp)
if (mapped) {
out.push(mapped)
} else {
sawUnmapped = true
}
}
}
if (!out.length) return null
if (sawUnmapped) return null
return out
}
Loaded 100 of 329 files, more files were not shown because too many files have changed in this diff. Show more