Compare commits

..
Author SHA1 Message Date
github-actions[bot]andMervin Praison 73b4a78487 feat: implement rich inline message elements with Chainlit parity
- Add typed schema for 6 element types (image, PDF, video, audio, file, code)
- Implement unified ElementRenderer with display modes (inline/side/page)
- Add click-to-zoom for images and PDF inline viewer
- Include accessibility features (ARIA labels, alt text, keyboard nav)
- Add size limits and graceful fallbacks for errors
- Maintain backward compatibility with legacy message properties
- Add comprehensive documentation for element schema

Resolves #4

Co-authored-by: Mervin Praison <[email protected]>
2026-04-17 12:47:15 +00:00
708 changed files with 7193 additions and 98413 deletions

No files matched your search

@@ -19,17 +19,18 @@ Deep Analysis → Gap Analysis → Critical Review → Plan → Create GitHub Is
- **No perf impact**: preserve import-time and hot-path; heavy deps optional + lazy.
- **Async-safe + multi-agent safe** by default.
- **Easy for non-developers**: "Few lines of code to do the task!"
- **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").
---
## CANONICAL PATHS
```
UI Repo: /Users/praison/praisonaiui (praisonaiui)
SDK Core: /Users/praison/praisonai-package/src/praisonai-agents (praisonaiagents)
Core SDK: /Users/praison/praisonai-package/src/praisonai-agents (praisonaiagents)
Wrapper: /Users/praison/praisonai-package/src/praisonai (praisonai)
Tools: /Users/praison/PraisonAI-tools
Docs: /Users/praison/PraisonAIDocs
Extension: src/praisonaiui/*, docs/*, tests/*
TypeScript: /Users/praison/praisonai-package/src/praisonai-ts
Extension: tools/base.py, tools/decorator.py, db/*
```
---
@@ -90,13 +91,16 @@ Key files to read:
Go through ALL relevant files, understand current logic end-to-end.
```bash
# UI repo
ls /Users/praison/praisonaiui/src/praisonaiui/
ls /Users/praison/praisonaiui/tests/
ls /Users/praison/praisonaiui/docs/
# Optional cross-check against SDK only when needed
# Core SDK
ls /Users/praison/praisonai-package/src/praisonai-agents/praisonaiagents/
grep -r "class.*Protocol" praisonaiagents/ --include="*.py" | wc -l
# Wrapper
ls /Users/praison/praisonai-package/src/praisonai/praisonai/
ls /Users/praison/praisonai-package/src/praisonai/praisonai/cli/commands/
# TypeScript
ls /Users/praison/praisonai-package/src/praisonai-ts/src/
```
**Document:**
@@ -316,7 +320,7 @@ Working code example.
```bash
gh issue create \
--repo MervinPraison/PraisonAIUI \
--repo MervinPraison/PraisonAI \
--title "<Type>: <Descriptive Title>" \
--body "<issue body>"
```
@@ -330,7 +334,7 @@ gh issue create \
### 9b. Verify issue created
```bash
gh issue view <number> --repo MervinPraison/PraisonAIUI | head -20
gh issue view <number> --repo MervinPraison/PraisonAI | head -20
```
---
+7 -4
View File
@@ -17,10 +17,13 @@ PRINCIPLES (apply in all analysis/proposals):
- Easy for non-developers. "Few lines of code to do the task!"
CANONICAL PATHS:
- UI Repo: /Users/praison/praisonaiui (praisonaiui)
- SDK Core (reference): /Users/praison/praisonai-package/src/praisonai-agents (praisonaiagents)
- Core SDK: /Users/praison/praisonai-package/src/praisonai-agents (praisonaiagents)
- Wrapper: /Users/praison/praisonai-package/src/praisonai (praisonai)
- Tools: /Users/praison/PraisonAI-tools
- Docs: /Users/praison/PraisonAIDocs (JS: docs/js, Rust: docs/rust)
- Extension points: src/praisonaiui/*, docs/*, tests/*
- TypeScript: /Users/praison/praisonai-package/src/praisonai-ts
- Rust: /Users/praison/praisonai-package/src/praisonai-rust
- Extension points: tools/base.py, tools/decorator.py, db/*
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
MANDATORY PROCESS (NO IMPLEMENTATION)
@@ -51,7 +54,7 @@ STEP 4 — Detailed Review
STEP 5 — Gap Analysis
- Compare acceptance criteria vs current state. Checklist:
- UI Core, CLI, Docs, Tests, Exports, Perf, Multi-agent + async
- Core SDK, Wrapper, Tools/plugins, CLI, Docs, Tests, Exports, Perf, Multi-agent + async
- Each gap: severity, impact, risk, recommended location (core/wrapper/tools).
STEP 6 — Critical Review
@@ -19,7 +19,6 @@ Deep Analysis → Gap Analysis → Critical Review → Plan → Create Issue →
- **No perf impact**: preserve import-time and hot-path; heavy deps optional + lazy.
- **Async-safe + multi-agent safe** by default.
- **Easy for non-developers**: "Few lines of code to do the task!"
- **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.
+1 -17
View File
@@ -138,23 +138,7 @@ Final rule: conclude only when evidence shows missing = 0.
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
PUBLISH WORKFLOW (reference)
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
GitHub Actions (recommended):
GitHub → Actions → "PyPI Release" → Run workflow → bump: patch
Requires repo secrets: PYPI_TOKEN, GH_TOKEN
Requires GitHub Environment "pypi" with required reviewers (Settings → Environments)
Manual — Publish aiui (PraisonAIUI):
cd /Users/praison/PraisonAIUI
# Bump version in pyproject.toml and src/praisonaiui/__version__.py
rm -rf dist && uv lock && uv build && uv publish --token "$PYPI_TOKEN"
git add pyproject.toml uv.lock src/praisonaiui/__version__.py
git commit -m "Release vX.Y.Z" && git tag vX.Y.Z && git push && git push --tags
gh release create vX.Y.Z --title "PraisonAIUI vX.Y.Z" --notes "Release vX.Y.Z" --latest
PyPI package name: aiui
Verify: pip index versions aiui | head -1
PraisonAI stack (separate repo — publish SDK before wrapper):
Step 1 — Publish praisonaiagents (Core SDK):
cd /Users/praison/praisonai-package/src/praisonai-agents
praisonai publish pypi
# Uses uv internally: uv lock → uv build → uv publish
@@ -1,290 +0,0 @@
name: "Claude Code Action"
description: "Run Claude Code in GitHub Actions workflows"
inputs:
anthropic_api_key:
description: "Anthropic API key"
required: false
claude_code_oauth_token:
description: "Claude Code OAuth Token for Claude Pro"
required: false
github_token:
description: "GitHub token for Claude to operate with"
required: false
default: ${{ github.token }}
trigger_phrase:
description: "The trigger phrase to look for in comments, issue/PR bodies, and issue titles"
required: false
default: "@claude"
assignee_trigger:
description: "The assignee username that triggers the action (e.g. @claude). Only used for issue assignment"
required: false
max_turns:
description: "Maximum number of conversation turns Claude can take"
required: false
timeout_minutes:
description: "Timeout in minutes for execution"
required: false
default: "30"
model:
description: "Model to use (provider-specific format required for Bedrock/Vertex)"
required: false
use_bedrock:
description: "Use Amazon Bedrock with OIDC authentication instead of direct Anthropic API"
required: false
default: "false"
use_vertex:
description: "Use Google Vertex AI with OIDC authentication instead of direct Anthropic API"
required: false
default: "false"
allowed_tools:
description: "Additional tools for Claude to use (the base GitHub tools will always be included)"
required: false
default: ""
disallowed_tools:
description: "Tools that Claude should never use"
required: false
default: ""
custom_instructions:
description: "Additional custom instructions to include in the prompt for Claude"
required: false
default: ""
mcp_config:
description: "Additional MCP configuration (JSON string) that merges with the built-in GitHub MCP servers"
required: false
default: ""
claude_env:
description: "Custom environment variables to pass to Claude Code execution (YAML format)"
required: false
default: ""
direct_prompt:
description: "Direct prompt for Claude to execute automatically without needing a trigger (for automated workflows)"
required: false
runs:
using: "composite"
steps:
- name: Install Claude Code
shell: bash
run: npm install -g @anthropic-ai/claude-code
- name: Install GitHub MCP Server
shell: bash
run: |
claude mcp add-json github '{
"command": "docker",
"args": [
"run",
"-i",
"--rm",
"-e",
"GITHUB_PERSONAL_ACCESS_TOKEN",
"ghcr.io/github/github-mcp-server:sha-ff3036d"
],
"env": {
"GITHUB_PERSONAL_ACCESS_TOKEN": "${{ inputs.github_token }}"
}
}'
- name: Extract GitHub Context and Create Prompt
shell: bash
id: prepare_context
env:
# Pass user-controlled inputs via environment variables to prevent script injection (GHSL-2025-093)
EVENT_COMMENT_BODY: ${{ github.event.comment.body }}
EVENT_ISSUE_TITLE: ${{ github.event.issue.title }}
EVENT_ISSUE_BODY: ${{ github.event.issue.body }}
EVENT_ISSUE_NUMBER: ${{ github.event.issue.number }}
EVENT_PR_TITLE: ${{ github.event.pull_request.title }}
EVENT_PR_NUMBER: ${{ github.event.pull_request.number }}
EVENT_REVIEW_BODY: ${{ github.event.review.body }}
EVENT_ASSIGNEE_LOGIN: ${{ github.event.assignee.login }}
INPUT_TRIGGER_PHRASE: ${{ inputs.trigger_phrase }}
INPUT_ASSIGNEE_TRIGGER: ${{ inputs.assignee_trigger }}
INPUT_DIRECT_PROMPT: ${{ inputs.direct_prompt }}
EVENT_ACTION: ${{ github.event.action }}
EVENT_NAME: ${{ github.event_name }}
REPO_NAME: ${{ github.repository }}
run: |
echo "🔍 Extracting GitHub context from event: $EVENT_NAME"
# Function to check for trigger phrase
check_trigger() {
local text="$1"
local trigger="$INPUT_TRIGGER_PHRASE"
if [[ "$text" == *"$trigger"* ]]; then
return 0
fi
return 1
}
# Extract context based on event type
TRIGGER_FOUND="false"
USER_REQUEST=""
CONTEXT_INFO=""
case "$EVENT_NAME" in
"issue_comment")
COMMENT_BODY="$EVENT_COMMENT_BODY"
ISSUE_TITLE="$EVENT_ISSUE_TITLE"
ISSUE_NUMBER="$EVENT_ISSUE_NUMBER"
if check_trigger "$COMMENT_BODY"; then
TRIGGER_FOUND="true"
USER_REQUEST="$COMMENT_BODY"
CONTEXT_INFO="Issue Comment on #$ISSUE_NUMBER: $ISSUE_TITLE"
fi
;;
"pull_request_review_comment")
COMMENT_BODY="$EVENT_COMMENT_BODY"
PR_TITLE="$EVENT_PR_TITLE"
PR_NUMBER="$EVENT_PR_NUMBER"
if check_trigger "$COMMENT_BODY"; then
TRIGGER_FOUND="true"
USER_REQUEST="$COMMENT_BODY"
CONTEXT_INFO="PR Comment on #$PR_NUMBER: $PR_TITLE"
fi
;;
"pull_request_review")
REVIEW_BODY="$EVENT_REVIEW_BODY"
PR_TITLE="$EVENT_PR_TITLE"
PR_NUMBER="$EVENT_PR_NUMBER"
if check_trigger "$REVIEW_BODY"; then
TRIGGER_FOUND="true"
USER_REQUEST="$REVIEW_BODY"
CONTEXT_INFO="PR Review on #$PR_NUMBER: $PR_TITLE"
fi
;;
"issues")
ISSUE_BODY="$EVENT_ISSUE_BODY"
ISSUE_TITLE="$EVENT_ISSUE_TITLE"
ISSUE_NUMBER="$EVENT_ISSUE_NUMBER"
if check_trigger "$ISSUE_TITLE" || check_trigger "$ISSUE_BODY"; then
TRIGGER_FOUND="true"
USER_REQUEST="$ISSUE_BODY"
CONTEXT_INFO="Issue #$ISSUE_NUMBER: $ISSUE_TITLE"
elif [[ "$EVENT_ACTION" == "assigned" && -n "$INPUT_ASSIGNEE_TRIGGER" ]]; then
ASSIGNEE="$EVENT_ASSIGNEE_LOGIN"
if [[ "$ASSIGNEE" == "$INPUT_ASSIGNEE_TRIGGER" ]]; then
TRIGGER_FOUND="true"
USER_REQUEST="$ISSUE_BODY"
CONTEXT_INFO="Issue #$ISSUE_NUMBER assigned to $ASSIGNEE: $ISSUE_TITLE"
fi
fi
;;
esac
# Check for direct prompt override
if [[ -n "$INPUT_DIRECT_PROMPT" ]]; then
TRIGGER_FOUND="true"
USER_REQUEST="$INPUT_DIRECT_PROMPT"
CONTEXT_INFO="Automated GitHub workflow"
fi
if [[ "$TRIGGER_FOUND" != "true" ]]; then
echo "❌ No trigger phrase found or direct prompt provided. Exiting gracefully."
echo "SKIP_EXECUTION=true" >> $GITHUB_ENV
exit 0
fi
echo "✅ Trigger found! Context: $CONTEXT_INFO"
# Create comprehensive prompt
mkdir -p /tmp/claude-action
cat > /tmp/claude-action/github-context-prompt.txt << EOF
You are Claude Code, an AI assistant helping with GitHub workflows and code.
Repository: $REPO_NAME
Context: $CONTEXT_INFO
Event: $EVENT_NAME
User Request:
$USER_REQUEST
Please analyze the request and provide helpful assistance.
CRITICAL INSTRUCTION: If your task involves modifying codebase files to add a feature or fix a bug:
STEP 0 — SETUP GIT IDENTITY:
git config user.name "MervinPraison"
git config user.email "[email protected]"
STEP 1 — READ GUIDELINES:
MANDATORY: Before writing any code, you MUST carefully read "src/praisonai-agents/AGENTS.md" to understand this repository's extremely specific Architecture, Protocol-First design rules, and Agent-Centric Philosophy.
STEP 2 — ARCHITECTURE VALIDATION & ROUTING (MANDATORY before writing code):
Before implementing anything, answer these questions:
- CORE vs WRAPPER vs TOOLS ROUTING:
1. Core SDK (praisonaiagents/): Only core protocols, base classes, decorators. No heavy implementations.
2. Wrapper (praisonai/): CLI, heavy implementations, optional dependencies.
3. Tools (PraisonAI-Tools): Specific integrations, external tools (e.g. SurrealDB, Slack), and community extensions MUST go to the `MervinPraison/PraisonAI-Tools` repository. If the feature is a tool, clone `https://github.com/MervinPraison/PraisonAI-Tools`, implement it there, and create the PR using `gh pr create -R MervinPraison/PraisonAI-Tools`.
- Does it duplicate existing functionality? Check if Agent already supports this via existing params (reflection, planning, tools, hooks, memory).
- Does it inherit from Agent properly? New agent types MUST inherit Agent, not wrap it with composition.
- Does it add new dependencies? Only optional deps allowed, must be lazy-imported.
- Will agent.py grow larger? If the change adds >50 lines to agent.py, find a way to extract instead.
- Is there a name collision with existing exports in __init__.py?
If ANY of these conceptual checks fail (excluding routing), add a comment to the issue explaining why and close it. Do NOT create a PR.
STEP 3 — IMPLEMENT:
- Create a new git branch appropriately named (e.g. \`feature/issue-<number>\`)
- Follow protocol-driven design: protocols in core SDK, heavy implementations in wrapper
- Keep changes small and backward-compatible
STEP 4 — TEST:
- Run: cd src/praisonai-agents && PYTHONPATH=. python -m pytest tests/ -x -q --timeout=30
- Ensure no regressions
STEP 5 — CREATE PR:
- Commit with descriptive message, push the branch
- Use the GitHub CLI (\`gh pr create --title "..." --body "Fixes #<number>"\`) to open a Pull Request
CRITICAL: You MUST create the PR automatically using \`gh pr create\`. Do NOT just provide a link.
Respond naturally and helpfully to the user's request using the available tools.
EOF
echo "PROMPT_FILE=/tmp/claude-action/github-context-prompt.txt" >> $GITHUB_ENV
echo "SKIP_EXECUTION=false" >> $GITHUB_ENV
- name: Run Claude Code
if: env.SKIP_EXECUTION != 'true'
shell: bash
run: |
echo "🚀 Running Claude Code with GitHub context..."
# Build command arguments
CMD_ARGS=("-p" "--verbose" "--output-format" "stream-json")
# Add max turns if specified
if [[ -n "${{ inputs.max_turns }}" ]]; then
CMD_ARGS+=("--max-turns" "${{ inputs.max_turns }}")
fi
# Add allowed tools (include GitHub tools by default)
TOOLS="mcp__github__get_issue,mcp__github__get_issue_comments,mcp__github__update_issue,mcp__github__search_issues,mcp__github__list_issues,mcp__github__create_comment,Read,Write,Edit,Bash"
if [[ -n "${{ inputs.allowed_tools }}" ]]; then
TOOLS="$TOOLS,${{ inputs.allowed_tools }}"
fi
CMD_ARGS+=("--allowedTools" "$TOOLS")
# Add disallowed tools
if [[ -n "${{ inputs.disallowed_tools }}" ]]; then
CMD_ARGS+=("--disallowedTools" "${{ inputs.disallowed_tools }}")
fi
echo "📝 Executing Claude Code with prompt from file..."
# Execute Claude Code with timeout, using stdin for the prompt
TIMEOUT_SECONDS=$((${{ inputs.timeout_minutes }} * 60))
timeout $TIMEOUT_SECONDS claude "${CMD_ARGS[@]}" < "${{ env.PROMPT_FILE }}"
echo "✅ Claude Code execution completed"
env:
ANTHROPIC_API_KEY: ${{ inputs.anthropic_api_key }}
CLAUDE_CODE_OAUTH_TOKEN: ${{ inputs.claude_code_oauth_token }}
GITHUB_TOKEN: ${{ inputs.github_token }}
@@ -1,91 +0,0 @@
name: "Claude Issue Triage Action"
description: "Automatically triage GitHub issues using Claude Code"
inputs:
timeout_minutes:
description: "Timeout in minutes for execution"
required: false
default: "5"
claude_code_oauth_token:
description: "Claude Code OAuth token for Pro subscription access"
required: true
github_token:
description: "GitHub token with repo and issues permissions"
required: true
runs:
using: "composite"
steps:
- name: Checkout repository code
uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Create prompt
id: generate_prompt
shell: bash
run: |
mkdir -p /tmp/claude-prompts
cat > /tmp/claude-prompts/claude-issue-triage-prompt.txt << 'EOF'
You're an issue triage assistant for GitHub issues. Your task is to analyze the issue and select appropriate labels from the provided list.
IMPORTANT: Don't post any comments or messages to the issue. Your only action should be to apply labels.
Issue Information:
- REPO: ${{ github.repository }}
- ISSUE_NUMBER: ${{ github.event.issue.number }}
TASK OVERVIEW:
1. First, fetch the list of labels available in this repository by running: `gh label list`. Run exactly this command with nothing else.
2. Next, use the GitHub tools to get context about the issue:
- You have access to these tools:
- mcp__github__get_issue: Use this to retrieve the current issue's details including title, description, and existing labels
- mcp__github__get_issue_comments: Use this to read any discussion or additional context provided in the comments
- mcp__github__update_issue: Use this to apply labels to the issue (do not use this for commenting)
- mcp__github__search_issues: Use this to find similar issues that might provide context for proper categorization and to identify potential duplicate issues
- mcp__github__list_issues: Use this to understand patterns in how other issues are labeled
- Start by using mcp__github__get_issue to get the issue details
3. Analyze the issue content, considering:
- The issue title and description
- The type of issue (bug report, feature request, question, etc.)
- Technical areas mentioned
- Severity or priority indicators
- User impact
- Components affected
4. Select appropriate labels from the available labels list provided above:
- Choose labels that accurately reflect the issue's nature
- Be specific but comprehensive
- Select priority labels if you can determine urgency (high-priority, med-priority, or low-priority)
- Consider platform labels (android, ios) if applicable
- If you find similar issues using mcp__github__search_issues, consider using a "duplicate" label if appropriate. Only do so if the issue is a duplicate of another OPEN issue.
5. Apply the selected labels:
- Use mcp__github__update_issue to apply your selected labels
- DO NOT post any comments explaining your decision
- DO NOT communicate directly with users
- If no labels are clearly applicable, do not apply any labels
IMPORTANT GUIDELINES:
- Be thorough in your analysis
- Only select labels from the provided list above
- DO NOT post any comments to the issue
- Your ONLY action should be to apply labels using mcp__github__update_issue
- It's okay to not add any labels if none are clearly applicable
EOF
echo "PROMPT<<DELIMITER" >> $GITHUB_OUTPUT
cat /tmp/claude-prompts/claude-issue-triage-prompt.txt >> $GITHUB_OUTPUT
echo "DELIMITER" >> $GITHUB_OUTPUT
- name: Run Claude Code
uses: ./.github/actions/claude-code-action
with:
direct_prompt: ${{ steps.generate_prompt.outputs.PROMPT }}
allowed_tools: "Bash(gh label list),mcp__github__get_issue,mcp__github__get_issue_comments,mcp__github__update_issue,mcp__github__search_issues,mcp__github__list_issues"
timeout_minutes: ${{ inputs.timeout_minutes }}
claude_code_oauth_token: ${{ inputs.claude_code_oauth_token }}
github_token: ${{ inputs.github_token }}
-9
View File
@@ -1,9 +0,0 @@
{
"templateRepo": "MervinPraison/github-automation-template",
"stack": "claude",
"profile": "sdk",
"mode": "hub",
"hubRef": "v1.1.11",
"templateVersion": "1.1.7",
"installedAt": "2026-09-07T10:02:11Z"
}
-153
View File
@@ -1,153 +0,0 @@
/**
* Idempotent CodeRabbit/Qodo kick for bot-opened PRs.
* @see .github/workflows/auto-pr-comment.yml, claude.yml, bot-pr-recovery.yml
*/
const KICK_AUTHORS = new Set(['MervinPraison', 'github-actions[bot]']);
const BOT_PR_AUTHORS = new Set(['praisonai-triage-agent[bot]', 'github-actions[bot]']);
async function listAllComments(github, owner, repo, issueNumber) {
if (typeof github.paginate === 'function') {
return github.paginate(github.rest.issues.listComments, {
owner,
repo,
issue_number: issueNumber,
per_page: 100,
});
}
const { data } = await github.rest.issues.listComments({
owner,
repo,
issue_number: issueNumber,
per_page: 100,
});
return data;
}
function kickAuthored(comment, marker) {
return (
KICK_AUTHORS.has(comment.user?.login) &&
(comment.body || '').includes(marker)
);
}
function coderabbitKickPosted(comments) {
return comments.some((c) => kickAuthored(c, '@coderabbitai review'));
}
function qodoKickPosted(comments) {
return comments.some((c) => kickAuthored(c, '/review'));
}
function chainKickPosted(comments) {
return coderabbitKickPosted(comments) && qodoKickPosted(comments);
}
function isBotOpenedPr(pr) {
if (BOT_PR_AUTHORS.has(pr.user?.login)) return true;
return pr.user?.type === 'Bot';
}
async function kickReviewChain(github, owner, repo, prNumber, core, preFetchedComments = null) {
const comments = preFetchedComments || await listAllComments(github, owner, repo, prNumber);
const needCoderabbit = !coderabbitKickPosted(comments);
const needQodo = !qodoKickPosted(comments);
if (!needCoderabbit && !needQodo) {
core?.info?.(`Review chain already kicked on PR #${prNumber}`);
return { kicked: false, reason: 'already_kicked' };
}
if (needCoderabbit) {
await github.rest.issues.createComment({
owner,
repo,
issue_number: prNumber,
body: '@coderabbitai review',
});
}
if (needQodo) {
await github.rest.issues.createComment({
owner,
repo,
issue_number: prNumber,
body: '/review',
});
}
core?.info?.(`Kicked review chain for PR #${prNumber} (coderabbit=${needCoderabbit}, qodo=${needQodo})`);
return { kicked: true, coderabbit: needCoderabbit, qodo: needQodo };
}
async function findOpenPrForIssue(github, owner, repo, issueNumber) {
const prefix = `claude/issue-${issueNumber}-`;
const { data: prs } = await github.rest.pulls.list({
owner,
repo,
state: 'open',
sort: 'created',
direction: 'desc',
per_page: 30,
});
return (
prs.find(
(p) => (p.head?.ref || '').startsWith(prefix) && isBotOpenedPr(p)
) || null
);
}
async function kickReviewChainForIssue(github, owner, repo, issueNumber, core) {
const pr = await findOpenPrForIssue(github, owner, repo, issueNumber);
if (!pr) {
core?.info?.(`No open PR for issue #${issueNumber}, skipping review kick`);
return { kicked: false, reason: 'no_pr' };
}
const result = await kickReviewChain(github, owner, repo, pr.number, core);
return { ...result, prNumber: pr.number };
}
async function recoverStalledBotPrs(github, owner, repo, options, core) {
const { prNumber = null, minAgeMs = 10 * 60 * 1000, maxRecover = 10 } = options || {};
let prs;
if (prNumber) {
const { data } = await github.rest.pulls.get({ owner, repo, pull_number: prNumber });
prs = [data];
} else if (typeof github.paginate === 'function') {
prs = await github.paginate(github.rest.pulls.list, {
owner,
repo,
state: 'open',
per_page: 100,
});
} else {
const { data } = await github.rest.pulls.list({ owner, repo, state: 'open', per_page: 100 });
prs = data;
}
const cutoff = Date.now() - minAgeMs;
let recovered = 0;
for (const pr of prs) {
if (recovered >= maxRecover) break;
if (!isBotOpenedPr(pr)) continue;
if (!prNumber && new Date(pr.created_at).getTime() > cutoff) continue;
const comments = await listAllComments(github, owner, repo, pr.number);
if (chainKickPosted(comments)) continue;
await kickReviewChain(github, owner, repo, pr.number, core, comments);
recovered += 1;
}
core?.info?.(`Recovery complete (${recovered} PR(s) kicked)`);
return recovered;
}
module.exports = {
KICK_AUTHORS,
BOT_PR_AUTHORS,
listAllComments,
chainKickPosted,
coderabbitKickPosted,
qodoKickPosted,
isBotOpenedPr,
kickReviewChain,
findOpenPrForIssue,
kickReviewChainForIssue,
recoverStalledBotPrs,
};
@@ -1,44 +0,0 @@
#!/usr/bin/env node
/**
* Run: node .github/scripts/ci-failure-claude-selftest.js
*/
const ciFix = require('./ci-failure-claude.js');
const mergeGate = require('./merge-gate.js');
const config = require('./gate-config.js');
let failed = 0;
function assert(name, cond) {
if (!cond) {
console.error('FAIL:', name);
failed += 1;
} else {
console.log('ok:', name);
}
}
const LOG = `
python UNKNOWN STEP FAILED (0.0100s) tests/unit/test_example.py::test_foo - AssertionError: bar
python UNKNOWN STEP ##[error]Process completed with exit code 1.
`;
const parsed = ciFix.parsePytestFailures(LOG);
assert('parses pytest failure', parsed.length === 1);
assert('uses CI workflow list', config.ciFailureWorkflowRuns.includes('CI'));
const comment = ciFix.buildCiFixComment({
headSha: 'abc1234567890abcdef1234567890abcdef12',
failedChecks: [{ name: 'python', workflow: 'CI', html_url: 'https://example.com/job/1' }],
failureSummaries: [{ jobName: 'python', failures: parsed }],
});
assert('comment mentions product guardrails', comment.includes('Product guardrails'));
const skip = ciFix.shouldSkipCiFix({
comments: [],
headSha: 'abc1234567890abcdef1234567890abcdef12',
labels: [],
claudeInProgress: false,
failedChecks: [{ name: 'python', workflow: 'CI' }],
});
assert('ci fix without FINAL review allowed', !skip.skip);
process.exit(failed ? 1 : 0);
-293
View File
@@ -1,293 +0,0 @@
/**
* Post @claude comments with CI failure details for internal PRs.
* @see .github/workflows/ci-failure-claude.yml
*/
const mergeGate = require('./merge-gate.js');
const config = require('./gate-config.js');
const CI_FIX_LABEL = 'claude-ci-fix-pending';
const COOLDOWN_MS = 12 * 60 * 60 * 1000;
const AUTO_ACTORS = mergeGate.AUTO_ACTORS;
const MAX_FAILURES = 15;
const MAX_JOBS_TO_FETCH = 5;
function shortSha(headSha) {
return (headSha || '').slice(0, 8);
}
function ciFixShaMarker(headSha) {
return `ci failed on head \`${shortSha(headSha)}`.toLowerCase();
}
function isCiFixComment(comment) {
const body = (comment.body || '').toLowerCase();
return body.includes('@claude') && body.includes('ci failed on head');
}
function hasCiFixCommentForSha(comments, headSha) {
const marker = shortSha(headSha).toLowerCase();
return comments.some((c) => {
if (!AUTO_ACTORS.includes(c.user.login)) return false;
if (!isCiFixComment(c)) return false;
return (c.body || '').toLowerCase().includes(marker);
});
}
function hasRecentCiFixComment(comments, headSha) {
const cutoff = Date.now() - COOLDOWN_MS;
const marker = shortSha(headSha).toLowerCase();
return comments.some((c) => {
if (!AUTO_ACTORS.includes(c.user.login)) return false;
if (!isCiFixComment(c)) return false;
const body = (c.body || '').toLowerCase();
if (body.includes(marker)) return true;
return new Date(c.created_at).getTime() > cutoff;
});
}
function shouldSkipCiFix({ comments, headSha, labels, claudeInProgress, failedChecks }) {
if (!failedChecks.length) return { skip: true, reason: 'no failed checks' };
if (labels.includes(CI_FIX_LABEL)) return { skip: true, reason: 'ci fix pending' };
if (claudeInProgress) return { skip: true, reason: 'claude in progress' };
if (hasCiFixCommentForSha(comments, headSha)) {
return { skip: true, reason: 'already commented for this sha' };
}
if (hasRecentCiFixComment(comments, headSha)) {
return { skip: true, reason: 'recent ci fix comment cooldown' };
}
return { skip: false };
}
function parsePytestFailures(logText) {
if (!logText) return [];
const lines = logText.split('\n');
const failures = [];
const seen = new Set();
for (let i = lines.length - 1; i >= 0 && failures.length < MAX_FAILURES; i -= 1) {
const line = lines[i].trim();
if (!line) continue;
const pytestMatch = line.match(
/FAILED\s*(?:\([^)]+\))?\s+(tests\/[^\s]+(?:::[^\s-]+)*)\s*-\s*(.+)/i
);
if (pytestMatch) {
const testId = pytestMatch[1];
if (!seen.has(testId)) {
seen.add(testId);
failures.push({ testId, error: pytestMatch[2].trim() });
}
continue;
}
const errorMatch = line.match(/##\[error\](.+)/);
if (errorMatch && /tests\//.test(errorMatch[1])) {
const msg = errorMatch[1].trim();
if (!seen.has(msg)) {
seen.add(msg);
failures.push({ testId: msg, error: msg });
}
}
}
return failures;
}
async function fetchJobFailureSummary(github, owner, repo, jobId, jobName) {
try {
const response = await github.rest.actions.downloadJobLogsForWorkflowRun({
owner,
repo,
job_id: jobId,
});
let logText = typeof response.data === 'string' ? response.data : String(response.data || '');
if (logText.length > 100000) {
logText = logText.slice(-100000);
}
return { jobName, jobId, failures: parsePytestFailures(logText) };
} catch (err) {
return { jobName, jobId, failures: [], error: err.message };
}
}
function mapFailedChecks(runs) {
return runs.map((run) => ({
name: run.name,
id: run.id,
html_url: run.html_url || run.details_url,
workflow: run.app?.slug || run.check_suite?.app?.slug || run.name,
}));
}
function buildCiFixComment({ headSha, failedChecks, failureSummaries }) {
const parts = [
`@claude CI failed on HEAD \`${shortSha(headSha)}\`. Please fix the failures below and push to this branch.`,
'',
'## Failed checks',
];
for (const check of failedChecks) {
parts.push(`- **${check.workflow || check.name}** / \`${check.name}\` — ${check.html_url}`);
}
parts.push('', '## Failures (extracted)');
let idx = 0;
for (const summary of failureSummaries) {
for (const failure of summary.failures) {
idx += 1;
if (idx > MAX_FAILURES) break;
parts.push(`${idx}. \`${failure.testId}\` — \`${failure.error}\``);
parts.push(` - Job: \`${summary.jobName}\``);
}
if (idx >= MAX_FAILURES) break;
}
if (idx === 0) {
parts.push('_(Could not extract pytest details — see job logs above.)_');
}
const exampleTest = failureSummaries.find((s) => s.failures.length)?.failures[0]?.testId;
parts.push(
'',
'## Critical review first',
'Before changing code or tests, decide **which side is wrong**:',
'1. **Legitimate feature change** — the PR intent is correct but implementation or tests need updating. Preserve product guarantees; update tests only when behaviour intentionally changed and document why.',
'2. **Regression / bug in this PR** — the failure exposes a real breakage introduced here. Fix the implementation; **do not weaken, skip, or delete tests** just to go green.',
'3. **Pre-existing flake or unrelated failure** — say so explicitly; prefer fixing the root cause over masking it.',
'',
'**Product guardrails (AGENTS.md):**',
'- Do not disturb PraisonAIUI contracts or frontend/backend layering to accommodate scope creep.',
'- Core agent runtime belongs in praisonaiagents; agent-callable tools belong in PraisonAI-Tools — not here.',
'- Prefer existing YAML config, plugins, and feature modules over new server.py/create_app() params or knobs with no live consumer.',
'- Tests must continue to guard backward compatibility and UX behaviour — passing CI by lowering test standards is not acceptable.',
'- If the feature does not genuinely add product value, recommend reverting or narrowing scope instead of patching around failures.',
'',
'## What to do',
'1. State your verdict: **legitimate fix**, **regression fix**, or **needs human review** — and why (1–3 sentences).',
'2. Fix root cause with **minimal changes**; never bloat server.py or create_app() with extra params.',
exampleTest
? `3. Run failing tests locally, e.g. \`pytest ${exampleTest} -q\`, plus any related tests touched by the PR.`
: '3. Run failing tests locally with targeted pytest, plus any related tests touched by the PR.',
'4. Push to this branch and comment: files changed, review verdict, and why tests still protect product behaviour.',
);
return parts.join('\n');
}
async function maybeClearCiFixLabel(github, owner, repo, prNumber, labels, headSha, core) {
if (!labels.includes(CI_FIX_LABEL)) return false;
const ciGreen = await mergeGate.allChecksGreenOnSha(github, owner, repo, headSha, core);
if (!ciGreen) return false;
try {
await github.rest.issues.removeLabel({
owner,
repo,
issue_number: prNumber,
name: CI_FIX_LABEL,
});
core?.info?.(`Removed ${CI_FIX_LABEL} from PR #${prNumber}`);
} catch (err) {
if (err.status !== 404) throw err;
}
return true;
}
async function maybeTriggerCiFixClaude(github, owner, repo, prNumber, core, opts = {}) {
const baseRepo = `${owner}/${repo}`;
const ctx = await mergeGate.loadPrContext(github, owner, repo, prNumber);
const pr = ctx.pr;
if (pr.state !== 'open') return { skipped: true, reason: 'not open' };
if (pr.draft) return { skipped: true, reason: 'draft' };
const headRepo = pr.head.repo?.full_name;
if (headRepo && headRepo !== baseRepo && !pr.maintainer_can_modify) {
return { skipped: true, reason: 'fork without maintainer edits' };
}
const headSha = pr.head.sha;
const labels = ctx.labels;
if (await maybeClearCiFixLabel(github, owner, repo, prNumber, labels, headSha, core)) {
return { skipped: true, reason: 'ci green, label cleared' };
}
const ciGreen = await mergeGate.allChecksGreenOnSha(github, owner, repo, headSha, core);
if (ciGreen) return { skipped: true, reason: 'ci green' };
const runs = await mergeGate.listChecksOnSha(github, owner, repo, headSha);
const failedRuns = mergeGate.listFailedChecksOnSha(runs);
if (failedRuns.length === 0) {
return { skipped: true, reason: 'no failed checks yet (may be pending)' };
}
const claudeInProgress = await mergeGate.hasInProgressClaudeAssistant(
github, owner, repo, prNumber
);
const skip = shouldSkipCiFix({
comments: ctx.comments,
headSha,
labels,
claudeInProgress,
failedChecks: failedRuns,
});
if (skip.skip) return { skipped: true, reason: skip.reason };
const failedChecks = mapFailedChecks(failedRuns);
const failureSummaries = [];
for (const check of failedChecks.slice(0, MAX_JOBS_TO_FETCH)) {
failureSummaries.push(
await fetchJobFailureSummary(github, owner, repo, check.id, check.name)
);
}
const body = buildCiFixComment({ headSha, failedChecks, failureSummaries });
await github.rest.issues.addLabels({
owner,
repo,
issue_number: prNumber,
labels: [CI_FIX_LABEL],
});
await github.rest.issues.createComment({
owner,
repo,
issue_number: prNumber,
body,
});
core?.info?.(`Posted CI fix @claude on PR #${prNumber} (${shortSha(headSha)})`);
return { triggered: true, headSha: shortSha(headSha) };
}
async function processWorkflowRunFailure(github, owner, repo, workflowRun, core) {
if (workflowRun.conclusion !== 'failure') {
return { skipped: true, reason: `conclusion=${workflowRun.conclusion}` };
}
const allowed = config.ciFailureWorkflowRuns;
if (!allowed.includes(workflowRun.name)) {
return { skipped: true, reason: `workflow ${workflowRun.name}` };
}
const prNumber = await mergeGate.resolvePrNumberFromWorkflowRun(
github, owner, repo, workflowRun
);
if (!prNumber) return { skipped: true, reason: 'no linked PR' };
return maybeTriggerCiFixClaude(github, owner, repo, prNumber, core);
}
module.exports = {
CI_FIX_LABEL,
COOLDOWN_MS,
ciFixShaMarker,
isCiFixComment,
hasCiFixCommentForSha,
hasRecentCiFixComment,
shouldSkipCiFix,
parsePytestFailures,
fetchJobFailureSummary,
buildCiFixComment,
maybeClearCiFixLabel,
maybeTriggerCiFixClaude,
processWorkflowRunFailure,
};
-55
View File
@@ -1,55 +0,0 @@
/**
* PraisonAIUI gate configuration — imported by merge-gate, release-gate, ci-failure-claude.
*/
module.exports = {
repoFullName: 'MervinPraison/PraisonAIUI',
gitUser: 'MervinPraison',
gitEmail: '[email protected]',
triggerLogins: ['MervinPraison', 'github-actions[bot]'],
allowedTriageBots: ['praisonai-triage-agent[bot]'],
productPathPrefixes: ['src/praisonaiui/', 'src/frontend/', 'tests/'],
sensitivePathPatterns: [
/^\.github\/workflows\//,
/^pyproject\.toml$/,
/credentials\.json$/i,
],
requiredCheckPatterns: [/^ci$/i, /python/i, /test/i, /lint/i, /ruff/i],
ciWorkflowFile: 'ci.yml',
ciWorkflowName: 'CI',
mergeGateWorkflowRuns: ['CI', 'Claude Assistant'],
ciFailureWorkflowRuns: ['CI'],
pypiPackageName: 'aiui',
packagePaths: ['src/praisonaiui', 'src/frontend', 'pyproject.toml'],
finalClaudeScope:
'SCOPE: Focus ONLY on PraisonAIUI (src/praisonaiui, src/frontend, tests, docs). '
+ 'Do NOT expand into praisonaiagents or the monorepo unless the PR explicitly requires it.',
finalClaudeProductValue:
'4. Product value: review in depth whether the change genuinely adds PraisonAIUI UX value — '
+ 'never add features for the sake of adding them. It must strengthen the UI (simpler, '
+ 'more user-friendly, robust). If it does not clearly add value, request changes or recommend '
+ 'rejecting/closing rather than merging scope creep.\n'
+ '5. Layering: UI/frontend/server features here; core agent runtime belongs in praisonaiagents '
+ '(PraisonAI monorepo). Agent-callable tools → PraisonAI-Tools. Do not pull monorepo or SDK '
+ 'scope into this repo unless the PR explicitly requires it.\n'
+ '6. Do not bloat server.py or create_app() with new params — prefer existing YAML config, '
+ 'plugins, and feature modules; only add params if absolutely required. Reject knobs/exports '
+ 'with no live consumer.',
mergeGateProductValue:
'Confirm product value gate: change strengthens PraisonAIUI UX (no scope creep). The aim is a '
+ 'LIGHTWEIGHT AND POWERFUL package — BLOCK changes that add params/modules/exports duplicating '
+ 'existing UI capabilities (YAML config, plugins, feature modules, existing server hooks), or '
+ 'add knobs/fields with no live consumer, i.e. scope creep for the sake of it.',
mergeGateLayering:
'Layering routing: BLOCK if core agent logic was added here instead of praisonaiagents; BLOCK if '
+ 'agent-callable tools were added here instead of PraisonAI-Tools.',
mergeGateServerBloat:
'Do not bloat server.py or create_app() with additional params — only if absolutely required; '
+ 'prefer existing config/plugin patterns.',
agentPyChecks: false,
reviewBotLogins: [
'coderabbitai[bot]',
'qodo-code-review[bot]',
'greptile-apps[bot]',
],
};
-234
View File
@@ -1,234 +0,0 @@
#!/usr/bin/env node
/**
* Local self-test for merge-gate.js heuristics (no GitHub API).
* Run: node .github/scripts/merge-gate-selftest.js
*/
const mg = require('./merge-gate.js');
let failed = 0;
function assert(name, cond) {
if (!cond) {
console.error('FAIL:', name);
failed++;
} else {
console.log('ok:', name);
}
}
// Stale FINAL: push after FINAL, no @claude since head
const finals = [
{ user: { login: 'github-actions[bot]' }, body: '@claude FINAL architecture reviewer', created_at: '2026-06-12T08:00:00Z' },
];
assert('stale when head after final', mg.isStaleFinalAfterPush(finals, '2026-06-12T09:00:00Z'));
const withRecovery = [
...finals,
{ user: { login: 'github-actions[bot]' }, body: '@claude FINAL architecture reviewer', created_at: '2026-06-12T09:30:00Z' },
];
assert('not stale when @claude after head', !mg.isStaleFinalAfterPush(withRecovery, '2026-06-12T09:00:00Z'));
const withClaudeReply = [
...finals,
{
user: { login: 'praisonai-triage-agent[bot]' },
body: "**Claude finished @MervinPraison's task** —— [View job](https://github.com/)",
created_at: '2026-06-12T08:30:00Z',
},
];
assert('not stale when Claude replied after FINAL', !mg.isStaleFinalAfterPush(withClaudeReply, '2026-06-12T09:00:00Z'));
assert('claude final reply detected', mg.isClaudeFinalReplyComment(withClaudeReply[1]));
assert('cancelled detect-and-trigger does not block', mg.OPTIONAL_CANCELLED_CHECKS.has('detect-and-trigger'));
// Stale-FINAL recovery guards (PR #2560 push loop)
const nowMs = Date.now();
const iso = (ms) => new Date(ms).toISOString();
const firstFinal = {
user: { login: 'MervinPraison' },
body: '@claude You are the FINAL architecture reviewer.',
created_at: iso(nowMs - 20 * 60 * 1000),
};
const pushSoonAfter = mg.shouldSkipStaleFinalRecovery(
[firstFinal],
iso(nowMs - 16 * 60 * 1000)
);
assert('debounce push soon after FINAL', pushSoonAfter.skip && pushSoonAfter.reason.includes('soon after'));
const secondFinal = {
user: { login: 'MervinPraison' },
body: '@claude You are the FINAL architecture reviewer.',
created_at: iso(nowMs - 20 * 60 * 1000),
};
const capped = mg.shouldSkipStaleFinalRecovery(
[firstFinal, secondFinal],
iso(nowMs - 2 * 60 * 1000)
);
assert('hourly cap blocks third FINAL in window', capped.skip && capped.reason.includes('capped'));
const botPush = mg.shouldSkipStaleFinalRecovery(
[firstFinal],
iso(nowMs - 16 * 60 * 1000),
'praisonai-triage-agent[bot]'
);
assert('skip when automation pushed head', botPush.skip && botPush.reason.includes('automation'));
assert('claude automation login', mg.isClaudeAutomationLogin('praisonai-triage-agent[bot]'));
// Bot CHANGES_REQUESTED then APPROVE
const reviews = [
{ user: { login: 'coderabbit[bot]', type: 'Bot' }, state: 'CHANGES_REQUESTED', submitted_at: '2026-06-12T08:00:00Z' },
{ user: { login: 'coderabbit[bot]', type: 'Bot' }, state: 'APPROVED', submitted_at: '2026-06-12T09:00:00Z' },
];
assert('bot approve clears CR', !mg.hasAnyChangesRequested(reviews));
assert('human CR blocks', mg.hasAnyChangesRequested([
{ user: { login: 'MervinPraison', type: 'User' }, state: 'CHANGES_REQUESTED', submitted_at: '2026-06-12T09:00:00Z' },
]));
// Verdict after HEAD
const verdictComments = [
{ body: 'MERGE_GATE_VERDICT: APPROVE', created_at: '2026-06-12T08:00:00Z' },
];
assert('verdict before head rejected', mg.findMergeGateVerdict(verdictComments, null, '2026-06-12T09:00:00Z') === null);
assert('verdict after head accepted', mg.findMergeGateVerdict(
[{ body: 'MERGE_GATE_VERDICT: APPROVE', created_at: '2026-06-12T10:00:00Z' }],
null,
'2026-06-12T09:00:00Z'
) === 'APPROVE');
const fallbackApprove = [
{
body: 'MERGE_GATE_VERDICT: APPROVE\n\nAutomated fallback — Claude assess did not post a verdict comment.',
created_at: '2026-06-12T10:00:00Z',
},
];
assert(
'automated fallback APPROVE ignored when Opus required',
mg.findMergeGateVerdict(fallbackApprove, null, '2026-06-12T09:00:00Z', { excludeAutomatedFallback: true }) === null
);
assert(
'automated fallback APPROVE still visible without Opus-only filter',
mg.findMergeGateVerdict(fallbackApprove, null, '2026-06-12T09:00:00Z') === 'APPROVE'
);
const fallbackBlockNewFormat = [
{
body: 'MERGE_GATE_VERDICT: BLOCK\n\nAutomated fallback — Opus merge gate assessment did not complete.',
created_at: '2026-06-12T10:00:00Z',
},
];
assert(
'new-format fallback BLOCK ignored when Opus required',
mg.findMergeGateVerdict(fallbackBlockNewFormat, null, '2026-06-12T09:00:00Z', { excludeAutomatedFallback: true }) === null
);
assert(
'new-format fallback BLOCK detected by marker helper',
mg.isAutomatedFallbackVerdict(fallbackBlockNewFormat[0].body)
);
assert(
'findMergeGateVerdict null-safe with explicit null options',
mg.findMergeGateVerdict(fallbackApprove, null, '2026-06-12T09:00:00Z', null) === 'APPROVE'
);
const noise = [{ user: { login: 'MervinPraison' }, body: '**Merge gate scan** — wait for `@claude`', created_at: new Date().toISOString() }];
assert('diagnostic comment not a trigger', !mg.hasRecentClaudeTrigger(noise, 35));
// Cooldown skip requires an actual verdict on HEAD, not just a FINAL trigger comment
const recentFinalOnHead = [
{
user: { login: 'MervinPraison' },
body: '@claude You are the FINAL architecture reviewer.',
created_at: '2026-06-27T10:00:00Z',
},
];
assert(
'final trigger alone does not count as verdict on head',
mg.findMergeGateVerdict(recentFinalOnHead, null, '2026-06-27T09:55:00Z') === null
);
const recentVerdictOnHead = [
...recentFinalOnHead,
{
user: { login: 'github-actions[bot]' },
body: 'MERGE_GATE_VERDICT: APPROVE',
created_at: '2026-06-27T10:05:00Z',
},
];
assert(
'verdict on head skips cooldown gate',
mg.findMergeGateVerdict(recentVerdictOnHead, null, '2026-06-27T09:55:00Z') === 'APPROVE'
);
// Sensitive + secrets
assert('workflow path sensitive', mg.sensitivePathReasons([{ filename: '.github/workflows/foo.yml' }]).length === 1);
assert('ci-only label exempts workflows', mg.sensitivePathReasons(
[{ filename: '.github/workflows/foo.yml' }],
[mg.WORKFLOW_ONLY_LABEL]
).length === 0);
assert('ci-only label not exempt mixed changes', mg.sensitivePathReasons(
[{ filename: '.github/workflows/foo.yml' }, { filename: 'src/praisonaiui/foo.py' }],
[mg.WORKFLOW_ONLY_LABEL]
).length === 1);
assert('secret in patch', mg.secretScanReasons([{ filename: 'x.py', patch: '+key = "ghp_abcdefghijklmnopqrstuvwxyz1234567890"' }]).length === 1);
// Claude run scoping — other PR branches must not block
assert('other branch claude does not block', !mg.hasBlockingClaudeRunForPr(
[{ event: 'issue_comment', head_branch: 'other-branch' }],
'my-branch'
));
assert('same branch claude blocks', mg.hasBlockingClaudeRunForPr(
[{ event: 'issue_comment', head_branch: 'my-branch' }],
'my-branch'
));
assert('issues event never blocks', !mg.hasBlockingClaudeRunForPr(
[{ event: 'issues', head_branch: 'my-branch' }],
'my-branch'
));
// Conflict rebase clears after bot completion + FINAL on HEAD (within 12h cooldown)
const conflictNowMs = Date.now();
const conflictIso = (offsetMs) => new Date(conflictNowMs + offsetMs).toISOString();
const conflictTrigger = {
user: { login: 'MervinPraison' },
body: '@claude this PR has merge conflicts with main. Please rebase',
created_at: conflictIso(-30 * 60 * 1000),
};
const rebaseDone = {
user: { login: 'praisonai-triage-agent[bot]' },
body: 'Rebase complete — PR #2308 onto latest main',
created_at: conflictIso(-29 * 60 * 1000),
};
const finalAfterRebase = {
user: { login: 'MervinPraison' },
body: '@claude You are the FINAL architecture reviewer.',
created_at: conflictIso(-20 * 60 * 1000),
};
const headAfterRebase = conflictIso(-21 * 60 * 1000);
const rebaseComments = [conflictTrigger, rebaseDone, finalAfterRebase];
assert('conflict blocks before rebase done', mg.hasRecentConflictComment([conflictTrigger], headAfterRebase));
assert('conflict clears after rebase + FINAL on HEAD', !mg.hasRecentConflictComment(rebaseComments, headAfterRebase));
assert('conflict clears after rebase without FINAL on HEAD', !mg.hasRecentConflictComment(
[conflictTrigger, rebaseDone],
headAfterRebase
));
// Tests heuristic
assert('product code without tests', mg.missingTestsReason([{ filename: 'src/praisonaiui/a/b.py', additions: 3 }]) !== null);
assert('product code with tests ok', mg.missingTestsReason([
{ filename: 'src/praisonaiui/a/b.py', additions: 3 },
{ filename: 'tests/test_x.py', additions: 10 },
]) === null);
// PR size
assert('large PR blocked', mg.prSizeReasons([{ additions: 900 }]).length > 0);
assert('internal PR link accepted', mg.isInternalPullRequestLink(
{ base: { repo: { full_name: 'MervinPraison/PraisonAIUI' } } },
'MervinPraison',
'PraisonAIUI'
));
assert('fork sync PR link rejected', !mg.isInternalPullRequestLink(
{ number: 21, base: { repo: { full_name: 'Milkmange/PraisonAIUI' } } },
'MervinPraison',
'PraisonAIUI'
));
process.exit(failed ? 1 : 0);
-940
View File
@@ -1,940 +0,0 @@
/**
* Shared merge-gate helpers for claude-merge-gate.yml
* @see .github/workflows/claude-merge-gate.yml
*/
const config = require('./gate-config.js');
const CLAUDE_TRIGGER_LOGINS = ['MervinPraison', 'github-actions[bot]'];
const AUTO_ACTORS = CLAUDE_TRIGGER_LOGINS;
const CONFLICT_COOLDOWN_MS = 12 * 60 * 60 * 1000;
const CLAUDE_ACTIVE_MS = 35 * 60 * 1000;
const STALE_FINAL_RECOVERY_WINDOW_MS = 60 * 60 * 1000;
const STALE_FINAL_MAX_PER_WINDOW = 2;
const PUSH_AFTER_FINAL_DEBOUNCE_MS = 15 * 60 * 1000;
const ALLOWED_MERGE_STATES = new Set(['CLEAN', 'UNSTABLE']);
const AGENT_PY_MAX_AUTO_LINES = 100;
const PR_MAX_AUTO_ADDITIONS = 800;
const PR_MAX_AUTO_FILES = 30;
const MANUAL_ONLY_LABELS = new Set(['security', 'breaking-change', 'needs-manual-review', 'release']);
const WORKFLOW_ONLY_LABEL = 'merge-gate-ci-only';
const CI_ONLY_PATH_PREFIXES = ['.github/workflows/', '.github/actions/', '.github/scripts/merge-gate'];
const SDK_PATH_PREFIXES = config.productPathPrefixes;
const SENSITIVE_PATH_PATTERNS = config.sensitivePathPatterns;
const REQUIRED_SDK_CHECK_PATTERNS = config.requiredCheckPatterns;
const SECRET_PATTERNS = [
/sk-[a-zA-Z0-9]{20,}/,
/AKIA[0-9A-Z]{16}/,
/ghp_[a-zA-Z0-9]{36,}/,
/github_pat_/,
/Bearer eyJ[a-zA-Z0-9_-]+\.[a-zA-Z0-9_-]+/,
/-----BEGIN (RSA |EC |OPENSSH )?PRIVATE KEY-----/,
];
const BLOCK_LABELS = new Set([
'claude-conflict-pending',
'claude-merge-gate-active',
'no-auto-merge',
'auto-merged-by-gate',
]);
const MERGE_READY_LABEL = 'pipeline/merge-ready';
/** Superseded concurrency runs; must not block merge when real tests passed. */
const OPTIONAL_CANCELLED_CHECKS = new Set(['detect-and-trigger']);
const BOT_REVIEWER_PATTERNS = [
'coderabbit',
'qodo',
'gemini',
'copilot',
'greptile',
];
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
function isFinalClaudeTriggerComment(c) {
const body = (c.body || '').toLowerCase();
if (!AUTO_ACTORS.includes(c.user.login)) return false;
if (!body.includes('@claude')) return false;
if (body.includes('merge conflict')) return false;
return body.includes('final architecture reviewer') || body.includes('lead engineer');
}
function isClaudeTriggerNoise(c) {
const body = c.body || '';
if (body.includes('Merge gate scan')) return true;
if (body.includes('MERGE_GATE_VERDICT')) return true;
if (body.includes('Merged by **Claude PR merge gate**')) return true;
return false;
}
function isClaudeFinalReplyComment(c) {
const login = (c.user?.login || '').toLowerCase();
const fromAutomation =
login.includes('praisonai-triage') ||
login.includes('github-actions') ||
AUTO_ACTORS.some((a) => a.toLowerCase() === login);
if (!fromAutomation) return false;
return (c.body || '').includes('Claude finished');
}
function hasRecentClaudeTrigger(comments, minutes = 35) {
const cutoff = Date.now() - minutes * 60 * 1000;
return comments.some((c) => {
if (!CLAUDE_TRIGGER_LOGINS.includes(c.user.login)) return false;
if (isClaudeTriggerNoise(c)) return false;
if (!(c.body || '').includes('@claude')) return false;
return new Date(c.created_at).getTime() > cutoff;
});
}
function isConflictRebaseTriggerComment(c) {
if (!AUTO_ACTORS.includes(c.user.login)) return false;
const body = (c.body || '').toLowerCase();
return body.includes('@claude') && body.includes('merge conflict');
}
function isConflictRebaseCompletionComment(c) {
const login = (c.user?.login || '').toLowerCase();
const fromAutomation =
login.includes('praisonai-triage') ||
login.includes('github-actions') ||
AUTO_ACTORS.some((a) => a.toLowerCase() === login);
if (!fromAutomation) return false;
const body = (c.body || '').toLowerCase();
return (
body.includes('rebase complete') ||
body.includes('rebase onto') ||
body.includes('force-pushed') ||
(body.includes('conflict') && body.includes('resolved'))
);
}
/** HEAD landed after conflict rebase but no FINAL @claude since that push. */
function needsFinalReReviewAfterConflictRebase(comments, headPushedAt) {
if (!headPushedAt) return false;
const headTime = new Date(headPushedAt).getTime();
const conflictTriggers = comments.filter(isConflictRebaseTriggerComment);
if (conflictTriggers.length === 0) return false;
const latestConflict = conflictTriggers.reduce((a, b) =>
new Date(a.created_at) > new Date(b.created_at) ? a : b
);
const conflictTime = new Date(latestConflict.created_at).getTime();
if (headTime <= conflictTime) return false;
const rebaseDone = comments.some(
(c) =>
new Date(c.created_at).getTime() > conflictTime && isConflictRebaseCompletionComment(c)
);
if (!rebaseDone) return false;
const finalsAfterHead = comments.filter(
(c) =>
isFinalClaudeTriggerComment(c) &&
new Date(c.created_at).getTime() >= headTime - 60000
);
return finalsAfterHead.length === 0;
}
function conflictRebaseQuiescent(comments, headPushedAt) {
const conflictTriggers = comments.filter(isConflictRebaseTriggerComment);
if (conflictTriggers.length === 0) return true;
const latestConflict = conflictTriggers.reduce((a, b) =>
new Date(a.created_at) > new Date(b.created_at) ? a : b
);
const conflictTime = new Date(latestConflict.created_at).getTime();
const rebaseDone = comments.some(
(c) =>
new Date(c.created_at).getTime() > conflictTime && isConflictRebaseCompletionComment(c)
);
if (!rebaseDone) return false;
return true;
}
function hasRecentConflictComment(comments, headPushedAt = null) {
const cutoff = Date.now() - CONFLICT_COOLDOWN_MS;
const hasRecentTrigger = comments.some((c) => {
if (!isConflictRebaseTriggerComment(c)) return false;
return new Date(c.created_at).getTime() > cutoff;
});
if (!hasRecentTrigger) return false;
if (headPushedAt && conflictRebaseQuiescent(comments, headPushedAt)) {
return false;
}
return true;
}
function isBotReviewer(login, userType) {
const lower = (login || '').toLowerCase();
if (userType === 'Bot') return true;
if (lower.endsWith('[bot]')) return true;
return ['coderabbit', 'qodo', 'gemini', 'copilot', 'greptile'].some((p) => lower.includes(p));
}
function latestReviewsByUser(reviews) {
const latestByUser = new Map();
for (const r of reviews) {
const login = r.user?.login;
if (!login) continue;
const prev = latestByUser.get(login);
if (!prev || new Date(r.submitted_at) > new Date(prev.submitted_at)) {
latestByUser.set(login, r);
}
}
return latestByUser;
}
function hasHumanChangesRequested(reviews) {
for (const [login, review] of latestReviewsByUser(reviews)) {
if (review.state !== 'CHANGES_REQUESTED') continue;
if (!isBotReviewer(login, review.user?.type)) return true;
}
return false;
}
function hasAnyChangesRequested(reviews) {
for (const [, review] of latestReviewsByUser(reviews)) {
if (review.state === 'CHANGES_REQUESTED') return true;
}
return false;
}
function hasFinalClaudeReviewTrigger(comments) {
return comments.some(isFinalClaudeTriggerComment);
}
function isStaleFinalAfterPush(comments, headPushedAt) {
if (!headPushedAt) return false;
const headTime = new Date(headPushedAt).getTime();
const finals = comments.filter(isFinalClaudeTriggerComment);
if (finals.length === 0) return true;
const latestFinal = finals.reduce((a, b) =>
new Date(a.created_at) > new Date(b.created_at) ? a : b
);
const finalTime = new Date(latestFinal.created_at).getTime();
if (headTime <= finalTime + 60000) return false;
const claudeRepliedAfterFinal = comments.some((c) => {
if (!isClaudeFinalReplyComment(c)) return false;
return new Date(c.created_at).getTime() >= finalTime - 60000;
});
if (claudeRepliedAfterFinal) return false;
const claudeSinceHead = comments.some((c) => {
if (!CLAUDE_TRIGGER_LOGINS.includes(c.user.login)) return false;
if (isClaudeTriggerNoise(c)) return false;
if (!(c.body || '').includes('@claude')) return false;
return new Date(c.created_at).getTime() >= headTime - 60000;
});
return !claudeSinceHead;
}
function needsStaleFinalRecovery(comments, headPushedAt) {
return (
hasFinalClaudeReviewTrigger(comments) &&
isStaleFinalAfterPush(comments, headPushedAt)
);
}
function shouldSkipFinalRecovery(comments, headPushedAt) {
const isStale = isStaleFinalAfterPush(comments, headPushedAt);
if (isStale) return false;
return hasRecentClaudeTrigger(comments, 35);
}
function countFinalTriggersSince(comments, sinceMs) {
return comments.filter(
(c) => isFinalClaudeTriggerComment(c) && new Date(c.created_at).getTime() > sinceMs
).length;
}
function isClaudeAutomationLogin(login) {
const lower = (login || '').toLowerCase();
return lower.includes('praisonai-triage') || lower === 'github-actions[bot]';
}
function isPushSoonAfterLatestFinal(comments, headPushedAt) {
if (!headPushedAt) return false;
const finals = comments.filter(isFinalClaudeTriggerComment);
if (finals.length === 0) return false;
const latestFinal = finals.reduce((a, b) =>
new Date(a.created_at) > new Date(b.created_at) ? a : b
);
const finalTime = new Date(latestFinal.created_at).getTime();
const headTime = new Date(headPushedAt).getTime();
if (headTime <= finalTime) return false;
return headTime - finalTime < PUSH_AFTER_FINAL_DEBOUNCE_MS;
}
/** Returns { skip: true, reason } when stale-FINAL recovery should not post. */
function shouldSkipStaleFinalRecovery(comments, headPushedAt, headPusherLogin = null) {
if (!isStaleFinalAfterPush(comments, headPushedAt)) {
return { skip: true, reason: 'not stale' };
}
if (headPusherLogin && isClaudeAutomationLogin(headPusherLogin)) {
return { skip: true, reason: 'head pushed by Claude automation' };
}
if (needsFinalReReviewAfterConflictRebase(comments, headPushedAt)) {
return { skip: false, reason: '' };
}
if (isPushSoonAfterLatestFinal(comments, headPushedAt)) {
return {
skip: true,
reason: 'head pushed soon after FINAL (wait for CI / batched fixes)',
};
}
const windowStart = Date.now() - STALE_FINAL_RECOVERY_WINDOW_MS;
const finalsInWindow = countFinalTriggersSince(comments, windowStart);
if (finalsInWindow >= STALE_FINAL_MAX_PER_WINDOW) {
return {
skip: true,
reason: `stale-FINAL capped (${finalsInWindow} FINAL triggers in last hour)`,
};
}
return { skip: false, reason: '' };
}
function finalClaudeCompletedOnSha(comments, headPushedAt) {
if (!hasFinalClaudeReviewTrigger(comments)) return false;
if (isStaleFinalAfterPush(comments, headPushedAt)) return false;
return true;
}
const FINAL_CLAUDE_REVIEW_BODY =
`@claude You are the FINAL architecture reviewer. If the branch is under ${config.repoFullName} (not a fork), you are able to make modifications to this branch and push directly. ${config.finalClaudeScope} Read ALL comments above from Gemini, Qodo, CodeRabbit, and Copilot carefully before responding.\n\n**Phase 1: Review per AGENTS.md**\n1. Protocol-driven: check heavy implementations vs core SDK\n2. Backward compatible: ensure zero feature regressions\n3. Performance: no hot-path regressions (lazy imports, import-time <200ms)\n${config.finalClaudeProductValue}\n\n**Phase 2: FIX Valid Issues**\n5. For any VALID bugs or architectural flaws found by Gemini, CodeRabbit, Qodo, Copilot, or any other reviewer: implement the fix\n6. Push all code fixes directly to THIS branch (do NOT create a new PR)\n7. Comment a summary of exact files modified and what you skipped\n\n**Phase 3: Final Verdict**\n8. If all issues are resolved, approve the PR / close the Issue\n9. If blocking issues remain, request changes / leave clear action items`;
async function getMergeState(github, owner, repo, prNumber) {
const query = `
query($owner: String!, $repo: String!, $number: Int!) {
repository(owner: $owner, name: $repo) {
pullRequest(number: $number) {
mergeStateStatus
maintainerCanModify
isDraft
headRefOid
headRef { repository { nameWithOwner } }
}
}
}
`;
for (let attempt = 0; attempt < 3; attempt++) {
const result = await github.graphql(query, { owner, repo, number: prNumber });
const prGql = result.repository.pullRequest;
const status = (prGql?.mergeStateStatus || '').toUpperCase();
if (status && status !== 'UNKNOWN') {
return {
status,
isDraft: prGql.isDraft,
headRepo: prGql.headRef?.repository?.nameWithOwner,
headSha: prGql.headRefOid,
maintainerCanModify: prGql.maintainerCanModify === true,
};
}
if (attempt < 2) await sleep(10000);
}
const { data: pr } = await github.rest.pulls.get({ owner, repo, pull_number: prNumber });
return {
status: (pr.mergeable_state || '').toUpperCase(),
isDraft: pr.draft,
headRepo: pr.head.repo?.full_name,
headSha: pr.head.sha,
maintainerCanModify: pr.maintainer_can_modify === true,
};
}
async function listChecksOnSha(github, owner, repo, sha) {
const { data } = await github.rest.checks.listForRef({
owner,
repo,
ref: sha,
per_page: 100,
});
return (data.check_runs || []).filter((r) => r.head_sha === sha);
}
function listFailedChecksOnSha(runs) {
return (runs || []).filter((run) => {
if (run.status !== 'completed') return false;
return run.conclusion === 'failure';
});
}
async function allChecksGreenOnSha(github, owner, repo, sha, core) {
const runs = await listChecksOnSha(github, owner, repo, sha);
if (runs.length === 0) {
core?.info?.(`No check runs on ${sha.slice(0, 7)} — allowing (e.g. docs-only PR)`);
return true;
}
for (const run of runs) {
if (run.status !== 'completed') {
core?.info?.(`Check pending: ${run.name} (${run.status})`);
return false;
}
const ok =
['success', 'neutral', 'skipped'].includes(run.conclusion) ||
(run.conclusion === 'cancelled' && OPTIONAL_CANCELLED_CHECKS.has(run.name));
if (!ok) {
core?.info?.(`Check failed: ${run.name} (${run.conclusion})`);
return false;
}
}
return true;
}
function claudeRunBlocksPr(run, headRef) {
if (!run || run.event === 'issues') return false;
if (!headRef) return true;
return run.head_branch === headRef;
}
function hasBlockingClaudeRunForPr(runs, headRef) {
return (runs || []).some((r) => claudeRunBlocksPr(r, headRef));
}
async function hasInProgressClaudeAssistant(github, owner, repo, prNumber = null) {
try {
const { data } = await github.rest.actions.listWorkflowRuns({
owner,
repo,
workflow_id: 'claude.yml',
status: 'in_progress',
per_page: 20,
});
const runs = (data.workflow_runs || []).filter((r) => r.event !== 'issues');
if (runs.length === 0) return false;
if (prNumber == null) return runs.length > 0;
const { data: pr } = await github.rest.pulls.get({ owner, repo, pull_number: prNumber });
const headRef = pr.head.ref;
return hasBlockingClaudeRunForPr(runs, headRef);
} catch {
return false;
}
}
function isCiOnlyChange(files) {
if (!files.length) return false;
return files.every((f) => CI_ONLY_PATH_PREFIXES.some((p) => f.filename.startsWith(p)));
}
function isInternalPullRequestLink(link, owner, repo) {
if (link?.base?.repo?.full_name === `${owner}/${repo}`) return true;
const baseUrl = link?.base?.repo?.url || '';
return baseUrl.endsWith(`/repos/${owner}/${repo}`);
}
async function resolvePrNumberFromLinkedPullRequests(github, owner, repo, linked) {
const repoFull = `${owner}/${repo}`;
const internal = (linked || []).filter((l) => isInternalPullRequestLink(l, owner, repo));
for (const link of internal) {
if (!link.number) continue;
try {
const { data } = await github.rest.pulls.get({
owner,
repo,
pull_number: link.number,
});
if (data.state === 'open' && data.base?.repo?.full_name === repoFull) {
return data.number;
}
} catch (err) {
if (err.status !== 404) throw err;
}
}
return null;
}
async function resolvePrNumberFromHeadBranch(github, owner, repo, branch) {
if (!branch || branch === 'main' || branch === 'master') return null;
const { data } = await github.rest.pulls.list({
owner,
repo,
state: 'open',
head: `${owner}:${branch}`,
per_page: 1,
});
return data[0]?.number || null;
}
async function resolvePrNumberFromWorkflowRun(github, owner, repo, workflowRun) {
const fromLinked = await resolvePrNumberFromLinkedPullRequests(
github, owner, repo, workflowRun.pull_requests
);
if (fromLinked) return fromLinked;
const fromBranch = await resolvePrNumberFromHeadBranch(
github, owner, repo, workflowRun.head_branch
);
if (fromBranch) return fromBranch;
return null;
}
async function listPullFiles(github, owner, repo, prNumber) {
if (typeof github.paginate === 'function') {
return github.paginate(github.rest.pulls.listFiles, {
owner,
repo,
pull_number: prNumber,
per_page: 100,
});
}
const { data } = await github.rest.pulls.listFiles({
owner,
repo,
pull_number: prNumber,
per_page: 100,
});
return data;
}
function countNewAgentParams(patch) {
if (!patch) return 0;
return patch
.split('\n')
.filter((l) => l.startsWith('+') && !l.startsWith('+++'))
.filter((l) => {
const t = l.slice(1).trim();
if (!t || t.startsWith('#') || t.startsWith('"""') || t.startsWith("'''")) return false;
if (/^(def|class|@|return\b|if\b|elif\b|else\b|for\b|while\b)/.test(t)) return false;
if (/^\w+\s*[:=]/.test(t) && !t.startsWith('self.')) return true;
return false;
}).length;
}
function getAgentPyChangeFromFiles(files) {
const agentFile = files.find((f) => f.filename.endsWith('praisonaiagents/agent/agent.py'));
if (!agentFile) {
return { touched: false, additions: 0, newParams: 0 };
}
return {
touched: true,
additions: agentFile.additions || 0,
newParams: countNewAgentParams(agentFile.patch || ''),
};
}
async function getAgentPyChange(github, owner, repo, prNumber) {
const files = await listPullFiles(github, owner, repo, prNumber);
return getAgentPyChangeFromFiles(files);
}
function touchesSdk(files) {
return files.some((f) => SDK_PATH_PREFIXES.some((p) => f.filename.startsWith(p)));
}
function hasManualOnlyLabel(labels) {
return labels.some((l) => MANUAL_ONLY_LABELS.has(l));
}
function sensitivePathReasons(files, labels = []) {
if (labels.includes(WORKFLOW_ONLY_LABEL) && isCiOnlyChange(files)) {
return [];
}
const reasons = [];
for (const f of files) {
if (SENSITIVE_PATH_PATTERNS.some((p) => p.test(f.filename))) {
reasons.push(`sensitive path: ${f.filename}`);
break;
}
}
return reasons;
}
function prSizeReasons(files) {
const reasons = [];
const totalAdditions = files.reduce((sum, f) => sum + (f.additions || 0), 0);
if (totalAdditions > PR_MAX_AUTO_ADDITIONS) {
reasons.push(`PR +${totalAdditions} lines (>${PR_MAX_AUTO_ADDITIONS}) requires manual review`);
}
if (files.length > PR_MAX_AUTO_FILES) {
reasons.push(`${files.length} files changed (>${PR_MAX_AUTO_FILES}) requires manual review`);
}
return reasons;
}
function missingTestsReason(files) {
const productAdds = files.filter(
(f) =>
SDK_PATH_PREFIXES.some((p) => f.filename.startsWith(p)) &&
f.filename.startsWith('src/praisonaiui/') &&
f.filename.endsWith('.py') &&
!f.filename.endsWith('__init__.py') &&
(f.additions || 0) > 0
);
if (productAdds.length === 0) return null;
const hasTestChange = files.some(
(f) => /\/tests?\//.test(f.filename) || /test_.*\.py$/.test(f.filename) || /_test\.py$/.test(f.filename)
);
if (!hasTestChange) return 'Product code added without test file changes — requires manual review';
return null;
}
function secretScanReasons(files) {
for (const f of files) {
if (/\/tests?\//.test(f.filename) || /test_.*\.py$/.test(f.filename)) continue;
const patch = f.patch || '';
if (!patch) continue;
for (const pattern of SECRET_PATTERNS) {
if (pattern.test(patch)) {
return [`possible secret in diff (${f.filename}) — requires manual review`];
}
}
}
return [];
}
async function sdkTestChecksReason(github, owner, repo, sha, files, core) {
if (!touchesSdk(files)) return null;
const { data } = await github.rest.checks.listForRef({
owner,
repo,
ref: sha,
per_page: 100,
});
const runs = (data.check_runs || []).filter((r) => r.head_sha === sha);
if (runs.length === 0) {
return 'SDK code changed but no CI checks on HEAD';
}
const testRuns = runs.filter((r) => REQUIRED_SDK_CHECK_PATTERNS.some((p) => p.test(r.name || '')));
if (testRuns.length === 0) {
return 'SDK code changed but no test check runs on HEAD';
}
core?.info?.(`SDK test checks on HEAD: ${testRuns.map((r) => r.name).join(', ')}`);
return null;
}
function manualReviewReasonForAgentPy(agentChange) {
if (!agentChange.touched) return null;
if (agentChange.additions > AGENT_PY_MAX_AUTO_LINES) {
return `agent.py +${agentChange.additions} lines (>${AGENT_PY_MAX_AUTO_LINES}) requires manual review`;
}
if (agentChange.newParams > 0) {
return `agent.py adds ${agentChange.newParams} new Agent param(s) — requires manual review`;
}
return null;
}
async function listAllComments(github, owner, repo, issueNumber) {
if (typeof github.paginate === 'function') {
return github.paginate(github.rest.issues.listComments, {
owner,
repo,
issue_number: issueNumber,
per_page: 100,
});
}
const { data } = await github.rest.issues.listComments({
owner,
repo,
issue_number: issueNumber,
per_page: 100,
});
return data;
}
async function getHeadCommitDate(github, owner, repo, prNumber) {
try {
let commits;
if (typeof github.paginate === 'function') {
commits = await github.paginate(github.rest.pulls.listCommits, {
owner,
repo,
pull_number: prNumber,
per_page: 100,
});
} else {
const { data } = await github.rest.pulls.listCommits({
owner,
repo,
pull_number: prNumber,
per_page: 100,
});
commits = data;
}
const last = commits[commits.length - 1];
return last?.commit?.committer?.date || last?.commit?.author?.date || null;
} catch {
return null;
}
}
async function loadPrContext(github, owner, repo, prNumber) {
const { data: pr } = await github.rest.pulls.get({ owner, repo, pull_number: prNumber });
const { data: issue } = await github.rest.issues.get({ owner, repo, issue_number: prNumber });
const comments = await listAllComments(github, owner, repo, prNumber);
const { data: reviews } = await github.rest.pulls.listReviews({
owner,
repo,
pull_number: prNumber,
per_page: 100,
});
const mergeState = await getMergeState(github, owner, repo, prNumber);
const headSha = mergeState.headSha || pr.head.sha;
const headCommitDate = await getHeadCommitDate(github, owner, repo, prNumber);
const headPushedAt = headCommitDate || pr.updated_at;
return {
pr,
issue,
comments,
reviews,
mergeState,
headSha,
headPushedAt,
labels: issue.labels.map((l) => l.name),
baseRepo: `${owner}/${repo}`,
};
}
async function evaluatePipelineQuiescent(github, owner, repo, prNumber, core, options = {}) {
const {
forMergeStep = false,
skipGlobalClaudeRunCheck = false,
skipRecentClaudeCooldown = false,
} = options;
const ctx = await loadPrContext(github, owner, repo, prNumber);
const reasons = [];
if (ctx.pr.draft) reasons.push('draft');
if (ctx.pr.state !== 'open') reasons.push('not open');
if (ctx.labels.includes('auto-merged-by-gate')) reasons.push('already merged by gate');
if (ctx.labels.includes('no-auto-merge')) reasons.push('no-auto-merge label');
if (ctx.labels.includes('claude-conflict-pending')) reasons.push('claude-conflict-pending');
if (!forMergeStep && ctx.labels.includes('claude-merge-gate-active')) {
reasons.push('claude-merge-gate-active');
}
const { status, headRepo, maintainerCanModify } = ctx.mergeState;
if (!ALLOWED_MERGE_STATES.has(status)) reasons.push(`mergeState=${status}`);
if (headRepo && headRepo !== ctx.baseRepo) {
reasons.push('fork PR');
}
if (hasRecentConflictComment(ctx.comments, ctx.headPushedAt)) {
reasons.push('recent merge-conflict @claude');
}
if (!skipRecentClaudeCooldown && hasRecentClaudeTrigger(ctx.comments, 35)) {
const verdictOnHead = findMergeGateVerdict(ctx.comments, null, ctx.headPushedAt) !== null;
if (!verdictOnHead) reasons.push('recent @claude within 35min');
}
if (!skipGlobalClaudeRunCheck && (await hasInProgressClaudeAssistant(github, owner, repo, prNumber))) {
reasons.push('claude.yml in progress');
}
const checksOk = await allChecksGreenOnSha(github, owner, repo, ctx.headSha, core);
if (!checksOk) reasons.push('CI not green on HEAD');
if (!finalClaudeCompletedOnSha(ctx.comments, ctx.headPushedAt)) {
if (!hasFinalClaudeReviewTrigger(ctx.comments)) {
reasons.push('no FINAL Claude review trigger');
} else if (isStaleFinalAfterPush(ctx.comments, ctx.headPushedAt)) {
reasons.push('stale FINAL after new commits (needs @claude re-review)');
} else {
reasons.push('FINAL Claude not complete on HEAD');
}
}
if (hasAnyChangesRequested(ctx.reviews)) reasons.push('CHANGES_REQUESTED review');
if (hasManualOnlyLabel(ctx.labels)) {
const manualLabel = ctx.labels.find((l) => MANUAL_ONLY_LABELS.has(l));
reasons.push(`manual-only label: ${manualLabel}`);
}
const pullFiles = await listPullFiles(github, owner, repo, prNumber);
if (config.agentPyChecks) {
const agentChange = getAgentPyChangeFromFiles(pullFiles);
const agentManual = manualReviewReasonForAgentPy(agentChange);
if (agentManual) reasons.push(agentManual);
}
reasons.push(...sensitivePathReasons(pullFiles, ctx.labels));
reasons.push(...prSizeReasons(pullFiles));
reasons.push(...secretScanReasons(pullFiles));
const testsReason = missingTestsReason(pullFiles);
if (testsReason) reasons.push(testsReason);
const sdkCheckReason = await sdkTestChecksReason(github, owner, repo, ctx.headSha, pullFiles, core);
if (sdkCheckReason) reasons.push(sdkCheckReason);
if (ctx.pr.mergeable === false) reasons.push('not mergeable');
const ready = reasons.length === 0;
return {
ready,
reasons,
headSha: ctx.headSha,
prNumber,
};
}
async function listPrNumbersForMergeGateScan(github, owner, repo, core) {
let mergeReadyIssues;
if (typeof github.paginate === 'function') {
mergeReadyIssues = await github.paginate(github.rest.issues.listForRepo, {
owner,
repo,
state: 'open',
labels: MERGE_READY_LABEL,
per_page: 100,
});
} else {
({ data: mergeReadyIssues } = await github.rest.issues.listForRepo({
owner,
repo,
state: 'open',
labels: MERGE_READY_LABEL,
per_page: 100,
}));
}
const mergeReady = mergeReadyIssues
.filter((issue) => issue.pull_request)
.map((issue) => issue.number)
.sort((a, b) => a - b);
if (mergeReady.length > 0) {
core?.info?.(
`Merge gate scan: ${mergeReady.length} ${MERGE_READY_LABEL} PR(s) (skipping unlabelled open PRs)`
);
return mergeReady;
}
core?.info?.(`No ${MERGE_READY_LABEL} PRs — scanning oldest open PRs`);
const prs =
typeof github.paginate === 'function'
? await github.paginate(github.rest.pulls.list, {
owner,
repo,
state: 'open',
per_page: 100,
sort: 'created',
direction: 'asc',
})
: (await github.rest.pulls.list({
owner,
repo,
state: 'open',
per_page: 100,
sort: 'created',
direction: 'asc',
})).data;
return prs.map((pr) => pr.number);
}
async function selectMergeGateCandidates(github, owner, repo, prNumbers, maxCandidates, core) {
const readyList = [];
const skipped = [];
for (const num of prNumbers) {
if (readyList.length >= maxCandidates) {
core?.info?.(`Found ${maxCandidates} candidate(s) — skipping remaining PR scans`);
break;
}
const result = await evaluatePipelineQuiescent(github, owner, repo, num, core);
if (result.ready) {
readyList.push({ pr_number: num, head_sha: result.headSha });
} else {
skipped.push({ pr: num, reasons: result.reasons });
}
}
readyList.sort((a, b) => a.pr_number - b.pr_number);
return {
candidates: readyList.slice(0, maxCandidates),
skipped,
};
}
const AUTOMATED_FALLBACK_MARKER = 'Automated fallback —';
function isAutomatedFallbackVerdict(body) {
return (body || '').includes(AUTOMATED_FALLBACK_MARKER);
}
function findMergeGateVerdict(comments, minCreatedAt = null, headPushedAt = null, options = {}) {
const { excludeAutomatedFallback = false } = options || {};
const minTime = minCreatedAt ? new Date(minCreatedAt).getTime() : 0;
const headTime = headPushedAt ? new Date(headPushedAt).getTime() - 60000 : 0;
const gateComments = comments
.filter((c) => {
if (!(c.body || '').includes('MERGE_GATE_VERDICT:')) return false;
if (excludeAutomatedFallback && isAutomatedFallbackVerdict(c.body)) return false;
const created = new Date(c.created_at).getTime();
if (minTime && created < minTime) return false;
if (headTime && created < headTime) return false;
return true;
})
.sort((a, b) => new Date(b.created_at) - new Date(a.created_at));
if (gateComments.length === 0) return null;
const body = gateComments[0].body || '';
if (body.includes('MERGE_GATE_VERDICT: APPROVE')) return 'APPROVE';
if (body.includes('MERGE_GATE_VERDICT: BLOCK')) return 'BLOCK';
return null;
}
module.exports = {
CLAUDE_TRIGGER_LOGINS,
AUTO_ACTORS,
isFinalClaudeTriggerComment,
isClaudeTriggerNoise,
isClaudeFinalReplyComment,
hasRecentClaudeTrigger,
hasRecentConflictComment,
isConflictRebaseTriggerComment,
isConflictRebaseCompletionComment,
needsFinalReReviewAfterConflictRebase,
conflictRebaseQuiescent,
hasHumanChangesRequested,
hasAnyChangesRequested,
hasFinalClaudeReviewTrigger,
finalClaudeCompletedOnSha,
getMergeState,
OPTIONAL_CANCELLED_CHECKS,
listChecksOnSha,
listFailedChecksOnSha,
allChecksGreenOnSha,
hasInProgressClaudeAssistant,
claudeRunBlocksPr,
hasBlockingClaudeRunForPr,
isCiOnlyChange,
isInternalPullRequestLink,
resolvePrNumberFromLinkedPullRequests,
resolvePrNumberFromHeadBranch,
resolvePrNumberFromWorkflowRun,
WORKFLOW_ONLY_LABEL,
getAgentPyChange,
getAgentPyChangeFromFiles,
manualReviewReasonForAgentPy,
countNewAgentParams,
listPullFiles,
touchesSdk,
hasManualOnlyLabel,
sensitivePathReasons,
prSizeReasons,
missingTestsReason,
secretScanReasons,
sdkTestChecksReason,
listAllComments,
getHeadCommitDate,
isStaleFinalAfterPush,
needsStaleFinalRecovery,
shouldSkipFinalRecovery,
shouldSkipStaleFinalRecovery,
isClaudeAutomationLogin,
isPushSoonAfterLatestFinal,
countFinalTriggersSince,
STALE_FINAL_RECOVERY_WINDOW_MS,
STALE_FINAL_MAX_PER_WINDOW,
FINAL_CLAUDE_REVIEW_BODY,
loadPrContext,
evaluatePipelineQuiescent,
MERGE_READY_LABEL,
listPrNumbersForMergeGateScan,
selectMergeGateCandidates,
isAutomatedFallbackVerdict,
AUTOMATED_FALLBACK_MARKER,
findMergeGateVerdict,
};
@@ -1,82 +0,0 @@
#!/usr/bin/env node
/**
* Run: node .github/scripts/pipeline-status-selftest.js
*/
const ps = require('./pipeline-status.js');
const mg = require('./merge-gate.js');
const config = require('./gate-config.js');
let failed = 0;
function assert(name, cond) {
if (!cond) {
console.error('FAIL:', name);
failed++;
} else {
console.log('ok:', name);
}
}
const kickComments = [
{ user: { login: 'MervinPraison' }, body: '@coderabbitai review' },
{ user: { login: 'MervinPraison' }, body: '/review' },
];
const finalComment = {
user: { login: 'MervinPraison' },
body: '@claude You are the FINAL architecture reviewer.',
created_at: '2026-06-26T10:00:00Z',
};
assert(
'reviews pending when not kicked',
ps.deriveStage([], { ready: false, reasons: ['no FINAL Claude review trigger'] })
=== 'pipeline/reviews-pending'
);
assert(
'final pending after kick',
ps.deriveStage(kickComments, { ready: false, reasons: ['no FINAL Claude review trigger'] })
=== 'pipeline/final-claude-pending'
);
assert(
'awaiting merge gate after bot review comment',
ps.deriveStage(
[
{ user: { login: 'coderabbitai[bot]' }, body: 'review summary' },
finalComment,
],
{ ready: false, reasons: ['recent @claude within 35min'] }
) === 'pipeline/awaiting-merge-gate'
);
assert('review bot logins configured', config.reviewBotLogins.includes('coderabbitai[bot]'));
assert(
'merge ready',
ps.deriveStage([...kickComments, finalComment], { ready: true, reasons: [] })
=== 'pipeline/merge-ready'
);
const blockers = ps.deriveBlockerLabels({
ready: false,
reasons: ['CI not green on HEAD', 'SDK code added without test file changes — requires manual review'],
});
assert('maps ci blocker', blockers.includes('pipeline/blocked:ci'));
assert('maps manual blocker', blockers.includes('pipeline/blocked:manual-review'));
assert(
'internal link matches upstream base',
mg.isInternalPullRequestLink(
{ base: { repo: { full_name: 'MervinPraison/PraisonAI' } } },
'MervinPraison',
'PraisonAI'
)
);
assert(
'fork sync link rejected',
!mg.isInternalPullRequestLink(
{ number: 21, base: { repo: { full_name: 'Milkmange/PraisonAI' } } },
'MervinPraison',
'PraisonAI'
)
);
process.exit(failed ? 1 : 0);
-264
View File
@@ -1,264 +0,0 @@
/**
* Sync pipeline stage/blocker labels on open PRs.
* @see .github/workflows/pipeline-status-sync.yml
*/
const mergeGate = require('./merge-gate.js');
const chain = require('./bot-pr-review-chain.js');
const ciFix = require('./ci-failure-claude.js');
const config = require('./gate-config.js');
const PIPELINE_PREFIX = 'pipeline/';
const STAGE_LABELS = [
'pipeline/reviews-pending',
'pipeline/final-claude-pending',
'pipeline/awaiting-merge-gate',
'pipeline/merge-ready',
'pipeline/merged',
];
const BLOCKER_LABELS = [
'pipeline/blocked:ci',
'pipeline/blocked:conflict',
'pipeline/blocked:manual-review',
'pipeline/blocked:cooldown',
'pipeline/blocked:stale-final',
'pipeline/blocked:no-final',
];
const ALL_PIPELINE_LABELS = [...STAGE_LABELS, ...BLOCKER_LABELS];
const LABEL_SPECS = [
{ name: 'pipeline/reviews-pending', color: 'fbca04', description: 'Waiting for CodeRabbit/Qodo/Copilot reviews' },
{ name: 'pipeline/final-claude-pending', color: 'd4c5f9', description: 'Reviews done; waiting for FINAL @claude' },
{ name: 'pipeline/awaiting-merge-gate', color: 'c5def5', description: 'FINAL done; waiting for merge gate / CI' },
{ name: 'pipeline/merge-ready', color: '0e8a16', description: 'Eligible for merge gate auto-merge' },
{ name: 'pipeline/merged', color: '6f42c1', description: 'Merged via pipeline' },
{ name: 'pipeline/blocked:ci', color: 'd93f0b', description: 'Blocked: CI not green on HEAD' },
{ name: 'pipeline/blocked:conflict', color: 'b60205', description: 'Blocked: merge conflict or rebase pending' },
{ name: 'pipeline/blocked:manual-review', color: 'e99695', description: 'Blocked: requires manual review' },
{ name: 'pipeline/blocked:cooldown', color: 'fef2c0', description: 'Blocked: post-push or @claude cooldown' },
{ name: 'pipeline/blocked:stale-final', color: 'f9d0c4', description: 'Blocked: FINAL stale after new commits' },
{ name: 'pipeline/blocked:no-final', color: 'ededed', description: 'Blocked: no FINAL @claude trigger yet' },
];
function reviewChainStarted(comments) {
if (chain.chainKickPosted(comments)) return true;
return comments.some((c) => config.reviewBotLogins.includes(c.user?.login));
}
function deriveStage(comments, evalResult) {
if (evalResult.ready) return 'pipeline/merge-ready';
if (!reviewChainStarted(comments)) return 'pipeline/reviews-pending';
if (!mergeGate.hasFinalClaudeReviewTrigger(comments)) return 'pipeline/final-claude-pending';
return 'pipeline/awaiting-merge-gate';
}
function reasonToBlockerLabel(reason) {
const r = (reason || '').toLowerCase();
if (r.includes('ci not green') || r.includes('sdk code changed but no ci')) return 'pipeline/blocked:ci';
if (
r.includes('conflict') ||
r.includes('mergestate=dirty') ||
r.includes('not mergeable')
) return 'pipeline/blocked:conflict';
if (
r.includes('manual') ||
r.includes('requires manual') ||
r.includes('sensitive path') ||
r.includes('no-auto-merge') ||
r.includes('manual-only label') ||
r.includes('without test') ||
r.includes('product code') ||
r.includes('files changed') ||
r.includes('possible secret')
) return 'pipeline/blocked:manual-review';
if (r.includes('recent @claude') || r.includes('post-push buffer')) return 'pipeline/blocked:cooldown';
if (r.includes('stale final')) return 'pipeline/blocked:stale-final';
if (r.includes('no final claude')) return 'pipeline/blocked:no-final';
if (r.includes('final claude not complete')) return 'pipeline/blocked:stale-final';
if (r.includes('claude.yml in progress') || r.includes('claude-merge-gate-active')) {
return null;
}
return null;
}
function deriveBlockerLabels(evalResult) {
if (evalResult.ready) return [];
const labels = new Set();
for (const reason of evalResult.reasons || []) {
const mapped = reasonToBlockerLabel(reason);
if (mapped && mapped.startsWith('pipeline/blocked:')) labels.add(mapped);
}
return [...labels];
}
function computePipelineLabels(comments, evalResult) {
const stage = deriveStage(comments, evalResult);
const blockers = stage === 'pipeline/merge-ready' ? [] : deriveBlockerLabels(evalResult);
return { stage, blockers, all: [stage, ...blockers] };
}
async function ensurePipelineLabels(github, owner, repo, core) {
let existing;
if (typeof github.paginate === 'function') {
existing = await github.paginate(github.rest.issues.listLabelsForRepo, {
owner,
repo,
per_page: 100,
});
} else {
({ data: existing } = await github.rest.issues.listLabelsForRepo({
owner,
repo,
per_page: 100,
}));
}
const names = new Set(existing.map((l) => l.name));
for (const spec of LABEL_SPECS) {
if (names.has(spec.name)) continue;
try {
await github.rest.issues.createLabel({
owner,
repo,
name: spec.name,
color: spec.color,
description: spec.description,
});
core?.info?.(`Created label ${spec.name}`);
} catch (err) {
if (err.status !== 422) throw err;
core?.info?.(`Label ${spec.name} already exists`);
}
}
}
async function syncPipelineLabels(github, owner, repo, prNumber, core) {
const ctx = await mergeGate.loadPrContext(github, owner, repo, prNumber);
if (ctx.pr.state !== 'open') return { synced: false, reason: 'not_open' };
const evalResult = await mergeGate.evaluatePipelineQuiescent(
github, owner, repo, prNumber, core
);
const { stage, blockers, all } = computePipelineLabels(ctx.comments, evalResult);
const current = ctx.labels.filter((l) => l.startsWith(PIPELINE_PREFIX));
const desired = new Set(all);
const toRemove = current.filter((l) => !desired.has(l) && l !== 'pipeline/merged');
const toAdd = all.filter((l) => !current.includes(l));
for (const name of toRemove) {
try {
await github.rest.issues.removeLabel({ owner, repo, issue_number: prNumber, name });
} catch (err) {
if (err.status !== 404) throw err;
}
}
if (toAdd.length) {
await github.rest.issues.addLabels({
owner,
repo,
issue_number: prNumber,
labels: toAdd,
});
}
core?.info?.(`PR #${prNumber}: stage=${stage} blockers=[${blockers.join(', ')}]`);
const ciNotGreen = (evalResult.reasons || []).some((r) =>
r.toLowerCase().includes('ci not green')
);
if (ciNotGreen || blockers.includes('pipeline/blocked:ci')) {
await ciFix.maybeTriggerCiFixClaude(github, owner, repo, prNumber, core);
} else {
await ciFix.maybeClearCiFixLabel(
github, owner, repo, prNumber, ctx.labels, ctx.headSha, core
);
}
return {
synced: true,
stage,
blockers,
ready: evalResult.ready,
reasons: evalResult.reasons,
labels: ctx.labels,
createdAt: new Date(ctx.pr.created_at).getTime(),
};
}
async function dispatchMergeGateForOldestReady(github, owner, repo, readyCandidates, core) {
if (!readyCandidates.length) return 0;
readyCandidates.sort((a, b) => a.createdAt - b.createdAt);
for (const cand of readyCandidates) {
if ((cand.labels || []).includes('claude-merge-gate-active')) {
core?.info?.(`Skip dispatch PR #${cand.prNumber}: merge gate already active`);
continue;
}
await github.rest.repos.createDispatchEvent({
owner,
repo,
event_type: 'claude-merge-gate',
client_payload: { pr_number: cand.prNumber },
});
core?.info?.(`Dispatched merge gate for ready PR #${cand.prNumber}`);
return cand.prNumber;
}
return 0;
}
async function syncOpenPullRequests(github, owner, repo, options, core) {
const { maxPrs = 20, dispatchMergeGate = true } = options || {};
await ensurePipelineLabels(github, owner, repo, core);
let prs;
if (maxPrs <= 100) {
({ data: prs } = await github.rest.pulls.list({
owner,
repo,
state: 'open',
per_page: 100,
}));
} else {
prs = await github.paginate(github.rest.pulls.list, {
owner,
repo,
state: 'open',
per_page: 100,
});
}
let synced = 0;
const readyCandidates = [];
for (const pr of prs) {
if (synced >= maxPrs) break;
if (pr.draft) continue;
if (pr.head?.repo?.full_name && pr.head.repo.full_name !== `${owner}/${repo}`) continue;
const result = await syncPipelineLabels(github, owner, repo, pr.number, core);
if (result.ready) {
readyCandidates.push({
prNumber: pr.number,
createdAt: result.createdAt || new Date(pr.created_at).getTime(),
labels: result.labels || [],
});
}
synced += 1;
}
let dispatched = 0;
if (dispatchMergeGate && readyCandidates.length) {
dispatched = await dispatchMergeGateForOldestReady(
github, owner, repo, readyCandidates, core
);
}
core?.info?.(`Pipeline label sync complete (${synced} PR(s), dispatched=${dispatched || 'none'})`);
return synced;
}
module.exports = {
STAGE_LABELS,
BLOCKER_LABELS,
ALL_PIPELINE_LABELS,
deriveStage,
deriveBlockerLabels,
computePipelineLabels,
ensurePipelineLabels,
syncPipelineLabels,
syncOpenPullRequests,
dispatchMergeGateForOldestReady,
};
@@ -1,90 +0,0 @@
#!/usr/bin/env node
const chain = require('./pr-review-chain.js');
let failed = 0;
function assert(name, cond) {
if (!cond) {
console.error('FAIL:', name);
failed++;
} else {
console.log('ok:', name);
}
}
const coderabbit = {
user: { login: 'coderabbitai[bot]' },
body: '<!-- summarize by coderabbit.ai --> summary',
created_at: '2026-07-02T07:16:00Z',
};
const greptile = {
user: { login: 'greptile-apps[bot]' },
body: '<h3>Greptile Summary</h3><p>Looks good</p>',
created_at: '2026-07-02T07:19:00Z',
};
const copilotKick = {
user: { login: 'MervinPraison' },
body: '@copilot Do a thorough review',
created_at: '2026-07-02T07:20:00Z',
};
const copilotReply = {
user: { login: 'copilot-swe-agent' },
body: 'Copilot review here',
created_at: '2026-07-02T07:22:00Z',
};
assert('prior not ready without greptile', !chain.priorReviewersReady([coderabbit]).ready);
assert('prior ready with coderabbit + greptile', chain.priorReviewersReady([coderabbit, greptile]).ready);
assert('copilot not ready before trigger', !chain.copilotReviewReady([coderabbit, greptile]).ready);
assert(
'copilot ready after reply',
chain.copilotReviewReady([coderabbit, greptile, copilotKick, copilotReply]).ready
);
assert(
'claude not ready before copilot',
!chain.claudeFinalReady([coderabbit, greptile]).ready
);
assert(
'claude ready after full chain',
chain.claudeFinalReady([coderabbit, greptile, copilotKick, copilotReply]).ready
);
assert(
'claude timeout fallback when copilot triggered but silent',
chain.claudeFinalReady([coderabbit, greptile, copilotKick], [], { allowCopilotTimeout: true }).ready
);
assert(
'no timeout fallback without copilot trigger',
!chain.claudeFinalReady([coderabbit, greptile], [], { allowCopilotTimeout: true }).ready
);
assert(
'skipCopilot proceeds after prior reviewers',
chain.claudeFinalReady([coderabbit, greptile], [], { skipCopilot: true }).ready
);
const oldPr = new Date(Date.now() - 31 * 60 * 1000).toISOString();
assert(
'prior not ready without coderabbit before timeout',
!chain.priorReviewersReady([greptile], [], { prCreatedAt: new Date().toISOString(), requiredWaitMs: 30 * 60 * 1000 }).ready
);
assert(
'prior timeout when coderabbit silent after 30m',
chain.priorReviewersReady([greptile], [], { prCreatedAt: oldPr, requiredWaitMs: 30 * 60 * 1000 }).ready
);
assert(
'claude FINAL timeout when required reviewer silent and no prior FINAL',
chain.claudeFinalReady([greptile], [], { prCreatedAt: oldPr, requiredWaitMs: 30 * 60 * 1000 }).ready
);
assert(
'claude FINAL timeout auto-skips copilot without explicit skipCopilot',
chain.claudeFinalReady([greptile], [], { prCreatedAt: oldPr, requiredWaitMs: 30 * 60 * 1000 }).copilotSkipped
);
const finalClaude = {
user: { login: 'github-actions[bot]' },
body: '@claude You are the FINAL architecture reviewer for this PR.',
created_at: '2026-07-02T07:25:00Z',
};
assert(
'claude FINAL not re-posted when already triggered',
!chain.claudeFinalReady([greptile, finalClaude], [], { prCreatedAt: oldPr, requiredWaitMs: 0 }).ready
);
process.exit(failed ? 1 : 0);
-319
View File
@@ -1,319 +0,0 @@
/**
* PR review pipeline: CodeRabbit / Greptile (+ optional Qodo/Gemini) → Claude (FINAL).
* Copilot step optional — set REVIEW_CHAIN_SKIP_COPILOT=1 or skipCopilot: true to bypass.
* @see .github/workflows/auto-pr-comment.yml
*/
function isSkipCopilot(options = {}) {
if (options.skipCopilot === true) return true;
if (options.skipCopilot === false) return false;
return process.env.REVIEW_CHAIN_SKIP_COPILOT === '1';
}
const COPILOT_TRIGGER_LOGINS = new Set(['MervinPraison', 'github-actions[bot]']);
const CLAUDE_TRIGGER_LOGINS = new Set(['MervinPraison', 'github-actions[bot]']);
const REQUIRED_PRIOR = ['coderabbit', 'greptile'];
const OPTIONAL_PRIOR = ['qodo', 'gemini'];
const OPTIONAL_PRIOR_WAIT_MS = 20 * 60 * 1000;
const REQUIRED_PRIOR_WAIT_MS = 30 * 60 * 1000;
function loginOf(item) {
return (item?.user?.login || '').toLowerCase();
}
function bodyOf(item) {
return item?.body || '';
}
function hasCoderabbitSummary(comments) {
return comments.some(
(c) =>
loginOf(c).includes('coderabbit') &&
bodyOf(c).toLowerCase().includes('summarize by coderabbit')
);
}
function hasGreptileReview(comments) {
return comments.some((c) => {
if (!loginOf(c).includes('greptile')) return false;
const body = bodyOf(c);
return body.includes('Greptile Summary') || body.includes('<h3>Greptile Summary</h3>') || body.length > 150;
});
}
function hasQodoReview(comments, reviews = []) {
if (comments.some((c) => loginOf(c).includes('qodo'))) return true;
return reviews.some((r) => loginOf(r).includes('qodo'));
}
function hasGeminiReview(comments, reviews = []) {
if (reviews.some((r) => loginOf(r).includes('gemini'))) return true;
return comments.some((c) => {
if (loginOf(c).includes('gemini')) return true;
const body = bodyOf(c);
return (
body.includes('Review completed by Gemini CLI') ||
body.includes('The fix is ready for review. Please test')
);
});
}
const PRIOR_CHECKS = {
coderabbit: (comments) => hasCoderabbitSummary(comments),
greptile: (comments) => hasGreptileReview(comments),
qodo: (comments, reviews) => hasQodoReview(comments, reviews),
gemini: (comments, reviews) => hasGeminiReview(comments, reviews),
};
function priorReviewerStatus(comments, reviews = []) {
const status = {};
for (const [id, check] of Object.entries(PRIOR_CHECKS)) {
status[id] = check(comments, reviews);
}
return status;
}
function priorReviewersReady(comments, reviews = [], options = {}) {
const waitMs = options.optionalWaitMs ?? OPTIONAL_PRIOR_WAIT_MS;
const requiredWaitMs = options.requiredWaitMs ?? REQUIRED_PRIOR_WAIT_MS;
const prCreatedAt = options.prCreatedAt || null;
const status = priorReviewerStatus(comments, reviews);
const missingRequired = REQUIRED_PRIOR.filter((id) => !status[id]);
if (missingRequired.length) {
if (prCreatedAt) {
const age = Date.now() - new Date(prCreatedAt).getTime();
if (age >= requiredWaitMs) {
return {
ready: true,
reason: `required reviewer timeout (${missingRequired.join(', ')} silent after ${Math.round(requiredWaitMs / 60000)}m)`,
status,
requiredTimeout: true,
};
}
}
return { ready: false, reason: `waiting for ${missingRequired.join(', ')}`, status };
}
const missingOptional = OPTIONAL_PRIOR.filter((id) => !status[id]);
if (missingOptional.length && prCreatedAt) {
const age = Date.now() - new Date(prCreatedAt).getTime();
if (age < waitMs) {
return {
ready: false,
reason: `waiting for optional ${missingOptional.join(', ')} (${Math.round((waitMs - age) / 60000)}m left)`,
status,
};
}
}
return { ready: true, reason: '', status };
}
function copilotTriggerComment(comments) {
return comments.find(
(c) =>
bodyOf(c).includes('@copilot') &&
COPILOT_TRIGGER_LOGINS.has(c.user?.login) &&
!bodyOf(c).toLowerCase().includes('@claude')
);
}
function copilotTriggered(comments) {
return Boolean(copilotTriggerComment(comments));
}
function copilotReviewReady(comments, reviews = []) {
const trigger = copilotTriggerComment(comments);
if (!trigger) {
return { ready: false, reason: 'copilot not triggered yet' };
}
const triggerTime = new Date(trigger.created_at).getTime();
const copilotComment = comments.some((c) => {
if (c === trigger) return false;
if (new Date(c.created_at).getTime() < triggerTime) return false;
const login = loginOf(c);
return (
login.includes('copilot') ||
c.user?.login === 'Copilot' ||
c.user?.login === 'copilot-swe-agent'
);
});
const copilotReview = reviews.some((r) => {
if (new Date(r.submitted_at).getTime() < triggerTime) return false;
return loginOf(r).includes('copilot');
});
if (copilotComment || copilotReview) {
return { ready: true, reason: '' };
}
return { ready: false, reason: 'awaiting copilot review' };
}
function isFinalClaudeTriggerComment(c) {
const body = bodyOf(c).toLowerCase();
if (!CLAUDE_TRIGGER_LOGINS.has(c.user?.login)) return false;
if (!body.includes('@claude')) return false;
if (body.includes('merge conflict')) return false;
return body.includes('final architecture reviewer') || body.includes('lead engineer');
}
function claudeFinalAlreadyTriggered(comments) {
return comments.some(isFinalClaudeTriggerComment);
}
function claudeFinalReady(comments, reviews = [], options = {}) {
if (!options.allowStaleRepost && claudeFinalAlreadyTriggered(comments)) {
return { ready: false, reason: 'claude FINAL already triggered', already: true };
}
const prior = priorReviewersReady(comments, reviews, options);
if (!prior.ready) {
return { ready: false, reason: prior.reason };
}
if (isSkipCopilot(options) || prior.requiredTimeout) {
return {
ready: true,
reason: prior.requiredTimeout ? prior.reason : 'copilot skipped',
copilotSkipped: true,
requiredTimeout: prior.requiredTimeout,
};
}
const copilot = copilotReviewReady(comments, reviews);
if (copilot.ready) {
return { ready: true, reason: '' };
}
if (options.allowCopilotTimeout && copilotTriggered(comments)) {
return { ready: true, reason: 'copilot timeout fallback', copilotSkipped: true };
}
return { ready: false, reason: copilot.reason };
}
/** Post @copilot when prior reviewers are ready, then @claude FINAL when Copilot done (or timeout). */
async function advanceReviewChain(github, owner, repo, prNumber, finalBody, core, options = {}) {
const { data: pr } = await github.rest.pulls.get({ owner, repo, pull_number: prNumber });
const chainOpts = {
prCreatedAt: pr.created_at,
optionalWaitMs: options.optionalWaitMs ?? 0,
};
const copilot = options.skipCopilot
? { triggered: false, reason: 'skipped' }
: await maybeTriggerCopilot(github, owner, repo, prNumber, core, chainOpts);
const claude = await maybeTriggerClaudeFinal(
github, owner, repo, prNumber, finalBody, core,
{ ...options, ...chainOpts }
);
return { copilot, claude };
}
async function maybeTriggerClaudeFinal(github, owner, repo, prNumber, finalBody, core, options = {}) {
const { data: pr } = await github.rest.pulls.get({ owner, repo, pull_number: prNumber });
const { comments, reviews } = await listCommentsAndReviews(github, owner, repo, prNumber);
const gate = claudeFinalReady(comments, reviews, {
prCreatedAt: pr.created_at,
optionalWaitMs: options.optionalWaitMs,
allowCopilotTimeout: options.allowCopilotTimeout !== false,
allowStaleRepost: options.allowStaleRepost === true,
skipCopilot: options.skipCopilot,
});
if (!gate.ready) {
core?.info?.(`PR #${prNumber}: skip Claude FINAL — ${gate.reason}`);
return { posted: false, reason: gate.reason };
}
await github.rest.issues.createComment({
owner,
repo,
issue_number: prNumber,
body: finalBody,
});
const note = gate.copilotSkipped ? ' (Copilot timeout fallback)' : '';
core?.info?.(`Posted Claude FINAL on PR #${prNumber}${note}`);
return { posted: true, reason: '' };
}
const COPILOT_REVIEW_BODY =
'@copilot Do a thorough review of this PR. Read ALL existing reviewer comments above from Qodo, CodeRabbit, Gemini, and Greptile first — incorporate their findings.\n\nReview areas:\n1. **Bloat check**: Are changes minimal and focused? Any unnecessary code or scope creep?\n2. **Security**: Any hardcoded secrets, unsafe eval/exec, missing input validation?\n3. **Performance**: Any module-level heavy imports? Hot-path regressions?\n4. **Tests**: Are tests included? Do they cover the changes adequately?\n5. **Backward compat**: Any public API changes without deprecation?\n6. **Code quality**: DRY violations, naming conventions, error handling?\n7. **Address reviewer feedback**: If Qodo, CodeRabbit, Gemini, or Greptile flagged valid issues, include them in your review\n8. Suggest specific improvements with code examples where possible';
async function listCommentsAndReviews(github, owner, repo, prNumber) {
let comments;
if (typeof github.paginate === 'function') {
comments = await github.paginate(github.rest.issues.listComments, {
owner,
repo,
issue_number: prNumber,
per_page: 100,
});
} else {
const { data } = await github.rest.issues.listComments({
owner,
repo,
issue_number: prNumber,
per_page: 100,
});
comments = data;
}
const { data: reviews } = await github.rest.pulls.listReviews({
owner,
repo,
pull_number: prNumber,
per_page: 100,
});
return { comments, reviews };
}
async function maybeTriggerCopilot(github, owner, repo, prNumber, core, options = {}) {
const { comments, reviews } = await listCommentsAndReviews(github, owner, repo, prNumber);
if (copilotTriggered(comments)) {
core?.info?.(`Copilot already triggered on PR #${prNumber}`);
return { triggered: false, reason: 'already_triggered' };
}
const prior = priorReviewersReady(comments, reviews, options);
if (!prior.ready) {
core?.info?.(`PR #${prNumber}: not ready for Copilot — ${prior.reason}`);
return { triggered: false, reason: prior.reason };
}
await github.rest.issues.createComment({
owner,
repo,
issue_number: prNumber,
body: COPILOT_REVIEW_BODY,
});
core?.info?.(`Posted @copilot on PR #${prNumber} after prior reviewers`);
return { triggered: true };
}
async function pollCopilotResponse(github, owner, repo, prNumber, options = {}) {
const maxAttempts = options.maxAttempts ?? 20;
const delayMs = options.delayMs ?? 30000;
for (let i = 0; i < maxAttempts; i += 1) {
const { comments, reviews } = await listCommentsAndReviews(github, owner, repo, prNumber);
const copilot = copilotReviewReady(comments, reviews);
if (copilot.ready) {
return { ready: true, reason: '' };
}
if (i < maxAttempts - 1) {
await new Promise((resolve) => setTimeout(resolve, delayMs));
}
}
return { ready: false, reason: 'copilot timeout' };
}
module.exports = {
isSkipCopilot,
REQUIRED_PRIOR,
OPTIONAL_PRIOR,
REQUIRED_PRIOR_WAIT_MS,
COPILOT_REVIEW_BODY,
priorReviewerStatus,
priorReviewersReady,
copilotTriggered,
copilotReviewReady,
claudeFinalReady,
claudeFinalAlreadyTriggered,
isFinalClaudeTriggerComment,
maybeTriggerCopilot,
maybeTriggerClaudeFinal,
advanceReviewChain,
pollCopilotResponse,
hasGeminiReview,
listCommentsAndReviews,
};
-32
View File
@@ -1,32 +0,0 @@
#!/usr/bin/env node
/**
* Run: node .github/scripts/release-gate-selftest.js
*/
const rg = require('./release-gate.js');
const config = require('./gate-config.js');
let failed = 0;
function assert(name, cond) {
if (!cond) {
console.error('FAIL:', name);
failed += 1;
} else {
console.log('ok:', name);
}
}
assert('package name is aiui', config.pypiPackageName === 'aiui');
assert('bumpPatch works', rg.bumpPatch('0.3.127') === '0.3.128');
try {
const versions = rg.readVersionsFromTree(process.cwd());
assert('reads current version', /^\d+\.\d+\.\d+$/.test(versions.current));
assert('computes target patch', versions.target === rg.bumpPatch(versions.current));
} catch (err) {
console.error('FAIL: readVersionsFromTree', err.message);
failed += 1;
}
assert('PACKAGE_PATHS includes pyproject', rg.PACKAGE_PATHS.includes('pyproject.toml'));
process.exit(failed ? 1 : 0);
-205
View File
@@ -1,205 +0,0 @@
/**
* Release gate preflight — path changes, CI SHA, dedupe, PyPI version checks (PraisonAIUI).
*/
const https = require('https');
const config = require('./gate-config.js');
const PACKAGE_PATHS = config.packagePaths;
const ACTIVE_RELEASE_STATUSES = new Set([
'queued', 'in_progress', 'waiting', 'pending', 'requested',
]);
function bumpPatch(version) {
const parts = version.split('.');
if (parts.length !== 3) throw new Error(`Invalid version: ${version}`);
return `${parts[0]}.${parts[1]}.${Number(parts[2]) + 1}`;
}
function readVersionsFromTree(root = '.') {
const fs = require('fs');
const path = require('path');
const toml = fs.readFileSync(path.join(root, 'pyproject.toml'), 'utf8');
const match = toml.match(/^version\s*=\s*"([^"]+)"/m);
if (!match) throw new Error('Could not read aiui version from pyproject.toml');
const current = match[1];
const target = bumpPatch(current);
return { current, target, packageName: config.pypiPackageName };
}
function pypiVersionExists(packageName, version) {
return new Promise((resolve) => {
const req = https.get(
`https://pypi.org/pypi/${packageName}/${version}/json`,
{ timeout: 15000 },
(res) => {
res.resume();
resolve(res.statusCode === 200);
}
);
req.on('error', () => resolve(false));
req.on('timeout', () => {
req.destroy();
resolve(false);
});
});
}
async function hasActiveReleaseRun(github, owner, repo) {
const runs = await github.rest.actions.listWorkflowRuns({
owner,
repo,
workflow_id: 'pypi-release.yml',
per_page: 20,
});
return runs.data.workflow_runs.some(
(r) => ACTIVE_RELEASE_STATUSES.has(r.status) && !r.conclusion
);
}
function utcDayStart(now = new Date()) {
return new Date(Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), now.getUTCDate()));
}
async function hasSuccessfulReleaseToday(github, owner, repo, now = new Date()) {
const dayStart = utcDayStart(now);
const runs = await github.rest.actions.listWorkflowRuns({
owner,
repo,
workflow_id: 'pypi-release.yml',
status: 'completed',
per_page: 30,
});
return runs.data.workflow_runs.some(
(r) => r.conclusion === 'success' && new Date(r.created_at) >= dayStart
);
}
async function lastGreenCiSha(github, owner, repo) {
const runs = await github.rest.actions.listWorkflowRuns({
owner,
repo,
workflow_id: config.ciWorkflowFile,
branch: 'main',
status: 'completed',
per_page: 30,
});
const hit = runs.data.workflow_runs.find((r) => r.conclusion === 'success');
return hit ? hit.head_sha : '';
}
async function evaluateReleasePreflight(github, owner, repo, options, core) {
const {
headSha,
isCiTrigger = false,
bump = 'patch',
} = options;
const reasons = [];
const out = {
ready: false,
reasons,
headSha: headSha || '',
lastTag: '',
targetVersion: '',
packageName: config.pypiPackageName,
};
if (bump !== 'patch') {
reasons.push('only patch auto-release supported');
return out;
}
if (await hasActiveReleaseRun(github, owner, repo)) {
reasons.push('PyPI Release already in progress or awaiting approval');
return out;
}
const referenceTime = options.now instanceof Date ? options.now : new Date();
if (await hasSuccessfulReleaseToday(github, owner, repo, referenceTime)) {
const day = referenceTime.toISOString().slice(0, 10);
reasons.push(`already released today (UTC ${day}); max one patch release per day`);
return out;
}
let versions;
try {
versions = readVersionsFromTree();
} catch (err) {
reasons.push(err.message);
return out;
}
out.targetVersion = versions.target;
const onPypi = await pypiVersionExists(config.pypiPackageName, versions.target);
if (onPypi) {
reasons.push(`already published: ${config.pypiPackageName}==${versions.target}`);
return out;
}
const { execSync } = require('child_process');
let lastTag = '';
try {
lastTag = execSync('git describe --tags --match "v*" --abbrev=0', { encoding: 'utf8' }).trim();
} catch {
reasons.push('no v* tag found');
return out;
}
out.lastTag = lastTag;
let changed = '';
try {
changed = execSync(
`git diff --name-only ${lastTag} HEAD -- ${PACKAGE_PATHS.join(' ')}`,
{ encoding: 'utf8' }
).trim();
} catch {
reasons.push('git diff failed');
return out;
}
if (!changed) {
reasons.push(`no changes in ${PACKAGE_PATHS.join(' or ')} since ${lastTag}`);
return out;
}
if (isCiTrigger) {
let mainSha = '';
try {
mainSha = execSync('git rev-parse origin/main', { encoding: 'utf8' }).trim();
} catch {
reasons.push('could not resolve origin/main');
return out;
}
if (headSha !== mainSha) {
reasons.push(`superseded: green SHA ${headSha.slice(0, 7)} != main ${mainSha.slice(0, 7)}`);
return out;
}
out.headSha = headSha;
} else {
const evalSha = execSync('git rev-parse HEAD', { encoding: 'utf8' }).trim();
out.headSha = evalSha;
const greenSha = await lastGreenCiSha(github, owner, repo);
if (greenSha !== evalSha) {
reasons.push(`CI not green on HEAD (last green: ${greenSha ? greenSha.slice(0, 7) : 'none'})`);
return out;
}
}
out.ready = true;
out.reasons = ['ready'];
if (core) core.info(`Release preflight passed for ${out.headSha || headSha}`);
return out;
}
module.exports = {
bumpPatch,
readVersionsFromTree,
pypiVersionExists,
hasSuccessfulReleaseToday,
utcDayStart,
evaluateReleasePreflight,
lastGreenCiSha,
ACTIVE_RELEASE_STATUSES,
PACKAGE_PATHS,
};
-95
View File
@@ -1,95 +0,0 @@
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'
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);
const hadClaude = labels.includes('claude');
if (!hadClaude) {
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 — dispatching Claude only.`);
}
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}`);
+59 -24
View File
@@ -1,33 +1,68 @@
name: Auto PR Comment
on:
issue_comment:
types: [created]
pull_request_review:
types: [submitted]
pull_request:
types: [opened, synchronize]
pull_request_target:
types: [opened, synchronize]
schedule:
- cron: '30 * * * *'
workflow_dispatch:
env:
REVIEW_CHAIN_SKIP_COPILOT: '1'
types: [opened, reopened, synchronize, ready_for_review]
permissions:
issues: write
contents: read
pull-requests: write
issues: write
jobs:
review-chain:
# Grant union of callee job permissions (repos with default_workflow_permissions: read
# reject reusable calls when the caller ceiling is lower than any callee job).
permissions:
issues: write
pull-requests: write
uses: MervinPraison/github-automation-template/.github/workflows/[email protected]
secrets: inherit
with:
hub_ref: v1.1.20
trigger-claude:
if: github.event.pull_request.draft == false
runs-on: ubuntu-latest
steps:
- name: Check whether Claude already triggered
id: check_claude
uses: actions/github-script@v7
with:
github-token: ${{ secrets.GITHUB_TOKEN }}
script: |
const owner = context.repo.owner;
const repo = context.repo.repo;
const issue_number = context.issue.number;
const { data: comments } = await github.rest.issues.listComments({
owner,
repo,
issue_number,
per_page: 100
});
const already = comments.some((c) =>
c.user?.login === 'github-actions[bot]' &&
(c.body || '').includes('@claude')
);
core.setOutput('already_triggered', already ? 'true' : 'false');
- name: Post Claude trigger prompt
if: steps.check_claude.outputs.already_triggered != 'true'
uses: actions/github-script@v7
with:
github-token: ${{ secrets.GITHUB_TOKEN }}
script: |
const body = `@claude You are the FINAL architecture reviewer. If the branch is under MervinPraison/PraisonAIUI (not a fork), you are able to make modifications to this branch and push directly. SCOPE: Focus ONLY on PraisonAIUI package and docs (src/praisonaiui, tests, docs). Read ALL comments above from reviewers carefully before responding.
**Phase 1: Review per AGENTS.md**
1. Keep changes minimal and focused
2. Backward compatible: ensure zero feature regressions
3. Performance: no hot-path regressions
**Phase 2: FIX Valid Issues**
4. For any VALID bugs or architectural flaws found by reviewers: implement the fix
5. Push all code fixes directly to THIS branch (do NOT create a new PR)
6. Comment a summary of exact files modified and what you skipped
**Phase 3: Final Verdict**
7. If all issues are resolved, approve the PR / close linked issue
8. If blocking issues remain, clearly list blockers with exact file paths`;
await github.rest.issues.createComment({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: context.issue.number,
body
});
-23
View File
@@ -1,23 +0,0 @@
name: Bot PR Recovery
on:
schedule:
- cron: '*/15 * * * *'
workflow_dispatch:
inputs:
pr_number:
description: 'Specific PR number to kick (optional)'
required: false
type: string
permissions:
pull-requests: write
issues: write
jobs:
recover:
uses: MervinPraison/github-automation-template/.github/workflows/[email protected]
secrets: inherit
with:
hub_ref: v1.1.20
pr_number: ${{ github.event.inputs.pr_number || '' }}
-32
View File
@@ -1,32 +0,0 @@
name: Auto CI Failure → Claude
on:
workflow_run:
workflows: [Core Tests, Optimized Test Suite]
types: [completed]
pull_request:
types: [opened, synchronize, reopened]
workflow_dispatch:
inputs:
pr_number:
required: false
type: string
schedule:
- cron: '15 */6 * * *'
concurrency:
group: ci-failure-scan-${{ github.repository }}-${{ github.event.pull_request.number || github.event.workflow_run.id || github.run_id }}
cancel-in-progress: true
permissions:
pull-requests: read
issues: write
actions: read
jobs:
detect-and-trigger:
uses: MervinPraison/github-automation-template/.github/workflows/[email protected]
secrets: inherit
with:
hub_ref: v1.1.20
pr_number: ${{ github.event.inputs.pr_number || '' }}
+1 -32
View File
@@ -5,11 +5,9 @@ on:
branches: [main]
pull_request:
branches: [main]
workflow_dispatch:
jobs:
python:
name: python
runs-on: ubuntu-latest
strategy:
matrix:
@@ -30,35 +28,7 @@ jobs:
run: ruff check src/praisonaiui
- name: Test with pytest
# 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
frontend:
name: frontend
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Set up Node.js
uses: actions/setup-node@v4
with:
node-version: "22"
cache: npm
cache-dependency-path: src/frontend/package-lock.json
- name: Install dependencies
run: npm ci
working-directory: src/frontend
# `npm run build` is the dashboard ship path: dist/ is synced to
# src/praisonaiui/templates/frontend/ (see src/frontend/README.md).
- name: Build frontend
run: npm run build
working-directory: src/frontend
run: pytest tests/unit tests/integration -v
docs:
runs-on: ubuntu-latest
@@ -90,4 +60,3 @@ jobs:
with:
github_token: ${{ secrets.GITHUB_TOKEN }}
publish_dir: ./site
force_orphan: true
-96
View File
@@ -1,96 +0,0 @@
name: Claude PR Merge Gate
on:
schedule:
- cron: '*/10 * * * *'
workflow_dispatch:
inputs:
pr_number:
required: false
type: string
merge_method:
required: false
type: choice
default: merge
options: [merge, squash, rebase]
workflow_run:
workflows:
- Claude Assistant
- Core Tests
types: [completed]
repository_dispatch:
types: [claude-merge-gate]
concurrency:
group: claude-merge-gate-${{ github.repository }}-${{ github.event.inputs.pr_number || github.event.client_payload.pr_number || github.event.workflow_run.id || 'scan' }}
cancel-in-progress: false
permissions:
contents: read
pull-requests: read
issues: read
actions: read
jobs:
auto-dispatch:
if: github.event_name == 'workflow_run'
runs-on: ubuntu-latest
permissions:
contents: read
pull-requests: read
issues: read
actions: write
id-token: write
steps:
- uses: actions/checkout@v4
- uses: MervinPraison/github-automation-template/.github/actions/[email protected]
with:
hub_ref: v1.1.20
- name: Generate GitHub App token
id: app-token
uses: actions/create-github-app-token@v1
with:
app-id: ${{ secrets.CLAUDE_APP_ID }}
private-key: ${{ secrets.CLAUDE_APP_PRIVATE_KEY }}
owner: ${{ github.repository_owner }}
- name: Dispatch merge gate when PR pipeline is ready
uses: actions/github-script@v7
with:
github-token: ${{ steps.app-token.outputs.token }}
script: |
const path = require('path');
const mergeGate = require(path.join(process.env.HUB_SCRIPTS, 'merge-gate.js'));
const wr = context.payload.workflow_run;
const owner = context.repo.owner;
const repo = context.repo.repo;
if (wr.conclusion !== 'success') return;
const allowed = ['Claude Assistant', 'Core Tests'];
if (!allowed.includes(wr.name)) return;
const prNumber = await mergeGate.resolvePrNumberFromWorkflowRun(github, owner, repo, wr);
if (!prNumber) return;
const fromClaude = wr.name === 'Claude Assistant';
const check = await mergeGate.evaluatePipelineQuiescent(github, owner, repo, prNumber, core, {
skipGlobalClaudeRunCheck: fromClaude,
skipRecentClaudeCooldown: fromClaude,
});
if (!check.ready) return;
await github.rest.repos.createDispatchEvent({
owner, repo, event_type: 'claude-merge-gate',
client_payload: { pr_number: prNumber },
});
merge-gate:
if: github.event_name != 'workflow_run'
permissions:
contents: write
pull-requests: write
issues: write
actions: read
id-token: write
statuses: write # the assess job records security/outsider-review
uses: MervinPraison/github-automation-template/.github/workflows/[email protected]
secrets: inherit
with:
hub_ref: v1.1.20
pr_number: ${{ github.event.inputs.pr_number || github.event.client_payload.pr_number || '' }}
merge_method: ${{ github.event.inputs.merge_method || 'merge' }}
+31 -17
View File
@@ -3,22 +3,36 @@ name: Claude Assistant
on:
issue_comment:
types: [created]
pull_request_review_comment:
types: [created]
issues:
types: [assigned, labeled, opened]
pull_request_review:
types: [submitted]
permissions:
contents: write
pull-requests: write
issues: write
id-token: write
jobs:
claude:
permissions:
contents: write
pull-requests: write
issues: write
actions: read
id-token: write
uses: MervinPraison/github-automation-template/.github/workflows/[email protected]
secrets: inherit
with:
hub_ref: v1.1.20
claude-response:
if: |
github.event_name == 'issue_comment' &&
contains(github.event.comment.body, '@claude') &&
github.actor != 'dependabot[bot]' &&
github.actor != 'cursor[bot]' &&
github.actor != 'renovate[bot]'
runs-on: ubuntu-latest
steps:
- name: Checkout repository
uses: actions/checkout@v4
with:
fetch-depth: 0
ref: ${{ github.event.issue.pull_request && format('refs/pull/{0}/head', github.event.issue.number) || github.event.repository.default_branch }}
- name: Run Claude Code Action
uses: anthropics/claude-code-action@beta
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
with:
claude_code_oauth_token: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }}
github_token: ${{ secrets.GITHUB_TOKEN }}
trigger_phrase: "@claude"
allowed_bots: "praisonai-triage-agent[bot]"
timeout_minutes: "30"
-41
View File
@@ -1,41 +0,0 @@
name: Deploy Docs
on:
push:
branches: [main]
workflow_dispatch:
permissions:
contents: write
pages: write
id-token: write
concurrency:
group: deploy-docs
cancel-in-progress: true
jobs:
deploy:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Set up Python
uses: actions/setup-python@v5
with:
python-version: "3.11"
- name: Install AIUI
run: pip install -e .
- name: Build docs with AIUI
run: |
aiui build --output site --minify
cp CNAME site/CNAME
- name: Deploy to GitHub Pages
uses: peaceiris/actions-gh-pages@v4
with:
github_token: ${{ secrets.GITHUB_TOKEN }}
publish_dir: ./site
force_orphan: true
@@ -1,52 +0,0 @@
name: Auto Merge Conflict → Claude
on:
push:
branches: [main]
pull_request:
types: [opened, synchronize, reopened]
workflow_dispatch:
schedule:
- cron: '0 */6 * * *'
concurrency:
group: merge-conflict-scan-${{ github.repository }}-${{ github.event.pull_request.number || github.run_id }}
cancel-in-progress: true
env:
GH_TOKEN: ${{ secrets.GH_TOKEN || github.token }}
permissions:
pull-requests: write
issues: write
jobs:
detect-and-trigger:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: MervinPraison/github-automation-template/.github/actions/[email protected]
with:
hub_ref: v1.1.20
- name: Detect conflicting PRs and trigger Claude
uses: actions/github-script@v7
with:
github-token: ${{ env.GH_TOKEN }}
script: |
const path = require('path');
const mg = require(path.join(process.env.HUB_SCRIPTS, 'merge-gate.js'));
const owner = context.repo.owner;
const repo = context.repo.repo;
const baseRepo = `${owner}/${repo}`;
const prs = await github.paginate(github.rest.pulls.list, { owner, repo, state: 'open', per_page: 50 });
for (const pr of prs) {
if (pr.draft) continue;
if (pr.head?.repo?.full_name !== baseRepo) continue;
const state = await mg.getMergeState(github, owner, repo, pr.number);
if (state.status !== 'DIRTY') continue;
const { data: comments } = await github.rest.issues.listComments({ owner, repo, issue_number: pr.number, per_page: 100 });
if (mg.hasRecentConflictComment(comments)) continue;
const body = '@claude this PR has merge conflicts with `main`. Rebase onto latest main, resolve conflicts, run tests, force-push with --force-with-lease.';
await github.rest.issues.createComment({ owner, repo, issue_number: pr.number, body });
await github.rest.issues.addLabels({ owner, repo, issue_number: pr.number, labels: ['claude-conflict-pending'] });
}
-129
View File
@@ -1,129 +0,0 @@
name: Nightly Release Gate
# Preflight before PyPI Release (patch bump).
# Primary: CI success on main (workflow_run).
# Backstop: nightly cron at 00:00 UTC.
on:
schedule:
- cron: '0 0 * * *'
workflow_dispatch:
inputs:
dry_run:
description: 'Run preflight only; do not dispatch pypi-release.yml'
required: false
default: false
type: boolean
workflow_run:
workflows:
- CI
types:
- completed
branches:
- main
permissions:
contents: read
actions: write
concurrency:
group: nightly-release-gate
cancel-in-progress: false
jobs:
preflight:
if: |
github.event_name == 'workflow_dispatch' ||
github.event_name == 'schedule' ||
(
github.event_name == 'workflow_run' &&
github.event.workflow_run.conclusion == 'success' &&
github.event.workflow_run.head_branch == 'main' &&
github.event.workflow_run.event == 'push'
)
runs-on: ubuntu-latest
env:
GH_TOKEN: ${{ secrets.GH_TOKEN }}
steps:
- name: Resolve trigger context
id: ctx
run: |
if [ "${{ github.event_name }}" = "workflow_run" ]; then
echo "checkout_sha=${{ github.event.workflow_run.head_sha }}" >> "$GITHUB_OUTPUT"
echo "source=ci" >> "$GITHUB_OUTPUT"
echo "is_ci=true" >> "$GITHUB_OUTPUT"
else
echo "checkout_sha=main" >> "$GITHUB_OUTPUT"
echo "source=nightly" >> "$GITHUB_OUTPUT"
echo "is_ci=false" >> "$GITHUB_OUTPUT"
fi
- name: Checkout
uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
with:
ref: ${{ steps.ctx.outputs.checkout_sha }}
fetch-depth: 0
- name: Fetch main and tags
run: git fetch --tags origin main
- name: Preflight — path changes, CI SHA, dedupe
id: preflight
uses: actions/github-script@v7
env:
HEAD_SHA: ${{ steps.ctx.outputs.checkout_sha }}
IS_CI: ${{ steps.ctx.outputs.is_ci }}
with:
github-token: ${{ secrets.GH_TOKEN }}
script: |
const path = require('path');
const rg = require(path.join(process.env.GITHUB_WORKSPACE, '.github/scripts/release-gate.js'));
const owner = context.repo.owner;
const repo = context.repo.repo;
const headSha = process.env.HEAD_SHA;
const isCi = process.env.IS_CI === 'true';
const result = await rg.evaluateReleasePreflight(
github, owner, repo,
{ headSha, isCiTrigger: isCi, bump: 'patch' },
core
);
core.setOutput('should_release', result.ready ? 'true' : 'false');
core.setOutput('reason', result.reasons.join('; '));
core.setOutput('head_sha', result.headSha || headSha);
core.setOutput('last_tag', result.lastTag || '');
core.setOutput('target_version', result.targetVersion || '');
core.summary.addRaw('## Release gate preflight\n');
core.summary.addRaw(`Ready: ${result.ready}\n`);
core.summary.addRaw(`Reason: ${result.reasons.join('; ')}\n`);
if (result.targetVersion) {
core.summary.addRaw(`Target: aiui==${result.targetVersion}\n`);
}
await core.summary.write();
if (!result.ready) {
core.info(`Skip release: ${result.reasons.join('; ')}`);
}
- name: Dry run summary
if: inputs.dry_run == true
run: |
echo "Dry run — preflight only, no pypi-release dispatch."
echo "should_release=${{ steps.preflight.outputs.should_release }}"
echo "reason=${{ steps.preflight.outputs.reason }}"
- name: Dispatch PyPI Release (patch)
if: steps.preflight.outputs.should_release == 'true' && inputs.dry_run != true
run: |
set -euo pipefail
gh workflow run pypi-release.yml \
--repo "${GITHUB_REPOSITORY}" \
--ref main \
-f bump=patch \
-f source=${{ steps.ctx.outputs.source }} \
-f trigger_sha=${{ steps.preflight.outputs.head_sha }} \
-f dry_run=false
echo "Dispatched pypi-release.yml (patch, source=${{ steps.ctx.outputs.source }})"
echo "Uses pypi-auto environment for ci/nightly patch releases."
@@ -1,19 +0,0 @@
name: Pipeline Status Sync
on:
schedule:
- cron: '*/10 * * * *'
workflow_dispatch:
permissions:
pull-requests: read
issues: write
actions: write
id-token: write
jobs:
sync:
uses: MervinPraison/github-automation-template/.github/workflows/[email protected]
secrets: inherit
with:
hub_ref: v1.1.20
-232
View File
@@ -1,232 +0,0 @@
name: PyPI Release
# Requires GitHub Environment "pypi" (manual) or "pypi-auto" (ci/nightly patch).
# Secrets: PYPI_TOKEN (or PYPI_API_TOKEN), GH_TOKEN
on:
workflow_dispatch:
inputs:
bump:
description: 'Version bump type for aiui'
required: true
default: patch
type: choice
options:
- patch
- minor
- major
dry_run:
description: 'Preview versions and steps without publishing or pushing'
required: false
default: false
type: boolean
version:
description: 'Override version (empty = auto bump from pyproject.toml)'
required: false
default: ''
type: string
source:
description: 'Trigger source (audit trail)'
required: false
default: manual
type: string
trigger_sha:
description: 'SHA that triggered this release'
required: false
default: ''
type: string
permissions:
contents: write
concurrency:
group: pypi-release
cancel-in-progress: false
jobs:
release:
if: github.ref == 'refs/heads/main'
runs-on: ubuntu-latest
environment: >-
${{
(inputs.source == 'ci' || inputs.source == 'nightly') &&
inputs.bump == 'patch' &&
'pypi-auto' || 'pypi'
}}
env:
UV_PUBLISH_TOKEN: ${{ secrets.PYPI_TOKEN || secrets.PYPI_API_TOKEN }}
GH_TOKEN: ${{ secrets.GH_TOKEN }}
PACKAGE: aiui
steps:
- name: Release audit
run: |
echo "Source: ${{ inputs.source }}"
echo "Trigger SHA: ${{ inputs.trigger_sha }}"
echo "Bump: ${{ inputs.bump }}"
echo "Environment: ${{ (inputs.source == 'ci' || inputs.source == 'nightly') && inputs.bump == 'patch' && 'pypi-auto' || 'pypi' }}"
- name: Checkout main
uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
with:
ref: main
fetch-depth: 0
token: ${{ secrets.GH_TOKEN }}
- name: Set up Python
uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5
with:
python-version: '3.11'
- name: Set up uv
uses: astral-sh/setup-uv@38f3f104447c67c051c4a08e39b64a148898af3a # v4
- name: Configure git
run: |
git config user.name "MervinPraison"
git config user.email "[email protected]"
gh auth setup-git
- name: Compute release version
id: versions
env:
BUMP: ${{ inputs.bump }}
VERSION_OVERRIDE: ${{ inputs.version }}
run: |
python <<'PY'
import os
import re
import sys
from pathlib import Path
SEMVER = re.compile(r"^\d+\.\d+\.\d+$")
bump = os.environ["BUMP"]
override = os.environ.get("VERSION_OVERRIDE", "").strip()
def bump_version(version: str) -> str:
parts = version.split(".")
if len(parts) != 3:
print(f"Invalid version: {version}", file=sys.stderr)
sys.exit(1)
maj, min_, patch = (int(parts[0]), int(parts[1]), int(parts[2]))
if bump == "major":
return f"{maj + 1}.0.0"
if bump == "minor":
return f"{maj}.{min_ + 1}.0"
return f"{maj}.{min_}.{patch + 1}"
pyproject = Path("pyproject.toml")
content = pyproject.read_text()
match = re.search(r'^version\s*=\s*"([^"]+)"', content, re.MULTILINE)
if not match:
print("Could not read version from pyproject.toml", file=sys.stderr)
sys.exit(1)
current = match.group(1)
if not SEMVER.match(current):
print(f"Invalid current version: {current}", file=sys.stderr)
sys.exit(1)
if override:
if not SEMVER.match(override):
print(f"Invalid version override: {override}", file=sys.stderr)
sys.exit(1)
new_version = override
else:
new_version = bump_version(current)
with open(os.environ["GITHUB_OUTPUT"], "a", encoding="utf-8") as fh:
fh.write(f"current_version={current}\n")
fh.write(f"new_version={new_version}\n")
print(f"aiui: {current} -> {new_version}")
PY
- name: Dry run summary
if: inputs.dry_run
run: |
echo "Dry run — no publish, commit, or push."
echo "Package: ${{ env.PACKAGE }}"
echo "Version: ${{ steps.versions.outputs.current_version }} -> ${{ steps.versions.outputs.new_version }}"
echo "Would: bump pyproject.toml + __version__.py, uv publish, commit, tag, GitHub release"
- name: Bump version files
if: inputs.dry_run != true
env:
CURRENT: ${{ steps.versions.outputs.current_version }}
NEW_VERSION: ${{ steps.versions.outputs.new_version }}
run: |
set -euo pipefail
python <<'PY'
import os
import re
from pathlib import Path
current = os.environ["CURRENT"]
new = os.environ["NEW_VERSION"]
pyproject = Path("pyproject.toml")
content = pyproject.read_text()
pyproject.write_text(content.replace(f'version = "{current}"', f'version = "{new}"', 1))
version_py = Path("src/praisonaiui/__version__.py")
vcontent = version_py.read_text()
version_py.write_text(re.sub(
r'__version__ = "[^"]+"',
f'__version__ = "{new}"',
vcontent,
count=1,
))
PY
- name: Publish aiui to PyPI
if: inputs.dry_run != true
run: |
set -euo pipefail
rm -rf dist
uv lock
uv build
uv publish
- name: Wait for aiui on PyPI
if: inputs.dry_run != true
run: |
set -euo pipefail
VERSION="${{ steps.versions.outputs.new_version }}"
MAX_WAIT=600
INTERVAL=30
START=$(date +%s)
while true; do
if curl -fsSL "https://pypi.org/pypi/${PACKAGE}/${VERSION}/json" >/dev/null; then
echo "✅ ${PACKAGE}==${VERSION} is available on PyPI"
exit 0
fi
ELAPSED=$(( $(date +%s) - START ))
if [ "$ELAPSED" -ge "$MAX_WAIT" ]; then
echo "❌ Timeout waiting for ${PACKAGE}==${VERSION} on PyPI"
exit 1
fi
echo "⏳ Waiting for ${PACKAGE}==${VERSION} ($(( MAX_WAIT - ELAPSED ))s remaining...)"
sleep "$INTERVAL"
done
- name: Commit, tag, and release
if: inputs.dry_run != true
env:
VERSION: ${{ steps.versions.outputs.new_version }}
run: |
set -euo pipefail
TAG="v${VERSION}"
git add pyproject.toml uv.lock src/praisonaiui/__version__.py
git diff --cached --quiet && { echo "No version files to commit"; exit 0; }
git commit -m "Release ${TAG}"
git tag -f "${TAG}"
git pull --rebase origin main
git tag -f "${TAG}"
git push origin main
git push --tags -f
gh release create "${TAG}" \
--title "PraisonAIUI ${TAG}" \
--notes "Release ${TAG}" \
--latest
- name: Verify release
if: inputs.dry_run != true
run: echo "✅ ${{ env.PACKAGE }}==${{ steps.versions.outputs.new_version }}"
@@ -1,33 +0,0 @@
name: Sync Claude Secrets from PraisonAI
# GitHub does not allow reading secret values back via API.
# This workflow triggers the sync job in MervinPraison/PraisonAI, which copies:
# CLAUDE_APP_ID, CLAUDE_APP_PRIVATE_KEY, CLAUDE_CODE_OAUTH_TOKEN, GH_TOKEN
#
# Requires SECRETS_ADMIN_PAT in PraisonAI (repo Settings → Secrets).
# If sync fails, copy secrets manually:
# PraisonAI → Settings → Secrets → Actions
# PraisonAIUI → Settings → Secrets → Actions (same names)
on:
workflow_dispatch:
permissions:
contents: read
jobs:
trigger-sync:
runs-on: ubuntu-latest
steps:
- name: Trigger PraisonAI secret sync
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
gh workflow run sync-secrets-to-aiui.yml \
--repo MervinPraison/PraisonAI \
--ref main \
-f target_repo=MervinPraison/PraisonAIUI
echo "Triggered: https://github.com/MervinPraison/PraisonAI/actions/workflows/sync-secrets-to-aiui.yml"
echo ""
echo "If sync fails (401/403), refresh SECRETS_ADMIN_PAT in PraisonAI,"
echo "or copy CLAUDE_APP_ID + CLAUDE_APP_PRIVATE_KEY + GH_TOKEN manually."
+1 -3
View File
@@ -44,7 +44,6 @@ Thumbs.db
# Project specific
aiui/
*.log
*.err
.praison/
.gemini/
.internal/
@@ -56,5 +55,4 @@ aiui-output/
_site
.praisonai
tmp/
*.db
tmp/
@@ -19,17 +19,18 @@ Deep Analysis → Gap Analysis → Critical Review → Plan → Create GitHub Is
- **No perf impact**: preserve import-time and hot-path; heavy deps optional + lazy.
- **Async-safe + multi-agent safe** by default.
- **Easy for non-developers**: "Few lines of code to do the task!"
- **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").
---
## CANONICAL PATHS
```
UI Repo: /Users/praison/praisonaiui (praisonaiui)
SDK Core: /Users/praison/praisonai-package/src/praisonai-agents (praisonaiagents)
Core SDK: /Users/praison/praisonai-package/src/praisonai-agents (praisonaiagents)
Wrapper: /Users/praison/praisonai-package/src/praisonai (praisonai)
Tools: /Users/praison/PraisonAI-tools
Docs: /Users/praison/PraisonAIDocs
Extension: src/praisonaiui/*, docs/*, tests/*
TypeScript: /Users/praison/praisonai-package/src/praisonai-ts
Extension: tools/base.py, tools/decorator.py, db/*
```
---
@@ -90,13 +91,16 @@ Key files to read:
Go through ALL relevant files, understand current logic end-to-end.
```bash
# UI repo
ls /Users/praison/praisonaiui/src/praisonaiui/
ls /Users/praison/praisonaiui/tests/
ls /Users/praison/praisonaiui/docs/
# Optional cross-check against SDK only when needed
# Core SDK
ls /Users/praison/praisonai-package/src/praisonai-agents/praisonaiagents/
grep -r "class.*Protocol" praisonaiagents/ --include="*.py" | wc -l
# Wrapper
ls /Users/praison/praisonai-package/src/praisonai/praisonai/
ls /Users/praison/praisonai-package/src/praisonai/praisonai/cli/commands/
# TypeScript
ls /Users/praison/praisonai-package/src/praisonai-ts/src/
```
**Document:**
@@ -316,7 +320,7 @@ Working code example.
```bash
gh issue create \
--repo MervinPraison/PraisonAIUI \
--repo MervinPraison/PraisonAI \
--title "<Type>: <Descriptive Title>" \
--body "<issue body>"
```
@@ -330,7 +334,7 @@ gh issue create \
### 9b. Verify issue created
```bash
gh issue view <number> --repo MervinPraison/PraisonAIUI | head -20
gh issue view <number> --repo MervinPraison/PraisonAI | head -20
```
---
+7 -4
View File
@@ -17,10 +17,13 @@ PRINCIPLES (apply in all analysis/proposals):
- Easy for non-developers. "Few lines of code to do the task!"
CANONICAL PATHS:
- UI Repo: /Users/praison/praisonaiui (praisonaiui)
- SDK Core (reference): /Users/praison/praisonai-package/src/praisonai-agents (praisonaiagents)
- Core SDK: /Users/praison/praisonai-package/src/praisonai-agents (praisonaiagents)
- Wrapper: /Users/praison/praisonai-package/src/praisonai (praisonai)
- Tools: /Users/praison/PraisonAI-tools
- Docs: /Users/praison/PraisonAIDocs (JS: docs/js, Rust: docs/rust)
- Extension points: src/praisonaiui/*, docs/*, tests/*
- TypeScript: /Users/praison/praisonai-package/src/praisonai-ts
- Rust: /Users/praison/praisonai-package/src/praisonai-rust
- Extension points: tools/base.py, tools/decorator.py, db/*
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
MANDATORY PROCESS (NO IMPLEMENTATION)
@@ -51,7 +54,7 @@ STEP 4 — Detailed Review
STEP 5 — Gap Analysis
- Compare acceptance criteria vs current state. Checklist:
- UI Core, CLI, Docs, Tests, Exports, Perf, Multi-agent + async
- Core SDK, Wrapper, Tools/plugins, CLI, Docs, Tests, Exports, Perf, Multi-agent + async
- Each gap: severity, impact, risk, recommended location (core/wrapper/tools).
STEP 6 — Critical Review
@@ -19,10 +19,8 @@ Deep Analysis → Gap Analysis → Critical Review → Plan → Create Issue →
- **No perf impact**: preserve import-time and hot-path; heavy deps optional + lazy.
- **Async-safe + multi-agent safe** by default.
- **Easy for non-developers**: "Few lines of code to do the task!"
- **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).
---
@@ -537,11 +535,6 @@ 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
@@ -674,4 +667,3 @@ 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.
+1 -17
View File
@@ -138,23 +138,7 @@ Final rule: conclude only when evidence shows missing = 0.
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
PUBLISH WORKFLOW (reference)
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
GitHub Actions (recommended):
GitHub → Actions → "PyPI Release" → Run workflow → bump: patch
Requires repo secrets: PYPI_TOKEN, GH_TOKEN
Requires GitHub Environment "pypi" with required reviewers (Settings → Environments)
Manual — Publish aiui (PraisonAIUI):
cd /Users/praison/PraisonAIUI
# Bump version in pyproject.toml and src/praisonaiui/__version__.py
rm -rf dist && uv lock && uv build && uv publish --token "$PYPI_TOKEN"
git add pyproject.toml uv.lock src/praisonaiui/__version__.py
git commit -m "Release vX.Y.Z" && git tag vX.Y.Z && git push && git push --tags
gh release create vX.Y.Z --title "PraisonAIUI vX.Y.Z" --notes "Release vX.Y.Z" --latest
PyPI package name: aiui
Verify: pip index versions aiui | head -1
PraisonAI stack (separate repo — publish SDK before wrapper):
Step 1 — Publish praisonaiagents (Core SDK):
cd /Users/praison/praisonai-package/src/praisonai-agents
praisonai publish pypi
# Uses uv internally: uv lock → uv build → uv publish
+3 -6
View File
@@ -111,12 +111,9 @@ 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. 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`
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`
## Gotchas
-79
View File
@@ -1,79 +0,0 @@
# 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
```
-4
View File
@@ -366,7 +366,3 @@ pytest tests -v
## License
MIT © [Praison Limited](https://praison.ai)
Third-party vendored source is documented in [THIRD_PARTY_NOTICES.md](THIRD_PARTY_NOTICES.md).
See the [BeautifulUI upgrade playbook](docs/frontend/beautifului.md) for how BeautifulUI
primitives are vendored and pinned.
-37
View File
@@ -1,37 +0,0 @@
# Security Policy
We take the security of PraisonAIUI seriously and appreciate responsible
disclosure of vulnerabilities.
## Supported Versions
Security fixes are applied to the latest released version of PraisonAIUI.
Please make sure you are on the most recent release before reporting.
## Reporting a Vulnerability
**Please do not open a public issue for security vulnerabilities.**
Report privately through GitHub's **Private Vulnerability Reporting**:
1. Go to the [Security tab](https://github.com/MervinPraison/PraisonAIUI/security)
of this repository.
2. Click **"Report a vulnerability"** to open a private advisory.
3. Include as much detail as you can:
- Affected component(s) and version.
- Steps to reproduce.
- Impact assessment.
- Any suggested fix or mitigation.
If you are unable to use private reporting, you may open a public issue that
contains **no technical specifics** and requests a private channel, and a
maintainer will follow up.
## What to Expect
- We aim to acknowledge new reports within a few business days.
- We will keep you informed as we investigate and work on a fix.
- Once a fix is released, we are happy to credit you in the advisory (unless
you prefer to remain anonymous).
Thank you for helping keep PraisonAIUI and its users safe.
-43
View File
@@ -1,43 +0,0 @@
# Third-Party Notices
PraisonAIUI incorporates or vendors source from the third-party projects listed below.
Their licences and copyright notices are retained here as required.
---
## BeautifulUI
- Project: BeautifulUI — <https://www.beautifului.dev/>
- Source: <https://github.com/slev12397/beautiful-ui>
- Licence: MIT
BeautifulUI is distributed as a shadcn registry / copy-paste library (no npm package).
When its React primitives are vendored into `src/frontend`, the following MIT licence
applies to that copied source.
```
MIT License
Copyright (c) Shane Levine
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
```
See the upgrade playbook at [`docs/frontend/beautifului.md`](docs/frontend/beautifului.md)
for how BeautifulUI is vendored and pinned.
+4 -18
View File
@@ -9,14 +9,6 @@ site:
radius: "md"
darkMode: true
seo:
siteUrl: "https://ui.praison.ai"
titleTemplate: "%s | %s"
defaultImage: "/icon.svg"
robots:
index: true
follow: true
# Mintlify-style navigation: few tabs, many groups per tab
navigation:
tabs:
@@ -59,11 +51,6 @@ navigation:
- typescript/runtime
- typescript/nextjs
- typescript/components
- group: "Frontend"
icon: "layout"
prefix: "frontend"
pages:
- frontend/beautifului
- group: "Examples"
icon: "folder-open"
prefix: "examples"
@@ -86,12 +73,11 @@ navigation:
# Navbar actions
navbar:
primary:
type: "button"
label: "Get started"
href: "/docs/getting-started/quickstart"
type: "github"
href: "https://github.com/MervinPraison/PraisonAIUI"
links:
- label: "GitHub"
href: "https://github.com/MervinPraison/PraisonAIUI"
- label: "Submit Issues"
href: "https://github.com/MervinPraison/PraisonAIUI/issues"
# Footer
footer:
-94
View File
@@ -1,94 +0,0 @@
# ADR 0001: PraisonAI Replay view, a clean-room design
- **Status:** accepted
- **Date:** 2026-10-26
- **Scope:** `src/praisonaiui/features/replay.py`, `features/_trace_jsonl.py`,
`templates/frontend/plugins/views/replay-core.js`, `templates/frontend/plugins/views/replay.js`
## Context
PraisonAI records agent runs as context-trace JSONL (one `ContextEvent.to_dict()` per
line). People need to see what a team or flow did: who did what, when, why, and at what
cost, both after the fact and live. Other tools have shown that a graph plus a trace is a
useful way to look at agent runs. We want that value without copying anyone's product,
code or look, and without adding dependencies.
## Decision
Build our own view, in plain DOM and SVG, with our own layout code, palette and words.
### Ideas we took (ideas only)
| Idea | Where it came from | What we did with it |
|---|---|---|
| Show the graph of a multi-agent program next to the trace of a run | The general concept behind LangGraph Studio's graph-plus-trace view, known from public talks and articles | A **Map** view drawn by our own layered layout (`layoutMap`); the default view is a **Timeline**, which Studio does not have |
| Parallel voices on shared time, read left to right | Musical scores and swimlane diagrams | One lane per agent plus Manager and You; parallel branches as sub-lanes; tasks and steps as "chapters" across the top |
| Vocabulary for LLM and tool spans, tokens and cost | OpenTelemetry GenAI semantic conventions (public specification) | Block kinds (LLM, tool), token and cost totals; the wrapper's OTLP export uses the conventions, the UI only borrows the concepts |
### What we did not use
- **No third-party UI code, CSS, icons, layouts or screenshots** were used to build this
view. Shapes, palette, spacing, wording ("Brief", "Delivered", "Waiting for you") and
the layout algorithm are our own.
- **LangGraph's server (Elastic License 2.0) and the proprietary LangGraph Studio were
not consulted for code**, and none of their assets, markup or styles were copied. We
do not use the name "Studio", any "Lang…" naming, or `__start__`/`__end__` pills.
- No Mermaid, D3, React Flow, dagre, ELK or other graph libraries. The dashboard's
existing Mermaid CDN plugin is not used by Replay.
### How it is built
- `replay-core.js`: pure functions (`buildModel`, `narrate`, `layoutMap`,
`deriveGraph`). No DOM, no network. Tested with `node --test`.
- `replay.js`: DOM/SVG rendering, keyboard handling, SSE follow and approvals. It also
boots by itself when `window.__REPLAY_DATA__ = {events, graph}` is set, which is how
the wrapper's single-file HTML export works (`standalone_html()` inlines both files).
- `layoutMap` is a textbook layered layout written from scratch: depth-first search to
find back edges (loops), longest-path layering, one virtual waypoint per crossed layer
for long edges, and a few barycentre sweeps to order each layer. It is deterministic.
- Narration is template text, chosen per event type. No LLM, no randomness.
## Palette and accessibility rationale
| Role | Fill | Shape | Why |
|---|---|---|---|
| Agent | `#8B0000` | circle | PraisonAI's brand red; white text on it is 10.0:1 |
| Tool | `#0F7287` | rounded rectangle | Teal kept from the brand but darkened from `#189AB4` (white text on `#189AB4` fails AA); white on `#0F7287` is 5.6:1 |
| You (human) | `#8A5A00` | diamond | Amber distinct from red and teal for common colour-vision deficiencies; white on it is 5.9:1 |
| Manager | `#3C4A5C` | square | Neutral slate, so the manager reads as coordination, not work; white on it is 9.0:1 |
- **Never colour alone.** Every lane, block, node and step has a shape and a text label;
arrows differ by dash pattern and carry words ("hands off", "delegates", "routes").
Heat in the cost/latency overlay is a bar whose height and shade both grow, plus the
value in text.
- **Theme.** Follows the system setting (`prefers-color-scheme`) by default, with a
Light/Dark override; the light theme is the design baseline.
- **Light theme first.** Text tokens are at least 6.9:1 on their backgrounds in light mode
and 7.7:1 in dark mode. In dark mode the palette fills keep white text at AA, and get a
light 2 px outline so each shape's boundary stays above 3:1 against the dark page.
- **Keyboard throughout.** ← → (and ↑ ↓ in the step list) move the replay cursor, Home
and End jump to the ends, tabs follow the ARIA tabs pattern, map nodes use a roving
tab stop, and every action has a real button.
- **Screen readers.** The narration column is a real ordered list and is the main way
through a run. Timeline blocks and map nodes are named buttons whose names start with
their visible text (WCAG 2.5.3). Live updates are announced through a polite live
region, throttled.
- **Motion.** The only animation (the live dot) runs only under
`prefers-reduced-motion: no-preference`; scrolling is instant when motion is reduced.
- Checked with axe-core against WCAG 2.0, 2.1 and 2.2 A/AA in both themes (dev-only test
in `tests/browser/`).
## Dependency licences
No new runtime or build dependencies. The Python side uses only the standard library and
Starlette, which aiui already depends on. The browser side is plain ES modules with no
imports beyond our own file. Dev-only test tools that are not shipped: Playwright
(Apache-2.0, already used in this environment) and axe-core (MPL-2.0, loaded from a path
given by `AXE_CORE_JS`; not vendored).
## Consequences
- We own the look and the layout code, and can change them without licence questions.
- The layout is simpler than a dedicated graph engine. It is fine for the team and flow
sizes we see (tens of nodes). Very large graphs may need a better crossing-reduction
step later.
-133
View File
@@ -802,136 +802,3 @@ The `dashboard.js` renderer supports any dict with a `type` key. Unknown types r
# Custom component — renders as JSON until frontend support is added
{"type": "timeline", "events": [{"time": "10:00", "label": "Deploy"}]}
```
---
## Component Render Protocol
Understanding how components flow from Python to the browser.
### Data Flow
```
Python Server Frontend
────── ────── ────────
aiui.card("X", value=42) → {"type":"card",...} → renderComponent()
↓ ↓
@aiui.page() handler → /api/pages/{id}/data → renderCard()
↓ ↓
aiui.layout([...]) → {"_components":[...]} → DOM elements
```
### Step-by-Step
1. **Python**: Call `aiui.card("Revenue", value="$1,500")` — returns a dict:
```python
{"type": "card", "title": "Revenue", "value": "$1,500"}
```
2. **Server**: `@aiui.page()` handler returns `aiui.layout([...])`:
```python
{"_components": [{"type": "card", ...}, {"type": "table", ...}]}
```
3. **API**: `GET /api/pages/{id}/data` returns the JSON
4. **Frontend**: `dashboard.js` detects `_components` key and calls `renderComponents()`:
```javascript
function renderComponents(data, container) {
if (data && data._components) {
data._components.forEach(comp => {
container.appendChild(renderComponent(comp));
});
}
}
```
5. **Renderer**: `renderComponent(comp)` dispatches on `comp.type`:
```javascript
function renderComponent(comp) {
switch (comp.type) {
case 'card': return renderCard(comp);
case 'table': return renderTable(comp);
// ... 48 component types
default: return renderAsJSON(comp);
}
}
```
6. **DOM**: Each renderer creates HTML elements with `db-*` CSS classes
### The `_components` Key
The magic key that triggers structured rendering:
```python
# With _components — renders as UI elements
return {"_components": [aiui.card("X"), aiui.table(...)]}
# Without _components — renders as raw JSON
return {"status": "ok", "data": [...]}
```
`aiui.layout(children)` automatically wraps children in `{"_components": [...]}`.
### Component Dict Protocol
Every component function returns a dict with:
| Key | Required | Description |
|-----|----------|-------------|
| `type` | ✅ | Component type name (e.g. `"card"`, `"table"`) |
| Other keys | Varies | Component-specific properties |
```python
# Card component
{"type": "card", "title": "Revenue", "value": "$1,500", "footer": "+8%"}
# Table component
{"type": "table", "headers": ["A", "B"], "rows": [["x", "y"]]}
# Nested components
{"type": "columns", "children": [
{"type": "card", "title": "A"},
{"type": "card", "title": "B"},
]}
```
### CSS Classes
All components use `db-*` prefixed CSS classes:
| Component | CSS Class |
|-----------|-----------|
| Card | `.db-card`, `.db-card-title`, `.db-card-value`, `.db-card-footer` |
| Table | `.db-viewer table` |
| Alert | `.db-alert`, `.db-alert-info`, `.db-alert-success`, etc. |
| Badge | `.db-badge`, `.db-badge-secondary`, `.db-badge-destructive` |
| Tabs | `.db-tabs`, `.db-tab-list`, `.db-tab-btn`, `.db-tab-panel` |
### Extending with Custom Components
To add a new component type:
1. **Python**: Add function to `ui.py`:
```python
def timeline(events: list[dict]) -> dict:
return {"type": "timeline", "events": events}
```
2. **Frontend**: Add renderer to `dashboard.js`:
```javascript
function renderTimeline(comp) {
const el = document.createElement('div');
el.className = 'db-timeline';
// ... render events
return el;
}
// Add to switch statement in renderComponent()
case 'timeline': return renderTimeline(comp);
```
3. **CSS**: Add styles for `.db-timeline`
Until the frontend renderer is added, unknown types render as formatted JSON.
-136
View File
@@ -1,136 +0,0 @@
# 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');
```
+3 -348
View File
@@ -110,336 +110,6 @@ aiui.remove_page("debug")
---
### `aiui.set_chat_mode(mode, *, position, size, resizable, minimized)`
Configure chat window display mode — full page, floating window, or sidebar panel.
| Parameter | Type | Default | Description |
|-----------|------|---------|-------------|
| `mode` | `str` | `"fullpage"` | `"fullpage"`, `"floating"`, or `"sidebar"` |
| `position` | `tuple[int, int]` | `(20, 20)` | (bottom, right) pixel offset for floating mode |
| `size` | `tuple[int, int]` | `(400, 500)` | (width, height) initial size for floating mode |
| `resizable` | `bool` | `True` | Allow resizing the floating window |
| `minimized` | `bool` | `False` | Start minimized (floating mode only) |
```python
import praisonaiui as aiui
# Full page chat (default)
aiui.set_chat_mode("fullpage")
# Floating chat window
aiui.set_chat_mode("floating", position=(20, 20), size=(420, 550))
# Sidebar panel
aiui.set_chat_mode("sidebar")
```
---
### `aiui.set_sidebar_config(*, collapsible, default_collapsed, width, min_width, max_width)`
Configure sidebar behavior and dimensions.
| Parameter | Type | Default | Description |
|-----------|------|---------|-------------|
| `collapsible` | `bool` | `True` | Allow sidebar to be collapsed |
| `default_collapsed` | `bool` | `False` | Start with sidebar collapsed |
| `width` | `int` | `260` | Default sidebar width in pixels |
| `min_width` | `int` | `200` | Minimum width when resizing |
| `max_width` | `int` | `360` | Maximum width when resizing |
```python
import praisonaiui as aiui
aiui.set_sidebar_config(collapsible=True, width=280)
aiui.set_sidebar_config(default_collapsed=True) # Start collapsed
```
---
### `aiui.set_brand_color(color)`
Set the brand/primary accent color. Overrides the theme's default accent.
| Parameter | Type | Description |
|-----------|------|-------------|
| `color` | `str` | Hex color (e.g. `"#6366f1"`) or CSS color value |
```python
import praisonaiui as aiui
aiui.set_brand_color("#818cf8") # Indigo-400
aiui.set_brand_color("#22c55e") # Green-500
```
---
### `aiui.set_chat_features(*, history, streaming, file_upload, audio, reasoning, tools, multimedia, feedback)`
Configure which chat features are enabled in the UI.
| Parameter | Type | Default | Description |
|-----------|------|---------|-------------|
| `history` | `bool` | `True` | Show session history sidebar |
| `streaming` | `bool` | `True` | Enable streaming responses |
| `file_upload` | `bool` | `False` | Show file upload button |
| `audio` | `bool` | `False` | Show audio input button |
| `reasoning` | `bool` | `True` | Show reasoning/thinking steps |
| `tools` | `bool` | `True` | Show tool call displays |
| `multimedia` | `bool` | `True` | Enable multimedia rendering |
| `feedback` | `bool` | `False` | Show feedback buttons |
```python
import praisonaiui as aiui
# Minimal chat — no history sidebar, no file upload
aiui.set_chat_features(history=False, file_upload=False)
# Full-featured chat
aiui.set_chat_features(
history=True,
streaming=True,
file_upload=True,
audio=True,
feedback=True,
)
```
---
### `aiui.set_dashboard(*, sidebar, page_header)`
Configure dashboard layout options.
| Parameter | Type | Default | Description |
|-----------|------|---------|-------------|
| `sidebar` | `bool` | `True` | Show the left sidebar navigation |
| `page_header` | `bool` | `True` | Show the page title/description header |
```python
import praisonaiui as aiui
aiui.set_style("dashboard")
aiui.set_dashboard(sidebar=False) # Chat-only dashboard, no sidebar
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`.
| Parameter | Type | Description |
|-----------|------|-------------|
| `name` | `str` | Unique theme name (e.g. `"ocean"`, `"sunset"`) |
| `variables` | `dict[str, str]` | Dict with at least `{"accent": "#hexcolor"}` |
```python
import praisonaiui as aiui
# Register a custom theme
aiui.register_theme("ocean", {"accent": "#0077b6"})
# With explicit RGB (auto-derived if omitted)
aiui.register_theme("sunset", {
"accent": "#ff6b35",
"accentRgb": "255,107,53",
})
# Apply the custom theme
aiui.set_theme(preset="ocean")
```
See [CSS Architecture](../features/css-architecture.md) for all available CSS variables.
---
### `aiui.set_custom_js(path)`
Inject a local JavaScript file into the UI. Reads the file at `path` and
serves it at `/custom.js`. A `<script src="/custom.js">` tag is injected
into the host HTML **after** the plugin loader, so `window.aiui` and all
registry APIs (`registerView`, `registerComponent`) are ready when your
code runs.
| Parameter | Type | Description |
|-----------|------|-------------|
| `path` | `str \| Path` | Local filesystem path to a `.js` file |
Raises `FileNotFoundError` if the path does not exist.
```python
import praisonaiui as aiui
aiui.set_custom_js("plugin.js")
```
Where `plugin.js` uses the client-side extension APIs:
```javascript
// plugin.js
window.aiui.registerComponent('timeline', function(comp) {
const el = document.createElement('div');
(comp.events || []).forEach(ev => {
const row = document.createElement('div');
row.textContent = `${ev.time} — ${ev.label}`;
el.appendChild(row);
});
return el;
});
```
---
### `aiui.register_component_schema(component_type, schema)`
Register a JSON Schema contract for a component dict. Built-in schemas
are auto-derived from every `aiui.ui.*` builder at startup; user schemas
take priority and are merged on top.
| Parameter | Type | Description |
|-----------|------|-------------|
| `component_type` | `str` | Component type string (e.g. `"timeline"`) |
| `schema` | `dict` | JSON Schema dict (Draft 2020-12 compatible) |
```python
import praisonaiui as aiui
aiui.register_component_schema("timeline", {
"type": "object",
"required": ["type", "events"],
"properties": {
"type": {"const": "timeline"},
"events": {"type": "array"},
},
})
```
### `aiui.get_component_schemas()`
Returns the merged registry as a `dict[str, dict]`. Also available as a
JSON endpoint: `GET /api/components/schemas`.
```python
schemas = aiui.get_component_schemas()
print(sorted(schemas)) # ['accordion', 'alert', ..., 'timeline', ...]
```
---
### `aiui.prompt(question, *, options=None, timeout=300.0)`
Pause the agent and wait for the user to answer. Preferred entry point
for interactive agent flows — supersedes the legacy `AskUserMessage`
class.
| Parameter | Type | Description |
|-----------|------|-------------|
| `question` | `str` | Text shown to the user. |
| `options` | `list[str] \| None` | Optional list of choices. When given, the user picks one; otherwise they type free text. |
| `timeout` | `float` | Seconds to wait before giving up. Default 300 (5 min). |
Returns a `PromptResult` dataclass (`text`, `choice`, `message_id`).
The result is truthy when the user answered, falsy on timeout.
```python
import praisonaiui as aiui
@aiui.reply
async def on_message(msg: str):
name = await aiui.prompt("What's your name?")
if name:
await aiui.say(f"Hi {name.text}!")
pick = await aiui.prompt("Pick a colour", options=["red", "blue"])
if pick:
await aiui.say(f"You picked {pick.choice}")
```
---
### `aiui.error(content, *, details=None)`
Emit an error message to the chat. A thin helper around `Message` with
`metadata={"kind": "error"}` — keeps the public surface small.
```python
try:
data = await fetch()
except Exception as exc:
await aiui.error("Couldn't load data", details=str(exc))
```
---
### `aiui.configure(*, datastore=None, branding=None, theme=None, chat=None, custom_css=None, custom_js=None, style=None)`
One-stop configuration function. Every keyword is optional, so you only
set what you need. Replaces the dozen individual `set_*` setters for
most common cases (the `set_*` functions remain for advanced control).
| Parameter | Type | Description |
|-----------|------|-------------|
| `datastore` | `str` | Storage backend: `"memory"`, `"json"`, `"json:/path"`, `"sdk"`, `"sdk:/path"`. |
| `branding` | `dict` | Forwarded to `set_branding(...)`. Keys: `title`, `logo`, `subtitle`. |
| `theme` | `dict` | Forwarded to `set_theme(...)`. Keys: `preset`, `dark`/`dark_mode`, `radius`, `brand_color`. |
| `chat` | `dict` | Forwarded to `set_chat_features(...)`. Keys: `feedback`, `mode`, plus any chat-feature flag. |
| `custom_css`| `str \| Path` | Path to a CSS file; same as `set_custom_css`. |
| `custom_js` | `str \| Path` | Path to a JS file; same as `set_custom_js`. |
| `style` | `str` | UI style — `"chat"`, `"dashboard"`, etc. |
```python
import praisonaiui as aiui
aiui.configure(
branding={"title": "My App", "logo": "🎨"},
theme={"preset": "ocean", "dark": True, "radius": "md"},
chat={"feedback": True, "mode": "single"},
datastore="json",
custom_css="styles.css",
custom_js="plugin.js",
)
```
```python
import praisonaiui as aiui
# In-memory (default, volatile)
aiui.configure(datastore="memory")
# JSON files at ~/.praisonaiui/sessions/
aiui.configure(datastore="json")
# JSON files at custom path
aiui.configure(datastore="json:/tmp/my-sessions")
# SDK-backed store (unifies with praisonai-agents)
aiui.configure(datastore="sdk")
```
---
## Callback Decorators
### `@aiui.reply`
@@ -578,19 +248,6 @@ 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.
@@ -620,8 +277,8 @@ Build custom page layouts with UI components.
async def my_dashboard():
return aiui.layout([
aiui.columns([
aiui.card("Metric A", value="42"),
aiui.card("Metric B", value="98%", footer="+5% this week"),
aiui.card("Metric A", aiui.text("42")),
aiui.card("Metric B", aiui.text("98%")),
]),
aiui.card("Data", aiui.table(
headers=["Name", "Value"],
@@ -637,14 +294,12 @@ async def my_dashboard():
| Component | Usage |
|-----------|-------|
| `aiui.layout(children)` | Root container for a page |
| `aiui.card(title, *, value, footer)` | Metric/stat card with title, value, and optional footer |
| `aiui.card(title, content)` | Card with title and body |
| `aiui.columns(children)` | Horizontal column layout |
| `aiui.chart(data, type)` | Chart (line, bar, pie) |
| `aiui.table(headers, rows)` | Data table |
| `aiui.text(content)` | Text block |
See [Component API Reference](components.md) for all 48 components.
---
## Data Classes
+6 -9
View File
@@ -16,8 +16,7 @@ graph TD
GW["WebSocketGateway<br/>REST + WebSocket<br/>Bot management"]
end
CHAT -->|"WebSocket / SSE<br/>(RunEvent protocol)"| GW
CHAT -.->|"AG-UI (optional)<br/>POST /agui"| AGUI
CHAT -->|"AG-UI protocol<br/>(SSE events)"| AGUI
DASH -->|"REST API<br/>(/api/channels, /api/nodes)"| GW
```
@@ -111,7 +110,7 @@ sequenceDiagram
## Using Both Together
`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:
`AIUIGateway.start()` in `integration.py` mounts both layers on the same server:
```python
from praisonaiui.integration import AIUIGateway
@@ -122,12 +121,10 @@ agent = Agent(name="assistant", instructions="You are helpful.")
gateway.register_agent(agent)
await gateway.start()
# 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)
# 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
```
## Summary
+1 -30
View File
@@ -108,34 +108,6 @@ 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
@@ -183,7 +155,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 (includes `element` for `message_element`) |
| `extra_data` | `dict` | Custom payload |
### Event Types (27 total)
@@ -206,7 +178,6 @@ 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"
-13
View File
@@ -22,19 +22,6 @@ Each example teaches exactly **one new concept** — no overlap.
| 12 | agent-dashboard | OpenClaw-style rendered HTML admin panel | `python app.py` |
| 13 | real-dashboard | Production dashboard with seeded data | `python app.py` |
| 14 | all-features | Comprehensive 16-feature API test suite | `python app.py` |
| 15 | dashboard-test | Dashboard component rendering tests | `aiui run app.py` |
| 16 | gateway-integration | Gateway WebSocket integration | `aiui run app.py` |
| 17 | three-column-demo | Three-column layout with sidebar + TOC | `aiui run app.py` |
| 18 | full-chat | Full-featured chat with all options enabled | `aiui run app.py` |
| 19 | components-showcase | All 48 UI components demonstrated | `aiui run app.py` |
| 20 | email-channel | Email channel integration | `aiui run app.py` |
| 21 | agentmail-bot | Agentmail bot integration | `aiui run app.py` |
| 22 | agentmail-channel | Agentmail channel setup | `aiui run app.py` |
| 23 | e2e-components-test | End-to-end component testing | `aiui run app.py` |
| 24 | custom-design | Custom CSS design system with `set_custom_css()` | `aiui run app.py` |
| 25 | clean-chat | Clean chat UI — `sidebar=False`, `page_header=False`, blue theme | `aiui run app.py` |
| 26 | custom-theme-chat | Custom theme registration via `register_theme()` | `aiui run app.py` |
| 27 | multica-style | Floating chat window, collapsible sidebar, indigo theme | `aiui run app.py` |
## Minimal Docs Site
-128
View File
@@ -1,128 +0,0 @@
# 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
@@ -1,223 +0,0 @@
# 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
@@ -1,25 +0,0 @@
# 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.
-56
View File
@@ -1,56 +0,0 @@
# BeautifulUI Integration Strategy
> **Epic:** [#292](https://github.com/MervinPraison/PraisonAIUI/issues/292) — BeautifulUI integration strategy for AI-native dashboard UX.
> This is the durable, agreed strategy that the linked child issues execute against.
[BeautifulUI](https://www.beautifului.dev/) (MIT, Shane Levine / Turbo) is a **copy-paste / shadcn-registry** library of React primitives for AI-native interfaces: thinking traces, streaming answers with inline sources, HITL approval cards, tool chips, task rows, context chunks, diff/records tables, prompt bars, and a reference chat harness.
There is **no published npm package** named `beautiful-ui`. It ships only as source consumed via the shadcn registry or manual copy.
## 1. Strategic decision
BeautifulUI is adopted as **vendored source**, not a runtime dependency, and is exposed to dashboard plugins **only through the existing `@praisonaiui/react` package** — never by importing upstream file paths directly. Rollout is **phased behind a feature flag**, keeping the current vanilla dashboard as the fallback until parity is proven.
Upstream stack (Next.js 15 · React 19 · Tailwind v4 · TypeScript) is closely aligned with PraisonAIUI's `src/frontend` (Vite · React 19 · Tailwind v4 · shadcn/Radix), so primitives port with adapter work rather than a runtime bridge.
## 2. Pillars
| # | Pillar | Decision |
|---|--------|----------|
| 1 | **Vendoring model** | Add primitives into `src/frontend` via `npx shadcn add https://www.beautifului.dev/r/<component>.json`, or manual copy from [github.com/slev12397/beautiful-ui](https://github.com/slev12397/beautiful-ui). Pin via lockfile plus a recorded registry URL / git SHA. No `beautiful-ui` npm dependency in production builds. |
| 2 | **Adapter layer** | Map PraisonAIUI chat / approval / trace **protocol events** to stable internal props. Primitives sit behind `@praisonaiui/react` exports; dashboard plugins consume those exports, not upstream paths. See [#294](https://github.com/MervinPraison/PraisonAIUI/issues/294). |
| 3 | **Hybrid rollout** | Mount React **islands** (or expand `@praisonaiui/react`) for high-value surfaces first — chat streaming/thinking/tools, then approvals. Vanilla chat (`templates/frontend/plugins/views/chat.js`) stays as the flag-gated fallback until parity is proven. |
| 4 | **Token strategy** | Merge BeautifulUI foundation CSS **selectively** into the existing shadcn theme generation (`praisonaiui/themes.py`). One design system per page — avoid two competing token sets. |
| 5 | **Icon licence** | Upstream `SidebarNav` uses the paid `@central-icons-react`. Port nav patterns with **lucide-react** (already in PraisonAIUI) instead. |
## 3. Phasing
1. **Spike** — evaluate primitives in the dev environment, confirm React 19 / Tailwind v4 alignment. [#293](https://github.com/MervinPraison/PraisonAIUI/issues/293)
2. **Architecture** — adapter layer + upstream pin policy. [#294](https://github.com/MervinPraison/PraisonAIUI/issues/294)
3. **CI gate** — frontend build + BeautifulUI compatibility check (no frontend build gate exists today). [#295](https://github.com/MervinPraison/PraisonAIUI/issues/295)
4. **Migrations** — chat streaming / thinking / tool chips ([#296](https://github.com/MervinPraison/PraisonAIUI/issues/296)), then the approval card ([#297](https://github.com/MervinPraison/PraisonAIUI/issues/297)).
5. **Docs** — upgrade playbook + MIT attribution. [#298](https://github.com/MervinPraison/PraisonAIUI/issues/298)
## 4. Risks
| Risk | Mitigation |
|------|------------|
| No semver npm package | Vendor the source; pin to a recorded registry URL / git SHA in the lockfile. |
| Upstream breaking registry JSON | Vendoring decouples builds from live registry; re-sync is a deliberate, reviewed step. |
| Icon licence (`@central-icons-react` is paid) | Use lucide-react for ported nav patterns; never vendor the paid package. |
| Two competing design systems on one page | Selective CSS token merge into `themes.py`; adapter-only prop surface. |
| React / Tailwind drift | Upstream (React 19 / Tailwind v4) already matches `src/frontend`; the CI gate catches drift. |
## 5. Out of scope
- Replacing the entire dashboard shell in one PR.
- PyPI packaging of BeautifulUI.
- iframe embedding of beautifului.dev.
- Depending on any unpublished npm package for production builds.
## 6. Acceptance criteria status
- [x] Written integration strategy agreed (vendoring + adapter + phased rollout) — this document.
- [x] Risks documented — section 4.
- [x] Child issues filed and prioritised — [#293](https://github.com/MervinPraison/PraisonAIUI/issues/293)–[#298](https://github.com/MervinPraison/PraisonAIUI/issues/298).
- [x] No requirement to depend on unpublished npm packages for production builds — pillar 1, out of scope.
+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 surfaces | 🟢 A2UI | Agent-driven UI via `send_a2ui_messages` |
| Canvas (visual workspace) | 🔴 | 🟢 A2UI | Agent-driven UI |
| 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 partial parity | 🟡 Medium |
| **Canvas (A2UI)** | Agent-driven visual workspace vs none | 🟡 Medium |
| **Agent-to-agent** | Sessions-based messaging vs none | 🟡 Medium |
### Strategic Positioning
-80
View File
@@ -313,83 +313,3 @@ if __name__ == "__main__":
| `/api/pages/{id}/data` | GET | Get page handler response |
| `/api/features` | GET | List all registered features |
| `/plugins/plugins.json` | GET | Style-aware frontend plugin list |
---
## Client-Side Extension API
The dashboard provides a JavaScript API for registering custom views from the client side.
### `window.aiui.registerView(pageId, renderFn, cleanupFn)`
Register a custom view renderer for a page ID.
| Parameter | Type | Description |
|-----------|------|-------------|
| `pageId` | `string` | Page ID to handle (e.g. `"my-custom-page"`) |
| `renderFn` | `async (container: HTMLElement) => void` | Async function that renders into the container |
| `cleanupFn` | `() => void` | Optional cleanup function called when navigating away |
```javascript
// Register a custom view
window.aiui.registerView('my-analytics', async (container) => {
const data = await fetch('/api/my-data').then(r => r.json());
container.innerHTML = `
<div class="db-card">
<div class="db-card-title">Custom Analytics</div>
<div class="db-card-value">${data.total}</div>
</div>
`;
}, () => {
// Cleanup (optional) — called when user navigates away
console.log('Cleaning up my-analytics view');
});
```
### View Resolution Priority
When a page is selected, the dashboard resolves views in this order:
1. **Custom registered views** — `window.aiui.registerView()` (highest priority)
2. **Built-in view modules** — Dynamic import from `/plugins/views/*.js`
3. **Generic JSON viewer** — Falls back to rendering `_components` or raw JSON
### `window.aiui.views`
Access the view registry directly:
```javascript
// Check if a view is registered
if (window.aiui.views['my-page']) {
console.log('my-page view is registered');
}
// List all registered views
console.log(Object.keys(window.aiui.views));
```
### Built-in Views
The dashboard includes 22 built-in view modules:
| Page ID | View Module | Description |
|---------|-------------|-------------|
| `chat` | `chat.js` | Chat interface |
| `overview` | `overview.js` | Dashboard overview |
| `agents` | `agents.js` | Agent management |
| `sessions` | `sessions.js` | Session management |
| `memory` | `memory.js` | Memory viewer |
| `knowledge` | `knowledge.js` | Knowledge base |
| `logs` | `logs.js` | Real-time logs |
| `schedules` | `schedules.js` | Scheduled jobs |
| `config` | `config.js` | Runtime config |
| `approvals` | `approvals.js` | Tool approvals |
| `usage` | `usage.js` | Token usage |
| `channels` | `channels.js` | Messaging channels |
| `skills` | `skills.js` | Agent skills |
| `nodes` | `nodes.js` | Execution nodes |
| `guardrails` | `guardrails.js` | Safety guardrails |
| `eval` | `eval.js` | Agent evaluation |
| `telemetry` | `telemetry.js` | Performance metrics |
| `traces` | `traces.js` | Distributed tracing |
| `security` | `security.js` | Security monitoring |
+9 -39
View File
@@ -49,7 +49,7 @@ services:
volumes:
- aiui-data:/data
healthcheck:
test: ["CMD", "curl", "-f", "http://localhost:8082/health/live"]
test: ["CMD", "curl", "-f", "http://localhost:8082/health"]
interval: 30s
timeout: 5s
retries: 3
@@ -81,7 +81,7 @@ curl -H "Authorization: Bearer <api-key>" http://localhost:8082/api/config
```
**Exempt paths** (always accessible without auth):
- `/health`, `/health/live`, `/health/ready`, `/api/health` — health checks
- `/health`, `/api/health` — health checks
- `/api/auth/login`, `/login` — login endpoints
- `/api/protocol`, `/api/protocol/negotiate` — protocol discovery
@@ -146,36 +146,8 @@ This affects:
### Health Endpoint
```bash
# Fast liveness probe (<500ms) - use for load balancers and K8s livenessProbe
curl http://localhost:8082/health/live
# Returns: {"status": "ok", "timestamp": "2024-..."}
# Readiness probe with parallel feature checks - use for K8s readinessProbe
curl http://localhost:8082/health/ready
# Deep diagnostics (parallel feature checks, deep by default)
curl http://localhost:8082/health
# Pass ?deep=false for an immediate liveness response
```
### Kubernetes Probes
Use the fast liveness endpoint for probes to avoid timeouts:
```yaml
livenessProbe:
httpGet:
path: /health/live
port: 8082
initialDelaySeconds: 10
periodSeconds: 30
timeoutSeconds: 1
readinessProbe:
httpGet:
path: /health/ready
port: 8082
initialDelaySeconds: 5
periodSeconds: 10
# Returns: {"status": "healthy", "timestamp": "2024-..."}
```
### Docker HEALTHCHECK
@@ -184,17 +156,17 @@ The Dockerfile includes a built-in health check:
```dockerfile
HEALTHCHECK --interval=30s --timeout=5s --start-period=10s --retries=3 \
CMD curl -f http://localhost:${AIUI_PORT}/health/live || exit 1
CMD curl -f http://localhost:${AIUI_PORT}/health || exit 1
```
### CLI Health Commands
```bash
# Quick health check
aiui health-check --server http://localhost:8082
aiui health --server http://localhost:8082
# Detailed per-feature health
aiui health-check --server http://localhost:8082 --detailed
# Detailed per-feature health
aiui health --server http://localhost:8082 --detailed
# Full diagnostic (7 checks)
aiui doctor --server http://localhost:8082
@@ -227,7 +199,7 @@ curl -H "X-API-Key: <key>" http://localhost:8082/api/config
- **Internal networks**: Use `AUTH_ENFORCE=false` if behind a trusted reverse proxy
- **Public exposure**: Always set `AUTH_ENFORCE=true` and use HTTPS
- **Load balancers**: Health check path is `/health/live` (fast, exempt from auth)
- **Load balancers**: Health check path is `/health` (exempt from auth)
## Managed Hosting
@@ -237,9 +209,7 @@ Hosting platforms should expose these endpoints:
| Endpoint | Purpose |
|----------|---------|
| `/health/live` | Fast liveness probe (returns `{"status": "ok"}`) |
| `/health/ready` | Readiness probe (parallel feature checks) |
| `/health` | Deep health (parallel feature checks; `?deep=false` for liveness) |
| `/health` | Health check (returns `{"status": "healthy"}`) |
| `/api/protocol` | Protocol version and capabilities |
| `/api/protocol/negotiate` | Capability negotiation |
| `/api/features` | List of registered features |
-206
View File
@@ -1,206 +0,0 @@
# What Makes PraisonAIUI Different
PraisonAIUI is a **protocol-driven UI framework** for building AI applications. This page explains how it differs from other popular tools.
## Quick Comparison
| Feature | PraisonAIUI | Streamlit | Gradio |
|---------|-------------|-----------|--------|
| **Architecture** | Protocol-driven (SDK→JSON→Client) | Script re-run | Input→Output |
| **UI Styles** | 6 (chat, agents, dashboard, playground, docs, custom) | 1 | 1 |
| **Components** | 48 structured | ~30 widgets | ~20 blocks |
| **Custom Pages** | `@aiui.page()` decorator | Multipage apps | Tabs |
| **Theme System** | 22 presets + custom CSS variables | Limited | Limited |
| **Agent-First** | ✅ Built for agents | ❌ General | ❌ ML demos |
| **Extensibility** | Plugin architecture | ❌ | ❌ |
| **YAML Config** | ✅ Full config | ❌ | ❌ |
---
## vs Streamlit
### How Streamlit Works
Streamlit re-runs your entire Python script on every interaction. The server renders HTML and sends it to the browser.
```python
# Streamlit
import streamlit as st
if st.button("Click me"):
st.write("Clicked!") # Entire script re-runs
```
### How PraisonAIUI Works
PraisonAIUI uses a **protocol-driven architecture**. Python functions return JSON dicts, which the client renders independently.
```python
# PraisonAIUI
import praisonaiui as aiui
@aiui.page("dashboard")
async def dashboard():
return aiui.layout([
aiui.card("Users", value=42), # Returns JSON, client renders
])
```
### Key Differences
| Aspect | PraisonAIUI | Streamlit |
|--------|-------------|-----------|
| **Rendering** | Client-side from JSON | Server-side HTML |
| **State** | WebSocket + sessions | Script re-run |
| **Chat** | Real-time WebSocket streaming | Polling |
| **Extensibility** | `window.aiui.registerView()` | Limited |
**When to use PraisonAIUI**: Real-time chat, agent dashboards, multi-page admin panels, custom themes.
**When to use Streamlit**: Quick data apps, ML prototypes, simple dashboards.
---
## vs Gradio
### How Gradio Works
Gradio is designed for ML demos with an **input→output paradigm**. You define inputs, a function, and outputs.
```python
# Gradio
import gradio as gr
def greet(name):
return f"Hello {name}!"
gr.Interface(fn=greet, inputs="text", outputs="text").launch()
```
### How PraisonAIUI Works
PraisonAIUI is designed for **conversational AI** with callbacks and structured components.
```python
# PraisonAIUI
import praisonaiui as aiui
@aiui.reply
async def on_message(message):
await aiui.say(f"Hello {message.content}!")
```
### Key Differences
| Aspect | PraisonAIUI | Gradio |
|--------|-------------|--------|
| **Paradigm** | Conversational callbacks | Input→Output |
| **Components** | 48 structured (cards, tables, charts) | ~20 blocks |
| **Dashboard** | Full admin panel with pages | Tabs only |
| **Agent Support** | Built-in profiles, tools, memory | Manual |
**When to use PraisonAIUI**: Chat agents, admin dashboards, multi-agent systems.
**When to use Gradio**: ML model demos, quick input/output interfaces.
---
## Unique to PraisonAIUI
### 1. Protocol-Driven Architecture
Every feature implements a `FeatureProtocol` ABC with SDK-first fallback:
```
FeatureProtocol (ABC)
├── SDKFeatureManager ← wraps praisonaiagents SDK
└── SimpleFeatureManager ← in-memory fallback
```
### 2. 48 Structured Components
Python functions return JSON dicts that the client renders:
```python
aiui.card("Revenue", value="$1,500", footer="+8%")
# → {"type": "card", "title": "Revenue", "value": "$1,500", "footer": "+8%"}
```
Components include: cards, tables, charts, forms, alerts, badges, tabs, accordions, and more.
### 3. 6 UI Styles
| Style | Use Case |
|-------|----------|
| `chat` | Conversational AI |
| `agents` | Multi-agent playground |
| `playground` | Prompt testing |
| `dashboard` | Admin panels |
| `docs` | Documentation sites |
| `custom` | Full control |
### 4. Client-Driven Plugin Architecture
Extend the dashboard with custom views:
```javascript
window.aiui.registerView('my-page', async (container) => {
container.innerHTML = '<h1>Custom Page</h1>';
});
```
### 5. Theme System
22 Tailwind presets + custom CSS variables + dark/light mode:
```python
aiui.set_theme(preset="blue", dark_mode=True, radius="lg")
aiui.set_custom_css(":root { --db-accent: #22c55e; }")
aiui.register_theme("ocean", {"accent": "#0077b6"})
```
### 6. YAML + Python Dual Config
Same features configurable either way:
```yaml
# YAML
site:
theme:
preset: blue
darkMode: true
```
```python
# Python
aiui.set_theme(preset="blue", dark_mode=True)
```
---
## When to Choose PraisonAIUI
✅ **Choose PraisonAIUI when you need:**
- Real-time chat with WebSocket streaming
- Multi-agent systems with profile switching
- Admin dashboards with custom pages
- 48 structured UI components
- Extensible plugin architecture
- YAML-driven configuration
- Custom themes with CSS variables
- Documentation sites
❌ **Consider alternatives when you need:**
- Quick ML model demos → Gradio
- Simple data dashboards → Streamlit
---
## Related Pages
- [UI Styles](styles.md) — The 6 UI styles
- [CSS Architecture](css-architecture.md) — Theme system deep dive
- [Component API](../api/components.md) — All 48 components
- [Dashboard](dashboard.md) — Custom pages with `@aiui.page()`
-316
View File
@@ -1,316 +0,0 @@
# How to Add a Feature to PraisonAIUI
This guide explains every way a user can extend PraisonAIUI — and which approach fits your use case.
## Extension Points at a Glance
| I want to add… | Use | Side | Effort |
|----------------|-----|------|--------|
| A new dashboard page | `@aiui.page()` | Server | 5 lines |
| A form that saves data | `aiui.form_action()` + `@aiui.register_page_action()` | Both | 15 lines |
| A brand-new UI component type | `window.aiui.registerComponent()` + dict type | Both | 20 lines |
| A page rendered entirely in the browser | `window.aiui.registerView()` | Client | 10 lines |
| Ship client-side JS from Python | `aiui.set_custom_js(path)` | Server | 1 line |
| A typed contract for a component | `aiui.register_component_schema(type, schema)` | Server | 5 lines |
| A custom theme | `aiui.register_theme()` | Server | 1 line |
| A new AI backend | Subclass `aiui.BaseProvider` | Server | ~50 lines |
| A whole new feature module (routes, state) | Subclass `BaseFeatureProtocol` + `register_feature()` | Server | ~100 lines |
| Persistence backend | Subclass `aiui.BaseDataStore` | Server | ~50 lines |
---
## Decision Flow
```
┌─────────────────────────────────────┐
│ What do you want to add? │
└────────────────┬────────────────────┘
│
┌───────────┼───────────────────────────────┐
▼ ▼ ▼
A PAGE A COMPONENT A BACKEND THING
│ │ │
▼ ▼ ▼
@aiui.page Is it in the 48 Is it a provider?
│ built-in components? → BaseProvider
▼ │ Is it storage?
Compose from │ → BaseDataStore
existing Yes │ No Is it a feature with
components │ │ its own routes/state?
│ ▼ ▼ → BaseFeatureProtocol
│ Use it Custom component
│ via registerComponent
▼
Need form data back?
→ add form_action
+ @register_page_action
```
---
## 1. Add a new page
**Goal**: Show a new page in the sidebar with custom content.
```python
import praisonaiui as aiui
@aiui.page("analytics", title="Analytics", icon="📈", group="Custom")
async def analytics():
return aiui.layout([
aiui.columns([
aiui.card("Revenue", value="$12,340"),
aiui.card("Users", value="1,204"),
]),
aiui.chart(data=[10, 20, 30], type="line"),
])
```
**What happens**:
1. The page is registered in the server's page registry.
2. It appears in the sidebar at `/api/pages`.
3. When the user clicks it, the frontend fetches `/api/pages/analytics/data`.
4. Your handler returns a dict; `aiui.layout()` wraps it in `{"_components": [...]}`.
5. `dashboard.js` loops over `_components` and calls `renderComponent(comp)` on each.
See [Dashboard](dashboard.md) for the full `@aiui.page()` signature.
---
## 2. Add an interactive form
**Goal**: Let users submit data from the UI back to your Python code.
```python
import praisonaiui as aiui
_saved = []
@aiui.page("contact", title="Contact", icon="📝")
async def contact_form():
return aiui.layout([
aiui.form_action(
"contact",
submit_label="Save",
children=[
aiui.text_input("Name"),
aiui.text_input("Email"),
aiui.select_input("Role", options=["Engineer", "PM"]),
aiui.checkbox_input("Subscribe", checked=True),
],
),
])
@aiui.register_page_action("contact")
async def handle_contact(data: dict) -> dict:
_saved.append(data)
return {"status": "saved", "count": len(_saved)}
```
**What happens**:
1. The form renders with all inputs.
2. On submit, the frontend POSTs to `/api/pages/contact/action`.
3. The server invokes `handle_contact(data)` with the form values keyed by label.
4. The returned dict is sent back to the browser as JSON.
> **Note**: Input labels become dict keys. Use distinct labels per input.
---
## 3. Add a custom component type
**Goal**: Add a new UI widget that doesn't exist in the 48 built-ins.
### Step 1 — Python side: return a dict with your new type
```python
@aiui.page("activity", title="Activity", icon="🕒")
async def activity():
return aiui.layout([
{
"type": "timeline", # ← custom type
"events": [
{"time": "09:00", "label": "Deploy", "icon": "🚀"},
{"time": "13:00", "label": "Review", "icon": "🔍"},
],
},
])
```
**If you stop here**, the component renders as formatted JSON (graceful fallback).
### Step 2 — Client side: register a renderer
Put this in a `plugin.js` file (or paste into DevTools Console):
```javascript
window.aiui.registerComponent('timeline', function(comp) {
const el = document.createElement('div');
el.className = 'db-timeline';
(comp.events || []).forEach(ev => {
const row = document.createElement('div');
row.innerHTML = `<b>${ev.icon}</b> ${ev.time} — ${ev.label}`;
el.appendChild(row);
});
return el;
});
```
**What happens**:
1. `renderComponent(comp)` checks `COMPONENT_REGISTRY[comp.type]` before the built-in switch.
2. Your renderer takes priority and returns a DOM element.
### Auto-load `plugin.js` from Python
Use `aiui.set_custom_js(path)` to ship client-side extensions directly from
your app:
```python
import praisonaiui as aiui
aiui.set_custom_js("plugin.js") # reads file, serves at /custom.js,
# injects <script> after the plugin loader
```
The script runs **after** the plugin loader, so `window.aiui.registerView`
and `window.aiui.registerComponent` are ready when it executes. No DevTools
workaround needed.
---
## 4. Add a client-only page
**Goal**: A page rendered entirely in the browser — no server round-trip on navigation.
```javascript
// In plugin.js
window.aiui.registerView('live-feed', async (container) => {
container.innerHTML = '<h1>Live Feed</h1>';
const ws = new WebSocket('wss://your-feed.example.com');
ws.onmessage = (e) => {
const div = document.createElement('div');
div.textContent = e.data;
container.appendChild(div);
};
// Store ws for cleanup
container._ws = ws;
}, () => {
// Cleanup when user navigates away
if (container._ws) container._ws.close();
});
```
Still register a placeholder page in Python so it appears in the sidebar:
```python
@aiui.page("live-feed", title="Live Feed", icon="📡")
async def live_feed():
return aiui.layout([aiui.text("Loading…")])
```
**What happens**:
1. `registerView` takes priority over the built-in + server-side rendering.
2. Navigation calls your `render(container)` function.
3. When the user navigates away, your `cleanup()` function runs.
---
## 5. Register a custom theme
```python
aiui.register_theme("ocean", {"accent": "#0077b6"})
aiui.set_theme(preset="ocean")
```
See [CSS Architecture](css-architecture.md) for all variables.
---
## 6. Add a new AI provider
```python
from praisonaiui.provider import BaseProvider, RunEvent, RunEventType
class MyProvider(BaseProvider):
async def run(self, message, **kw):
yield RunEvent(type=RunEventType.RUN_STARTED)
yield RunEvent(type=RunEventType.RUN_CONTENT, token="Hello")
yield RunEvent(type=RunEventType.RUN_COMPLETED, content="Hello")
aiui.set_provider(MyProvider())
```
See [Providers](../concepts/providers.md).
---
## 7. Add a feature module (advanced)
Features live in `praisonaiui/features/*.py` and implement `BaseFeatureProtocol`:
```python
from praisonaiui.features import BaseFeatureProtocol, register_feature
from starlette.routing import Route
from starlette.responses import JSONResponse
class MyFeature(BaseFeatureProtocol):
name = "my-feature"
feature_name = "my-feature"
def routes(self):
return [
Route("/api/my-feature", self._handler, methods=["GET"]),
]
async def _handler(self, request):
return JSONResponse({"data": "hello"})
register_feature(MyFeature())
```
See [Protocol Architecture](protocol-architecture.md).
---
## Is This the Best Approach?
### Strengths ✅
- **Clean separation** — Python returns dicts, client renders DOM. You can mix and match server/client work per-feature.
- **Graceful degradation** — Unknown component types render as JSON (no crashes).
- **Zero build step for Python users** — No webpack/TS compile required for server-side pages.
- **Single source of truth** — Every feature has a protocol ABC.
- **Hot-swap friendly** — `window.aiui.registerView/Component` updates live without server restart.
### Previously reported gaps — all closed ✅
| Gap | Resolution |
|-----|------------|
| No `aiui.set_custom_js(path)` | **Implemented** — serves the file at `/custom.js` and injects a `<script>` tag after the plugin loader. Unit tests: `tests/unit/test_custom_js.py` (10 tests). |
| `form_action` uses input labels as keys | **Implemented** — every form input now accepts an optional `name=` kwarg that becomes the submit-key. Tests: `tests/unit/test_form_input_names.py` (18 tests). |
| No typed Python ⇄ JS contract | **Implemented** — `aiui.register_component_schema(type, schema)` + `GET /api/components/schemas`. Built-in schemas are auto-derived from `ui.py` signatures. Tests: `tests/unit/test_component_schemas.py` (9 tests). |
| `/api/features` cold-start slow | **Mitigated** — `info()` calls are parallelized via `asyncio.gather`. Warm calls are now instant; cold starts are dominated by LiteLLM import (out of scope). |
| `pygments 2.20` mkdocs build crash on `providers.md` | **Fixed** — `docs_hooks.py` patches `pymdownx.BlockHtmlFormatter.__init__` to coerce `filename=None` → `""`. |
### When NOT to use this approach
- **You need a full SPA with routing**: Use the Next.js adapter or scaffold a React project with `aiui init --frontend`.
- **Your UI needs TypeScript types**: Use the TypeScript SDK (`@praisonaiui/runtime`).
- **You need server-sent events beyond chat**: Extend `BaseFeatureProtocol` with a WebSocket route.
---
## Checklist for a New Feature
- [ ] Decide: is it a page, a component, or a backend module?
- [ ] Write the Python side first (returns a dict / routes).
- [ ] If you added a new component type: implement the JS renderer.
- [ ] Write a unit test (see `tests/unit/test_form_action.py` as an example).
- [ ] Add a runnable example under `examples/python/`.
- [ ] Update the docs under `docs/features/` or `docs/api/`.
- [ ] Run `mkdocs build --strict`.
- [ ] Run `pytest tests/unit -v`.
-171
View File
@@ -1,171 +0,0 @@
# 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
-69
View File
@@ -1,69 +0,0 @@
# Knowledge Embedding Model Configuration
Knowledge / RAG search uses vector **embeddings**. The default embedding model is
`text-embedding-3-small`. Many OpenAI keys or projects do not have access to that
model (organization tier, model allowlist, or regional restrictions), which caused
knowledge search to silently degrade to local substring matching.
PraisonAIUI now makes this failure mode configurable and observable.
## Configure the embedding model
Set an accessible model via environment variable:
```bash
export AIUI_EMBEDDING_MODEL=text-embedding-ada-002
```
`OPENAI_EMBEDDING_MODEL` is also honoured as a fallback if `AIUI_EMBEDDING_MODEL`
is not set.
## Fallback chain
The OpenAI-compatible `/v1/embeddings` endpoint tries the requested (or default)
model first, then each model in the fallback chain if the primary returns a
`model_not_found` / 403 access error:
```bash
export AIUI_EMBEDDING_FALLBACK_MODELS="text-embedding-ada-002,text-embedding-3-small"
```
If every candidate is inaccessible, the endpoint returns `503` with an actionable
`model_not_found` error instead of a generic `500`.
## Observing degraded search
`GET /api/knowledge/status` reports embedding availability:
```json
{
"total": 12,
"backend": "SDKKnowledgeManager",
"status": "degraded",
"embedding": {"available": false, "error": "Project does not have access to model `text-embedding-3-small`"},
"warnings": ["Embedding model unavailable; knowledge search degraded to local text matching. Set AIUI_EMBEDDING_MODEL to an accessible model."]
}
```
`POST /api/knowledge/search` indicates whether results came from vector search or a
degraded local fallback:
```json
{
"results": [],
"count": 0,
"search_mode": "fallback",
"warning": "Vector search unavailable: Project does not have access to model text-embedding-3-small. Set AIUI_EMBEDDING_MODEL=text-embedding-ada-002 or enable embedding models in your provider project."
}
```
A dashboard can show a warning banner whenever `search_mode == "fallback"` or
`embedding.available == false`.
## Model reference
| Model | Notes |
|-------|-------|
| `text-embedding-3-small` | Default — best cost/quality when available |
| `text-embedding-3-large` | Higher quality — may also be restricted |
| `text-embedding-ada-002` | Legacy — widely available on older projects |
+15 -8
View File
@@ -1,6 +1,6 @@
# Message Elements
PraisonAIUI supports rich inline message elements for displaying images, PDFs, videos, audio, files, and code blocks within chat messages.
PraisonAIUI supports rich inline message elements for displaying images, PDFs, videos, audio, files, and code blocks within chat messages. This provides Chainlit-style element rendering parity for seamless migration.
## Element Types
@@ -146,6 +146,19 @@ All elements support three display modes:
- **Files**: Icon-based file type recognition
- **Code**: Copy button with keyboard support
## Migration from Chainlit
PraisonAIUI elements provide direct parity with Chainlit elements:
| Chainlit | PraisonAIUI |
|----------|-------------|
| `cl.Image()` | `msg.add_image()` |
| `cl.Pdf()` | `msg.add_pdf()` |
| `cl.Video()` | `msg.add_video()` |
| `cl.Audio()` | `msg.add_audio()` |
| `cl.File()` | `msg.add_file()` |
| Code blocks | `msg.add_code()` |
## Low-Level API
For advanced use cases, you can use the low-level element API:
@@ -172,10 +185,4 @@ 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.
## 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.
This ensures existing code continues to work while providing a migration path to the new standardized element system.
@@ -1,201 +0,0 @@
# 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
-176
View File
@@ -1,176 +0,0 @@
# Replay
**PraisonAI Replay** shows a recorded agent run as a story: who did what, when, why, and
at what cost. It works for finished runs and for runs that are still going.
## Record a run, then open it
```bash
praisonai replay view agents.yaml # records the run and opens Replay on it
```
or from Python:
```python
from praisonai.replay import record
with record(live=True):
team.start()
```
Every recorded run is a file `<id>.jsonl` in the traces folder. Replay reads it from
`$PRAISONAI_TRACES_DIR`, else `$PRAISON_HOME/traces`, else `~/.praison/traces`.
To open aiui on Replay yourself:
```bash
aiui run "$(python -c 'import praisonaiui.replay_app as m; print(m.__file__)')" \
--style dashboard --host 127.0.0.1 --port 8090
# then open http://127.0.0.1:8090/replay?run=<id>
```
`/#/replay?run=<id>` works too. In a full dashboard, Replay is in the sidebar under
**Control**.
## Watch a run live: the Run button
For learning, demos and debugging, open a page with a **Run** button. Each click records
a new run, and the page draws it as it happens:
```python
from praisonai.replay import watch
watch(flow, input="solar sails") # an Agent, AgentTeam, AgentFlow or agents.yaml
```
```bash
praisonai replay watch agents.yaml
```
On the **Map**, each step and agent shows where the run is: dashed means not reached,
blue (pulsing) means running now, green means done and red means failed. After a run,
**▶ Play** moves through it again one step at a time, and the map rewinds with it. One
run goes at a time. Each run of a team starts its tasks afresh; an Agent keeps its chat
history between runs.
## Show progress inside your own app
`create_replay_app()` is the same view as a small ASGI app, without the dashboard. Mount
it in any FastAPI or Starlette app:
```python
from praisonai.replay import record, replay_app
app.mount("/replay", replay_app()) # read-only: shows recorded runs
# wherever your app starts work:
with record(run_id, live=True):
team.start()
```
Then show one run, live, in your page, for example in an `<iframe>`:
```html
<iframe src="/replay/?run=RUN_ID" style="width:100%;height:640px"></iframe>
```
| URL | Shows |
|---|---|
| `/replay/` | The run list and the selected run |
| `/replay/?run=<id>` | Only that run, no list. A run that has not started yet is waited for. |
`replay_app(team)` adds the Run box to the mounted page. Without the PraisonAI wrapper,
pass any `run(input, run_id)` callable to `praisonaiui.features.replay.create_replay_app`;
it must record the run under `run_id`. For a page you build yourself, the view is one
module:
```html
<div id="progress"></div>
<script type="module">
import { render } from "/replay/replay.js";
render(document.getElementById("progress"), { base: "/replay", run: "RUN_ID", approve: false });
</script>
```
`render()` options: `base` (where the API is mounted), `run` (show only this run),
`runner` (`{input, placeholder, ask}` to show the Run box) and `approve` (`false` hides
the approve and deny buttons; the embedded app has no approvals API, so answer approvals
where your app asks for them).
Anyone who can reach the mounted app can read the runs, and with a runner can start one.
Keep it on localhost or behind your app's login. `POST /api/replay/run` only accepts
`Content-Type: application/json`, so another website cannot start a run from a visitor's
browser.
## What you see
| Part | What it shows |
|---|---|
| **Runs** | Recorded runs, newest first. A run that is still being written has a **Live** badge. |
| **Timeline** (default) | One lane per agent, plus **Manager** and **You**. Time runs left to right. LLM calls and tool calls are blocks in their agent's lane. Arrows show hand-offs, delegation, routing and approval requests. Parallel branches get their own sub-lanes. A call or step repeated back to back is one block marked ×N. Tasks and steps run across the top. |
| **Map** | The team or flow as a graph, drawn from the graph recorded at the start of the run and fitted to the page (−, Fit and + zoom; drag to pan). Agents that took part but are not in the graph, for example after a hand-off to an agent outside the team, appear in an **Outside the team** group with their hand-offs. Older recordings without a graph get a "who talked to whom" map built from the events. Each node shows its state at the cursor: dashed means not reached, blue means running, green means done (×N when it ran more than once) and red means failed; the node the cursor is on has a bold outline. |
| **What happened** | One plain sentence per step, for example *"Researcher asked web_search for 'solar sail facts' — 1.2 s, $0.002."* Hovering or focusing a sentence highlights its block, and the other way round. |
| **Details** | Three tabs for the selected step. **What happened**: when, who, how long, cost, tokens, output. **Why**: only what the recording holds, such as the input given to a tool, the context passed in a hand-off and its reason, the manager's task snapshot, the route a step chose, and an approval's decision and where an automatic approval came from. **Raw JSON**: the events themselves. |
| **Cost & latency** | Adds a bar under each block (taller and darker means more cost, or more time) and per-agent totals in the lane labels. |
Shapes always carry meaning as well as colour: agents are circles, tools are rounded
rectangles, you are a diamond and the manager is a square. In each lane, LLM calls sit on
the top row and that agent's tool calls on a thinner row under them, so a long LLM call
never hides its tools. Labels that do not fit end in "…"; hover or focus a block to see the
whole label.
The **Theme** menu follows your system's light or dark setting by default; choose Light or
Dark to override it. On a phone the run list, timeline and steps stack in one column.
## Moving through a run
Replay opens at the end of the run, so you see everything. Move the cursor to replay it:
anything after the cursor is drawn as an outline.
| Key | Action |
|---|---|
| ← → | Previous / next step |
| ↑ ↓ | Previous / next step in the step list; previous / next node in the map |
| Home / End | First / last step |
| Enter or Space | Open the focused block or node |
| ← → in the tabs | Switch between What happened, Why and Raw JSON |
The scrubber under the timeline does the same with a mouse or touch, and **▶ Play** steps
through on its own. On a live run the
cursor follows new events; if you step back, **Jump to live** takes you to the latest.
## Approving from Replay
When an agent asks for approval and the run uses aiui's approval store, a **Waiting for
you** card appears above the timeline:
- **Approve** or **Deny**, with an optional reason.
- **Edit arguments…** opens the arguments as JSON (taken from the server, not the redacted
recording when the server has them). The JSON must be an object; mistakes are shown
as you type. **Approve with these arguments** sends them as `modified_args`.
These buttons call `POST /api/approvals/{id}/approve` and `/deny`. The agent waits until
you answer, and is denied if nobody answers before the approval timeout.
## Sharing a run as one file
`praisonai replay export RUN --format html` writes a single HTML file with the run and
this viewer inside. It needs no server and no network. Approvals cannot be answered from
an exported file.
## HTTP API
| Endpoint | Returns |
|---|---|
| `GET /api/replay/runs` | `[{run_id, session_id, path, started_at, ended_at?, status, kind, name, agents, events, cost_usd, live}]`, newest first |
| `GET /api/replay/runs/{id}/events?after_seq=N` | `{events, graph, run}`; `after_seq` keeps only events with a larger `sequence_num`; `wait=1` answers `{events: [], graph: null, run: null}` instead of 404 for a run that has not started yet |
| `GET /api/replay/runs/{id}/stream` | Server-sent events: `event: ctx` per trace event (with `id:` = `sequence_num`), `ping` every 15 s, `end` once the run has finished and gone quiet for 2 s. Honours `after_seq` and `Last-Event-ID`. |
With a runner (`create_replay_app(run=...)`), `POST /api/replay/run` with
`{"input": "..."}` starts a run and returns `{run_id}` (409 while one is going).
Run ids must match `^[A-Za-z0-9._-]+$`. Replay never writes trace files itself. Half-written last lines, unknown fields and unknown event types are skipped
safely, and schema 1.0 recordings still open.
The **Traces** page reads the same files: each run is a trace, and its spans come from
matching start and end events.
+51 -54
View File
@@ -1,76 +1,73 @@
# SEO
Built-in SEO for documentation sites — sitemap, robots, llms.txt, per-page meta tags, and crawlable static HTML.
Built-in SEO features for better search engine visibility.
## Configuration
## Automatic Meta Tags
Top-level `seo` block in `aiui.template.yaml`:
PraisonAIUI generates meta tags from your configuration:
```yaml
seo:
siteUrl: "https://ui.praison.ai"
titleTemplate: "%s | %s"
defaultImage: "/icon.svg"
robots:
index: true
follow: true
twitter:
handle: "@yourhandle"
site:
title: "My Documentation"
description: "Comprehensive guides for my product"
```
`siteUrl` is required for absolute canonical URLs, sitemap, and social tags.
Generates:
```html
<title>My Documentation</title>
<meta name="description" content="Comprehensive guides for my product">
```
## Build outputs
## Page-Level Overrides
`aiui build` automatically generates:
| File | Purpose |
|------|---------|
| `sitemap.xml` | All doc URLs with `lastmod` dates |
| `robots.txt` | Crawl rules + sitemap pointer |
| `llms.txt` | AI crawler index ([llmstxt.org](https://llmstxt.org)) |
| `llms-full.txt` | Full markdown export for AI tools |
| `docs/**/*.html` | Per-route HTML with meta tags + crawl nav |
| `.nojekyll` | GitHub Pages Jekyll bypass |
No extra flags are needed — SEO files are emitted whenever `content.docs` is configured.
## Page-level overrides
Use frontmatter on any markdown page:
Use frontmatter to override per-page:
```markdown
---
title: "Custom Page Title"
description: "Custom meta description for search snippets"
noindex: true
description: "Custom meta description for this page"
---
# Page Content
```
If `description` is omitted, the first paragraph is used automatically.
## Per-page HTML includes
Each route HTML file contains:
- `<link rel="canonical">` (absolute HTTPS)
- `<meta name="description">`
- Open Graph tags (`og:title`, `og:description`, `og:url`, `og:type`, `og:image`)
- Twitter Card tags
- JSON-LD `TechArticle` schema
- Static `<nav>` with links to every doc page (for non-JS crawlers)
- `<noscript>` pre-rendered page content
## Runtime (SPA navigation)
When users navigate via the sidebar, React updates title, canonical, OG, Twitter, robots, and JSON-LD to match the current page.
## Deploy
GitHub Pages deploy runs via the **Deploy Docs** workflow on every push to `main`. Submit your sitemap in [Google Search Console](https://search.google.com/search-console):
## SEO Configuration
```yaml
site:
seo:
titleTemplate: "%s | My Site"
defaultImage: "/og-image.png"
twitterHandle: "@myhandle"
```
https://your-domain.com/sitemap.xml
## Open Graph
Automatic Open Graph tags for social sharing:
```html
<meta property="og:title" content="Page Title">
<meta property="og:description" content="Page description">
<meta property="og:image" content="/og-image.png">
<meta property="og:type" content="website">
```
## Sitemap
Generate a sitemap during build:
```bash
aiui build --sitemap
```
Creates `sitemap.xml` with all pages.
## Robots.txt
```yaml
site:
seo:
robots:
index: true
follow: true
```
-53
View File
@@ -1,53 +0,0 @@
# 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).
-112
View File
@@ -1,112 +0,0 @@
# Steps / Chain-of-Thought
Visualize agent reasoning and tool calls as collapsible, nested steps in the chat interface.
## Basic Usage
```python
import praisonaiui as aiui
@aiui.reply
async def agent_response(message):
# Tool call step
async with aiui.Step(name="🔧 Tool: web_search", type="tool_call") as step:
await step.stream_token("Input: { query: 'rust async ecosystem 2026' }")
# ... perform actual tool call
await step.stream_token("Output: [ 10 search results ]")
# Reasoning step
async with aiui.Step(name="🧠 Reasoning: Synthesizing results", type="reasoning") as step:
await step.stream_token("I see three major async runtimes...")
return "Rust's async ecosystem in 2026 is dominated by..."
```
## Nested Steps
Steps can be nested to show hierarchical reasoning:
```python
async with aiui.Step(name="Multi-agent research", type="reasoning") as parent:
await parent.stream_token("Starting research task...")
# Sub-agent step
async with aiui.Step(name="🤖 Sub-agent: Technical Analysis", type="sub_agent", parent=parent) as sub:
await sub.stream_token("Analyzing technical details...")
# Tool called by sub-agent
async with aiui.Step(name="🔧 Tool: code_search", type="tool_call", parent=sub) as tool:
await tool.stream_token("Searching codebase...")
```
## Step Types
Common step patterns:
```python
# Tool calls
async with aiui.Step(name="🔧 Tool: database_query", type="tool_call") as step:
await step.stream_token("Querying user database...")
# Reasoning/thinking
async with aiui.Step(name="🧠 Reasoning: Plan evaluation", type="reasoning") as step:
await step.stream_token("Evaluating different approaches...")
# Sub-agents
async with aiui.Step(name="🤖 Sub-agent: Code reviewer", type="sub_agent") as step:
await step.stream_token("Reviewing code quality...")
# Retrieval
async with aiui.Step(name="📚 Retrieval: Knowledge search", type="retrieval") as step:
await step.stream_token("Searching knowledge base...")
# Custom
async with aiui.Step(name="⚡ Custom: Data processing", type="custom") as step:
await step.stream_token("Processing large dataset...")
```
## Frontend Visualization
Steps appear in the chat as collapsible panels showing:
- **Icons** indicating step type (🔧 🧠 🤖 📚 ⚡)
- **Expandable content** with input/output details
- **Nested hierarchy** for complex reasoning chains
- **Real-time updates** as steps stream content
## API Reference
### `aiui.Step(name, type="reasoning", parent=None, metadata={})`
**Parameters:**
- `name` (str): Display name for the step
- `type` (StepType, optional): Step type - one of `"tool_call"`, `"reasoning"`, `"sub_agent"`, `"retrieval"`, `"custom"` (default: `"reasoning"`)
- `parent` (Step, optional): Parent step for nesting
- `metadata` (dict, optional): Additional step metadata
**Methods:**
- `async stream_token(token)`: Stream content to the step
- Context manager protocol: `async with step: ...`
### `@aiui.step(name, type="reasoning", **metadata)`
**Decorator Parameters:**
- `name` (str): Display name for the step
- `type` (StepType, optional): Step type (default: `"reasoning"`)
- `**metadata`: Additional metadata to include with the step
**Usage:**
```python
@aiui.step("🔧 Tool: web_search", type="tool_call")
async def web_search(query: str):
# Function will be wrapped in Step context
result = await search_web(query)
return result
```
### Events Emitted
Steps emit these `RunEventType` events:
- `REASONING_STARTED`: Step begins (includes `step_type`, `parent_id`, `metadata`)
- `REASONING_STEP`: Content streamed (includes `step_type`)
- `REASONING_COMPLETED`: Step finishes (includes `step_type`, `duration`, `error`, `metadata`)
-2
View File
@@ -101,8 +101,6 @@ The theme system injects CSS custom properties into the `:root` element. All cha
## Related
- [CSS Architecture](css-architecture.md) — Deep dive into CSS layers, variables, and cascade order
- [Gateway Chat](gateway-chat.md) — Chat interface (themed)
- [Theming](theming.md) — General theming concepts
- [Dark Mode](dark-mode.md) — Dark mode toggle
- [Python API](../api/python.md) — `set_theme()`, `set_custom_css()`, `register_theme()`
-9
View File
@@ -138,12 +138,3 @@ cd frontend
pnpm install
pnpm dev
```
---
## Related Pages
- [CSS Architecture](css-architecture.md) — Deep dive into CSS layers, variables, and cascade order
- [Theme System](theme-system.md) — Light/dark mode and custom theme registration
- [Dark Mode](dark-mode.md) — Dark mode toggle configuration
- [Python API](../api/python.md) — `set_theme()`, `set_custom_css()`, `register_theme()`
-114
View File
@@ -1,114 +0,0 @@
# 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
-35
View File
@@ -1,35 +0,0 @@
# BeautifulUI upstream pin record
BeautifulUI is vendored (shadcn-style registry copy), **not** an npm dependency.
This file is the single source of truth for what upstream revision is vendored.
Update it in the **same PR** that adds or bumps any vendored component, and make
sure the frontend CI gate (issue #295) passes.
See [beautifului-adapter-architecture.md](./beautifului-adapter-architecture.md)
for the adapter boundary and folder layout.
## Pin
| Field | Value |
|-------|-------|
| Upstream repo | _TBD — record on first vendor_ |
| Git SHA | _TBD_ |
| Vendored on (date) | _TBD_ |
| Registry base URL | _TBD_ |
| License | MIT (kept in-tree) |
## Vendored components
Record one row per component the first time it is copied in.
| Component | Registry URL | SHA | Vendored path | Date |
|-----------|--------------|-----|---------------|------|
| _(none yet)_ | | | | |
## Upgrade checklist
1. Update the SHA / date / registry rows above.
2. Re-copy changed components into `src/frontend/src/agent-ui/vendors/beautifului/`.
3. Keep all upstream imports inside `src/agent-ui/` (adapter boundary rule).
4. Run the frontend build + adapter unit tests (frontend CI gate, #295).
5. Do **not** add a `beautiful-ui` entry to `package.json` dependencies.
@@ -1,102 +0,0 @@
# ADR: BeautifulUI adapter layer & upstream pin policy
**Status:** Accepted · **Issue:** [#294](https://github.com/MervinPraison/PraisonAIUI/issues/294)
## Context
BeautifulUI is **not** a semver npm library — it is distributed as shadcn-style
registry components that are copied into the consuming repo. PraisonAIUI must
integrate it without leaking upstream types or import paths across the codebase,
and without breaking when either side updates.
## Decision
Introduce a single **adapter boundary** between transport payloads and the UI,
and vendor BeautifulUI components behind that boundary.
```
Agent APIs (Python) ──► event bus / WS messages
│
▼
Agent-UI adapter (src/agent-ui/adapter.ts)
(stable PraisonAIUI contracts)
│
▼
@praisonaiui/react re-exports
(BeautifulUI vendored components)
│
▼
Dashboard / chat mount points (React islands, then full shell)
```
### 1. Folder layout
```
src/frontend/src/agent-ui/
├── contracts.ts # Stable PraisonAIUI view models (the ONLY types consumers import)
├── adapter.ts # Pure mappers: wire payload -> contracts
├── adapter.test.ts # Unit tests (mock events -> props), node:test
├── index.ts # Barrel: contracts + adapters + (later) vendored re-exports
└── vendors/
└── beautifului/ # Vendored BeautifulUI components (added during migration)
```
**Rule:** BeautifulUI / upstream prop types must **never** be imported outside
`src/agent-ui/`. Consumers depend on `contracts.ts` only, so the vendored
implementation can be swapped later without touching call sites.
### 2. Adapter contracts
Internal, transport-agnostic types live in `contracts.ts`:
- `AgentStreamEvent` — normalized form of the WebSocket frames handled in
`chat.js` (`run_started`, `run_content`, `tool_call_started`, …). Unknown
frame `type`s degrade to `kind: 'unknown'` with `rawType` preserved instead
of throwing, so new upstream events never break the UI.
- `ToolCallChipModel` — tool-call chip view model.
- `ApprovalPromptModel` — approval card view model, mapped from the
`/api/approvals/pending` REST payload.
Mappers (`adapter.ts`): `mapChatEventToUi(frame)` and
`mapApprovalToCardProps(item)`. Both are **pure** and unit tested.
### 3. Vendoring & pin policy
| Mechanism | Purpose |
|-----------|---------|
| [`BEAUTIFULUI_UPSTREAM.md`](./BEAUTIFULUI_UPSTREAM.md) | Record git SHA, date, registry base URL for every vendored component |
| shadcn registry URLs | Per-component install, pinned in the upstream doc |
| **No** `dependencies.beautiful-ui` in `package.json` | Avoid a phantom npm package |
**Upgrade rule:** bump upstream **only** via a PR that updates
`BEAUTIFULUI_UPSTREAM.md` and passes the frontend CI gate (issue #295). Icons
stay on `lucide-react`; do not vendor paid Central icons.
### 4. Feature flag (default **off**)
Integration is gated so the existing vanilla-JS chat/approvals plugins remain
the fallback until migration milestones (#296, #297) complete.
- **Flag:** `AIUI_BEAUTIFULUI` (build-time env / dashboard config JSON).
- **Default:** off — vanilla JS renders; the adapter is imported but not mounted.
- **On:** React islands mount using `src/agent-ui` contracts.
- **Fallback:** if a vendored component fails to load, the flag is treated as
off and the vanilla path renders. No user-facing regression when off.
### 5. Design tokens
Scope agent-UI primitives under an `.aiui-agent-ui { … }` CSS layer (or extend
the existing shadcn presets in `themes.py`) so vendored component tokens cannot
collide with the global dashboard theme.
### 6. Deprecation / upstream abandonment
Vendored code stays MIT-licensed in-tree. Because consumers depend only on the
`contracts.ts` view models, a future first-party implementation can satisfy the
same contracts and replace the vendored components with no call-site churn.
## Consequences
- One place (`src/agent-ui/`) owns the upstream coupling.
- Adapters are testable without a browser or the transport layer.
- The default-off flag keeps the migration reversible at every step.
-107
View File
@@ -1,107 +0,0 @@
# BeautifulUI Upgrade Playbook
How PraisonAIUI vendors [BeautifulUI](https://www.beautifului.dev/) React primitives so
upgrades are **repeatable** and licence obligations are **explicit**.
!!! info "Related planning"
Epic [#292](https://github.com/MervinPraison/PraisonAIUI/issues/292) (integration
strategy) and the adapter/pin policy in
[#294](https://github.com/MervinPraison/PraisonAIUI/issues/294).
## 1. What BeautifulUI is
BeautifulUI (MIT, © Shane Levine / Turbo) is a library of React primitives aimed at
**AI-native interfaces** — thinking traces, streaming answers with inline sources,
human-in-the-loop approval cards, tool chips, task rows, context chunks, and a reference
chat harness.
**It is a shadcn registry + copy-paste library — not an npm package.** There is no
published `beautiful-ui` package. You install components by resolving a registry URL, and
the source is copied into your tree (like any other shadcn/ui primitive).
- Website & registry: <https://www.beautifului.dev/>
- Upstream source: <https://github.com/slev12397/beautiful-ui>
Upstream stack (Next.js 15 · React 19 · Tailwind CSS v4 · TypeScript) is closely aligned
with PraisonAIUI's `src/frontend` (Vite · React 19 · Tailwind v4 · shadcn/Radix), which is
why copy-paste vendoring works without a framework shim.
## 2. One-time foundation setup
PraisonAIUI already carries the shadcn/ui foundation that BeautifulUI primitives depend on:
- **shadcn config** — `src/frontend/components.json` (`style: new-york`,
`baseColor: neutral`, `iconLibrary: lucide`, `cssVariables: true`). Its `registries`
map is where pinned BeautifulUI registry URLs are added (see §3).
- **Theme tokens** — `src/praisonaiui/themes.py` generates the shadcn CSS variables
(`--background`, `--foreground`, `--primary`, `--radius`, …) at build time for all 22
official presets, offline. BeautifulUI's foundation CSS (upstream `app/globals.css`)
must only contribute **new** tokens/utilities; it must **not** redefine variables that
`themes.py` already owns, or theme switching and dark mode break.
**Rule:** when merging upstream `app/globals.css`, diff it against the variables emitted by
`themes.py` and keep only the additive slices (new keyframes, component-scoped utilities).
Record what was merged in the pin doc (§3).
## 3. Adding or updating a primitive
1. **Install with a pinned registry URL** from `src/frontend/`:
```bash
npx shadcn add https://www.beautifului.dev/r/<component>.json
```
The registry resolves dependencies and CSS slices. Commit the resulting
`package-lock.json` change in the same PR.
2. **Re-export through the adapter** — add the component to
`src/frontend/src/components/index.ts` (`@praisonaiui/react`). Consumers never import
the raw vendored path (see §4).
3. **Update the pin record** — create/update `docs/frontend/BEAUTIFULUI_UPSTREAM.md` with:
the upstream **git SHA**, the **date**, the **registry base URL**, and the **component
list** vendored so far. This file is the single source of truth for "what version are
we on". (It is created the first time a real primitive is vendored — do not add it
empty.)
4. **Rebuild & sync the bundle** — the committed frontend bundle must be regenerated:
```bash
cd src/frontend && npm run build
# then sync dist/{index.html,assets/,icon.svg} → src/praisonaiui/templates/frontend/
```
(See `AGENTS.md` → "Frontend Build".)
5. **Run frontend CI** — `.github/workflows/ci.yml` (ruff + pytest) must pass, plus the
frontend build step.
## 4. Adapter boundary
Consumers import from **`@praisonaiui/react` only** — never from raw vendored paths such as
`@/components/ui/...` or any upstream module.
```tsx
// ✅ stable PraisonAIUI contract
import { Button, ThemeToggle } from '@praisonaiui/react'
// ❌ leaks upstream/shadcn paths across the codebase
import { Button } from '@/components/ui/button'
```
This keeps the upstream surface behind one file (`components/index.ts`), so a BeautifulUI
bump changes vendored source but not consumer imports.
## 5. Rollback
- BeautifulUI-backed views mount behind a **feature flag**; the **vanilla dashboard views**
(`src/praisonaiui/templates/frontend/plugins/*`) remain the fallback and are kept working.
- To roll back a bad bump: revert the vendoring PR (source + `BEAUTIFULUI_UPSTREAM.md` +
rebuilt bundle move together in one commit), and disable the flag. No consumer import
changes are needed because everything routes through the adapter (§4).
## 6. Licence
BeautifulUI is **MIT, © Shane Levine**. The full licence text is retained in
[`THIRD_PARTY_NOTICES.md`](https://github.com/MervinPraison/PraisonAIUI/blob/main/THIRD_PARTY_NOTICES.md)
at the repository root. Any PR that vendors or bumps BeautifulUI source must keep that
notice present and current.
## 7. Commercial icons — do not vendor
Do **not** use `@central-icons-react` without a licence — it is commercial. PraisonAIUI
standardises on **`lucide-react`** (`iconLibrary: lucide` in `components.json`). If an
upstream BeautifulUI primitive imports commercial icons, swap them for lucide equivalents
during vendoring.
+2 -5
View File
@@ -276,13 +276,10 @@ aiui features list # List all registered features
aiui features status # Feature health summary
```
### `aiui health-check`
Check server health. Can also be invoked as `aiui health` for convenience.
### `aiui health`
```bash
aiui health-check [--detailed] # Check server health
aiui health [--detailed] # Alias for health-check
aiui health [--detailed] # Check server health
```
### `aiui session-ext`
+1 -1
View File
@@ -101,6 +101,6 @@ If something isn't working:
```bash
aiui doctor # Check server health
aiui health-check --detailed # Detailed health check (alias: aiui health)
aiui health --detailed # Detailed health check
aiui test all # Run all integration tests
```
-209
View File
@@ -1,209 +0,0 @@
# 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.
-69
View File
@@ -1,69 +0,0 @@
# Application Design Protocol (ADP)
Declarative UI design standard for describing application layout, navigation, components, and theming from YAML or JSON manifests.
**Status:** v1alpha1 research draft — see [implementation-status.md](./implementation-status.md) for what is implemented today.
## Documents
| Document | Description |
|----------|-------------|
| [Research report](../application-design-protocol-report.md) | 46 standards compared; YAML vs JSON debate; design principles |
| [spec.md](./spec.md) | Normative specification (draft) |
| [validation-rules.md](./validation-rules.md) | Cross-reference rules R1–R30 |
| [aiui-mapping.md](./aiui-mapping.md) | PraisonAIUI field mapping |
| [implementation-status.md](./implementation-status.md) | Feature implementation tracker |
## Schemas
| File | Purpose |
|------|---------|
| [schema/application.schema.json](./schema/application.schema.json) | Root Application kind |
| [schema/component-catalog.schema.json](./schema/component-catalog.schema.json) | Catalog meta-schema |
| [schema/theme.schema.json](./schema/theme.schema.json) | Theme preset or DTCG tokens |
| [catalogs/default.catalog.json](./catalogs/default.catalog.json) | Default component and layout catalog |
## Examples
| File | Description |
|------|-------------|
| [examples/minimal.application.yaml](./examples/minimal.application.yaml) | Smallest valid app |
| [examples/docs-site.application.yaml](./examples/docs-site.application.yaml) | Full docs site |
| [examples/landing-page.application.yaml](./examples/landing-page.application.yaml) | Zone-based layout |
## Validate locally
```bash
pip install jsonschema pyyaml
pytest tests/unit/test_adp_schemas.py -v
```
## Quick start
```yaml
schemaVersion: 1
apiVersion: adp.io/v1alpha1
kind: Application
metadata:
name: my-app
version: "1.0.0"
spec:
site:
title: "My App"
theme:
preset: zinc
darkMode: true
templates:
docs:
layout: TwoColumnLayout
slots:
main: { type: DocContent }
routes:
- match: "/docs/**"
template: docs
content:
docs:
dir: "./docs"
```
-138
View File
@@ -1,138 +0,0 @@
# PraisonAIUI → ADP Field Mapping
Field-by-field mapping from current `aiui.template.yaml` (`Config` model in `src/praisonaiui/schema/models.py`) to ADP `Application` spec.
**Compatibility:** Flat `aiui.template.yaml` files work with the PraisonAIUI compiler today. ADP envelope format is **planned** — see [implementation-status.md](./implementation-status.md).
---
## Envelope (new in ADP)
| ADP field | PraisonAIUI today | Status | Change |
|-----------|-------------------|--------|--------|
| `schemaVersion: 1` | `schemaVersion: 1` | Implemented | Compatible |
| `apiVersion: adp.io/v1alpha1` | Not present | Planned | Add when envelope parser lands |
| `kind: Application` | Not present | Planned | Add when envelope parser lands |
| `metadata.name` | Not present | Planned | Derive from `site.title` slug |
| `metadata.version` | Not present | Planned | Recommend SemVer |
## Top-level → spec wrapper
| PraisonAIUI (flat) | ADP (envelope) | Status | Change |
|--------------------|----------------|--------|--------|
| `site:` | `spec.site:` | Planned | Wrap under `spec` |
| `site.theme` | `spec.theme:` | Planned | Promote theme to spec level |
| `navigation:` | `spec.navigation:` | Partial | Tabs compiled; not rendered |
| `navbar:` | `spec.navbar:` | Partial | Compiled; not rendered |
| `footer:` | `spec.footer:` | Partial | Compiled; not rendered |
| `search:` | `spec.search:` | Partial | Compiled; not rendered |
| `content:` | `spec.content:` | Implemented | Same |
| `components:` | `spec.components:` | Implemented | Same |
| `templates:` | `spec.templates:` | Implemented | Same |
| `routes:` | `spec.routes:` | Partial | Compiled; routing not honoured |
| `dependencies:` | `spec.dependencies:` | Implemented | Same |
| `logo:` (top-level) | Future ADP field | Implemented | Top-level on `Config`, not under `site` |
| `style:`, `chat:`, `auth:`, `dashboard:` | Not in ADP v1alpha1 | N/A | Separate kinds in future |
## Navigation fields
| Field | PraisonAIUI | ADP | Status | Notes |
|-------|-------------|-----|--------|-------|
| `navigation.tabs[]` | Mintlify-style tabs | `spec.navigation.tabs` | Partial | Compiled, not rendered |
| `content.docs.nav.mode` | `NavConfig.mode` | Same | Gap | Defined but unused by compiler |
| Top-level `navigation.mode` | Not present | Removed from ADP | N/A | Use `content.docs.nav.mode` instead |
## Theme fields
| Field | PraisonAIUI | ADP | Status |
|-------|-------------|-----|--------|
| `site.theme.preset` | Implemented | `spec.theme.preset` | Planned (envelope) |
| `site.theme.radius` | Implemented | Same | Same |
| `site.theme.darkMode` | Implemented | Same | Same |
| `theme.tokens` | Not present | DTCG file ref | Planned |
## Component fields
| Field | PraisonAIUI | ADP | Status |
|-------|-------------|-----|--------|
| `components.{id}.type` | String literal | Catalog allowlist | Planned (R6) |
| `components.{id}.props` | `dict[str, Any]` | Catalog schema | Planned (R7) |
## Template fields
| Field | PraisonAIUI | ADP | Status |
|-------|-------------|-----|--------|
| `templates.{id}.layout` | String | Catalog enum | Same values |
| `templates.{id}.slots` | `{ ref \| type }` | Same | Same |
| `templates.{id}.zones` | Widget arrays | Same | Same |
| Slots + zones on same template | Allowed | R13: one or the other | Stricter in ADP |
## Route fields
| Field | PraisonAIUI | ADP | Status |
|-------|-------------|-----|--------|
| `routes[].match` | Glob string | Same | Same |
| `routes[].template` | Template key | Same | Same |
| Runtime route resolution | Not implemented | R28 required | Gap |
## Migration example
### Today (PraisonAIUI)
```yaml
schemaVersion: 1
site:
title: "My Docs"
theme:
preset: zinc
templates:
docs:
layout: TwoColumnLayout
slots:
main: { type: DocContent }
routes:
- match: "/docs/**"
template: docs
```
### Target (ADP)
```yaml
schemaVersion: 1
apiVersion: adp.io/v1alpha1
kind: Application
metadata:
name: my-docs
version: "1.0.0"
spec:
site:
title: "My Docs"
theme:
preset: zinc
templates:
docs:
layout: TwoColumnLayout
slots:
main: { type: DocContent }
routes:
- match: "/docs/**"
template: docs
content:
docs:
dir: "./docs"
```
## Breaking vs compatible changes
| Change | Breaking? | Mitigation |
|--------|-----------|------------|
| Add envelope fields | No | Envelope parser normalises to flat `Config` |
| Catalog validation | Soft break | Warn first, error in strict mode |
| Route runtime resolution | No (fix) | Frontend change |
| R13 slots/zones exclusivity | Soft break | Warn on violation |
| Remove chat/dashboard from ADP | No | Separate kinds in future |
@@ -1,128 +0,0 @@
{
"name": "adp-default-catalog",
"version": "1.0.0",
"description": "Default ADP component and layout catalog for PraisonAIUI",
"components": {
"Header": {
"description": "Site header with logo and navigation",
"props": {
"type": "object",
"properties": {
"logoText": { "type": "string" },
"logoHref": { "type": "string", "default": "/" }
},
"required": ["logoText"],
"additionalProperties": false
},
"aria": { "role": "banner" },
"slots": ["header"]
},
"Footer": {
"description": "Site footer",
"props": {
"type": "object",
"properties": {
"text": { "type": "string" }
},
"additionalProperties": false
},
"aria": { "role": "contentinfo" },
"slots": ["footer"]
},
"DocsSidebar": {
"description": "Documentation sidebar navigation",
"props": {
"type": "object",
"properties": {
"source": { "type": "string", "enum": ["docs-nav", "manual"], "default": "docs-nav" }
},
"additionalProperties": false
},
"aria": { "role": "navigation" },
"slots": ["left", "leftSidebar"]
},
"DocContent": {
"description": "Main documentation content area",
"props": { "type": "object", "additionalProperties": false },
"aria": { "role": "main" },
"slots": ["main"]
},
"Toc": {
"description": "Table of contents for current page",
"props": { "type": "object", "additionalProperties": false },
"aria": { "role": "navigation" },
"slots": ["right", "rightSidebar"]
},
"HeroBanner": {
"description": "Hero banner with title and CTA",
"props": {
"type": "object",
"properties": {
"title": { "type": "string" },
"subtitle": { "type": "string" },
"ctaLabel": { "type": "string" },
"ctaHref": { "type": "string" }
},
"required": ["title"],
"additionalProperties": false
},
"slots": ["hero"]
},
"SocialLinks": {
"description": "Social media link row",
"props": {
"type": "object",
"properties": {
"links": {
"type": "array",
"items": {
"type": "object",
"properties": {
"platform": { "type": "string" },
"href": { "type": "string" }
},
"required": ["platform", "href"],
"additionalProperties": false
}
}
},
"additionalProperties": false
},
"slots": ["footer"]
},
"Copyright": {
"description": "Copyright notice",
"props": {
"type": "object",
"properties": {
"text": { "type": "string" }
},
"required": ["text"],
"additionalProperties": false
},
"slots": ["footer"]
}
},
"layouts": {
"ThreeColumnLayout": {
"description": "Header + left sidebar + main + right TOC + footer",
"slots": ["header", "left", "main", "right", "footer"]
},
"TwoColumnLayout": {
"description": "Sidebar + main content",
"slots": ["left", "main"]
},
"CenteredLayout": {
"description": "Centred single column",
"slots": ["main"]
},
"FullWidthLayout": {
"description": "Full-width content",
"slots": ["header", "main", "footer"]
},
"FlexibleLayout": {
"description": "WordPress-style zone grid",
"zones": ["header", "topNav", "hero", "leftSidebar", "main", "rightSidebar", "bottomNav", "footer"]
}
}
}
@@ -1,104 +0,0 @@
schemaVersion: 1
apiVersion: adp.io/v1alpha1
kind: Application
metadata:
name: praisonaiui-docs
version: "1.0.0"
labels:
generator: praisonaiui
spec:
site:
title: "PraisonAIUI Docs"
description: "Documentation for PraisonAIUI - YAML-driven website generator"
ui: shadcn
theme:
preset: zinc
radius: md
darkMode: true
navigation:
tabs:
- tab: "Documentation"
groups:
- group: "Getting Started"
icon: rocket
prefix: getting-started
pages:
- index
- getting-started/installation
- getting-started/quickstart
- group: "Core Concepts"
icon: lightbulb
prefix: concepts
pages:
- concepts/architecture
- concepts/configuration
- concepts/templates
- tab: "API Reference"
groups:
- group: "API Reference"
icon: code
prefix: api
pages:
- api/cli
- api/python
navbar:
primary:
type: github
href: "https://github.com/MervinPraison/PraisonAIUI"
links:
- label: "Submit Issues"
href: "https://github.com/MervinPraison/PraisonAIUI/issues"
footer:
socials:
github: "https://github.com/MervinPraison/PraisonAI"
links:
- header: "Resources"
items:
- label: "GitHub"
href: "https://github.com/MervinPraison/PraisonAI"
search:
enabled: true
provider: fusejs
content:
docs:
dir: "./docs"
include:
- "**/*.md"
- "**/*.mdx"
nav:
mode: manual
components:
header_main:
type: Header
props:
logoText: "PraisonAIUI"
footer_main:
type: Footer
props:
text: "© 2026 PraisonAI"
sidebar_docs:
type: DocsSidebar
props:
source: docs-nav
templates:
docs:
layout: ThreeColumnLayout
slots:
header: { ref: header_main }
left: { ref: sidebar_docs }
main: { type: DocContent }
footer: { ref: footer_main }
routes:
- match: "/docs/**"
template: docs
@@ -1,58 +0,0 @@
schemaVersion: 1
apiVersion: adp.io/v1alpha1
kind: Application
metadata:
name: colorful-landing
version: "1.0.0"
spec:
site:
title: "Colorful Docs"
description: "Zone-based landing page with hero and footer widgets"
ui: shadcn
theme:
preset: rose
radius: xl
darkMode: true
content:
docs:
dir: "./docs"
nav:
mode: auto
collapsible: true
templates:
docs:
layout: FlexibleLayout
zones:
hero:
- type: HeroBanner
props:
title: "Colorful Documentation"
subtitle: "Vibrant, playful, and beautifully designed"
ctaLabel: "Get Started"
ctaHref: "/docs/getting-started/quickstart"
rightSidebar:
- type: Toc
footer:
- type: SocialLinks
props:
links:
- platform: Twitter
href: "https://twitter.com"
- platform: GitHub
href: "https://github.com"
- type: Copyright
props:
text: "Made with love by Colorful Team"
routes:
- match: "/docs/**"
template: docs
a11y:
skipToContent: true
reduceMotion: false
@@ -1,32 +0,0 @@
schemaVersion: 1
apiVersion: adp.io/v1alpha1
kind: Application
metadata:
name: minimal-docs
version: "1.0.0"
spec:
site:
title: "Minimal Docs"
description: "Smallest valid ADP Application manifest"
ui: shadcn
theme:
preset: zinc
radius: md
darkMode: true
content:
docs:
dir: "./docs"
templates:
docs:
layout: TwoColumnLayout
slots:
main: { type: DocContent }
routes:
- match: "/docs/**"
template: docs
@@ -1,36 +0,0 @@
# ADP Implementation Status
Honest status of ADP v1alpha1 features against the PraisonAIUI reference implementation.
**Last updated:** August 2026
| Feature | Spec reference | Status | Notes |
|---------|----------------|--------|-------|
| JSON Schema validation in CLI | spec §6 | Planned | Schemas published; no `aiui validate --adp` yet |
| ADP envelope parsing (`apiVersion/kind/spec`) | spec §3 | Planned | Flat `aiui.template.yaml` works today |
| Component catalog validation (R6–R7) | spec §5 | Planned | Catalog split to `catalogs/default.catalog.json` |
| `$yaml:` / `$json:` file includes | spec §7 | Planned | Not implemented in compiler |
| Strict mode unknown-field rejection (R4) | validation-rules R4 | Planned | Pydantic allows extra fields on some models |
| Route-driven template resolution (R28) | validation-rules R28 | Gap | Frontend hardcodes `templates.docs` |
| Sidebar nav mode (R29) | validation-rules R29 | Gap | Filesystem-only sidebar today |
| Catalog ARIA roles at render (R30) | validation-rules R30 | Gap | No catalog-driven a11y in renderer |
| `navigation.tabs` rendering | spec §4.3 | Gap | Compiled to manifest but not consumed by React |
| DTCG token file validation (R26) | validation-rules R26 | Planned | Preset shorthand works via Tailwind |
| Published `$schema` URLs | README | Partial | Local schemas only; `https://adp.io/` is placeholder |
## What works today
- Flat `aiui.template.yaml` → Pydantic `Config` → compiler → JSON manifests → React SPA
- Components, templates, routes, theme presets, content scanning
- ADP example manifests validate against `application.schema.json` (standalone)
- Unit tests in `tests/unit/test_adp_schemas.py`
## Migration path
1. Add envelope normalisation layer before `Config.model_validate`
2. Wire `route-manifest.json` into frontend routing (R28)
3. Honour `navigation.tabs` and `content.docs.nav.mode` (R29)
4. Add `aiui validate --schema adp` using published JSON Schema
5. Apply catalog ARIA hints at render time (R30)
See [aiui-mapping.md](./aiui-mapping.md) for field-level compatibility.
@@ -1,449 +0,0 @@
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "application.schema.json",
"title": "ADP Application",
"description": "Application Design Protocol — Application kind manifest",
"type": "object",
"required": ["schemaVersion", "apiVersion", "kind", "metadata", "spec"],
"additionalProperties": false,
"patternProperties": {
"^x-adp-": {}
},
"properties": {
"schemaVersion": {
"type": "integer",
"const": 1,
"description": "ADP envelope version"
},
"apiVersion": {
"type": "string",
"const": "adp.io/v1alpha1"
},
"kind": {
"type": "string",
"const": "Application"
},
"metadata": {
"type": "object",
"required": ["name"],
"additionalProperties": false,
"patternProperties": {
"^x-adp-": {}
},
"properties": {
"name": { "type": "string", "minLength": 1 },
"version": { "type": "string" },
"labels": {
"type": "object",
"additionalProperties": { "type": "string" }
},
"annotations": {
"type": "object",
"additionalProperties": { "type": "string" }
}
}
},
"spec": {
"type": "object",
"required": ["site", "templates", "routes"],
"additionalProperties": false,
"patternProperties": {
"^x-adp-": {}
},
"properties": {
"site": { "$ref": "#/$defs/site" },
"theme": { "$ref": "#/$defs/theme" },
"navigation": { "$ref": "#/$defs/navigation" },
"navbar": { "$ref": "#/$defs/navbar" },
"footer": { "$ref": "#/$defs/footer" },
"search": { "$ref": "#/$defs/search" },
"components": { "$ref": "#/$defs/components" },
"templates": { "$ref": "#/$defs/templates" },
"routes": { "$ref": "#/$defs/routes" },
"content": { "$ref": "#/$defs/content" },
"dependencies": { "$ref": "#/$defs/dependencies" },
"seo": { "$ref": "#/$defs/seo" },
"i18n": { "$ref": "#/$defs/i18n" },
"a11y": { "$ref": "#/$defs/a11y" }
}
}
},
"$defs": {
"site": {
"type": "object",
"required": ["title"],
"additionalProperties": false,
"patternProperties": {
"^x-adp-": {}
},
"properties": {
"title": { "type": "string" },
"description": { "type": "string" },
"ui": {
"type": "string",
"enum": ["shadcn", "mui", "chakra"],
"default": "shadcn"
},
"routeBaseDocs": { "type": "string", "default": "/docs" },
"customCss": { "type": "string" },
"plugins": {
"type": "array",
"items": { "type": "string" }
}
}
},
"theme": {
"oneOf": [
{
"title": "Preset theme",
"type": "object",
"additionalProperties": false,
"properties": {
"preset": {
"type": "string",
"enum": [
"zinc", "slate", "stone", "gray", "neutral",
"red", "orange", "amber", "yellow", "lime", "green",
"emerald", "teal", "cyan", "sky", "blue", "indigo",
"violet", "purple", "fuchsia", "pink", "rose"
]
},
"radius": {
"type": "string",
"enum": ["none", "sm", "md", "lg", "xl"]
},
"darkMode": { "type": "boolean" }
},
"required": ["preset"]
},
{
"title": "DTCG token reference",
"type": "object",
"additionalProperties": false,
"properties": {
"tokens": {
"type": "string",
"description": "Path or URL to DTCG 2025.10 compliant tokens.json"
},
"darkMode": { "type": "boolean" }
},
"required": ["tokens"]
}
]
},
"slotRef": {
"oneOf": [
{ "type": "null" },
{
"type": "object",
"required": ["ref"],
"additionalProperties": false,
"properties": {
"ref": { "type": "string" }
}
},
{
"type": "object",
"required": ["type"],
"additionalProperties": false,
"properties": {
"type": { "type": "string" },
"props": {
"type": "object",
"additionalProperties": true
}
}
}
]
},
"widget": {
"type": "object",
"required": ["type"],
"additionalProperties": false,
"properties": {
"type": { "type": "string" },
"props": {
"type": "object",
"additionalProperties": true
}
}
},
"components": {
"type": "object",
"additionalProperties": {
"type": "object",
"required": ["type"],
"additionalProperties": false,
"properties": {
"type": { "type": "string" },
"props": {
"type": "object",
"additionalProperties": true
}
}
}
},
"template": {
"type": "object",
"required": ["layout"],
"allOf": [
{
"oneOf": [
{ "required": ["slots"] },
{ "required": ["zones"] }
]
}
],
"additionalProperties": false,
"properties": {
"layout": {
"type": "string",
"enum": [
"ThreeColumnLayout",
"TwoColumnLayout",
"CenteredLayout",
"FullWidthLayout",
"FlexibleLayout"
]
},
"slots": {
"type": "object",
"additionalProperties": { "$ref": "#/$defs/slotRef" }
},
"zones": {
"type": "object",
"additionalProperties": {
"type": "array",
"items": { "$ref": "#/$defs/widget" }
}
}
}
},
"templates": {
"type": "object",
"minProperties": 1,
"additionalProperties": { "$ref": "#/$defs/template" }
},
"route": {
"type": "object",
"required": ["match", "template"],
"additionalProperties": false,
"properties": {
"match": { "type": "string" },
"template": { "type": "string" },
"slots": {
"type": "object",
"additionalProperties": { "$ref": "#/$defs/slotRef" }
}
}
},
"routes": {
"type": "array",
"minItems": 1,
"items": { "$ref": "#/$defs/route" }
},
"navGroup": {
"type": "object",
"required": ["group"],
"additionalProperties": false,
"properties": {
"group": { "type": "string" },
"icon": { "type": "string" },
"prefix": { "type": "string" },
"pages": {
"type": "array",
"items": { "type": "string" }
}
}
},
"navTab": {
"type": "object",
"required": ["tab"],
"additionalProperties": false,
"properties": {
"tab": { "type": "string" },
"url": { "type": "string" },
"groups": {
"type": "array",
"items": { "$ref": "#/$defs/navGroup" }
}
}
},
"navigation": {
"type": "object",
"additionalProperties": false,
"properties": {
"tabs": {
"type": "array",
"items": { "$ref": "#/$defs/navTab" }
}
}
},
"navbar": {
"type": "object",
"additionalProperties": false,
"properties": {
"primary": {
"type": "object",
"additionalProperties": false,
"properties": {
"type": { "type": "string", "enum": ["button", "github"] },
"label": { "type": "string" },
"href": { "type": "string" }
}
},
"links": {
"type": "array",
"items": {
"type": "object",
"required": ["label", "href"],
"additionalProperties": false,
"properties": {
"label": { "type": "string" },
"href": { "type": "string" }
}
}
}
}
},
"footer": {
"type": "object",
"additionalProperties": false,
"properties": {
"socials": {
"type": "object",
"additionalProperties": { "type": "string" }
},
"links": {
"type": "array",
"items": {
"type": "object",
"required": ["header"],
"additionalProperties": false,
"properties": {
"header": { "type": "string" },
"items": {
"type": "array",
"items": {
"type": "object",
"required": ["label", "href"],
"additionalProperties": false,
"properties": {
"label": { "type": "string" },
"href": { "type": "string" }
}
}
}
}
}
}
}
},
"search": {
"type": "object",
"additionalProperties": false,
"properties": {
"enabled": { "type": "boolean" },
"provider": {
"type": "string",
"enum": ["fusejs", "lunrjs", "pagefind"]
}
}
},
"dependencies": {
"type": "object",
"additionalProperties": false,
"properties": {
"shadcn": {
"type": "array",
"items": { "type": "string" }
}
}
},
"contentSource": {
"type": "object",
"required": ["dir"],
"additionalProperties": false,
"properties": {
"dir": { "type": "string" },
"include": {
"type": "array",
"items": { "type": "string" }
},
"exclude": {
"type": "array",
"items": { "type": "string" }
},
"indexFiles": {
"type": "array",
"items": { "type": "string" }
},
"nav": {
"type": "object",
"additionalProperties": false,
"properties": {
"mode": { "type": "string", "enum": ["auto", "manual"] },
"sort": { "type": "string", "enum": ["filesystem", "alpha", "date"] },
"collapsible": { "type": "boolean" },
"maxDepth": { "type": "integer", "minimum": 1, "maximum": 10 }
},
"allOf": [
{
"if": {
"properties": { "mode": { "const": "manual" } },
"required": ["mode"]
},
"then": {
"description": "Manual sidebar mode requires navigation.tabs at spec level"
}
}
]
}
}
},
"content": {
"type": "object",
"additionalProperties": false,
"properties": {
"docs": { "$ref": "#/$defs/contentSource" },
"blog": { "$ref": "#/$defs/contentSource" }
}
},
"seo": {
"type": "object",
"additionalProperties": false,
"properties": {
"titleTemplate": { "type": "string" },
"defaultImage": { "type": "string" },
"twitter": {
"type": "object",
"additionalProperties": { "type": "string" }
}
}
},
"i18n": {
"type": "object",
"additionalProperties": false,
"properties": {
"defaultLocale": { "type": "string" },
"locales": { "type": "array", "items": { "type": "string" } },
"rtlLocales": { "type": "array", "items": { "type": "string" } },
"fallbackLocale": { "type": "string" },
"translationsDir": { "type": "string" }
}
},
"a11y": {
"type": "object",
"additionalProperties": false,
"properties": {
"skipToContent": { "type": "boolean" },
"focusVisible": { "type": "boolean" },
"reduceMotion": { "type": "boolean" },
"ariaLabels": {
"type": "object",
"additionalProperties": { "type": "string" }
}
}
}
}
}
@@ -1,82 +0,0 @@
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "component-catalog.schema.json",
"title": "ADP Component Catalog",
"description": "Allowlisted component types for ADP Application manifests",
"type": "object",
"required": ["name", "version", "components"],
"additionalProperties": false,
"patternProperties": {
"^x-adp-": {}
},
"properties": {
"name": { "type": "string" },
"version": { "type": "string" },
"description": { "type": "string" },
"components": {
"type": "object",
"additionalProperties": { "$ref": "#/$defs/componentType" }
},
"layouts": {
"type": "object",
"additionalProperties": { "$ref": "#/$defs/layoutType" }
}
},
"$defs": {
"componentType": {
"type": "object",
"required": ["description", "props"],
"additionalProperties": false,
"properties": {
"description": { "type": "string" },
"props": { "type": "object" },
"aria": {
"type": "object",
"additionalProperties": false,
"properties": {
"role": { "type": "string" },
"label": { "type": "string" }
}
},
"slots": {
"type": "array",
"items": { "type": "string" }
},
"deprecated": { "type": "boolean" },
"x-sunset": { "type": "string", "format": "date" }
}
},
"layoutType": {
"oneOf": [
{
"title": "Slot-based layout",
"type": "object",
"required": ["description", "slots"],
"additionalProperties": false,
"properties": {
"description": { "type": "string" },
"slots": {
"type": "array",
"items": { "type": "string" },
"minItems": 1
}
}
},
{
"title": "Zone-based layout",
"type": "object",
"required": ["description", "zones"],
"additionalProperties": false,
"properties": {
"description": { "type": "string" },
"zones": {
"type": "array",
"items": { "type": "string" },
"minItems": 1
}
}
}
]
}
}
}
@@ -1,47 +0,0 @@
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "theme.schema.json",
"title": "ADP Theme",
"description": "Theme configuration — preset shorthand or DTCG 2025.10 token reference",
"oneOf": [
{
"title": "Preset theme",
"type": "object",
"additionalProperties": false,
"properties": {
"preset": {
"type": "string",
"enum": [
"zinc", "slate", "stone", "gray", "neutral",
"red", "orange", "amber", "yellow", "lime", "green",
"emerald", "teal", "cyan", "sky", "blue", "indigo",
"violet", "purple", "fuchsia", "pink", "rose"
]
},
"radius": {
"type": "string",
"enum": ["none", "sm", "md", "lg", "xl"],
"default": "md"
},
"darkMode": {
"type": "boolean",
"default": true
}
},
"required": ["preset"]
},
{
"title": "DTCG token reference",
"type": "object",
"additionalProperties": false,
"properties": {
"tokens": {
"type": "string",
"description": "Path or URL to DTCG 2025.10 compliant tokens.json"
},
"darkMode": { "type": "boolean" }
},
"required": ["tokens"]
}
]
}
-314
View File
@@ -1,314 +0,0 @@
# Application Design Protocol (ADP) — Normative Specification
**Version:** v1alpha1 (draft)
**Status:** Draft — not yet normative
**Date:** August 2026
This document uses [RFC 2119](https://datatracker.ietf.org/doc/html/rfc2119) keywords: MUST, MUST NOT, SHOULD, MAY.
---
## 1. Scope
ADP defines a declarative format for describing **application UI design**: site metadata, theming, navigation, reusable components, page templates, and URL routing. It does NOT define:
- Server-side APIs or data models
- Agent orchestration or workflow graphs
- Infrastructure or deployment configuration
- Executable code or templating logic in manifests
Implementations MUST treat ADP manifests as **data**, not code.
---
## 2. Terminology
| Term | Definition |
|------|------------|
| **Manifest** | A YAML or JSON document conforming to this spec |
| **Application** | The root `kind: Application` manifest describing a complete UI |
| **Component** | A named, reusable UI block with a `type` and `props` |
| **Template** | A layout definition assigning components to slots or zones |
| **Slot** | A named region within a layout (e.g. `header`, `main`) |
| **Zone** | An ordered list of widgets in a slot (WordPress-style) |
| **Route** | A URL pattern mapping to a template |
| **Catalog** | Published JSON Schema defining allowed component types |
| **Compiler** | Tool that validates and emits runtime JSON manifests |
| **Renderer** | Runtime that consumes compiled manifests and renders UI |
---
## 3. Document envelope
Every ADP manifest MUST include:
```yaml
schemaVersion: 1 # Integer; ADP envelope version
apiVersion: adp.io/v1alpha1
kind: Application # Application | Theme | ComponentCatalog (future)
metadata:
name: string # Required; unique identifier
version: string # SHOULD use SemVer
labels: object # MAY contain key-value pairs
annotations: object # MAY contain tooling metadata
spec: object # Required; kind-specific payload
```
### 3.1 Versioning policy
- `schemaVersion` — integer bumped on breaking envelope changes
- `apiVersion` — string group/version (e.g. `adp.io/v1alpha1`, `adp.io/v1`)
- `metadata.version` — application SemVer (independent of protocol version)
- Breaking changes MUST increment `apiVersion` (e.g. v1alpha1 → v1)
- Deprecated fields MUST include `deprecated: true` and SHOULD include `x-sunset` date
### 3.2 Authoring formats
Manifests MAY be authored as YAML (`.application.yaml`) or JSON (`.application.json`). Semantics MUST be identical. Runtimes MUST NOT consume YAML directly; compilers MUST normalise to JSON before validation.
---
## 4. Application spec
An `Application` spec MUST contain at minimum:
| Field | Required | Description |
|-------|----------|-------------|
| `site.title` | YES | Application display name |
| `templates` | YES | At least one template |
| `routes` | YES | At least one route |
All other fields are OPTIONAL.
### 4.1 Site (`spec.site`)
```yaml
site:
title: string # Required
description: string # Optional
ui: shadcn # UI framework hint; default shadcn
routeBaseDocs: /docs # Base path for documentation routes
```
### 4.2 Theme (`spec.theme`)
Theme MAY use preset shorthand or reference a DTCG token file:
```yaml
theme:
preset: zinc # Tailwind-style preset name
radius: md # none | sm | md | lg | xl
darkMode: true
# OR
tokens: $yaml: ./theme.tokens.json # DTCG 2025.10 file
```
### 4.3 Navigation
ADP separates **top tab bar** from **sidebar source**:
```yaml
navigation:
tabs: # Top tab bar (Mintlify-style)
- tab: Documentation
groups:
- group: Getting Started
pages: [index, getting-started/installation]
content:
docs:
dir: ./docs
nav:
mode: auto # auto | manual — sidebar source
sort: filesystem
collapsible: true
maxDepth: 4
```
- **`navigation.tabs`** — top-level tab bar with grouped pages
- **`content.docs.nav.mode: auto`** — sidebar generated from filesystem scan
- **`content.docs.nav.mode: manual`** — sidebar driven by `navigation.tabs` groups
- Renderers MUST honour `content.docs.nav.mode` when building sidebar (R29)
### 4.4 Components (`spec.components`)
```yaml
components:
header_main:
type: Header # MUST exist in component catalog
props:
logoText: PraisonAIUI
```
Rules:
- Component IDs MUST be unique within the manifest
- `type` MUST match an entry in the published component catalog
- `props` MUST validate against the catalog JSON Schema for that type
### 4.5 Templates (`spec.templates`)
```yaml
templates:
docs:
layout: ThreeColumnLayout
slots:
header: { ref: header_main }
left: { ref: sidebar_docs }
main: { type: DocContent }
right: null # Explicitly hide slot
landing:
layout: FlexibleLayout
zones:
hero:
- type: HeroBanner
props: { title: "Welcome" }
```
Rules:
- `layout` MUST be a known layout type in the catalog
- Each slot value MUST be `{ ref: id }`, `{ type: ComponentType }`, or `null`
- Zones MUST use zone names valid for `FlexibleLayout`
- A template MUST declare either `slots` or `zones`, not both (R13)
### 4.6 Routes (`spec.routes`)
```yaml
routes:
- match: /docs/changelog
template: changelog
slots: # Optional per-route slot overrides
right: null
- match: /docs/**
template: docs
```
Rules:
- Routes MUST be ordered most-specific-first
- `match` MUST be a glob pattern
- `template` MUST reference a key in `spec.templates`
- Renderers MUST resolve routes at runtime using compiled `route-manifest.json`
### 4.7 Content (`spec.content`)
```yaml
content:
docs:
dir: ./docs
include: ["**/*.md", "**/*.mdx"]
exclude: []
nav:
mode: auto
sort: filesystem
collapsible: true
maxDepth: 4
```
---
## 5. Component catalog
Implementations MUST publish a `component-catalog.schema.json` (meta-schema) and a catalog instance such as [`catalogs/default.catalog.json`](./catalogs/default.catalog.json):
```json
{
"name": "adp-default-catalog",
"version": "1.0.0",
"components": {
"Header": {
"description": "Site header with logo and navigation",
"props": {
"type": "object",
"properties": { "logoText": { "type": "string" } },
"required": ["logoText"],
"additionalProperties": false
},
"aria": { "role": "banner" },
"slots": ["header"]
}
},
"layouts": {
"ThreeColumnLayout": {
"description": "Header + sidebar + main + TOC + footer",
"slots": ["header", "left", "main", "right", "footer"]
}
}
}
```
Custom component types MUST be registered via plugin schema layers. Unknown types MUST cause validation failure in strict mode.
---
## 6. Compilation pipeline
A conforming compiler MUST:
1. Parse YAML or JSON manifest
2. Normalise to JSON
3. Validate against `application.schema.json` (JSON Schema 2020-12)
4. Validate cross-reference rules (see [validation-rules.md](./validation-rules.md))
5. Validate component props against catalog schema
6. Emit runtime manifests:
- `ui-config.json` — site, components, templates, theme, navigation
- `route-manifest.json` — ordered routes with template refs
- `docs-nav.json` — filesystem-derived navigation (if mode auto)
---
## 7. File composition
Manifests MAY include external files using Backstage-style placeholders:
```yaml
theme:
$yaml: ./theme.yaml
navigation:
$yaml: ./navigation.yaml
```
Rules:
- Paths MUST be relative to the containing manifest
- Path traversal (`../../`) MUST be rejected
- Supported placeholders: `$yaml:`, `$json:`, `$text:`
---
## 8. Extensions
Vendor extensions MUST use the `x-adp-` prefix:
```yaml
spec:
site:
x-adp-customAnalytics: true
```
Implementations MUST preserve unknown `x-adp-*` fields. Non-prefixed unknown fields MUST be rejected in strict mode.
---
## 9. Conformance
An implementation is **ADP-conformant** if and only if it:
1. Accepts manifests with `apiVersion: adp.io/v1alpha1` and `kind: Application`
2. Validates all MUST-level requirements in this spec and validation-rules.md
3. Rejects manifests with unknown fields in strict mode
4. Emits runtime JSON manifests from valid Application specs
5. Resolves routes to templates at render time
---
## 10. Related documents
- [Research report](../application-design-protocol-report.md)
- [Implementation status](./implementation-status.md)
- [PraisonAIUI mapping](./aiui-mapping.md)
- [JSON Schema: application](./schema/application.schema.json)
- [JSON Schema: component catalog](./schema/component-catalog.schema.json)
- [JSON Schema: theme](./schema/theme.schema.json)
- [Examples](./examples/)
-115
View File
@@ -1,115 +0,0 @@
# ADP Validation Rules
Cross-reference validation rules for Application Design Protocol manifests. These constraints cannot be expressed in JSON Schema alone and MUST be enforced by conforming compilers.
Rules use RFC 2119 keywords: MUST, MUST NOT, SHOULD, MAY.
Inspired by AWP validation rules R1–R32 pattern.
---
## Envelope rules
| Rule | Level | Description |
|------|-------|-------------|
| **R1** | MUST | `schemaVersion` MUST equal `1` for `apiVersion: adp.io/v1alpha1` |
| **R2** | MUST | `kind` MUST be `Application` for root manifests |
| **R3** | MUST | `metadata.name` MUST be unique within a deployment scope |
| **R4** | MUST NOT | Unknown top-level fields outside `x-adp-*` namespace in strict mode |
## Component rules
| Rule | Level | Description |
|------|-------|-------------|
| **R5** | MUST | Every `spec.components` entry MUST have a unique ID |
| **R6** | MUST | Every component `type` MUST exist in the published component catalog |
| **R7** | MUST | Component `props` MUST validate against catalog JSON Schema for that type |
| **R8** | MUST NOT | A component ID be referenced by `ref:` unless it exists in `spec.components` |
| **R9** | SHOULD | Unused components (defined but never referenced) SHOULD emit a warning |
## Template rules
| Rule | Level | Description |
|------|-------|-------------|
| **R10** | MUST | Every `spec.templates` entry MUST declare a valid `layout` from the catalog |
| **R11** | MUST | Slot names in `templates.*.slots` MUST be valid for the declared layout |
| **R12** | MUST | Zone names in `templates.*.zones` MUST be valid for `FlexibleLayout` |
| **R13** | MUST | Each template MUST declare either `slots` or `zones`, not both |
| **R14** | MUST | Inline slot `{ type: X }` types MUST exist in the component catalog |
| **R15** | MUST | Slot `{ ref: id }` MUST resolve to a component in `spec.components` |
## Route rules
| Rule | Level | Description |
|------|-------|-------------|
| **R16** | MUST | Every route `template` MUST reference a key in `spec.templates` |
| **R17** | MUST | Routes MUST be ordered most-specific-first (longer/more precise patterns before wildcards) |
| **R18** | MUST | Route slot overrides MUST use valid slot names for the target template's layout |
| **R19** | SHOULD | At least one route SHOULD match the site root or docs base path |
## Content and navigation rules
| Rule | Level | Description |
|------|-------|-------------|
| **R20** | MUST | If `content.docs.dir` is declared, the directory MUST exist at compile time |
| **R21** | MUST | If `content.docs.nav.mode` is `manual`, `navigation.tabs` MUST be non-empty |
| **R22** | SHOULD | If `content.docs.nav.mode` is `auto`, a content source MUST be declared |
Navigation model:
- **`navigation.tabs`** — top tab bar (Mintlify-style)
- **`content.docs.nav.mode`** — sidebar source: `auto` (filesystem scan) or `manual` (use `navigation.tabs` groups)
## File include rules
| Rule | Level | Description |
|------|-------|-------------|
| **R23** | MUST NOT | `$yaml:`, `$json:`, or `$text:` paths traverse outside the project root |
| **R24** | MUST | Included files MUST parse as valid YAML/JSON/text respectively |
| **R25** | MUST NOT | Circular file includes (A includes B includes A) |
## Theme rules
| Rule | Level | Description |
|------|-------|-------------|
| **R26** | MUST | If `theme.tokens` is declared, the referenced file MUST exist and validate against DTCG 2025.10 |
| **R27** | MUST NOT | Both `theme.preset` and `theme.tokens` be declared simultaneously |
## Renderer conformance (target — not yet implemented in PraisonAIUI)
These rules define the target behaviour for ADP-conformant renderers. See [implementation-status.md](./implementation-status.md).
| Rule | Level | Description |
|------|-------|-------------|
| **R28** | MUST | Renderers MUST resolve the active template from `route-manifest.json` at runtime |
| **R29** | MUST | Renderers MUST honour `content.docs.nav.mode` when building sidebar |
| **R30** | MUST | Renderers MUST apply ARIA roles from catalog for each rendered component |
---
## Validation pipeline order
```
1. Parse YAML/JSON
2. Resolve $yaml/$json/$text includes (R23–R25)
3. JSON Schema validate (application.schema.json)
4. Cross-ref rules R1–R27
5. Component props validate against catalog (R6–R7)
6. Emit runtime manifests
7. Runtime conformance R28–R30 (renderer responsibility)
```
## Error format
Validation failures MUST report:
```json
{
"rule": "R8",
"path": "spec.templates.docs.slots.header.ref",
"message": "Component ref 'missing_header' not found in spec.components",
"severity": "error"
}
```
Warnings (R9) use `"severity": "warning"`.
@@ -1,427 +0,0 @@
# Application Design Protocol (ADP) — Research Report
**Version:** 1.0 (draft)
**Date:** August 2026
**Scope:** UI, layout, navigation, theming — declarative application design from text manifests
**Status:** Research and recommendation; not yet a published standard
---
## 1. Executive summary
There is no single existing standard for **designing full application UI from a declarative text file**. Instead, the problem is addressed in fragments:
- **Docs-site generators** (Mintlify, PraisonAIUI, Hugo) declare navigation and layout in config files.
- **Form libraries** (JSON Forms, RJSF) declare layout trees from JSON Schema.
- **Agent UI protocols** (A2UI, AG-UI) declare streaming component surfaces for LLM-generated UI.
- **Catalog standards** (OpenUI, Backstage) declare component metadata without runtime layout.
- **Design token standards** (DTCG 2025.10) declare theming without structure.
**Recommendation:** Define an **Application Design Protocol (ADP)** that synthesises proven patterns from 46 surveyed standards into a UI-only manifest format.
PraisonAIUI already implements approximately 60% of this model. ADP formalises, extends, and publishes what exists today.
---
## 2. Problem statement
### 2.1 What we need
A protocol that lets a developer (or LLM) describe an application's visual design — pages, navigation, components, themes, routes — in **one or more text files**, without writing application code.
### 2.2 What exists today (fragmented)
| Need | Who solves it | Gap |
|------|---------------|-----|
| Docs site from config | Mintlify, PraisonAIUI, Docusaurus | No shared spec; Mintlify uses JSON, others use YAML or JS |
| Component catalog | OpenUI, A2UI | No layout or routing |
| Layout trees | JSON Forms uiSchema | Forms only, not app shells |
| Theming | DTCG, Material 3 | Tokens only, no pages |
| Agent-generated UI | A2UI | Runtime streaming, not human-authored static sites |
| Software metadata | Backstage | Catalog entities, not UI composition |
### 2.3 Design goals for ADP
1. **Human-readable** — editable without IDE plugins
2. **LLM-safe** — structural schema rejects unknown fields
3. **Portable** — same manifest renders on any ADP-compliant runtime
4. **Validatable** — fail fast at build time, not in the browser
5. **Extensible** — plugins add catalog entries, not inline code
6. **Accessible** — ARIA hints required in component catalog
---
## 3. Standards landscape — 46 protocols in 10 categories
Each entry describes **how UI/layout/navigation is declared** — the primary comparison dimension.
### Category A: Web platform baseline (5)
| Standard | Format | How UI/layout/navigation is declared | Schema | Versioning |
|----------|--------|--------------------------------------|--------|------------|
| **HTML + CSS** | `.html` + `.css` | Semantic elements; CSS Flexbox/Grid; `<nav>` + links | Living spec grammar | Browser Baseline |
| **Web Components + DSD** | HTML + JS | Custom element tree; slots; Declarative Shadow DOM | DOM IDL | Platform flags |
| **WAI-ARIA + APG** | HTML attributes | Landmarks, widget roles, keyboard patterns | WAI-ARIA 1.2 | Rec track |
| **DTCG Design Tokens** | JSON | Token groups: `$value`, `$type`, `{alias}` refs — **no layout** | DTCG 2025.10 | Date editions |
| **OpenUI** | YAML/JSON | Component catalog: `components{id: props}` — **no layout tree** | Prop definitions | Per-library version |
### Category B: Schema-driven forms and layout (3)
| Standard | Format | How UI/layout/navigation is declared | Schema | Versioning |
|----------|--------|--------------------------------------|--------|------------|
| **JSON Forms** | JSON Schema + UI Schema | `VerticalLayout`, `Control`, `Categorization` with `scope` pointers | JSON Schema + uiSchema | Package SemVer |
| **RJSF** | JSON Schema + uiSchema | Schema-tree mirroring; `ui:widget`, `ui:options` | React convention | Package SemVer |
| **Google A2UI** | JSONL stream | Flat adjacency-list; `createSurface`; catalog-constrained types | JSON Schema catalog | v0.9.1 / v1.0 RC |
### Category C: Docs-site generators (6)
| Standard | Format | How UI/layout/navigation is declared | Schema | Versioning |
|----------|--------|--------------------------------------|--------|------------|
| **Mintlify** | JSON (`docs.json`) | `navigation.groups[].pages[]`; `$schema` URL; `$ref` splitting | Published JSON Schema URL | Platform |
| **PraisonAIUI** | YAML | `components` → `templates{layout, slots, zones}` → `routes` | Pydantic (internal) | `schemaVersion: 1` |
| **Docusaurus** | JS config | `themeConfig.navbar`, plugin sidebar config | TypeScript types | Package SemVer |
| **Hugo** | TOML/YAML | `menu.main[]`, theme selection | Hugo schema | Release train |
| **Jekyll** | YAML | Frontmatter per page; `_data/` nav | Conventions | SemVer |
| **Gatsby** | JS/TS | `siteMetadata` + `plugins[]` | JS object | Major versions |
### Category D: Native declarative UI — reference only (5)
| Standard | Format | Serialised wire format? | Navigation model |
|----------|--------|-------------------------|------------------|
| **Jetpack Compose** | Kotlin | No | `NavHost` + routes |
| **SwiftUI** | Swift | No | `NavigationStack` |
| **Flutter** | Dart | No | `Router` / `Navigator` |
| **Qt QML** | `.qml` | Yes | `StackView`, bindings |
| **XAML** | XML | Yes (dialect-fragmented) | `Frame`, `NavigationView` |
### Category E: Design systems (2)
| Standard | Format | Theming model |
|----------|--------|---------------|
| **Material Design 3** | DTCG JSON | reference → system → component token tiers |
| **Bootstrap 5.3** | SCSS → CSS vars | Sass maps → `--bs-*` custom properties |
### Category F: Design-to-code bridges — not source-of-truth (3)
| Standard | Output | Why not ADP source |
|----------|--------|-------------------|
| **Figma MCP** | React+Tailwind context | Proprietary; bridge only |
| **Webflow export** | HTML/CSS/JS ZIP | No schema; paid export |
| **Framer export** | React bundles | Platform lock-in |
### Category G: Component documentation (1)
| Standard | Format | Navigation |
|----------|--------|------------|
| **Storybook CSF 3** | TS modules | Sidebar via `title` path hierarchy |
### Category H: Configuration and packaging (12)
| Standard | Format | Composition mechanism | ADP borrow |
|----------|--------|----------------------|------------|
| **Kubernetes CRD** | YAML | `apiVersion/kind/spec`; structural OpenAPI | Structural validation |
| **Helm** | YAML + templates | Subcharts; values merge; `values.schema.json` | Defaults + overrides |
| **Backstage catalog** | YAML | `$yaml:`/`$json:` includes; relation graph | Envelope + includes |
| **OpenAPI 3.1** | YAML/JSON | `$ref`, `components` registry | JSON Schema foundation |
| **AsyncAPI 3.x** | YAML/JSON | Channels → messages | Future event UI |
| **JSON Schema 2020-12** | JSON | `$ref`, `$defs`, conditionals | Validation dialect |
| **CloudEvents** | JSON envelope | Fixed envelope + opaque payload | Future event wrapper |
| **Docker Compose** | YAML | Multi-file merge; profiles | Override stacking |
| **CUE** | CUE | Unify schema + data | Power-user compile layer |
| **Jsonnet** | Jsonnet → JSON | `import` + merge | Theme variants |
| **Pkl** | `.pkl` | `amends`, mixins | Typed config model |
| **OCI Artifact** | JSON manifest | Multi-layer blobs | Bundle packaging |
### Category I: Agent and workflow protocols (7)
| Standard | Format | UI/state declaration | ADP borrow |
|----------|--------|---------------------|------------|
| **MCP** | JSON-RPC | Tool schemas — no UI layout | Future widget actions |
| **AG-UI** | Streaming JSON | EventType enum; snapshots + deltas | Streaming model |
| **A2A** | Proto → JSON-RPC | Agent Cards | Transport |
| **AWP** | Multi-file YAML | 7 layers; rules R1–R32 | Layer + validation model |
| **LangGraph** | workflow.yaml | Graph nodes + edges | Layout graph |
| **AutoGen** | ComponentModel JSON | `{provider, type, version, config}` | Pluggable components |
| **CrewAI** | JSONC/YAML | Agent arrays — weak schema | Anti-pattern |
### Category J: Application packaging (2)
| Standard | Format | Application declaration |
|----------|--------|------------------------|
| **OAM** | YAML | `Application` with `components[]`, `traits[]` |
| **CNAB** | bundle.json | Parameters, credentials, actions |
---
## 4. How they do it — mechanism comparison matrix
| Mechanism | Standards | How it works | ADP |
|-----------|-----------|--------------|-----|
| **Envelope document** | K8s, Backstage, OAM, AWP | `apiVersion` + `kind` + `metadata` + `spec` | Adopt |
| **Component registry + ref** | PraisonAIUI, OpenUI | Named map; templates use `ref:` | Adopt |
| **Layout + slots** | PraisonAIUI, JSON Forms | Layout type + named regions | Adopt (unified) |
| **Route → template** | PraisonAIUI, Flutter | URL glob → template | Adopt |
| **Navigation tree** | Mintlify, Storybook | Hierarchical groups → pages | Adopt |
| **Design token file** | DTCG, Material 3 | Token JSON with aliases | Adopt |
| **Catalog allowlist** | A2UI, OpenUI | Closed component vocabulary | Adopt |
| **Schema + uiSchema split** | JSON Forms, RJSF | Data vs presentation | Partial |
| **Streaming surfaces** | A2UI, AG-UI | Incremental JSON updates | Future |
| **Layered spec** | AWP, Helm | Separate files merged at compile | Adopt |
| **File includes** | Backstage, Mintlify | `$yaml:` / `$ref` external files | Adopt |
| **Structural pruning** | K8s CRD | Reject unknown fields | Adopt |
| **Validation rules R1–Rn** | AWP | Cross-ref beyond JSON Schema | Adopt |
| **JS executable config** | Docusaurus, Gatsby | Code config | Reject |
| **Visual canvas** | Figma, Webflow | Proprietary designer | Reject |
| **Code-only UI** | Compose, SwiftUI | No wire format | Out of scope |
---
## 5. Side-by-side: four closest analogues
| Dimension | PraisonAIUI | Mintlify | A2UI | Backstage |
|-----------|-------------|----------|------|-----------|
| **Authoring format** | YAML | JSON | JSON (stream) | YAML |
| **Envelope** | `schemaVersion` only | Flat JSON | Message `version` | `apiVersion/kind/metadata/spec` |
| **Navigation** | `navigation.tabs[]` + filesystem | `navigation.groups[]` | N/A | N/A (metadata catalog) |
| **Components** | `components{id: type, props}` | N/A | Catalog + adjacency list | Entity kinds |
| **Layout** | `templates{layout, slots, zones}` | Theme only | Surfaces | N/A |
| **Routes** | `routes{match, template}` | Page paths in nav | N/A | N/A |
| **Schema published** | Pydantic only | `$schema` URL | JSON Schema catalog | JSON Schema per kind |
| **Runtime** | React SPA | Hosted platform | Any renderer | Catalog API |
| **Primary use** | Docs sites | Docs sites | Agent-generated UI | Software inventory |
**Synthesis:** ADP combines PraisonAIUI's composition model, Mintlify's schema publishing, A2UI's catalog allowlist, and Backstage's envelope and file includes.
---
## 6. YAML vs alternatives — structured debate
### 6.1 The assumption
YAML is the best authoring format for application design manifests.
### 6.2 Counter-evidence
**Mintlify** — the closest commercial docs-site analogue — uses **JSON** (`docs.json`) with a published `$schema` URL for IDE autocomplete. This works well for flat-to-moderately-nested config but becomes verbose for deep layout trees.
**A2UI** uses **JSON** as both authoring and wire format because LLMs emit structured JSON reliably and ambiguity is unacceptable at runtime.
**Docusaurus and Gatsby** use **JavaScript/TypeScript** config for programmatic power — but this sacrifices portability and LLM generation safety.
### 6.3 Format comparison
| Format | Strengths | Weaknesses for ADP |
|--------|-----------|-------------------|
| **YAML** | Comments; readable nesting; LLM-friendly; PraisonAIUI continuity | Ambiguous typing; merge key footguns; no native DRY |
| **JSON** | Unambiguous; `$schema` IDE support; Mintlify-proven | No comments; verbose for layout trees |
| **TOML** | Clear flat tables (Hugo) | Poor for nested templates/zones |
| **CUE / Jsonnet / Pkl** | Typed composition without templating | Learning curve; not LLM-default |
| **HCL** | Modules, expressions | Ecosystem-bound; overkill for UI-only |
| **JS/TS** | Full programmatic power | Not portable; not LLM-safe |
### 6.4 Recommendation
```
Author (human/LLM) → YAML (default) or JSON (alternate)
↓
ADP compiler/validator (JSON Schema 2020-12)
↓
Normalised JSON manifest (runtime)
↓
UI renderer (catalog-constrained)
```
- **Primary authoring:** YAML — best for nested `templates` / `zones` trees
- **Alternate authoring:** JSON — for teams preferring Mintlify-style `$schema` tooling
- **Never at runtime:** YAML — always compile to JSON before serve
- **Composition:** `$yaml:` file includes (Backstage), not Helm Go templates
- **Power users:** Optional CUE/Jsonnet compiles to YAML before validation
**Verdict:** YAML remains the recommended default, but ADP MUST accept JSON as an equivalent authoring format with identical semantics.
---
## 7. ADP design principles
1. **Declarative, not executable** — manifests describe intent; runtimes render; no inline code or templating logic in core spec.
2. **Schema-first** — JSON Schema 2020-12 is canonical; YAML is authoring convenience.
3. **Structural strictness** — unknown fields rejected in strict mode (K8s CRD pattern).
4. **Catalog allowlist** — component `type` must exist in published catalog (A2UI pattern).
5. **Envelope consistency** — every document uses `apiVersion`, `kind`, `metadata`, `spec`.
6. **Separation of concerns** — theme, navigation, components, templates, routes are distinct spec sections.
7. **Compile, don't interpret** — YAML → validated JSON manifest → static assets at build time.
8. **Accessibility by default** — catalog entries include ARIA role hints (WAI-ARIA pattern).
9. **Versioned protocol** — `schemaVersion` integer + `apiVersion` string; breaking changes bump version.
10. **Extension namespace** — `x-adp-*` prefix reserved; plugins register catalog layers.
11. **File composition** — `$yaml:` includes for modular configs (Backstage pattern).
12. **Route-driven rendering** — URL resolution selects template; no hardcoded layout in renderer.
---
## 8. Proposed ADP document model
```yaml
schemaVersion: 1
apiVersion: adp.io/v1alpha1
kind: Application
metadata:
name: my-docs-site
version: "1.0.0"
labels: {}
spec:
site:
title: "My Site"
description: "..."
theme:
preset: zinc # or DTCG token ref
radius: md
darkMode: true
navigation:
mode: auto # auto | manual
tabs: [...]
components:
header_main:
type: Header
props: { logoText: "..." }
templates:
docs:
layout: ThreeColumnLayout
slots:
header: { ref: header_main }
main: { type: DocContent }
routes:
- match: "/docs/**"
template: docs
content:
docs:
dir: "./docs"
```
### Data flow
```
application.yaml → Parse → JSON Schema validate → Cross-ref rules (R1–R30)
→ Compile → ui-config.json + route-manifest.json + docs-nav.json
→ React / static renderer
```
---
## 9. PraisonAIUI alignment and gaps
### Already aligned
| PraisonAIUI today | ADP equivalent |
|-------------------|----------------|
| `schemaVersion: 1` | `schemaVersion` + `apiVersion` |
| `components` + `ref:` | `spec.components` + slot refs |
| `templates.layout` + `slots` | `spec.templates` |
| `routes` | `spec.routes` |
| `site.theme` | `spec.theme` |
| Compiler → JSON manifests | ADP compile pipeline |
| Pydantic validation | JSON Schema + cross-ref rules |
### Gaps to close
| Gap | ADP fix |
|-----|---------|
| No published JSON Schema | Publish `application.schema.json` |
| `navigation.tabs` compiled but not rendered | Mandate runtime consumption in spec |
| `route-manifest.json` not used for routing | Mandate route-driven template resolution |
| Dual slots + zones models | Unified: zones = ordered slot arrays |
| Closed hardcoded component types | Published `component-catalog.schema.json` |
| No envelope (`kind`, `metadata`) | Add Backstage-style envelope |
---
## 10. Migration path
Existing `aiui.template.yaml` files remain valid. Migration steps:
1. Add `apiVersion: adp.io/v1alpha1` and `kind: Application` (optional during transition).
2. Wrap top-level keys under `spec:` (compiler accepts both shapes during v1alpha1).
3. Rename `site` fields unchanged; map `navigation`, `templates`, `routes` as-is.
4. Publish catalog; register custom component types via plugin schema layers.
See [adp/aiui-mapping.md](./adp/aiui-mapping.md) for field-by-field mapping.
---
## 11. Open questions
1. **Should ADP standardise layout type names** (`ThreeColumnLayout`) or allow runtime-defined layouts?
2. **Should navigation support both filesystem-auto and manual tabs** in one spec, or require a mode switch?
3. **Should agent-driven surfaces (A2UI) be a separate `kind: Surface`** or embedded in Application?
4. **Should theme reference DTCG token files directly** or use simplified preset shorthand?
---
## 12. Appendix A — Standards index
| # | Standard | URL |
|---|----------|-----|
| 1 | A2UI | https://a2ui.org/ |
| 2 | AG-UI | https://docs.ag-ui.com/ |
| 3 | AWP | https://github.com/veegee82/agent-workflow-protocol |
| 4 | AsyncAPI | https://www.asyncapi.com/ |
| 5 | Backstage catalog | https://backstage.io/docs/features/software-catalog/descriptor-format |
| 6 | Bootstrap | https://getbootstrap.com/ |
| 7 | CloudEvents | https://cloudevents.io/ |
| 8 | CNAB | https://cnab.io/ |
| 9 | CrewAI | https://docs.crewai.com/ |
| 10 | CUE | https://cuelang.org/ |
| 11 | Docusaurus | https://docusaurus.io/ |
| 12 | DTCG Design Tokens | https://www.designtokens.org/ |
| 13 | Figma Code Connect | https://developers.figma.com/docs/code-connect/ |
| 14 | Framer | https://www.framer.com/ |
| 15 | Gatsby | https://www.gatsbyjs.com/ |
| 16 | Helm | https://helm.sh/ |
| 17 | Hugo | https://gohugo.io/ |
| 18 | Jekyll | https://jekyllrb.com/ |
| 19 | JSON Forms | https://jsonforms.io/ |
| 20 | JSON Schema | https://json-schema.org/ |
| 21 | Kubernetes CRD | https://kubernetes.io/docs/concepts/extend-kubernetes/api-extension/custom-resources/ |
| 22 | LangGraph | https://langchain-ai.github.io/langgraph/ |
| 23 | Material Design 3 | https://m3.material.io/ |
| 24 | MCP | https://modelcontextprotocol.io/ |
| 25 | Mintlify | https://mintlify.com/docs |
| 26 | OAM | https://oam.dev/ |
| 27 | OCI Artifact | https://opencontainers.org/ |
| 28 | OpenAPI | https://www.openapis.org/ |
| 29 | OpenUI | https://openuispec.org/ |
| 30 | Pkl | https://pkl-lang.org/ |
| 31 | PraisonAIUI | https://github.com/MervinPraison/PraisonAIUI |
| 32 | RJSF | https://rjsf-team.github.io/react-jsonschema-form/ |
| 33 | Storybook CSF | https://storybook.js.org/docs/api/csf |
| 34 | W3C ARIA APG | https://www.w3.org/WAI/ARIA/apg/ |
| 35 | Web Components | https://web.dev/articles/declarative-shadow-dom |
| 36 | Webflow | https://webflow.com/ |
| 37 | AutoGen | https://microsoft.github.io/autogen/ |
| 38 | A2A | https://google.github.io/A2A/ |
| 39 | Jsonnet | https://jsonnet.org/ |
| 40 | Docker Compose | https://docs.docker.com/compose/ |
| 41 | Qt QML | https://doc.qt.io/qt-6/qmlapplications.html |
| 42 | XAML | https://learn.microsoft.com/en-us/windows/apps/develop/platform/xaml/xaml-overview |
| 43 | Jetpack Compose | https://developer.android.com/develop/ui/compose |
| 44 | SwiftUI | https://developer.apple.com/xcode/swiftui/ |
| 45 | HTML + CSS | https://html.spec.whatwg.org/ |
| 46 | Flutter | https://flutter.dev/ |
---
## 13. Conclusion
ADP is feasible and largely prefigured by PraisonAIUI's existing architecture. The research across 46 standards yields a clear synthesis:
- **Envelope** from Backstage/K8s
- **Composition** from PraisonAIUI (components → templates → routes)
- **Catalog** from A2UI/OpenUI
- **Theming** from DTCG
- **Validation** from AWP (structural schema + R-rules)
- **Authoring** in YAML (default) with JSON alternate (Mintlify pattern)
**Status:** Research draft — see [adp/implementation-status.md](./adp/implementation-status.md) for implementation gaps.
The draft spec, JSON Schema files, and example manifests are published alongside this report in [`docs/protocols/adp/`](./adp/).
+1 -1
View File
@@ -91,4 +91,4 @@ aiui.template.yaml
## Writing Tests
See the `tests/README.md` file in the repository for conventions, examples, and templates.
See [tests/README.md](../tests/README.md) for conventions, examples, and templates.
-44
View File
@@ -1,44 +0,0 @@
"""mkdocs hook: patch pymdownx.highlight for pygments >= 2.20 compatibility.
Pygments 2.20 removed tolerance for ``filename=None`` in HtmlFormatter,
which pymdownx.highlight can pass when a code fence has no ``title``.
Without this patch, ANY untitled ```python``` block triggers::
AttributeError: 'NoneType' object has no attribute 'replace'
Reference: https://github.com/facelessuser/pymdown-extensions/issues/XXXX
This is a drop-in fix: normalize ``filename`` to an empty string.
"""
from __future__ import annotations
def _patch_pymdownx_highlight() -> None:
try:
from pymdownx import highlight as _hi # type: ignore
except Exception:
return
if getattr(_hi, "_aiui_patched", False):
return
_orig_init = _hi.BlockHtmlFormatter.__init__
def patched_init(self, **options): # type: ignore
if options.get("filename") is None:
options["filename"] = ""
_orig_init(self, **options)
_hi.BlockHtmlFormatter.__init__ = patched_init
_hi._aiui_patched = True
# Apply immediately when this module is loaded
_patch_pymdownx_highlight()
# mkdocs hook interface: provide at least one expected hook so mkdocs loads us.
def on_config(config, **kwargs):
_patch_pymdownx_highlight()
return config
+23 -45
View File
@@ -2,12 +2,6 @@
> 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 |
@@ -21,32 +15,16 @@ See also: [`docs/examples/basic.md`](../docs/examples/basic.md) and [`docs/examp
| 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()` | `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 |
| 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` |
| 13 | [real-dashboard](python/13-real-dashboard/) | Real PraisonAI Agent + native dashboard + live metrics | `aiui run app.py --style dashboard` |
| 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` |
| 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` |
---
@@ -55,22 +33,22 @@ See also: [`docs/examples/basic.md`](../docs/examples/basic.md) and [`docs/examp
| # | 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 | `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` |
| 02 | [clean-chat](yaml/02-clean-chat/) | `dashboard.sidebar: false` — dashboard chat without left nav (YAML parity) | `aiui run clean-chat.yaml` |
---
## YAML Track — Site Templates
| # | 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` |
| # | 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 |
@@ -9,8 +9,8 @@ site:
darkMode: false
radius: lg
# Fullscreen chat layout
style: chat
# Use chainlit-style fullscreen chat layout
style: chainlit
# Chat configuration
chat:
+12 -12
View File
@@ -40,10 +40,8 @@ def _get_client():
_client = AsyncOpenAI(api_key=api_key)
return _client
# Use SessionGovernor for bounded session memory management
from praisonaiui.session_governor import SessionGovernor
_governor = SessionGovernor(max_turns=20, max_sessions=100)
# Keep per-session context in memory (DataStore handles persistence)
_contexts: dict[str, list[dict]] = {}
@aiui.starters
@@ -68,18 +66,20 @@ 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"
# Add user message to bounded session context
_governor.add_message(session_id, {"role": "user", "content": str(message)})
# 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)})
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=messages,
messages=_contexts[session_id],
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 bounded context
_governor.add_message(session_id, {"role": "assistant", "content": full_response})
# Save assistant response to context
_contexts[session_id].append({"role": "assistant", "content": full_response})
# Feedback buttons
await aiui.action_buttons([
@@ -15,20 +15,8 @@ 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
+31 -66
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,11 +65,13 @@ class FullFeaturedProvider(BaseProvider):
def calculate(expression: str) -> str:
"""Evaluate a math expression safely."""
from praisonaiui.math_eval import eval_math_expression
try:
result = eval_math_expression(expression)
return f"Result: {result}"
# 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"
except Exception as e:
return f"Error: {e}"
@@ -87,8 +89,7 @@ class FullFeaturedProvider(BaseProvider):
yield RunEvent(type=RunEventType.REASONING_STARTED)
yield RunEvent(type=RunEventType.REASONING_STEP, step="Analyzing the request...")
# Get the running event loop for thread-safe queue operations
loop = asyncio.get_running_loop()
# Set up streaming bridge
event_queue = asyncio.Queue()
_has_streaming = False
@@ -98,32 +99,27 @@ class FullFeaturedProvider(BaseProvider):
def _on_stream(evt):
if evt.type == SET.DELTA_TEXT and evt.content:
if evt.is_reasoning:
run_event = RunEvent(
event_queue.put_nowait(RunEvent(
type=RunEventType.REASONING_STEP,
step=evt.content,
)
# Use thread-safe queue operation
loop.call_soon_threadsafe(event_queue.put_nowait, run_event)
))
else:
run_event = RunEvent(
event_queue.put_nowait(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:
run_event = RunEvent(
event_queue.put_nowait(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:
run_event = RunEvent(
event_queue.put_nowait(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()
@@ -133,57 +129,26 @@ class FullFeaturedProvider(BaseProvider):
yield RunEvent(type=RunEventType.REASONING_COMPLETED)
# 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:
# 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:
try:
agent.stream_emitter.remove_callback(_on_stream)
except Exception:
pass
# 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
# Drain streamed events
while not event_queue.empty():
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
yield event_queue.get_nowait()
except asyncio.QueueEmpty:
break
# If no tokens were streamed, emit the full response
if event_queue.empty() and not _has_streaming:
+27 -17
View File
@@ -10,17 +10,14 @@ Run:
aiui run app.py
"""
import asyncio
import os
import sys
import asyncio
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"
@@ -73,22 +70,36 @@ async def on_message(message: str):
await aiui.think("Thinking...")
# Set up thread-safe streaming bridge
bridge = StreamBridge()
# Set up a streaming callback that forwards tokens to PraisonAIUI
token_queue = asyncio.Queue()
# Register callback using the bridge
callback = bridge.emitter_callback()
agent.stream_emitter.add_callback(callback)
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)
agent.stream_emitter.enable_metrics()
try:
# Run agent.chat concurrently with token consumption
chat_task = asyncio.create_task(
asyncio.to_thread(lambda: agent.chat(str(message), stream=True))
# 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)
)
# Consume tokens as they arrive and stream to UI
async for token in bridge.consume():
# 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
await aiui.stream_token(token)
# Wait for the chat to fully complete
@@ -96,8 +107,7 @@ async def on_message(message: str):
finally:
# Clean up callback
agent.stream_emitter.remove_callback(callback)
bridge.cancel()
agent.stream_emitter.remove_callback(on_stream_event)
@aiui.cancel
+4 -8
View File
@@ -11,21 +11,18 @@ Demonstrates:
Run:
pip install praisonaiui
aiui run app.py
python app.py
# Dashboard at http://localhost:8082
# Set HOST=0.0.0.0 for Docker/LAN demos (defaults to 127.0.0.1)
"""
import os
import sys
import uvicorn
import os
from praisonaiui.server import create_app
import uvicorn
# Use shared seed data helper
sys.path.insert(0, os.path.join(os.path.dirname(__file__), ".."))
from _shared.console import safe_print as print
from _shared.seed_data import seed_demo_data
seed_demo_data()
@@ -34,5 +31,4 @@ if __name__ == "__main__":
app = create_app()
print("✅ Full Dashboard at http://localhost:8082")
print(" API: http://localhost:8082/api/features")
host = os.getenv("HOST", "127.0.0.1")
uvicorn.run(app, host=host, port=8082, log_level="info")
uvicorn.run(app, host="0.0.0.0", port=8082, log_level="info")
+1 -3
View File
@@ -20,7 +20,6 @@ import sys
# Use shared seed data helper
sys.path.insert(0, os.path.join(os.path.dirname(__file__), ".."))
from _shared.console import safe_print as print
from _shared.seed_data import seed_demo_data
@@ -172,8 +171,7 @@ def run_standalone():
aiui_app.routes.insert(0, Route("/", landing, methods=["GET"]))
print("✅ UI Integration Demo at http://localhost:8082")
host = os.getenv("HOST", "127.0.0.1")
uvicorn.run(aiui_app, host=host, port=8082, log_level="info")
uvicorn.run(aiui_app, host="0.0.0.0", port=8082, log_level="info")
if __name__ == "__main__":
+493
View File
@@ -0,0 +1,493 @@
"""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")
Loaded 100 of 708 files, more files were not shown because too many files have changed in this diff. Show more